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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/agent-runtime/hook-helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,6 +63,7 @@ await reportLifecycle({
state,
event,
turnId,
...(codexThreadId ? { codexThreadId } : {}),
...(result === undefined ? {} : { result }),
...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage })
});
Expand Down
13 changes: 12 additions & 1 deletion src/agent-runtime/runtime-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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",
Expand All @@ -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]);
Expand Down Expand Up @@ -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);
}
3 changes: 2 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ async function initializeServices(): Promise<void> {
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) {
Expand Down
33 changes: 28 additions & 5 deletions src/main/services/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ interface ManagedSession {
lifecycle: ProviderLifecycleParser | null;
awaitingInitialResize: boolean;
resumeOnLaunch: boolean;
resumeThreadId?: string;
codexThreadId?: string;
captureResult: boolean;
}

export interface ProviderLifecycleSignal {
kind: "lifecycle";
state: "idle" | "working" | "needs_approval";
requestId?: string;
codexThreadId?: string;
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -368,6 +375,7 @@ export class TerminalManager {
if (runtimeStatus) session.metadata.status = runtimeStatus;
}
this.emitSession(session.metadata, failureOrigin);
this.schedulePersistence();
return snapshot(session);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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))
);
}

Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, {
Expand Down
27 changes: 24 additions & 3 deletions src/main/services/TerminalSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface PersistedTerminalSession {
position: Point;
size: Size;
parentSessionId?: string;
codexThreadId?: string;
}

interface PersistedTerminalSessionState {
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {})
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
18 changes: 17 additions & 1 deletion src/main/services/agent-runtime/RuntimeGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,7 @@ interface ParsedLifecycleMessage {
state: RuntimeLifecycleState;
event: string;
turnId: string | null;
codexThreadId?: string;
result?: { text: string; truncated: boolean };
lastAssistantMessage?: string;
}
Expand Down Expand Up @@ -314,14 +316,20 @@ 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 })
};
if (message.lastAssistantMessage !== undefined && lease.answerCaptureGrantExpiresAt !== null) {
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);
}
}
Expand All @@ -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.");
Expand All @@ -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"
Expand All @@ -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"
Expand Down
24 changes: 21 additions & 3 deletions src/main/services/terminalLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -72,11 +75,26 @@ export function resolveTerminalLaunch(
};
}

function resolveResumeArguments(
provider: Exclude<ProviderId, "terminal">,
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<Exclude<ProviderId, "terminal">, string[]> = {
codex: ["resume", "--last"],
const RESUME_ARGUMENTS: Record<Exclude<ProviderId, "terminal" | "codex">, string[]> = {
claude: ["--continue"],
qwen: ["--continue"],
kimi: ["--continue"],
Expand Down
Loading
Loading