Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Changelog

All notable changes to this project will be documented in this file.

## [1.1.0] - 2026-03-22

### Added
- **CLI entry point**: `npx aegis-bridge` — zero-config quick start with auto-detection
- **Permission prompt DX**: Actionable hints in health check response (`actionHints` field)
- **`aegis-bridge create` subcommand**: Create sessions from CLI
- **Stall detection**: Per-session configurable stall threshold (default: 5 min)
- **Session health endpoint**: `GET /v1/sessions/:id/health` with window/pane status
- **Prompt delivery verification**: Retry logic via `capture-pane` confirmation
- **StopFailure hook support**: Detect CC errors via hook integration
- **Filesystem discovery fallback**: Session ID discovery when hooks are unavailable
- **Bare flag detection**: Handle `claude --bare` which skips hooks
- **Session state archive**: Auto-archive stale JSONL session files on spawn

### Fixed
- **Stale session reuse**: Timestamp + mtime guards reject old claudeSessionId (#6)
- **Tmux window creation**: Retry logic (3x) for prolonged uptime (#7)
- **Session spawn failure**: Health check between retries (#7)

## [1.0.0] - 2026-03-21

### Added
- Initial release
- HTTP API for Claude Code session management via tmux
- Session CRUD: create, read, send, approve, reject, interrupt, kill
- JSONL transcript parsing
- Terminal state detection (working, idle, permission_prompt, stalled)
- Telegram channel for event notifications
- Webhook channel for event notifications
- Configuration via `~/.aegis/config.json`
- Migration support from `~/.manus/config.json`
96 changes: 96 additions & 0 deletions src/__tests__/cli-create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* cli-create.test.ts — Tests for Issue #5 stretch: aegis-bridge create subcommand.
*/

import { describe, it, expect } from 'vitest';

describe('aegis-bridge create subcommand', () => {
describe('argument parsing', () => {
it('should extract brief from first non-flag argument', () => {
const args = ['Build a login page'];
let brief = '';
for (const arg of args) {
if (!arg.startsWith('-')) { brief = arg; break; }
}
expect(brief).toBe('Build a login page');
});

it('should extract --cwd option', () => {
const args = ['Build something', '--cwd', '/path/to/project'];
let cwd = process.cwd();
for (let i = 0; i < args.length; i++) {
if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; }
}
expect(cwd).toBe('/path/to/project');
});

it('should default cwd to process.cwd()', () => {
const args = ['Build something'];
let cwd = '/default/path';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; }
}
expect(cwd).toBe('/default/path');
});

it('should extract --port option', () => {
const args = ['Build something', '--port', '3000'];
let port = 9100;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' && args[i + 1]) { port = parseInt(args[++i], 10); }
}
expect(port).toBe(3000);
});

it('should reject empty brief', () => {
const args: string[] = [];
let brief = '';
for (const arg of args) {
if (!arg.startsWith('-')) { brief = arg; break; }
}
expect(brief).toBe('');
});
});

describe('session name generation', () => {
it('should generate a clean session name from brief', () => {
const brief = 'Build a login page with OAuth';
const name = `cc-${brief.slice(0, 20).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()}`;
expect(name).toBe('cc-build-a-login-page-w');
expect(name.startsWith('cc-')).toBe(true);
});

it('should handle special characters in brief', () => {
const brief = 'Fix bug #123!';
const name = `cc-${brief.slice(0, 20).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()}`;
expect(name).toBe('cc-fix-bug--123-');
});

it('should truncate long briefs', () => {
const brief = 'This is a very long brief that exceeds twenty characters';
const name = `cc-${brief.slice(0, 20).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()}`;
expect(name.length).toBeLessThanOrEqual(23); // 'cc-' + 20 chars
});
});

describe('URL construction', () => {
it('should construct correct session create URL', () => {
const port = 9100;
const url = `http://127.0.0.1:${port}/v1/sessions`;
expect(url).toBe('http://127.0.0.1:9100/v1/sessions');
});

it('should construct correct send URL with session ID', () => {
const port = 9100;
const sessionId = 'abc-123';
const url = `http://127.0.0.1:${port}/v1/sessions/${sessionId}/send`;
expect(url).toBe('http://127.0.0.1:9100/v1/sessions/abc-123/send');
});

it('should use custom port', () => {
const port = 3000;
const url = `http://127.0.0.1:${port}/v1/sessions`;
expect(url).toBe('http://127.0.0.1:3000/v1/sessions');
});
});
});
100 changes: 100 additions & 0 deletions src/__tests__/permission-hints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* permission-hints.test.ts — Tests for Issue #20: actionHints in API responses.
*/

import { describe, it, expect } from 'vitest';

describe('Permission prompt action hints', () => {
describe('actionHints generation', () => {
it('should include actionHints for permission_prompt status', () => {
const status = 'permission_prompt';
const sessionId = 'test-session-123';
const needsHints = status === 'permission_prompt' || status === 'bash_approval';

expect(needsHints).toBe(true);

const hints = {
approve: { method: 'POST', url: `/v1/sessions/${sessionId}/approve`, description: 'Approve the pending permission' },
reject: { method: 'POST', url: `/v1/sessions/${sessionId}/reject`, description: 'Reject the pending permission' },
};

expect(hints.approve.method).toBe('POST');
expect(hints.approve.url).toContain(sessionId);
expect(hints.approve.url).toContain('/approve');
expect(hints.reject.url).toContain('/reject');
});

it('should include actionHints for bash_approval status', () => {
const status: string = 'bash_approval';
const needsHints = status === 'permission_prompt' || status === 'bash_approval';
expect(needsHints).toBe(true);
});

it('should NOT include actionHints for idle status', () => {
const status: string = 'idle';
const needsHints = status === 'permission_prompt' || status === 'bash_approval';
expect(needsHints).toBe(false);
});

it('should NOT include actionHints for working status', () => {
const status: string = 'working';
const needsHints = status === 'permission_prompt' || status === 'bash_approval';
expect(needsHints).toBe(false);
});
});

describe('health endpoint details message', () => {
it('should include approve/reject URLs in details for permission_prompt', () => {
const sessionId = 'abc-123';
const status = 'permission_prompt';
let details = '';

if (status === 'permission_prompt' || status === 'bash_approval') {
details = `Claude is waiting for permission approval. POST /v1/sessions/${sessionId}/approve to approve, or /v1/sessions/${sessionId}/reject to reject.`;
}

expect(details).toContain('/approve');
expect(details).toContain('/reject');
expect(details).toContain(sessionId);
expect(details).toContain('POST');
});
});

describe('addActionHints helper', () => {
it('should add actionHints to session with permission_prompt', () => {
const session = {
id: 'test-id',
status: 'permission_prompt' as const,
windowId: '@1',
windowName: 'test',
};

const result: Record<string, unknown> = { ...session };
if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
result.actionHints = {
approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
};
}

expect(result.actionHints).toBeDefined();
expect((result.actionHints as any).approve.url).toBe('/v1/sessions/test-id/approve');
});

it('should NOT add actionHints to session with idle status', () => {
const session = {
id: 'test-id',
status: 'idle' as string,
windowId: '@1',
windowName: 'test',
};

const result: Record<string, unknown> = { ...session };
if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
result.actionHints = {};
}

expect(result.actionHints).toBeUndefined();
});
});
});
91 changes: 91 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,86 @@ function printBanner(port: number): void {
`);
}

/** Issue #5 stretch: create a session from CLI. */
async function handleCreate(args: string[]): Promise<void> {
// Parse brief text (first non-flag argument)
let brief = '';
let cwd = process.cwd();
let port = parseInt(process.env.AEGIS_PORT || '9100', 10);

for (let i = 0; i < args.length; i++) {
if (args[i] === '--cwd' && args[i + 1]) {
cwd = args[++i];
} else if (args[i] === '--port' && args[i + 1]) {
port = parseInt(args[++i], 10);
} else if (!args[i].startsWith('-')) {
brief = args[i];
}
}

if (!brief) {
console.error(' ❌ Missing brief. Usage: aegis-bridge create "Build a login page"');
process.exit(1);
}

const baseUrl = `http://127.0.0.1:${port}`;
const sessionName = `cc-${brief.slice(0, 20).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()}`;

// Create session
let sessionId: string;
try {
const res = await fetch(`${baseUrl}/v1/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: cwd, name: sessionName }),
});

if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
console.error(` ❌ Failed to create session: ${(err as any).error || res.statusText}`);
process.exit(1);
}

const session = await res.json() as { id: string; windowName: string };
sessionId = session.id;
console.log(` ✅ Session created: ${session.windowName}`);
console.log(` ID: ${sessionId}`);
} catch (e: any) {
if (e.cause?.code === 'ECONNREFUSED') {
console.error(` ❌ Cannot connect to Aegis on port ${port}.`);
console.error(` Start the server first: aegis-bridge`);
} else {
console.error(` ❌ ${e.message}`);
}
process.exit(1);
}

// Send brief
try {
const res = await fetch(`${baseUrl}/v1/sessions/${sessionId}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: brief }),
});

const result = await res.json() as { delivered?: boolean; attempts?: number };
if (result.delivered) {
console.log(` ✅ Brief delivered (attempt ${result.attempts})`);
} else {
console.log(` ⚠️ Brief sent but delivery not confirmed after ${result.attempts} attempts`);
}
} catch (e: any) {
console.error(` ⚠️ Failed to send brief: ${e.message}`);
}

// Print next steps
console.log('');
console.log(' Next steps:');
console.log(` Status: curl ${baseUrl}/v1/sessions/${sessionId}/health`);
console.log(` Read: curl ${baseUrl}/v1/sessions/${sessionId}/read`);
console.log(` Kill: curl -X DELETE ${baseUrl}/v1/sessions/${sessionId}`);
}

async function main(): Promise<void> {
const args = process.argv.slice(2);

Expand All @@ -39,8 +119,13 @@ async function main(): Promise<void> {
Usage:
aegis-bridge Start the server (port 9100)
aegis-bridge --port 3000 Custom port
aegis-bridge create "brief" Create a session and send brief
aegis-bridge --help Show this help

Create:
aegis-bridge create "Build a login page" --cwd /path/to/project
aegis-bridge create "Fix the tests" (uses current directory)

Environment variables:
AEGIS_PORT Server port (default: 9100)
AEGIS_HOST Server host (default: 127.0.0.1)
Expand Down Expand Up @@ -72,6 +157,12 @@ async function main(): Promise<void> {
process.exit(0);
}

// Subcommand: create
if (args[0] === 'create') {
await handleCreate(args.slice(1));
process.exit(0);
}

// Port override from CLI
const portIdx = args.indexOf('--port');
if (portIdx !== -1 && args[portIdx + 1]) {
Expand Down
18 changes: 15 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,16 @@ app.post<{
return reply.status(201).send(session);
});

// Get session
// Get session (Issue #20: includes actionHints for interactive states)
app.get<{ Params: { id: string } }>('/v1/sessions/:id', async (req, reply) => {
const session = sessions.getSession(req.params.id);
if (!session) return reply.status(404).send({ error: 'Session not found' });
return session;
return addActionHints(session);
});
app.get<{ Params: { id: string } }>('/sessions/:id', async (req, reply) => {
const session = sessions.getSession(req.params.id);
if (!session) return reply.status(404).send({ error: 'Session not found' });
return session;
return addActionHints(session);
});

// Session health check (Issue #2)
Expand Down Expand Up @@ -436,6 +436,18 @@ async function reapStaleSessions(maxAgeMs: number): Promise<void> {

// ── Helpers ──────────────────────────────────────────────────────────

/** Issue #20: Add actionHints to session response for interactive states. */
function addActionHints(session: import('./session.js').SessionInfo): Record<string, unknown> {
const result: Record<string, unknown> = { ...session };
if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
result.actionHints = {
approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
};
}
return result;
}

function makePayload(event: 'session.ended', sessionId: string, detail: string): SessionEventPayload {
const session = sessions.getSession(sessionId);
return {
Expand Down
Loading
Loading