@rynx-ai/runtime 0.1.11-beta.1 → 0.1.11-beta.11
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/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- 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 +48 -47
- package/dist/host.js +823 -355
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/models-catalog.d.ts +3 -1
- package/dist/models-catalog.js +125 -4
- package/dist/provider-workspace.d.ts +56 -0
- package/dist/provider-workspace.js +83 -0
- package/dist/runner/child.d.ts +59 -6
- package/dist/runner/child.js +138 -19
- package/dist/runner/manager.d.ts +104 -19
- package/dist/runner/manager.js +922 -89
- 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 +10 -1
- package/dist/terminal/claude-tui.js +9 -1
- package/dist/terminal/codex-tui.d.ts +5 -1
- package/dist/terminal/codex-tui.js +12 -3
- package/dist/terminal/tmux.d.ts +11 -1
- package/dist/terminal/tmux.js +61 -12
- package/package.json +3 -3
- 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,12 +269,17 @@ 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
|
-
this.defaultRuntime = config.
|
|
282
|
+
this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
|
|
259
283
|
this.injectedCommandRunner = commandRunner;
|
|
260
284
|
this.injectedAppServerClient = appServerClient;
|
|
261
285
|
this.clock = now;
|
|
@@ -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,44 @@ 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 traexYolo = runtime === "traex" &&
|
|
476
|
+
sandbox === "danger-full-access" &&
|
|
477
|
+
approvalPolicy === "never";
|
|
478
|
+
const configOverrides = sandbox === "workspace-write"
|
|
448
479
|
? ["sandbox_workspace_write.network_access=true"]
|
|
449
480
|
: [];
|
|
481
|
+
configOverrides.push(`sandbox_mode=${JSON.stringify(sandbox)}`, `approval_policy=${JSON.stringify(approvalPolicy)}`);
|
|
482
|
+
// A managed TUI has no user at its stdin during startup. Codex's periodic
|
|
483
|
+
// "Update now / Skip" prompt would otherwise park the detached pane before
|
|
484
|
+
// it connects to the app-server, leaving the session only partially live.
|
|
485
|
+
if (runtime === "codex")
|
|
486
|
+
configOverrides.push("check_for_update_on_startup=false");
|
|
450
487
|
// Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
|
|
451
488
|
// shared per-runtime, so a per-session model rides a launch `-c model=` instead).
|
|
452
|
-
const launchModel = live
|
|
489
|
+
const launchModel = live.model;
|
|
453
490
|
if (launchModel)
|
|
454
491
|
configOverrides.push(`model=${JSON.stringify(launchModel)}`);
|
|
455
|
-
const launchEffort = live
|
|
492
|
+
const launchEffort = live.reasoningEffort;
|
|
456
493
|
if (launchEffort)
|
|
457
494
|
configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
|
|
458
495
|
if (live?.instructions) {
|
|
459
496
|
configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
|
|
460
497
|
}
|
|
461
498
|
const profile = getRuntimeProfile(runtime);
|
|
499
|
+
const activeThreadId = live?.threadId ?? record?.codexSessionId;
|
|
462
500
|
return {
|
|
463
501
|
command: resolveRuntimeBinary(runtime),
|
|
464
502
|
args: buildCodexRemoteArgs({
|
|
465
503
|
remoteUrl,
|
|
466
|
-
...(
|
|
504
|
+
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
467
505
|
configOverrides,
|
|
506
|
+
codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
|
|
507
|
+
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
468
508
|
}),
|
|
469
|
-
cwd:
|
|
509
|
+
cwd: live.workspace.cwd,
|
|
510
|
+
...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
|
|
470
511
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
471
512
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
472
513
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -486,25 +527,33 @@ export class LocalAgentHost {
|
|
|
486
527
|
* DETERMINISTIC (`resp_codex_<turnId>`) so items converge across snapshot/live.
|
|
487
528
|
*
|
|
488
529
|
* 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.
|
|
530
|
+
* streaming run path). For a fresh session the native remote TUI creates the
|
|
531
|
+
* Codex thread and its `thread/started` notification supplies the id. For an
|
|
532
|
+
* existing session, the app-server resumes the persisted id. Rynx never writes
|
|
533
|
+
* or repairs Codex's private rollout files.
|
|
495
534
|
*/
|
|
496
535
|
async ensureLiveCodexSession(localThreadId, emit, opts) {
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
536
|
+
const snapshotOpts = {
|
|
537
|
+
workspace: structuredClone(opts.workspace),
|
|
538
|
+
execution: structuredClone(opts.execution),
|
|
539
|
+
...(opts.retargetMirror ? { retargetMirror: opts.retargetMirror } : {}),
|
|
540
|
+
};
|
|
541
|
+
const existingClaude = this.liveClaudeSessions.get(localThreadId);
|
|
542
|
+
if (existingClaude) {
|
|
543
|
+
return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
|
|
544
|
+
}
|
|
502
545
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
503
|
-
// session (two would double-mirror every turn).
|
|
546
|
+
// session (two would double-mirror every turn). Check this before the live
|
|
547
|
+
// map: startLiveCodexSession publishes its partially initialized session
|
|
548
|
+
// before thread/start completes, so a concurrent Terminal start must await
|
|
549
|
+
// the thread id instead of launching a bare `codex --remote` pane.
|
|
504
550
|
const inflight = this.liveEnsuring.get(localThreadId);
|
|
505
551
|
if (inflight)
|
|
506
552
|
return inflight;
|
|
507
|
-
const
|
|
553
|
+
const live = this.liveSessions.get(localThreadId);
|
|
554
|
+
if (live)
|
|
555
|
+
return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
|
|
556
|
+
const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
|
|
508
557
|
this.liveEnsuring.set(localThreadId, started);
|
|
509
558
|
try {
|
|
510
559
|
return await started;
|
|
@@ -522,48 +571,51 @@ export class LocalAgentHost {
|
|
|
522
571
|
this.stopLiveCodexSession(localThreadId);
|
|
523
572
|
}
|
|
524
573
|
const record = await this.sessionStore.get(localThreadId);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const runtime =
|
|
574
|
+
const retargetMirror = opts.retargetMirror;
|
|
575
|
+
const workspace = structuredClone(opts.workspace);
|
|
576
|
+
const execution = structuredClone(opts.execution);
|
|
577
|
+
const runtime = execution.provider;
|
|
529
578
|
if (runtime === "claude")
|
|
530
579
|
return this.startLiveClaudeSession(localThreadId, emit, record, opts);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
580
|
+
let snapshotSkills = {
|
|
581
|
+
selectedSkills: [],
|
|
582
|
+
skillsCleanup: async () => undefined,
|
|
583
|
+
};
|
|
584
|
+
if (this.runtimeHomeSessionId === this.sessionId) {
|
|
585
|
+
try {
|
|
586
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Persist the owner's skills before the app-server boots. Fork targets reuse
|
|
594
|
+
// the owner's immutable copies and never mutate the shared Provider home.
|
|
595
|
+
const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
537
596
|
try {
|
|
538
|
-
|
|
597
|
+
populateCodexSkills(this.runtimeHome(runtime), liveSkills);
|
|
539
598
|
}
|
|
540
599
|
catch (err) {
|
|
541
|
-
|
|
600
|
+
void snapshotSkills.skillsCleanup();
|
|
601
|
+
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
542
602
|
return false;
|
|
543
603
|
}
|
|
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;
|
|
604
|
+
const sandbox = execution.sandbox;
|
|
605
|
+
const approvalPolicy = execution.approvalPolicy;
|
|
606
|
+
if (!sandbox || !approvalPolicy) {
|
|
607
|
+
void snapshotSkills.skillsCleanup();
|
|
608
|
+
throw new CodexRuntimeError(`${runtime} execution snapshot is missing sandbox or approval policy`, 422, "invalid_execution_snapshot");
|
|
609
|
+
}
|
|
610
|
+
this.sessionSandbox = sandbox;
|
|
611
|
+
this.sessionApprovalPolicy = approvalPolicy;
|
|
560
612
|
// Backend client owns the app-server + drives injection. Every failed-start
|
|
561
613
|
// path below removes the session skills dir it would otherwise leak.
|
|
562
614
|
const abandon = () => {
|
|
563
|
-
void
|
|
615
|
+
void snapshotSkills.skillsCleanup();
|
|
564
616
|
return false;
|
|
565
617
|
};
|
|
566
|
-
const injectClient = this.getBackend(runtime).appServerClient;
|
|
618
|
+
const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
567
619
|
if (!injectClient) {
|
|
568
620
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
569
621
|
return abandon();
|
|
@@ -591,36 +643,40 @@ export class LocalAgentHost {
|
|
|
591
643
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
592
644
|
return abandon();
|
|
593
645
|
}
|
|
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;
|
|
646
|
+
const model = execution.model ?? "";
|
|
647
|
+
const reasoningEffort = execution.reasoningEffort ?? undefined;
|
|
603
648
|
let markReady;
|
|
604
649
|
const ready = new Promise((resolve) => {
|
|
605
650
|
markReady = resolve;
|
|
606
651
|
});
|
|
652
|
+
let markTerminalReady;
|
|
653
|
+
const terminalReady = new Promise((resolve) => {
|
|
654
|
+
markTerminalReady = resolve;
|
|
655
|
+
});
|
|
607
656
|
const live = {
|
|
608
657
|
injectClient,
|
|
609
658
|
forwarderClient,
|
|
610
659
|
forwarder: null,
|
|
611
660
|
runtime,
|
|
612
|
-
|
|
661
|
+
workspace,
|
|
662
|
+
execution,
|
|
663
|
+
sandbox,
|
|
664
|
+
approvalPolicy,
|
|
613
665
|
model,
|
|
614
666
|
reasoningEffort,
|
|
615
|
-
...(
|
|
667
|
+
...(execution.instructions ? { instructions: execution.instructions } : {}),
|
|
616
668
|
threadId: record?.codexSessionId ?? null,
|
|
617
669
|
ready,
|
|
618
670
|
markReady,
|
|
671
|
+
terminalReady,
|
|
672
|
+
markTerminalReady,
|
|
619
673
|
injectLock: Promise.resolve(),
|
|
674
|
+
pendingInjectedInputs: [],
|
|
675
|
+
publishInjectedInput: () => undefined,
|
|
620
676
|
subscribing: false,
|
|
677
|
+
rotationPending: false,
|
|
621
678
|
stopped: false,
|
|
622
|
-
skillsCleanup:
|
|
623
|
-
configSignature,
|
|
679
|
+
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
624
680
|
interactionOwners: new Map(),
|
|
625
681
|
interactionStandbys: new Map(),
|
|
626
682
|
interactionSubmissions: new Map(),
|
|
@@ -630,26 +686,48 @@ export class LocalAgentHost {
|
|
|
630
686
|
interactionFailedClients: new Map(),
|
|
631
687
|
disconnectedClients: new Set(),
|
|
632
688
|
};
|
|
689
|
+
let currentSessionId = localThreadId;
|
|
690
|
+
let pendingRotationEvents = null;
|
|
691
|
+
const emitCurrent = (event) => {
|
|
692
|
+
if (pendingRotationEvents) {
|
|
693
|
+
pendingRotationEvents.push(event);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
emit(event);
|
|
697
|
+
};
|
|
633
698
|
let normalizer = null;
|
|
634
699
|
let currentResponseId = null;
|
|
635
700
|
const startNormalizer = (turnId) => {
|
|
636
|
-
const responseId =
|
|
701
|
+
const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
|
|
702
|
+
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
637
703
|
if (normalizer && currentResponseId === responseId)
|
|
638
704
|
return normalizer;
|
|
639
705
|
currentResponseId = responseId;
|
|
640
706
|
normalizer = new SessionNormalizer({
|
|
641
|
-
sessionId:
|
|
707
|
+
sessionId: currentSessionId,
|
|
642
708
|
// turnId unknown → fixed literal (NEVER random): items arriving before
|
|
643
709
|
// `turn/started` land on ONE response instead of splitting into random
|
|
644
710
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
645
711
|
responseId,
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// label, so report the runtime when the exact model is unknown.
|
|
712
|
+
// A null Session model intentionally leaves selection to the Provider
|
|
713
|
+
// CLI. Canonical events still require a printable label.
|
|
649
714
|
model: model || runtime,
|
|
650
715
|
});
|
|
651
716
|
return normalizer;
|
|
652
717
|
};
|
|
718
|
+
live.publishInjectedInput = (turnId, content) => {
|
|
719
|
+
const n = startNormalizer(turnId);
|
|
720
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === JSON.stringify(content) && entry.responseId === undefined);
|
|
721
|
+
if (pending && currentResponseId)
|
|
722
|
+
pending.responseId = currentResponseId;
|
|
723
|
+
for (const se of n.userInput(content))
|
|
724
|
+
emitCurrent(se);
|
|
725
|
+
};
|
|
726
|
+
const clearPendingInputsForResponse = (responseId) => {
|
|
727
|
+
if (!responseId)
|
|
728
|
+
return;
|
|
729
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
730
|
+
};
|
|
653
731
|
const rememberSettledInteraction = (interactionId) => {
|
|
654
732
|
live.settledInteractions.add(interactionId);
|
|
655
733
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -692,7 +770,7 @@ export class LocalAgentHost {
|
|
|
692
770
|
...(event.reason ? { reason: event.reason } : {}),
|
|
693
771
|
};
|
|
694
772
|
for (const se of n.next(agentEvent))
|
|
695
|
-
|
|
773
|
+
emitCurrent(se);
|
|
696
774
|
};
|
|
697
775
|
const clearRecovery = (interactionId) => {
|
|
698
776
|
live.interactionRecoveries.delete(interactionId);
|
|
@@ -848,24 +926,38 @@ export class LocalAgentHost {
|
|
|
848
926
|
const sink = {
|
|
849
927
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
850
928
|
onUserMessage: (content) => {
|
|
929
|
+
const normalizedContent = typeof content === "string"
|
|
930
|
+
? [{ type: "input_text", text: content }]
|
|
931
|
+
: content;
|
|
932
|
+
const signature = JSON.stringify(normalizedContent);
|
|
933
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
934
|
+
if (pending?.state === "optimistic" || pending?.state === "prepublished") {
|
|
935
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (pending)
|
|
939
|
+
pending.observed = true;
|
|
851
940
|
const n = normalizer ?? startNormalizer();
|
|
852
|
-
for (const se of n.userInput(
|
|
853
|
-
|
|
941
|
+
for (const se of n.userInput(normalizedContent))
|
|
942
|
+
emitCurrent(se);
|
|
854
943
|
},
|
|
855
944
|
onEvent: (event) => {
|
|
856
945
|
const n = normalizer ?? startNormalizer();
|
|
857
946
|
for (const se of n.next(event))
|
|
858
|
-
|
|
947
|
+
emitCurrent(se);
|
|
859
948
|
},
|
|
860
949
|
onTurnEnd: (usage) => {
|
|
861
950
|
closeCanonicalInteractions();
|
|
862
951
|
if (!normalizer)
|
|
863
952
|
return;
|
|
864
|
-
|
|
953
|
+
const completedResponseId = currentResponseId;
|
|
954
|
+
if (usage) {
|
|
865
955
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
866
|
-
|
|
956
|
+
emitCurrent(se);
|
|
957
|
+
}
|
|
867
958
|
for (const se of normalizer.next({ type: "done" }))
|
|
868
|
-
|
|
959
|
+
emitCurrent(se);
|
|
960
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
869
961
|
normalizer = null;
|
|
870
962
|
currentResponseId = null;
|
|
871
963
|
},
|
|
@@ -873,6 +965,7 @@ export class LocalAgentHost {
|
|
|
873
965
|
closeCanonicalInteractions();
|
|
874
966
|
if (!normalizer)
|
|
875
967
|
return;
|
|
968
|
+
const failedResponseId = currentResponseId;
|
|
876
969
|
const responseStopped = error.message === "Codex turn was interrupted";
|
|
877
970
|
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
878
971
|
const message = responseStopped
|
|
@@ -887,14 +980,14 @@ export class LocalAgentHost {
|
|
|
887
980
|
message,
|
|
888
981
|
source: "execution",
|
|
889
982
|
}))
|
|
890
|
-
|
|
983
|
+
emitCurrent(se);
|
|
984
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
891
985
|
normalizer = null;
|
|
892
986
|
currentResponseId = null;
|
|
893
987
|
},
|
|
894
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
|
|
988
|
+
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
989
|
+
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
990
|
+
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
898
991
|
onThreadActive: () => live.releaseActive?.(),
|
|
899
992
|
};
|
|
900
993
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
@@ -908,99 +1001,182 @@ export class LocalAgentHost {
|
|
|
908
1001
|
surfaceQueueStatus: runtime === "traex",
|
|
909
1002
|
});
|
|
910
1003
|
live.forwarder = forwarder;
|
|
1004
|
+
live.onNativeThreadRotated = (threadId, kind) => {
|
|
1005
|
+
if (live.rotationPending) {
|
|
1006
|
+
live.stopped = true;
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const previousSessionId = currentSessionId;
|
|
1010
|
+
const newSessionId = makeSessionId();
|
|
1011
|
+
live.rotationPending = true;
|
|
1012
|
+
currentSessionId = newSessionId;
|
|
1013
|
+
normalizer = null;
|
|
1014
|
+
currentResponseId = null;
|
|
1015
|
+
live.pendingInjectedInputs = [];
|
|
1016
|
+
pendingRotationEvents = [];
|
|
1017
|
+
void this.sessionStore
|
|
1018
|
+
.get(previousSessionId)
|
|
1019
|
+
.then((previous) => this.sessionStore.set({
|
|
1020
|
+
localThreadId: newSessionId,
|
|
1021
|
+
codexSessionId: threadId,
|
|
1022
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1023
|
+
...(previous?.runtimeHomeOwnerSessionId
|
|
1024
|
+
? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
|
|
1025
|
+
: {}),
|
|
1026
|
+
updatedAt: new Date().toISOString(),
|
|
1027
|
+
}))
|
|
1028
|
+
.then(() => {
|
|
1029
|
+
emit({
|
|
1030
|
+
type: "session.rotated",
|
|
1031
|
+
sessionId: previousSessionId,
|
|
1032
|
+
newSessionId,
|
|
1033
|
+
kind,
|
|
1034
|
+
});
|
|
1035
|
+
this.liveSessions.set(newSessionId, live);
|
|
1036
|
+
retargetMirror?.(newSessionId, {
|
|
1037
|
+
kind,
|
|
1038
|
+
workspace: structuredClone(live.workspace),
|
|
1039
|
+
execution: structuredClone(live.execution),
|
|
1040
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1041
|
+
});
|
|
1042
|
+
const queued = pendingRotationEvents ?? [];
|
|
1043
|
+
pendingRotationEvents = null;
|
|
1044
|
+
live.rotationPending = false;
|
|
1045
|
+
for (const event of queued)
|
|
1046
|
+
emit(event);
|
|
1047
|
+
void this.subscribeUntilReady(live, threadId);
|
|
1048
|
+
})
|
|
1049
|
+
.catch(() => {
|
|
1050
|
+
pendingRotationEvents = null;
|
|
1051
|
+
live.rotationPending = false;
|
|
1052
|
+
live.stopped = true;
|
|
1053
|
+
});
|
|
1054
|
+
};
|
|
911
1055
|
bindInteractionClient(injectClient);
|
|
912
1056
|
bindInteractionClient(forwarderClient);
|
|
913
1057
|
this.liveSessions.set(localThreadId, live);
|
|
914
1058
|
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;
|
|
1059
|
+
// Existing bindings resume through the public app-server API. A fresh thread
|
|
1060
|
+
// is created by the native remote TUI instead: its `thread/started`
|
|
1061
|
+
// notification binds this live handle in `onLiveThreadStarted`. Do not
|
|
1062
|
+
// synthesize Codex's private rollout JSONL merely to make a just-created,
|
|
1063
|
+
// turn-less app-server thread resumable by the TUI.
|
|
930
1064
|
try {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
1065
|
+
if (record?.codexSessionId) {
|
|
1066
|
+
let resumeError;
|
|
1067
|
+
let resumedThreadId;
|
|
1068
|
+
const attempts = 20;
|
|
1069
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1070
|
+
try {
|
|
1071
|
+
const resumed = await injectClient.threadResume({
|
|
1072
|
+
threadId: record.codexSessionId,
|
|
1073
|
+
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1074
|
+
approvalPolicy,
|
|
1075
|
+
excludeTurns: true,
|
|
1076
|
+
});
|
|
1077
|
+
resumedThreadId = resumed.threadId;
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
resumeError = error;
|
|
1082
|
+
if (!isRetryableThreadResumeError(error))
|
|
1083
|
+
throw error;
|
|
1084
|
+
if (attempt + 1 < attempts) {
|
|
1085
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (resumedThreadId) {
|
|
1090
|
+
this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
|
|
1091
|
+
}
|
|
1092
|
+
else if (resumeError &&
|
|
1093
|
+
isThreadNotReadyError(resumeError) &&
|
|
1094
|
+
!record.parentSessionId &&
|
|
1095
|
+
!record.runtimeHomeOwnerSessionId) {
|
|
1096
|
+
// A native thread that still has no rollout after the bounded retry
|
|
1097
|
+
// window has never materialized a Turn. It is safe to forget this
|
|
1098
|
+
// ordinary binding and let the TUI create a fresh native thread.
|
|
1099
|
+
// Fork bindings stay strict because their Provider-native ancestry
|
|
1100
|
+
// must not be silently replaced.
|
|
1101
|
+
await this.sessionStore.delete(localThreadId);
|
|
1102
|
+
live.threadId = null;
|
|
1103
|
+
live.markTerminalReady(true);
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
// A delayed/missing native index or a fork binding remains explicit:
|
|
1107
|
+
// neither is proof that replacing Provider history is safe.
|
|
1108
|
+
throw resumeError;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
// No thread exists yet, so the TUI must launch without `resume`. Once it
|
|
1113
|
+
// broadcasts `thread/started`, the normal ready + subscription path
|
|
1114
|
+
// takes over and web injection can begin.
|
|
1115
|
+
live.markTerminalReady(true);
|
|
972
1116
|
}
|
|
973
|
-
return true;
|
|
974
1117
|
}
|
|
975
1118
|
catch (err) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1119
|
+
this.liveSessions.delete(localThreadId);
|
|
1120
|
+
live.stopped = true;
|
|
1121
|
+
forwarder.stop();
|
|
1122
|
+
void forwarderClient.stop().catch(() => undefined);
|
|
1123
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1124
|
+
return abandon();
|
|
979
1125
|
}
|
|
1126
|
+
return true;
|
|
980
1127
|
}
|
|
981
1128
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
982
1129
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
983
|
-
onLiveThreadStarted(live, localThreadId,
|
|
1130
|
+
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1131
|
+
const previousThreadId = live.threadId;
|
|
1132
|
+
if (previousThreadId && previousThreadId !== threadId) {
|
|
1133
|
+
live.threadId = threadId;
|
|
1134
|
+
live.subscribing = true;
|
|
1135
|
+
live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
984
1138
|
if (live.subscribing)
|
|
985
1139
|
return;
|
|
986
1140
|
live.subscribing = true;
|
|
987
1141
|
live.threadId = threadId;
|
|
988
1142
|
void this.sessionStore
|
|
989
|
-
.
|
|
990
|
-
|
|
991
|
-
codexSessionId
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1143
|
+
.get(localThreadId)
|
|
1144
|
+
.then((existing) => {
|
|
1145
|
+
if (existing?.codexSessionId === threadId)
|
|
1146
|
+
return;
|
|
1147
|
+
return this.sessionStore.set({
|
|
1148
|
+
localThreadId,
|
|
1149
|
+
codexSessionId: threadId,
|
|
1150
|
+
...(existing?.parentSessionId
|
|
1151
|
+
? { parentSessionId: existing.parentSessionId }
|
|
1152
|
+
: {}),
|
|
1153
|
+
...(existing?.runtimeHomeOwnerSessionId
|
|
1154
|
+
? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
|
|
1155
|
+
: {}),
|
|
1156
|
+
updatedAt: new Date().toISOString(),
|
|
1157
|
+
});
|
|
1000
1158
|
})
|
|
1001
1159
|
.catch(() => undefined);
|
|
1002
1160
|
live.markReady(); // thread id known → injection can turn/start
|
|
1003
|
-
void this.subscribeUntilReady(live, threadId);
|
|
1161
|
+
void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
|
|
1162
|
+
}
|
|
1163
|
+
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1164
|
+
const pending = live.managedFork;
|
|
1165
|
+
if (pending && forkedFromId === pending.sourceThreadId) {
|
|
1166
|
+
pending.observedThreadIds.add(threadId);
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
const expectedSource = this.managedForkThreadStarts.get(threadId);
|
|
1170
|
+
return expectedSource !== undefined && expectedSource === forkedFromId;
|
|
1171
|
+
}
|
|
1172
|
+
rememberManagedForkThreadStart(threadId, sourceThreadId) {
|
|
1173
|
+
this.managedForkThreadStarts.set(threadId, sourceThreadId);
|
|
1174
|
+
const timer = setTimeout(() => {
|
|
1175
|
+
if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
|
|
1176
|
+
this.managedForkThreadStarts.delete(threadId);
|
|
1177
|
+
}
|
|
1178
|
+
}, 5_000);
|
|
1179
|
+
timer.unref?.();
|
|
1004
1180
|
}
|
|
1005
1181
|
/**
|
|
1006
1182
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
@@ -1016,17 +1192,18 @@ export class LocalAgentHost {
|
|
|
1016
1192
|
try {
|
|
1017
1193
|
const resp = await live.forwarderClient.threadResume({
|
|
1018
1194
|
threadId,
|
|
1019
|
-
|
|
1195
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1196
|
+
approvalPolicy: live.approvalPolicy,
|
|
1020
1197
|
excludeTurns: !sawNotReady,
|
|
1021
1198
|
});
|
|
1022
1199
|
if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
|
|
1023
1200
|
live.forwarder.replayBackfill(resp.thread.turns);
|
|
1024
1201
|
}
|
|
1025
|
-
return; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1202
|
+
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1026
1203
|
}
|
|
1027
1204
|
catch (error) {
|
|
1028
1205
|
if (!isThreadNotReadyError(error)) {
|
|
1029
|
-
return; // other failure — injection still works via the backend client
|
|
1206
|
+
return false; // other failure — injection still works via the backend client
|
|
1030
1207
|
}
|
|
1031
1208
|
sawNotReady = true;
|
|
1032
1209
|
// Park until the thread goes active (its first turn materializes the
|
|
@@ -1038,13 +1215,14 @@ export class LocalAgentHost {
|
|
|
1038
1215
|
live.releaseActive = undefined;
|
|
1039
1216
|
}
|
|
1040
1217
|
}
|
|
1218
|
+
return false;
|
|
1041
1219
|
}
|
|
1042
1220
|
/** Await a live session's thread binding (bounded). Returns false on timeout /
|
|
1043
1221
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1044
1222
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1045
1223
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1046
1224
|
if (claude)
|
|
1047
|
-
return raceReady(claude.ready, timeoutMs);
|
|
1225
|
+
return raceReady(claude.ready, timeoutMs, claude.failed);
|
|
1048
1226
|
const live = this.liveSessions.get(localThreadId);
|
|
1049
1227
|
if (!live)
|
|
1050
1228
|
return false;
|
|
@@ -1057,11 +1235,36 @@ export class LocalAgentHost {
|
|
|
1057
1235
|
clearTimeout(timer);
|
|
1058
1236
|
return ok;
|
|
1059
1237
|
}
|
|
1238
|
+
/** Await the stronger Terminal gate: another app-server connection has
|
|
1239
|
+
* successfully resumed the thread, so the detached TUI cannot race rollout
|
|
1240
|
+
* discovery or indexing. */
|
|
1241
|
+
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1242
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1243
|
+
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
1244
|
+
// SessionStart then reveals the native id + actual transcript path.
|
|
1245
|
+
if (claude)
|
|
1246
|
+
return true;
|
|
1247
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1248
|
+
if (!live)
|
|
1249
|
+
return false;
|
|
1250
|
+
let timer;
|
|
1251
|
+
const timeout = new Promise((resolve) => {
|
|
1252
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1253
|
+
});
|
|
1254
|
+
const ready = await Promise.race([live.terminalReady, timeout]);
|
|
1255
|
+
if (timer)
|
|
1256
|
+
clearTimeout(timer);
|
|
1257
|
+
return ready;
|
|
1258
|
+
}
|
|
1259
|
+
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1260
|
+
liveSessionError(localThreadId) {
|
|
1261
|
+
return this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1262
|
+
}
|
|
1060
1263
|
/**
|
|
1061
1264
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
1062
1265
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
1063
|
-
* supplement), else `turn/start`.
|
|
1064
|
-
*
|
|
1266
|
+
* supplement), else `turn/start`. The thread was created by the native TUI or
|
|
1267
|
+
* resumed from the persisted native id. Serialized per session so two
|
|
1065
1268
|
* injects can't double-open a turn.
|
|
1066
1269
|
*
|
|
1067
1270
|
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
@@ -1077,15 +1280,34 @@ export class LocalAgentHost {
|
|
|
1077
1280
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1078
1281
|
if (claude) {
|
|
1079
1282
|
const text = claudeInputText(runtimeInput);
|
|
1283
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1284
|
+
const pendingInput = {
|
|
1285
|
+
content,
|
|
1286
|
+
signature: JSON.stringify(content),
|
|
1287
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1288
|
+
observed: false,
|
|
1289
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1290
|
+
};
|
|
1291
|
+
claude.pendingInjectedInputs.push(pendingInput);
|
|
1292
|
+
const forgetPendingInput = () => {
|
|
1293
|
+
const index = claude.pendingInjectedInputs.indexOf(pendingInput);
|
|
1294
|
+
if (index >= 0)
|
|
1295
|
+
claude.pendingInjectedInputs.splice(index, 1);
|
|
1296
|
+
};
|
|
1080
1297
|
const token = claudeAttachmentToken(text);
|
|
1081
1298
|
if (token) {
|
|
1082
|
-
claude.pendingImageInputs.set(token,
|
|
1299
|
+
claude.pendingImageInputs.set(token, content);
|
|
1083
1300
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1084
1301
|
expiry.unref?.();
|
|
1085
1302
|
}
|
|
1086
1303
|
const outcome = await this.injectClaude(claude, localThreadId, text);
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1304
|
+
if (outcome !== "injected") {
|
|
1305
|
+
forgetPendingInput();
|
|
1306
|
+
if (token)
|
|
1307
|
+
claude.pendingImageInputs.delete(token);
|
|
1308
|
+
}
|
|
1309
|
+
else if (pendingInput.observed) {
|
|
1310
|
+
forgetPendingInput();
|
|
1089
1311
|
}
|
|
1090
1312
|
return outcome;
|
|
1091
1313
|
}
|
|
@@ -1093,6 +1315,8 @@ export class LocalAgentHost {
|
|
|
1093
1315
|
if (!live)
|
|
1094
1316
|
return "notLive";
|
|
1095
1317
|
const run = live.injectLock.then(async () => {
|
|
1318
|
+
if (live.rotationPending || live.stopped)
|
|
1319
|
+
return "failed";
|
|
1096
1320
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1097
1321
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1098
1322
|
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
@@ -1100,34 +1324,74 @@ export class LocalAgentHost {
|
|
|
1100
1324
|
if (!bound || !threadId)
|
|
1101
1325
|
return "notReady";
|
|
1102
1326
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1327
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1328
|
+
const pendingInput = {
|
|
1329
|
+
content,
|
|
1330
|
+
signature: JSON.stringify(content),
|
|
1331
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1332
|
+
observed: false,
|
|
1333
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1334
|
+
};
|
|
1335
|
+
live.pendingInjectedInputs.push(pendingInput);
|
|
1336
|
+
const forgetPendingInput = () => {
|
|
1337
|
+
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1338
|
+
if (index >= 0)
|
|
1339
|
+
live.pendingInjectedInputs.splice(index, 1);
|
|
1340
|
+
};
|
|
1103
1341
|
try {
|
|
1104
1342
|
// Inject via the BACKEND client (the forwarder connection only observes).
|
|
1105
1343
|
if (live.forwarder.isTurnOpen()) {
|
|
1106
1344
|
const turnId = live.forwarder.currentTurnId();
|
|
1107
1345
|
if (turnId) {
|
|
1108
|
-
await live.injectClient.turnSteer({
|
|
1346
|
+
const steered = await live.injectClient.turnSteer({
|
|
1109
1347
|
threadId,
|
|
1110
1348
|
expectedTurnId: turnId,
|
|
1111
1349
|
input: nativeInput,
|
|
1112
1350
|
});
|
|
1351
|
+
if (pendingInput.state === "prepublished") {
|
|
1352
|
+
// The caller already persisted and published this user input
|
|
1353
|
+
// before waiting for the native Terminal to become ready.
|
|
1354
|
+
}
|
|
1355
|
+
else if (pendingInput.observed) {
|
|
1356
|
+
forgetPendingInput();
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1360
|
+
pendingInput.state = "optimistic";
|
|
1361
|
+
}
|
|
1113
1362
|
return "injected";
|
|
1114
1363
|
}
|
|
1115
1364
|
}
|
|
1116
1365
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
1117
1366
|
// agent's model even if the TUI's config default differs.
|
|
1118
|
-
await live.injectClient.turnStart({
|
|
1367
|
+
const started = await live.injectClient.turnStart({
|
|
1119
1368
|
threadId,
|
|
1120
1369
|
input: nativeInput,
|
|
1121
|
-
|
|
1370
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1371
|
+
approvalPolicy: live.approvalPolicy,
|
|
1122
1372
|
...(live.model ? { model: live.model } : {}),
|
|
1123
1373
|
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1124
1374
|
});
|
|
1375
|
+
if (pendingInput.state === "prepublished") {
|
|
1376
|
+
// The caller already persisted and published this user input before
|
|
1377
|
+
// waiting for the native Terminal to become ready.
|
|
1378
|
+
}
|
|
1379
|
+
else if (pendingInput.observed) {
|
|
1380
|
+
forgetPendingInput();
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1384
|
+
pendingInput.state = "optimistic";
|
|
1385
|
+
}
|
|
1125
1386
|
return "injected";
|
|
1126
1387
|
}
|
|
1127
|
-
catch {
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
forgetPendingInput();
|
|
1390
|
+
// Preserve the app-server's error instead of collapsing every failure
|
|
1391
|
+
// into the unactionable `live injection failed` string.
|
|
1392
|
+
const detail = codexInjectionError(error);
|
|
1393
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1394
|
+
throw new Error(detail, { cause: error });
|
|
1131
1395
|
}
|
|
1132
1396
|
});
|
|
1133
1397
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1236,6 +1500,15 @@ export class LocalAgentHost {
|
|
|
1236
1500
|
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1237
1501
|
forwarder.finalizeStop();
|
|
1238
1502
|
this.pendingClaudeFinalizers.clear();
|
|
1503
|
+
// A runner owns its app-server processes. Stopping only the forwarder leaves
|
|
1504
|
+
// those children re-parented after the runner exits, along with remote TUIs
|
|
1505
|
+
// that can only reconnect to a dead session. `stop()` sends SIGTERM before
|
|
1506
|
+
// its returned promise yields, so this remains safe in the synchronous
|
|
1507
|
+
// runner shutdown path.
|
|
1508
|
+
for (const backend of this.backends.values()) {
|
|
1509
|
+
void backend.appServerClient?.stop().catch(() => undefined);
|
|
1510
|
+
}
|
|
1511
|
+
this.backends.clear();
|
|
1239
1512
|
}
|
|
1240
1513
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
1241
1514
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
@@ -1245,7 +1518,10 @@ export class LocalAgentHost {
|
|
|
1245
1518
|
* generic interaction bridge and remain inside the active Turn. */
|
|
1246
1519
|
claudeTerminalSpec(localThreadId, record) {
|
|
1247
1520
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1248
|
-
|
|
1521
|
+
if (!live) {
|
|
1522
|
+
throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
|
|
1523
|
+
}
|
|
1524
|
+
const cwd = live.workspace.cwd;
|
|
1249
1525
|
try {
|
|
1250
1526
|
ensureProjectTrusted(cwd);
|
|
1251
1527
|
}
|
|
@@ -1253,24 +1529,17 @@ export class LocalAgentHost {
|
|
|
1253
1529
|
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
1254
1530
|
}
|
|
1255
1531
|
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;
|
|
1532
|
+
const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
|
|
1533
|
+
const hookPermissionMode = live.permissionMode;
|
|
1264
1534
|
const hookSettings = buildClaudeHookSettings({
|
|
1265
1535
|
bridgeDir: claudeBridgeDir(localThreadId),
|
|
1266
1536
|
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
1267
1537
|
messageDisplay: true,
|
|
1268
1538
|
statusLine: true,
|
|
1269
1539
|
});
|
|
1270
|
-
const settings = { ...
|
|
1540
|
+
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1271
1541
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1272
|
-
// The live session
|
|
1273
|
-
// carries the agent-spec launch config: model, instructions, and skills.
|
|
1542
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1274
1543
|
const args = buildClaudeTuiArgs({
|
|
1275
1544
|
settingsJson: settingsPath,
|
|
1276
1545
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1278,10 +1547,24 @@ export class LocalAgentHost {
|
|
|
1278
1547
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1279
1548
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1280
1549
|
settingSources: "",
|
|
1281
|
-
model: live
|
|
1282
|
-
...(live
|
|
1283
|
-
|
|
1284
|
-
|
|
1550
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
1551
|
+
...(live.execution.reasoningEffort
|
|
1552
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
1553
|
+
: {}),
|
|
1554
|
+
...(live.execution.instructions
|
|
1555
|
+
? { appendSystemPrompt: live.execution.instructions }
|
|
1556
|
+
: {}),
|
|
1557
|
+
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
1558
|
+
...(live.forkIntent
|
|
1559
|
+
? {
|
|
1560
|
+
resume: live.forkIntent.sourceClaudeSessionId,
|
|
1561
|
+
forkSession: true,
|
|
1562
|
+
sessionId: live.forkIntent.targetClaudeSessionId,
|
|
1563
|
+
}
|
|
1564
|
+
: record?.codexSessionId
|
|
1565
|
+
? { resume: record.codexSessionId }
|
|
1566
|
+
: {}),
|
|
1567
|
+
...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
1285
1568
|
});
|
|
1286
1569
|
return {
|
|
1287
1570
|
command: resolveRuntimeBinary("claude"),
|
|
@@ -1292,44 +1575,15 @@ export class LocalAgentHost {
|
|
|
1292
1575
|
env: { ...process.env, RYNX_SESSION_ID: localThreadId },
|
|
1293
1576
|
};
|
|
1294
1577
|
}
|
|
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
|
-
});
|
|
1578
|
+
/** Materialize only the immutable skill declarations stored on the Session.
|
|
1579
|
+
* Provider startup never re-opens an Agent template or process-level plugin
|
|
1580
|
+
* snapshot. */
|
|
1581
|
+
async prepareExecutionSkills(execution) {
|
|
1328
1582
|
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
1329
1583
|
let skillEnv;
|
|
1330
1584
|
try {
|
|
1331
|
-
skillEnv = await buildAgentSkillEnvironment(
|
|
1332
|
-
const declaredNames =
|
|
1585
|
+
skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
|
|
1586
|
+
const declaredNames = execution.skills.map((skill) => skill.name);
|
|
1333
1587
|
const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
|
|
1334
1588
|
const { report } = skillEnv;
|
|
1335
1589
|
if (report.errors.length > 0 ||
|
|
@@ -1351,52 +1605,97 @@ export class LocalAgentHost {
|
|
|
1351
1605
|
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1352
1606
|
throw error;
|
|
1353
1607
|
}
|
|
1354
|
-
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
1355
1608
|
const { report } = skillEnv;
|
|
1356
1609
|
if (report.installed.length > 0) {
|
|
1357
|
-
console.error(`[skills]
|
|
1610
|
+
console.error(`[skills] installed=[${report.installed.join(", ")}]`);
|
|
1611
|
+
}
|
|
1612
|
+
try {
|
|
1613
|
+
const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
|
|
1614
|
+
const packageRoot = await realpath(source.packageRoot);
|
|
1615
|
+
const skillRoot = await realpath(source.root);
|
|
1616
|
+
const relativeSkillRoot = path.relative(packageRoot, skillRoot);
|
|
1617
|
+
if (relativeSkillRoot === ".." ||
|
|
1618
|
+
relativeSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1619
|
+
path.isAbsolute(relativeSkillRoot)) {
|
|
1620
|
+
throw new Error(`plugin skill root escaped package: ${source.installationId}`);
|
|
1621
|
+
}
|
|
1622
|
+
const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
|
|
1623
|
+
await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
|
|
1624
|
+
await cp(packageRoot, snapshotPackageRoot, {
|
|
1625
|
+
recursive: true,
|
|
1626
|
+
errorOnExist: true,
|
|
1627
|
+
force: false,
|
|
1628
|
+
dereference: false,
|
|
1629
|
+
});
|
|
1630
|
+
const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
|
|
1631
|
+
const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
|
|
1632
|
+
if (integrity !== source.integrity) {
|
|
1633
|
+
throw new Error(`plugin package integrity changed: ${source.installationId}`);
|
|
1634
|
+
}
|
|
1635
|
+
const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
|
|
1636
|
+
const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
|
|
1637
|
+
if (relativeSnapshotSkillRoot === ".." ||
|
|
1638
|
+
relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1639
|
+
path.isAbsolute(relativeSnapshotSkillRoot)) {
|
|
1640
|
+
throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
|
|
1641
|
+
}
|
|
1642
|
+
const discovered = await scanSkillsDir(snapshotSkillRoot);
|
|
1643
|
+
if (discovered.length === 0) {
|
|
1644
|
+
throw new Error(`plugin skill root is empty: ${source.installationId}`);
|
|
1645
|
+
}
|
|
1646
|
+
return discovered.map((skill) => ({
|
|
1647
|
+
...skill,
|
|
1648
|
+
name: providerPluginSkillName(source.namespace, skill.name),
|
|
1649
|
+
}));
|
|
1650
|
+
}));
|
|
1651
|
+
const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
|
|
1652
|
+
if (failedPluginSkill?.status === "rejected") {
|
|
1653
|
+
throw failedPluginSkill.reason;
|
|
1654
|
+
}
|
|
1655
|
+
const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1656
|
+
const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
|
|
1657
|
+
const selectedNames = new Set();
|
|
1658
|
+
for (const skill of selectedSkills) {
|
|
1659
|
+
if (selectedNames.has(skill.name)) {
|
|
1660
|
+
throw new Error(`duplicate final Session skill name: ${skill.name}`);
|
|
1661
|
+
}
|
|
1662
|
+
selectedNames.add(skill.name);
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
selectedSkills,
|
|
1666
|
+
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
catch (error) {
|
|
1670
|
+
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1671
|
+
throw error;
|
|
1358
1672
|
}
|
|
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
1673
|
}
|
|
1372
1674
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
1373
|
-
const retargetMirror = opts
|
|
1675
|
+
const retargetMirror = opts.retargetMirror;
|
|
1676
|
+
const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
|
|
1677
|
+
if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
|
|
1678
|
+
throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
|
|
1679
|
+
}
|
|
1374
1680
|
const bridgeDir = prepareClaudeBridgeDir(localThreadId);
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1681
|
+
const workspace = structuredClone(opts.workspace);
|
|
1682
|
+
const execution = structuredClone(opts.execution);
|
|
1683
|
+
if (execution.provider !== "claude") {
|
|
1684
|
+
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1685
|
+
}
|
|
1686
|
+
const cwd = workspace.cwd;
|
|
1687
|
+
let snapshotSkills;
|
|
1381
1688
|
try {
|
|
1382
|
-
|
|
1689
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
1383
1690
|
}
|
|
1384
1691
|
catch (err) {
|
|
1385
|
-
console.error(`[
|
|
1692
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1386
1693
|
return false;
|
|
1387
1694
|
}
|
|
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;
|
|
1695
|
+
const model = execution.model ?? "";
|
|
1696
|
+
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1697
|
+
void snapshotSkills.skillsCleanup();
|
|
1698
|
+
const permissionMode = execution.permissionMode;
|
|
1400
1699
|
const launchExtraArgs = [
|
|
1401
1700
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
1402
1701
|
...(permissionMode ? ["--permission-mode", permissionMode] : []),
|
|
@@ -1406,16 +1705,25 @@ export class LocalAgentHost {
|
|
|
1406
1705
|
let currentSessionId = localThreadId;
|
|
1407
1706
|
let normalizer = null;
|
|
1408
1707
|
let currentResponseId;
|
|
1708
|
+
let pendingRotationEvents = null;
|
|
1709
|
+
const emitCurrent = (event) => {
|
|
1710
|
+
if (pendingRotationEvents) {
|
|
1711
|
+
pendingRotationEvents.push(event);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
emit(event);
|
|
1715
|
+
};
|
|
1409
1716
|
const startNormalizer = (turnId) => {
|
|
1410
1717
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1411
|
-
const responseId =
|
|
1718
|
+
const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
|
|
1719
|
+
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
1412
1720
|
if (normalizer && currentResponseId === responseId)
|
|
1413
1721
|
return normalizer;
|
|
1414
1722
|
currentResponseId = responseId;
|
|
1415
1723
|
normalizer = new SessionNormalizer({
|
|
1416
1724
|
sessionId: currentSessionId,
|
|
1417
1725
|
responseId,
|
|
1418
|
-
model,
|
|
1726
|
+
model: model || "claude",
|
|
1419
1727
|
});
|
|
1420
1728
|
return normalizer;
|
|
1421
1729
|
};
|
|
@@ -1435,26 +1743,33 @@ export class LocalAgentHost {
|
|
|
1435
1743
|
...(event.reason ? { reason: event.reason } : {}),
|
|
1436
1744
|
};
|
|
1437
1745
|
for (const se of n.next(agentEvent))
|
|
1438
|
-
|
|
1746
|
+
emitCurrent(se);
|
|
1439
1747
|
};
|
|
1440
1748
|
let markReady;
|
|
1441
1749
|
const ready = new Promise((resolve) => {
|
|
1442
1750
|
markReady = resolve;
|
|
1443
1751
|
});
|
|
1752
|
+
let markFailed;
|
|
1753
|
+
const failed = new Promise((resolve) => {
|
|
1754
|
+
markFailed = resolve;
|
|
1755
|
+
});
|
|
1444
1756
|
const live = {
|
|
1445
1757
|
forwarder: null,
|
|
1446
|
-
|
|
1758
|
+
workspace,
|
|
1759
|
+
execution,
|
|
1447
1760
|
bridgeDir,
|
|
1448
1761
|
injectLock: Promise.resolve(),
|
|
1449
1762
|
pendingImageInputs: new Map(),
|
|
1763
|
+
pendingInjectedInputs: [],
|
|
1450
1764
|
ready,
|
|
1451
1765
|
markReady,
|
|
1766
|
+
failed,
|
|
1767
|
+
markFailed,
|
|
1452
1768
|
discoveredReady: false,
|
|
1453
1769
|
stopped: false,
|
|
1454
|
-
launchModel: model,
|
|
1455
|
-
...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
|
|
1456
1770
|
launchExtraArgs,
|
|
1457
|
-
|
|
1771
|
+
permissionMode,
|
|
1772
|
+
...(forkIntent ? { forkIntent } : {}),
|
|
1458
1773
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
1459
1774
|
};
|
|
1460
1775
|
const sink = {
|
|
@@ -1465,19 +1780,28 @@ export class LocalAgentHost {
|
|
|
1465
1780
|
const content = token ? live.pendingImageInputs.get(token) : undefined;
|
|
1466
1781
|
if (token && content)
|
|
1467
1782
|
live.pendingImageInputs.delete(token);
|
|
1468
|
-
|
|
1469
|
-
|
|
1783
|
+
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1784
|
+
const signature = JSON.stringify(normalizedContent);
|
|
1785
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1786
|
+
if (pending?.state === "prepublished" || pending?.state === "optimistic") {
|
|
1787
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (pending)
|
|
1791
|
+
pending.observed = true;
|
|
1792
|
+
for (const se of n.userInput(normalizedContent))
|
|
1793
|
+
emitCurrent(se);
|
|
1470
1794
|
},
|
|
1471
1795
|
onTerminalCommand: (cmd) => {
|
|
1472
1796
|
const n = normalizer ?? startNormalizer();
|
|
1473
1797
|
for (const se of n.terminalCommand(cmd))
|
|
1474
|
-
|
|
1798
|
+
emitCurrent(se);
|
|
1475
1799
|
},
|
|
1476
|
-
onTodos: (todos) =>
|
|
1800
|
+
onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
|
|
1477
1801
|
onEvent: (event) => {
|
|
1478
1802
|
const n = normalizer ?? startNormalizer();
|
|
1479
1803
|
for (const se of n.next(event))
|
|
1480
|
-
|
|
1804
|
+
emitCurrent(se);
|
|
1481
1805
|
},
|
|
1482
1806
|
onInteraction: forwardInteraction,
|
|
1483
1807
|
onTurnEnd: (usage) => {
|
|
@@ -1485,11 +1809,13 @@ export class LocalAgentHost {
|
|
|
1485
1809
|
return;
|
|
1486
1810
|
const rid = currentResponseId;
|
|
1487
1811
|
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
1488
|
-
if (usage)
|
|
1812
|
+
if (usage) {
|
|
1489
1813
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1490
|
-
|
|
1814
|
+
emitCurrent(se);
|
|
1815
|
+
}
|
|
1491
1816
|
for (const se of normalizer.next({ type: "done" }))
|
|
1492
|
-
|
|
1817
|
+
emitCurrent(se);
|
|
1818
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1493
1819
|
normalizer = null;
|
|
1494
1820
|
currentResponseId = undefined;
|
|
1495
1821
|
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
@@ -1497,7 +1823,7 @@ export class LocalAgentHost {
|
|
|
1497
1823
|
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1498
1824
|
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1499
1825
|
live.contextWarned = true;
|
|
1500
|
-
|
|
1826
|
+
emitCurrent({
|
|
1501
1827
|
type: "session.status",
|
|
1502
1828
|
sessionId: currentSessionId,
|
|
1503
1829
|
responseId: rid,
|
|
@@ -1510,7 +1836,7 @@ export class LocalAgentHost {
|
|
|
1510
1836
|
// Surface idle on the current turn WITHOUT finalizing it (see the sink's
|
|
1511
1837
|
// onIdle doc): a late assistant record still joins this response.
|
|
1512
1838
|
if (currentResponseId) {
|
|
1513
|
-
|
|
1839
|
+
emitCurrent({
|
|
1514
1840
|
type: "session.status",
|
|
1515
1841
|
sessionId: currentSessionId,
|
|
1516
1842
|
responseId: currentResponseId,
|
|
@@ -1521,87 +1847,115 @@ export class LocalAgentHost {
|
|
|
1521
1847
|
onTurnError: () => {
|
|
1522
1848
|
if (!normalizer)
|
|
1523
1849
|
return;
|
|
1850
|
+
const rid = currentResponseId;
|
|
1524
1851
|
for (const se of normalizer.fail({
|
|
1525
1852
|
code: "agent_error",
|
|
1526
1853
|
message: "Agent turn failed",
|
|
1527
1854
|
source: "execution",
|
|
1528
1855
|
}))
|
|
1529
|
-
|
|
1856
|
+
emitCurrent(se);
|
|
1857
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1530
1858
|
normalizer = null;
|
|
1531
1859
|
currentResponseId = undefined;
|
|
1532
1860
|
},
|
|
1533
|
-
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId,
|
|
1861
|
+
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
|
|
1862
|
+
onSessionResumeError: (error) => {
|
|
1863
|
+
live.error = error.message;
|
|
1864
|
+
live.markFailed();
|
|
1865
|
+
},
|
|
1534
1866
|
onSessionRotated: (kind, claudeSessionId) => {
|
|
1867
|
+
if (pendingRotationEvents) {
|
|
1868
|
+
live.error = "Claude reported another Session rotation before publication completed";
|
|
1869
|
+
live.markFailed();
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1535
1872
|
// The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
|
|
1536
|
-
// new transcript.
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1873
|
+
// new transcript. Persist the Provider binding first, then announce on
|
|
1874
|
+
// the OLD session and re-target the mirror. Events produced during the
|
|
1875
|
+
// file-store commit are buffered so none can land under the old identity.
|
|
1539
1876
|
const previousSessionId = currentSessionId;
|
|
1540
1877
|
const newSessionId = makeSessionId();
|
|
1541
|
-
|
|
1878
|
+
currentSessionId = newSessionId;
|
|
1879
|
+
normalizer = null;
|
|
1880
|
+
currentResponseId = undefined;
|
|
1881
|
+
live.contextWarned = false;
|
|
1882
|
+
pendingRotationEvents = [];
|
|
1883
|
+
this.liveClaudeSessions.set(newSessionId, live);
|
|
1542
1884
|
void this.sessionStore
|
|
1543
1885
|
.set({
|
|
1544
1886
|
localThreadId: newSessionId,
|
|
1545
1887
|
codexSessionId: claudeSessionId,
|
|
1546
|
-
cwd: live.cwd,
|
|
1547
|
-
model,
|
|
1548
|
-
runtime: "claude",
|
|
1549
|
-
...(record?.agent ? { agent: record.agent } : {}),
|
|
1550
1888
|
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1551
1889
|
updatedAt: new Date().toISOString(),
|
|
1552
1890
|
})
|
|
1553
|
-
.
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1891
|
+
.then(() => {
|
|
1892
|
+
emit({
|
|
1893
|
+
type: "session.rotated",
|
|
1894
|
+
sessionId: previousSessionId,
|
|
1895
|
+
newSessionId,
|
|
1896
|
+
kind,
|
|
1897
|
+
});
|
|
1898
|
+
retargetMirror?.(newSessionId, {
|
|
1899
|
+
kind,
|
|
1900
|
+
workspace: structuredClone(live.workspace),
|
|
1901
|
+
execution: structuredClone(live.execution),
|
|
1902
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1903
|
+
});
|
|
1904
|
+
const queued = pendingRotationEvents ?? [];
|
|
1905
|
+
pendingRotationEvents = null;
|
|
1906
|
+
for (const event of queued)
|
|
1907
|
+
emit(event);
|
|
1908
|
+
})
|
|
1909
|
+
.catch((error) => {
|
|
1910
|
+
pendingRotationEvents = null;
|
|
1911
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1912
|
+
live.markFailed();
|
|
1565
1913
|
});
|
|
1566
1914
|
},
|
|
1567
1915
|
};
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
: undefined;
|
|
1916
|
+
// Claude itself resolves `--resume <id>`. The SessionStart hook is the
|
|
1917
|
+
// authoritative source for the actual transcript path; do not infer or
|
|
1918
|
+
// generate Claude's private on-disk format here.
|
|
1572
1919
|
live.forwarder = new ClaudeLiveSession({
|
|
1573
1920
|
bridgeDir,
|
|
1574
1921
|
sink,
|
|
1575
|
-
...(
|
|
1576
|
-
|
|
1922
|
+
...(forkIntent
|
|
1923
|
+
? { claudeSessionId: forkIntent.targetClaudeSessionId }
|
|
1924
|
+
: record?.codexSessionId
|
|
1925
|
+
? { claudeSessionId: record.codexSessionId }
|
|
1926
|
+
: {}),
|
|
1927
|
+
...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
|
|
1577
1928
|
});
|
|
1578
1929
|
this.liveClaudeSessions.set(localThreadId, live);
|
|
1579
1930
|
live.forwarder.start();
|
|
1580
|
-
if (record?.codexSessionId) {
|
|
1581
|
-
live.discoveredReady = true;
|
|
1582
|
-
markReady(); // resumed → injection can proceed without awaiting discovery
|
|
1583
|
-
}
|
|
1584
1931
|
return true;
|
|
1585
1932
|
}
|
|
1586
1933
|
/** Persist claude's discovered session id (reusing the `codexSessionId` store
|
|
1587
1934
|
* field, as the claude executor already does) and release the readiness gate. */
|
|
1588
|
-
onClaudeDiscovered(live, localThreadId,
|
|
1935
|
+
onClaudeDiscovered(live, localThreadId, claudeSessionId) {
|
|
1589
1936
|
if (live.discoveredReady)
|
|
1590
1937
|
return;
|
|
1938
|
+
const forkIntent = live.forkIntent;
|
|
1939
|
+
if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
|
|
1940
|
+
live.error =
|
|
1941
|
+
`Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
|
|
1942
|
+
live.markFailed();
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1591
1945
|
live.discoveredReady = true;
|
|
1592
1946
|
void this.sessionStore
|
|
1593
1947
|
.set({
|
|
1594
1948
|
localThreadId,
|
|
1595
1949
|
codexSessionId: claudeSessionId,
|
|
1596
|
-
|
|
1597
|
-
// Persist the agent-spec-resolved launch model (was the config default).
|
|
1598
|
-
model: live.launchModel,
|
|
1599
|
-
runtime: "claude",
|
|
1600
|
-
agent,
|
|
1950
|
+
...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
|
|
1601
1951
|
updatedAt: new Date().toISOString(),
|
|
1602
1952
|
})
|
|
1603
|
-
.
|
|
1604
|
-
|
|
1953
|
+
.then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
|
|
1954
|
+
.then(() => live.markReady())
|
|
1955
|
+
.catch((error) => {
|
|
1956
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1957
|
+
live.markFailed();
|
|
1958
|
+
});
|
|
1605
1959
|
}
|
|
1606
1960
|
/** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
|
|
1607
1961
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
@@ -1683,12 +2037,12 @@ export class LocalAgentHost {
|
|
|
1683
2037
|
if (!record?.codexSessionId) {
|
|
1684
2038
|
return { ok: false, reason: "no_active_thread" };
|
|
1685
2039
|
}
|
|
1686
|
-
const
|
|
1687
|
-
if (!
|
|
2040
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2041
|
+
if (!live) {
|
|
1688
2042
|
return { ok: false, reason: "unsupported" };
|
|
1689
2043
|
}
|
|
1690
2044
|
try {
|
|
1691
|
-
return { ok: true, data: await fn(
|
|
2045
|
+
return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
|
|
1692
2046
|
}
|
|
1693
2047
|
catch (error) {
|
|
1694
2048
|
return {
|
|
@@ -1714,30 +2068,108 @@ export class LocalAgentHost {
|
|
|
1714
2068
|
await client.threadGoalClear({ threadId });
|
|
1715
2069
|
});
|
|
1716
2070
|
}
|
|
1717
|
-
async forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
2071
|
+
async forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
2072
|
+
const inflight = this.forkingTargets.get(newLocalThreadId);
|
|
2073
|
+
if (inflight)
|
|
2074
|
+
return inflight;
|
|
2075
|
+
const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
|
|
2076
|
+
this.forkingTargets.set(newLocalThreadId, operation);
|
|
2077
|
+
try {
|
|
2078
|
+
return await operation;
|
|
2079
|
+
}
|
|
2080
|
+
finally {
|
|
2081
|
+
if (this.forkingTargets.get(newLocalThreadId) === operation) {
|
|
2082
|
+
this.forkingTargets.delete(newLocalThreadId);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
1718
2087
|
const record = await this.sessionStore.get(currentLocalThreadId);
|
|
1719
2088
|
if (!record) {
|
|
1720
2089
|
return { ok: false, reason: "no_active_thread" };
|
|
1721
2090
|
}
|
|
1722
|
-
const
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
2091
|
+
const live = this.liveSessions.get(currentLocalThreadId);
|
|
2092
|
+
const workspace = live?.workspace ?? options?.workspace;
|
|
2093
|
+
const execution = live?.execution ?? options?.execution;
|
|
2094
|
+
if (!workspace || !execution) {
|
|
2095
|
+
return { ok: false, reason: "no_active_thread" };
|
|
2096
|
+
}
|
|
2097
|
+
if (execution.provider === "claude") {
|
|
2098
|
+
return {
|
|
2099
|
+
ok: false,
|
|
2100
|
+
reason: "unsupported",
|
|
2101
|
+
message: "Claude fork must be materialized by the target runner",
|
|
2102
|
+
};
|
|
1726
2103
|
}
|
|
1727
2104
|
try {
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2105
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
2106
|
+
if (existingTarget) {
|
|
2107
|
+
if (existingTarget.parentSessionId !== currentLocalThreadId) {
|
|
2108
|
+
return {
|
|
2109
|
+
ok: false,
|
|
2110
|
+
reason: "error",
|
|
2111
|
+
message: "target Session already belongs to a different fork",
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
return { ok: true, data: undefined };
|
|
2115
|
+
}
|
|
2116
|
+
const runtime = execution.provider;
|
|
2117
|
+
const client = live?.injectClient ??
|
|
2118
|
+
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2119
|
+
if (!client) {
|
|
2120
|
+
return { ok: false, reason: "unsupported" };
|
|
2121
|
+
}
|
|
2122
|
+
await client.ensureInitialized();
|
|
2123
|
+
if (live?.managedFork) {
|
|
2124
|
+
return {
|
|
2125
|
+
ok: false,
|
|
2126
|
+
reason: "error",
|
|
2127
|
+
message: "source Session already has a managed fork in progress",
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
const managedFork = live
|
|
2131
|
+
? {
|
|
2132
|
+
sourceThreadId: record.codexSessionId,
|
|
2133
|
+
observedThreadIds: new Set(),
|
|
2134
|
+
}
|
|
2135
|
+
: undefined;
|
|
2136
|
+
const forked = await (async () => {
|
|
2137
|
+
if (live && managedFork)
|
|
2138
|
+
live.managedFork = managedFork;
|
|
2139
|
+
try {
|
|
2140
|
+
return await client.threadFork({
|
|
2141
|
+
threadId: record.codexSessionId,
|
|
2142
|
+
excludeTurns: true,
|
|
2143
|
+
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2144
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2145
|
+
approvalPolicy: execution.approvalPolicy,
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
finally {
|
|
2149
|
+
if (live && live.managedFork === managedFork)
|
|
2150
|
+
delete live.managedFork;
|
|
2151
|
+
for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
|
|
2152
|
+
this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
})();
|
|
1733
2156
|
const forkedThreadId = forked.thread.id;
|
|
2157
|
+
if (live && forkedThreadId) {
|
|
2158
|
+
this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
|
|
2159
|
+
}
|
|
2160
|
+
if (!forkedThreadId ||
|
|
2161
|
+
forkedThreadId === record.codexSessionId ||
|
|
2162
|
+
(forked.thread.forkedFromId !== undefined &&
|
|
2163
|
+
forked.thread.forkedFromId !== null &&
|
|
2164
|
+
forked.thread.forkedFromId !== record.codexSessionId)) {
|
|
2165
|
+
throw new Error("Provider returned an invalid forked thread");
|
|
2166
|
+
}
|
|
2167
|
+
const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
|
|
1734
2168
|
await this.sessionStore.set({
|
|
1735
2169
|
localThreadId: newLocalThreadId,
|
|
1736
2170
|
codexSessionId: forkedThreadId,
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
reasoningEffort: record.reasoningEffort,
|
|
1740
|
-
runtime,
|
|
2171
|
+
parentSessionId: currentLocalThreadId,
|
|
2172
|
+
runtimeHomeOwnerSessionId: sourceHomeOwner,
|
|
1741
2173
|
updatedAt: new Date().toISOString(),
|
|
1742
2174
|
});
|
|
1743
2175
|
return { ok: true, data: undefined };
|
|
@@ -1751,6 +2183,12 @@ export class LocalAgentHost {
|
|
|
1751
2183
|
}
|
|
1752
2184
|
}
|
|
1753
2185
|
}
|
|
2186
|
+
function codexInjectionError(error) {
|
|
2187
|
+
if (error instanceof CodexTransportError) {
|
|
2188
|
+
return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
|
|
2189
|
+
}
|
|
2190
|
+
return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2191
|
+
}
|
|
1754
2192
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
1755
2193
|
const normalized = output.trim();
|
|
1756
2194
|
const match = normalized.match(/Logged in using (.+)$/im);
|
|
@@ -1774,25 +2212,48 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
1774
2212
|
issues: normalized ? [normalized] : [],
|
|
1775
2213
|
};
|
|
1776
2214
|
}
|
|
1777
|
-
/** Race a live session's `ready` promise against
|
|
1778
|
-
function raceReady(ready, timeoutMs) {
|
|
2215
|
+
/** Race a live session's `ready` promise against failure / timeout. */
|
|
2216
|
+
function raceReady(ready, timeoutMs, failed) {
|
|
1779
2217
|
let timer;
|
|
1780
2218
|
const timeout = new Promise((resolve) => {
|
|
1781
2219
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1782
2220
|
});
|
|
1783
|
-
|
|
2221
|
+
const outcomes = [ready.then(() => true), timeout];
|
|
2222
|
+
if (failed)
|
|
2223
|
+
outcomes.push(failed.then(() => false));
|
|
2224
|
+
return Promise.race(outcomes).finally(() => {
|
|
1784
2225
|
if (timer)
|
|
1785
2226
|
clearTimeout(timer);
|
|
1786
2227
|
});
|
|
1787
2228
|
}
|
|
1788
|
-
function
|
|
2229
|
+
function sameSessionSnapshots(workspace, execution, opts) {
|
|
1789
2230
|
return JSON.stringify(stableValue({
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2231
|
+
workspace,
|
|
2232
|
+
execution,
|
|
2233
|
+
})) === JSON.stringify(stableValue({
|
|
2234
|
+
workspace: opts.workspace,
|
|
2235
|
+
execution: opts.execution,
|
|
1794
2236
|
}));
|
|
1795
2237
|
}
|
|
2238
|
+
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2239
|
+
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2240
|
+
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
2241
|
+
function applyClaudePermissionModeSnapshot(settings, permissionMode) {
|
|
2242
|
+
const next = { ...settings };
|
|
2243
|
+
const inherited = settings.permissions;
|
|
2244
|
+
const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
|
|
2245
|
+
? { ...inherited }
|
|
2246
|
+
: {};
|
|
2247
|
+
if (permissionMode)
|
|
2248
|
+
permissions.defaultMode = permissionMode;
|
|
2249
|
+
else
|
|
2250
|
+
delete permissions.defaultMode;
|
|
2251
|
+
if (Object.keys(permissions).length > 0)
|
|
2252
|
+
next.permissions = permissions;
|
|
2253
|
+
else
|
|
2254
|
+
delete next.permissions;
|
|
2255
|
+
return next;
|
|
2256
|
+
}
|
|
1796
2257
|
function stableValue(value) {
|
|
1797
2258
|
if (Array.isArray(value))
|
|
1798
2259
|
return value.map(stableValue);
|
|
@@ -1826,3 +2287,10 @@ export function isThreadNotReadyError(error) {
|
|
|
1826
2287
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1827
2288
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
1828
2289
|
}
|
|
2290
|
+
/** A persisted thread id that a freshly started app-server cannot load yet.
|
|
2291
|
+
* During startup, both errors can be transient while the rollout index catches
|
|
2292
|
+
* up. Retry the same id; never use either error as permission to replace it. */
|
|
2293
|
+
export function isRetryableThreadResumeError(error) {
|
|
2294
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2295
|
+
return message.includes("thread not found") || isThreadNotReadyError(error);
|
|
2296
|
+
}
|