diff --git a/apps/relay/src/db/migrations/0006_session_parent_id.sql b/apps/relay/src/db/migrations/0006_session_parent_id.sql new file mode 100644 index 0000000..aabfcd3 --- /dev/null +++ b/apps/relay/src/db/migrations/0006_session_parent_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE "sessions" ADD COLUMN "parent_session_id" uuid;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_parent_session_id_sessions_id_fk" FOREIGN KEY ("parent_session_id") REFERENCES "public"."sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sessions_parent_session_id_idx" ON "sessions" USING btree ("parent_session_id"); \ No newline at end of file diff --git a/apps/relay/src/db/migrations/meta/0006_snapshot.json b/apps/relay/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..6e6bd63 --- /dev/null +++ b/apps/relay/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,604 @@ +{ + "id": "82ddf852-048b-454f-a2fd-533d6bc13934", + "prevId": "7b57513f-d4bf-41a4-b910-21022c78c5bb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_hash_unique": { + "name": "auth_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_token_hash": { + "name": "device_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_id_idx": { + "name": "devices_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "access_token_cipher": { + "name": "access_token_cipher", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_nonce": { + "name": "access_token_nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscriptions_user_id_idx": { + "name": "push_subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starting'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'launched'" + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_id_idx": { + "name": "sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_parent_session_id_idx": { + "name": "sessions_parent_session_id_idx", + "columns": [ + { + "expression": "parent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_device_id_devices_id_fk": { + "name": "sessions_device_id_devices_id_fk", + "tableFrom": "sessions", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_parent_session_id_sessions_id_fk": { + "name": "sessions_parent_session_id_sessions_id_fk", + "tableFrom": "sessions", + "tableTo": "sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_provider_identity_unique": { + "name": "users_provider_identity_unique", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/relay/src/db/migrations/meta/_journal.json b/apps/relay/src/db/migrations/meta/_journal.json index 2eda104..0b3caa8 100644 --- a/apps/relay/src/db/migrations/meta/_journal.json +++ b/apps/relay/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782840201170, "tag": "0005_session_origin", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1783042491278, + "tag": "0006_session_parent_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/relay/src/db/schema.ts b/apps/relay/src/db/schema.ts index 33b23cb..5ed56d9 100644 --- a/apps/relay/src/db/schema.ts +++ b/apps/relay/src/db/schema.ts @@ -1,5 +1,13 @@ import { SESSION_ORIGINS, SESSION_STATUSES } from '@telecode/protocol'; -import { index, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core'; +import { + type AnyPgColumn, + index, + pgTable, + text, + timestamp, + unique, + uuid, +} from 'drizzle-orm/pg-core'; /** * The relay's persisted registries (the control plane owns DB access — see SUPABASE.md). Drizzle owns @@ -76,6 +84,18 @@ export const sessions = pgTable( * to `launched` so every pre-existing row and every browser-initiated launch is unchanged. */ origin: text('origin', { enum: SESSION_ORIGINS }).default('launched').notNull(), + /** + * The adopted session this one continues, for a free-form handover (Journey 4). When the user takes + * over an adopted session's free-form question, telecode launches a forked continuation whose + * `parent_session_id` points back at the adopted (`external`) row — recording the adopted → launched + * migration so the dashboard can link them. Null for every ordinary (unchained) session. Self-FK; a + * self-reference needs the {@link AnyPgColumn} return annotation on the thunk. `set null` on delete so a + * removed parent never orphan-deletes its telecode-owned continuation. Added in `0006_session_parent_id`; + * to reverse: drop the `sessions_parent_session_id_idx` index, the self-FK, then the column. + */ + parentSessionId: uuid('parent_session_id').references((): AnyPgColumn => sessions.id, { + onDelete: 'set null', + }), /** Working directory the session runs in (single cwd in Phase 1; worktrees in Phase 2). */ cwd: text('cwd'), permissionMode: text('permission_mode'), @@ -86,6 +106,7 @@ export const sessions = pgTable( (t) => ({ userIdx: index('sessions_user_id_idx').on(t.userId), deviceIdx: index('sessions_device_id_idx').on(t.deviceId), + parentIdx: index('sessions_parent_session_id_idx').on(t.parentSessionId), }), ); diff --git a/apps/relay/src/registry/session-registry.ts b/apps/relay/src/registry/session-registry.ts index b5e26fc..40de64d 100644 --- a/apps/relay/src/registry/session-registry.ts +++ b/apps/relay/src/registry/session-registry.ts @@ -16,6 +16,11 @@ export interface SessionSummary { readonly status: SessionStatusName; /** `launched` (started from telecode) or `external` (a user's own Claude Code session telecode adopted). */ readonly origin: SessionOrigin; + /** + * The adopted session this one continues (free-form handover, Journey 4), or `null` for an unchained + * session. Set on a forked continuation so the dashboard can link parent ↔ child. + */ + readonly parentSessionId: string | null; readonly createdAt: Date; readonly updatedAt: Date; readonly endedAt: Date | null; @@ -39,6 +44,8 @@ export interface SessionRegistry { origin?: SessionOrigin; title?: string; cwd?: string; + /** Link to the adopted session this one continues (free-form handover, Journey 4). */ + parentSessionId?: string; }): Promise; /** List the user's sessions, newest first (RLS-scoped). Powers the dashboard list + reconnect. */ listByUser(userId: string): Promise; @@ -66,7 +73,14 @@ export function createSessionRegistry(db: DbHandle): SessionRegistry { } return { - async createSession({ userId, deviceId, origin, title, cwd }): Promise { + async createSession({ + userId, + deviceId, + origin, + title, + cwd, + parentSessionId, + }): Promise { const sessionOrigin: SessionOrigin = origin ?? 'launched'; // An adopted session is already running on the user's machine; a launched one is just starting. const status: SessionStatusName = sessionOrigin === 'external' ? 'running' : 'starting'; @@ -80,6 +94,7 @@ export function createSessionRegistry(db: DbHandle): SessionRegistry { status, ...(title !== undefined ? { title } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(parentSessionId !== undefined ? { parentSessionId } : {}), }) .returning({ id: sessions.id }); if (!row) { @@ -98,6 +113,7 @@ export function createSessionRegistry(db: DbHandle): SessionRegistry { title: sessions.title, status: sessions.status, origin: sessions.origin, + parentSessionId: sessions.parentSessionId, createdAt: sessions.createdAt, updatedAt: sessions.updatedAt, endedAt: sessions.endedAt, diff --git a/apps/relay/src/registry/session-routes.ts b/apps/relay/src/registry/session-routes.ts index f41f917..339f8f9 100644 --- a/apps/relay/src/registry/session-routes.ts +++ b/apps/relay/src/registry/session-routes.ts @@ -27,6 +27,7 @@ export function registerSessionListRoute( title: session.title, status: session.status, origin: session.origin, + parent_session_id: session.parentSessionId, created_at: session.createdAt.toISOString(), updated_at: session.updatedAt.toISOString(), ended_at: session.endedAt?.toISOString() ?? null, diff --git a/apps/relay/src/relay.ts b/apps/relay/src/relay.ts index a5e1249..c09bad7 100644 --- a/apps/relay/src/relay.ts +++ b/apps/relay/src/relay.ts @@ -8,6 +8,7 @@ import { makeEnvelope, parseEnvelope, sessionAdoptedPayloadSchema, + sessionChainedPayloadSchema, sessionEndedPayloadSchema, type Envelope, } from '@telecode/protocol'; @@ -146,6 +147,9 @@ const CACHEABLE_TYPES = new Set([ 'agent.tool_use', 'agent.permission_request', 'agent.question', + // A free-form handover offer (Journey 4) is a standing, actionable offer — cache it so a browser that + // reopens before answering still sees the "continue here" card (unlike the transient `agent.notice`). + 'agent.handover', 'session.ended', 'session.key', ]); @@ -444,6 +448,68 @@ export async function buildRelay(options: RelayOptions = {}): Promise { + let app: FastifyInstance; + let handle: DbHandle; + let admin: Pool; + let relayUrl: string; + let userId: string; + let deviceId: string; + let registry: ReturnType; + const relayLogs: string[] = []; + + beforeAll(async () => { + if (!DATABASE_URL) { + throw new Error('DATABASE_URL is not set — start the DB (supabase start) and load .env'); + } + await runMigrations(DATABASE_URL); + handle = createDb(DATABASE_URL); + admin = new Pool({ connectionString: DATABASE_URL }); + + await admin.query('truncate table users restart identity cascade'); + const userRow = await admin.query<{ id: string }>( + "insert into users (provider, provider_user_id) values ('dev', 'chained') returning id", + ); + userId = userRow.rows[0]!.id; + const deviceRow = await admin.query<{ id: string }>( + "insert into devices (user_id, name, device_token_hash) values ($1, 'laptop', 'h') returning id", + [userId], + ); + deviceId = deviceRow.rows[0]!.id; + + registry = createSessionRegistry(handle); + const relayLogger = pino( + { level: 'info' }, + { write: (chunk: string) => relayLogs.push(chunk) }, + ); + app = await buildRelay({ logger: relayLogger, sessionRegistry: registry }); + await app.listen({ port: 0, host: '127.0.0.1' }); + relayUrl = `ws://127.0.0.1:${(app.server.address() as AddressInfo).port}/ws`; + }); + + afterAll(async () => { + await app?.close(); + await handle?.close(); + await admin?.end(); + }); + + beforeEach(async () => { + await admin.query('truncate table sessions cascade'); + }); + + it('mints a launched row linked to the parent, acks the daemon, and broadcasts to the browser', async () => { + // The adopted parent the continuation resumes from. + const parentId = await registry.createSession({ + userId, + deviceId, + origin: 'external', + title: 'adopted', + }); + + const daemon = await connectDaemon(relayUrl, userId, deviceId); + const browser = await connectBrowser(relayUrl, userId, deviceId); + + const onBrowser = waitForEnvelope(browser, (e) => e.type === 'session.chained'); + const onDaemonAck = waitForEnvelope( + daemon, + (e) => e.type === 'session.chained' && e.session_id !== undefined, + ); + + daemon.send( + JSON.stringify( + makeEnvelope({ + type: 'session.chained', + userId, + deviceId, + payload: { + clientRef: 'fork-abc', + parentSessionId: parentId, + title: 'Continue: which database?', + cwd: '/Users/me/repo', + }, + }), + ), + ); + + const browserFrame = await onBrowser; + const daemonAck = await onDaemonAck; + + const childId = browserFrame.session_id; + expect(childId).toMatch(/^[0-9a-f-]{36}$/); + expect(childId).not.toBe(parentId); + // The daemon's ack carries the SAME minted id + its own clientRef, so it can drive the child's turns. + expect(daemonAck.session_id).toBe(childId); + expect((daemonAck.payload as { clientRef: string }).clientRef).toBe('fork-abc'); + expect((browserFrame.payload as { parentSessionId: string }).parentSessionId).toBe(parentId); + + // The row is persisted as a launched continuation linked to the adopted parent. + const row = await admin.query<{ + origin: string; + parent_session_id: string; + title: string; + cwd: string; + }>('select origin, parent_session_id, title, cwd from sessions where id = $1', [childId]); + expect(row.rows[0]).toMatchObject({ + origin: 'launched', + parent_session_id: parentId, + title: 'Continue: which database?', + cwd: '/Users/me/repo', + }); + + expect(relayLogs.some((l) => l.includes(childId!) && l.includes('session chained'))).toBe(true); + + daemon.close(); + browser.close(); + }); + + it('drops a session.chained with an invalid payload (no parentSessionId)', async () => { + const daemon = await connectDaemon(relayUrl, userId, deviceId); + const before = relayLogs.length; + daemon.send( + JSON.stringify( + makeEnvelope({ type: 'session.chained', userId, deviceId, payload: { clientRef: 'x' } }), + ), + ); + await vi.waitUntil(() => relayLogs.slice(before).some((l) => l.includes('invalid payload')), { + timeout: 2000, + }); + const count = await admin.query<{ n: string }>('select count(*)::text as n from sessions'); + expect(count.rows[0]!.n).toBe('0'); + daemon.close(); + }); + + it('surfaces parent_session_id on GET /me/sessions so the dashboard can link parent ↔ child', async () => { + const parentId = await registry.createSession({ userId, deviceId, origin: 'external' }); + const childId = await registry.createSession({ + userId, + deviceId, + origin: 'launched', + parentSessionId: parentId, + }); + const list = await registry.listByUser(userId); + const child = list.find((s) => s.id === childId); + const parent = list.find((s) => s.id === parentId); + expect(child?.parentSessionId).toBe(parentId); + expect(parent?.parentSessionId).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/components/HandoverCard.svelte b/apps/web/src/lib/components/HandoverCard.svelte new file mode 100644 index 0000000..8f39b1b --- /dev/null +++ b/apps/web/src/lib/components/HandoverCard.svelte @@ -0,0 +1,276 @@ + + +
+
+ {isPending ? 'AWAITING INPUT' : 'HANDOVER'} + + continue here + +
+ +

{entry.question}

+ {#if entry.summary} +

{entry.summary}

+ {/if} + + {#if isPending && offline} +

+ This device is offline, so telecode can’t take over yet. Answer at your device, or wait for it to + reconnect to continue here. +

+ {:else if isPending} +
+ + {#if error} + + {/if} +
+ + + Starts a new telecode-launched session that resumes this conversation with your answer. + +
+
+ {:else if isSubmitting} +

+ + Taking over… +

+ {:else if entry.state === 'submitted'} +
+ {#if entry.answerText} +

YOUR ANSWER {entry.answerText}

+ {/if} +

Taken over · continued in a new session

+ {#if entry.childSessionId} + View the continuation → + {/if} +
+ {:else} + +

Handover closed — the session ended before you took it over.

+ {/if} +
+ + diff --git a/apps/web/src/lib/components/Transcript.svelte b/apps/web/src/lib/components/Transcript.svelte index d970621..67ceb5e 100644 --- a/apps/web/src/lib/components/Transcript.svelte +++ b/apps/web/src/lib/components/Transcript.svelte @@ -3,6 +3,7 @@ import type { TranscriptEntry } from '$lib/session'; + import HandoverCard from './HandoverCard.svelte'; import MessageBody from './MessageBody.svelte'; import PermissionGate from './PermissionGate.svelte'; import QuestionGate from './QuestionGate.svelte'; @@ -16,14 +17,19 @@ */ let { entries, + offline = false, onapprove, onreject, onanswer, + onhandover, }: { entries: readonly TranscriptEntry[]; + /** The device is offline — degrades a pending free-form handover to its "answer at your device" state. */ + offline?: boolean; onapprove: (requestId: string) => void; onreject: (requestId: string) => void; onanswer: (requestId: string, answers: QuestionAnswerItem[]) => void; + onhandover: (requestId: string, answerText: string) => void; } = $props(); let listEl = $state(); @@ -70,8 +76,14 @@ onapprove={() => onapprove(entry.requestId)} onreject={() => onreject(entry.requestId)} /> - {:else} + {:else if entry.kind === 'question'} onanswer(entry.requestId, answers)} /> + {:else} + onhandover(entry.requestId, answerText)} + /> {/if} {/each} diff --git a/apps/web/src/lib/relay-client.ts b/apps/web/src/lib/relay-client.ts index 226eb23..4fcde48 100644 --- a/apps/web/src/lib/relay-client.ts +++ b/apps/web/src/lib/relay-client.ts @@ -6,6 +6,7 @@ import { type AdoptStatePayload, type Envelope, type MessageType, + type HandoverAnswerPayload, type PermissionDecisionPayload, type QuestionAnswerPayload, type SessionControlAction, @@ -70,6 +71,11 @@ export interface RelayConnection { decide(sessionId: string, decision: PermissionDecisionPayload): void; /** Send the human's answer to a pending `agent.question` on `sessionId` (adopted-session questions). */ answer(sessionId: string, payload: QuestionAnswerPayload): void; + /** + * Take over a pending `agent.handover` on `sessionId` (Journey 4): the daemon forks-and-resumes the + * adopted conversation with this answer, migrating it to a telecode-owned continuation. + */ + answerHandover(sessionId: string, payload: HandoverAnswerPayload): void; /** Send an operator control (interrupt / end) for `sessionId`. */ control(sessionId: string, action: SessionControlAction): void; /** @@ -265,6 +271,9 @@ export function createRelayConnection(options: RelayConnectionOptions): RelayCon answer(sessionId: string, payload: QuestionAnswerPayload): void { enqueueSend(() => sessionFrame('question.answer', sessionId, payload)); }, + answerHandover(sessionId: string, payload: HandoverAnswerPayload): void { + enqueueSend(() => sessionFrame('handover.answer', sessionId, payload)); + }, control(sessionId: string, action: SessionControlAction): void { enqueueSend(() => sessionFrame('session.control', sessionId, { action })); }, diff --git a/apps/web/src/lib/server/relay-api.ts b/apps/web/src/lib/server/relay-api.ts index b45603b..d2eeff3 100644 --- a/apps/web/src/lib/server/relay-api.ts +++ b/apps/web/src/lib/server/relay-api.ts @@ -44,6 +44,8 @@ export interface RelaySession { status: SessionStatusName; /** `launched` (started from telecode) or `external` (a session telecode adopted from the user's machine). */ origin: SessionOrigin; + /** The adopted session this one continues (free-form handover, Journey 4), or null when unchained. */ + parentSessionId: string | null; createdAt: Date; updatedAt: Date; endedAt: Date | null; @@ -166,6 +168,7 @@ export async function listSessions(sessionToken: string): Promise) => void = () => undefined; let emitAdoptState: (state: AdoptSettings) => void = () => undefined; @@ -44,6 +46,7 @@ function makeFakeConnection() { sendUserMessage: () => undefined, decide: () => undefined, answer: (sessionId, payload) => answered.push({ sessionId, payload }), + answerHandover: (sessionId, payload) => handovers.push({ sessionId, payload }), control: () => undefined, sendAdoptConfig: (set) => adoptConfigs.push(set), close: () => undefined, @@ -54,6 +57,7 @@ function makeFakeConnection() { launched, subscribed, answered, + handovers, adoptConfigs, /** Simulate the daemon's sealed adopt.state reply (after the relay-client opens it). */ adoptStateReply(state: AdoptSettings) { @@ -81,6 +85,30 @@ function makeFakeConnection() { }), ); }, + /** Simulate the daemon offering a free-form handover on an adopted session (Journey 4). */ + handover(sessionId: string, requestId: string) { + emit( + makeEnvelope({ + type: 'agent.handover', + userId, + deviceId, + sessionId, + payload: { requestId, question: 'Which database?', summary: 'scaffolding an API' }, + }), + ); + }, + /** Simulate the daemon registering a forked continuation linked to its parent (Journey 4). */ + chained(childSessionId: string, parentSessionId: string) { + emit( + makeEnvelope({ + type: 'session.chained', + userId, + deviceId, + sessionId: childSessionId, + payload: { clientRef: 'fork-1', parentSessionId }, + }), + ); + }, /** Simulate the connection re-authenticating after a dropped socket (browser auto-reconnect). */ reconnect() { fireReconnect(); @@ -203,6 +231,35 @@ describe('session-store launch correlation (Task 11)', () => { ]); }); + it('takes over a handover and links parent ↔ child on session.chained (Journey 4)', () => { + // Real relay-minted UUIDs — session.chained's parentSessionId is validated as a UUID on the wire. + const parentId = '11111111-1111-1111-1111-111111111111'; + const childId = '22222222-2222-2222-2222-222222222222'; + const fake = makeFakeConnection(); + connect( + { relayUrl: 'ws://x', userId, deviceId, getChannelToken: () => Promise.resolve('t') }, + fake.create, + ); + fake.started(parentId); + fake.handover(parentId, 'h1'); + + answerHandover(parentId, { requestId: 'h1', answerText: 'Use Postgres.' }); + // Marked in-flight locally (submitting) and forwarded to the relay. + expect(fake.handovers).toEqual([ + { sessionId: parentId, payload: { requestId: 'h1', answerText: 'Use Postgres.' } }, + ]); + + // The daemon registers the forked continuation → parent ↔ child linked across sessions. + fake.chained(childId, parentId); + const map = get(sessions); + const parentEntry = map.get(parentId)?.entries.find((e) => e.kind === 'handover'); + expect(parentEntry?.kind).toBe('handover'); + if (parentEntry?.kind === 'handover') { + expect(parentEntry.childSessionId).toBe(childId); + } + expect(map.get(childId)?.parentSessionId).toBe(parentId); + }); + it('reads + writes the adoption policy and surfaces adopt.state (Journey 3)', () => { const fake = makeFakeConnection(); connect( diff --git a/apps/web/src/lib/session-store.ts b/apps/web/src/lib/session-store.ts index 8413647..692e040 100644 --- a/apps/web/src/lib/session-store.ts +++ b/apps/web/src/lib/session-store.ts @@ -1,8 +1,10 @@ import { devicePresencePayloadSchema, + sessionChainedPayloadSchema, sessionStartedPayloadSchema, type AdoptSettings, type Envelope, + type HandoverAnswerPayload, type PermissionDecisionPayload, type QuestionAnswerPayload, type SessionControlAction, @@ -11,7 +13,14 @@ import { import { get, writable, type Readable } from 'svelte/store'; import { createRelayConnection, type ConnectionStatus, type RelayConnection } from './relay-client'; -import { appendUserMessage, markAnswering, markDeciding } from './session'; +import { + appendUserMessage, + initialSessionState, + linkHandoverChild, + markAnswering, + markDeciding, + markHandoverSubmitting, +} from './session'; import { foldSessionFrame, markChannelOffline, type SessionMap } from './sessions'; /** @@ -73,6 +82,27 @@ function handleEvent(envelope: Envelope): void { else sessionMap.update((map) => markChannelOffline(map)); return; } + // A forked handover continuation was registered (Journey 4): it links the parent (adopted) session to the + // child. This is a cross-session update (the frame carries the CHILD id + the PARENT id in its payload), + // so it's handled here rather than in foldSessionFrame (which routes by a single session_id). The child's + // own status/transcript still stream in via its `session.started` etc. + if (envelope.type === 'session.chained' && envelope.session_id !== undefined) { + const chained = sessionChainedPayloadSchema.safeParse(envelope.payload); + if (!chained.success) return; + const childId = envelope.session_id; + const { parentSessionId } = chained.data; + sessionMap.update((map) => { + const next = new Map(map); + // Record the parent link on the child (create its state if the chained frame beat session.started). + const child = next.get(childId) ?? initialSessionState; + next.set(childId, { ...child, parentSessionId }); + // Link the parent's handover to the child so its card can offer a "view continuation" link. + const parent = next.get(parentSessionId); + if (parent) next.set(parentSessionId, linkHandoverChild(parent, childId)); + return next; + }); + return; + } sessionMap.update((map) => foldSessionFrame(map, envelope)); // A started session resolves the launch carrying its correlation id (offline launches never start — // they reject on the launch timeout instead, since the relay can't read the opaque clientRef). @@ -234,6 +264,25 @@ export function answer(sessionId: string, payload: QuestionAnswerPayload): void conn.answer(sessionId, payload); } +/** + * Take over an adopted session's free-form question (Journey 4); mark the offer in-flight locally + * (confirmed on the daemon's next frame, like {@link answer}). The daemon forks-and-resumes the adopted + * conversation with this answer as its next turn — migrating it to a telecode-owned continuation. + */ +export function answerHandover(sessionId: string, payload: HandoverAnswerPayload): void { + const conn = connection; + // Guard before the optimistic mark: a dropped send would otherwise strand the card spinning forever. + if (!conn) return; + sessionMap.update((map) => { + const current = map.get(sessionId); + if (!current) return map; + const next = new Map(map); + next.set(sessionId, markHandoverSubmitting(current, payload.requestId, payload.answerText)); + return next; + }); + conn.answerHandover(sessionId, payload); +} + /** Send an operator control (interrupt / end); the daemon reports the resulting status. */ export function sendControl(sessionId: string, action: SessionControlAction): void { connection?.control(sessionId, action); diff --git a/apps/web/src/lib/session.test.ts b/apps/web/src/lib/session.test.ts index bf46df9..86cc731 100644 --- a/apps/web/src/lib/session.test.ts +++ b/apps/web/src/lib/session.test.ts @@ -8,6 +8,9 @@ import { initialSessionState, markAnswering, markDeciding, + linkHandoverChild, + markHandoverSubmitting, + pendingHandover, pendingPermission, pendingQuestion, startingState, @@ -345,3 +348,116 @@ describe('session status coverage', () => { expect(SESSION_DISPLAY[status]).toBeDefined(); }); }); + +describe('session reducer: free-form handover (Journey 4)', () => { + const HANDOVER = { + requestId: 'h1', + question: 'Which database should we use?', + summary: 'scaffolding an API', + }; + + it('surfaces an agent.handover as awaiting_input with a pending, actionable offer', () => { + const state = fold([frame('session.started', {}), frame('agent.handover', HANDOVER)]); + expect(state.status).toBe('awaiting_input'); + const pending = pendingHandover(state); + expect(pending?.kind).toBe('handover'); + if (pending?.kind === 'handover') { + expect(pending.requestId).toBe('h1'); + expect(pending.question).toContain('database'); + expect(pending.summary).toBe('scaffolding an API'); + } + }); + + it('marks a take-over in-flight (submitting) and confirms it on the next frame', () => { + let state = fold([frame('session.started', {}), frame('agent.handover', HANDOVER)]); + state = markHandoverSubmitting(state, 'h1', 'Use Postgres.'); + const submitting = state.entries.find((e) => e.kind === 'handover'); + expect(submitting?.kind === 'handover' && submitting.state).toBe('submitting'); + expect(submitting?.kind === 'handover' && submitting.answerText).toBe('Use Postgres.'); + expect(pendingHandover(state)).toBeUndefined(); + + // The daemon's next frame (the parent ending — the conversation migrated) confirms the take-over. + state = applyEnvelope(state, frame('session.ended', { status: 'done' })); + const submitted = state.entries.find((e) => e.kind === 'handover'); + expect(submitted?.kind === 'handover' && submitted.state).toBe('submitted'); + expect(state.status).toBe('done'); + }); + + it('closes a still-pending handover when the session ends (no dead, clickable card)', () => { + let state = fold([frame('session.started', {}), frame('agent.handover', HANDOVER)]); + state = applyEnvelope(state, frame('session.ended', { status: 'done' })); + const closed = state.entries.find((e) => e.kind === 'handover'); + expect(closed?.kind === 'handover' && closed.state).toBe('closed'); + expect(pendingHandover(state)).toBeUndefined(); + }); + + it('backfills an answered handover as submitted, an unanswered one as pending', () => { + const state = applyEnvelope( + startingState(), + frame('session.history', { + status: 'done', + entries: [ + { + kind: 'handover', + requestId: 'h1', + question: 'Which DB?', + summary: '', + answerText: 'Postgres', + }, + { kind: 'handover', requestId: 'h2', question: 'Which region?', summary: '' }, + ], + }), + ); + const [a, b] = state.entries; + expect(a?.kind === 'handover' && a.state).toBe('submitted'); + expect(a?.kind === 'handover' && a.answerText).toBe('Postgres'); + expect(b?.kind === 'handover' && b.state).toBe('pending'); + }); + + it('ignores an agent.handover with an invalid payload', () => { + const state = applyEnvelope(startingState(), frame('agent.handover', { requestId: 'h1' })); + expect(state.entries).toHaveLength(0); + }); + + it('links the taken-over handover to its forked continuation (childSessionId)', () => { + let state = fold([frame('session.started', {}), frame('agent.handover', HANDOVER)]); + state = markHandoverSubmitting(state, 'h1', 'Use Postgres.'); + state = linkHandoverChild(state, 'child-sess-1'); + const linked = state.entries.find((e) => e.kind === 'handover'); + expect(linked?.kind === 'handover' && linked.childSessionId).toBe('child-sess-1'); + }); + + it('does not link when there is no taken-over handover to link', () => { + const state = fold([frame('session.started', {}), frame('agent.handover', HANDOVER)]); + // Still pending (not submitting/submitted) → nothing to link, same state reference back. + expect(linkHandoverChild(state, 'child-sess-1')).toBe(state); + }); + + it('backfills all three decision kinds (permission + question + handover) in one adopted transcript', () => { + const state = applyEnvelope( + startingState(), + frame('session.history', { + status: 'awaiting_input', + entries: [ + { kind: 'user', text: 'help me set up the db' }, + { kind: 'permission', requestId: 'p1', toolName: 'Bash', input: {}, decision: 'allow' }, + { + kind: 'question', + requestId: 'q1', + questions: [DB_QUESTION], + answers: [{ selectedLabels: ['Postgres'] }], + }, + { kind: 'handover', requestId: 'h1', question: 'Which region?', summary: '' }, + ], + }), + ); + const kinds = state.entries.map((e) => e.kind); + expect(kinds).toEqual(['user', 'permission', 'question', 'handover']); + const permission = state.entries[1]; + const question = state.entries[2]; + const handover = state.entries[3]; + expect(permission?.kind === 'permission' && permission.decision).toBe('approved'); + expect(question?.kind === 'question' && question.answer).toBe('answered'); + expect(handover?.kind === 'handover' && handover.state).toBe('pending'); + }); +}); diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts index 614c1f2..c93e0a5 100644 --- a/apps/web/src/lib/session.ts +++ b/apps/web/src/lib/session.ts @@ -1,4 +1,5 @@ import { + agentHandoverPayloadSchema, agentMessagePayloadSchema, agentNoticePayloadSchema, agentPermissionRequestPayloadSchema, @@ -32,6 +33,14 @@ export type DecisionState = 'pending' | 'approving' | 'rejecting' | 'approved' | */ export type AnswerState = 'pending' | 'answering' | 'answered' | 'closed'; +/** + * Lifecycle of a free-form handover offer (Journey 4): `pending` shows the actionable "continue here" card; + * `submitting` is the verification-gated in-flight state after the user takes it over; `submitted` is + * confirmed (a forked continuation was launched — the parent session then ends); `closed` is an offer the + * session ended before the user took it up (the card disappears, honestly not taken over). + */ +export type HandoverState = 'pending' | 'submitting' | 'submitted' | 'closed'; + export type TranscriptEntry = | { readonly kind: 'user'; readonly id: string; readonly text: string } | { readonly kind: 'message'; readonly id: string; readonly text: string } @@ -57,6 +66,20 @@ export type TranscriptEntry = readonly answer: AnswerState; /** The human's pick(s), one per question — present once answering/answered. */ readonly answers?: readonly QuestionAnswerItem[]; + } + | { + readonly kind: 'handover'; + readonly id: string; + readonly requestId: string; + /** The exact free-form question the adopted session ended its turn on. */ + readonly question: string; + /** Deterministic handover summary of recent context (may be empty). */ + readonly summary: string; + readonly state: HandoverState; + /** The user's free-text answer — present once they took it over (submitting/submitted). */ + readonly answerText?: string; + /** The forked continuation this handover launched — present once the daemon registered it (link target). */ + readonly childSessionId?: string; }; export interface SessionState { @@ -71,6 +94,11 @@ export interface SessionState { * arrives (the session moved on). Non-blocking — distinct from the `awaiting_input` gate (Journey 3). */ readonly notice: string | null; + /** + * The adopted session this one continues (free-form handover, Journey 4), or null when unchained. Set + * from the daemon's `session.chained` for a forked continuation, so the child can link back to its parent. + */ + readonly parentSessionId: string | null; } export const initialSessionState: SessionState = { @@ -79,11 +107,19 @@ export const initialSessionState: SessionState = { entries: [], seq: 0, notice: null, + parentSessionId: null, }; /** Reset to a fresh transcript when launching a new session (the relay assigns the next id). */ export function startingState(): SessionState { - return { sessionId: null, status: 'starting', entries: [], seq: 0, notice: null }; + return { + sessionId: null, + status: 'starting', + entries: [], + seq: 0, + notice: null, + parentSessionId: null, + }; } /** @@ -109,6 +145,10 @@ function confirmInFlightActions(entries: readonly TranscriptEntry[]): readonly T changed = true; return { ...entry, answer: 'answered' as const }; } + if (entry.kind === 'handover' && entry.state === 'submitting') { + changed = true; + return { ...entry, state: 'submitted' as const }; + } return entry; }); return changed ? next : entries; @@ -131,6 +171,12 @@ function closeOpenGates(entries: readonly TranscriptEntry[]): readonly Transcrip changed = true; return { ...entry, answer: 'closed' as const }; } + // A handover the user never took over (the session ended some other way) closes — the card disappears. + // A `submitting` one was already confirmed to `submitted` by confirmInFlightActions before this runs. + if (entry.kind === 'handover' && entry.state === 'pending') { + changed = true; + return { ...entry, state: 'closed' as const }; + } return entry; }); return changed ? next : entries; @@ -228,6 +274,29 @@ export function applyEnvelope(state: SessionState, envelope: Envelope): SessionS }; } + case 'agent.handover': { + // A free-form handover offer (Journey 4): an adopted session ended its turn asking a free-form + // question. Park at awaiting_input and surface the actionable "continue here" card. + const parsed = agentHandoverPayloadSchema.safeParse(envelope.payload); + if (!parsed.success) return base; + return { + ...base, + status: 'awaiting_input', + entries: [ + ...base.entries, + { + kind: 'handover', + id: `e${base.seq}`, + requestId: parsed.data.requestId, + question: parsed.data.question, + summary: parsed.data.summary, + state: 'pending', + }, + ], + seq: base.seq + 1, + }; + } + case 'session.ended': { const parsed = sessionEndedPayloadSchema.safeParse(envelope.payload); return { @@ -290,6 +359,18 @@ export function applyEnvelope(state: SessionState, envelope: Envelope): SessionS answer: entry.answers !== undefined ? 'answered' : 'pending', ...(entry.answers !== undefined ? { answers: entry.answers } : {}), }; + case 'handover': + // A backfilled handover replays as submitted (taken over) when it carries the user's answerText, + // or still-open (pending, actionable) otherwise — the same decided-vs-pending split. + return { + kind: 'handover', + id, + requestId: entry.requestId, + question: entry.question, + summary: entry.summary, + state: entry.answerText !== undefined ? 'submitted' : 'pending', + ...(entry.answerText !== undefined ? { answerText: entry.answerText } : {}), + }; default: { // Exhaustiveness: a new history-entry kind must be handled here (parse already rejects // unknown kinds at runtime, so this is unreachable). @@ -304,6 +385,7 @@ export function applyEnvelope(state: SessionState, envelope: Envelope): SessionS entries, seq: entries.length, notice: null, // a backfilled reopen carries no live notice + parentSessionId: base.parentSessionId, }; } @@ -367,3 +449,52 @@ export function markAnswering( export function pendingQuestion(state: SessionState): TranscriptEntry | undefined { return state.entries.find((entry) => entry.kind === 'question' && entry.answer === 'pending'); } + +/** + * Mark a free-form handover as being taken over locally (in-flight) the instant the user submits their + * answer, carrying it. Verification-gated like {@link markAnswering}: it shows `submitting` and confirms to + * `submitted` on the daemon's next frame (the parent session then ends — the conversation migrates to the + * forked continuation). Status is left at `awaiting_input` until that terminal frame — the parent is being + * superseded, not resumed, so it should not optimistically read as `running`. + */ +export function markHandoverSubmitting( + state: SessionState, + requestId: string, + answerText: string, +): SessionState { + return { + ...state, + entries: state.entries.map((entry) => + entry.kind === 'handover' && entry.requestId === requestId + ? { ...entry, state: 'submitting', answerText } + : entry, + ), + }; +} + +/** The free-form handover currently awaiting the user, if any. */ +export function pendingHandover(state: SessionState): TranscriptEntry | undefined { + return state.entries.find((entry) => entry.kind === 'handover' && entry.state === 'pending'); +} + +/** + * Link a handover to the forked continuation the daemon just registered (its `session.chained`), so the + * card can offer a "view the continuation" link. Sets `childSessionId` on the most recent taken-over + * handover (submitting/submitted) that doesn't already have one — a handover leads to exactly one child. + */ +export function linkHandoverChild(state: SessionState, childSessionId: string): SessionState { + let linked = false; + const entries = [...state.entries].reverse().map((entry) => { + if ( + !linked && + entry.kind === 'handover' && + entry.childSessionId === undefined && + (entry.state === 'submitting' || entry.state === 'submitted') + ) { + linked = true; + return { ...entry, childSessionId }; + } + return entry; + }); + return linked ? { ...state, entries: entries.reverse() } : state; +} diff --git a/apps/web/src/routes/(app)/sessions/[id]/+page.svelte b/apps/web/src/routes/(app)/sessions/[id]/+page.svelte index e7fb40a..f374cf2 100644 --- a/apps/web/src/routes/(app)/sessions/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/sessions/[id]/+page.svelte @@ -10,6 +10,7 @@ import { SESSION_DISPLAY } from '$lib/session-display'; import { answer, + answerHandover, connectionState, decide, sendControl, @@ -43,9 +44,19 @@ const connected = $derived($connectionState === 'connected'); const isTerminal = $derived(session.status === 'done' || session.status === 'error'); const showControls = $derived(known && session.status !== 'idle'); - // The session's first prompt names it (in the header + browser tab); fall back to the short id. + // The session's first prompt names it (in the header + browser tab); fall back to a short id prefix. + const SESSION_ID_DISPLAY_LENGTH = 12; const sessionTitle = $derived( - session.entries.find((e) => e.kind === 'user')?.text ?? sessionId.slice(0, 12), + session.entries.find((e) => e.kind === 'user')?.text ?? + sessionId.slice(0, SESSION_ID_DISPLAY_LENGTH), + ); + + // A forked handover continuation links back to the adopted session it continues (Journey 4): live from + // the daemon's session.chained, or from the persisted registry on a cold reload. + const parentSessionId = $derived( + session.parentSessionId ?? + data.sessions.find((s) => s.id === sessionId)?.parentSessionId ?? + null, ); function onControl(action: SessionControlAction): void { @@ -79,6 +90,10 @@ function onAnswer(requestId: string, answers: QuestionAnswerItem[]): void { answer(sessionId, { requestId, answers }); } + + function onHandover(requestId: string, answerText: string): void { + answerHandover(sessionId, { requestId, answerText }); + } @@ -101,6 +116,11 @@
+ {#if parentSessionId} + + ← Continued from an adopted session + + {/if} {#if known && showNotice && session.notice} onDecide('allow')} onreject={() => onDecide('deny')} onanswer={onAnswer} + onhandover={onHandover} /> {/if} @@ -167,6 +189,22 @@ min-width: 0; min-height: 0; } + .continued-from { + align-self: flex-start; + margin: var(--space-3) var(--space-4) 0; + font-size: var(--text-xs); + color: var(--text-secondary); + text-decoration: none; + border-radius: var(--radius-sm); + } + .continued-from:hover { + color: var(--text); + text-decoration: underline; + } + .continued-from:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus-ring); + } .placeholder { flex: 1; display: flex; diff --git a/packages/daemon/src/adopt/free-form-question.test.ts b/packages/daemon/src/adopt/free-form-question.test.ts new file mode 100644 index 0000000..6984c82 --- /dev/null +++ b/packages/daemon/src/adopt/free-form-question.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { isFreeFormQuestion } from './free-form-question'; + +/** + * The free-form question detector (Journey 4): decides whether an adopted session's end-of-turn message + * looks like it is asking the user something, so the daemon offers a handover. Heuristic + dismissible, so + * it favours precision (a trailing `?` or a clear solicitation) while tolerating misses. + */ +describe('isFreeFormQuestion', () => { + it('detects a message ending in a question mark', () => { + expect(isFreeFormQuestion('Which database should we use for the app?')).toBe(true); + }); + + it('detects a trailing question mark through closing quotes/emphasis/brackets', () => { + expect(isFreeFormQuestion('Should I use the "staging" config?')).toBe(true); + expect(isFreeFormQuestion('Ready to proceed?**')).toBe(true); + expect(isFreeFormQuestion('(shall we continue?)')).toBe(true); + }); + + it('detects a solicitation phrased without a question mark', () => { + expect(isFreeFormQuestion('Let me know which approach you prefer and I will continue.')).toBe( + true, + ); + expect(isFreeFormQuestion('Please confirm the target environment before I deploy.')).toBe(true); + }); + + it('ignores a statement that does not solicit input', () => { + expect(isFreeFormQuestion('I have finished the refactor and all tests pass.')).toBe(false); + expect(isFreeFormQuestion('Done. The build is green.')).toBe(false); + }); + + it('ignores a message whose only question mark is inside a fenced code block', () => { + expect( + isFreeFormQuestion('Here is the regex:\n```\n/foo\\?bar/\n```\nApplied it to the parser.'), + ).toBe(false); + }); + + it('still triggers on a real question that follows a code block', () => { + expect( + isFreeFormQuestion('```\nconst x = 1;\n```\nDoes that match what you had in mind?'), + ).toBe(true); + }); + + it('ignores empty, whitespace, and undefined messages', () => { + expect(isFreeFormQuestion(undefined)).toBe(false); + expect(isFreeFormQuestion('')).toBe(false); + expect(isFreeFormQuestion(' \n ')).toBe(false); + }); + + it('ignores an absurdly long blob (not a concise question)', () => { + expect(isFreeFormQuestion(`${'a '.repeat(5000)}?`)).toBe(false); + }); +}); diff --git a/packages/daemon/src/adopt/free-form-question.ts b/packages/daemon/src/adopt/free-form-question.ts new file mode 100644 index 0000000..82ae516 --- /dev/null +++ b/packages/daemon/src/adopt/free-form-question.ts @@ -0,0 +1,38 @@ +/** + * Does this end-of-turn assistant message look like a **free-form question** soliciting the user's input? + * + * Claude Code's `Stop` hook fires on EVERY turn end, so the free-form handover detector (Journey 4) uses + * this to decide whether to offer a "continue here" card. It is deliberately a HEURISTIC: a false positive + * only surfaces a *dismissible* card, never a wrong action (AD-J4-3), so it favours precision but tolerates + * the occasional miss. The strongest signal is a message whose prose ends in a question mark; a short list + * of high-precision solicitation cues adds recall for questions phrased without one. Fenced code blocks are + * stripped first so a `?` inside a code sample never triggers, and empty / absurdly long messages are ignored. + */ + +/** Upper bound: mirrors the wire cap on the handover question; a longer blob is not a concise question. */ +const MAX_QUESTION_LENGTH = 8000; + +/** High-precision cues for a question phrased without a trailing `?` (e.g. "Let me know which you prefer."). */ +const SOLICITATION_CUES = [ + /\blet me know\b/i, + /\bwhich (?:one|option|approach|would you)\b/i, + /\bwould you (?:like|prefer|want)\b/i, + /\bdo you want\b/i, + /\bplease (?:confirm|choose|specify|clarify|let me know)\b/i, + /\bhow would you like\b/i, +]; + +/** Prevents a `?` inside a fenced code block from being read as a question by the detector. */ +function stripCodeBlocks(text: string): string { + return text.replace(/```[\s\S]*?```/g, ' ').replace(/```[\s\S]*$/g, ' '); +} + +export function isFreeFormQuestion(lastAssistantMessage: string | undefined): boolean { + if (lastAssistantMessage === undefined) return false; + const text = lastAssistantMessage.trim(); + if (text.length === 0 || text.length > MAX_QUESTION_LENGTH) return false; + const prose = stripCodeBlocks(text).trim(); + if (prose.length === 0) return false; + if (/\?["'”’)\]*_`]*\s*$/.test(prose)) return true; + return SOLICITATION_CUES.some((cue) => cue.test(prose)); +} diff --git a/packages/daemon/src/adopt/handover-fallback-prompt.test.ts b/packages/daemon/src/adopt/handover-fallback-prompt.test.ts new file mode 100644 index 0000000..c810550 --- /dev/null +++ b/packages/daemon/src/adopt/handover-fallback-prompt.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { buildHandoverFallbackPrompt } from './handover-fallback-prompt'; + +/** + * The fresh-launch fallback prompt (Journey 4): when a free-form handover cannot resume the adopted + * conversation, the fresh continuation is seeded with this text so it continues with context instead of cold. + */ +describe('buildHandoverFallbackPrompt', () => { + it('carries the summary, the question, and the answer', () => { + const prompt = buildHandoverFallbackPrompt( + 'Scaffolding a new API and choosing storage.', + 'Which database should we use for the app?', + 'Use Postgres.', + ); + expect(prompt).toContain('Scaffolding a new API and choosing storage.'); + expect(prompt).toContain('Which database should we use for the app?'); + expect(prompt).toContain('Use Postgres.'); + }); + + it('omits the summary section when the summary is empty (still orients on question + answer)', () => { + const prompt = buildHandoverFallbackPrompt('', 'Which region?', 'us-west'); + expect(prompt).not.toContain('Summary of the session so far'); + expect(prompt).toContain('Which region?'); + expect(prompt).toContain('us-west'); + }); + + it('trims whitespace in each part', () => { + const prompt = buildHandoverFallbackPrompt(' ctx ', ' q? ', ' a '); + expect(prompt).toContain('\nctx\n'); + expect(prompt).toContain('\nq?\n'); + expect(prompt).toContain('\na\n'); + }); +}); diff --git a/packages/daemon/src/adopt/handover-fallback-prompt.ts b/packages/daemon/src/adopt/handover-fallback-prompt.ts new file mode 100644 index 0000000..e14b1d6 --- /dev/null +++ b/packages/daemon/src/adopt/handover-fallback-prompt.ts @@ -0,0 +1,33 @@ +/** + * Build the seeded prompt for a free-form handover's FRESH-LAUNCH fallback (Journey 4). Preferred, telecode + * resumes the adopted conversation directly (full context); but if that resume fails — the SDK can't pick up + * an externally-created conversation (transcript gone, version skew) — the continuation runs as a brand-new + * conversation instead. This prompt hands that fresh session the context it lost: a summary of where the + * adopted session left off, the exact question it asked, and the user's answer — so it continues sensibly + * rather than starting cold. `summary` may be empty (deterministic extraction found little); the question + + * answer alone still orient the model. + */ +export function buildHandoverFallbackPrompt( + summary: string, + question: string, + answerText: string, +): string { + const lines = [ + 'You are continuing a previous session that could not be resumed directly, so here is its context.', + ]; + const trimmedSummary = summary.trim(); + if (trimmedSummary.length > 0) { + lines.push('', 'Summary of the session so far:', trimmedSummary); + } + lines.push( + '', + 'The last thing the session asked was:', + question.trim(), + '', + "The user's answer:", + answerText.trim(), + '', + 'Continue the work from here, taking the answer into account.', + ); + return lines.join('\n'); +} diff --git a/packages/daemon/src/adopt/handover-summary.test.ts b/packages/daemon/src/adopt/handover-summary.test.ts new file mode 100644 index 0000000..41afc72 --- /dev/null +++ b/packages/daemon/src/adopt/handover-summary.test.ts @@ -0,0 +1,56 @@ +import { type SessionHistoryEntry } from '@telecode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { buildHandoverSummary } from './handover-summary'; + +/** + * The deterministic handover summary (Journey 4): a concise "what the session was doing" blob extracted from + * the mirrored transcript — no model call. Keeps recent user/assistant text turns; skips tool/gate entries. + */ +describe('buildHandoverSummary', () => { + it('summarizes recent user + assistant text turns, labelled', () => { + const entries: SessionHistoryEntry[] = [ + { kind: 'user', text: 'Add a REST API for orders.' }, + { kind: 'message', text: 'I scaffolded the routes and a service layer.' }, + ]; + const summary = buildHandoverSummary(entries); + expect(summary).toContain('User: Add a REST API for orders.'); + expect(summary).toContain('Assistant: I scaffolded the routes and a service layer.'); + }); + + it('skips tool calls, permission gates, questions, and handovers', () => { + const entries: SessionHistoryEntry[] = [ + { kind: 'user', text: 'do it' }, + { kind: 'tool', toolName: 'Write', input: { path: 'x' } }, + { kind: 'permission', requestId: 'r', toolName: 'Bash', input: {}, decision: 'allow' }, + { kind: 'message', text: 'done' }, + ]; + const summary = buildHandoverSummary(entries); + expect(summary).not.toContain('Write'); + expect(summary).not.toContain('Bash'); + expect(summary.split('\n')).toEqual(['User: do it', 'Assistant: done']); + }); + + it('keeps only the most recent entries (maxEntries)', () => { + const entries: SessionHistoryEntry[] = Array.from({ length: 10 }, (_, i) => ({ + kind: 'message' as const, + text: `line ${i}`, + })); + const summary = buildHandoverSummary(entries, { maxEntries: 3 }); + expect(summary).toBe('Assistant: line 7\nAssistant: line 8\nAssistant: line 9'); + }); + + it('collapses whitespace and truncates a long turn', () => { + const entries: SessionHistoryEntry[] = [ + { kind: 'user', text: `line one\n\n line two\t\tend ${'x'.repeat(400)}` }, + ]; + const summary = buildHandoverSummary(entries, { maxCharsPerEntry: 20 }); + // Whitespace collapsed to single spaces; the turn text truncated to 20 chars + an ellipsis. + expect(summary).toBe('User: line one line two en…'); + }); + + it('returns an empty string for a transcript with no text turns', () => { + expect(buildHandoverSummary([])).toBe(''); + expect(buildHandoverSummary([{ kind: 'tool', toolName: 'Read', input: {} }])).toBe(''); + }); +}); diff --git a/packages/daemon/src/adopt/handover-summary.ts b/packages/daemon/src/adopt/handover-summary.ts new file mode 100644 index 0000000..55c64b8 --- /dev/null +++ b/packages/daemon/src/adopt/handover-summary.ts @@ -0,0 +1,56 @@ +import { type SessionHistoryEntry } from '@telecode/protocol'; + +/** + * Build a concise "what this session was doing" summary for a free-form handover (Journey 4), by + * DETERMINISTIC extraction from the mirrored transcript — no extra model call (fast, private, zero cost; + * AD-J4-2/Q2). Keeps only the conversational text turns (user prompts + assistant prose — tool calls, + * permission gates, questions, and handovers are skipped), takes the most recent few, collapses whitespace, + * truncates each, and bounds the total. Returns '' when there is nothing to summarize (the question + the + * user's answer still carry the handover on their own). Never throws — a thin transcript just yields a thin + * summary. + */ +const DEFAULT_MAX_ENTRIES = 6; +const DEFAULT_MAX_CHARS_PER_ENTRY = 300; +const DEFAULT_MAX_TOTAL_CHARS = 1500; + +interface HandoverSummaryOptions { + readonly maxEntries?: number; + readonly maxCharsPerEntry?: number; + readonly maxTotalChars?: number; +} + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +export function buildHandoverSummary( + entries: readonly SessionHistoryEntry[], + options: HandoverSummaryOptions = {}, +): string { + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const maxCharsPerEntry = options.maxCharsPerEntry ?? DEFAULT_MAX_CHARS_PER_ENTRY; + const maxTotalChars = options.maxTotalChars ?? DEFAULT_MAX_TOTAL_CHARS; + + const textTurns = entries.filter( + (e): e is Extract => + e.kind === 'user' || e.kind === 'message', + ); + const lines = textTurns + .slice(-maxEntries) + .map((e) => { + const who = e.kind === 'user' ? 'User' : 'Assistant'; + const text = truncate(collapseWhitespace(e.text), maxCharsPerEntry); + return text.length > 0 ? `${who}: ${text}` : ''; + }) + .filter((line) => line.length > 0); + + const summary = lines.join('\n'); + // Bound the total from the FRONT (keep the most recent context) when it overflows. + return summary.length > maxTotalChars + ? `…${summary.slice(summary.length - maxTotalChars)}` + : summary; +} diff --git a/packages/daemon/src/adopt/hook-event.ts b/packages/daemon/src/adopt/hook-event.ts index f3b0a67..7156bfc 100644 --- a/packages/daemon/src/adopt/hook-event.ts +++ b/packages/daemon/src/adopt/hook-event.ts @@ -29,5 +29,16 @@ export const hookEventSchema = z.object({ reason: z.string().optional(), /** Notification: the human-readable notification text (idle / needs-permission prompts). */ message: z.string().optional(), + /** + * Stop (Journey 4): the assistant's final text for the just-ended turn, handed to us directly — so a + * free-form question (prose, no tool call) needs no transcript parse to detect. The free-form handover + * detector reads this verbatim. + */ + last_assistant_message: z.string().optional(), + /** + * Stop: true when the stop is itself firing from within a Stop hook that continued the session — the + * re-entrancy guard, so the handover detector never loops on its own continuation. + */ + stop_hook_active: z.boolean().optional(), }); export type HookEvent = z.infer; diff --git a/packages/daemon/src/adopt/hooks-install.ts b/packages/daemon/src/adopt/hooks-install.ts index 8b948ab..0705c8f 100644 --- a/packages/daemon/src/adopt/hooks-install.ts +++ b/packages/daemon/src/adopt/hooks-install.ts @@ -14,9 +14,15 @@ import { stripTelecodeHooks } from './strip-telecode-hooks'; * - `SessionStart` — adopt a session before its first tool, incl. chat-only ones (Journey 3). * - `SessionEnd` — end the adopted session when the Claude Code process exits (Journey 3). * - `Notification` — surface "needs attention / went idle" cues (Journey 3). - * (`Stop` is reserved for the free-form handover detector in a later journey.) + * - `Stop` — detect a free-form question at turn end and offer to take it over (Journey 4). */ -const TELECODE_HOOK_EVENTS = ['PreToolUse', 'SessionStart', 'SessionEnd', 'Notification'] as const; +const TELECODE_HOOK_EVENTS = [ + 'PreToolUse', + 'SessionStart', + 'SessionEnd', + 'Notification', + 'Stop', +] as const; export interface InstallHooksOptions { readonly settingsPath: string; diff --git a/packages/daemon/src/adopt/hooks-settings.test.ts b/packages/daemon/src/adopt/hooks-settings.test.ts index 5319d25..1a4eb76 100644 --- a/packages/daemon/src/adopt/hooks-settings.test.ts +++ b/packages/daemon/src/adopt/hooks-settings.test.ts @@ -52,10 +52,11 @@ describe('hooks installer', () => { const group = settings.hooks.PreToolUse[0]!; expect(group.matcher).toBe('*'); expect(group.hooks[0]).toMatchObject({ type: 'command', command: COMMAND, timeout: 3600 }); - // All four lifecycle events telecode adopts on (Journey 1–3): gate/question + adopt/end + attention. + // All five lifecycle events telecode adopts on (Journey 1–4): gate/question + adopt/end + attention + + // the free-form handover detector (Stop). expect(await readHooksStatus({ settingsPath })).toEqual({ installed: true, - events: ['PreToolUse', 'SessionStart', 'SessionEnd', 'Notification'], + events: ['PreToolUse', 'SessionStart', 'SessionEnd', 'Notification', 'Stop'], }); }); @@ -78,14 +79,20 @@ describe('hooks installer', () => { await installHooks({ settingsPath, command: COMMAND }); const settings = (await read()) as { model?: string; - hooks: { PreToolUse: { hooks: { command: string }[] }[]; Stop?: unknown[] }; + hooks: { + PreToolUse: { hooks: { command: string }[] }[]; + Stop: { hooks: { command: string }[] }[]; + }; }; expect(settings.model).toBe('claude-opus'); - const commands = settings.hooks.PreToolUse.flatMap((g) => g.hooks.map((h) => h.command)); - expect(commands).toContain('my-linter'); - expect(commands).toContain(COMMAND); - expect(settings.hooks.Stop).toBeDefined(); + const preCommands = settings.hooks.PreToolUse.flatMap((g) => g.hooks.map((h) => h.command)); + expect(preCommands).toContain('my-linter'); + expect(preCommands).toContain(COMMAND); + // telecode now installs a Stop hook too (Journey 4) — the user's own Stop hook is preserved alongside it. + const stopCommands = settings.hooks.Stop.flatMap((g) => g.hooks.map((h) => h.command)); + expect(stopCommands).toContain('notify-me'); + expect(stopCommands).toContain(COMMAND); }); it('uninstall removes telecode hooks but keeps the user’s (and prunes empties)', async () => { diff --git a/packages/daemon/src/agent-adapter.ts b/packages/daemon/src/agent-adapter.ts index 5e81705..a307d27 100644 --- a/packages/daemon/src/agent-adapter.ts +++ b/packages/daemon/src/agent-adapter.ts @@ -40,6 +40,13 @@ export interface AgentRunOptions { * earlier run). Omitted on the first turn of a session. */ readonly resume?: string; + /** + * Fork the resumed conversation instead of continuing it in place (Journey 4 free-form handover). With + * `resume` set, `forkSession: true` makes the SDK branch a NEW conversation (new id, own transcript) that + * inherits the resumed context — so telecode can take over an adopted session by resuming its `session_id` + * without writing into the still-live external process's transcript. Ignored without `resume`. + */ + readonly forkSession?: boolean; /** * Working directory the agent runs in — the session's git worktree (Phase 2). Omitted falls back to * the daemon's own cwd. The daemon derives this path; it is never taken from an untrusted client. @@ -75,8 +82,8 @@ export interface AgentAdapter { export interface FakeAgentAdapterOptions { /** The conversation id every run reports (so the daemon can thread `resume` across turns). */ readonly sessionId?: string; - /** Invoked once per `run` with the turn's prompt + the `resume` id it was called with. */ - readonly onRun?: (call: { prompt: string; resume?: string }) => void; + /** Invoked once per `run` with the turn's prompt + the `resume` id / `forkSession` flag it was called with. */ + readonly onRun?: (call: { prompt: string; resume?: string; forkSession?: boolean }) => void; } /** @@ -92,9 +99,13 @@ export function createFakeAgentAdapter( return { async run( prompt: string, - { canUseTool, onEvent, resume }: AgentRunOptions, + { canUseTool, onEvent, resume, forkSession }: AgentRunOptions, ): Promise { - options.onRun?.({ prompt, ...(resume !== undefined ? { resume } : {}) }); + options.onRun?.({ + prompt, + ...(resume !== undefined ? { resume } : {}), + ...(forkSession !== undefined ? { forkSession } : {}), + }); const intercepted: PermissionRequest[] = []; const allowed: string[] = []; const denied: string[] = []; diff --git a/packages/daemon/src/claude-agent-adapter.test.ts b/packages/daemon/src/claude-agent-adapter.test.ts new file mode 100644 index 0000000..f5abecf --- /dev/null +++ b/packages/daemon/src/claude-agent-adapter.test.ts @@ -0,0 +1,55 @@ +import { pino } from 'pino'; +import { describe, expect, it, vi } from 'vitest'; + +// Capture the options the adapter passes to the SDK's query(), without a real model call. Hoisted so the +// mock factory (itself hoisted above the import) can reach it. +const { queryCalls } = vi.hoisted(() => ({ + queryCalls: [] as { options: Record }[], +})); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: (args: { options: Record }) => { + queryCalls.push(args); + return (async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sdk-x' }; + })(); + }, +})); + +import { createClaudeAgentAdapter } from './claude-agent-adapter'; + +/** + * The real adapter's SDK wiring (Journey 4): `forkSession` reaches `query()` only when a `resume` is set — + * so a free-form handover forks the resumed conversation (new id + own transcript), while an ordinary turn + * never forks. Model call is mocked; this locks the option threading the spike proved live. + */ +describe('createClaudeAgentAdapter: forkSession threading', () => { + const log = pino({ level: 'silent' }); + const run = (opts: { resume?: string; forkSession?: boolean }) => + createClaudeAgentAdapter({ logger: log }).run('go', { + canUseTool: async () => ({ behavior: 'allow' }), + onEvent: () => undefined, + ...opts, + }); + + it('passes forkSession to query() when resuming', async () => { + await run({ resume: 'r1', forkSession: true }); + const { options } = queryCalls.at(-1)!; + expect(options.resume).toBe('r1'); + expect(options.forkSession).toBe(true); + }); + + it('drops forkSession when there is no resume (an ordinary first turn never forks)', async () => { + await run({ forkSession: true }); + const { options } = queryCalls.at(-1)!; + expect(options.resume).toBeUndefined(); + expect(options.forkSession).toBeUndefined(); + }); + + it('omits forkSession on a plain resume', async () => { + await run({ resume: 'r2' }); + const { options } = queryCalls.at(-1)!; + expect(options.resume).toBe('r2'); + expect(options.forkSession).toBeUndefined(); + }); +}); diff --git a/packages/daemon/src/claude-agent-adapter.ts b/packages/daemon/src/claude-agent-adapter.ts index 8e68d39..84d097b 100644 --- a/packages/daemon/src/claude-agent-adapter.ts +++ b/packages/daemon/src/claude-agent-adapter.ts @@ -47,7 +47,7 @@ export function createClaudeAgentAdapter(options: ClaudeAgentAdapterOptions = {} return { async run( prompt: string, - { canUseTool, onEvent, resume, cwd, signal, permissionMode }: AgentRunOptions, + { canUseTool, onEvent, resume, forkSession, cwd, signal, permissionMode }: AgentRunOptions, ): Promise { const intercepted: PermissionRequest[] = []; const allowed: string[] = []; @@ -115,6 +115,9 @@ export function createClaudeAgentAdapter(options: ClaudeAgentAdapterOptions = {} // Run in the session's worktree so parallel agents never clobber each other's files. ...(cwd ? { cwd } : {}), ...(resume ? { resume } : {}), + // Fork the resumed conversation (free-form handover): a new SDK session id + its own transcript, + // so taking over an adopted session never writes into the still-live external process's transcript. + ...(resume && forkSession ? { forkSession: true } : {}), ...(options.model ? { model: options.model } : {}), ...(options.allowedTools ? { allowedTools: options.allowedTools } : {}), }, diff --git a/packages/daemon/src/daemon.handover.test.ts b/packages/daemon/src/daemon.handover.test.ts new file mode 100644 index 0000000..375a63f --- /dev/null +++ b/packages/daemon/src/daemon.handover.test.ts @@ -0,0 +1,435 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { encodeKey, generateKeyPair, makeEnvelope, type Envelope } from '@telecode/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createFakeAgentAdapter, + type AgentAdapter, + type AgentRunOptions, + type AgentRunResult, +} from './agent-adapter'; +import { createDaemon, type Daemon } from './daemon'; +import { startFakeRelay, type FakeRelay } from './fake-relay'; + +/** + * Free-form handover & resume, end-to-end through the daemon (Journey 4 walking skeleton): an adopted + * session ends its turn asking a free-form question → the `Stop` hook makes the daemon offer a handover + * (`agent.handover`, non-blocking) → the user answers (`handover.answer`) → the daemon registers a forked, + * telecode-owned continuation (`session.chained`, linked to the parent) and runs it by RESUMING the adopted + * conversation with `forkSession`, while the parent is marked handed-off. Real daemon + real socket + a fake + * relay (stands in for relay + browser); a fake adapter records the fork-resume call. + */ +const USER = 'user-handover'; +const DEVICE = 'device-handover'; +const CLAUDE_SESSION = 'claude-sess-ff'; +const PARENT_SESSION = '11111111-1111-1111-1111-111111111111'; +const CHILD_SESSION = '22222222-2222-2222-2222-222222222222'; + +/** One bridge round-trip over the hook socket: write the event, half-close, read the decision JSON. */ +function hookRpc(socketPath: string, event: unknown): Promise { + return new Promise((resolve, reject) => { + const client = createConnection(socketPath); + let out = ''; + client.on('connect', () => client.end(JSON.stringify(event))); + client.on('data', (chunk: Buffer) => { + out += chunk.toString('utf8'); + }); + client.on('end', () => { + try { + resolve(out === '' ? {} : JSON.parse(out)); + } catch (err) { + reject(err instanceof Error ? err : new Error('hook response parse failed')); + } + }); + client.on('error', reject); + }); +} + +/** Reply to the daemon's `session.adopted` announce with the relay-minted id (pairs the Claude session). */ +function ackAdopted(relay: FakeRelay, announce: Envelope): void { + const clientRef = (announce.payload as { clientRef: string }).clientRef; + relay.send( + makeEnvelope({ + type: 'session.adopted', + userId: USER, + deviceId: DEVICE, + sessionId: PARENT_SESSION, + payload: { clientRef }, + }), + ); +} + +/** Drive the read-only PreToolUse that first adopts the session, then ack the announce. */ +async function adopt(relay: FakeRelay, socketPath: string): Promise { + const first = hookRpc(socketPath, { + hook_event_name: 'PreToolUse', + session_id: CLAUDE_SESSION, + cwd: '/repo', + tool_name: 'Read', + tool_input: {}, + }); + ackAdopted(relay, await relay.waitForFrame((e) => e.type === 'session.adopted')); + await first; +} + +describe('daemon: free-form handover & resume', () => { + let relay: FakeRelay; + let daemon: Daemon | undefined; + let dir: string; + let socketPath: string; + + beforeEach(async () => { + relay = await startFakeRelay(USER, DEVICE); + dir = await mkdtemp(join(tmpdir(), 'telecode-daemon-handover-')); + socketPath = join(dir, 'run', 'hook.sock'); + daemon = undefined; + }); + + afterEach(async () => { + await daemon?.stop(); + await relay.close(); + await rm(dir, { recursive: true, force: true }); + }); + + /** + * Start a daemon with the given adapter (each test picks one to exercise the resume vs fallback path). + * A `keyPair` makes adopted sessions run end-to-end encrypted, exactly like a paired daemon. + */ + async function start( + agentAdapter: AgentAdapter, + keyPair?: { publicKey: string; privateKey: string }, + ): Promise { + daemon = createDaemon({ + relayUrl: relay.url, + userId: USER, + deviceId: DEVICE, + agentAdapter, + adopt: { socketPath, ackTimeoutMs: 2000, configPath: join(dir, 'adopt-config.json') }, + ...(keyPair ? { keyPair } : {}), + }); + await daemon.start(); + } + + /** A Stop hook event for the adopted session with a given last assistant message. */ + function stopEvent(lastAssistantMessage: string, extra: Record = {}): unknown { + return { + hook_event_name: 'Stop', + session_id: CLAUDE_SESSION, + cwd: '/repo', + last_assistant_message: lastAssistantMessage, + ...extra, + }; + } + + /** Set the daemon's adoption policy (cleartext, pre-E2E daemon) and wait for the adopt.state confirmation. */ + async function setAdoptConfig(enabled: boolean, denylist: string[]): Promise { + const onState = relay.waitForFrame((e) => e.type === 'adopt.state'); + relay.send( + makeEnvelope({ + type: 'adopt.config', + userId: USER, + deviceId: DEVICE, + payload: { set: { enabled, denylist } }, + }), + ); + await onState; + } + + it('offers a handover on a free-form Stop, then forks-and-resumes the conversation on the answer', async () => { + const runCalls: { prompt: string; resume?: string; forkSession?: boolean }[] = []; + await start( + createFakeAgentAdapter([{ type: 'message', text: 'Continuing with your answer.' }], { + sessionId: 'fork-sdk-id', + onRun: (call) => runCalls.push(call), + }), + ); + await adopt(relay, socketPath); + + // The adopted session ends its turn asking a free-form question → the Stop hook offers a handover. + const stop = hookRpc(socketPath, { + hook_event_name: 'Stop', + session_id: CLAUDE_SESSION, + cwd: '/repo', + last_assistant_message: 'Which database should we use for the app?', + }); + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + expect(offer.session_id).toBe(PARENT_SESSION); + const { requestId, question } = offer.payload as { requestId: string; question: string }; + expect(question).toContain('database'); + // Stop is NON-blocking — the hook returns immediately (the idle external process is never held). + expect(await stop).toEqual({}); + + // The user takes it over: answers the free-form question. + relay.send( + makeEnvelope({ + type: 'handover.answer', + userId: USER, + deviceId: DEVICE, + sessionId: PARENT_SESSION, + payload: { requestId, answerText: 'Use Postgres.' }, + }), + ); + + // The daemon registers a forked continuation linked to the adopted parent (no id yet) — ack it. + const chained = await relay.waitForFrame((e) => e.type === 'session.chained'); + expect(chained.session_id).toBeUndefined(); + const chainPayload = chained.payload as { + clientRef: string; + parentSessionId: string; + cwd?: string; + }; + expect(chainPayload.parentSessionId).toBe(PARENT_SESSION); + expect(chainPayload.cwd).toBe('/repo'); + relay.send( + makeEnvelope({ + type: 'session.chained', + userId: USER, + deviceId: DEVICE, + sessionId: CHILD_SESSION, + payload: { clientRef: chainPayload.clientRef, parentSessionId: PARENT_SESSION }, + }), + ); + + // The child starts, the parent is handed off (ended), and the child ran by RESUMING the adopted + // conversation with forkSession — the answer as its next turn. + const started = await relay.waitForFrame( + (e) => e.type === 'session.started' && e.session_id === CHILD_SESSION, + ); + expect(started.session_id).toBe(CHILD_SESSION); + const parentEnded = await relay.waitForFrame( + (e) => e.type === 'session.ended' && e.session_id === PARENT_SESSION, + ); + expect(parentEnded.status).toBe('done'); + await relay.waitForFrame((e) => e.type === 'session.ended' && e.session_id === CHILD_SESSION); + + await vi.waitFor(() => + expect(runCalls).toContainEqual({ + prompt: 'Use Postgres.', + resume: CLAUDE_SESSION, + forkSession: true, + }), + ); + }); + + it('falls back to a summary-seeded fresh launch when the resume fails', async () => { + // An adapter that fails the fork-resume of the (externally-created) conversation but succeeds a fresh + // launch — the exact "resume unavailable" case the AD-7 fallback covers. + const runCalls: { prompt: string; resume?: string; forkSession?: boolean }[] = []; + const fallbackAdapter: AgentAdapter = { + async run(prompt: string, opts: AgentRunOptions): Promise { + runCalls.push({ + prompt, + ...(opts.resume !== undefined ? { resume: opts.resume } : {}), + ...(opts.forkSession !== undefined ? { forkSession: opts.forkSession } : {}), + }); + if (opts.resume !== undefined) { + throw new Error('cannot resume an externally-created conversation'); + } + opts.onEvent({ type: 'message', text: 'Continuing from the handover summary.' }); + return { intercepted: [], allowed: [], denied: [], sessionId: 'fresh-sdk-id' }; + }, + }; + await start(fallbackAdapter); + await adopt(relay, socketPath); + + const stop = hookRpc(socketPath, { + hook_event_name: 'Stop', + session_id: CLAUDE_SESSION, + cwd: '/repo', + last_assistant_message: 'Which database should we use for the app?', + }); + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + const { requestId } = offer.payload as { requestId: string }; + await stop; + + relay.send( + makeEnvelope({ + type: 'handover.answer', + userId: USER, + deviceId: DEVICE, + sessionId: PARENT_SESSION, + payload: { requestId, answerText: 'Use Postgres.' }, + }), + ); + + const chained = await relay.waitForFrame((e) => e.type === 'session.chained'); + const { clientRef } = chained.payload as { clientRef: string }; + relay.send( + makeEnvelope({ + type: 'session.chained', + userId: USER, + deviceId: DEVICE, + sessionId: CHILD_SESSION, + payload: { clientRef, parentSessionId: PARENT_SESSION }, + }), + ); + + // The child continuation still completes (done, not error) — via the fresh-launch fallback. + const childEnded = await relay.waitForFrame( + (e) => e.type === 'session.ended' && e.session_id === CHILD_SESSION, + ); + expect(childEnded.status).toBe('done'); + + // Two adapter runs: the failed fork-resume, then a fresh launch (no resume) seeded with the handover + // context — carrying the exact question and the user's answer so the fresh conversation continues. + await vi.waitFor(() => expect(runCalls).toHaveLength(2)); + expect(runCalls[0]).toMatchObject({ resume: CLAUDE_SESSION, forkSession: true }); + // vi.waitFor guaranteed length 2, so runCalls[1] exists — assert non-null so a regression can't slip past. + expect(runCalls[1]!.resume).toBeUndefined(); + expect(runCalls[1]!.forkSession).toBeUndefined(); + expect(runCalls[1]!.prompt).toContain('Which database should we use for the app?'); + expect(runCalls[1]!.prompt).toContain('Use Postgres.'); + }); + + it('does not offer a handover on a non-question Stop (only the free-form question offers)', async () => { + await start(createFakeAgentAdapter([])); + await adopt(relay, socketPath); + + // A non-question turn end must NOT offer. A later free-form question then must — and the FIRST (only) + // handover frame carrying the question text proves the non-question Stop produced no offer (otherwise + // it would arrive first, or park the session at awaiting_input and suppress this one). + await hookRpc(socketPath, stopEvent('All done — the refactor is complete and tests pass.')); + await hookRpc(socketPath, stopEvent('Which database should we use for the app?')); + + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + expect((offer.payload as { question: string }).question).toBe( + 'Which database should we use for the app?', + ); + }); + + it('does not offer on a re-entrant Stop (stop_hook_active guard)', async () => { + await start(createFakeAgentAdapter([])); + await adopt(relay, socketPath); + + await hookRpc(socketPath, stopEvent('Should I keep going?', { stop_hook_active: true })); + await hookRpc(socketPath, stopEvent('Which region should we deploy to?')); + + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + expect((offer.payload as { question: string }).question).toBe( + 'Which region should we deploy to?', + ); + }); + + it('carries a deterministic summary of recent context from the transcript', async () => { + await start(createFakeAgentAdapter([])); + // A transcript with prior context the summary should extract (Claude JSONL record shapes). + const transcriptPath = join(dir, 'transcript.jsonl'); + await writeFile( + transcriptPath, + `${[ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'Add a REST API for orders.' }, + }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'I scaffolded the routes.' }], + }, + }), + ].join('\n')}\n`, + ); + + // Adopt via a PreToolUse carrying the transcript path (so the daemon mirrors the prior context). + const first = hookRpc(socketPath, { + hook_event_name: 'PreToolUse', + session_id: CLAUDE_SESSION, + cwd: '/repo', + transcript_path: transcriptPath, + tool_name: 'Read', + tool_input: {}, + }); + ackAdopted(relay, await relay.waitForFrame((e) => e.type === 'session.adopted')); + await first; + + await hookRpc( + socketPath, + stopEvent('Which database should we use?', { transcript_path: transcriptPath }), + ); + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + const { summary } = offer.payload as { summary: string }; + expect(summary).toContain('User: Add a REST API for orders.'); + expect(summary).toContain('Assistant: I scaffolded the routes.'); + }); + + it('sends the agent.handover offer as ciphertext to the relay (invariant #5)', async () => { + const keyPair = await generateKeyPair(); + await start(createFakeAgentAdapter([]), { + publicKey: encodeKey(keyPair.publicKey), + privateKey: encodeKey(keyPair.privateKey), + }); + await adopt(relay, socketPath); + + await hookRpc(socketPath, stopEvent('Which database should we use for the app?')); + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + + // The offer carries the free-form question (potentially sensitive prompt text) + a transcript summary — + // both opaque to the relay: a ciphertext string with a non-empty nonce, never the cleartext object. + expect(offer.nonce).not.toBe(''); + expect(typeof offer.payload).toBe('string'); + }); + + it('does not offer a handover while a permission gate is already pending', async () => { + await start(createFakeAgentAdapter([])); + await adopt(relay, socketPath); + + // A consequential tool blocks on the approval gate → the session is awaiting_input. + const gate = hookRpc(socketPath, { + hook_event_name: 'PreToolUse', + session_id: CLAUDE_SESSION, + cwd: '/repo', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 't1', + }); + gate.catch(() => undefined); + const request = await relay.waitForFrame((e) => e.type === 'agent.permission_request'); + const requestId = (request.payload as { requestId: string }).requestId; + + // While a gate is showing, a free-form Stop must NOT stack a handover offer on top of it. + await hookRpc(socketPath, stopEvent('Should I proceed with plan A?')); + + // Resolve the gate → running again → a later question offers, and the FIRST handover frame carries + // THAT question — proving the Stop-during-gate above produced no offer. + relay.send( + makeEnvelope({ + type: 'permission.decision', + userId: USER, + deviceId: DEVICE, + sessionId: PARENT_SESSION, + payload: { requestId, behavior: 'allow' }, + }), + ); + await gate; + await hookRpc(socketPath, stopEvent('Which region should we deploy to?')); + + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + expect((offer.payload as { question: string }).question).toBe( + 'Which region should we deploy to?', + ); + }); + + it('does not offer for a denylisted cwd, honoring a mid-session policy change', async () => { + await start(createFakeAgentAdapter([])); + await adopt(relay, socketPath); + + // The user denylists /repo AFTER adoption — a handover (which launches a new session) must be gated. + await setAdoptConfig(true, ['/repo']); + await hookRpc(socketPath, stopEvent('Which database should we use?')); + + // Re-allow, then a question offers — the FIRST handover carries THIS question, proving the denylisted + // Stop above produced no offer. + await setAdoptConfig(true, []); + await hookRpc(socketPath, stopEvent('Which region should we deploy to?')); + + const offer = await relay.waitForFrame((e) => e.type === 'agent.handover'); + expect((offer.payload as { question: string }).question).toBe( + 'Which region should we deploy to?', + ); + }); +}); diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 5f799b6..a0939ae 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -7,11 +7,13 @@ import WebSocket from 'ws'; import { adoptConfigPayloadSchema, echoPayloadSchema, + handoverAnswerPayloadSchema, makeEnvelope, parseEnvelope, permissionDecisionPayloadSchema, questionAnswerPayloadSchema, sessionAdoptedPayloadSchema, + sessionChainedPayloadSchema, sessionControlPayloadSchema, sessionEndedPayloadSchema, sessionLaunchPayloadSchema, @@ -34,7 +36,10 @@ import { import { DEFAULT_ADOPT_SETTINGS, loadAdoptConfig, saveAdoptConfig } from './adopt/adopt-config'; import { createAdoptedSessionManager, type AdoptedSessionManager } from './adopt/adopted-sessions'; import { type HookEvent } from './adopt/hook-event'; +import { buildHandoverFallbackPrompt } from './adopt/handover-fallback-prompt'; +import { buildHandoverSummary } from './adopt/handover-summary'; import { isAdoptionAllowed } from './adopt/is-adoption-allowed'; +import { isFreeFormQuestion } from './adopt/free-form-question'; import { createHookSocketServer, type HookSocketServer } from './adopt/hook-socket'; import { preToolUseOutput } from './adopt/pretooluse-output'; import { buildQuestionDenyReason } from './adopt/question-deny-reason'; @@ -42,6 +47,8 @@ import { questionsFromToolInput } from './adopt/question-from-tool-input'; import { createTranscriptMirror, type TranscriptMirror } from './adopt/transcript-mirror'; import { type AgentAdapter, + type AgentRunOptions, + type AgentRunResult, type PermissionDecision, type PermissionRequest, } from './agent-adapter'; @@ -61,6 +68,9 @@ import { type WorktreeManager } from './sessions/worktree-manager'; * human-in-the-loop gate: the daemon forwards it as `agent.permission_request` and blocks `canUseTool` * until the matching `permission.decision` returns from the browser. */ +/** How much of a free-form question to preview in a handover continuation's title (UI readability budget). */ +const HANDOVER_TITLE_PREVIEW_CHARS = 60; + export interface DaemonOptions { readonly relayUrl: string; readonly userId: string; @@ -149,6 +159,9 @@ export function createDaemon(options: DaemonOptions): Daemon { let reconnectTimer: ReturnType | null = null; const reconnectBaseMs = options.reconnect?.baseMs ?? 500; const reconnectMaxMs = options.reconnect?.maxMs ?? 10_000; + // How long to await the relay's `session.chained` ACK for a handover continuation before falling back + // (reuses the adopted-session ack timeout knob so tests can shrink it). Defaults to 10s. + const chainAckTimeoutMs = options.adopt?.ackTimeoutMs ?? 10_000; // Outbound frames are built asynchronously (encryption is async), so we serialize them through a chain // to preserve stream order — a later `agent.message` must never overtake the `session.key` / @@ -178,6 +191,24 @@ export function createDaemon(options: DaemonOptions): Daemon { string, { sessionId: string | undefined; resolve: (answers: QuestionAnswerItem[] | null) => void } >(); + // Free-form handover offers awaiting the user's answer (Journey 4), keyed by the correlation id sent to + // the browser as `agent.handover`. Unlike `pendingPermissions`/`pendingQuestions` this is NOT a blocking + // gate (the `Stop` hook already returned — the external process is idle): it is action-context storage so a + // later `handover.answer` can fork-resume the right conversation (AD-J4-7). `externalSessionId` is the + // adopted Claude `session_id` the fork resumes; `cwd` is the conversation's working directory. + const pendingHandovers = new Map< + string, + { + telecodeSessionId: string; + externalSessionId: string; + cwd: string | undefined; + question: string; + summary: string; + } + >(); + // Forked handover continuations awaiting the relay's minted child id, keyed by the daemon's clientRef; the + // relay's `session.chained` ACK resolves the matching one (mirrors the adopted-session announce/ack pattern). + const pendingChainRegistrations = new Map void>(); // The agent conversation id per telecode session, so a `user.message` follow-up resumes the same chat. const sdkSessions = new Map(); // The worktree cwd each session runs in, so every turn (launch + follow-ups) uses the same one. @@ -490,18 +521,35 @@ export function createDaemon(options: DaemonOptions): Daemon { } } + /** Optional per-turn configuration for {@link runTurn}. Ordinary launches/follow-ups leave most unset. */ + interface TurnOptions { + /** Continue a prior agent conversation (from an earlier run's returned conversation id). */ + readonly resume?: string; + /** The session's worktree cwd; omitted runs in the daemon cwd. */ + readonly cwd?: string; + /** Fork the resumed conversation into a new one (free-form handover — a new id + its own transcript). */ + readonly forkSession?: boolean; + /** + * Journey 4: when `resume` is set and the resume run fails — e.g. the SDK can't pick up an + * *externally-created* conversation (transcript gone, version skew) — the turn re-runs as a FRESH launch + * (no resume/fork) seeded with this prompt, so a free-form handover still continues instead of dropping + * the user's answer. Only used for the handover continuation; ordinary turns leave it unset. + */ + readonly resumeFallbackPrompt?: string; + } + /** * Run one agent turn (the initial prompt or a follow-up) and stream its activity up, then end the - * turn with `session.ended`. `resume` continues a prior agent conversation. The returned conversation - * id is stored so the next `user.message` follow-up resumes this same session. One turn at a time per - * session — a follow-up that races an in-flight turn is dropped (the UI also blocks it). + * turn with `session.ended`. The returned conversation id is stored so the next `user.message` follow-up + * resumes this same session. One turn at a time per session — a follow-up that races an in-flight turn is + * dropped (the UI also blocks it). {@link TurnOptions} carries the optional per-turn configuration. */ async function runTurn( envelope: Envelope, prompt: string, - resume?: string, - cwd?: string, + turn: TurnOptions = {}, ): Promise { + const { resume, cwd, forkSession, resumeFallbackPrompt } = turn; const sessionId = envelope.session_id; if (sessionId !== undefined && activeRuns.has(sessionId)) { log.warn({ deviceId: options.deviceId, sessionId }, 'daemon: turn already running; dropped'); @@ -515,26 +563,45 @@ export function createDaemon(options: DaemonOptions): Daemon { } const permissionMode = sessionId !== undefined ? sessionRecords.get(sessionId)?.permissionMode : undefined; + // Options shared by both the resume attempt and the seeded-fresh fallback (only prompt/resume/fork differ). + const baseOptions: Omit = { + canUseTool: (request) => requestPermission(envelope, request), + signal: abort.signal, + ...(cwd !== undefined ? { cwd } : {}), + ...(permissionMode !== undefined ? { permissionMode } : {}), + onEvent: (event) => { + if (event.type === 'message') { + record(sessionId, { kind: 'message', text: event.text }); + sendForSession(envelope, 'agent.message', { text: event.text }); + } else { + record(sessionId, { kind: 'tool', toolName: event.toolName, input: event.input }); + sendForSession(envelope, 'agent.tool_use', { + toolName: event.toolName, + input: event.input, + }); + } + }, + }; try { - const result = await agentAdapter.run(prompt, { - canUseTool: (request) => requestPermission(envelope, request), - signal: abort.signal, - ...(cwd !== undefined ? { cwd } : {}), - ...(permissionMode !== undefined ? { permissionMode } : {}), - onEvent: (event) => { - if (event.type === 'message') { - record(sessionId, { kind: 'message', text: event.text }); - sendForSession(envelope, 'agent.message', { text: event.text }); - } else { - record(sessionId, { kind: 'tool', toolName: event.toolName, input: event.input }); - sendForSession(envelope, 'agent.tool_use', { - toolName: event.toolName, - input: event.input, - }); - } - }, - ...(resume !== undefined ? { resume } : {}), - }); + let result: AgentRunResult; + try { + result = await agentAdapter.run(prompt, { + ...baseOptions, + ...(resume !== undefined ? { resume } : {}), + ...(forkSession !== undefined ? { forkSession } : {}), + }); + } catch (resumeErr) { + // A failed resume of an externally-created conversation is recoverable: continue the handover as a + // fresh, summary-seeded launch so the user's answer isn't lost. An operator abort is not recoverable. + if (abort.signal.aborted || resume === undefined || resumeFallbackPrompt === undefined) { + throw resumeErr; + } + log.warn( + { err: resumeErr, deviceId: options.deviceId, sessionId }, + 'daemon: resume failed — falling back to a summary-seeded fresh launch', + ); + result = await agentAdapter.run(resumeFallbackPrompt, baseOptions); + } if (sessionId !== undefined && result.sessionId !== undefined) { sdkSessions.set(sessionId, result.sessionId); } @@ -672,7 +739,7 @@ export function createDaemon(options: DaemonOptions): Daemon { 'session.started', launch.data.clientRef !== undefined ? { clientRef: launch.data.clientRef } : {}, ); - await runTurn(envelope, launch.data.prompt, undefined, cwd); + await runTurn(envelope, launch.data.prompt, { ...(cwd !== undefined ? { cwd } : {}) }); } /** Run a follow-up turn for an existing session by resuming its agent conversation. */ @@ -704,7 +771,7 @@ export function createDaemon(options: DaemonOptions): Daemon { setStatus(sessionId, 'running'); // Reuse the session's worktree cwd (set on launch) so the follow-up turn runs in the same place. const cwd = sessionId !== undefined ? sessionCwds.get(sessionId) : undefined; - await runTurn(envelope, message.data.text, resume, cwd); + await runTurn(envelope, message.data.text, { resume, ...(cwd !== undefined ? { cwd } : {}) }); } async function handleFrame(raw: Buffer, onReady: () => void): Promise { @@ -874,6 +941,10 @@ export function createDaemon(options: DaemonOptions): Daemon { pending.resolve(answer.data.answers); return; } + case 'handover.answer': { + await handleHandoverAnswer(envelope); + return; + } case 'session.control': { const control = sessionControlPayloadSchema.safeParse(await readSessionPayload(envelope)); if (!control.success) { @@ -890,6 +961,10 @@ export function createDaemon(options: DaemonOptions): Daemon { handleAdoptedAck(envelope); return; } + case 'session.chained': { + handleChainedAck(envelope); + return; + } case 'adopt.config': { await handleAdoptConfig(envelope); return; @@ -1000,6 +1075,60 @@ export function createDaemon(options: DaemonOptions): Daemon { } } + /** + * Announce a forked handover continuation (Journey 4) to the relay and await the minted child id. The + * relay mints an `origin='launched'` row linked to `parentSessionId` and ACKs with `session.chained` + * (carrying the child's id + our clientRef, resolved in {@link handleChainedAck}). Symmetric with the + * adopted-session announce/ack. Rejects if no ACK arrives within `ackTimeoutMs` so the caller can fall + * back rather than hang. Routing metadata only (cleartext), like `session.adopted`. + */ + function registerChained(payload: { + clientRef: string; + parentSessionId: string; + title?: string; + cwd?: string; + }): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingChainRegistrations.delete(payload.clientRef); + reject(new Error('session.chained ack timed out')); + }, chainAckTimeoutMs); + if (typeof timer.unref === 'function') timer.unref(); + pendingChainRegistrations.set(payload.clientRef, (childSessionId) => { + clearTimeout(timer); + resolve(childSessionId); + }); + enqueueSend(async () => + JSON.stringify( + makeEnvelope({ + type: 'session.chained', + userId: options.userId, + deviceId: options.deviceId, + payload, + }), + ), + ); + }); + } + + /** Pair the relay's `session.chained` ACK (minted child id on the envelope, our clientRef echoed) to its + * pending registration. Only resolves a clientRef we are awaiting, so a forged ACK can't inject a child. */ + function handleChainedAck(envelope: Envelope): void { + if (envelope.session_id === undefined) return; + const ack = sessionChainedPayloadSchema.safeParse(envelope.payload); + if (!ack.success) { + log.warn({ deviceId: options.deviceId }, 'daemon: malformed session.chained ack — dropping'); + return; + } + const resolve = pendingChainRegistrations.get(ack.data.clientRef); + if (resolve) { + pendingChainRegistrations.delete(ack.data.clientRef); + resolve(envelope.session_id); + } else { + log.warn({ deviceId: options.deviceId }, 'daemon: unexpected session.chained ack — dropping'); + } + } + /** * Handle a sealed `adopt.config` (web → daemon, Journey 3): open it under the device shared key, optionally * persist + apply a new policy, then reply the current policy as `adopt.state` sealed back to the requesting @@ -1106,12 +1235,129 @@ export function createDaemon(options: DaemonOptions): Daemon { } /** - * Handle one hook event from the bridge (the T4 socket calls this). Adopt the session (announce + await - * the relay's minted id), mirror its transcript, and for a `PreToolUse` route the tool through telecode's - * existing gate: a read-only tool auto-allows; a consequential one blocks on the browser's decision. - * FAIL-CLOSED (AD-2): any failure returns `ask` / `{}`, so Claude Code falls back to its own local prompt - * — never an auto-allow of a consequential tool because adoption hit a snag. + * Handle a `handover.answer` (Journey 4): the user chose to take over an adopted session's free-form + * question. This is an action trigger, not a gate resolution — it launches a forked telecode-owned + * continuation (see {@link launchHandoverContinuation}) and marks the parent handed-off. A duplicate/late + * answer (already-settled offer) reconciles with the authoritative session state so the browser's + * "taking over…" doesn't hang. */ + async function handleHandoverAnswer(envelope: Envelope): Promise { + const answer = handoverAnswerPayloadSchema.safeParse(await readSessionPayload(envelope)); + if (!answer.success) { + log.warn( + { deviceId: options.deviceId }, + 'daemon: dropped handover.answer with invalid payload', + ); + return; + } + const handover = pendingHandovers.get(answer.data.requestId); + if (!handover) { + log.info( + { deviceId: options.deviceId, requestId: answer.data.requestId }, + 'daemon: handover.answer for a settled offer — reconciling with session state', + ); + sendForSession(envelope, 'session.history', historyPayloadFor(envelope.session_id)); + return; + } + pendingHandovers.delete(answer.data.requestId); + // Record the answer on the parent's handover entry so a later backfill shows it resolved. + const parentId = handover.telecodeSessionId; + const entry = sessionRecords + .get(parentId) + ?.transcript.find( + (e): e is Extract => + e.kind === 'handover' && e.requestId === answer.data.requestId, + ); + if (entry) entry.answerText = answer.data.answerText; + log.info( + { deviceId: options.deviceId, sessionId: parentId, requestId: answer.data.requestId }, + 'daemon: handover accepted — launching continuation', + ); + // Fire-and-forget: the launch runs a long agent turn, so it must not block the inbound frame chain. Its + // own paths are exception-safe, but the `.catch` guards against a synchronous throw so an error can never + // vanish silently (a detached `void` promise is not covered by the inbound chain's catch). + void launchHandoverContinuation( + adoptedSource(parentId), + handover, + answer.data.answerText, + ).catch((err: unknown) => + log.error( + { err, deviceId: options.deviceId, sessionId: parentId }, + 'daemon: handover continuation failed', + ), + ); + } + + /** + * Take over an adopted session's free-form question (Journey 4): launch a forked, telecode-OWNED + * continuation that resumes the adopted conversation (`resume` + `forkSession`, from the spike) with the + * user's answer as its next turn, and mark the parent adopted row handed-off (read-only, linked). The fork + * inherits full context but gets a NEW session id + its own transcript, so it never writes into the still- + * live external process's transcript. `source` is the parent's source envelope (for its `session.ended`). + */ + async function launchHandoverContinuation( + source: Envelope, + handover: { + telecodeSessionId: string; + externalSessionId: string; + cwd: string | undefined; + question: string; + summary: string; + }, + answerText: string, + ): Promise { + const parentId = handover.telecodeSessionId; + const clientRef = randomUUID(); + const title = `Continue: ${handover.question.slice(0, HANDOVER_TITLE_PREVIEW_CHARS)}`; + let childId: string; + try { + childId = await registerChained({ + clientRef, + parentSessionId: parentId, + title, + ...(handover.cwd !== undefined ? { cwd: handover.cwd } : {}), + }); + } catch (err) { + // The relay never minted the child (offline / dropped). Leave the parent as-is; a later answer can + // retry. (Journey 4 T2 adds the summary-seeded fresh-launch fallback for a resume that errors.) + log.warn( + { err, deviceId: options.deviceId, sessionId: parentId }, + 'daemon: handover continuation registration failed', + ); + return; + } + const childSource = adoptedSource(childId); + // The child is a telecode-owned launched session; encrypt its frames under E2E (the browser receives the + // content key when it subscribes, exactly like an adopted session). Cleartext only on a pre-E2E daemon. + if (cipher.enabled) cipher.establish(childId); + recordFor(childId).permissionMode = 'default'; + record(childId, { kind: 'user', text: answerText }); + setStatus(childId, 'running'); + sendForSession(childSource, 'session.started', {}); + // Migrate the conversation: the parent adopted row is handed off (terminal, read-only) — the child now + // carries it forward. Ended promptly (before the long turn) so the dashboard reflects the migration. + setStatus(parentId, 'done'); + sendForSession(source, 'session.ended', { status: 'done' }); + log.info( + { deviceId: options.deviceId, parentSessionId: parentId, sessionId: childId }, + 'daemon: handover continuation launched (forked resume)', + ); + // Run the forked continuation: resume the adopted conversation by its Claude id, fork it, seed the answer. + // If the resume fails (an externally-created conversation the SDK can't pick up), fall back to a fresh + // launch seeded with the handover context so the answer still lands (AD-7 fallback). + const resumeFallbackPrompt = buildHandoverFallbackPrompt( + handover.summary, + handover.question, + answerText, + ); + await runTurn(childSource, answerText, { + resume: handover.externalSessionId, + ...(handover.cwd !== undefined ? { cwd: handover.cwd } : {}), + forkSession: true, + resumeFallbackPrompt, + }); + } + /** * SessionEnd (Journey 3): the Claude Code process exited. End the adopted session if we are tracking it — * never force-adopt an unknown session just to end it (no phantom row). Until now adopted sessions never @@ -1160,11 +1406,73 @@ export function createDaemon(options: DaemonOptions): Daemon { return {}; } + /** + * Stop (Journey 4): the adopted session ended its turn. If its last assistant message looks like a + * free-form question (heuristic, {@link isFreeFormQuestion}), offer to take it over: emit a non-blocking + * `agent.handover` carrying the exact question + a handover summary, park the session at `awaiting_input`, + * and remember the context so a later `handover.answer` can fork-resume the conversation. NON-blocking — + * the hook returns `{}` immediately (the idle external process is never held). Acts only on a tracked + * session; skips the re-entrancy case (`stop_hook_active`) and never offers twice (already awaiting input). + */ + async function handleStopHook(event: HookEvent): Promise { + const knownId = adoptedSessions?.telecodeIdFor(event.session_id); + if (knownId === undefined) return {}; + if (event.stop_hook_active === true) return {}; + // Honor the CURRENT adoption policy: offering a handover launches a NEW telecode-owned session, so a repo + // the user has since denylisted (or adoption turned off) must not get an offer — even though the session + // was tracked before that change. Denied → stay out; the session keeps running via Claude Code locally. + if (!isAdoptionAllowed(adoptConfig, event.cwd)) return {}; + if (!isFreeFormQuestion(event.last_assistant_message)) return {}; + // Don't stack a second offer while a gate/offer is already showing for this session. + if (recordFor(knownId).status === 'awaiting_input') return {}; + const question = (event.last_assistant_message ?? '').trim(); + if (cipher.enabled) cipher.establish(knownId); // idempotent; the mirror + offer must encrypt under E2E + // Pull the turn's latest transcript lines in before summarizing, so the handover summary reflects the + // most recent context (a `Stop` fires after the last turn, which no `PreToolUse` may have mirrored yet). + // Fail-closed: an unreadable transcript skips the offer (the session simply stays running locally). + try { + await mirrorTranscript(knownId, event.transcript_path); + } catch (err) { + log.warn( + { err, deviceId: options.deviceId, sessionId: knownId }, + 'daemon: transcript mirror failed in Stop hook — skipping the handover offer', + ); + return {}; + } + // Deterministic "what the session was doing" summary from the mirrored transcript — no extra model call. + const summary = buildHandoverSummary(recordFor(knownId).transcript); + const requestId = randomUUID(); + pendingHandovers.set(requestId, { + telecodeSessionId: knownId, + externalSessionId: event.session_id, + cwd: event.cwd, + question, + summary, + }); + record(knownId, { kind: 'handover', requestId, question, summary }); + setStatus(knownId, 'awaiting_input'); + sendForSession(adoptedSource(knownId), 'agent.handover', { requestId, question, summary }); + log.info( + { deviceId: options.deviceId, sessionId: knownId, requestId }, + 'daemon: free-form handover offered', + ); + return {}; + } + + /** + * Handle one hook event from the bridge (the socket calls this). Lifecycle events (SessionEnd / + * Notification / Stop) act only on a session we already track. Otherwise adopt the session (announce + + * await the relay's minted id), mirror its transcript, and for a `PreToolUse` route the tool through + * telecode's existing gate: a read-only tool auto-allows; a consequential one blocks on the browser's + * decision. FAIL-CLOSED (AD-2): any failure returns `ask` / `{}`, so Claude Code falls back to its own + * local prompt — never an auto-allow of a consequential tool because adoption hit a snag. + */ async function handleHookEvent(event: HookEvent): Promise { if (!adoptedSessions) return {}; // Lifecycle events act only on a session we already track (never force-adopt to handle them). if (event.hook_event_name === 'SessionEnd') return handleSessionEndHook(event); if (event.hook_event_name === 'Notification') return handleNotificationHook(event); + if (event.hook_event_name === 'Stop') return handleStopHook(event); // Adoption policy gate (Journey 3): for a session we are NOT already tracking, apply the per-machine // policy — if adoption is disabled or this project is on the denylist, telecode stays out entirely and @@ -1290,6 +1598,11 @@ export function createDaemon(options: DaemonOptions): Daemon { resolve(null); } pendingQuestions.clear(); + // Handover offers (Journey 4) are non-blocking — no hook is parked on them, so there is nothing to + // resolve; just discard the context. Pending chain registrations can't be ACKed on a closed socket; + // their timers are already `unref`'d, so clearing is for symmetry/tidiness. + pendingHandovers.clear(); + pendingChainRegistrations.clear(); await hookSocket?.stop(); socket?.close(); socket = null; diff --git a/packages/daemon/src/doctor.test.ts b/packages/daemon/src/doctor.test.ts index 9cb26e7..0913d4d 100644 --- a/packages/daemon/src/doctor.test.ts +++ b/packages/daemon/src/doctor.test.ts @@ -22,7 +22,7 @@ function deps(overrides: Partial = {}): DoctorDeps { probeRelay: async () => ({ ok: true }), adoptionHooks: async () => ({ installed: true, - events: ['PreToolUse', 'SessionStart', 'SessionEnd', 'Notification'], + events: ['PreToolUse', 'SessionStart', 'SessionEnd', 'Notification', 'Stop'], }), ...overrides, }; @@ -63,6 +63,8 @@ describe('runDoctor', () => { const check = find(await runDoctor(deps()), 'Adopted sessions'); expect(check?.status).toBe('pass'); expect(check?.detail).toContain('SessionEnd'); + // The free-form handover detector (Journey 4) is part of the installed set. + expect(check?.detail).toContain('Stop'); }); it('warns (does not fail) the adoption check when hooks are not installed', async () => { diff --git a/packages/protocol/src/envelope.test.ts b/packages/protocol/src/envelope.test.ts index 895f7e1..3979094 100644 --- a/packages/protocol/src/envelope.test.ts +++ b/packages/protocol/src/envelope.test.ts @@ -127,6 +127,13 @@ describe('envelope E2E routing-metadata fields (Phase 3)', () => { it('recognizes session.adopted as a valid message type (adopted sessions)', () => { expect(safeParseEnvelope({ ...validWire, type: 'session.adopted' }).success).toBe(true); }); + + it.each(['agent.handover', 'handover.answer', 'session.chained'] as const)( + 'recognizes %s as a valid message type (Journey 4)', + (type) => { + expect(safeParseEnvelope({ ...validWire, type }).success).toBe(true); + }, + ); }); describe('makeEnvelope routing-metadata fields', () => { diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts index 3c9b7b2..ad7d399 100644 --- a/packages/protocol/src/envelope.ts +++ b/packages/protocol/src/envelope.ts @@ -29,6 +29,10 @@ export const MESSAGE_TYPES = [ // adopted sessions (daemon -> relay -> web): the daemon announces an externally-started Claude Code // session it discovered via the hooks bridge, so the relay mints a registry row (origin='external'). 'session.adopted', + // free-form handover (daemon -> relay -> web): the daemon registers a telecode-OWNED continuation that + // resumes an adopted conversation the user chose to take over remotely (origin='launched'), linked to the + // adopted row via `parent_session_id`. Symmetric with `session.adopted`, but the child is launched. (J4.) + 'session.chained', // agent stream (daemon -> web) 'agent.message', 'agent.tool_use', @@ -39,6 +43,10 @@ export const MESSAGE_TYPES = [ // adopted-session attention signal (daemon -> web): Claude Code's `Notification` (e.g. went idle waiting // for input) surfaced as a non-blocking "needs a look" cue. No answer required (Journey 3). 'agent.notice', + // free-form handover offer (daemon -> web, Journey 4): an adopted session ended its turn asking a + // free-form question (no tool call, so no gate). Non-blocking — carries the exact question + a handover + // summary so the browser can offer to take the conversation over by resuming it under telecode's control. + 'agent.handover', // adoption policy (Journey 3), session-less + box-sealed so the relay never sees repo paths: // `adopt.config` (web -> daemon) reads/sets the per-machine enabled + denylist; `adopt.state` // (daemon -> web) reports the current policy back to the requesting browser. @@ -48,6 +56,9 @@ export const MESSAGE_TYPES = [ 'permission.decision', // the human's pick for an `agent.question`, relayed to the model as deny-feedback (web -> daemon). 'question.answer', + // the human's answer to an `agent.handover` (web -> daemon, Journey 4): triggers the daemon to launch a + // forked telecode-owned continuation that resumes the adopted conversation with this answer as its turn. + 'handover.answer', 'user.message', // per-session controls (web -> daemon): end / interrupt / pause / resume 'session.control', diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index a74beba..8884f63 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -82,6 +82,9 @@ export { agentQuestionPayloadSchema, questionAnswerItemSchema, questionAnswerPayloadSchema, + agentHandoverPayloadSchema, + handoverAnswerPayloadSchema, + sessionChainedPayloadSchema, userMessagePayloadSchema, sessionControlActionSchema, sessionControlPayloadSchema, @@ -113,6 +116,9 @@ export type { AgentQuestionPayload, QuestionAnswerItem, QuestionAnswerPayload, + AgentHandoverPayload, + HandoverAnswerPayload, + SessionChainedPayload, UserMessagePayload, SessionControlAction, SessionControlPayload, diff --git a/packages/protocol/src/session.test.ts b/packages/protocol/src/session.test.ts index 6eedd26..095848c 100644 --- a/packages/protocol/src/session.test.ts +++ b/packages/protocol/src/session.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'; import { adoptConfigPayloadSchema, adoptStatePayloadSchema, + agentHandoverPayloadSchema, agentNoticePayloadSchema, agentPermissionRequestPayloadSchema, agentQuestionPayloadSchema, + handoverAnswerPayloadSchema, permissionDecisionPayloadSchema, questionAnswerPayloadSchema, sessionAdoptedPayloadSchema, + sessionChainedPayloadSchema, sessionControlPayloadSchema, sessionHistoryPayloadSchema, sessionKeyPayloadSchema, @@ -466,6 +469,124 @@ describe('sessionAdoptedPayloadSchema (adopted sessions)', () => { }); }); +/** + * The free-form handover messages (Journey 4 / Tier 4). When an adopted session ends its turn asking a + * free-form question, telecode offers to take it over by resuming the conversation under its own control. + * `agent.handover` (daemon → web) is a NON-blocking offer carrying the exact question + a handover summary; + * `handover.answer` (web → daemon) carries the user's answer, which triggers a forked telecode-owned + * continuation; `session.chained` (daemon → relay → browser) registers that continuation linked to the + * adopted row via `parentSessionId`. + */ +describe('agentHandoverPayloadSchema (free-form handover offer)', () => { + it('parses an offer carrying the exact question and a summary', () => { + const parsed = agentHandoverPayloadSchema.parse({ + requestId: 'h1', + question: 'Which database should we use for the app?', + summary: 'The session was scaffolding a new API and asked about storage.', + }); + expect(parsed.requestId).toBe('h1'); + expect(parsed.question).toContain('database'); + }); + + it('accepts an empty summary (deterministic extraction may find little context)', () => { + expect( + agentHandoverPayloadSchema.parse({ requestId: 'h1', question: 'Ready?', summary: '' }) + .summary, + ).toBe(''); + }); + + it('rejects a missing correlation id or an empty question', () => { + expect( + agentHandoverPayloadSchema.safeParse({ requestId: '', question: 'q', summary: '' }).success, + ).toBe(false); + expect( + agentHandoverPayloadSchema.safeParse({ requestId: 'h1', question: '', summary: '' }).success, + ).toBe(false); + }); +}); + +describe('handoverAnswerPayloadSchema (the user takes over remotely)', () => { + it('parses the free-text answer that seeds the resumed turn', () => { + const parsed = handoverAnswerPayloadSchema.parse({ + requestId: 'h1', + answerText: 'Use Postgres.', + }); + expect(parsed.answerText).toBe('Use Postgres.'); + }); + + it('rejects an empty answer or a missing correlation id', () => { + expect(handoverAnswerPayloadSchema.safeParse({ requestId: 'h1', answerText: '' }).success).toBe( + false, + ); + expect(handoverAnswerPayloadSchema.safeParse({ requestId: '', answerText: 'x' }).success).toBe( + false, + ); + }); +}); + +describe('sessionChainedPayloadSchema (forked continuation registration)', () => { + // parentSessionId is a relay-minted session id — validated as a UUID on the wire. + const PARENT = '11111111-1111-1111-1111-111111111111'; + + it('parses a child registration linked to its parent', () => { + const parsed = sessionChainedPayloadSchema.parse({ + clientRef: 'fork-1', + parentSessionId: PARENT, + title: 'Continue: database choice', + cwd: '/repo', + }); + expect(parsed.parentSessionId).toBe(PARENT); + expect(parsed.clientRef).toBe('fork-1'); + }); + + it('rejects a parentSessionId that is not a UUID', () => { + expect( + sessionChainedPayloadSchema.safeParse({ clientRef: 'fork-1', parentSessionId: 'p' }).success, + ).toBe(false); + }); + + it('requires both the correlation ref and the parent link (title/cwd optional)', () => { + const parsed = sessionChainedPayloadSchema.parse({ + clientRef: 'fork-1', + parentSessionId: PARENT, + }); + expect(parsed.title).toBeUndefined(); + expect(sessionChainedPayloadSchema.safeParse({ clientRef: 'fork-1' }).success).toBe(false); + expect(sessionChainedPayloadSchema.safeParse({ parentSessionId: PARENT }).success).toBe(false); + expect( + sessionChainedPayloadSchema.safeParse({ clientRef: '', parentSessionId: PARENT }).success, + ).toBe(false); + }); +}); + +describe('sessionHistoryPayloadSchema — handover entry (Journey 4)', () => { + it('backfills a pending handover offer (question + summary, no answer yet)', () => { + const parsed = sessionHistoryPayloadSchema.parse({ + status: 'awaiting_input', + entries: [ + { kind: 'handover', requestId: 'h1', question: 'Which DB?', summary: 'scaffolding an API' }, + ], + }); + expect(parsed.entries[0]).toMatchObject({ kind: 'handover', requestId: 'h1' }); + }); + + it('backfills an answered handover (carries the answerText for the resolved state)', () => { + const parsed = sessionHistoryPayloadSchema.parse({ + status: 'done', + entries: [ + { + kind: 'handover', + requestId: 'h1', + question: 'Which DB?', + summary: '', + answerText: 'Postgres', + }, + ], + }); + expect(parsed.entries[0]).toMatchObject({ answerText: 'Postgres' }); + }); +}); + describe('sessionKeyPayloadSchema', () => { // A well-formed base64 32-byte content key (43 base64 chars + one `=` pad). const VALID_KEY = `${'A'.repeat(43)}=`; diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index 6dd9021..8972afb 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -283,6 +283,52 @@ export const questionAnswerPayloadSchema = z.object({ }); export type QuestionAnswerPayload = z.infer; +/** + * Payload for `agent.handover` (daemon → web, Journey 4 / Tier 4): an adopted session ended its turn asking + * a **free-form** question — prose, no tool call — so there is no `PreToolUse` gate to answer through. + * Rather than a dead "answer at your device" wall, telecode offers to take the conversation over: this + * message is a NON-blocking offer carrying the exact `question` (Claude Code's `Stop` hook + * `last_assistant_message`) and a deterministic `summary` of recent context. The human's reply + * ({@link handoverAnswerPayloadSchema}) launches a telecode-owned continuation that **resumes** the same + * conversation. `requestId` correlates the offer with its answer. `summary` may be empty (little context). + * Bounds keep a long transcript from bloating the encrypted frame. + */ +export const agentHandoverPayloadSchema = z.object({ + requestId: z.string().min(1), + question: z.string().min(1).max(8000), + summary: z.string().max(8000), +}); +export type AgentHandoverPayload = z.infer; + +/** + * Payload for `handover.answer` (web → daemon, Journey 4): the human's free-text answer to a pending + * {@link agentHandoverPayloadSchema}. It triggers the daemon to launch a forked, telecode-owned session + * that resumes the adopted conversation (`resume` + `forkSession`) with `answerText` as the next turn. + * Unlike `question.answer` this is an **action trigger**, not deny-feedback. `requestId` ties it to the offer. + */ +export const handoverAnswerPayloadSchema = z.object({ + requestId: z.string().min(1), + answerText: z.string().min(1).max(8000), +}); +export type HandoverAnswerPayload = z.infer; + +/** + * Payload for `session.chained` (daemon → relay → browser, Journey 4): the daemon registers the forked + * continuation that resumes an adopted conversation, so the relay mints a registry row (`origin: 'launched'`) + * linked to the adopted parent via `parentSessionId`. Symmetric with {@link sessionAdoptedPayloadSchema} — + * `clientRef` is the daemon's correlation token, echoed back with the minted telecode `session_id` — but the + * child is a telecode-owned launched session, and `parentSessionId` records the adopted → launched migration. + */ +export const sessionChainedPayloadSchema = z.object({ + clientRef: z.string().min(1).max(256), + // A relay-minted telecode session id (the adopted parent) — validated as a UUID at the boundary so a + // malformed value is rejected on parse rather than failing later against the `uuid` DB column. + parentSessionId: z.string().uuid(), + title: z.string().min(1).max(512).optional(), + cwd: z.string().min(1).max(1024).optional(), +}); +export type SessionChainedPayload = z.infer; + /** * Payload for `user.message` (web → daemon): a follow-up instruction the human sends to steer an * already-launched session. The daemon resumes the same agent conversation for the next turn (the @@ -337,6 +383,16 @@ export const sessionHistoryEntrySchema = z.discriminatedUnion('kind', [ questions: z.array(agentQuestionItemSchema).min(1), answers: z.array(questionAnswerItemSchema).optional(), }), + // A free-form handover offer (Journey 4). Carries the exact question + summary so a replay can render the + // "continue here" card; `answerText` is present once the human took it over (resolved) and absent while + // the offer is still open — the same decided-vs-pending distinction as `permission` / `question` entries. + z.object({ + kind: z.literal('handover'), + requestId: z.string().min(1), + question: z.string().min(1), + summary: z.string(), + answerText: z.string().min(1).optional(), + }), ]); export type SessionHistoryEntry = z.infer;