From 20422d519a3ad1ead04a4bdeccdee469b53891c1 Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Sat, 26 Sep 2026 19:46:03 +0300 Subject: [PATCH 1/2] fix(terminal): restore each Codex card to its own conversation --- src/agent-runtime/hook-helper.mjs | 9 + src/agent-runtime/runtime-client.mjs | 13 +- src/main/index.ts | 3 +- src/main/services/TerminalManager.ts | 33 +++- src/main/services/TerminalSessionStore.ts | 27 ++- .../services/agent-runtime/RuntimeGateway.ts | 18 +- src/main/services/terminalLaunch.ts | 24 ++- tests/agent-runtime-gateway.test.mjs | 123 +++++++++++++ tests/terminal-launch.test.mjs | 84 ++++++++- tests/terminal-session-exact-resume.test.mjs | 171 ++++++++++++++++++ tests/terminal-session-restore.test.mjs | 3 +- tests/terminal-session-store.test.mjs | 69 ++++++- 12 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 tests/terminal-session-exact-resume.test.mjs diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index a60a655e..7cbe023d 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,6 +40,14 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); +const codexThreadId = firstString( + input?.session_id, + input?.sessionId, + input?.thread_id, + input?.threadId, + input?.conversation_id, + input?.conversationId +); const finalAnswer = state === "idle" && event === "Stop" && typeof input?.last_assistant_message === "string" ? input.last_assistant_message : null; @@ -55,6 +63,7 @@ await reportLifecycle({ state, event, turnId, + ...(codexThreadId ? { codexThreadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index aae729c9..88d1d8b5 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -11,7 +11,7 @@ import { const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, codexThreadId, result, lastAssistantMessage }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -20,6 +20,10 @@ export async function reportLifecycle({ state, event, turnId = null, result, las const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; + const validCodexThreadId = provider === "codex" && typeof codexThreadId === "string" && isCanonicalUuid(codexThreadId) + ? codexThreadId.toLowerCase() + : undefined; + const message = { v: RUNTIME_PROTOCOL_VERSION, type: "lifecycle", @@ -29,6 +33,7 @@ export async function reportLifecycle({ state, event, turnId = null, result, las state, event, turnId: normalizedId(turnId), + ...(validCodexThreadId !== undefined ? { codexThreadId: validCodexThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); @@ -98,3 +103,9 @@ function sendMessage(address, payload, accepted) { function normalizedId(value) { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } + +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isCanonicalUuid(value) { + return typeof value === "string" && CANONICAL_UUID_RE.test(value); +} diff --git a/src/main/index.ts b/src/main/index.ts index ee3eb3a3..b8d11de7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -341,7 +341,8 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, - ...(signal.turnId ? { requestId: signal.turnId } : {}) + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.codexThreadId ? { codexThreadId: signal.codexThreadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 66e43358..148fdd94 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -83,6 +83,8 @@ interface ManagedSession { lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; resumeOnLaunch: boolean; + resumeThreadId?: string; + codexThreadId?: string; captureResult: boolean; } @@ -90,6 +92,7 @@ export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; + codexThreadId?: string; } /** @@ -324,14 +327,18 @@ export class TerminalManager { : null; session.awaitingInitialResize = true; session.resumeOnLaunch = false; + delete session.resumeThreadId; + delete session.codexThreadId; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); + this.schedulePersistence(); return snapshot(session); } + delete session.codexThreadId; session.agentOrchestration?.cleanup(); const launched = this.spawnProcess( id, @@ -368,6 +375,7 @@ export class TerminalManager { if (runtimeStatus) session.metadata.status = runtimeStatus; } this.emitSession(session.metadata, failureOrigin); + this.schedulePersistence(); return snapshot(session); } @@ -432,6 +440,12 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + if (signal.codexThreadId && session.metadata.provider === "codex" + && signal.codexThreadId !== session.codexThreadId) { + session.codexThreadId = signal.codexThreadId; + this.schedulePersistence(); + } + const nextStatus = signal.state; if (session.metadata.status === nextStatus) return; session.metadata.status = nextStatus; @@ -580,7 +594,9 @@ export class TerminalManager { INITIAL_TERMINAL_ROWS, descriptor.provider !== "terminal", false, - descriptor.role + descriptor.role, + undefined, + descriptor.codexThreadId ); process = launched.process; agentBrowser = launched.agentBrowser; @@ -613,6 +629,7 @@ export class TerminalManager { : null, awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", + ...(descriptor.codexThreadId ? { resumeThreadId: descriptor.codexThreadId, codexThreadId: descriptor.codexThreadId } : {}), captureResult: false }; this.sessions.set(descriptor.id, session); @@ -631,7 +648,7 @@ export class TerminalManager { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.codexThreadId)) ); } @@ -654,6 +671,8 @@ export class TerminalManager { session.awaitingInitialResize = false; const resumePrevious = session.resumeOnLaunch; session.resumeOnLaunch = false; + const resumeThreadId = session.resumeThreadId; + delete session.resumeThreadId; try { const launched = this.spawnProcess( id, @@ -664,7 +683,9 @@ export class TerminalManager { session.rows, resumePrevious, session.captureResult, - session.metadata.role + session.metadata.role, + undefined, + resumeThreadId ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; @@ -701,7 +722,8 @@ export class TerminalManager { resumePrevious = false, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number + answerCaptureGrantExpiresAt?: number, + resumeThreadId?: string ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -755,7 +777,8 @@ export class TerminalManager { const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious + resumePrevious, + ...(resumeThreadId ? { resumeThreadId } : {}) }); return { process: this.spawnPty(launch.command, launch.args, { diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index e8389abc..25f462b0 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -40,6 +40,7 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; + codexThreadId?: string; } interface PersistedTerminalSessionState { @@ -106,7 +107,20 @@ export class TerminalSessionStore { } } -export function persistedTerminalSession(metadata: SessionMetadata): PersistedTerminalSession { +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function normalizeCodexThreadId(provider: ProviderId, candidate: unknown): string | undefined { + if (provider !== "codex") return undefined; + if (typeof candidate !== "string") return undefined; + const trimmed = candidate.trim().toLowerCase(); + return UUID_REGEX.test(trimmed) ? trimmed : undefined; +} + +export function persistedTerminalSession( + metadata: SessionMetadata, + codexThreadId?: unknown +): PersistedTerminalSession { + const normalizedCodexThreadId = normalizeCodexThreadId(metadata.provider, codexThreadId); return { id: metadata.id, provider: metadata.provider, @@ -117,7 +131,8 @@ export function persistedTerminalSession(metadata: SessionMetadata): PersistedTe cwd: metadata.cwd, position: { ...metadata.position }, size: { ...metadata.size }, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}) + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(normalizedCodexThreadId !== undefined ? { codexThreadId: normalizedCodexThreadId } : {}) }; } @@ -150,6 +165,11 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste : undefined; if (session.parentSessionId !== undefined && parentSessionId === undefined) continue; if (role === "subagent" && parentSessionId === undefined) continue; + // A damaged or obsolete conversation ID must not make the whole card disappear. + // It can still restore with Codex's interactive resume picker. + const codexThreadId = session.provider === "codex" && typeof session.codexThreadId === "string" && UUID_REGEX.test(session.codexThreadId.trim()) + ? session.codexThreadId.trim().toLowerCase() + : undefined; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -163,7 +183,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste width: clamp(session.size.width, 420, 1_600), height: clamp(session.size.height, 260, 1_100) }, - ...(parentSessionId !== undefined ? { parentSessionId } : {}) + ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(codexThreadId !== undefined ? { codexThreadId } : {}) }); ids.add(session.id); } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index a575cd75..7fd4f551 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -29,6 +29,7 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; answerCaptureGrantExpiresAt?: number; @@ -58,6 +59,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -314,6 +316,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, + ...(message.codexThreadId === undefined ? {} : { codexThreadId: message.codexThreadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -321,7 +324,12 @@ export class RuntimeGateway { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } // Captured text is delivered once and never stored in the lifecycle lease. - lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId }; + lease.latest = { + state: signal.state, + event: signal.event, + turnId: signal.turnId, + ...(signal.codexThreadId === undefined ? {} : { codexThreadId: signal.codexThreadId }) + }; this.onSignal?.(message.terminalSessionId, signal); } } @@ -337,6 +345,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); + if (value.codexThreadId !== undefined) expected.push("codexThreadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -359,6 +368,11 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); + if (value.codexThreadId !== undefined && ( + value.provider !== "codex" + || typeof value.codexThreadId !== "string" + || !CANONICAL_UUID_RE.test(value.codexThreadId) + )) throw new Error("Runtime codexThreadId is invalid."); if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" @@ -372,6 +386,8 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 0f4e795c..53cbee8a 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -19,8 +19,11 @@ interface LaunchResolutionOptions { fileExists?: (path: string) => boolean; providerCli?: ProviderCliResolution; resumePrevious?: boolean; + resumeThreadId?: string; } +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"]; export function resolveTerminalLaunch( @@ -51,7 +54,7 @@ export function resolveTerminalLaunch( const providerArgs = [ ...(profile === "yolo" && provider !== "opencode" ? DANGEROUS_ARGUMENTS[provider] : []), ...agentBrowserArgs, - ...(options.resumePrevious ? RESUME_ARGUMENTS[provider] : []) + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) ]; const combinedEnvironment = { ...providerCli.environment, @@ -72,11 +75,26 @@ export function resolveTerminalLaunch( }; } +function resolveResumeArguments( + provider: Exclude, + resumeThreadId?: string +): string[] { + if (provider === "codex") { + if (resumeThreadId) { + if (!UUID_REGEX.test(resumeThreadId)) { + throw new Error(`Invalid Codex thread ID format: "${resumeThreadId}". Expected a canonical UUID.`); + } + return ["resume", resumeThreadId.toLowerCase()]; + } + return ["resume"]; + } + return RESUME_ARGUMENTS[provider]; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. -const RESUME_ARGUMENTS: Record, string[]> = { - codex: ["resume", "--last"], +const RESUME_ARGUMENTS: Record, string[]> = { claude: ["--continue"], qwen: ["--continue"], kimi: ["--continue"], diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index f60e0861..89992067 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -107,6 +107,129 @@ test("RuntimeGateway rejects a wrong capability and ignores a stale turn complet assert.equal(gateway.currentStatus("terminal-two"), "working"); }); +test("RuntimeGateway propagates codexThreadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-thread", "codex"); + + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-1", session_id: validUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.turnId, "turn-codex-1"); + assert.equal(signals[0].signal.codexThreadId, validUuid); +}); + +test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for codex", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-upper", "codex"); + + const upperUuid = "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D"; + const lowerUuid = upperUuid.toLowerCase(); + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-upper", session_id: upperUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, lowerUuid); +}); + +test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + + // 1. Cross-provider: claude provider with session_id UUID in hook input + const claudeCap = gateway.registerSession("terminal-claude-test", "claude"); + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + + const claudeChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: claudeCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: claudeCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: claudeCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: claudeCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + claudeChild.stdin.end(JSON.stringify({ turn_id: "turn-claude-1", session_id: validUuid })); + const claudeResult = await childResult(claudeChild); + assert.equal(claudeResult.code, 0, claudeResult.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, undefined); + + // 2. Malformed UUID: not canonical format (e.g. invalid chars, wrong length, path traversal) + const codexCap = gateway.registerSession("terminal-codex-malformed", "codex"); + const malformedInputs = [ + "not-a-uuid", + "12345678-1234-1234-1234-123456789abz", // 'z' is not hex + "12345678123412341234123456789abc", // no hyphens + "../../../etc/passwd", + "12345678-1234-1234-1234-123456789abc\n", + "Bearer token123456" + ]; + for (const badId of malformedInputs) { + const childBad = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: codexCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: codexCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: codexCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: codexCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + childBad.stdin.end(JSON.stringify({ turn_id: "turn-bad", session_id: badId })); + const badRes = await childResult(childBad); + assert.equal(badRes.code, 0, badRes.stderr); + } + // All malformed ones should either be omitted or ignored without codexThreadId + for (let i = 1; i < signals.length; i++) { + assert.equal(signals[i].signal.codexThreadId, undefined); + } + + // 3. Direct protocol injection with cross-provider codexThreadId is rejected + await send(claudeCap.address, { + ...message(claudeCap, "working", "UserPromptSubmit", "turn-claude-direct"), + codexThreadId: validUuid + }); + // Signal should not be delivered or accepted + assert.equal(signals.filter((s) => s.id === "terminal-claude-test").length, 1); +}); + test("ordinary Codex Stop reports omit answer text without an explicit capture grant", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index f542a3da..0f621a01 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -68,8 +68,7 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(codex.args, [ "--dangerously-bypass-approvals-and-sandbox", "--bridge", - "resume", - "--last" + "resume" ]); for (const provider of ["claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin"]) { @@ -87,6 +86,87 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(restored.args, ["--bridge"]); }); +test("Codex restore with resumeThreadId launches codex resume ", () => { + const uuid1 = "12345678-1234-4234-8234-123456789abc"; + const uuid2 = "abcdef01-abcd-4def-9abc-def012345678"; + + const launch1 = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid1 + }); + assert.deepEqual(launch1.args, ["--bridge", "resume", uuid1]); + + const launch2 = resolveTerminalLaunch("codex", "yolo", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid2.toUpperCase() + }); + assert.deepEqual(launch2.args, [ + "--dangerously-bypass-approvals-and-sandbox", + "resume", + uuid2.toLowerCase() + ]); +}); + +test("Codex restore without resumeThreadId launches interactive resume chooser", () => { + const launch = resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true + }); + assert.deepEqual(launch.args, ["resume"]); +}); + +test("Codex restore throws on malformed resumeThreadId", () => { + for (const malformed of ["not-a-uuid", "12345678-1234-1234-1234", "12345678-1234-1234-1234-123456789abc-extra", "../escape"]) { + assert.throws( + () => + resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: malformed + }), + /Invalid Codex thread ID format/u + ); + } +}); + +test("Other providers ignore resumeThreadId and retain standard continue flags", () => { + const uuid = "12345678-1234-4234-8234-123456789abc"; + const claude = resolveTerminalLaunch("claude", "normal", ["--bridge"], { + providerCli: available("claude", "/resolved/claude"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(claude.args, ["--bridge", "--continue"]); + + const qwen = resolveTerminalLaunch("qwen", "yolo", [], { + providerCli: available("qwen", "/resolved/qwen"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(qwen.args, ["--yolo", "--continue"]); +}); + +test("Windows batch quoting with Codex resume thread UUID", () => { + const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; + const uuid = "12345678-1234-4234-8234-123456789abc"; + const providerCli = available( + "codex", + "C:\\Users\\Kisa\\AppData\\Roaming\\npm\\codex.cmd", + { launcher: "batch", commandPrompt, environment: { Path: "C:\\resolved" } } + ); + const launch = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + platform: "win32", + providerCli, + resumePrevious: true, + resumeThreadId: uuid + }); + assert.equal(launch.command, commandPrompt); + assert.match(launch.args, /codex\.cmd/u); + assert.match(launch.args, new RegExp(uuid, "u")); +}); + test("OMP and Pi use their documented dangerous flags instead of the legacy default", () => { const omp = resolveTerminalLaunch("omp", "yolo", [], { providerCli: available("omp", "/resolved/omp") }); assert.deepEqual(omp.args, ["--auto-approve"]); diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs new file mode 100644 index 00000000..fa55a463 --- /dev/null +++ b/tests/terminal-session-exact-resume.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; + +const FIRST_THREAD = "11111111-1111-4111-8111-111111111111"; +const SECOND_THREAD = "22222222-2222-4222-8222-222222222222"; + +function registry() { + return { + get(provider) { + return { + state: "available", + provider, + executable: `/resolved/${provider}`, + launcher: "native", + environment: {}, + checked: [] + }; + }, + snapshot() { return {}; } + }; +} + +function spawner(calls) { + return (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 10_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit() { return { dispose() {} }; } + }; + }; +} + +function manager(directory, calls) { + const instance = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + spawner(calls) + ); + instance.configureSessionPersistence(new TerminalSessionStore(directory), true); + return instance; +} + +test("restoring two Codex cards in one cwd resumes their own conversations", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-exact-resume-")); + try { + const initial = manager(directory, []); + await initial.restorePersistedSessions(); + const first = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const second = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 20, y: 20 } }); + // SessionStart is idle while a new card is already idle. The ID must still persist. + initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", codexThreadId: SECOND_THREAD }); + await initial.shutdown(); + + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 2); + assert.deepEqual(calls.map((call) => call.args.slice(-2)), [ + ["resume", FIRST_THREAD], + ["resume", SECOND_THREAD] + ]); + assert.deepEqual(restored.list().map((session) => session.id), [first.id, second.id]); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("legacy Codex card without an ID opens the resume picker", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-legacy-resume-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([{ + id: "legacy-codex-card", + provider: "codex", + profile: "normal", + role: "agent", + title: "Legacy card", + titleCustomized: true, + cwd: process.cwd(), + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } + }]); + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.at(-1), "resume"); + assert.equal(calls[0].args.includes("--last"), false); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("restarting a Codex card after an exit clears stale thread ID and launches fresh", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restart-codex-")); + try { + const calls = []; + let exitHandler = null; + const customSpawner = (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 30_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit(handler) { + exitHandler = handler; + return { dispose() {} }; + } + }; + }; + + const inst = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + customSpawner + ); + const store = new TerminalSessionStore(directory); + inst.configureSessionPersistence(store, true); + await inst.restorePersistedSessions(); + + const created = inst.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + + // Verify stored + assert.equal(store.get()[0]?.codexThreadId, FIRST_THREAD); + + // Simulate exit + exitHandler?.({ exitCode: 0 }); + + // Restart the session + inst.restart(created.id); + + // Fresh restart: second spawn call shouldn't have "resume" or thread ID + assert.equal(calls.length, 2); + assert.equal(calls[1].args.includes("resume"), false); + assert.equal(calls[1].args.includes(FIRST_THREAD), false); + + // Stored session should no longer have the stale thread ID + assert.equal(store.get()[0]?.codexThreadId, undefined); + + await inst.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/terminal-session-restore.test.mjs b/tests/terminal-session-restore.test.mjs index 1a38ff78..19bd3558 100644 --- a/tests/terminal-session-restore.test.mjs +++ b/tests/terminal-session-restore.test.mjs @@ -80,7 +80,8 @@ test("opt-in restore preserves card identity and relaunches the agent in native await restored.restorePersistedSessions(); assert.equal(restoredCalls.length, 1); - assert.deepEqual(restoredCalls[0].args.slice(-2), ["resume", "--last"]); + assert.deepEqual(restoredCalls[0].args.slice(-1), ["resume"]); + assert.equal(restoredCalls[0].args.includes("--last"), false); assert.deepEqual(restored.list().map(({ buffer, revision, status, startedAt, exitCode, failureDetails, ...session }) => session), [{ id: created.id, provider: "codex", diff --git a/tests/terminal-session-store.test.mjs b/tests/terminal-session-store.test.mjs index 6dd822e6..630ca2b5 100644 --- a/tests/terminal-session-store.test.mjs +++ b/tests/terminal-session-store.test.mjs @@ -5,7 +5,8 @@ import { join } from "node:path"; import test from "node:test"; import { TerminalSessionStore, - normalizePersistedTerminalSessions + normalizePersistedTerminalSessions, + persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; const descriptor = { @@ -91,3 +92,69 @@ test("legacy roles restore as agents while unknown roles are dropped", async () await rm(directory, { recursive: true, force: true }); } }); + +test("codexThreadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { + const threadIdUpper = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D"; + const threadIdCanonical = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + + const sessionMetadata = { + ...descriptor, + revision: 1, + status: "idle", + startedAt: Date.now(), + exitCode: null, + failureDetails: null + }; + + // persistedTerminalSession helper round-trips valid codexThreadId + const persisted = persistedTerminalSession(sessionMetadata, ` ${threadIdUpper} `); + assert.equal(persisted.codexThreadId, threadIdCanonical); + + // Non-codex provider ignores codexThreadId in persistedTerminalSession helper + const claudePersisted = persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper); + assert.equal(claudePersisted.codexThreadId, undefined); + + // Malformed thread IDs are ignored in persistedTerminalSession helper + assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").codexThreadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, 12345).codexThreadId, undefined); + + // Store persistence and load round-trip + const directory = await mkdtemp(join(tmpdir(), "canvastty-terminal-state-codex-thread-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([persisted]); + const reloaded = await store.load(); + assert.equal(reloaded.length, 1); + assert.equal(reloaded[0].codexThreadId, threadIdCanonical); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("normalizePersistedTerminalSessions preserves cards with malformed or foreign thread IDs", () => { + const validUuid = "11111111-2222-3333-4444-555555555555"; + const normalized = normalizePersistedTerminalSessions({ + version: 1, + sessions: [ + // Valid codex thread ID + { ...descriptor, id: "valid-codex", provider: "codex", codexThreadId: validUuid }, + // Valid codex session without codexThreadId (backward compatibility) + { ...descriptor, id: "valid-codex-no-thread", provider: "codex" }, + // Malformed thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-uuid", provider: "codex", codexThreadId: "invalid-uuid" }, + // Non-string thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-type-uuid", provider: "codex", codexThreadId: 12345 }, + // codexThreadId attached to non-codex provider -> ignored + { ...descriptor, id: "claude-with-thread", provider: "claude", codexThreadId: validUuid }, + { ...descriptor, id: "terminal-with-thread", provider: "terminal", codexThreadId: validUuid } + ] + }); + + assert.deepEqual(normalized.sessions.map((s) => s.id), [ + "valid-codex", "valid-codex-no-thread", "bad-uuid", "bad-type-uuid", + "claude-with-thread", "terminal-with-thread" + ]); + assert.equal(normalized.sessions[0].codexThreadId, validUuid); + assert.equal(normalized.sessions[1].codexThreadId, undefined); + assert.ok(normalized.sessions.slice(2).every((session) => session.codexThreadId === undefined)); +}); From c1d603a6e9cb2ae159171a8e7aa88d192b8a8281 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:39:02 +0000 Subject: [PATCH 2/2] feat(sessions): restore agent sessions through one "after restart" model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #80 (teo-nex, "restore each Codex card to its own conversation"): its capture of the conversation id from authenticated lifecycle hooks, the validated id saved per card, `codex resume `, the resume picker when no id is known and a plain restart forgetting the id are kept as they are. This extends the same exact resume to Claude Code (`claude --resume `) and OpenCode (`opencode --session `); the field is renamed from codexThreadId to threadId for that, with one per-provider check (canonical UUID for Codex and Claude, `ses_` id for OpenCode) shared by the hook client, the gateway, the store and the launch, and v1 records' codexThreadId still read. Settings → General now offers Don't save / Reopen windows / Continue conversations (settings v21; the old opt-in boolean migrates true→continue, false→off). Session records move to v2 (v1 stays readable): last state at quit or exit, the thread id, a per-card restore flag, and two validated opaque plugin slots (launch options and an environment ref, 4 KB each). No scrollback, prompts or secrets are saved. Restore puts parents before children, resumes a recorded conversation by id, and without one uses a "latest in this folder" flag only when that CLI has one card in the folder (otherwise it starts fresh with a note on the card; Codex opens its picker). Finished cards come back stopped with Restart / Continue (Continue resumes the card's own conversation), and a card whose environment is unavailable is held stopped with its reason instead of running locally. Cards get an options menu with "Don't restore this card". For plugins: the v2 record's two opaque slots are where later extension points keep per-card state across restarts. A launch contributor's chosen options are saved in `options[pluginId]` and an environment's ref in `environment`, both validated and capped at 4 KB, so a restored card can be prepared or placed again (or held stopped with a reason) without the core knowing what the values mean. --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh-CN.md | 1 + docs/ARCHITECTURE.md | 4 +- src/agent-runtime/hook-helper.mjs | 5 +- src/agent-runtime/opencode-plugin.mjs | 4 +- src/agent-runtime/runtime-client.mjs | 15 +- src/agent-runtime/runtime-protocol.d.mts | 2 + src/agent-runtime/runtime-protocol.mjs | 16 ++ src/main/index.ts | 6 +- src/main/ipc/registerIpc.ts | 5 +- src/main/services/SettingsStore.ts | 22 +- src/main/services/TerminalManager.ts | 194 +++++++++----- src/main/services/TerminalSessionStore.ts | 128 +++++++-- .../services/agent-runtime/RuntimeGateway.ts | 23 +- src/main/services/sessionRestorePlan.ts | 86 ++++++ src/main/services/terminalLaunch.ts | 29 +++ src/preload/index.ts | 3 +- src/renderer/src/App.tsx | 6 +- .../src/features/settings/SettingsPanel.tsx | 11 +- .../src/features/terminal/TerminalCard.tsx | 66 ++++- .../features/workspace/WorkspaceCanvas.tsx | 4 +- src/renderer/src/lib/i18n.ts | 22 +- src/renderer/src/styles/app.css | 5 + src/shared/contracts.ts | 18 +- tests/agent-control.test.mjs | 4 +- tests/agent-runtime-gateway.test.mjs | 57 ++-- tests/api-profiles.test.mjs | 4 +- tests/attention-notifications.test.mjs | 6 +- tests/session-hierarchy.test.mjs | 6 +- tests/session-restore-v2.test.mjs | 246 ++++++++++++++++++ tests/settings-normalizer.test.mjs | 19 +- tests/settings-ui.test.mjs | 4 +- tests/terminal-launch.test.mjs | 21 +- tests/terminal-lifecycle.test.mjs | 4 +- tests/terminal-session-exact-resume.test.mjs | 14 +- tests/terminal-session-restore.test.mjs | 8 +- tests/terminal-session-store.test.mjs | 31 ++- 38 files changed, 883 insertions(+), 218 deletions(-) create mode 100644 src/main/services/sessionRestorePlan.ts create mode 100644 tests/session-restore-v2.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 078880c1..71c91d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Reworked "Windows after restart" into one "Agent sessions after restart" model: **Don't save**, **Reopen windows** (new conversations), or **Continue conversations** (the old "on" migrates here). Claude Code and OpenCode now resume their own conversation by the id their lifecycle hook reported, as Codex does (`claude --resume`, `opencode --session`); two cards of one CLI in one folder no longer continue the same conversation. Finished agents come back stopped with Restart / Continue instead of rerunning, the card options menu has **Don't restore this card**, and session records (v2, read-compatible with v1) keep no scrollback, prompts or secrets. - Made the agent orchestration endpoint an explicit setting (Settings → Agents → "Agent orchestration endpoint", `agentControlEnabled`, off by default; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` still force it on for one launch) that starts and stops the endpoint at runtime, and added an **Orchestrator** role to the launch dialog next to the normal/YOLO profile: the session keeps the provider you opened the dialog for, gets `CANVASTTY_CONTROL_CONNECTION` and `CANVASTTY_CONTROL_CLI` in its environment so the bundled CLI works without setup, shows an "Orchestrator" badge, keeps its role across restore, and the dialog offers to enable the endpoint first when it is off instead of enabling anything silently. The endpoint's `create` now accepts every agent provider (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) and reports `capabilities { result, menus }` per worker on `create` and `list`: both are `true` for Codex only; other providers' `screen` has no menu interaction, `choose`/`dismiss` fail with `NOT_SUPPORTED`, `send` relies on the idle status alone, and `result` completes as `no_result`. - Added a native Codex orchestration CLI (`agent-control/canvastty-control.mjs`, documented in `agent/orchestrator/SKILL.md`) behind `--agent-control` or `CANVASTTY_AGENT_CONTROL=1`: a local controller creates Codex sessions in a project directory, sends work, observes bounded terminal output against a screen revision, and collects the final answer. Each controller sees only the sessions it created, grants are bound to the session generation, mutation IDs are deduplicated, and only controlled sessions opt into authenticated Stop-hook result capture. No automatic approval or terminal deletion endpoint is included. - Added the opt-in Even G2 companion (Settings → Controls → Even G2, with the Even App companion under `integrations/even-g2`): Bonjour discovery, short-lived SRP-6a pairing with a six-digit code and explicit device approval, encrypted local requests and audio, per-session grants, bounded terminal presentation on the glasses HUD, local speech recognition through the pinned transcribe.cpp helper (bundled on macOS only), and session creation through the existing desktop launcher. The final answer of a Codex turn reaches the companion only for sessions spawned while the companion is enabled: the runtime hook reports it under a separate per-session grant, bounded to 4000 characters, and the gateway refuses it for any other session. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 2ed4b884..ea9a34ce 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,7 @@ ## Unreleased +- «Окна после перезапуска» стали единой моделью «Сессии агентов после перезапуска»: **Не сохранять**, **Открыть окна** (новые разговоры) или **Продолжить разговоры** (прежнее «включено» переходит сюда). Claude Code и OpenCode теперь, как и Codex, продолжают свой разговор по id, который сообщил их lifecycle hook (`claude --resume`, `opencode --session`); две карточки одного CLI в одной папке больше не продолжают один и тот же разговор. Завершённые агенты возвращаются остановленными с кнопками «Перезапустить» / «Продолжить», в меню карточки есть **Не восстанавливать это окно**, а записи сессий (v2, совместимы с v1 при чтении) не хранят буфер, промпты и секреты. - Эндпоинт оркестрации агентов стал явной настройкой (Настройки → Агенты → «Эндпоинт оркестрации агентов», `agentControlEnabled`, по умолчанию выключен; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` по-прежнему принудительно включают его на один запуск), которая запускает и останавливает эндпоинт на лету, а в диалог запуска рядом с профилем normal/YOLO добавлена роль **Оркестратор**: сессия сохраняет провайдера, для которого открыт диалог, получает в окружении `CANVASTTY_CONTROL_CONNECTION` и `CANVASTTY_CONTROL_CLI`, чтобы встроенный CLI работал без настройки, показывает бейдж «Оркестратор», сохраняет роль при восстановлении, а при выключенном эндпоинте диалог предлагает сначала включить его, ничего не включая молча. `create` эндпоинта теперь принимает любого провайдера-агента (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) и в ответах `create` и `list` сообщает `capabilities { result, menus }` для каждого воркера: оба значения `true` только для Codex; у остальных провайдеров `screen` не содержит взаимодействия с меню, `choose`/`dismiss` завершаются ошибкой `NOT_SUPPORTED`, `send` опирается только на статус idle, а `result` завершается как `no_result`. - Добавлен нативный CLI оркестрации Codex (`agent-control/canvastty-control.mjs`, описан в `agent/orchestrator/SKILL.md`), включаемый флагом `--agent-control` или `CANVASTTY_AGENT_CONTROL=1`: локальный контроллер создаёт сессии Codex в каталоге проекта, отправляет задачи, наблюдает ограниченный вывод терминала относительно ревизии экрана и получает итоговый ответ. Контроллер видит только созданные им сессии, гранты привязаны к поколению сессии, идентификаторы мутаций дедуплицируются, и только управляемые сессии включают аутентифицированный захват результата через Stop-hook. Автоматического одобрения и удаления терминалов нет. - Добавлен опциональный компаньон Even G2 (Настройки → Управление → Even G2, приложение-компаньон в `integrations/even-g2`): обнаружение через Bonjour, короткоживущее сопряжение SRP-6a с шестизначным кодом и явным подтверждением устройства, шифрованные локальные запросы и аудио, гранты на сессию, ограниченное отображение терминала на HUD очков, локальное распознавание речи через закреплённый helper transcribe.cpp (поставляется только для macOS) и создание сессий через обычный лаунчер. Итоговый ответ хода Codex попадает в компаньон только для сессий, запущенных при включённом компаньоне: runtime-hook передаёт его по отдельному гранту сессии с ограничением 4000 символов, а gateway отклоняет его для любой другой сессии. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b92a94a2..6120ca95 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,7 @@ ## Unreleased +- 将“重启后的窗口”改为统一的“重启后的智能体会话”模型:**不保存**、**重新打开窗口**(新会话)或 **继续会话**(原先的“开启”迁移到此项)。Claude Code 和 OpenCode 现在与 Codex 一样,按其 lifecycle hook 报告的 id 继续自己的会话(`claude --resume`、`opencode --session`);同一文件夹中同一 CLI 的两张卡片不再继续同一个会话。已结束的智能体恢复为停止状态,提供“重启”/“继续”,卡片选项菜单提供 **不恢复此卡片**,会话记录(v2,可读取 v1)不保存 scrollback、提示词或密钥。 - 将代理编排端点改为显式设置(设置 → 代理 → “代理编排端点”,`agentControlEnabled`,默认关闭;`--agent-control` / `CANVASTTY_AGENT_CONTROL=1` 仍可为单次启动强制开启),并在运行时按设置启动和停止端点;启动对话框在 normal/YOLO 配置旁新增 **Orchestrator(编排器)** 角色:会话保留打开对话框时的提供方,环境中携带 `CANVASTTY_CONTROL_CONNECTION` 和 `CANVASTTY_CONTROL_CLI`,使内置 CLI 无需配置即可工作,卡片显示 “Orchestrator” 徽标,恢复会话时保留角色;端点关闭时对话框会先提示并提供开启按钮,而不会静默开启任何内容。端点的 `create` 现在接受所有代理提供方(`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`),并在 `create` 和 `list` 响应中为每个工作会话报告 `capabilities { result, menus }`:仅 Codex 两者都为 `true`;其他提供方的 `screen` 没有菜单交互,`choose`/`dismiss` 返回 `NOT_SUPPORTED`,`send` 仅依据 idle 状态,`result` 以 `no_result` 结束。 - 新增原生 Codex 编排 CLI(`agent-control/canvastty-control.mjs`,文档见 `agent/orchestrator/SKILL.md`),通过 `--agent-control` 或 `CANVASTTY_AGENT_CONTROL=1` 启用:本地控制器在项目目录中创建 Codex 会话、发送任务、按屏幕修订号观察有界的终端输出并收集最终回答。每个控制器只能看到自己创建的会话,授权绑定到会话代次,变更 ID 去重,且只有受控会话会启用经过认证的 Stop-hook 结果捕获。不包含自动批准或删除终端的端点。 - 新增可选的 Even G2 伴侣(设置 → 控制 → Even G2,伴侣应用位于 `integrations/even-g2`):Bonjour 发现、带六位码和显式设备批准的短期 SRP-6a 配对、加密的本地请求与音频、按会话授权、眼镜 HUD 上的有界终端展示、通过固定版本的 transcribe.cpp helper 进行本地语音识别(仅 macOS 随包提供),以及通过现有桌面启动器创建会话。Codex 一轮的最终回答只会送达在伴侣启用期间启动的会话:runtime hook 以单独的会话授权上报,限制为 4000 个字符,gateway 会拒绝任何其他会话的该字段。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5b2400a..c07feb5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ Electron main process - `src/preload/index.ts` exposes only the typed capabilities the renderer needs. Node integration stays disabled; context isolation and sandbox stay enabled. - Terminal file drops resolve native `File` objects through preload's `webUtils.getPathForFile`, format paths for the host's default shell, and paste through xterm without submitting. File contents are not read and no new main-process IPC is exposed. - `src/main/ipc/registerIpc.ts` owns native side effects and validates access to persisted media. -- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence writes only provider/profile/title/cwd/bounds descriptors through `TerminalSessionStore`; it never writes PTY scrollback, child environment, or capabilities. Restored agents use each provider's native project-scoped continue mode, while plain terminals reopen as fresh shells in their saved folder. +- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence (Settings → General, "Agent sessions after restart": Don't save / Reopen windows / Continue conversations) writes session records v2 through `TerminalSessionStore`: the card descriptor, the state at quit or exit, the provider conversation id a lifecycle hook reported (`threadId`: Codex thread or Claude session UUID, OpenCode `ses_` id), the per-card restore flag, and two opaque plugin slots (launch options and an environment reference, at most 4 KB each). It never writes PTY scrollback, prompts, child environment, secrets, or capabilities. Plain terminals reopen as fresh shells in their saved folder. - `src/main/services/LimitsService.ts` reads Codex through the installed CLI's app-server protocol and Claude, Kimi, OpenCode Go, and Grok Build through their provider usage or billing endpoints. Qwen Code is multi-provider and exposes no provider-neutral read-only quota protocol, so its adapter reports `cli-not-found` or `unsupported-protocol` and never invents percentages. Provider credentials are read only inside the trusted main process, sent only to the matching provider over HTTPS, and never logged or exposed over IPC. The service owns timeout, structural normalization, caching, stale fallback, and subprocess cleanup; raw provider responses never cross IPC. - `src/main/services/SettingsStore.ts` normalizes every update and persists through a serialized atomic write. Canvas regions and sticky notes have independent persistence gates: disabling one keeps its live objects for the current process but omits that collection from the disk snapshot and therefore from the next launch. The configurable canvas launcher and UI scale use the same boundary; transient window stacking does not. - `src/main/services/PluginManager.ts` installs ready-to-run repositories without executing package scripts during install/update, rejects symlinks and oversized packages, persists the enabled registry, serves only contained package files, and enforces per-plugin permissions/storage quotas. Optional native agent-hook entries remain off by default; explicit per-hook trust is persisted in the plugin registry and compiled into a separate private atomic runtime registry. Update, module replacement, plugin disable, and uninstall revoke that trust before executable files change. @@ -91,7 +91,7 @@ Keep domain decisions in pure selectors such as `homeModel.ts`, orchestration in ## Session flow -When terminal restore is enabled, startup loads validated window descriptors before the renderer and relaunches each saved agent through its provider's native continue mode. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. +When session restore is on, startup loads validated records before the renderer and restores parents before children (`sessionRestorePlan.ts`). "Continue conversations" resumes the conversation id the card's lifecycle hook reported (`codex resume `, `claude --resume `, `opencode --session `); without one Codex opens its own resume picker, and other CLIs use their "latest in this folder" flag only when that CLI has exactly one card in the folder, and otherwise start fresh and say so on the card. A plain Restart starts a new conversation and forgets the id; Continue on a stopped card resumes it. Cards that had already exited come back stopped with Restart / Continue, cards marked "Don't restore this card" do not come back, and a card placed in an environment that is unavailable comes back stopped with the reason and is never started locally. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. 1. Home requests a terminal or opens a provider-specific launch card. 2. `App` sends a typed `terminal:create` request. diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 7cbe023d..024a1376 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,7 +40,8 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); -const codexThreadId = firstString( +// The provider's own conversation id; runtime-client keeps it only in a shape that provider issues. +const threadId = firstString( input?.session_id, input?.sessionId, input?.thread_id, @@ -63,7 +64,7 @@ await reportLifecycle({ state, event, turnId, - ...(codexThreadId ? { codexThreadId } : {}), + ...(threadId ? { threadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index 73ca8978..d2333031 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -25,7 +25,9 @@ export const CanvasTTYLifecycle = async () => ({ rootSessionId = stringField(session.id, sessionId); rootWorking = false; if (!rootSessionId) return; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) { + await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, threadId: rootSessionId }); + } runPluginHooks("session-start", event.type, event); return; } diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index 88d1d8b5..cc441733 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -5,13 +5,14 @@ import { CAPTURE_ANSWER_EXPIRES_AT_ENV, MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, + normalizeThreadId, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "./runtime-protocol.mjs"; const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, codexThreadId, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -20,9 +21,7 @@ export async function reportLifecycle({ state, event, turnId = null, codexThread const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; - const validCodexThreadId = provider === "codex" && typeof codexThreadId === "string" && isCanonicalUuid(codexThreadId) - ? codexThreadId.toLowerCase() - : undefined; + const validThreadId = normalizeThreadId(provider, threadId); const message = { v: RUNTIME_PROTOCOL_VERSION, @@ -33,7 +32,7 @@ export async function reportLifecycle({ state, event, turnId = null, codexThread state, event, turnId: normalizedId(turnId), - ...(validCodexThreadId !== undefined ? { codexThreadId: validCodexThreadId } : {}), + ...(validThreadId !== undefined ? { threadId: validThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); @@ -103,9 +102,3 @@ function sendMessage(address, payload, accepted) { function normalizedId(value) { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } - -const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function isCanonicalUuid(value) { - return typeof value === "string" && CANONICAL_UUID_RE.test(value); -} diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index 03fda4cd..a73b8807 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -13,3 +13,5 @@ export const AGENT_RUNTIME_ENV: Readonly<{ capabilityToken: "CANVASTTY_RUNTIME_CAPABILITY"; }>; export const RUNTIME_STATES: readonly ["idle", "working", "needs_approval"]; +/** The provider's conversation id in the one form it may be stored or passed to its CLI, or undefined. */ +export function normalizeThreadId(provider: string, value: unknown): string | undefined; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 45de46d4..96a9c23c 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -19,3 +19,19 @@ export const AGENT_RUNTIME_ENV = Object.freeze({ }); export const RUNTIME_STATES = Object.freeze(["idle", "working", "needs_approval"]); + +// A provider's own conversation id, as its lifecycle hook reports it, lets a restored +// card resume exactly that conversation. It ends up in the provider's argv, so only +// the shapes those CLIs issue are accepted: canonical UUIDs for Codex threads and +// Claude sessions (lower-cased), `ses_` tokens for OpenCode sessions. +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9]{1,120}$/; + +export function normalizeThreadId(provider, value) { + if (typeof value !== "string") return undefined; + if (provider === "codex" || provider === "claude") { + return CANONICAL_UUID_RE.test(value) ? value.toLowerCase() : undefined; + } + if (provider === "opencode") return OPENCODE_SESSION_RE.test(value) ? value : undefined; + return undefined; +} diff --git a/src/main/index.ts b/src/main/index.ts index b8d11de7..823458ee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -342,7 +342,7 @@ async function initializeServices(): Promise { kind: "lifecycle", state: signal.state, ...(signal.turnId ? { requestId: signal.turnId } : {}), - ...(signal.codexThreadId ? { codexThreadId: signal.codexThreadId } : {}) + ...(signal.threadId ? { threadId: signal.threadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { @@ -431,7 +431,7 @@ async function initializeServices(): Promise { } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); const terminalSessionStore = new TerminalSessionStore(userDataPath); - terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().restoreTerminalSessions); + terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().sessionRestoreMode); // The orchestration bridge exists only for sessions explicitly launched with // the orchestrator role; interactive sessions never receive capabilities. @@ -567,7 +567,7 @@ async function initializeServices(): Promise { wheelBinding: activeCanvasWheelBinding(next.canvasWheelCaptureMode, next.canvasWheelOverride), navigationBinding: next.canvasNavigationOverride }); - await terminalManager?.setSessionPersistenceEnabled(next.restoreTerminalSessions); + await terminalManager?.setSessionRestoreMode(next.sessionRestoreMode); }, setCanvasNavigationShortcutCapture: (active) => { if (active) browserService?.cancelCanvasNavigationGesture(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 1fb3ca10..8c2b4349 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -617,13 +617,16 @@ export function registerIpc({ return terminals.readBuffer(id); }); ipcMain.handle(IPC.terminalCreate, (_event, request: CreateSessionRequest) => terminals.create(request)); - ipcMain.handle(IPC.terminalRestart, (_event, id: string) => terminals.restart(id)); + ipcMain.handle(IPC.terminalRestart, (_event, id: string, options?: { resume?: unknown }) => ( + terminals.restart(id, { resume: options?.resume === true }) + )); ipcMain.on(IPC.terminalInput, (_event, id: string, data: string) => terminals.input(id, data)); ipcMain.on(IPC.terminalResize, (_event, id: string, cols: number, rows: number) => { terminals.resize(id, cols, rows); }); ipcMain.on(IPC.terminalBounds, (_event, id: string, bounds: SessionBounds) => terminals.setBounds(id, bounds)); ipcMain.handle(IPC.terminalRename, (_event, id: string, title: string) => terminals.rename(id, title)); + ipcMain.handle(IPC.terminalSetRestore, (_event, id: string, restore: boolean) => terminals.setRestore(id, restore)); ipcMain.handle(IPC.terminalDispose, (_event, id: string) => terminals.dispose(id)); // Fire-and-forget, like the other stream-reporting channels: a malformed // report is ignored rather than rejecting into the renderer. diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index a75a6e3a..5c379e6a 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -28,6 +28,7 @@ import type { PluginCanvasInstance, ProviderSecretId, RadialLauncherItemId, + SessionRestoreMode, SessionRowColorMode, ShortcutBindings, StickyNote, @@ -63,13 +64,15 @@ import { } from "../../shared/canvasNavigation.ts"; const LOCALES = new Set(["ru", "en"]); +const SESSION_RESTORE_MODES = new Set(["off", "reopen", "continue"]); const PALETTES = new Set(["sage", "lilac", "night"]); const HOME_ACCENT_PRESETS = new Set(["classic", "warm", "cool", "mono", "custom"]); const SESSION_ROW_COLOR_MODES = new Set(["monochrome", "status"]); const CANVAS_COLORS = new Set(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]); const PATTERNS = new Set(["dots", "grid", "waves", "diagonal", "rings", "none"]); const MEDIA_FITS = new Set(["cover", "contain"]); -const SETTINGS_VERSION = 20; +// 21: the on/off "restoreTerminalSessions" became sessionRestoreMode (off / reopen / continue). +const SETTINGS_VERSION = 21; const GROK_LAUNCHER_SETTINGS_VERSION = 3; const EXPANDED_LIMIT_SETTINGS_VERSION = 5; const QWEN_SETTINGS_VERSION = 6; @@ -165,7 +168,7 @@ export class SettingsStore { || !("attentionQueueVisible" in source) || !("attentionQueuePlacement" in source) || !("agentControlEnabled" in source) - || !("restoreTerminalSessions" in source) + || !("sessionRestoreMode" in source) || !("persistCanvasRegions" in source) || !("persistStickyNotes" in source) || !("canvasRegions" in source) @@ -299,6 +302,15 @@ export class SettingsStore { } } +/** The old boolean migrates as it behaved: saved windows continued their conversations. */ +function normalizeSessionRestoreMode(source: Record, fallback: SessionRestoreMode | undefined): SessionRestoreMode { + if (SESSION_RESTORE_MODES.has(source.sessionRestoreMode as SessionRestoreMode)) { + return source.sessionRestoreMode as SessionRestoreMode; + } + if (typeof source.restoreTerminalSessions === "boolean") return source.restoreTerminalSessions ? "continue" : "off"; + return fallback ?? "off"; +} + function isLegacyDefaultLimitSelection(candidate: unknown[] | null): boolean { return candidate !== null && candidate.length === LEGACY_LIMIT_PROVIDERS.length @@ -314,7 +326,7 @@ function isPreQwenDefaultSelection(candidate: unknown[] | null function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform): AppSettings { return { locale: systemLocale.toLowerCase().startsWith("ru") ? "ru" : "en", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -484,9 +496,7 @@ export function normalizeSettings( return { locale: LOCALES.has(source.locale as LocaleId) ? source.locale as LocaleId : fallback.locale, - restoreTerminalSessions: typeof source.restoreTerminalSessions === "boolean" - ? source.restoreTerminalSessions - : fallback.restoreTerminalSessions ?? false, + sessionRestoreMode: normalizeSessionRestoreMode(source as Record, fallback.sessionRestoreMode), persistCanvasRegions: typeof source.persistCanvasRegions === "boolean" ? source.persistCanvasRegions : fallback.persistCanvasRegions ?? true, diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 148fdd94..a4fd0570 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -12,6 +12,7 @@ import type { SessionEvent, SessionMetadata, SessionRemovedEvent, + SessionRestoreMode, SessionSnapshot, TerminalBufferSnapshot, TerminalDataEvent @@ -36,7 +37,8 @@ import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, CAPTURE_ANSWER_EXPIRES_AT_ENV, - CAPTURE_RESULT_ENV + CAPTURE_RESULT_ENV, + normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { CONTROL_CLI_ENV, @@ -50,9 +52,11 @@ import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { resolveTerminalLaunch } from "./terminalLaunch.ts"; import { persistedTerminalSession, - type PersistedTerminalSession, + type PersistedEnvironmentRef, + type PersistedSessionExtras, type TerminalSessionStore } from "./TerminalSessionStore.ts"; +import { chooseResume, planSessionRestore, type ResumeRequest, type RestoreStep } from "./sessionRestorePlan.ts"; import type { ProviderCliRegistry, UnavailableProviderCli } from "./providerCliRegistry.ts"; import { createProviderLifecycleParser, @@ -82,17 +86,19 @@ interface ManagedSession { agentOrchestration: PreparedOrchestrationPtyLaunch | null; lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; - resumeOnLaunch: boolean; - resumeThreadId?: string; - codexThreadId?: string; + resumeOnLaunch: ResumeRequest; + /** The provider's own conversation id, once its hook reported it (or from the saved record). */ + threadId?: string; captureResult: boolean; + /** Plugin options and environment ref carried into the saved record. */ + extras: PersistedSessionExtras; } export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; - codexThreadId?: string; + threadId?: string; } /** @@ -122,7 +128,10 @@ export class TerminalManager { private lifecycleHooksEnabled: boolean; private agentOrchestration: OrchestrationLaunchCoordinator | null = null; private sessionStore: TerminalSessionStore | null = null; - private sessionPersistenceEnabled = false; + private sessionRestoreMode: SessionRestoreMode = "off"; + // No environment provider exists in core yet, so a placed session can only + // come back stopped; the environment registry (plugins) answers this later. + private environmentAvailable: (environment: PersistedEnvironmentRef) => boolean = () => false; private suppressPersistence = false; // The live agent-control descriptor, handed only to orchestrator-role sessions // spawned while it is set; null while the endpoint is off. @@ -152,37 +161,47 @@ export class TerminalManager { this.agentOrchestration = coordinator; } - configureSessionPersistence(store: TerminalSessionStore, enabled: boolean): void { + configureSessionPersistence(store: TerminalSessionStore, mode: SessionRestoreMode): void { this.sessionStore = store; - this.sessionPersistenceEnabled = Boolean(enabled); + this.sessionRestoreMode = mode; } async restorePersistedSessions(): Promise { const store = this.sessionStore; if (!store) return; const persisted = await store.load(); - if (!this.sessionPersistenceEnabled) { + if (this.sessionRestoreMode === "off") { if (persisted.length > 0) await store.clear(); return; } - // A subagent whose owning session is gone restores as nothing: its - // parent's runtime state no longer exists to collect its result. - const restorable = persisted.filter((descriptor) => ( - descriptor.role !== "subagent" - || persisted.some((candidate) => candidate.id === descriptor.parentSessionId) - || this.sessions.has(descriptor.parentSessionId ?? "") - )); - for (const descriptor of restorable) this.restorePersistedSession(descriptor); + // Parents come first; a subagent whose owning session is gone restores as + // nothing, since its parent's runtime state no longer exists. + const steps = planSessionRestore(persisted, this.sessionRestoreMode, { + isLiveSession: (id) => this.sessions.has(id), + environmentAvailable: (environment) => this.environmentAvailable(environment) + }); + for (const step of steps) this.restorePersistedSession(step); await this.persistSessions(); } - async setSessionPersistenceEnabled(enabled: boolean): Promise { - const next = Boolean(enabled); - if (this.sessionPersistenceEnabled === next) return; - this.sessionPersistenceEnabled = next; - if (next) await this.persistSessions(); - else await this.sessionStore?.clear(); + async setSessionRestoreMode(mode: SessionRestoreMode): Promise { + if (this.sessionRestoreMode === mode) return; + this.sessionRestoreMode = mode; + if (mode === "off") await this.sessionStore?.clear(); + else await this.persistSessions(); + } + + /** The per-card "Don't restore this card" choice. */ + setRestore(id: string, restore: boolean): SessionMetadata { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal session does not exist."); + if (typeof restore !== "boolean") throw new Error("Restore choice is invalid."); + if (restore) delete session.metadata.skipRestore; + else session.metadata.skipRestore = true; + this.emitSession(session.metadata); + this.schedulePersistence(); + return structuredClone(session.metadata); } async shutdown(): Promise { @@ -275,7 +294,7 @@ export class TerminalManager { const launched = awaitMeasuredGrid ? { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: null } : this.spawnProcess(id, request.provider, request.profile, request.cwd, - INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, false, control.captureResult, role, + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, null, control.captureResult, role, control.answerCaptureGrantExpiresAt); if (launched.failure) applyLaunchFailure(metadata, launched.failure); @@ -297,8 +316,9 @@ export class TerminalManager { ? createProviderLifecycleParser(request.provider, request.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: false, - captureResult: control.captureResult === true + resumeOnLaunch: null, + captureResult: control.captureResult === true, + extras: {} }; this.sessions.set(id, session); if (launched.process) this.bindProcess(id, session, launched.process); @@ -310,10 +330,29 @@ export class TerminalManager { return snapshot(session); } - restart(id: string): SessionSnapshot { + restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); + const environment = session.extras.environment; + if (environment && !this.environmentAvailable(environment)) { + // Never run a placed session locally instead of where it belongs. + throw new Error(`This card runs in ${environment.label} from plugin ${environment.pluginId}, which is not available. It was not started locally.`); + } + delete session.extras.heldState; + delete session.metadata.restoreNote; + let resume: ResumeRequest = null; + if (options.resume === true && session.metadata.provider !== "terminal") { + const peers = [...this.sessions.values()].filter((candidate) => ( + candidate.metadata.provider === session.metadata.provider && candidate.metadata.cwd === session.metadata.cwd + )).length; + const chosen = chooseResume(session.metadata.provider, session.threadId, peers); + resume = chosen.resume; + if (chosen.note) session.metadata.restoreNote = chosen.note; + } else { + // A plain restart is a new conversation, so the old id must not be resumed later. + delete session.threadId; + } if (session.metadata.provider === "grok") { session.agentBrowser?.cleanup(); @@ -326,9 +365,7 @@ export class TerminalManager { ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; session.awaitingInitialResize = true; - session.resumeOnLaunch = false; - delete session.resumeThreadId; - delete session.codexThreadId; + session.resumeOnLaunch = resume; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; @@ -338,7 +375,6 @@ export class TerminalManager { return snapshot(session); } - delete session.codexThreadId; session.agentOrchestration?.cleanup(); const launched = this.spawnProcess( id, @@ -347,7 +383,7 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - false, + resume, session.captureResult, session.metadata.role ); @@ -440,9 +476,9 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; - if (signal.codexThreadId && session.metadata.provider === "codex" - && signal.codexThreadId !== session.codexThreadId) { - session.codexThreadId = signal.codexThreadId; + const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); + if (threadId && threadId !== session.threadId) { + session.threadId = threadId; this.schedulePersistence(); } @@ -546,7 +582,8 @@ export class TerminalManager { } } - private restorePersistedSession(descriptor: PersistedTerminalSession): void { + private restorePersistedSession(step: RestoreStep): void { + const descriptor = step.record; if (this.sessions.has(descriptor.id)) return; const metadata: SessionMetadata = { id: descriptor.id, @@ -563,22 +600,43 @@ export class TerminalManager { status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, - failureDetails: null + failureDetails: null, + ...(step.note ? { restoreNote: step.note } : {}) + }; + const extras: PersistedSessionExtras = { + ...(descriptor.options ? { options: descriptor.options } : {}), + ...(descriptor.environment ? { environment: descriptor.environment } : {}) }; let process: IPty | null = null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; let agentRuntime: PreparedAgentRuntimePtyLaunch | null = null; let agentOrchestration: PreparedOrchestrationPtyLaunch | null = null; - let directoryReady = true; - try { - assertDirectory(descriptor.cwd); - } catch (error) { - directoryReady = false; - metadata.status = "failed"; - metadata.exitCode = 1; - metadata.failureDetails = error instanceof Error ? error.message : String(error); + let directoryReady = step.launch !== "stopped"; + if (step.launch === "stopped") { + // A finished card comes back as it ended; a placed card whose environment + // is unavailable is held with its reason and keeps its saved state. + if (step.note === "environment-unavailable" && descriptor.environment) { + extras.heldState = descriptor.lastState; + metadata.status = "failed"; + metadata.exitCode = descriptor.exitCode ?? 1; + metadata.failureDetails = `Needs plugin ${descriptor.environment.pluginId} (${descriptor.environment.label}).`; + } else { + metadata.exitCode = descriptor.exitCode ?? (descriptor.lastState === "exited" ? 0 : 1); + metadata.status = metadata.exitCode === 0 ? "done" : "failed"; + } } + if (directoryReady) { + try { + assertDirectory(descriptor.cwd); + } catch (error) { + directoryReady = false; + metadata.status = "failed"; + metadata.exitCode = 1; + metadata.failureDetails = error instanceof Error ? error.message : String(error); + } + } + const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; const awaitMeasuredGrid = directoryReady && descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"; @@ -592,11 +650,9 @@ export class TerminalManager { descriptor.cwd, INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, - descriptor.provider !== "terminal", + resume, false, - descriptor.role, - undefined, - descriptor.codexThreadId + descriptor.role ); process = launched.process; agentBrowser = launched.agentBrowser; @@ -610,6 +666,10 @@ export class TerminalManager { } } + // A card that started comes back tied to the conversation the plan chose (none for + // a fresh start); one that did not start keeps its recorded id for Continue. + const started = process !== null || awaitMeasuredGrid; + const threadId = started ? step.threadId : descriptor.threadId; const session: ManagedSession = { metadata, process, @@ -628,9 +688,10 @@ export class TerminalManager { ? createProviderLifecycleParser(descriptor.provider, descriptor.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", - ...(descriptor.codexThreadId ? { resumeThreadId: descriptor.codexThreadId, codexThreadId: descriptor.codexThreadId } : {}), - captureResult: false + resumeOnLaunch: awaitMeasuredGrid ? resume : null, + ...(threadId ? { threadId } : {}), + captureResult: false, + extras }; this.sessions.set(descriptor.id, session); if (process) this.bindProcess(descriptor.id, session, process); @@ -644,11 +705,11 @@ export class TerminalManager { } private persistSessions(): Promise { - if (!this.sessionPersistenceEnabled || this.suppressPersistence || !this.sessionStore) { + if (this.sessionRestoreMode === "off" || this.suppressPersistence || !this.sessionStore) { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.codexThreadId)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) ); } @@ -669,10 +730,8 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; session.awaitingInitialResize = false; - const resumePrevious = session.resumeOnLaunch; - session.resumeOnLaunch = false; - const resumeThreadId = session.resumeThreadId; - delete session.resumeThreadId; + const resume = session.resumeOnLaunch; + session.resumeOnLaunch = null; try { const launched = this.spawnProcess( id, @@ -681,11 +740,9 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - resumePrevious, + resume, session.captureResult, - session.metadata.role, - undefined, - resumeThreadId + session.metadata.role ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; @@ -719,11 +776,10 @@ export class TerminalManager { cwd: string, cols = INITIAL_TERMINAL_COLS, rows = INITIAL_TERMINAL_ROWS, - resumePrevious = false, + resume: ResumeRequest = null, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number, - resumeThreadId?: string + answerCaptureGrantExpiresAt?: number ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -777,8 +833,8 @@ export class TerminalManager { const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious, - ...(resumeThreadId ? { resumeThreadId } : {}) + resumePrevious: resume !== null, + ...(resume && typeof resume === "object" ? { resumeThreadId: resume.threadId } : {}) }); return { process: this.spawnPty(launch.command, launch.args, { @@ -829,6 +885,8 @@ export class TerminalManager { current.agentOrchestration?.cleanup(); current.agentOrchestration = null; this.emitSession(current.metadata); + // Recorded at the moment of exit, so a finished agent is never relaunched. + this.schedulePersistence(); }); } diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 25f462b0..a70969d1 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -8,9 +8,13 @@ import type { SessionMetadata, Size } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; -export const TERMINAL_SESSION_STORE_VERSION = 1; +export const TERMINAL_SESSION_STORE_VERSION = 2; const MAX_PERSISTED_SESSIONS = 64; +/** Opaque plugin-owned JSON (launch options, environment refs) is capped per value. */ +export const MAX_PLUGIN_SLOT_BYTES = 4_096; +const MAX_OPTION_PLUGINS = 16; const PROVIDERS = new Set([ "terminal", "codex", @@ -40,7 +44,26 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; - codexThreadId?: string; + /** The provider's own conversation id (Codex thread, Claude or OpenCode session) its hook reported. */ + threadId?: string; + /** State at quit or at the moment the process exited; v1 records read as "running". */ + lastState: PersistedLastState; + exitCode?: number | null; + /** False when the person chose "Don't restore this card". */ + restore: boolean; + /** Plugin launch options keyed by plugin id, each opaque and at most 4 KB. */ + options?: Record; + /** Where the session runs when a plugin placed it; opaque to core, at most 4 KB. */ + environment?: PersistedEnvironmentRef; +} + +export type PersistedLastState = "running" | "exited" | "failed"; + +export interface PersistedEnvironmentRef { + pluginId: string; + kind: string; + ref: unknown; + label: string; } interface PersistedTerminalSessionState { @@ -107,20 +130,24 @@ export class TerminalSessionStore { } } -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function normalizeCodexThreadId(provider: ProviderId, candidate: unknown): string | undefined { - if (provider !== "codex") return undefined; - if (typeof candidate !== "string") return undefined; - const trimmed = candidate.trim().toLowerCase(); - return UUID_REGEX.test(trimmed) ? trimmed : undefined; +function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): string | undefined { + return typeof candidate === "string" ? normalizeThreadId(provider, candidate.trim()) : undefined; } +/** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ +export type PersistedSessionExtras = Pick & { + /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ + heldState?: PersistedLastState; +}; + export function persistedTerminalSession( metadata: SessionMetadata, - codexThreadId?: unknown + threadId?: unknown, + extras: PersistedSessionExtras = {} ): PersistedTerminalSession { - const normalizedCodexThreadId = normalizeCodexThreadId(metadata.provider, codexThreadId); + const normalizedThreadId = normalizeStoredThreadId(metadata.provider, threadId); + const lastState: PersistedLastState = extras.heldState + ?? (metadata.exitCode === null ? "running" : metadata.exitCode === 0 ? "exited" : "failed"); return { id: metadata.id, provider: metadata.provider, @@ -132,14 +159,20 @@ export function persistedTerminalSession( position: { ...metadata.position }, size: { ...metadata.size }, ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), - ...(normalizedCodexThreadId !== undefined ? { codexThreadId: normalizedCodexThreadId } : {}) + ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), + restore: metadata.skipRestore !== true, + ...(extras.options ? { options: structuredClone(extras.options) } : {}), + ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}) }; } export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); - const source = candidate as Partial; - if (source.version !== TERMINAL_SESSION_STORE_VERSION || !Array.isArray(source.sessions)) { + const source = candidate as { version?: unknown; sessions?: unknown }; + // v1 is read-compatible: missing v2 fields mean unknown conversation, no environment, running. + if ((source.version !== 1 && source.version !== TERMINAL_SESSION_STORE_VERSION) || !Array.isArray(source.sessions)) { return structuredClone(EMPTY_STATE); } @@ -147,7 +180,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste const ids = new Set(); for (const value of source.sessions.slice(0, MAX_PERSISTED_SESSIONS)) { if (!value || typeof value !== "object") continue; - const session = value as Partial; + // codexThreadId: the v1 name of threadId (Codex only). + const session = value as Partial & { codexThreadId?: unknown }; if (!isSessionId(session.id) || ids.has(session.id)) continue; if (!PROVIDERS.has(session.provider as ProviderId)) continue; if (session.profile !== "normal" && session.profile !== "yolo") continue; @@ -167,9 +201,18 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste if (role === "subagent" && parentSessionId === undefined) continue; // A damaged or obsolete conversation ID must not make the whole card disappear. // It can still restore with Codex's interactive resume picker. - const codexThreadId = session.provider === "codex" && typeof session.codexThreadId === "string" && UUID_REGEX.test(session.codexThreadId.trim()) - ? session.codexThreadId.trim().toLowerCase() - : undefined; + const threadId = normalizeStoredThreadId( + session.provider as ProviderId, + session.threadId ?? (session.provider === "codex" ? session.codexThreadId : undefined) + ); + const lastState: PersistedLastState = session.lastState === "exited" || session.lastState === "failed" + ? session.lastState + : "running"; + const exitCode = Number.isInteger(session.exitCode) ? session.exitCode as number : null; + const options = normalizeOptions(session.options); + const environment = normalizeEnvironment(session.environment); + // A placed session whose ref is unreadable must not come back as a local one. + if (session.environment !== undefined && !environment) continue; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -184,13 +227,60 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste height: clamp(session.size.height, 260, 1_100) }, ...(parentSessionId !== undefined ? { parentSessionId } : {}), - ...(codexThreadId !== undefined ? { codexThreadId } : {}) + ...(threadId !== undefined ? { threadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode } : {}), + restore: session.restore !== false, + ...(options ? { options } : {}), + ...(environment ? { environment } : {}) }); ids.add(session.id); } return { version: TERMINAL_SESSION_STORE_VERSION, sessions }; } +function normalizeOptions(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const options: Record = {}; + for (const [pluginId, entry] of Object.entries(value).slice(0, MAX_OPTION_PLUGINS)) { + if (!isPluginId(pluginId) || !fitsPluginSlot(entry)) continue; + options[pluginId] = structuredClone(entry); + } + return Object.keys(options).length > 0 ? options : undefined; +} + +function normalizeEnvironment(value: unknown): PersistedEnvironmentRef | undefined { + if (!isRecord(value) || !isPluginId(value.pluginId)) return undefined; + if (typeof value.kind !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value.kind)) return undefined; + if (typeof value.label !== "string" || value.label.trim().length === 0) return undefined; + if (value.ref === undefined || !fitsPluginSlot(value.ref)) return undefined; + return { + pluginId: value.pluginId, + kind: value.kind, + ref: structuredClone(value.ref), + label: value.label.trim().slice(0, 80) + }; +} + +/** Same shape PluginManager accepts for plugin ids. */ +function isPluginId(value: unknown): value is string { + return typeof value === "string" && value.length >= 3 && value.length <= 80 + && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value) && !value.includes(".."); +} + +function fitsPluginSlot(value: unknown): boolean { + try { + const json = JSON.stringify(value); + return typeof json === "string" && Buffer.byteLength(json, "utf8") <= MAX_PLUGIN_SLOT_BYTES; + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function isSessionId(value: unknown): value is string { return typeof value === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(value); } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7fd4f551..6fe3c6b8 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -9,6 +9,7 @@ import { MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, MAX_RESULT_CHARS, + normalizeThreadId, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "../../../agent-runtime/runtime-protocol.mjs"; @@ -29,7 +30,8 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; - codexThreadId?: string; + /** The provider's own conversation id (Codex thread, Claude or OpenCode session), as its hook reported it. */ + threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; answerCaptureGrantExpiresAt?: number; @@ -59,7 +61,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; - codexThreadId?: string; + threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -316,7 +318,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, - ...(message.codexThreadId === undefined ? {} : { codexThreadId: message.codexThreadId }), + ...(message.threadId === undefined ? {} : { threadId: message.threadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -328,7 +330,7 @@ export class RuntimeGateway { state: signal.state, event: signal.event, turnId: signal.turnId, - ...(signal.codexThreadId === undefined ? {} : { codexThreadId: signal.codexThreadId }) + ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; this.onSignal?.(message.terminalSessionId, signal); } @@ -345,7 +347,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); - if (value.codexThreadId !== undefined) expected.push("codexThreadId"); + if (value.threadId !== undefined) expected.push("threadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -368,11 +370,10 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); - if (value.codexThreadId !== undefined && ( - value.provider !== "codex" - || typeof value.codexThreadId !== "string" - || !CANONICAL_UUID_RE.test(value.codexThreadId) - )) throw new Error("Runtime codexThreadId is invalid."); + // Only the provider's own id shape, already in its stored form, is accepted. + if (value.threadId !== undefined && normalizeThreadId(String(value.provider), value.threadId) !== value.threadId) { + throw new Error("Runtime threadId is invalid."); + } if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" @@ -386,8 +387,6 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } -const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" diff --git a/src/main/services/sessionRestorePlan.ts b/src/main/services/sessionRestorePlan.ts new file mode 100644 index 00000000..8afdfa68 --- /dev/null +++ b/src/main/services/sessionRestorePlan.ts @@ -0,0 +1,86 @@ +import type { ProviderId, SessionRestoreMode, SessionRestoreNote } from "../../shared/contracts.ts"; +import type { PersistedEnvironmentRef, PersistedTerminalSession } from "./TerminalSessionStore.ts"; +import { canResumeLatestConversation, canResumeThreadById, resumeWithoutIdOpensPicker } from "./terminalLaunch.ts"; + +/** + * How a card starts: a new conversation, the provider's own resume without an id + * (its "latest in this folder" flag, or Codex's resume picker), an exact one, or not at all. + */ +export type ResumeRequest = null | "latest" | { threadId: string }; + +export interface RestoreStep { + record: PersistedTerminalSession; + /** "stopped" comes back as a card with Restart / Continue; nothing is launched. */ + launch: ResumeRequest | "stopped"; + note?: SessionRestoreNote; + /** + * The conversation the card stays tied to: the recorded one when nothing starts or + * it is resumed by id, none when a new conversation starts, so Continue never goes + * back to one that is no longer the card's. + */ + threadId?: string; +} + +/** + * Picks how an agent continues its own conversation: by the id its hook reported + * when there is one. Without an id Codex opens its resume picker, and a "latest in + * this folder" flag is used only when this card is the only card of that CLI in the + * folder; otherwise two cards would continue the same conversation, so it starts + * fresh and says so. + */ +export function chooseResume( + provider: ProviderId, + threadId: string | undefined, + cardsOfProviderInFolder: number +): { resume: ResumeRequest; note?: SessionRestoreNote } { + if (threadId && canResumeThreadById(provider)) return { resume: { threadId } }; + if (resumeWithoutIdOpensPicker(provider)) return { resume: "latest" }; + if (!canResumeLatestConversation(provider)) return { resume: null }; + if (cardsOfProviderInFolder <= 1) return { resume: "latest" }; + return { resume: null, note: "fresh-shared-folder" }; +} + +/** The core's one restore order and rule set, the same for every environment. */ +export function planSessionRestore( + records: readonly PersistedTerminalSession[], + mode: SessionRestoreMode, + context: { + isLiveSession(id: string): boolean; + environmentAvailable(environment: PersistedEnvironmentRef): boolean; + } +): RestoreStep[] { + if (mode === "off") return []; + let kept = records.filter((record) => record.restore); + // A subagent comes back only with its parent; a dropped parent drops its subtree. + for (let changed = true; changed;) { + const ids = new Set(kept.map((record) => record.id)); + const next = kept.filter((record) => record.role !== "subagent" + || ids.has(record.parentSessionId ?? "") || context.isLiveSession(record.parentSessionId ?? "")); + changed = next.length !== kept.length; + kept = next; + } + const byId = new Map(kept.map((record) => [record.id, record])); + const depth = (record: PersistedTerminalSession, seen = new Set()): number => { + const parent = record.parentSessionId ? byId.get(record.parentSessionId) : undefined; + if (!parent || seen.has(parent.id)) return 0; + seen.add(record.id); + return 1 + depth(parent, seen); + }; + const ordered = kept + .map((record, index) => ({ record, index, depth: depth(record) })) + .sort((left, right) => left.depth - right.depth || left.index - right.index) + .map(({ record }) => record); + + return ordered.map((record): RestoreStep => { + const recorded = record.threadId ? { threadId: record.threadId } : {}; + if (record.environment && !context.environmentAvailable(record.environment)) { + return { record, launch: "stopped", note: "environment-unavailable", ...recorded }; + } + if (record.lastState !== "running") return { record, launch: "stopped", ...recorded }; + if (record.provider === "terminal" || mode === "reopen") return { record, launch: null }; + const peers = kept.filter((candidate) => candidate.provider === record.provider && candidate.cwd === record.cwd); + const { resume, note } = chooseResume(record.provider, record.threadId, peers.length); + return { record, launch: resume, ...(note ? { note } : {}), + ...(resume && typeof resume === "object" ? { threadId: resume.threadId } : {}) }; + }); +} diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 53cbee8a..5e95febb 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { win32 } from "node:path"; import type { ProviderId } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { openCodeYoloEnvironment } from "./openCodeConfig.ts"; import { providerTerminalBatchCommandLine, @@ -88,9 +89,37 @@ function resolveResumeArguments( } return ["resume"]; } + const byId = RESUME_BY_ID_ARGUMENTS[provider]; + if (byId && resumeThreadId) { + const threadId = normalizeThreadId(provider, resumeThreadId); + if (!threadId) throw new Error(`Invalid ${provider} session ID format: "${resumeThreadId}".`); + return byId(threadId); + } return RESUME_ARGUMENTS[provider]; } +// The same exact resume for the other CLIs whose hook reports that CLI's own session +// id, each checked against its --help: `claude -r, --resume [value]`, +// `opencode -s, --session `. Everything else continues with RESUME_ARGUMENTS. +const RESUME_BY_ID_ARGUMENTS: Partial, (id: string) => string[]>> = { + claude: (id) => ["--resume", id], + opencode: (id) => ["--session", id] +}; + +export function canResumeThreadById(provider: ProviderId): boolean { + return provider === "codex" || (provider !== "terminal" && RESUME_BY_ID_ARGUMENTS[provider] !== undefined); +} + +/** Without an id, Codex opens its own resume picker, so the person chooses; nothing is guessed. */ +export function resumeWithoutIdOpensPicker(provider: ProviderId): boolean { + return provider === "codex"; +} + +/** The CLI has a "latest conversation in this folder" flag. */ +export function canResumeLatestConversation(provider: ProviderId): boolean { + return provider !== "terminal" && provider !== "codex" && RESUME_ARGUMENTS[provider].length > 0; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. diff --git a/src/preload/index.ts b/src/preload/index.ts index f864442f..edde12fe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -188,11 +188,12 @@ const api: CanvasTTYApi = { list: () => ipcRenderer.invoke(IPC.terminalList), readBuffer: (id: string) => ipcRenderer.invoke(IPC.terminalReadBuffer, id), create: (request: CreateSessionRequest) => ipcRenderer.invoke(IPC.terminalCreate, request), - restart: (id: string) => ipcRenderer.invoke(IPC.terminalRestart, id), + restart: (id: string, options?: { resume?: boolean }) => ipcRenderer.invoke(IPC.terminalRestart, id, options), input: (id: string, data: string) => ipcRenderer.send(IPC.terminalInput, id, data), resize: (id: string, cols: number, rows: number) => ipcRenderer.send(IPC.terminalResize, id, cols, rows), setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), + setRestore: (id: string, restore: boolean) => ipcRenderer.invoke(IPC.terminalSetRestore, id, restore), dispose: (id: string) => ipcRenderer.invoke(IPC.terminalDispose, id), setVisible: (id: string, visible: boolean) => ipcRenderer.send(IPC.terminalSetVisible, id, visible), onData: (listener: (event: TerminalDataEvent) => void) => subscribe(IPC.terminalData, listener), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 754bc1ad..98188366 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -72,7 +72,7 @@ interface HomeEditDraft { const FALLBACK_SETTINGS: AppSettings = { locale: "ru", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -441,9 +441,9 @@ export function App(): React.JSX.Element { showToast(`${t(settings.locale, "sessionStarted")}: ${provider}`); }, [createSession, launchPosition, settings.locale, showToast]); - const restartSession = useCallback(async (id: string): Promise => { + const restartSession = useCallback(async (id: string, resume = false): Promise => { try { - await window.canvasTTY.terminal.restart(id); + await window.canvasTTY.terminal.restart(id, { resume }); showToast(t(settings.locale, "sessionRestarted")); } catch (error) { showToast(error instanceof Error ? error.message : t(settings.locale, "restartFailed")); diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index 74549691..450f1164 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -30,6 +30,7 @@ import type { PluginInstallPreview, PluginUpdateStatus, RadialLauncherItemId, + SessionRestoreMode, SessionRowColorMode, ShortcutAction, UpdaterState, @@ -425,9 +426,13 @@ export function SettingsPanel({ description={t(locale, "terminalSessionRestoreDescription")} > void onChange({ restoreTerminalSessions: value === "save" })} + value={settings.sessionRestoreMode} + options={[ + ["off", t(locale, "doNotSave")], + ["reopen", t(locale, "sessionRestoreReopen")], + ["continue", t(locale, "sessionRestoreContinue")] + ]} + onChange={(value) => void onChange({ sessionRestoreMode: value as SessionRestoreMode })} /> diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 6df9f022..2afb4b0a 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -66,8 +66,10 @@ interface TerminalCardProps { onRename(id: string, title: string): Promise; onRenameEnd(): void; onBoundsChange(id: string, bounds: SessionBounds): void; - onRestart(id: string): Promise; + onRestart(id: string, resume?: boolean): Promise; onDispose(id: string): void; + /** Saving sessions is on, so the per-card "Don't restore" choice applies. */ + restoreEnabled?: boolean; onOpenUrl(url: string): void; } @@ -123,7 +125,8 @@ export function TerminalCard({ onBoundsChange, onRestart, onDispose, - onOpenUrl + onOpenUrl, + restoreEnabled = false }: TerminalCardProps): React.JSX.Element { const terminalHost = useRef(null); const terminalRef = useRef(null); @@ -140,7 +143,7 @@ export function TerminalCard({ const suppressFocusReport = useRef(false); const sessionExited = useRef(session.exitCode !== null); sessionExited.current = session.exitCode !== null; - const restartAction = useRef<() => Promise>(async () => undefined); + const restartAction = useRef<(resume?: boolean) => Promise>(async () => undefined); const invertTerminalWheelRef = useRef(invertTerminalWheel); invertTerminalWheelRef.current = invertTerminalWheel; const captureCanvasWheelRef = useRef(captureCanvasWheelOverWidgets); @@ -150,6 +153,8 @@ export function TerminalCard({ const [position, setPosition] = useState(session.position); const [size, setSize] = useState(session.size); const [restarting, setRestarting] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [noteDismissed, setNoteDismissed] = useState(null); const liveBounds = useRef({ position: session.position, size: session.size }); const summaryMode = zoom < 0.5; const summaryScale = summaryMode ? Math.min(2.5, Math.max(1, 0.5 / zoom)) : 1; @@ -170,11 +175,11 @@ export function TerminalCard({ const visibleTitleRef = useRef(visibleTitle); visibleTitleRef.current = visibleTitle; - restartAction.current = async () => { + restartAction.current = async (resume = false) => { if (restarting || !sessionExited.current) return; setRestarting(true); try { - await onRestart(session.id); + await onRestart(session.id, resume); const terminal = terminalRef.current; if (terminal) window.canvasTTY.terminal.resize(session.id, terminal.cols, terminal.rows); } finally { @@ -756,6 +761,31 @@ export function TerminalCard({ )} + {session.exitCode !== null && session.provider !== "terminal" && ( + + )} + {restoreEnabled && ( + + )} + + )} {searchOpen && !summaryMode && (
; + onRestartSession(id: string, resume?: boolean): Promise; onDisposeSession(id: string): void; onBrowserBoundsChange(bounds: BrowserCanvasState): void; onFocusBrowser(): void; @@ -865,6 +865,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onBoundsChange={onSessionBoundsChange} onRestart={onRestartSession} onDispose={onDisposeSession} + restoreEnabled={settings.sessionRestoreMode !== "off"} onOpenUrl={onOpenTerminalUrl} /> ))} @@ -1017,6 +1018,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onBoundsChange={() => {}} onRestart={onRestartSession} onDispose={onDisposeSession} + restoreEnabled={settings.sessionRestoreMode !== "off"} onOpenUrl={onOpenTerminalUrl} /> ))} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 27f575f2..7ecf6946 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -260,8 +260,15 @@ const ru = { apiProfileFieldModel: "Модель", apiProfileFieldSecret: "API-ключ", language: "Язык", - terminalSessionRestore: "Окна после перезапуска", - terminalSessionRestoreDescription: "Сохраняет открытые окна; агенты продолжают последнюю сессию в той же папке, обычные терминалы открываются заново.", + terminalSessionRestore: "Сессии агентов после перезапуска", + terminalSessionRestoreDescription: "«Открыть окна» возвращает карточки, папки и роли, а агенты начинают новый разговор. «Продолжить разговоры» возвращает каждому агенту его собственный разговор. Завершённые агенты возвращаются остановленными. Буфер терминала, промпты и секреты не сохраняются.", + sessionRestoreReopen: "Открыть окна", + sessionRestoreContinue: "Продолжить разговоры", + cardOptions: "Параметры окна", + cardSkipRestore: "Не восстанавливать это окно", + continueSession: "Продолжить разговор", + restoreNoteSharedFolder: "Начат новый разговор: в этой папке открыто ещё одно окно этого агента, и последний разговор мог принадлежать ему.", + restoreNoteEnvironment: "Окно остановлено: среда, в которой оно работало, недоступна. Локально оно не запускается.", persistCanvasRegions: "Сохранять цветные зоны после выхода", persistStickyNotes: "Сохранять заметки после выхода", doNotSave: "Не сохранять", @@ -880,8 +887,15 @@ const en: Record = { apiProfileFieldModel: "Model", apiProfileFieldSecret: "API key", language: "Language", - terminalSessionRestore: "Windows after restart", - terminalSessionRestoreDescription: "Saves open windows; agents continue the latest session in the same folder, while plain terminals reopen.", + terminalSessionRestore: "Agent sessions after restart", + terminalSessionRestoreDescription: "Reopen windows brings back cards, folders and roles, and each agent starts a new conversation. Continue conversations also resumes each agent's own conversation. Finished agents come back stopped. Scrollback, prompts and secrets are never saved.", + sessionRestoreReopen: "Reopen windows", + sessionRestoreContinue: "Continue conversations", + cardOptions: "Window options", + cardSkipRestore: "Don't restore this card", + continueSession: "Continue conversation", + restoreNoteSharedFolder: "Started a new conversation: another card of this agent uses this folder, so the latest conversation could be its.", + restoreNoteEnvironment: "Stopped: the environment this card ran in is unavailable. It is not started locally.", persistCanvasRegions: "Save colored regions after exit", persistStickyNotes: "Save notes after exit", doNotSave: "Do not save", diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index a5abfa43..172aa1ab 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1110,6 +1110,11 @@ button { border: 0; } .terminal-card__search input:focus { border-color: var(--primary); } .terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } +.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 700; } +.terminal-card__menu label { display: flex; align-items: center; gap: 8px; padding: 4px 2px; cursor: pointer; } +.terminal-card__note { position: absolute; z-index: 17; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: flex-start; gap: 8px; padding: 7px 9px; border-radius: 9px; color: white; background: rgba(28,30,40,.94); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 650; line-height: 1.35; } +.terminal-card__note span { flex: 1; min-width: 0; } +.terminal-card__note button { flex: none; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search-count { min-width: 46px; color: rgba(255,255,255,.62); font: 750 11px var(--font-mono); text-align: center; } .canvas-marquee { position: absolute; z-index: 40; border: 1px solid color-mix(in srgb, var(--primary) 78%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--primary) 18%, transparent); pointer-events: none; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 3e6cc7af..01554fca 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -13,6 +13,14 @@ export type LaunchProfileId = "normal" | "yolo"; export type LaunchRole = "agent" | "orchestrator"; export type SessionRole = LaunchRole | "subagent"; export type SessionStatus = "idle" | "working" | "needs_approval" | "unavailable" | "done" | "failed"; +/** Settings → General, "Agent sessions after restart". */ +export type SessionRestoreMode = "off" | "reopen" | "continue"; +/** + * Why a restored card is not simply running as before: it started a new + * conversation because another card of that CLI shares its folder, or it is + * held stopped because the environment it ran in is unavailable. + */ +export type SessionRestoreNote = "fresh-shared-folder" | "environment-unavailable"; export type PaletteId = "sage" | "lilac" | "night"; export type HomeAccentPresetId = "classic" | "warm" | "cool" | "mono" | "custom"; export type SessionRowColorMode = "monochrome" | "status"; @@ -185,7 +193,7 @@ export interface CameraState extends Point { export interface AppSettings { locale: LocaleId; - restoreTerminalSessions: boolean; + sessionRestoreMode: SessionRestoreMode; persistCanvasRegions: boolean; persistStickyNotes: boolean; palette: PaletteId; @@ -280,6 +288,9 @@ export interface SessionMetadata { startedAt: number; exitCode: number | null; failureDetails: string | null; + /** Set when the person chose "Don't restore this card". */ + skipRestore?: boolean; + restoreNote?: SessionRestoreNote; } export interface SessionSnapshot extends SessionMetadata { @@ -1179,11 +1190,13 @@ export interface CanvasTTYApi { list(): Promise; readBuffer(id: string): Promise; create(request: CreateSessionRequest): Promise; - restart(id: string): Promise; + /** `resume` continues the card's own conversation instead of starting a new one. */ + restart(id: string, options?: { resume?: boolean }): Promise; input(id: string, data: string): void; resize(id: string, cols: number, rows: number): void; setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; + setRestore(id: string, restore: boolean): Promise; dispose(id: string): Promise; /** Report whether the card renders live output; hidden cards keep history but skip streaming. */ setVisible(id: string, visible: boolean): void; @@ -1312,6 +1325,7 @@ export const IPC = { terminalResize: "terminal:resize", terminalBounds: "terminal:bounds", terminalRename: "terminal:rename", + terminalSetRestore: "terminal:set-restore", terminalDispose: "terminal:dispose", terminalData: "terminal:data", terminalSession: "terminal:session", diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index a70b4978..d92e7b0f 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -240,7 +240,7 @@ test("a control write waiting on terminal replay cannot reach a restarted sessio test("YOLO persists across native restart/restore while stale control grants fail", localSocket, async (t) => { const f = await fixture(t); - f.terminals.configureSessionPersistence(new TerminalSessionStore(f.root), true); + f.terminals.configureSessionPersistence(new TerminalSessionStore(f.root), "continue"); const { session } = await f.create(); f.calls[0].pty.exit(1); await delay(2); @@ -251,7 +251,7 @@ test("YOLO persists across native restart/restore while stale control grants fai const restoredCalls = []; const restored = new TerminalManager(() => {}, registry(), undefined, undefined, true, (_command, args) => { restoredCalls.push(args); return { onData() {}, onExit() {}, kill() {}, write() {}, resize() {} }; }); - restored.configureSessionPersistence(new TerminalSessionStore(f.root), true); + restored.configureSessionPersistence(new TerminalSessionStore(f.root), "continue"); await restored.restorePersistedSessions(); t.after(() => restored.shutdown()); assert.ok(restoredCalls[0].includes("--dangerously-bypass-approvals-and-sandbox")); diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index 89992067..38cf80c6 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -46,12 +46,17 @@ test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local }, stdio: ["pipe", "ignore", "pipe"] }); - child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one" })); + child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one", session_id: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" })); const result = await childResult(child); assert.equal(result.code, 0, result.stderr); assert.deepEqual(signals, [{ id: "terminal-one", - signal: { state: "working", event: "UserPromptSubmit", turnId: "turn-one" } + signal: { + state: "working", + event: "UserPromptSubmit", + turnId: "turn-one", + threadId: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" + } }]); assert.equal(JSON.stringify(signals).includes("must stay local"), false); }); @@ -105,9 +110,13 @@ test("RuntimeGateway rejects a wrong capability and ignores a stale turn complet assert.deepEqual(signals.map(({ signal }) => signal.state), ["working"]); assert.equal(gateway.currentStatus("terminal-two"), "working"); + + // A thread id reaches a provider argv on restore, so a flag-shaped one is refused. + await send(capability.address, { ...message(capability, "idle", "Stop", "turn-new"), threadId: "--config=evil" }); + assert.deepEqual(signals.map(({ signal }) => signal.state), ["working"]); }); -test("RuntimeGateway propagates codexThreadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { +test("RuntimeGateway propagates threadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); @@ -132,7 +141,7 @@ test("RuntimeGateway propagates codexThreadId for codex sessions with canonical assert.equal(result.code, 0, result.stderr); assert.equal(signals.length, 1); assert.equal(signals[0].signal.turnId, "turn-codex-1"); - assert.equal(signals[0].signal.codexThreadId, validUuid); + assert.equal(signals[0].signal.threadId, validUuid); }); test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for codex", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { @@ -160,36 +169,36 @@ test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for c const result = await childResult(child); assert.equal(result.code, 0, result.stderr); assert.equal(signals.length, 1); - assert.equal(signals[0].signal.codexThreadId, lowerUuid); + assert.equal(signals[0].signal.threadId, lowerUuid); }); -test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { +test("RuntimeGateway ignores threadId when non-canonical or from a provider without exact resume", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); await gateway.start(); t.after(() => gateway.close()); - // 1. Cross-provider: claude provider with session_id UUID in hook input - const claudeCap = gateway.registerSession("terminal-claude-test", "claude"); + // 1. Cross-provider: qwen has no exact resume, so its session_id UUID is dropped + const qwenCap = gateway.registerSession("terminal-qwen-test", "qwen"); const validUuid = "12345678-1234-1234-1234-123456789abc"; const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); - const claudeChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + const qwenChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { env: { ...process.env, - [AGENT_RUNTIME_ENV.address]: claudeCap.address, - [AGENT_RUNTIME_ENV.terminalSessionId]: claudeCap.terminalSessionId, - [AGENT_RUNTIME_ENV.provider]: claudeCap.provider, - [AGENT_RUNTIME_ENV.capabilityToken]: claudeCap.capabilityToken + [AGENT_RUNTIME_ENV.address]: qwenCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: qwenCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: qwenCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: qwenCap.capabilityToken }, stdio: ["pipe", "ignore", "pipe"] }); - claudeChild.stdin.end(JSON.stringify({ turn_id: "turn-claude-1", session_id: validUuid })); - const claudeResult = await childResult(claudeChild); - assert.equal(claudeResult.code, 0, claudeResult.stderr); + qwenChild.stdin.end(JSON.stringify({ turn_id: "turn-qwen-1", session_id: validUuid })); + const qwenResult = await childResult(qwenChild); + assert.equal(qwenResult.code, 0, qwenResult.stderr); assert.equal(signals.length, 1); - assert.equal(signals[0].signal.codexThreadId, undefined); + assert.equal(signals[0].signal.threadId, undefined); // 2. Malformed UUID: not canonical format (e.g. invalid chars, wrong length, path traversal) const codexCap = gateway.registerSession("terminal-codex-malformed", "codex"); @@ -216,18 +225,18 @@ test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider" const badRes = await childResult(childBad); assert.equal(badRes.code, 0, badRes.stderr); } - // All malformed ones should either be omitted or ignored without codexThreadId + // All malformed ones should either be omitted or ignored without threadId for (let i = 1; i < signals.length; i++) { - assert.equal(signals[i].signal.codexThreadId, undefined); + assert.equal(signals[i].signal.threadId, undefined); } - // 3. Direct protocol injection with cross-provider codexThreadId is rejected - await send(claudeCap.address, { - ...message(claudeCap, "working", "UserPromptSubmit", "turn-claude-direct"), - codexThreadId: validUuid + // 3. Direct protocol injection with cross-provider threadId is rejected + await send(qwenCap.address, { + ...message(qwenCap, "working", "UserPromptSubmit", "turn-qwen-direct"), + threadId: validUuid }); // Signal should not be delivered or accepted - assert.equal(signals.filter((s) => s.id === "terminal-claude-test").length, 1); + assert.equal(signals.filter((s) => s.id === "terminal-qwen-test").length, 1); }); test("ordinary Codex Stop reports omit answer text without an explicit capture grant", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { diff --git a/tests/api-profiles.test.mjs b/tests/api-profiles.test.mjs index c8e395f1..545a78ce 100644 --- a/tests/api-profiles.test.mjs +++ b/tests/api-profiles.test.mjs @@ -65,7 +65,7 @@ test("the profile catalog is capped at 32 entries", () => { assert.equal(normalizeApiProfiles(many, []).length, 32); }); -test("api profiles persist through the settings store and settingsVersion reaches 20", async (t) => { +test("api profiles persist through the settings store and settingsVersion is current", async (t) => { const directory = await mkdtemp(join(tmpdir(), "canvastty-settings-apiprofiles-")); t.after(() => rm(directory, { recursive: true, force: true })); const store = new SettingsStore(directory, "en"); @@ -85,7 +85,7 @@ test("api profiles persist through the settings store and settingsVersion reache assert.equal(reloaded.apiProfiles[1].defaultModel, "claude-sonnet-4-6"); const persisted = JSON.parse(await (await import("node:fs/promises")).readFile(join(directory, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.apiProfiles.length, 2); }); diff --git a/tests/attention-notifications.test.mjs b/tests/attention-notifications.test.mjs index f85222df..cea73027 100644 --- a/tests/attention-notifications.test.mjs +++ b/tests/attention-notifications.test.mjs @@ -112,7 +112,7 @@ test("a restored session whose folder vanished announces its failure as restore- try { const store = await persistedStore(dir, join(dir, "deleted-folder")); const { manager, announcements } = createManager(t); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); @@ -131,7 +131,7 @@ test("a restored session whose CLI is gone announces its failure as restore-deri const store = await persistedStore(dir, process.cwd()); const { manager, announcements, availability } = createManager(t); availability.state = "unavailable"; - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); @@ -147,7 +147,7 @@ test("a restored session that still launches announces no failure origin", async try { const store = await persistedStore(dir, process.cwd()); const { manager, announcements } = createManager(t); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); diff --git a/tests/session-hierarchy.test.mjs b/tests/session-hierarchy.test.mjs index b0d16a85..1d81061f 100644 --- a/tests/session-hierarchy.test.mjs +++ b/tests/session-hierarchy.test.mjs @@ -133,7 +133,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const calls = []; const first = manager(calls); const store = new TerminalSessionStore(directory); - first.configureSessionPersistence(store, true); + first.configureSessionPersistence(store, "continue"); const parent = first.create({ provider: "codex", cwd: process.cwd(), @@ -160,7 +160,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const secondCalls = []; const second = manager(secondCalls); - second.configureSessionPersistence(new TerminalSessionStore(directory), true); + second.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await second.restorePersistedSessions(); const restored = second.list(); assert.deepEqual( @@ -178,7 +178,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const thirdCalls = []; const third = manager(thirdCalls); - third.configureSessionPersistence(new TerminalSessionStore(directory), true); + third.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await third.restorePersistedSessions(); assert.deepEqual(third.list().map((session) => session.role), ["agent"]); await third.shutdown(); diff --git a/tests/session-restore-v2.test.mjs b/tests/session-restore-v2.test.mjs new file mode 100644 index 00000000..0408358d --- /dev/null +++ b/tests/session-restore-v2.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { + TerminalSessionStore, + normalizePersistedTerminalSessions +} from "../src/main/services/TerminalSessionStore.ts"; +import { planSessionRestore } from "../src/main/services/sessionRestorePlan.ts"; +import { SettingsStore } from "../src/main/services/SettingsStore.ts"; + +const CONVERSATION = "5f1c2a90-aa11-4b22-9c33-0d44e55f6677"; +const base = { + provider: "claude", + profile: "normal", + role: "agent", + title: "Agent", + titleCustomized: false, + cwd: process.cwd(), + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } +}; +const record = (id, extra = {}) => ({ ...base, id, lastState: "running", restore: true, ...extra }); +const noEnvironment = { isLiveSession: () => false, environmentAvailable: () => false }; + +function registry() { + return { + get(provider) { + return { state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", + environment: { PATH: "/usr/bin" }, checked: [] }; + }, + snapshot() { return {}; } + }; +} + +function spawner(calls) { + return (command, args, options) => { + const exits = []; + calls.push({ command, args, options, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }); + return { + pid: 30_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; +} + +async function withManagers(t) { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restore-v2-")); + const managers = []; + // Hooks run in order: stop every manager (and its pending writes) before removing the folder. + t.after(() => Promise.all(managers.map((manager) => manager.shutdown()))); + t.after(() => rm(directory, { recursive: true, force: true })); + return { + directory, + async start(mode, calls = []) { + const manager = new TerminalManager(() => undefined, registry(), undefined, undefined, true, spawner(calls)); + managers.push(manager); + manager.configureSessionPersistence(new TerminalSessionStore(directory), mode); + await manager.restorePersistedSessions(); + return { manager, calls }; + } + }; +} + +test("a v1 store is read as v2 with no environment and running state; a v1 Codex thread id is kept", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-store-v1-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const store = new TerminalSessionStore(directory); + await writeFile(store.filePath, JSON.stringify({ version: 1, sessions: [ + { ...base, id: "legacy" }, + { ...base, id: "legacy-codex", provider: "codex", codexThreadId: CONVERSATION } + ] })); + assert.deepEqual(await store.load(), [record("legacy"), record("legacy-codex", { provider: "codex", threadId: CONVERSATION })]); + await store.flush(); + const written = JSON.parse(await readFile(store.filePath, "utf8")); + assert.equal(written.version, 2); + assert.deepEqual(normalizePersistedTerminalSessions({ version: 3, sessions: [record("future")] }).sessions, []); +}); + +test("v2 slots are validated: thread ids, 4 KB plugin options and environment refs", () => { + const environment = { pluginId: "canvastty.environments", kind: "container", ref: { id: "c-1" }, label: "Container" }; + const { sessions } = normalizePersistedTerminalSessions({ + version: 2, + sessions: [ + record("kept", { + lastState: "failed", exitCode: 2, + threadId: CONVERSATION, + options: { "good.plugin": { a: 1 }, "Bad Id": {}, "big.plugin": "x".repeat(5_000) }, + environment + }), + record("flag-id", { threadId: "--resume-evil", restore: false }), + record("bad-environment", { environment: { ...environment, ref: "x".repeat(5_000) } }) + ] + }); + assert.deepEqual(sessions, [ + record("kept", { lastState: "failed", exitCode: 2, threadId: CONVERSATION, + options: { "good.plugin": { a: 1 } }, environment }), + record("flag-id", { restore: false }) + ]); +}); + +test("the restore plan orders parents first and applies the per-card and shared-folder rules", () => { + const records = [ + record("child", { role: "subagent", parentSessionId: "parent" }), + record("parent", { role: "orchestrator", threadId: CONVERSATION }), + record("skipped", { restore: false }), + record("orphan", { role: "subagent", parentSessionId: "skipped" }), + record("finished", { provider: "codex", cwd: "/elsewhere", lastState: "exited", exitCode: 0, threadId: CONVERSATION }), + record("solo", { provider: "codex", cwd: "/solo" }), + record("shared-codex", { provider: "codex", cwd: "/solo" }), + record("placed", { provider: "codex", cwd: "/placed", threadId: CONVERSATION, + environment: { pluginId: "canvastty.environments", kind: "container", ref: {}, label: "Container" } }) + ]; + const steps = planSessionRestore(records, "continue", noEnvironment); + assert.deepEqual(steps.map((step) => [step.record.id, step.launch, step.note]), [ + ["parent", { threadId: CONVERSATION }, undefined], + ["finished", "stopped", undefined], + // Without an id Codex opens its resume picker, even beside another Codex card. + ["solo", "latest", undefined], + ["shared-codex", "latest", undefined], + ["placed", "stopped", "environment-unavailable"], + ["child", null, "fresh-shared-folder"] + ]); + // Stopped and held cards keep their id for Continue; only an exact resume carries it into a start. + assert.deepEqual(steps.map((step) => step.threadId), + [CONVERSATION, CONVERSATION, undefined, undefined, CONVERSATION, undefined]); + const reopen = planSessionRestore(records, "reopen", noEnvironment); + assert.deepEqual(reopen.map((step) => step.launch), [null, "stopped", null, null, "stopped", null]); + assert.deepEqual(reopen.map((step) => step.threadId), + [undefined, CONVERSATION, undefined, undefined, CONVERSATION, undefined]); + assert.deepEqual(planSessionRestore(records, "off", noEnvironment), []); +}); + +test("Continue resumes each card's own conversation and never shares the folder's latest one", async (t) => { + const fixture = await withManagers(t); + const { manager } = await fixture.start("continue"); + const first = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const opencode = manager.create({ provider: "opencode", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + manager.applyProviderSignal(opencode.id, { kind: "lifecycle", state: "idle", threadId: "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn" }); + await manager.shutdown(); + + const { manager: restored, calls } = await fixture.start("continue"); + const [claudeById, claudeShared, codexSolo, opencodeById] = calls.map((call) => call.args); + assert.deepEqual(opencodeById.slice(-2), ["--session", "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn"]); + assert.deepEqual(claudeById.slice(-2), ["--resume", CONVERSATION]); + assert.equal(claudeShared.includes("--continue"), false); + assert.equal(codexSolo.at(-1), "resume"); + assert.deepEqual(restored.list().map((session) => session.restoreNote), [undefined, "fresh-shared-folder", undefined, undefined]); + const saved = JSON.parse(await readFile(join(fixture.directory, "terminal-sessions.json"), "utf8")); + assert.equal(saved.sessions[0].threadId, CONVERSATION); + assert.doesNotMatch(JSON.stringify(saved), /buffer|prompt|token|capability/u); +}); + +test("Reopen windows starts every agent fresh and forgets the old conversation; Don't save clears the store", async (t) => { + const fixture = await withManagers(t); + const savedThreadIds = async () => JSON.parse(await readFile(join(fixture.directory, "terminal-sessions.json"), "utf8")) + .sessions.map((session) => session.threadId); + // 1. A running card is saved with conversation A. + const { manager } = await fixture.start("continue"); + const card = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(card.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + await manager.shutdown(); + assert.deepEqual(await savedThreadIds(), [CONVERSATION]); + + // 2. Reopen starts it fresh and no lifecycle hook reports a new id. + const { manager: reopened, calls } = await fixture.start("reopen"); + // 3. Neither the launch nor the saved record is tied to A, also after switching to Continue conversations. + assert.equal(calls.length, 1); + assert.equal(calls[0].args.includes("--resume"), false); + assert.equal(calls[0].args.includes(CONVERSATION), false); + assert.deepEqual(await savedThreadIds(), [undefined]); + await reopened.setSessionRestoreMode("continue"); + assert.deepEqual(await savedThreadIds(), [undefined]); + // 4. After the fresh process exits, Continue does not resume A. + calls[0].exit(0); + reopened.restart(card.id, { resume: true }); + assert.equal(calls.length, 2); + assert.equal(calls[1].args.includes("--resume"), false); + assert.equal(calls[1].args.includes(CONVERSATION), false); + await reopened.setSessionRestoreMode("off"); + assert.deepEqual(new TerminalSessionStore(fixture.directory).get(), []); + assert.deepEqual(await new TerminalSessionStore(fixture.directory).load(), []); +}); + +test("finished cards come back stopped with Restart and Continue; skipped cards stay gone", async (t) => { + const fixture = await withManagers(t); + const { manager, calls: firstCalls } = await fixture.start("continue"); + const finished = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const skipped = manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(finished.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + firstCalls[0].exit(0); + assert.equal(manager.setRestore(skipped.id, false).skipRestore, true); + await manager.shutdown(); + + const { manager: restored, calls } = await fixture.start("continue"); + assert.equal(calls.length, 0); + assert.deepEqual(restored.list().map(({ id, status, exitCode }) => ({ id, status, exitCode })), + [{ id: finished.id, status: "done", exitCode: 0 }]); + restored.restart(finished.id, { resume: true }); + assert.deepEqual(calls[0].args.slice(-2), ["--resume", CONVERSATION]); + // A plain Restart is a new conversation: the id is forgotten, so Continue later cannot pick the old one. + calls[0].exit(0); + restored.restart(finished.id); + assert.equal(calls[1].args.includes("--resume"), false); + assert.equal(restored.list()[0].exitCode, null); + calls[1].exit(0); + restored.restart(finished.id, { resume: true }); + assert.deepEqual(calls[2].args.slice(-1), ["--continue"]); +}); + +test("a placed card whose environment is unavailable stays stopped and never runs locally", async (t) => { + const fixture = await withManagers(t); + const environment = { pluginId: "canvastty.environments", kind: "container", ref: { id: "c-1" }, label: "Container" }; + await new TerminalSessionStore(fixture.directory).replace([record("placed", { environment, threadId: CONVERSATION })]); + const { manager, calls } = await fixture.start("continue"); + assert.equal(calls.length, 0); + const [card] = manager.list(); + assert.equal(card.restoreNote, "environment-unavailable"); + assert.match(card.failureDetails, /canvastty\.environments/u); + assert.throws(() => manager.restart("placed"), /not started locally/u); + assert.equal(calls.length, 0); + await manager.shutdown(); + const [kept] = await new TerminalSessionStore(fixture.directory).load(); + assert.equal(kept.lastState, "running"); + assert.equal(kept.threadId, CONVERSATION); + assert.deepEqual(kept.environment, environment); +}); + +test("settings migrate the old switch: true continues conversations, false saves nothing", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-settings-restore-mode-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const [legacy, expected] of [[true, "continue"], [false, "off"]]) { + await writeFile(join(directory, "settings.json"), JSON.stringify({ settingsVersion: 20, restoreTerminalSessions: legacy })); + const settings = await new SettingsStore(directory, "en").load(); + assert.equal(settings.sessionRestoreMode, expected); + const written = JSON.parse(await readFile(join(directory, "settings.json"), "utf8")); + assert.equal(written.sessionRestoreMode, expected); + assert.equal("restoreTerminalSessions" in written, false); + } +}); diff --git a/tests/settings-normalizer.test.mjs b/tests/settings-normalizer.test.mjs index 987f7d56..8eb47c07 100644 --- a/tests/settings-normalizer.test.mjs +++ b/tests/settings-normalizer.test.mjs @@ -12,7 +12,7 @@ import { const fallback = { locale: "en", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -160,7 +160,8 @@ test("terminal restore remains opt-in and canvas regions are bounded and normali }] }, fallback); - assert.equal(normalized.restoreTerminalSessions, true); + // The old on/off switch continued conversations, so "on" migrates to "continue". + assert.equal(normalized.sessionRestoreMode, "continue"); assert.deepEqual(normalized.canvasRegions, [{ id: "region-1", title: "Backend", @@ -168,7 +169,11 @@ test("terminal restore remains opt-in and canvas regions are bounded and normali position: { x: 20, y: 30 }, size: { width: 360, height: 3_000 } }]); - assert.equal(normalizeSettings({ restoreTerminalSessions: "yes" }, fallback).restoreTerminalSessions, false); + assert.equal(normalizeSettings({ restoreTerminalSessions: "yes" }, fallback).sessionRestoreMode, "off"); + assert.equal(normalizeSettings({ restoreTerminalSessions: false }, fallback).sessionRestoreMode, "off"); + assert.equal(normalizeSettings({ sessionRestoreMode: "reopen", restoreTerminalSessions: true }, fallback).sessionRestoreMode, "reopen"); + assert.equal(normalizeSettings({ sessionRestoreMode: "later" }, fallback).sessionRestoreMode, "off"); + assert.equal("restoreTerminalSessions" in normalized, false); }); test("colored regions and notes use independent exit persistence gates", async () => { @@ -419,7 +424,7 @@ test("the Qwen migration does not rerun the older expanded-limit migration", asy assert.deepEqual(loaded.homeLimitProviders, ["codex", "claude", "kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["codex", "claude", "kimi"]); } finally { @@ -442,7 +447,7 @@ test("the limit-display migration preserves a version-three launcher subset", as assert.deepEqual(loaded.homeLimitProviders, fallback.homeLimitProviders); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, fallback.homeLimitProviders); } finally { @@ -462,7 +467,7 @@ test("the expanded limit migration preserves a curated version-four subset", asy assert.deepEqual(loaded.homeLimitProviders, ["kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["kimi"]); } finally { @@ -614,7 +619,7 @@ test("existing profiles migrate minimap interaction to click and persist later c const store = new SettingsStore(dir, "en"); assert.equal((await store.load()).minimapInteractionMode, "click"); let persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.minimapInteractionMode, "click"); await store.update({ minimapInteractionMode: "drag" }); diff --git a/tests/settings-ui.test.mjs b/tests/settings-ui.test.mjs index 75e0e00d..bea2e2d3 100644 --- a/tests/settings-ui.test.mjs +++ b/tests/settings-ui.test.mjs @@ -92,8 +92,8 @@ test("canvas overlays share configurable collision-safe corner slots", async () test("General keeps independent persistence controls", async () => { const settings = await readFile(settingsPanelPath, "utf8"); - assert.match(settings, /settings\.restoreTerminalSessions \? "save" : "discard"/); - assert.match(settings, /restoreTerminalSessions: value === "save"/); + assert.match(settings, /value=\{settings\.sessionRestoreMode\}/); + assert.match(settings, /\["off", t\(locale, "doNotSave"\)\],\s*\["reopen", t\(locale, "sessionRestoreReopen"\)\],\s*\["continue", t\(locale, "sessionRestoreContinue"\)\]/); assert.match(settings, /settings\.persistCanvasRegions \? "save" : "discard"/); assert.match(settings, /persistCanvasRegions: value === "save"/); assert.match(settings, /settings\.persistStickyNotes \? "save" : "discard"/); diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index 0f621a01..02def236 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -131,14 +131,29 @@ test("Codex restore throws on malformed resumeThreadId", () => { } }); -test("Other providers ignore resumeThreadId and retain standard continue flags", () => { +test("Claude and OpenCode resume their own session by id; other providers keep their continue flags", () => { const uuid = "12345678-1234-4234-8234-123456789abc"; const claude = resolveTerminalLaunch("claude", "normal", ["--bridge"], { providerCli: available("claude", "/resolved/claude"), resumePrevious: true, - resumeThreadId: uuid + resumeThreadId: uuid.toUpperCase() + }); + assert.deepEqual(claude.args, ["--bridge", "--resume", uuid]); + + const opencode = resolveTerminalLaunch("opencode", "normal", [], { + providerCli: available("opencode", "/resolved/opencode"), + resumePrevious: true, + resumeThreadId: "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn" }); - assert.deepEqual(claude.args, ["--bridge", "--continue"]); + assert.deepEqual(opencode.args, ["--session", "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn"]); + + for (const [provider, malformed] of [["claude", "--config=evil"], ["claude", "not-a-uuid"], ["opencode", uuid], ["opencode", "ses_../x"]]) { + assert.throws(() => resolveTerminalLaunch(provider, "normal", [], { + providerCli: available(provider, `/resolved/${provider}`), + resumePrevious: true, + resumeThreadId: malformed + }), /session ID format/u); + } const qwen = resolveTerminalLaunch("qwen", "yolo", [], { providerCli: available("qwen", "/resolved/qwen"), diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 811f7881..8eafbd43 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -167,13 +167,13 @@ test("an exited PTY can restart in place without recreating its xterm card", asy readFile(terminalManagerPath, "utf8") ]); - assert.match(manager, /restart\(id: string\): SessionSnapshot/); + assert.match(manager, /restart\(id: string, options: \{ resume\?: boolean \} = \{\}\): SessionSnapshot/); assert.match(manager, /session\.metadata\.exitCode === null/); assert.match(manager, /session\.metadata\.status = initialSessionStatus\(session\.metadata\.provider\)/); assert.match(manager, /session\.metadata\.failureDetails = null/); assert.match(manager, /if \(launched\.process\) this\.bindProcess\(id, session, launched\.process\)/); assert.match(card, /shouldRestartExitedTerminal\(event, sessionExited\.current\)/); - assert.match(card, /onRestart\(session\.id\)/); + assert.match(card, /onRestart\(session\.id, resume\)/); }); test("failed PTYs preserve their final sanitized output as failure details", async () => { diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs index fa55a463..c557eddf 100644 --- a/tests/terminal-session-exact-resume.test.mjs +++ b/tests/terminal-session-exact-resume.test.mjs @@ -51,7 +51,7 @@ function manager(directory, calls) { true, spawner(calls) ); - instance.configureSessionPersistence(new TerminalSessionStore(directory), true); + instance.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); return instance; } @@ -63,8 +63,8 @@ test("restoring two Codex cards in one cwd resumes their own conversations", asy const first = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); const second = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 20, y: 20 } }); // SessionStart is idle while a new card is already idle. The ID must still persist. - initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); - initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", codexThreadId: SECOND_THREAD }); + initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", threadId: FIRST_THREAD }); + initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", threadId: SECOND_THREAD }); await initial.shutdown(); const calls = []; @@ -141,14 +141,14 @@ test("restarting a Codex card after an exit clears stale thread ID and launches customSpawner ); const store = new TerminalSessionStore(directory); - inst.configureSessionPersistence(store, true); + inst.configureSessionPersistence(store, "continue"); await inst.restorePersistedSessions(); const created = inst.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); - inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", threadId: FIRST_THREAD }); // Verify stored - assert.equal(store.get()[0]?.codexThreadId, FIRST_THREAD); + assert.equal(store.get()[0]?.threadId, FIRST_THREAD); // Simulate exit exitHandler?.({ exitCode: 0 }); @@ -162,7 +162,7 @@ test("restarting a Codex card after an exit clears stale thread ID and launches assert.equal(calls[1].args.includes(FIRST_THREAD), false); // Stored session should no longer have the stale thread ID - assert.equal(store.get()[0]?.codexThreadId, undefined); + assert.equal(store.get()[0]?.threadId, undefined); await inst.shutdown(); } finally { diff --git a/tests/terminal-session-restore.test.mjs b/tests/terminal-session-restore.test.mjs index 19bd3558..5984b7ad 100644 --- a/tests/terminal-session-restore.test.mjs +++ b/tests/terminal-session-restore.test.mjs @@ -52,7 +52,7 @@ test("opt-in restore preserves card identity and relaunches the agent in native true, fakeSpawner(firstCalls) ); - first.configureSessionPersistence(new TerminalSessionStore(directory), true); + first.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await first.restorePersistedSessions(); const created = first.create({ provider: "codex", @@ -76,7 +76,7 @@ test("opt-in restore preserves card identity and relaunches the agent in native true, fakeSpawner(restoredCalls) ); - restored.configureSessionPersistence(new TerminalSessionStore(directory), true); + restored.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await restored.restorePersistedSessions(); assert.equal(restoredCalls.length, 1); @@ -122,7 +122,7 @@ test("the default opt-out clears old descriptors instead of restoring them", asy true, fakeSpawner(calls) ); - manager.configureSessionPersistence(store, false); + manager.configureSessionPersistence(store, "off"); await manager.restorePersistedSessions(); assert.deepEqual(manager.list(), []); assert.deepEqual(store.get(), []); @@ -155,7 +155,7 @@ test("a restored Grok session still waits for the measured grid before continuin true, fakeSpawner(calls) ); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); assert.equal(calls.length, 0); manager.resize("grok-session", 71, 17); diff --git a/tests/terminal-session-store.test.mjs b/tests/terminal-session-store.test.mjs index 630ca2b5..2847ba31 100644 --- a/tests/terminal-session-store.test.mjs +++ b/tests/terminal-session-store.test.mjs @@ -18,7 +18,9 @@ const descriptor = { titleCustomized: true, cwd: process.cwd(), position: { x: 120, y: 40 }, - size: { width: 700, height: 430 } + size: { width: 700, height: 430 }, + lastState: "running", + restore: true }; test("terminal window descriptors persist atomically without scrollback or environment", async () => { @@ -93,7 +95,7 @@ test("legacy roles restore as agents while unknown roles are dropped", async () } }); -test("codexThreadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { +test("threadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { const threadIdUpper = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D"; const threadIdCanonical = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; @@ -106,17 +108,18 @@ test("codexThreadId persists and normalizes to canonical lower-case UUID for cod failureDetails: null }; - // persistedTerminalSession helper round-trips valid codexThreadId + // persistedTerminalSession helper round-trips a valid thread id const persisted = persistedTerminalSession(sessionMetadata, ` ${threadIdUpper} `); - assert.equal(persisted.codexThreadId, threadIdCanonical); + assert.equal(persisted.threadId, threadIdCanonical); - // Non-codex provider ignores codexThreadId in persistedTerminalSession helper - const claudePersisted = persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper); - assert.equal(claudePersisted.codexThreadId, undefined); + // A provider without exact resume ignores the id; Claude keeps its UUID session id + const qwenPersisted = persistedTerminalSession({ ...sessionMetadata, provider: "qwen" }, threadIdUpper); + assert.equal(qwenPersisted.threadId, undefined); + assert.equal(persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper).threadId, threadIdCanonical); // Malformed thread IDs are ignored in persistedTerminalSession helper - assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").codexThreadId, undefined); - assert.equal(persistedTerminalSession(sessionMetadata, 12345).codexThreadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").threadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, 12345).threadId, undefined); // Store persistence and load round-trip const directory = await mkdtemp(join(tmpdir(), "canvastty-terminal-state-codex-thread-")); @@ -125,7 +128,7 @@ test("codexThreadId persists and normalizes to canonical lower-case UUID for cod await store.replace([persisted]); const reloaded = await store.load(); assert.equal(reloaded.length, 1); - assert.equal(reloaded[0].codexThreadId, threadIdCanonical); + assert.equal(reloaded[0].threadId, threadIdCanonical); } finally { await rm(directory, { recursive: true, force: true }); } @@ -136,7 +139,7 @@ test("normalizePersistedTerminalSessions preserves cards with malformed or forei const normalized = normalizePersistedTerminalSessions({ version: 1, sessions: [ - // Valid codex thread ID + // Valid codex thread ID, under its v1 name { ...descriptor, id: "valid-codex", provider: "codex", codexThreadId: validUuid }, // Valid codex session without codexThreadId (backward compatibility) { ...descriptor, id: "valid-codex-no-thread", provider: "codex" }, @@ -154,7 +157,7 @@ test("normalizePersistedTerminalSessions preserves cards with malformed or forei "valid-codex", "valid-codex-no-thread", "bad-uuid", "bad-type-uuid", "claude-with-thread", "terminal-with-thread" ]); - assert.equal(normalized.sessions[0].codexThreadId, validUuid); - assert.equal(normalized.sessions[1].codexThreadId, undefined); - assert.ok(normalized.sessions.slice(2).every((session) => session.codexThreadId === undefined)); + assert.equal(normalized.sessions[0].threadId, validUuid); + assert.equal(normalized.sessions[1].threadId, undefined); + assert.ok(normalized.sessions.slice(2).every((session) => session.threadId === undefined)); });