@rynx-ai/runtime 0.1.10 → 0.1.11-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/native-bridge.js +3 -8
- package/dist/claude/native-integration.d.ts +12 -1
- package/dist/claude/native-integration.js +16 -2
- package/dist/claude/transcript.d.ts +0 -7
- package/dist/claude/transcript.js +6 -20
- package/dist/codex-app-server/client.d.ts +2 -1
- package/dist/codex-app-server/forwarder.d.ts +4 -1
- package/dist/codex-app-server/forwarder.js +19 -1
- package/dist/codex-app-server/protocol.d.ts +45 -1
- package/dist/codex-home.d.ts +9 -26
- package/dist/codex-home.js +37 -65
- package/dist/codex-session-store.d.ts +22 -10
- package/dist/codex-session-store.js +277 -12
- package/dist/host.d.ts +47 -47
- package/dist/host.js +790 -350
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- package/dist/models-catalog.d.ts +1 -0
- package/dist/models-catalog.js +43 -1
- package/dist/provider-workspace.d.ts +56 -0
- package/dist/provider-workspace.js +83 -0
- package/dist/runner/child.d.ts +54 -6
- package/dist/runner/child.js +42 -17
- package/dist/runner/manager.d.ts +41 -18
- package/dist/runner/manager.js +432 -55
- package/dist/runner/protocol.d.ts +7 -18
- package/dist/runner-main.js +12 -4
- package/dist/runtime-state-paths.d.ts +10 -0
- package/dist/runtime-state-paths.js +53 -0
- package/dist/terminal/claude-tui.d.ts +8 -1
- package/dist/terminal/claude-tui.js +7 -1
- package/dist/terminal/codex-tui.d.ts +5 -1
- package/dist/terminal/codex-tui.js +12 -3
- package/package.json +2 -2
- package/dist/codex/rollout-synth.d.ts +0 -42
- package/dist/codex/rollout-synth.js +0 -245
package/dist/host.js
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { cp, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import readline from "node:readline";
|
|
6
|
-
import {
|
|
7
|
+
import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, } from "@rynx-ai/core";
|
|
7
8
|
import { getRuntimeProfile, } from "@rynx-ai/core";
|
|
8
|
-
import { resolveRuntimeBinary,
|
|
9
|
+
import { resolveRuntimeBinary, } from "@rynx-ai/core";
|
|
9
10
|
import { createCodexChildEnv } from "./codex-child-env.js";
|
|
10
|
-
import { prepareRuntimeHome, populateCodexSkills, } from "./codex-home.js";
|
|
11
|
+
import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
|
|
11
12
|
import { materializeSkillPlugin } from "./claude/executor.js";
|
|
12
13
|
import { listClaudeModels } from "./claude/models.js";
|
|
13
14
|
import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
|
|
14
|
-
import { buildAppServerBaseArgs } from "./codex-app-server/transport.js";
|
|
15
|
+
import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
|
|
15
16
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
16
17
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
17
18
|
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
18
19
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
20
|
+
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
19
21
|
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
20
|
-
import { claudeTranscriptPath } from "./claude/transcript.js";
|
|
21
22
|
import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
|
|
22
23
|
import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
23
24
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
@@ -156,10 +157,22 @@ export class SpawnCodexCommandRunner {
|
|
|
156
157
|
}
|
|
157
158
|
async capture({ args, cwd = process.cwd(), }) {
|
|
158
159
|
const running = this.start({ args, cwd });
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
const completion = running.completion.then((result) => ({ ok: true, result }), (error) => ({ ok: false, error }));
|
|
161
|
+
let streamError;
|
|
162
|
+
try {
|
|
163
|
+
for await (const _ of running.stream) {
|
|
164
|
+
// Drain output so completion captures stdout/stderr for parsing.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
streamError = error;
|
|
161
169
|
}
|
|
162
|
-
|
|
170
|
+
const outcome = await completion;
|
|
171
|
+
if (streamError)
|
|
172
|
+
throw streamError;
|
|
173
|
+
if (!outcome.ok)
|
|
174
|
+
throw outcome.error;
|
|
175
|
+
return outcome.result;
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
/** Fraction of an agent's `contextWindow` budget at which the orchestrator
|
|
@@ -168,6 +181,13 @@ export class SpawnCodexCommandRunner {
|
|
|
168
181
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
169
182
|
* signal is the lever consumers surface to the user. */
|
|
170
183
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
184
|
+
function providerPluginSkillName(namespace, name) {
|
|
185
|
+
const candidate = `${namespace}-${name}`;
|
|
186
|
+
if (candidate.length <= 128)
|
|
187
|
+
return candidate;
|
|
188
|
+
const digest = createHash("sha256").update(candidate).digest("hex").slice(0, 12);
|
|
189
|
+
return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
|
|
190
|
+
}
|
|
171
191
|
const MAX_CODEX_SETTLED_INTERACTIONS = 512;
|
|
172
192
|
/**
|
|
173
193
|
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
@@ -227,16 +247,15 @@ export class LocalAgentHost {
|
|
|
227
247
|
// Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
|
|
228
248
|
// a `${runtime}::${retryHash}` composite for a per-agent retry budget.
|
|
229
249
|
backends = new Map();
|
|
230
|
-
// The rynx
|
|
231
|
-
// CODEX_HOME is scoped to it so concurrent agents never share a `skills/` dir.
|
|
250
|
+
// The rynx Session this host serves (set from `RYNX_RUNNER_SESSION`).
|
|
232
251
|
sessionId;
|
|
252
|
+
runtimeHomeSessionId;
|
|
233
253
|
// Per-agent codex knobs resolved at live-session start, applied to this
|
|
234
254
|
// session's app-server (host is per-session, so the app-server is private to
|
|
235
255
|
// this agent). Absent ⇒ fall back to the daemon `config.*` default.
|
|
236
256
|
sessionSandbox;
|
|
237
257
|
sessionApprovalPolicy;
|
|
238
|
-
// Private CODEX_HOME / TRAE_HOME directories
|
|
239
|
-
// copied, update/NUX state NOT inherited) for this session's app-server + TUI.
|
|
258
|
+
// Private CODEX_HOME / TRAE_HOME directories for this Session lineage.
|
|
240
259
|
runtimeHomes = new Map();
|
|
241
260
|
// Per-session codex-native live forwarders, keyed by localThreadId. The
|
|
242
261
|
// single-writer of the session's canonical events + the target of turn injection.
|
|
@@ -250,9 +269,14 @@ export class LocalAgentHost {
|
|
|
250
269
|
// In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
|
|
251
270
|
// `live.ensure` racing a `term.open`) share ONE forwarder — never two.
|
|
252
271
|
liveEnsuring = new Map();
|
|
253
|
-
|
|
272
|
+
forkingTargets = new Map();
|
|
273
|
+
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
274
|
+
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
275
|
+
managedForkThreadStarts = new Map();
|
|
276
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, runtimeHomeSessionId, }) {
|
|
254
277
|
this.config = config;
|
|
255
278
|
this.sessionId = sessionId ?? "__default__";
|
|
279
|
+
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
256
280
|
this.sessionStore = sessionStore;
|
|
257
281
|
this.allowedRoots = allowedRoots;
|
|
258
282
|
this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
|
|
@@ -335,7 +359,9 @@ export class LocalAgentHost {
|
|
|
335
359
|
const existing = this.runtimeHomes.get(runtime);
|
|
336
360
|
if (existing)
|
|
337
361
|
return existing;
|
|
338
|
-
const prepared =
|
|
362
|
+
const prepared = this.runtimeHomeSessionId === this.sessionId
|
|
363
|
+
? prepareRuntimeHome(this.runtimeHomeSessionId, runtime)
|
|
364
|
+
: runtimeHomePath(this.runtimeHomeSessionId, runtime);
|
|
339
365
|
this.runtimeHomes.set(runtime, prepared);
|
|
340
366
|
return prepared;
|
|
341
367
|
}
|
|
@@ -425,11 +451,11 @@ export class LocalAgentHost {
|
|
|
425
451
|
return this.claudeTerminalSpec(localThreadId, record);
|
|
426
452
|
}
|
|
427
453
|
const live = this.liveSessions.get(localThreadId);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
return this.claudeTerminalSpec(localThreadId, record);
|
|
454
|
+
if (!live) {
|
|
455
|
+
throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
|
|
431
456
|
}
|
|
432
|
-
const
|
|
457
|
+
const runtime = live.runtime;
|
|
458
|
+
const backend = this.getBackend(runtime, live.execution.budget ?? undefined);
|
|
433
459
|
const client = backend.appServerClient;
|
|
434
460
|
if (!client) {
|
|
435
461
|
throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
|
|
@@ -444,29 +470,39 @@ export class LocalAgentHost {
|
|
|
444
470
|
// The TUI shares this session's app-server, so it inherits the per-agent
|
|
445
471
|
// sandbox already applied at boot; keep the network-access override aligned
|
|
446
472
|
// to the same effective sandbox.
|
|
447
|
-
const
|
|
473
|
+
const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
|
|
474
|
+
const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
|
|
475
|
+
const configOverrides = sandbox === "workspace-write"
|
|
448
476
|
? ["sandbox_workspace_write.network_access=true"]
|
|
449
477
|
: [];
|
|
478
|
+
configOverrides.push(`sandbox_mode=${JSON.stringify(sandbox)}`, `approval_policy=${JSON.stringify(approvalPolicy)}`);
|
|
479
|
+
// A managed TUI has no user at its stdin during startup. Codex's periodic
|
|
480
|
+
// "Update now / Skip" prompt would otherwise park the detached pane before
|
|
481
|
+
// it connects to the app-server, leaving the session only partially live.
|
|
482
|
+
if (runtime === "codex")
|
|
483
|
+
configOverrides.push("check_for_update_on_startup=false");
|
|
450
484
|
// Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
|
|
451
485
|
// shared per-runtime, so a per-session model rides a launch `-c model=` instead).
|
|
452
|
-
const launchModel = live
|
|
486
|
+
const launchModel = live.model;
|
|
453
487
|
if (launchModel)
|
|
454
488
|
configOverrides.push(`model=${JSON.stringify(launchModel)}`);
|
|
455
|
-
const launchEffort = live
|
|
489
|
+
const launchEffort = live.reasoningEffort;
|
|
456
490
|
if (launchEffort)
|
|
457
491
|
configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
|
|
458
492
|
if (live?.instructions) {
|
|
459
493
|
configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
|
|
460
494
|
}
|
|
461
495
|
const profile = getRuntimeProfile(runtime);
|
|
496
|
+
const activeThreadId = live?.threadId ?? record?.codexSessionId;
|
|
462
497
|
return {
|
|
463
498
|
command: resolveRuntimeBinary(runtime),
|
|
464
499
|
args: buildCodexRemoteArgs({
|
|
465
500
|
remoteUrl,
|
|
466
|
-
...(
|
|
501
|
+
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
467
502
|
configOverrides,
|
|
503
|
+
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
468
504
|
}),
|
|
469
|
-
cwd:
|
|
505
|
+
cwd: live.workspace.cwd,
|
|
470
506
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
471
507
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
472
508
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -486,25 +522,33 @@ export class LocalAgentHost {
|
|
|
486
522
|
* DETERMINISTIC (`resp_codex_<turnId>`) so items converge across snapshot/live.
|
|
487
523
|
*
|
|
488
524
|
* Returns false for claude / no app-server (the caller falls back to the
|
|
489
|
-
* streaming run path).
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* runner child, which owns the terminal registry) merely `resume`s this thread
|
|
494
|
-
* to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
|
|
525
|
+
* streaming run path). For a fresh session the native remote TUI creates the
|
|
526
|
+
* Codex thread and its `thread/started` notification supplies the id. For an
|
|
527
|
+
* existing session, the app-server resumes the persisted id. Rynx never writes
|
|
528
|
+
* or repairs Codex's private rollout files.
|
|
495
529
|
*/
|
|
496
530
|
async ensureLiveCodexSession(localThreadId, emit, opts) {
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
531
|
+
const snapshotOpts = {
|
|
532
|
+
workspace: structuredClone(opts.workspace),
|
|
533
|
+
execution: structuredClone(opts.execution),
|
|
534
|
+
...(opts.retargetMirror ? { retargetMirror: opts.retargetMirror } : {}),
|
|
535
|
+
};
|
|
536
|
+
const existingClaude = this.liveClaudeSessions.get(localThreadId);
|
|
537
|
+
if (existingClaude) {
|
|
538
|
+
return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
|
|
539
|
+
}
|
|
502
540
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
503
|
-
// session (two would double-mirror every turn).
|
|
541
|
+
// session (two would double-mirror every turn). Check this before the live
|
|
542
|
+
// map: startLiveCodexSession publishes its partially initialized session
|
|
543
|
+
// before thread/start completes, so a concurrent Terminal start must await
|
|
544
|
+
// the thread id instead of launching a bare `codex --remote` pane.
|
|
504
545
|
const inflight = this.liveEnsuring.get(localThreadId);
|
|
505
546
|
if (inflight)
|
|
506
547
|
return inflight;
|
|
507
|
-
const
|
|
548
|
+
const live = this.liveSessions.get(localThreadId);
|
|
549
|
+
if (live)
|
|
550
|
+
return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
|
|
551
|
+
const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
|
|
508
552
|
this.liveEnsuring.set(localThreadId, started);
|
|
509
553
|
try {
|
|
510
554
|
return await started;
|
|
@@ -522,48 +566,51 @@ export class LocalAgentHost {
|
|
|
522
566
|
this.stopLiveCodexSession(localThreadId);
|
|
523
567
|
}
|
|
524
568
|
const record = await this.sessionStore.get(localThreadId);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const runtime =
|
|
569
|
+
const retargetMirror = opts.retargetMirror;
|
|
570
|
+
const workspace = structuredClone(opts.workspace);
|
|
571
|
+
const execution = structuredClone(opts.execution);
|
|
572
|
+
const runtime = execution.provider;
|
|
529
573
|
if (runtime === "claude")
|
|
530
574
|
return this.startLiveClaudeSession(localThreadId, emit, record, opts);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
575
|
+
let snapshotSkills = {
|
|
576
|
+
selectedSkills: [],
|
|
577
|
+
skillsCleanup: async () => undefined,
|
|
578
|
+
};
|
|
579
|
+
if (this.runtimeHomeSessionId === this.sessionId) {
|
|
580
|
+
try {
|
|
581
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Persist the owner's skills before the app-server boots. Fork targets reuse
|
|
589
|
+
// the owner's immutable copies and never mutate the shared Provider home.
|
|
590
|
+
const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
537
591
|
try {
|
|
538
|
-
|
|
592
|
+
populateCodexSkills(this.runtimeHome(runtime), liveSkills);
|
|
539
593
|
}
|
|
540
594
|
catch (err) {
|
|
541
|
-
|
|
595
|
+
void snapshotSkills.skillsCleanup();
|
|
596
|
+
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
542
597
|
return false;
|
|
543
598
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
553
|
-
populateCodexSkills(this.runtimeHome(runtime), liveSkills);
|
|
554
|
-
// Apply this agent's sandbox / approval policy to the session's app-server
|
|
555
|
-
// BEFORE it boots (getBackend below lazily creates it). Host is per-session,
|
|
556
|
-
// so these knobs are the agent's own — no cross-session bleed. Set even when
|
|
557
|
-
// absent (undefined) so a re-used host doesn't inherit a prior agent's values.
|
|
558
|
-
this.sessionSandbox = liveCfg.sandbox;
|
|
559
|
-
this.sessionApprovalPolicy = liveCfg.approvalPolicy;
|
|
599
|
+
const sandbox = execution.sandbox;
|
|
600
|
+
const approvalPolicy = execution.approvalPolicy;
|
|
601
|
+
if (!sandbox || !approvalPolicy) {
|
|
602
|
+
void snapshotSkills.skillsCleanup();
|
|
603
|
+
throw new CodexRuntimeError(`${runtime} execution snapshot is missing sandbox or approval policy`, 422, "invalid_execution_snapshot");
|
|
604
|
+
}
|
|
605
|
+
this.sessionSandbox = sandbox;
|
|
606
|
+
this.sessionApprovalPolicy = approvalPolicy;
|
|
560
607
|
// Backend client owns the app-server + drives injection. Every failed-start
|
|
561
608
|
// path below removes the session skills dir it would otherwise leak.
|
|
562
609
|
const abandon = () => {
|
|
563
|
-
void
|
|
610
|
+
void snapshotSkills.skillsCleanup();
|
|
564
611
|
return false;
|
|
565
612
|
};
|
|
566
|
-
const injectClient = this.getBackend(runtime).appServerClient;
|
|
613
|
+
const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
567
614
|
if (!injectClient) {
|
|
568
615
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
569
616
|
return abandon();
|
|
@@ -591,36 +638,40 @@ export class LocalAgentHost {
|
|
|
591
638
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
592
639
|
return abandon();
|
|
593
640
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// the runtime default). The model rides a launch `-c model=` on the TUI (see
|
|
597
|
-
// `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
|
|
598
|
-
const model = resolveRuntimeModel(this.config, runtime, record?.model ?? liveCfg.model);
|
|
599
|
-
const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
|
|
600
|
-
const reasoningEffort = hasCurrentEffortSource
|
|
601
|
-
? liveCfg.reasoningEffort
|
|
602
|
-
: record?.reasoningEffort ?? liveCfg.reasoningEffort;
|
|
641
|
+
const model = execution.model;
|
|
642
|
+
const reasoningEffort = execution.reasoningEffort ?? undefined;
|
|
603
643
|
let markReady;
|
|
604
644
|
const ready = new Promise((resolve) => {
|
|
605
645
|
markReady = resolve;
|
|
606
646
|
});
|
|
647
|
+
let markTerminalReady;
|
|
648
|
+
const terminalReady = new Promise((resolve) => {
|
|
649
|
+
markTerminalReady = resolve;
|
|
650
|
+
});
|
|
607
651
|
const live = {
|
|
608
652
|
injectClient,
|
|
609
653
|
forwarderClient,
|
|
610
654
|
forwarder: null,
|
|
611
655
|
runtime,
|
|
612
|
-
|
|
656
|
+
workspace,
|
|
657
|
+
execution,
|
|
658
|
+
sandbox,
|
|
659
|
+
approvalPolicy,
|
|
613
660
|
model,
|
|
614
661
|
reasoningEffort,
|
|
615
|
-
...(
|
|
662
|
+
...(execution.instructions ? { instructions: execution.instructions } : {}),
|
|
616
663
|
threadId: record?.codexSessionId ?? null,
|
|
617
664
|
ready,
|
|
618
665
|
markReady,
|
|
666
|
+
terminalReady,
|
|
667
|
+
markTerminalReady,
|
|
619
668
|
injectLock: Promise.resolve(),
|
|
669
|
+
pendingInjectedInputs: [],
|
|
670
|
+
publishInjectedInput: () => undefined,
|
|
620
671
|
subscribing: false,
|
|
672
|
+
rotationPending: false,
|
|
621
673
|
stopped: false,
|
|
622
|
-
skillsCleanup:
|
|
623
|
-
configSignature,
|
|
674
|
+
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
624
675
|
interactionOwners: new Map(),
|
|
625
676
|
interactionStandbys: new Map(),
|
|
626
677
|
interactionSubmissions: new Map(),
|
|
@@ -630,15 +681,25 @@ export class LocalAgentHost {
|
|
|
630
681
|
interactionFailedClients: new Map(),
|
|
631
682
|
disconnectedClients: new Set(),
|
|
632
683
|
};
|
|
684
|
+
let currentSessionId = localThreadId;
|
|
685
|
+
let pendingRotationEvents = null;
|
|
686
|
+
const emitCurrent = (event) => {
|
|
687
|
+
if (pendingRotationEvents) {
|
|
688
|
+
pendingRotationEvents.push(event);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
emit(event);
|
|
692
|
+
};
|
|
633
693
|
let normalizer = null;
|
|
634
694
|
let currentResponseId = null;
|
|
635
695
|
const startNormalizer = (turnId) => {
|
|
636
|
-
const responseId =
|
|
696
|
+
const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
|
|
697
|
+
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
637
698
|
if (normalizer && currentResponseId === responseId)
|
|
638
699
|
return normalizer;
|
|
639
700
|
currentResponseId = responseId;
|
|
640
701
|
normalizer = new SessionNormalizer({
|
|
641
|
-
sessionId:
|
|
702
|
+
sessionId: currentSessionId,
|
|
642
703
|
// turnId unknown → fixed literal (NEVER random): items arriving before
|
|
643
704
|
// `turn/started` land on ONE response instead of splitting into random
|
|
644
705
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
@@ -650,6 +711,19 @@ export class LocalAgentHost {
|
|
|
650
711
|
});
|
|
651
712
|
return normalizer;
|
|
652
713
|
};
|
|
714
|
+
live.publishInjectedInput = (turnId, content) => {
|
|
715
|
+
const n = startNormalizer(turnId);
|
|
716
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === JSON.stringify(content) && entry.responseId === undefined);
|
|
717
|
+
if (pending && currentResponseId)
|
|
718
|
+
pending.responseId = currentResponseId;
|
|
719
|
+
for (const se of n.userInput(content))
|
|
720
|
+
emitCurrent(se);
|
|
721
|
+
};
|
|
722
|
+
const clearPendingInputsForResponse = (responseId) => {
|
|
723
|
+
if (!responseId)
|
|
724
|
+
return;
|
|
725
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
726
|
+
};
|
|
653
727
|
const rememberSettledInteraction = (interactionId) => {
|
|
654
728
|
live.settledInteractions.add(interactionId);
|
|
655
729
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -692,7 +766,7 @@ export class LocalAgentHost {
|
|
|
692
766
|
...(event.reason ? { reason: event.reason } : {}),
|
|
693
767
|
};
|
|
694
768
|
for (const se of n.next(agentEvent))
|
|
695
|
-
|
|
769
|
+
emitCurrent(se);
|
|
696
770
|
};
|
|
697
771
|
const clearRecovery = (interactionId) => {
|
|
698
772
|
live.interactionRecoveries.delete(interactionId);
|
|
@@ -848,24 +922,38 @@ export class LocalAgentHost {
|
|
|
848
922
|
const sink = {
|
|
849
923
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
850
924
|
onUserMessage: (content) => {
|
|
925
|
+
const normalizedContent = typeof content === "string"
|
|
926
|
+
? [{ type: "input_text", text: content }]
|
|
927
|
+
: content;
|
|
928
|
+
const signature = JSON.stringify(normalizedContent);
|
|
929
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
930
|
+
if (pending?.state === "optimistic" || pending?.state === "prepublished") {
|
|
931
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (pending)
|
|
935
|
+
pending.observed = true;
|
|
851
936
|
const n = normalizer ?? startNormalizer();
|
|
852
|
-
for (const se of n.userInput(
|
|
853
|
-
|
|
937
|
+
for (const se of n.userInput(normalizedContent))
|
|
938
|
+
emitCurrent(se);
|
|
854
939
|
},
|
|
855
940
|
onEvent: (event) => {
|
|
856
941
|
const n = normalizer ?? startNormalizer();
|
|
857
942
|
for (const se of n.next(event))
|
|
858
|
-
|
|
943
|
+
emitCurrent(se);
|
|
859
944
|
},
|
|
860
945
|
onTurnEnd: (usage) => {
|
|
861
946
|
closeCanonicalInteractions();
|
|
862
947
|
if (!normalizer)
|
|
863
948
|
return;
|
|
864
|
-
|
|
949
|
+
const completedResponseId = currentResponseId;
|
|
950
|
+
if (usage) {
|
|
865
951
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
866
|
-
|
|
952
|
+
emitCurrent(se);
|
|
953
|
+
}
|
|
867
954
|
for (const se of normalizer.next({ type: "done" }))
|
|
868
|
-
|
|
955
|
+
emitCurrent(se);
|
|
956
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
869
957
|
normalizer = null;
|
|
870
958
|
currentResponseId = null;
|
|
871
959
|
},
|
|
@@ -873,6 +961,7 @@ export class LocalAgentHost {
|
|
|
873
961
|
closeCanonicalInteractions();
|
|
874
962
|
if (!normalizer)
|
|
875
963
|
return;
|
|
964
|
+
const failedResponseId = currentResponseId;
|
|
876
965
|
const responseStopped = error.message === "Codex turn was interrupted";
|
|
877
966
|
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
878
967
|
const message = responseStopped
|
|
@@ -887,14 +976,14 @@ export class LocalAgentHost {
|
|
|
887
976
|
message,
|
|
888
977
|
source: "execution",
|
|
889
978
|
}))
|
|
890
|
-
|
|
979
|
+
emitCurrent(se);
|
|
980
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
891
981
|
normalizer = null;
|
|
892
982
|
currentResponseId = null;
|
|
893
983
|
},
|
|
894
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
|
|
984
|
+
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
985
|
+
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
986
|
+
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
898
987
|
onThreadActive: () => live.releaseActive?.(),
|
|
899
988
|
};
|
|
900
989
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
@@ -908,99 +997,169 @@ export class LocalAgentHost {
|
|
|
908
997
|
surfaceQueueStatus: runtime === "traex",
|
|
909
998
|
});
|
|
910
999
|
live.forwarder = forwarder;
|
|
1000
|
+
live.onNativeThreadRotated = (threadId, kind) => {
|
|
1001
|
+
if (live.rotationPending) {
|
|
1002
|
+
live.stopped = true;
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const previousSessionId = currentSessionId;
|
|
1006
|
+
const newSessionId = makeSessionId();
|
|
1007
|
+
live.rotationPending = true;
|
|
1008
|
+
currentSessionId = newSessionId;
|
|
1009
|
+
normalizer = null;
|
|
1010
|
+
currentResponseId = null;
|
|
1011
|
+
live.pendingInjectedInputs = [];
|
|
1012
|
+
pendingRotationEvents = [];
|
|
1013
|
+
void this.sessionStore
|
|
1014
|
+
.get(previousSessionId)
|
|
1015
|
+
.then((previous) => this.sessionStore.set({
|
|
1016
|
+
localThreadId: newSessionId,
|
|
1017
|
+
codexSessionId: threadId,
|
|
1018
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1019
|
+
...(previous?.runtimeHomeOwnerSessionId
|
|
1020
|
+
? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
|
|
1021
|
+
: {}),
|
|
1022
|
+
updatedAt: new Date().toISOString(),
|
|
1023
|
+
}))
|
|
1024
|
+
.then(() => {
|
|
1025
|
+
emit({
|
|
1026
|
+
type: "session.rotated",
|
|
1027
|
+
sessionId: previousSessionId,
|
|
1028
|
+
newSessionId,
|
|
1029
|
+
kind,
|
|
1030
|
+
});
|
|
1031
|
+
this.liveSessions.set(newSessionId, live);
|
|
1032
|
+
retargetMirror?.(newSessionId, {
|
|
1033
|
+
kind,
|
|
1034
|
+
workspace: structuredClone(live.workspace),
|
|
1035
|
+
execution: structuredClone(live.execution),
|
|
1036
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1037
|
+
});
|
|
1038
|
+
const queued = pendingRotationEvents ?? [];
|
|
1039
|
+
pendingRotationEvents = null;
|
|
1040
|
+
live.rotationPending = false;
|
|
1041
|
+
for (const event of queued)
|
|
1042
|
+
emit(event);
|
|
1043
|
+
void this.subscribeUntilReady(live, threadId);
|
|
1044
|
+
})
|
|
1045
|
+
.catch(() => {
|
|
1046
|
+
pendingRotationEvents = null;
|
|
1047
|
+
live.rotationPending = false;
|
|
1048
|
+
live.stopped = true;
|
|
1049
|
+
});
|
|
1050
|
+
};
|
|
911
1051
|
bindInteractionClient(injectClient);
|
|
912
1052
|
bindInteractionClient(forwarderClient);
|
|
913
1053
|
this.liveSessions.set(localThreadId, live);
|
|
914
1054
|
forwarder.start();
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
return true;
|
|
921
|
-
}
|
|
922
|
-
async refreshLiveCodexSession(localThreadId, live, opts) {
|
|
923
|
-
const desiredRuntime = opts?.runtime ?? live.runtime;
|
|
924
|
-
if (desiredRuntime !== live.runtime)
|
|
925
|
-
return false;
|
|
926
|
-
const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
|
|
927
|
-
if (live.configSignature === configSignature)
|
|
928
|
-
return true;
|
|
929
|
-
let liveCfg;
|
|
930
|
-
try {
|
|
931
|
-
liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
|
|
932
|
-
}
|
|
933
|
-
catch (err) {
|
|
934
|
-
console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
|
|
935
|
-
return false;
|
|
936
|
-
}
|
|
937
|
-
const previousCleanup = live.skillsCleanup;
|
|
1055
|
+
// Existing bindings resume through the public app-server API. A fresh thread
|
|
1056
|
+
// is created by the native remote TUI instead: its `thread/started`
|
|
1057
|
+
// notification binds this live handle in `onLiveThreadStarted`. Do not
|
|
1058
|
+
// synthesize Codex's private rollout JSONL merely to make a just-created,
|
|
1059
|
+
// turn-less app-server thread resumable by the TUI.
|
|
938
1060
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
}
|
|
971
|
-
|
|
1061
|
+
if (record?.codexSessionId) {
|
|
1062
|
+
let resumeError;
|
|
1063
|
+
let resumedThreadId;
|
|
1064
|
+
const attempts = 20;
|
|
1065
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1066
|
+
try {
|
|
1067
|
+
const resumed = await injectClient.threadResume({
|
|
1068
|
+
threadId: record.codexSessionId,
|
|
1069
|
+
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1070
|
+
approvalPolicy,
|
|
1071
|
+
excludeTurns: true,
|
|
1072
|
+
});
|
|
1073
|
+
resumedThreadId = resumed.threadId;
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
resumeError = error;
|
|
1078
|
+
if (!isRetryableThreadResumeError(error))
|
|
1079
|
+
throw error;
|
|
1080
|
+
if (attempt + 1 < attempts) {
|
|
1081
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (resumedThreadId) {
|
|
1086
|
+
this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
// Never replace an existing binding. A delayed native index is
|
|
1090
|
+
// retryable; a genuinely missing thread remains an explicit error.
|
|
1091
|
+
throw resumeError;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
// No thread exists yet, so the TUI must launch without `resume`. Once it
|
|
1096
|
+
// broadcasts `thread/started`, the normal ready + subscription path
|
|
1097
|
+
// takes over and web injection can begin.
|
|
1098
|
+
live.markTerminalReady(true);
|
|
972
1099
|
}
|
|
973
|
-
return true;
|
|
974
1100
|
}
|
|
975
1101
|
catch (err) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1102
|
+
this.liveSessions.delete(localThreadId);
|
|
1103
|
+
live.stopped = true;
|
|
1104
|
+
forwarder.stop();
|
|
1105
|
+
void forwarderClient.stop().catch(() => undefined);
|
|
1106
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1107
|
+
return abandon();
|
|
979
1108
|
}
|
|
1109
|
+
return true;
|
|
980
1110
|
}
|
|
981
1111
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
982
1112
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
983
|
-
onLiveThreadStarted(live, localThreadId,
|
|
1113
|
+
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1114
|
+
const previousThreadId = live.threadId;
|
|
1115
|
+
if (previousThreadId && previousThreadId !== threadId) {
|
|
1116
|
+
live.threadId = threadId;
|
|
1117
|
+
live.subscribing = true;
|
|
1118
|
+
live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
984
1121
|
if (live.subscribing)
|
|
985
1122
|
return;
|
|
986
1123
|
live.subscribing = true;
|
|
987
1124
|
live.threadId = threadId;
|
|
988
1125
|
void this.sessionStore
|
|
989
|
-
.
|
|
990
|
-
|
|
991
|
-
codexSessionId
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1126
|
+
.get(localThreadId)
|
|
1127
|
+
.then((existing) => {
|
|
1128
|
+
if (existing?.codexSessionId === threadId)
|
|
1129
|
+
return;
|
|
1130
|
+
return this.sessionStore.set({
|
|
1131
|
+
localThreadId,
|
|
1132
|
+
codexSessionId: threadId,
|
|
1133
|
+
...(existing?.parentSessionId
|
|
1134
|
+
? { parentSessionId: existing.parentSessionId }
|
|
1135
|
+
: {}),
|
|
1136
|
+
...(existing?.runtimeHomeOwnerSessionId
|
|
1137
|
+
? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
|
|
1138
|
+
: {}),
|
|
1139
|
+
updatedAt: new Date().toISOString(),
|
|
1140
|
+
});
|
|
1000
1141
|
})
|
|
1001
1142
|
.catch(() => undefined);
|
|
1002
1143
|
live.markReady(); // thread id known → injection can turn/start
|
|
1003
|
-
void this.subscribeUntilReady(live, threadId);
|
|
1144
|
+
void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
|
|
1145
|
+
}
|
|
1146
|
+
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1147
|
+
const pending = live.managedFork;
|
|
1148
|
+
if (pending && forkedFromId === pending.sourceThreadId) {
|
|
1149
|
+
pending.observedThreadIds.add(threadId);
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
const expectedSource = this.managedForkThreadStarts.get(threadId);
|
|
1153
|
+
return expectedSource !== undefined && expectedSource === forkedFromId;
|
|
1154
|
+
}
|
|
1155
|
+
rememberManagedForkThreadStart(threadId, sourceThreadId) {
|
|
1156
|
+
this.managedForkThreadStarts.set(threadId, sourceThreadId);
|
|
1157
|
+
const timer = setTimeout(() => {
|
|
1158
|
+
if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
|
|
1159
|
+
this.managedForkThreadStarts.delete(threadId);
|
|
1160
|
+
}
|
|
1161
|
+
}, 5_000);
|
|
1162
|
+
timer.unref?.();
|
|
1004
1163
|
}
|
|
1005
1164
|
/**
|
|
1006
1165
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
@@ -1016,17 +1175,18 @@ export class LocalAgentHost {
|
|
|
1016
1175
|
try {
|
|
1017
1176
|
const resp = await live.forwarderClient.threadResume({
|
|
1018
1177
|
threadId,
|
|
1019
|
-
|
|
1178
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1179
|
+
approvalPolicy: live.approvalPolicy,
|
|
1020
1180
|
excludeTurns: !sawNotReady,
|
|
1021
1181
|
});
|
|
1022
1182
|
if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
|
|
1023
1183
|
live.forwarder.replayBackfill(resp.thread.turns);
|
|
1024
1184
|
}
|
|
1025
|
-
return; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1185
|
+
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1026
1186
|
}
|
|
1027
1187
|
catch (error) {
|
|
1028
1188
|
if (!isThreadNotReadyError(error)) {
|
|
1029
|
-
return; // other failure — injection still works via the backend client
|
|
1189
|
+
return false; // other failure — injection still works via the backend client
|
|
1030
1190
|
}
|
|
1031
1191
|
sawNotReady = true;
|
|
1032
1192
|
// Park until the thread goes active (its first turn materializes the
|
|
@@ -1038,13 +1198,14 @@ export class LocalAgentHost {
|
|
|
1038
1198
|
live.releaseActive = undefined;
|
|
1039
1199
|
}
|
|
1040
1200
|
}
|
|
1201
|
+
return false;
|
|
1041
1202
|
}
|
|
1042
1203
|
/** Await a live session's thread binding (bounded). Returns false on timeout /
|
|
1043
1204
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1044
1205
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1045
1206
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1046
1207
|
if (claude)
|
|
1047
|
-
return raceReady(claude.ready, timeoutMs);
|
|
1208
|
+
return raceReady(claude.ready, timeoutMs, claude.failed);
|
|
1048
1209
|
const live = this.liveSessions.get(localThreadId);
|
|
1049
1210
|
if (!live)
|
|
1050
1211
|
return false;
|
|
@@ -1057,11 +1218,36 @@ export class LocalAgentHost {
|
|
|
1057
1218
|
clearTimeout(timer);
|
|
1058
1219
|
return ok;
|
|
1059
1220
|
}
|
|
1221
|
+
/** Await the stronger Terminal gate: another app-server connection has
|
|
1222
|
+
* successfully resumed the thread, so the detached TUI cannot race rollout
|
|
1223
|
+
* discovery or indexing. */
|
|
1224
|
+
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1225
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1226
|
+
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
1227
|
+
// SessionStart then reveals the native id + actual transcript path.
|
|
1228
|
+
if (claude)
|
|
1229
|
+
return true;
|
|
1230
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1231
|
+
if (!live)
|
|
1232
|
+
return false;
|
|
1233
|
+
let timer;
|
|
1234
|
+
const timeout = new Promise((resolve) => {
|
|
1235
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1236
|
+
});
|
|
1237
|
+
const ready = await Promise.race([live.terminalReady, timeout]);
|
|
1238
|
+
if (timer)
|
|
1239
|
+
clearTimeout(timer);
|
|
1240
|
+
return ready;
|
|
1241
|
+
}
|
|
1242
|
+
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1243
|
+
liveSessionError(localThreadId) {
|
|
1244
|
+
return this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1245
|
+
}
|
|
1060
1246
|
/**
|
|
1061
1247
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
1062
1248
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
1063
|
-
* supplement), else `turn/start`.
|
|
1064
|
-
*
|
|
1249
|
+
* supplement), else `turn/start`. The thread was created by the native TUI or
|
|
1250
|
+
* resumed from the persisted native id. Serialized per session so two
|
|
1065
1251
|
* injects can't double-open a turn.
|
|
1066
1252
|
*
|
|
1067
1253
|
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
@@ -1077,15 +1263,34 @@ export class LocalAgentHost {
|
|
|
1077
1263
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1078
1264
|
if (claude) {
|
|
1079
1265
|
const text = claudeInputText(runtimeInput);
|
|
1266
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1267
|
+
const pendingInput = {
|
|
1268
|
+
content,
|
|
1269
|
+
signature: JSON.stringify(content),
|
|
1270
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1271
|
+
observed: false,
|
|
1272
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1273
|
+
};
|
|
1274
|
+
claude.pendingInjectedInputs.push(pendingInput);
|
|
1275
|
+
const forgetPendingInput = () => {
|
|
1276
|
+
const index = claude.pendingInjectedInputs.indexOf(pendingInput);
|
|
1277
|
+
if (index >= 0)
|
|
1278
|
+
claude.pendingInjectedInputs.splice(index, 1);
|
|
1279
|
+
};
|
|
1080
1280
|
const token = claudeAttachmentToken(text);
|
|
1081
1281
|
if (token) {
|
|
1082
|
-
claude.pendingImageInputs.set(token,
|
|
1282
|
+
claude.pendingImageInputs.set(token, content);
|
|
1083
1283
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1084
1284
|
expiry.unref?.();
|
|
1085
1285
|
}
|
|
1086
1286
|
const outcome = await this.injectClaude(claude, localThreadId, text);
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1287
|
+
if (outcome !== "injected") {
|
|
1288
|
+
forgetPendingInput();
|
|
1289
|
+
if (token)
|
|
1290
|
+
claude.pendingImageInputs.delete(token);
|
|
1291
|
+
}
|
|
1292
|
+
else if (pendingInput.observed) {
|
|
1293
|
+
forgetPendingInput();
|
|
1089
1294
|
}
|
|
1090
1295
|
return outcome;
|
|
1091
1296
|
}
|
|
@@ -1093,6 +1298,8 @@ export class LocalAgentHost {
|
|
|
1093
1298
|
if (!live)
|
|
1094
1299
|
return "notLive";
|
|
1095
1300
|
const run = live.injectLock.then(async () => {
|
|
1301
|
+
if (live.rotationPending || live.stopped)
|
|
1302
|
+
return "failed";
|
|
1096
1303
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1097
1304
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1098
1305
|
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
@@ -1100,34 +1307,74 @@ export class LocalAgentHost {
|
|
|
1100
1307
|
if (!bound || !threadId)
|
|
1101
1308
|
return "notReady";
|
|
1102
1309
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1310
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1311
|
+
const pendingInput = {
|
|
1312
|
+
content,
|
|
1313
|
+
signature: JSON.stringify(content),
|
|
1314
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1315
|
+
observed: false,
|
|
1316
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1317
|
+
};
|
|
1318
|
+
live.pendingInjectedInputs.push(pendingInput);
|
|
1319
|
+
const forgetPendingInput = () => {
|
|
1320
|
+
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1321
|
+
if (index >= 0)
|
|
1322
|
+
live.pendingInjectedInputs.splice(index, 1);
|
|
1323
|
+
};
|
|
1103
1324
|
try {
|
|
1104
1325
|
// Inject via the BACKEND client (the forwarder connection only observes).
|
|
1105
1326
|
if (live.forwarder.isTurnOpen()) {
|
|
1106
1327
|
const turnId = live.forwarder.currentTurnId();
|
|
1107
1328
|
if (turnId) {
|
|
1108
|
-
await live.injectClient.turnSteer({
|
|
1329
|
+
const steered = await live.injectClient.turnSteer({
|
|
1109
1330
|
threadId,
|
|
1110
1331
|
expectedTurnId: turnId,
|
|
1111
1332
|
input: nativeInput,
|
|
1112
1333
|
});
|
|
1334
|
+
if (pendingInput.state === "prepublished") {
|
|
1335
|
+
// The caller already persisted and published this user input
|
|
1336
|
+
// before waiting for the native Terminal to become ready.
|
|
1337
|
+
}
|
|
1338
|
+
else if (pendingInput.observed) {
|
|
1339
|
+
forgetPendingInput();
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1343
|
+
pendingInput.state = "optimistic";
|
|
1344
|
+
}
|
|
1113
1345
|
return "injected";
|
|
1114
1346
|
}
|
|
1115
1347
|
}
|
|
1116
1348
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
1117
1349
|
// agent's model even if the TUI's config default differs.
|
|
1118
|
-
await live.injectClient.turnStart({
|
|
1350
|
+
const started = await live.injectClient.turnStart({
|
|
1119
1351
|
threadId,
|
|
1120
1352
|
input: nativeInput,
|
|
1121
|
-
|
|
1353
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1354
|
+
approvalPolicy: live.approvalPolicy,
|
|
1122
1355
|
...(live.model ? { model: live.model } : {}),
|
|
1123
1356
|
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1124
1357
|
});
|
|
1358
|
+
if (pendingInput.state === "prepublished") {
|
|
1359
|
+
// The caller already persisted and published this user input before
|
|
1360
|
+
// waiting for the native Terminal to become ready.
|
|
1361
|
+
}
|
|
1362
|
+
else if (pendingInput.observed) {
|
|
1363
|
+
forgetPendingInput();
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1367
|
+
pendingInput.state = "optimistic";
|
|
1368
|
+
}
|
|
1125
1369
|
return "injected";
|
|
1126
1370
|
}
|
|
1127
|
-
catch {
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
forgetPendingInput();
|
|
1373
|
+
// Preserve the app-server's error instead of collapsing every failure
|
|
1374
|
+
// into the unactionable `live injection failed` string.
|
|
1375
|
+
const detail = codexInjectionError(error);
|
|
1376
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1377
|
+
throw new Error(detail, { cause: error });
|
|
1131
1378
|
}
|
|
1132
1379
|
});
|
|
1133
1380
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1236,6 +1483,15 @@ export class LocalAgentHost {
|
|
|
1236
1483
|
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1237
1484
|
forwarder.finalizeStop();
|
|
1238
1485
|
this.pendingClaudeFinalizers.clear();
|
|
1486
|
+
// A runner owns its app-server processes. Stopping only the forwarder leaves
|
|
1487
|
+
// those children re-parented after the runner exits, along with remote TUIs
|
|
1488
|
+
// that can only reconnect to a dead session. `stop()` sends SIGTERM before
|
|
1489
|
+
// its returned promise yields, so this remains safe in the synchronous
|
|
1490
|
+
// runner shutdown path.
|
|
1491
|
+
for (const backend of this.backends.values()) {
|
|
1492
|
+
void backend.appServerClient?.stop().catch(() => undefined);
|
|
1493
|
+
}
|
|
1494
|
+
this.backends.clear();
|
|
1239
1495
|
}
|
|
1240
1496
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
1241
1497
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
@@ -1245,7 +1501,10 @@ export class LocalAgentHost {
|
|
|
1245
1501
|
* generic interaction bridge and remain inside the active Turn. */
|
|
1246
1502
|
claudeTerminalSpec(localThreadId, record) {
|
|
1247
1503
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1248
|
-
|
|
1504
|
+
if (!live) {
|
|
1505
|
+
throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
|
|
1506
|
+
}
|
|
1507
|
+
const cwd = live.workspace.cwd;
|
|
1249
1508
|
try {
|
|
1250
1509
|
ensureProjectTrusted(cwd);
|
|
1251
1510
|
}
|
|
@@ -1253,24 +1512,17 @@ export class LocalAgentHost {
|
|
|
1253
1512
|
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
1254
1513
|
}
|
|
1255
1514
|
const inheritedSettings = buildManagedClaudeSettings(cwd, {});
|
|
1256
|
-
const
|
|
1257
|
-
const
|
|
1258
|
-
typeof inheritedPermissions === "object" &&
|
|
1259
|
-
!Array.isArray(inheritedPermissions) &&
|
|
1260
|
-
typeof inheritedPermissions.defaultMode === "string"
|
|
1261
|
-
? inheritedPermissions.defaultMode
|
|
1262
|
-
: undefined;
|
|
1263
|
-
const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
|
|
1515
|
+
const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
|
|
1516
|
+
const hookPermissionMode = live.permissionMode;
|
|
1264
1517
|
const hookSettings = buildClaudeHookSettings({
|
|
1265
1518
|
bridgeDir: claudeBridgeDir(localThreadId),
|
|
1266
1519
|
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
1267
1520
|
messageDisplay: true,
|
|
1268
1521
|
statusLine: true,
|
|
1269
1522
|
});
|
|
1270
|
-
const settings = { ...
|
|
1523
|
+
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1271
1524
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1272
|
-
// The live session
|
|
1273
|
-
// carries the agent-spec launch config: model, instructions, and skills.
|
|
1525
|
+
// The live session carries the immutable execution snapshot used by Chat.
|
|
1274
1526
|
const args = buildClaudeTuiArgs({
|
|
1275
1527
|
settingsJson: settingsPath,
|
|
1276
1528
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1278,10 +1530,21 @@ export class LocalAgentHost {
|
|
|
1278
1530
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1279
1531
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1280
1532
|
settingSources: "",
|
|
1281
|
-
model: live
|
|
1282
|
-
...(live
|
|
1283
|
-
|
|
1284
|
-
|
|
1533
|
+
model: live.execution.model,
|
|
1534
|
+
...(live.execution.instructions
|
|
1535
|
+
? { appendSystemPrompt: live.execution.instructions }
|
|
1536
|
+
: {}),
|
|
1537
|
+
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
1538
|
+
...(live.forkIntent
|
|
1539
|
+
? {
|
|
1540
|
+
resume: live.forkIntent.sourceClaudeSessionId,
|
|
1541
|
+
forkSession: true,
|
|
1542
|
+
sessionId: live.forkIntent.targetClaudeSessionId,
|
|
1543
|
+
}
|
|
1544
|
+
: record?.codexSessionId
|
|
1545
|
+
? { resume: record.codexSessionId }
|
|
1546
|
+
: {}),
|
|
1547
|
+
...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
1285
1548
|
});
|
|
1286
1549
|
return {
|
|
1287
1550
|
command: resolveRuntimeBinary("claude"),
|
|
@@ -1292,44 +1555,15 @@ export class LocalAgentHost {
|
|
|
1292
1555
|
env: { ...process.env, RYNX_SESSION_ID: localThreadId },
|
|
1293
1556
|
};
|
|
1294
1557
|
}
|
|
1295
|
-
/**
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
|
|
1299
|
-
* for a LIVE native session, reusing the same core resolvers the non-live
|
|
1300
|
-
* run path uses ({@link resolveAgentExecution} for the model,
|
|
1301
|
-
* {@link resolveAgent} for instructions and skills).
|
|
1302
|
-
*
|
|
1303
|
-
* Skills are spec-rooted: each declared ref is replayed into a
|
|
1304
|
-
* SESSION-scoped temp dir (cache-accelerated); the owner's catalog plays no
|
|
1305
|
-
* role, and no spec / no `skills` means ZERO skills. Every declared skill
|
|
1306
|
-
* must resolve at its declared content hash; missing, failed, or drifted
|
|
1307
|
-
* materialization aborts launch before a native session starts.
|
|
1308
|
-
* `skillsCleanup` removes the session dir (call on session stop). */
|
|
1309
|
-
async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
|
|
1310
|
-
// Inline spec needs an id only so a relative instructions-file path can
|
|
1311
|
-
// resolve (mirrors runLocked); a preset agent loads by name.
|
|
1312
|
-
const spec = opts?.agentSpec
|
|
1313
|
-
? { ...opts.agentSpec, id: opts.agentName ?? "inline" }
|
|
1314
|
-
: undefined;
|
|
1315
|
-
const exec = await resolveAgentExecution({
|
|
1316
|
-
config: this.config,
|
|
1317
|
-
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
1318
|
-
...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
|
|
1319
|
-
runtime,
|
|
1320
|
-
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
1321
|
-
});
|
|
1322
|
-
const resolved = await resolveAgent({
|
|
1323
|
-
config: this.config,
|
|
1324
|
-
cwd,
|
|
1325
|
-
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
1326
|
-
...(spec ? { spec } : {}),
|
|
1327
|
-
});
|
|
1558
|
+
/** Materialize only the immutable skill declarations stored on the Session.
|
|
1559
|
+
* Provider startup never re-opens an Agent template or process-level plugin
|
|
1560
|
+
* snapshot. */
|
|
1561
|
+
async prepareExecutionSkills(execution) {
|
|
1328
1562
|
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
1329
1563
|
let skillEnv;
|
|
1330
1564
|
try {
|
|
1331
|
-
skillEnv = await buildAgentSkillEnvironment(
|
|
1332
|
-
const declaredNames =
|
|
1565
|
+
skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
|
|
1566
|
+
const declaredNames = execution.skills.map((skill) => skill.name);
|
|
1333
1567
|
const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
|
|
1334
1568
|
const { report } = skillEnv;
|
|
1335
1569
|
if (report.errors.length > 0 ||
|
|
@@ -1351,52 +1585,89 @@ export class LocalAgentHost {
|
|
|
1351
1585
|
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1352
1586
|
throw error;
|
|
1353
1587
|
}
|
|
1354
|
-
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
1355
1588
|
const { report } = skillEnv;
|
|
1356
1589
|
if (report.installed.length > 0) {
|
|
1357
|
-
console.error(`[skills]
|
|
1590
|
+
console.error(`[skills] installed=[${report.installed.join(", ")}]`);
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
|
|
1594
|
+
const packageRoot = await realpath(source.packageRoot);
|
|
1595
|
+
const skillRoot = await realpath(source.root);
|
|
1596
|
+
const relativeSkillRoot = path.relative(packageRoot, skillRoot);
|
|
1597
|
+
if (relativeSkillRoot === ".." ||
|
|
1598
|
+
relativeSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1599
|
+
path.isAbsolute(relativeSkillRoot)) {
|
|
1600
|
+
throw new Error(`plugin skill root escaped package: ${source.installationId}`);
|
|
1601
|
+
}
|
|
1602
|
+
const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
|
|
1603
|
+
await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
|
|
1604
|
+
await cp(packageRoot, snapshotPackageRoot, {
|
|
1605
|
+
recursive: true,
|
|
1606
|
+
errorOnExist: true,
|
|
1607
|
+
force: false,
|
|
1608
|
+
dereference: false,
|
|
1609
|
+
});
|
|
1610
|
+
const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
|
|
1611
|
+
const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
|
|
1612
|
+
if (integrity !== source.integrity) {
|
|
1613
|
+
throw new Error(`plugin package integrity changed: ${source.installationId}`);
|
|
1614
|
+
}
|
|
1615
|
+
const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
|
|
1616
|
+
const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
|
|
1617
|
+
if (relativeSnapshotSkillRoot === ".." ||
|
|
1618
|
+
relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1619
|
+
path.isAbsolute(relativeSnapshotSkillRoot)) {
|
|
1620
|
+
throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
|
|
1621
|
+
}
|
|
1622
|
+
const discovered = await scanSkillsDir(snapshotSkillRoot);
|
|
1623
|
+
if (discovered.length === 0) {
|
|
1624
|
+
throw new Error(`plugin skill root is empty: ${source.installationId}`);
|
|
1625
|
+
}
|
|
1626
|
+
return discovered.map((skill) => ({
|
|
1627
|
+
...skill,
|
|
1628
|
+
name: providerPluginSkillName(source.namespace, skill.name),
|
|
1629
|
+
}));
|
|
1630
|
+
}));
|
|
1631
|
+
const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
|
|
1632
|
+
if (failedPluginSkill?.status === "rejected") {
|
|
1633
|
+
throw failedPluginSkill.reason;
|
|
1634
|
+
}
|
|
1635
|
+
const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1636
|
+
return {
|
|
1637
|
+
selectedSkills: [...skillEnv.resolved, ...pluginSkills],
|
|
1638
|
+
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
catch (error) {
|
|
1642
|
+
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1643
|
+
throw error;
|
|
1358
1644
|
}
|
|
1359
|
-
return {
|
|
1360
|
-
model: exec.model,
|
|
1361
|
-
...((opts?.agentName || opts?.agentSpec) && resolved.instructions
|
|
1362
|
-
? { instructions: resolved.instructions }
|
|
1363
|
-
: {}),
|
|
1364
|
-
selectedSkills: skillEnv.resolved,
|
|
1365
|
-
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1366
|
-
...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
|
|
1367
|
-
...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
|
|
1368
|
-
...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
|
|
1369
|
-
...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
|
|
1370
|
-
};
|
|
1371
1645
|
}
|
|
1372
1646
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
1373
|
-
const retargetMirror = opts
|
|
1647
|
+
const retargetMirror = opts.retargetMirror;
|
|
1648
|
+
const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
|
|
1649
|
+
if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
|
|
1650
|
+
throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
|
|
1651
|
+
}
|
|
1374
1652
|
const bridgeDir = prepareClaudeBridgeDir(localThreadId);
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1653
|
+
const workspace = structuredClone(opts.workspace);
|
|
1654
|
+
const execution = structuredClone(opts.execution);
|
|
1655
|
+
if (execution.provider !== "claude") {
|
|
1656
|
+
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1657
|
+
}
|
|
1658
|
+
const cwd = workspace.cwd;
|
|
1659
|
+
let snapshotSkills;
|
|
1381
1660
|
try {
|
|
1382
|
-
|
|
1661
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
1383
1662
|
}
|
|
1384
1663
|
catch (err) {
|
|
1385
|
-
console.error(`[
|
|
1664
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1386
1665
|
return false;
|
|
1387
1666
|
}
|
|
1388
|
-
const model =
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
|
|
1393
|
-
void liveCfg.skillsCleanup();
|
|
1394
|
-
// Agent-spec permission mode → claude's `--permission-mode` (claude-private
|
|
1395
|
-
// approval axis). Unset sends no flag (claude's own default); any set value
|
|
1396
|
-
// is passed through (`bypassPermissions` is how an agent opts out of the
|
|
1397
|
-
// approval prompt). The shared `sandbox` axis is intentionally NOT applied
|
|
1398
|
-
// here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
|
|
1399
|
-
const permissionMode = liveCfg.permissionMode;
|
|
1667
|
+
const model = execution.model;
|
|
1668
|
+
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1669
|
+
void snapshotSkills.skillsCleanup();
|
|
1670
|
+
const permissionMode = execution.permissionMode;
|
|
1400
1671
|
const launchExtraArgs = [
|
|
1401
1672
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
1402
1673
|
...(permissionMode ? ["--permission-mode", permissionMode] : []),
|
|
@@ -1406,9 +1677,18 @@ export class LocalAgentHost {
|
|
|
1406
1677
|
let currentSessionId = localThreadId;
|
|
1407
1678
|
let normalizer = null;
|
|
1408
1679
|
let currentResponseId;
|
|
1680
|
+
let pendingRotationEvents = null;
|
|
1681
|
+
const emitCurrent = (event) => {
|
|
1682
|
+
if (pendingRotationEvents) {
|
|
1683
|
+
pendingRotationEvents.push(event);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
emit(event);
|
|
1687
|
+
};
|
|
1409
1688
|
const startNormalizer = (turnId) => {
|
|
1410
1689
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1411
|
-
const responseId =
|
|
1690
|
+
const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
|
|
1691
|
+
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
1412
1692
|
if (normalizer && currentResponseId === responseId)
|
|
1413
1693
|
return normalizer;
|
|
1414
1694
|
currentResponseId = responseId;
|
|
@@ -1435,26 +1715,33 @@ export class LocalAgentHost {
|
|
|
1435
1715
|
...(event.reason ? { reason: event.reason } : {}),
|
|
1436
1716
|
};
|
|
1437
1717
|
for (const se of n.next(agentEvent))
|
|
1438
|
-
|
|
1718
|
+
emitCurrent(se);
|
|
1439
1719
|
};
|
|
1440
1720
|
let markReady;
|
|
1441
1721
|
const ready = new Promise((resolve) => {
|
|
1442
1722
|
markReady = resolve;
|
|
1443
1723
|
});
|
|
1724
|
+
let markFailed;
|
|
1725
|
+
const failed = new Promise((resolve) => {
|
|
1726
|
+
markFailed = resolve;
|
|
1727
|
+
});
|
|
1444
1728
|
const live = {
|
|
1445
1729
|
forwarder: null,
|
|
1446
|
-
|
|
1730
|
+
workspace,
|
|
1731
|
+
execution,
|
|
1447
1732
|
bridgeDir,
|
|
1448
1733
|
injectLock: Promise.resolve(),
|
|
1449
1734
|
pendingImageInputs: new Map(),
|
|
1735
|
+
pendingInjectedInputs: [],
|
|
1450
1736
|
ready,
|
|
1451
1737
|
markReady,
|
|
1738
|
+
failed,
|
|
1739
|
+
markFailed,
|
|
1452
1740
|
discoveredReady: false,
|
|
1453
1741
|
stopped: false,
|
|
1454
|
-
launchModel: model,
|
|
1455
|
-
...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
|
|
1456
1742
|
launchExtraArgs,
|
|
1457
|
-
|
|
1743
|
+
permissionMode,
|
|
1744
|
+
...(forkIntent ? { forkIntent } : {}),
|
|
1458
1745
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
1459
1746
|
};
|
|
1460
1747
|
const sink = {
|
|
@@ -1465,19 +1752,28 @@ export class LocalAgentHost {
|
|
|
1465
1752
|
const content = token ? live.pendingImageInputs.get(token) : undefined;
|
|
1466
1753
|
if (token && content)
|
|
1467
1754
|
live.pendingImageInputs.delete(token);
|
|
1468
|
-
|
|
1469
|
-
|
|
1755
|
+
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1756
|
+
const signature = JSON.stringify(normalizedContent);
|
|
1757
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1758
|
+
if (pending?.state === "prepublished" || pending?.state === "optimistic") {
|
|
1759
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (pending)
|
|
1763
|
+
pending.observed = true;
|
|
1764
|
+
for (const se of n.userInput(normalizedContent))
|
|
1765
|
+
emitCurrent(se);
|
|
1470
1766
|
},
|
|
1471
1767
|
onTerminalCommand: (cmd) => {
|
|
1472
1768
|
const n = normalizer ?? startNormalizer();
|
|
1473
1769
|
for (const se of n.terminalCommand(cmd))
|
|
1474
|
-
|
|
1770
|
+
emitCurrent(se);
|
|
1475
1771
|
},
|
|
1476
|
-
onTodos: (todos) =>
|
|
1772
|
+
onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
|
|
1477
1773
|
onEvent: (event) => {
|
|
1478
1774
|
const n = normalizer ?? startNormalizer();
|
|
1479
1775
|
for (const se of n.next(event))
|
|
1480
|
-
|
|
1776
|
+
emitCurrent(se);
|
|
1481
1777
|
},
|
|
1482
1778
|
onInteraction: forwardInteraction,
|
|
1483
1779
|
onTurnEnd: (usage) => {
|
|
@@ -1485,11 +1781,13 @@ export class LocalAgentHost {
|
|
|
1485
1781
|
return;
|
|
1486
1782
|
const rid = currentResponseId;
|
|
1487
1783
|
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
1488
|
-
if (usage)
|
|
1784
|
+
if (usage) {
|
|
1489
1785
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1490
|
-
|
|
1786
|
+
emitCurrent(se);
|
|
1787
|
+
}
|
|
1491
1788
|
for (const se of normalizer.next({ type: "done" }))
|
|
1492
|
-
|
|
1789
|
+
emitCurrent(se);
|
|
1790
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1493
1791
|
normalizer = null;
|
|
1494
1792
|
currentResponseId = undefined;
|
|
1495
1793
|
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
@@ -1497,7 +1795,7 @@ export class LocalAgentHost {
|
|
|
1497
1795
|
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1498
1796
|
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1499
1797
|
live.contextWarned = true;
|
|
1500
|
-
|
|
1798
|
+
emitCurrent({
|
|
1501
1799
|
type: "session.status",
|
|
1502
1800
|
sessionId: currentSessionId,
|
|
1503
1801
|
responseId: rid,
|
|
@@ -1510,7 +1808,7 @@ export class LocalAgentHost {
|
|
|
1510
1808
|
// Surface idle on the current turn WITHOUT finalizing it (see the sink's
|
|
1511
1809
|
// onIdle doc): a late assistant record still joins this response.
|
|
1512
1810
|
if (currentResponseId) {
|
|
1513
|
-
|
|
1811
|
+
emitCurrent({
|
|
1514
1812
|
type: "session.status",
|
|
1515
1813
|
sessionId: currentSessionId,
|
|
1516
1814
|
responseId: currentResponseId,
|
|
@@ -1521,87 +1819,115 @@ export class LocalAgentHost {
|
|
|
1521
1819
|
onTurnError: () => {
|
|
1522
1820
|
if (!normalizer)
|
|
1523
1821
|
return;
|
|
1822
|
+
const rid = currentResponseId;
|
|
1524
1823
|
for (const se of normalizer.fail({
|
|
1525
1824
|
code: "agent_error",
|
|
1526
1825
|
message: "Agent turn failed",
|
|
1527
1826
|
source: "execution",
|
|
1528
1827
|
}))
|
|
1529
|
-
|
|
1828
|
+
emitCurrent(se);
|
|
1829
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1530
1830
|
normalizer = null;
|
|
1531
1831
|
currentResponseId = undefined;
|
|
1532
1832
|
},
|
|
1533
|
-
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId,
|
|
1833
|
+
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
|
|
1834
|
+
onSessionResumeError: (error) => {
|
|
1835
|
+
live.error = error.message;
|
|
1836
|
+
live.markFailed();
|
|
1837
|
+
},
|
|
1534
1838
|
onSessionRotated: (kind, claudeSessionId) => {
|
|
1839
|
+
if (pendingRotationEvents) {
|
|
1840
|
+
live.error = "Claude reported another Session rotation before publication completed";
|
|
1841
|
+
live.markFailed();
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1535
1844
|
// The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
|
|
1536
|
-
// new transcript.
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1845
|
+
// new transcript. Persist the Provider binding first, then announce on
|
|
1846
|
+
// the OLD session and re-target the mirror. Events produced during the
|
|
1847
|
+
// file-store commit are buffered so none can land under the old identity.
|
|
1539
1848
|
const previousSessionId = currentSessionId;
|
|
1540
1849
|
const newSessionId = makeSessionId();
|
|
1541
|
-
|
|
1850
|
+
currentSessionId = newSessionId;
|
|
1851
|
+
normalizer = null;
|
|
1852
|
+
currentResponseId = undefined;
|
|
1853
|
+
live.contextWarned = false;
|
|
1854
|
+
pendingRotationEvents = [];
|
|
1855
|
+
this.liveClaudeSessions.set(newSessionId, live);
|
|
1542
1856
|
void this.sessionStore
|
|
1543
1857
|
.set({
|
|
1544
1858
|
localThreadId: newSessionId,
|
|
1545
1859
|
codexSessionId: claudeSessionId,
|
|
1546
|
-
cwd: live.cwd,
|
|
1547
|
-
model,
|
|
1548
|
-
runtime: "claude",
|
|
1549
|
-
...(record?.agent ? { agent: record.agent } : {}),
|
|
1550
1860
|
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1551
1861
|
updatedAt: new Date().toISOString(),
|
|
1552
1862
|
})
|
|
1553
|
-
.
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1863
|
+
.then(() => {
|
|
1864
|
+
emit({
|
|
1865
|
+
type: "session.rotated",
|
|
1866
|
+
sessionId: previousSessionId,
|
|
1867
|
+
newSessionId,
|
|
1868
|
+
kind,
|
|
1869
|
+
});
|
|
1870
|
+
retargetMirror?.(newSessionId, {
|
|
1871
|
+
kind,
|
|
1872
|
+
workspace: structuredClone(live.workspace),
|
|
1873
|
+
execution: structuredClone(live.execution),
|
|
1874
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1875
|
+
});
|
|
1876
|
+
const queued = pendingRotationEvents ?? [];
|
|
1877
|
+
pendingRotationEvents = null;
|
|
1878
|
+
for (const event of queued)
|
|
1879
|
+
emit(event);
|
|
1880
|
+
})
|
|
1881
|
+
.catch((error) => {
|
|
1882
|
+
pendingRotationEvents = null;
|
|
1883
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1884
|
+
live.markFailed();
|
|
1565
1885
|
});
|
|
1566
1886
|
},
|
|
1567
1887
|
};
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
: undefined;
|
|
1888
|
+
// Claude itself resolves `--resume <id>`. The SessionStart hook is the
|
|
1889
|
+
// authoritative source for the actual transcript path; do not infer or
|
|
1890
|
+
// generate Claude's private on-disk format here.
|
|
1572
1891
|
live.forwarder = new ClaudeLiveSession({
|
|
1573
1892
|
bridgeDir,
|
|
1574
1893
|
sink,
|
|
1575
|
-
...(
|
|
1576
|
-
|
|
1894
|
+
...(forkIntent
|
|
1895
|
+
? { claudeSessionId: forkIntent.targetClaudeSessionId }
|
|
1896
|
+
: record?.codexSessionId
|
|
1897
|
+
? { claudeSessionId: record.codexSessionId }
|
|
1898
|
+
: {}),
|
|
1899
|
+
...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
|
|
1577
1900
|
});
|
|
1578
1901
|
this.liveClaudeSessions.set(localThreadId, live);
|
|
1579
1902
|
live.forwarder.start();
|
|
1580
|
-
if (record?.codexSessionId) {
|
|
1581
|
-
live.discoveredReady = true;
|
|
1582
|
-
markReady(); // resumed → injection can proceed without awaiting discovery
|
|
1583
|
-
}
|
|
1584
1903
|
return true;
|
|
1585
1904
|
}
|
|
1586
1905
|
/** Persist claude's discovered session id (reusing the `codexSessionId` store
|
|
1587
1906
|
* field, as the claude executor already does) and release the readiness gate. */
|
|
1588
|
-
onClaudeDiscovered(live, localThreadId,
|
|
1907
|
+
onClaudeDiscovered(live, localThreadId, claudeSessionId) {
|
|
1589
1908
|
if (live.discoveredReady)
|
|
1590
1909
|
return;
|
|
1910
|
+
const forkIntent = live.forkIntent;
|
|
1911
|
+
if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
|
|
1912
|
+
live.error =
|
|
1913
|
+
`Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
|
|
1914
|
+
live.markFailed();
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1591
1917
|
live.discoveredReady = true;
|
|
1592
1918
|
void this.sessionStore
|
|
1593
1919
|
.set({
|
|
1594
1920
|
localThreadId,
|
|
1595
1921
|
codexSessionId: claudeSessionId,
|
|
1596
|
-
|
|
1597
|
-
// Persist the agent-spec-resolved launch model (was the config default).
|
|
1598
|
-
model: live.launchModel,
|
|
1599
|
-
runtime: "claude",
|
|
1600
|
-
agent,
|
|
1922
|
+
...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
|
|
1601
1923
|
updatedAt: new Date().toISOString(),
|
|
1602
1924
|
})
|
|
1603
|
-
.
|
|
1604
|
-
|
|
1925
|
+
.then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
|
|
1926
|
+
.then(() => live.markReady())
|
|
1927
|
+
.catch((error) => {
|
|
1928
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1929
|
+
live.markFailed();
|
|
1930
|
+
});
|
|
1605
1931
|
}
|
|
1606
1932
|
/** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
|
|
1607
1933
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
@@ -1683,12 +2009,12 @@ export class LocalAgentHost {
|
|
|
1683
2009
|
if (!record?.codexSessionId) {
|
|
1684
2010
|
return { ok: false, reason: "no_active_thread" };
|
|
1685
2011
|
}
|
|
1686
|
-
const
|
|
1687
|
-
if (!
|
|
2012
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2013
|
+
if (!live) {
|
|
1688
2014
|
return { ok: false, reason: "unsupported" };
|
|
1689
2015
|
}
|
|
1690
2016
|
try {
|
|
1691
|
-
return { ok: true, data: await fn(
|
|
2017
|
+
return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
|
|
1692
2018
|
}
|
|
1693
2019
|
catch (error) {
|
|
1694
2020
|
return {
|
|
@@ -1714,30 +2040,108 @@ export class LocalAgentHost {
|
|
|
1714
2040
|
await client.threadGoalClear({ threadId });
|
|
1715
2041
|
});
|
|
1716
2042
|
}
|
|
1717
|
-
async forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
2043
|
+
async forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
2044
|
+
const inflight = this.forkingTargets.get(newLocalThreadId);
|
|
2045
|
+
if (inflight)
|
|
2046
|
+
return inflight;
|
|
2047
|
+
const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
|
|
2048
|
+
this.forkingTargets.set(newLocalThreadId, operation);
|
|
2049
|
+
try {
|
|
2050
|
+
return await operation;
|
|
2051
|
+
}
|
|
2052
|
+
finally {
|
|
2053
|
+
if (this.forkingTargets.get(newLocalThreadId) === operation) {
|
|
2054
|
+
this.forkingTargets.delete(newLocalThreadId);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
1718
2059
|
const record = await this.sessionStore.get(currentLocalThreadId);
|
|
1719
2060
|
if (!record) {
|
|
1720
2061
|
return { ok: false, reason: "no_active_thread" };
|
|
1721
2062
|
}
|
|
1722
|
-
const
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
2063
|
+
const live = this.liveSessions.get(currentLocalThreadId);
|
|
2064
|
+
const workspace = live?.workspace ?? options?.workspace;
|
|
2065
|
+
const execution = live?.execution ?? options?.execution;
|
|
2066
|
+
if (!workspace || !execution) {
|
|
2067
|
+
return { ok: false, reason: "no_active_thread" };
|
|
2068
|
+
}
|
|
2069
|
+
if (execution.provider === "claude") {
|
|
2070
|
+
return {
|
|
2071
|
+
ok: false,
|
|
2072
|
+
reason: "unsupported",
|
|
2073
|
+
message: "Claude fork must be materialized by the target runner",
|
|
2074
|
+
};
|
|
1726
2075
|
}
|
|
1727
2076
|
try {
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2077
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
2078
|
+
if (existingTarget) {
|
|
2079
|
+
if (existingTarget.parentSessionId !== currentLocalThreadId) {
|
|
2080
|
+
return {
|
|
2081
|
+
ok: false,
|
|
2082
|
+
reason: "error",
|
|
2083
|
+
message: "target Session already belongs to a different fork",
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
return { ok: true, data: undefined };
|
|
2087
|
+
}
|
|
2088
|
+
const runtime = execution.provider;
|
|
2089
|
+
const client = live?.injectClient ??
|
|
2090
|
+
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2091
|
+
if (!client) {
|
|
2092
|
+
return { ok: false, reason: "unsupported" };
|
|
2093
|
+
}
|
|
2094
|
+
await client.ensureInitialized();
|
|
2095
|
+
if (live?.managedFork) {
|
|
2096
|
+
return {
|
|
2097
|
+
ok: false,
|
|
2098
|
+
reason: "error",
|
|
2099
|
+
message: "source Session already has a managed fork in progress",
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
const managedFork = live
|
|
2103
|
+
? {
|
|
2104
|
+
sourceThreadId: record.codexSessionId,
|
|
2105
|
+
observedThreadIds: new Set(),
|
|
2106
|
+
}
|
|
2107
|
+
: undefined;
|
|
2108
|
+
const forked = await (async () => {
|
|
2109
|
+
if (live && managedFork)
|
|
2110
|
+
live.managedFork = managedFork;
|
|
2111
|
+
try {
|
|
2112
|
+
return await client.threadFork({
|
|
2113
|
+
threadId: record.codexSessionId,
|
|
2114
|
+
excludeTurns: true,
|
|
2115
|
+
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2116
|
+
model: execution.model,
|
|
2117
|
+
approvalPolicy: execution.approvalPolicy,
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
finally {
|
|
2121
|
+
if (live && live.managedFork === managedFork)
|
|
2122
|
+
delete live.managedFork;
|
|
2123
|
+
for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
|
|
2124
|
+
this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
})();
|
|
1733
2128
|
const forkedThreadId = forked.thread.id;
|
|
2129
|
+
if (live && forkedThreadId) {
|
|
2130
|
+
this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
|
|
2131
|
+
}
|
|
2132
|
+
if (!forkedThreadId ||
|
|
2133
|
+
forkedThreadId === record.codexSessionId ||
|
|
2134
|
+
(forked.thread.forkedFromId !== undefined &&
|
|
2135
|
+
forked.thread.forkedFromId !== null &&
|
|
2136
|
+
forked.thread.forkedFromId !== record.codexSessionId)) {
|
|
2137
|
+
throw new Error("Provider returned an invalid forked thread");
|
|
2138
|
+
}
|
|
2139
|
+
const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
|
|
1734
2140
|
await this.sessionStore.set({
|
|
1735
2141
|
localThreadId: newLocalThreadId,
|
|
1736
2142
|
codexSessionId: forkedThreadId,
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
reasoningEffort: record.reasoningEffort,
|
|
1740
|
-
runtime,
|
|
2143
|
+
parentSessionId: currentLocalThreadId,
|
|
2144
|
+
runtimeHomeOwnerSessionId: sourceHomeOwner,
|
|
1741
2145
|
updatedAt: new Date().toISOString(),
|
|
1742
2146
|
});
|
|
1743
2147
|
return { ok: true, data: undefined };
|
|
@@ -1751,6 +2155,12 @@ export class LocalAgentHost {
|
|
|
1751
2155
|
}
|
|
1752
2156
|
}
|
|
1753
2157
|
}
|
|
2158
|
+
function codexInjectionError(error) {
|
|
2159
|
+
if (error instanceof CodexTransportError) {
|
|
2160
|
+
return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
|
|
2161
|
+
}
|
|
2162
|
+
return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2163
|
+
}
|
|
1754
2164
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
1755
2165
|
const normalized = output.trim();
|
|
1756
2166
|
const match = normalized.match(/Logged in using (.+)$/im);
|
|
@@ -1774,25 +2184,48 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
1774
2184
|
issues: normalized ? [normalized] : [],
|
|
1775
2185
|
};
|
|
1776
2186
|
}
|
|
1777
|
-
/** Race a live session's `ready` promise against
|
|
1778
|
-
function raceReady(ready, timeoutMs) {
|
|
2187
|
+
/** Race a live session's `ready` promise against failure / timeout. */
|
|
2188
|
+
function raceReady(ready, timeoutMs, failed) {
|
|
1779
2189
|
let timer;
|
|
1780
2190
|
const timeout = new Promise((resolve) => {
|
|
1781
2191
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1782
2192
|
});
|
|
1783
|
-
|
|
2193
|
+
const outcomes = [ready.then(() => true), timeout];
|
|
2194
|
+
if (failed)
|
|
2195
|
+
outcomes.push(failed.then(() => false));
|
|
2196
|
+
return Promise.race(outcomes).finally(() => {
|
|
1784
2197
|
if (timer)
|
|
1785
2198
|
clearTimeout(timer);
|
|
1786
2199
|
});
|
|
1787
2200
|
}
|
|
1788
|
-
function
|
|
2201
|
+
function sameSessionSnapshots(workspace, execution, opts) {
|
|
1789
2202
|
return JSON.stringify(stableValue({
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2203
|
+
workspace,
|
|
2204
|
+
execution,
|
|
2205
|
+
})) === JSON.stringify(stableValue({
|
|
2206
|
+
workspace: opts.workspace,
|
|
2207
|
+
execution: opts.execution,
|
|
1794
2208
|
}));
|
|
1795
2209
|
}
|
|
2210
|
+
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2211
|
+
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2212
|
+
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
2213
|
+
function applyClaudePermissionModeSnapshot(settings, permissionMode) {
|
|
2214
|
+
const next = { ...settings };
|
|
2215
|
+
const inherited = settings.permissions;
|
|
2216
|
+
const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
|
|
2217
|
+
? { ...inherited }
|
|
2218
|
+
: {};
|
|
2219
|
+
if (permissionMode)
|
|
2220
|
+
permissions.defaultMode = permissionMode;
|
|
2221
|
+
else
|
|
2222
|
+
delete permissions.defaultMode;
|
|
2223
|
+
if (Object.keys(permissions).length > 0)
|
|
2224
|
+
next.permissions = permissions;
|
|
2225
|
+
else
|
|
2226
|
+
delete next.permissions;
|
|
2227
|
+
return next;
|
|
2228
|
+
}
|
|
1796
2229
|
function stableValue(value) {
|
|
1797
2230
|
if (Array.isArray(value))
|
|
1798
2231
|
return value.map(stableValue);
|
|
@@ -1826,3 +2259,10 @@ export function isThreadNotReadyError(error) {
|
|
|
1826
2259
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1827
2260
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
1828
2261
|
}
|
|
2262
|
+
/** A persisted thread id that a freshly started app-server cannot load yet.
|
|
2263
|
+
* During startup, both errors can be transient while the rollout index catches
|
|
2264
|
+
* up. Retry the same id; never use either error as permission to replace it. */
|
|
2265
|
+
export function isRetryableThreadResumeError(error) {
|
|
2266
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2267
|
+
return message.includes("thread not found") || isThreadNotReadyError(error);
|
|
2268
|
+
}
|