@rynx-ai/runtime 0.1.10 → 0.1.11-beta.10
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 +810 -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,169 @@ 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
|
-
updatedAt: new Date().toISOString(),
|
|
970
|
-
})
|
|
971
|
-
.catch(() => undefined);
|
|
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 {
|
|
1093
|
+
// Never replace an existing binding. A delayed native index is
|
|
1094
|
+
// retryable; a genuinely missing thread remains an explicit error.
|
|
1095
|
+
throw resumeError;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
// No thread exists yet, so the TUI must launch without `resume`. Once it
|
|
1100
|
+
// broadcasts `thread/started`, the normal ready + subscription path
|
|
1101
|
+
// takes over and web injection can begin.
|
|
1102
|
+
live.markTerminalReady(true);
|
|
972
1103
|
}
|
|
973
|
-
return true;
|
|
974
1104
|
}
|
|
975
1105
|
catch (err) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1106
|
+
this.liveSessions.delete(localThreadId);
|
|
1107
|
+
live.stopped = true;
|
|
1108
|
+
forwarder.stop();
|
|
1109
|
+
void forwarderClient.stop().catch(() => undefined);
|
|
1110
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1111
|
+
return abandon();
|
|
979
1112
|
}
|
|
1113
|
+
return true;
|
|
980
1114
|
}
|
|
981
1115
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
982
1116
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
983
|
-
onLiveThreadStarted(live, localThreadId,
|
|
1117
|
+
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1118
|
+
const previousThreadId = live.threadId;
|
|
1119
|
+
if (previousThreadId && previousThreadId !== threadId) {
|
|
1120
|
+
live.threadId = threadId;
|
|
1121
|
+
live.subscribing = true;
|
|
1122
|
+
live.onNativeThreadRotated?.(threadId, forkedFromId === previousThreadId ? "fork" : "clear");
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
984
1125
|
if (live.subscribing)
|
|
985
1126
|
return;
|
|
986
1127
|
live.subscribing = true;
|
|
987
1128
|
live.threadId = threadId;
|
|
988
1129
|
void this.sessionStore
|
|
989
|
-
.
|
|
990
|
-
|
|
991
|
-
codexSessionId
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1130
|
+
.get(localThreadId)
|
|
1131
|
+
.then((existing) => {
|
|
1132
|
+
if (existing?.codexSessionId === threadId)
|
|
1133
|
+
return;
|
|
1134
|
+
return this.sessionStore.set({
|
|
1135
|
+
localThreadId,
|
|
1136
|
+
codexSessionId: threadId,
|
|
1137
|
+
...(existing?.parentSessionId
|
|
1138
|
+
? { parentSessionId: existing.parentSessionId }
|
|
1139
|
+
: {}),
|
|
1140
|
+
...(existing?.runtimeHomeOwnerSessionId
|
|
1141
|
+
? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
|
|
1142
|
+
: {}),
|
|
1143
|
+
updatedAt: new Date().toISOString(),
|
|
1144
|
+
});
|
|
1000
1145
|
})
|
|
1001
1146
|
.catch(() => undefined);
|
|
1002
1147
|
live.markReady(); // thread id known → injection can turn/start
|
|
1003
|
-
void this.subscribeUntilReady(live, threadId);
|
|
1148
|
+
void this.subscribeUntilReady(live, threadId).then(live.markTerminalReady);
|
|
1149
|
+
}
|
|
1150
|
+
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1151
|
+
const pending = live.managedFork;
|
|
1152
|
+
if (pending && forkedFromId === pending.sourceThreadId) {
|
|
1153
|
+
pending.observedThreadIds.add(threadId);
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
const expectedSource = this.managedForkThreadStarts.get(threadId);
|
|
1157
|
+
return expectedSource !== undefined && expectedSource === forkedFromId;
|
|
1158
|
+
}
|
|
1159
|
+
rememberManagedForkThreadStart(threadId, sourceThreadId) {
|
|
1160
|
+
this.managedForkThreadStarts.set(threadId, sourceThreadId);
|
|
1161
|
+
const timer = setTimeout(() => {
|
|
1162
|
+
if (this.managedForkThreadStarts.get(threadId) === sourceThreadId) {
|
|
1163
|
+
this.managedForkThreadStarts.delete(threadId);
|
|
1164
|
+
}
|
|
1165
|
+
}, 5_000);
|
|
1166
|
+
timer.unref?.();
|
|
1004
1167
|
}
|
|
1005
1168
|
/**
|
|
1006
1169
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
@@ -1016,17 +1179,18 @@ export class LocalAgentHost {
|
|
|
1016
1179
|
try {
|
|
1017
1180
|
const resp = await live.forwarderClient.threadResume({
|
|
1018
1181
|
threadId,
|
|
1019
|
-
|
|
1182
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1183
|
+
approvalPolicy: live.approvalPolicy,
|
|
1020
1184
|
excludeTurns: !sawNotReady,
|
|
1021
1185
|
});
|
|
1022
1186
|
if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
|
|
1023
1187
|
live.forwarder.replayBackfill(resp.thread.turns);
|
|
1024
1188
|
}
|
|
1025
|
-
return; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1189
|
+
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1026
1190
|
}
|
|
1027
1191
|
catch (error) {
|
|
1028
1192
|
if (!isThreadNotReadyError(error)) {
|
|
1029
|
-
return; // other failure — injection still works via the backend client
|
|
1193
|
+
return false; // other failure — injection still works via the backend client
|
|
1030
1194
|
}
|
|
1031
1195
|
sawNotReady = true;
|
|
1032
1196
|
// Park until the thread goes active (its first turn materializes the
|
|
@@ -1038,13 +1202,14 @@ export class LocalAgentHost {
|
|
|
1038
1202
|
live.releaseActive = undefined;
|
|
1039
1203
|
}
|
|
1040
1204
|
}
|
|
1205
|
+
return false;
|
|
1041
1206
|
}
|
|
1042
1207
|
/** Await a live session's thread binding (bounded). Returns false on timeout /
|
|
1043
1208
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1044
1209
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1045
1210
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1046
1211
|
if (claude)
|
|
1047
|
-
return raceReady(claude.ready, timeoutMs);
|
|
1212
|
+
return raceReady(claude.ready, timeoutMs, claude.failed);
|
|
1048
1213
|
const live = this.liveSessions.get(localThreadId);
|
|
1049
1214
|
if (!live)
|
|
1050
1215
|
return false;
|
|
@@ -1057,11 +1222,36 @@ export class LocalAgentHost {
|
|
|
1057
1222
|
clearTimeout(timer);
|
|
1058
1223
|
return ok;
|
|
1059
1224
|
}
|
|
1225
|
+
/** Await the stronger Terminal gate: another app-server connection has
|
|
1226
|
+
* successfully resumed the thread, so the detached TUI cannot race rollout
|
|
1227
|
+
* discovery or indexing. */
|
|
1228
|
+
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1229
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1230
|
+
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
1231
|
+
// SessionStart then reveals the native id + actual transcript path.
|
|
1232
|
+
if (claude)
|
|
1233
|
+
return true;
|
|
1234
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1235
|
+
if (!live)
|
|
1236
|
+
return false;
|
|
1237
|
+
let timer;
|
|
1238
|
+
const timeout = new Promise((resolve) => {
|
|
1239
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1240
|
+
});
|
|
1241
|
+
const ready = await Promise.race([live.terminalReady, timeout]);
|
|
1242
|
+
if (timer)
|
|
1243
|
+
clearTimeout(timer);
|
|
1244
|
+
return ready;
|
|
1245
|
+
}
|
|
1246
|
+
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1247
|
+
liveSessionError(localThreadId) {
|
|
1248
|
+
return this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1249
|
+
}
|
|
1060
1250
|
/**
|
|
1061
1251
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
1062
1252
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
1063
|
-
* supplement), else `turn/start`.
|
|
1064
|
-
*
|
|
1253
|
+
* supplement), else `turn/start`. The thread was created by the native TUI or
|
|
1254
|
+
* resumed from the persisted native id. Serialized per session so two
|
|
1065
1255
|
* injects can't double-open a turn.
|
|
1066
1256
|
*
|
|
1067
1257
|
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
@@ -1077,15 +1267,34 @@ export class LocalAgentHost {
|
|
|
1077
1267
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1078
1268
|
if (claude) {
|
|
1079
1269
|
const text = claudeInputText(runtimeInput);
|
|
1270
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1271
|
+
const pendingInput = {
|
|
1272
|
+
content,
|
|
1273
|
+
signature: JSON.stringify(content),
|
|
1274
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1275
|
+
observed: false,
|
|
1276
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1277
|
+
};
|
|
1278
|
+
claude.pendingInjectedInputs.push(pendingInput);
|
|
1279
|
+
const forgetPendingInput = () => {
|
|
1280
|
+
const index = claude.pendingInjectedInputs.indexOf(pendingInput);
|
|
1281
|
+
if (index >= 0)
|
|
1282
|
+
claude.pendingInjectedInputs.splice(index, 1);
|
|
1283
|
+
};
|
|
1080
1284
|
const token = claudeAttachmentToken(text);
|
|
1081
1285
|
if (token) {
|
|
1082
|
-
claude.pendingImageInputs.set(token,
|
|
1286
|
+
claude.pendingImageInputs.set(token, content);
|
|
1083
1287
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1084
1288
|
expiry.unref?.();
|
|
1085
1289
|
}
|
|
1086
1290
|
const outcome = await this.injectClaude(claude, localThreadId, text);
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1291
|
+
if (outcome !== "injected") {
|
|
1292
|
+
forgetPendingInput();
|
|
1293
|
+
if (token)
|
|
1294
|
+
claude.pendingImageInputs.delete(token);
|
|
1295
|
+
}
|
|
1296
|
+
else if (pendingInput.observed) {
|
|
1297
|
+
forgetPendingInput();
|
|
1089
1298
|
}
|
|
1090
1299
|
return outcome;
|
|
1091
1300
|
}
|
|
@@ -1093,6 +1302,8 @@ export class LocalAgentHost {
|
|
|
1093
1302
|
if (!live)
|
|
1094
1303
|
return "notLive";
|
|
1095
1304
|
const run = live.injectLock.then(async () => {
|
|
1305
|
+
if (live.rotationPending || live.stopped)
|
|
1306
|
+
return "failed";
|
|
1096
1307
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1097
1308
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1098
1309
|
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
@@ -1100,34 +1311,74 @@ export class LocalAgentHost {
|
|
|
1100
1311
|
if (!bound || !threadId)
|
|
1101
1312
|
return "notReady";
|
|
1102
1313
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1314
|
+
const content = runtimeUserContent(runtimeInput);
|
|
1315
|
+
const pendingInput = {
|
|
1316
|
+
content,
|
|
1317
|
+
signature: JSON.stringify(content),
|
|
1318
|
+
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1319
|
+
observed: false,
|
|
1320
|
+
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1321
|
+
};
|
|
1322
|
+
live.pendingInjectedInputs.push(pendingInput);
|
|
1323
|
+
const forgetPendingInput = () => {
|
|
1324
|
+
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1325
|
+
if (index >= 0)
|
|
1326
|
+
live.pendingInjectedInputs.splice(index, 1);
|
|
1327
|
+
};
|
|
1103
1328
|
try {
|
|
1104
1329
|
// Inject via the BACKEND client (the forwarder connection only observes).
|
|
1105
1330
|
if (live.forwarder.isTurnOpen()) {
|
|
1106
1331
|
const turnId = live.forwarder.currentTurnId();
|
|
1107
1332
|
if (turnId) {
|
|
1108
|
-
await live.injectClient.turnSteer({
|
|
1333
|
+
const steered = await live.injectClient.turnSteer({
|
|
1109
1334
|
threadId,
|
|
1110
1335
|
expectedTurnId: turnId,
|
|
1111
1336
|
input: nativeInput,
|
|
1112
1337
|
});
|
|
1338
|
+
if (pendingInput.state === "prepublished") {
|
|
1339
|
+
// The caller already persisted and published this user input
|
|
1340
|
+
// before waiting for the native Terminal to become ready.
|
|
1341
|
+
}
|
|
1342
|
+
else if (pendingInput.observed) {
|
|
1343
|
+
forgetPendingInput();
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
live.publishInjectedInput(steered.turnId, pendingInput.content);
|
|
1347
|
+
pendingInput.state = "optimistic";
|
|
1348
|
+
}
|
|
1113
1349
|
return "injected";
|
|
1114
1350
|
}
|
|
1115
1351
|
}
|
|
1116
1352
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
1117
1353
|
// agent's model even if the TUI's config default differs.
|
|
1118
|
-
await live.injectClient.turnStart({
|
|
1354
|
+
const started = await live.injectClient.turnStart({
|
|
1119
1355
|
threadId,
|
|
1120
1356
|
input: nativeInput,
|
|
1121
|
-
|
|
1357
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1358
|
+
approvalPolicy: live.approvalPolicy,
|
|
1122
1359
|
...(live.model ? { model: live.model } : {}),
|
|
1123
1360
|
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1124
1361
|
});
|
|
1362
|
+
if (pendingInput.state === "prepublished") {
|
|
1363
|
+
// The caller already persisted and published this user input before
|
|
1364
|
+
// waiting for the native Terminal to become ready.
|
|
1365
|
+
}
|
|
1366
|
+
else if (pendingInput.observed) {
|
|
1367
|
+
forgetPendingInput();
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
live.publishInjectedInput(started.turnId, pendingInput.content);
|
|
1371
|
+
pendingInput.state = "optimistic";
|
|
1372
|
+
}
|
|
1125
1373
|
return "injected";
|
|
1126
1374
|
}
|
|
1127
|
-
catch {
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
forgetPendingInput();
|
|
1377
|
+
// Preserve the app-server's error instead of collapsing every failure
|
|
1378
|
+
// into the unactionable `live injection failed` string.
|
|
1379
|
+
const detail = codexInjectionError(error);
|
|
1380
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1381
|
+
throw new Error(detail, { cause: error });
|
|
1131
1382
|
}
|
|
1132
1383
|
});
|
|
1133
1384
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1236,6 +1487,15 @@ export class LocalAgentHost {
|
|
|
1236
1487
|
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1237
1488
|
forwarder.finalizeStop();
|
|
1238
1489
|
this.pendingClaudeFinalizers.clear();
|
|
1490
|
+
// A runner owns its app-server processes. Stopping only the forwarder leaves
|
|
1491
|
+
// those children re-parented after the runner exits, along with remote TUIs
|
|
1492
|
+
// that can only reconnect to a dead session. `stop()` sends SIGTERM before
|
|
1493
|
+
// its returned promise yields, so this remains safe in the synchronous
|
|
1494
|
+
// runner shutdown path.
|
|
1495
|
+
for (const backend of this.backends.values()) {
|
|
1496
|
+
void backend.appServerClient?.stop().catch(() => undefined);
|
|
1497
|
+
}
|
|
1498
|
+
this.backends.clear();
|
|
1239
1499
|
}
|
|
1240
1500
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
1241
1501
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
@@ -1245,7 +1505,10 @@ export class LocalAgentHost {
|
|
|
1245
1505
|
* generic interaction bridge and remain inside the active Turn. */
|
|
1246
1506
|
claudeTerminalSpec(localThreadId, record) {
|
|
1247
1507
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1248
|
-
|
|
1508
|
+
if (!live) {
|
|
1509
|
+
throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
|
|
1510
|
+
}
|
|
1511
|
+
const cwd = live.workspace.cwd;
|
|
1249
1512
|
try {
|
|
1250
1513
|
ensureProjectTrusted(cwd);
|
|
1251
1514
|
}
|
|
@@ -1253,24 +1516,17 @@ export class LocalAgentHost {
|
|
|
1253
1516
|
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
1254
1517
|
}
|
|
1255
1518
|
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;
|
|
1519
|
+
const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
|
|
1520
|
+
const hookPermissionMode = live.permissionMode;
|
|
1264
1521
|
const hookSettings = buildClaudeHookSettings({
|
|
1265
1522
|
bridgeDir: claudeBridgeDir(localThreadId),
|
|
1266
1523
|
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
1267
1524
|
messageDisplay: true,
|
|
1268
1525
|
statusLine: true,
|
|
1269
1526
|
});
|
|
1270
|
-
const settings = { ...
|
|
1527
|
+
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1271
1528
|
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
1272
|
-
// The live session
|
|
1273
|
-
// carries the agent-spec launch config: model, instructions, and skills.
|
|
1529
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1274
1530
|
const args = buildClaudeTuiArgs({
|
|
1275
1531
|
settingsJson: settingsPath,
|
|
1276
1532
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1278,10 +1534,24 @@ export class LocalAgentHost {
|
|
|
1278
1534
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1279
1535
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1280
1536
|
settingSources: "",
|
|
1281
|
-
model: live
|
|
1282
|
-
...(live
|
|
1283
|
-
|
|
1284
|
-
|
|
1537
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
1538
|
+
...(live.execution.reasoningEffort
|
|
1539
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
1540
|
+
: {}),
|
|
1541
|
+
...(live.execution.instructions
|
|
1542
|
+
? { appendSystemPrompt: live.execution.instructions }
|
|
1543
|
+
: {}),
|
|
1544
|
+
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
1545
|
+
...(live.forkIntent
|
|
1546
|
+
? {
|
|
1547
|
+
resume: live.forkIntent.sourceClaudeSessionId,
|
|
1548
|
+
forkSession: true,
|
|
1549
|
+
sessionId: live.forkIntent.targetClaudeSessionId,
|
|
1550
|
+
}
|
|
1551
|
+
: record?.codexSessionId
|
|
1552
|
+
? { resume: record.codexSessionId }
|
|
1553
|
+
: {}),
|
|
1554
|
+
...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
1285
1555
|
});
|
|
1286
1556
|
return {
|
|
1287
1557
|
command: resolveRuntimeBinary("claude"),
|
|
@@ -1292,44 +1562,15 @@ export class LocalAgentHost {
|
|
|
1292
1562
|
env: { ...process.env, RYNX_SESSION_ID: localThreadId },
|
|
1293
1563
|
};
|
|
1294
1564
|
}
|
|
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
|
-
});
|
|
1565
|
+
/** Materialize only the immutable skill declarations stored on the Session.
|
|
1566
|
+
* Provider startup never re-opens an Agent template or process-level plugin
|
|
1567
|
+
* snapshot. */
|
|
1568
|
+
async prepareExecutionSkills(execution) {
|
|
1328
1569
|
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
1329
1570
|
let skillEnv;
|
|
1330
1571
|
try {
|
|
1331
|
-
skillEnv = await buildAgentSkillEnvironment(
|
|
1332
|
-
const declaredNames =
|
|
1572
|
+
skillEnv = await buildAgentSkillEnvironment(execution.skills, sessionSkillsDir);
|
|
1573
|
+
const declaredNames = execution.skills.map((skill) => skill.name);
|
|
1333
1574
|
const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
|
|
1334
1575
|
const { report } = skillEnv;
|
|
1335
1576
|
if (report.errors.length > 0 ||
|
|
@@ -1351,52 +1592,97 @@ export class LocalAgentHost {
|
|
|
1351
1592
|
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1352
1593
|
throw error;
|
|
1353
1594
|
}
|
|
1354
|
-
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
1355
1595
|
const { report } = skillEnv;
|
|
1356
1596
|
if (report.installed.length > 0) {
|
|
1357
|
-
console.error(`[skills]
|
|
1597
|
+
console.error(`[skills] installed=[${report.installed.join(", ")}]`);
|
|
1598
|
+
}
|
|
1599
|
+
try {
|
|
1600
|
+
const materializedPluginSkills = await Promise.allSettled(execution.pluginSkills.map(async (source, sourceIndex) => {
|
|
1601
|
+
const packageRoot = await realpath(source.packageRoot);
|
|
1602
|
+
const skillRoot = await realpath(source.root);
|
|
1603
|
+
const relativeSkillRoot = path.relative(packageRoot, skillRoot);
|
|
1604
|
+
if (relativeSkillRoot === ".." ||
|
|
1605
|
+
relativeSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1606
|
+
path.isAbsolute(relativeSkillRoot)) {
|
|
1607
|
+
throw new Error(`plugin skill root escaped package: ${source.installationId}`);
|
|
1608
|
+
}
|
|
1609
|
+
const snapshotPackageRoot = path.join(sessionSkillsDir, ".plugin-packages", `source-${sourceIndex}`);
|
|
1610
|
+
await mkdir(path.dirname(snapshotPackageRoot), { recursive: true });
|
|
1611
|
+
await cp(packageRoot, snapshotPackageRoot, {
|
|
1612
|
+
recursive: true,
|
|
1613
|
+
errorOnExist: true,
|
|
1614
|
+
force: false,
|
|
1615
|
+
dereference: false,
|
|
1616
|
+
});
|
|
1617
|
+
const canonicalSnapshotPackageRoot = await realpath(snapshotPackageRoot);
|
|
1618
|
+
const integrity = await hashPluginPackageTree(canonicalSnapshotPackageRoot);
|
|
1619
|
+
if (integrity !== source.integrity) {
|
|
1620
|
+
throw new Error(`plugin package integrity changed: ${source.installationId}`);
|
|
1621
|
+
}
|
|
1622
|
+
const snapshotSkillRoot = await realpath(path.join(canonicalSnapshotPackageRoot, relativeSkillRoot));
|
|
1623
|
+
const relativeSnapshotSkillRoot = path.relative(canonicalSnapshotPackageRoot, snapshotSkillRoot);
|
|
1624
|
+
if (relativeSnapshotSkillRoot === ".." ||
|
|
1625
|
+
relativeSnapshotSkillRoot.startsWith(`..${path.sep}`) ||
|
|
1626
|
+
path.isAbsolute(relativeSnapshotSkillRoot)) {
|
|
1627
|
+
throw new Error(`snapshotted plugin skill root escaped package: ${source.installationId}`);
|
|
1628
|
+
}
|
|
1629
|
+
const discovered = await scanSkillsDir(snapshotSkillRoot);
|
|
1630
|
+
if (discovered.length === 0) {
|
|
1631
|
+
throw new Error(`plugin skill root is empty: ${source.installationId}`);
|
|
1632
|
+
}
|
|
1633
|
+
return discovered.map((skill) => ({
|
|
1634
|
+
...skill,
|
|
1635
|
+
name: providerPluginSkillName(source.namespace, skill.name),
|
|
1636
|
+
}));
|
|
1637
|
+
}));
|
|
1638
|
+
const failedPluginSkill = materializedPluginSkills.find((result) => result.status === "rejected");
|
|
1639
|
+
if (failedPluginSkill?.status === "rejected") {
|
|
1640
|
+
throw failedPluginSkill.reason;
|
|
1641
|
+
}
|
|
1642
|
+
const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1643
|
+
const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
|
|
1644
|
+
const selectedNames = new Set();
|
|
1645
|
+
for (const skill of selectedSkills) {
|
|
1646
|
+
if (selectedNames.has(skill.name)) {
|
|
1647
|
+
throw new Error(`duplicate final Session skill name: ${skill.name}`);
|
|
1648
|
+
}
|
|
1649
|
+
selectedNames.add(skill.name);
|
|
1650
|
+
}
|
|
1651
|
+
return {
|
|
1652
|
+
selectedSkills,
|
|
1653
|
+
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1658
|
+
throw error;
|
|
1358
1659
|
}
|
|
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
1660
|
}
|
|
1372
1661
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
1373
|
-
const retargetMirror = opts
|
|
1662
|
+
const retargetMirror = opts.retargetMirror;
|
|
1663
|
+
const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
|
|
1664
|
+
if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
|
|
1665
|
+
throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
|
|
1666
|
+
}
|
|
1374
1667
|
const bridgeDir = prepareClaudeBridgeDir(localThreadId);
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1668
|
+
const workspace = structuredClone(opts.workspace);
|
|
1669
|
+
const execution = structuredClone(opts.execution);
|
|
1670
|
+
if (execution.provider !== "claude") {
|
|
1671
|
+
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1672
|
+
}
|
|
1673
|
+
const cwd = workspace.cwd;
|
|
1674
|
+
let snapshotSkills;
|
|
1381
1675
|
try {
|
|
1382
|
-
|
|
1676
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
1383
1677
|
}
|
|
1384
1678
|
catch (err) {
|
|
1385
|
-
console.error(`[
|
|
1679
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1386
1680
|
return false;
|
|
1387
1681
|
}
|
|
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;
|
|
1682
|
+
const model = execution.model ?? "";
|
|
1683
|
+
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1684
|
+
void snapshotSkills.skillsCleanup();
|
|
1685
|
+
const permissionMode = execution.permissionMode;
|
|
1400
1686
|
const launchExtraArgs = [
|
|
1401
1687
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
1402
1688
|
...(permissionMode ? ["--permission-mode", permissionMode] : []),
|
|
@@ -1406,16 +1692,25 @@ export class LocalAgentHost {
|
|
|
1406
1692
|
let currentSessionId = localThreadId;
|
|
1407
1693
|
let normalizer = null;
|
|
1408
1694
|
let currentResponseId;
|
|
1695
|
+
let pendingRotationEvents = null;
|
|
1696
|
+
const emitCurrent = (event) => {
|
|
1697
|
+
if (pendingRotationEvents) {
|
|
1698
|
+
pendingRotationEvents.push(event);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
emit(event);
|
|
1702
|
+
};
|
|
1409
1703
|
const startNormalizer = (turnId) => {
|
|
1410
1704
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1411
|
-
const responseId =
|
|
1705
|
+
const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
|
|
1706
|
+
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
1412
1707
|
if (normalizer && currentResponseId === responseId)
|
|
1413
1708
|
return normalizer;
|
|
1414
1709
|
currentResponseId = responseId;
|
|
1415
1710
|
normalizer = new SessionNormalizer({
|
|
1416
1711
|
sessionId: currentSessionId,
|
|
1417
1712
|
responseId,
|
|
1418
|
-
model,
|
|
1713
|
+
model: model || "claude",
|
|
1419
1714
|
});
|
|
1420
1715
|
return normalizer;
|
|
1421
1716
|
};
|
|
@@ -1435,26 +1730,33 @@ export class LocalAgentHost {
|
|
|
1435
1730
|
...(event.reason ? { reason: event.reason } : {}),
|
|
1436
1731
|
};
|
|
1437
1732
|
for (const se of n.next(agentEvent))
|
|
1438
|
-
|
|
1733
|
+
emitCurrent(se);
|
|
1439
1734
|
};
|
|
1440
1735
|
let markReady;
|
|
1441
1736
|
const ready = new Promise((resolve) => {
|
|
1442
1737
|
markReady = resolve;
|
|
1443
1738
|
});
|
|
1739
|
+
let markFailed;
|
|
1740
|
+
const failed = new Promise((resolve) => {
|
|
1741
|
+
markFailed = resolve;
|
|
1742
|
+
});
|
|
1444
1743
|
const live = {
|
|
1445
1744
|
forwarder: null,
|
|
1446
|
-
|
|
1745
|
+
workspace,
|
|
1746
|
+
execution,
|
|
1447
1747
|
bridgeDir,
|
|
1448
1748
|
injectLock: Promise.resolve(),
|
|
1449
1749
|
pendingImageInputs: new Map(),
|
|
1750
|
+
pendingInjectedInputs: [],
|
|
1450
1751
|
ready,
|
|
1451
1752
|
markReady,
|
|
1753
|
+
failed,
|
|
1754
|
+
markFailed,
|
|
1452
1755
|
discoveredReady: false,
|
|
1453
1756
|
stopped: false,
|
|
1454
|
-
launchModel: model,
|
|
1455
|
-
...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
|
|
1456
1757
|
launchExtraArgs,
|
|
1457
|
-
|
|
1758
|
+
permissionMode,
|
|
1759
|
+
...(forkIntent ? { forkIntent } : {}),
|
|
1458
1760
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
1459
1761
|
};
|
|
1460
1762
|
const sink = {
|
|
@@ -1465,19 +1767,28 @@ export class LocalAgentHost {
|
|
|
1465
1767
|
const content = token ? live.pendingImageInputs.get(token) : undefined;
|
|
1466
1768
|
if (token && content)
|
|
1467
1769
|
live.pendingImageInputs.delete(token);
|
|
1468
|
-
|
|
1469
|
-
|
|
1770
|
+
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1771
|
+
const signature = JSON.stringify(normalizedContent);
|
|
1772
|
+
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
1773
|
+
if (pending?.state === "prepublished" || pending?.state === "optimistic") {
|
|
1774
|
+
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (pending)
|
|
1778
|
+
pending.observed = true;
|
|
1779
|
+
for (const se of n.userInput(normalizedContent))
|
|
1780
|
+
emitCurrent(se);
|
|
1470
1781
|
},
|
|
1471
1782
|
onTerminalCommand: (cmd) => {
|
|
1472
1783
|
const n = normalizer ?? startNormalizer();
|
|
1473
1784
|
for (const se of n.terminalCommand(cmd))
|
|
1474
|
-
|
|
1785
|
+
emitCurrent(se);
|
|
1475
1786
|
},
|
|
1476
|
-
onTodos: (todos) =>
|
|
1787
|
+
onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
|
|
1477
1788
|
onEvent: (event) => {
|
|
1478
1789
|
const n = normalizer ?? startNormalizer();
|
|
1479
1790
|
for (const se of n.next(event))
|
|
1480
|
-
|
|
1791
|
+
emitCurrent(se);
|
|
1481
1792
|
},
|
|
1482
1793
|
onInteraction: forwardInteraction,
|
|
1483
1794
|
onTurnEnd: (usage) => {
|
|
@@ -1485,11 +1796,13 @@ export class LocalAgentHost {
|
|
|
1485
1796
|
return;
|
|
1486
1797
|
const rid = currentResponseId;
|
|
1487
1798
|
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
1488
|
-
if (usage)
|
|
1799
|
+
if (usage) {
|
|
1489
1800
|
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1490
|
-
|
|
1801
|
+
emitCurrent(se);
|
|
1802
|
+
}
|
|
1491
1803
|
for (const se of normalizer.next({ type: "done" }))
|
|
1492
|
-
|
|
1804
|
+
emitCurrent(se);
|
|
1805
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1493
1806
|
normalizer = null;
|
|
1494
1807
|
currentResponseId = undefined;
|
|
1495
1808
|
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
@@ -1497,7 +1810,7 @@ export class LocalAgentHost {
|
|
|
1497
1810
|
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1498
1811
|
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1499
1812
|
live.contextWarned = true;
|
|
1500
|
-
|
|
1813
|
+
emitCurrent({
|
|
1501
1814
|
type: "session.status",
|
|
1502
1815
|
sessionId: currentSessionId,
|
|
1503
1816
|
responseId: rid,
|
|
@@ -1510,7 +1823,7 @@ export class LocalAgentHost {
|
|
|
1510
1823
|
// Surface idle on the current turn WITHOUT finalizing it (see the sink's
|
|
1511
1824
|
// onIdle doc): a late assistant record still joins this response.
|
|
1512
1825
|
if (currentResponseId) {
|
|
1513
|
-
|
|
1826
|
+
emitCurrent({
|
|
1514
1827
|
type: "session.status",
|
|
1515
1828
|
sessionId: currentSessionId,
|
|
1516
1829
|
responseId: currentResponseId,
|
|
@@ -1521,87 +1834,115 @@ export class LocalAgentHost {
|
|
|
1521
1834
|
onTurnError: () => {
|
|
1522
1835
|
if (!normalizer)
|
|
1523
1836
|
return;
|
|
1837
|
+
const rid = currentResponseId;
|
|
1524
1838
|
for (const se of normalizer.fail({
|
|
1525
1839
|
code: "agent_error",
|
|
1526
1840
|
message: "Agent turn failed",
|
|
1527
1841
|
source: "execution",
|
|
1528
1842
|
}))
|
|
1529
|
-
|
|
1843
|
+
emitCurrent(se);
|
|
1844
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1530
1845
|
normalizer = null;
|
|
1531
1846
|
currentResponseId = undefined;
|
|
1532
1847
|
},
|
|
1533
|
-
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId,
|
|
1848
|
+
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
|
|
1849
|
+
onSessionResumeError: (error) => {
|
|
1850
|
+
live.error = error.message;
|
|
1851
|
+
live.markFailed();
|
|
1852
|
+
},
|
|
1534
1853
|
onSessionRotated: (kind, claudeSessionId) => {
|
|
1854
|
+
if (pendingRotationEvents) {
|
|
1855
|
+
live.error = "Claude reported another Session rotation before publication completed";
|
|
1856
|
+
live.markFailed();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1535
1859
|
// The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
|
|
1536
|
-
// new transcript.
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1860
|
+
// new transcript. Persist the Provider binding first, then announce on
|
|
1861
|
+
// the OLD session and re-target the mirror. Events produced during the
|
|
1862
|
+
// file-store commit are buffered so none can land under the old identity.
|
|
1539
1863
|
const previousSessionId = currentSessionId;
|
|
1540
1864
|
const newSessionId = makeSessionId();
|
|
1541
|
-
|
|
1865
|
+
currentSessionId = newSessionId;
|
|
1866
|
+
normalizer = null;
|
|
1867
|
+
currentResponseId = undefined;
|
|
1868
|
+
live.contextWarned = false;
|
|
1869
|
+
pendingRotationEvents = [];
|
|
1870
|
+
this.liveClaudeSessions.set(newSessionId, live);
|
|
1542
1871
|
void this.sessionStore
|
|
1543
1872
|
.set({
|
|
1544
1873
|
localThreadId: newSessionId,
|
|
1545
1874
|
codexSessionId: claudeSessionId,
|
|
1546
|
-
cwd: live.cwd,
|
|
1547
|
-
model,
|
|
1548
|
-
runtime: "claude",
|
|
1549
|
-
...(record?.agent ? { agent: record.agent } : {}),
|
|
1550
1875
|
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1551
1876
|
updatedAt: new Date().toISOString(),
|
|
1552
1877
|
})
|
|
1553
|
-
.
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1878
|
+
.then(() => {
|
|
1879
|
+
emit({
|
|
1880
|
+
type: "session.rotated",
|
|
1881
|
+
sessionId: previousSessionId,
|
|
1882
|
+
newSessionId,
|
|
1883
|
+
kind,
|
|
1884
|
+
});
|
|
1885
|
+
retargetMirror?.(newSessionId, {
|
|
1886
|
+
kind,
|
|
1887
|
+
workspace: structuredClone(live.workspace),
|
|
1888
|
+
execution: structuredClone(live.execution),
|
|
1889
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1890
|
+
});
|
|
1891
|
+
const queued = pendingRotationEvents ?? [];
|
|
1892
|
+
pendingRotationEvents = null;
|
|
1893
|
+
for (const event of queued)
|
|
1894
|
+
emit(event);
|
|
1895
|
+
})
|
|
1896
|
+
.catch((error) => {
|
|
1897
|
+
pendingRotationEvents = null;
|
|
1898
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1899
|
+
live.markFailed();
|
|
1565
1900
|
});
|
|
1566
1901
|
},
|
|
1567
1902
|
};
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
: undefined;
|
|
1903
|
+
// Claude itself resolves `--resume <id>`. The SessionStart hook is the
|
|
1904
|
+
// authoritative source for the actual transcript path; do not infer or
|
|
1905
|
+
// generate Claude's private on-disk format here.
|
|
1572
1906
|
live.forwarder = new ClaudeLiveSession({
|
|
1573
1907
|
bridgeDir,
|
|
1574
1908
|
sink,
|
|
1575
|
-
...(
|
|
1576
|
-
|
|
1909
|
+
...(forkIntent
|
|
1910
|
+
? { claudeSessionId: forkIntent.targetClaudeSessionId }
|
|
1911
|
+
: record?.codexSessionId
|
|
1912
|
+
? { claudeSessionId: record.codexSessionId }
|
|
1913
|
+
: {}),
|
|
1914
|
+
...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
|
|
1577
1915
|
});
|
|
1578
1916
|
this.liveClaudeSessions.set(localThreadId, live);
|
|
1579
1917
|
live.forwarder.start();
|
|
1580
|
-
if (record?.codexSessionId) {
|
|
1581
|
-
live.discoveredReady = true;
|
|
1582
|
-
markReady(); // resumed → injection can proceed without awaiting discovery
|
|
1583
|
-
}
|
|
1584
1918
|
return true;
|
|
1585
1919
|
}
|
|
1586
1920
|
/** Persist claude's discovered session id (reusing the `codexSessionId` store
|
|
1587
1921
|
* field, as the claude executor already does) and release the readiness gate. */
|
|
1588
|
-
onClaudeDiscovered(live, localThreadId,
|
|
1922
|
+
onClaudeDiscovered(live, localThreadId, claudeSessionId) {
|
|
1589
1923
|
if (live.discoveredReady)
|
|
1590
1924
|
return;
|
|
1925
|
+
const forkIntent = live.forkIntent;
|
|
1926
|
+
if (forkIntent && claudeSessionId !== forkIntent.targetClaudeSessionId) {
|
|
1927
|
+
live.error =
|
|
1928
|
+
`Claude fork created ${claudeSessionId} instead of ${forkIntent.targetClaudeSessionId}`;
|
|
1929
|
+
live.markFailed();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1591
1932
|
live.discoveredReady = true;
|
|
1592
1933
|
void this.sessionStore
|
|
1593
1934
|
.set({
|
|
1594
1935
|
localThreadId,
|
|
1595
1936
|
codexSessionId: claudeSessionId,
|
|
1596
|
-
|
|
1597
|
-
// Persist the agent-spec-resolved launch model (was the config default).
|
|
1598
|
-
model: live.launchModel,
|
|
1599
|
-
runtime: "claude",
|
|
1600
|
-
agent,
|
|
1937
|
+
...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
|
|
1601
1938
|
updatedAt: new Date().toISOString(),
|
|
1602
1939
|
})
|
|
1603
|
-
.
|
|
1604
|
-
|
|
1940
|
+
.then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
|
|
1941
|
+
.then(() => live.markReady())
|
|
1942
|
+
.catch((error) => {
|
|
1943
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
1944
|
+
live.markFailed();
|
|
1945
|
+
});
|
|
1605
1946
|
}
|
|
1606
1947
|
/** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
|
|
1607
1948
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
@@ -1683,12 +2024,12 @@ export class LocalAgentHost {
|
|
|
1683
2024
|
if (!record?.codexSessionId) {
|
|
1684
2025
|
return { ok: false, reason: "no_active_thread" };
|
|
1685
2026
|
}
|
|
1686
|
-
const
|
|
1687
|
-
if (!
|
|
2027
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2028
|
+
if (!live) {
|
|
1688
2029
|
return { ok: false, reason: "unsupported" };
|
|
1689
2030
|
}
|
|
1690
2031
|
try {
|
|
1691
|
-
return { ok: true, data: await fn(
|
|
2032
|
+
return { ok: true, data: await fn(live.injectClient, record.codexSessionId) };
|
|
1692
2033
|
}
|
|
1693
2034
|
catch (error) {
|
|
1694
2035
|
return {
|
|
@@ -1714,30 +2055,108 @@ export class LocalAgentHost {
|
|
|
1714
2055
|
await client.threadGoalClear({ threadId });
|
|
1715
2056
|
});
|
|
1716
2057
|
}
|
|
1717
|
-
async forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
2058
|
+
async forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
2059
|
+
const inflight = this.forkingTargets.get(newLocalThreadId);
|
|
2060
|
+
if (inflight)
|
|
2061
|
+
return inflight;
|
|
2062
|
+
const operation = this.performForkSession(currentLocalThreadId, newLocalThreadId, options);
|
|
2063
|
+
this.forkingTargets.set(newLocalThreadId, operation);
|
|
2064
|
+
try {
|
|
2065
|
+
return await operation;
|
|
2066
|
+
}
|
|
2067
|
+
finally {
|
|
2068
|
+
if (this.forkingTargets.get(newLocalThreadId) === operation) {
|
|
2069
|
+
this.forkingTargets.delete(newLocalThreadId);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
async performForkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
1718
2074
|
const record = await this.sessionStore.get(currentLocalThreadId);
|
|
1719
2075
|
if (!record) {
|
|
1720
2076
|
return { ok: false, reason: "no_active_thread" };
|
|
1721
2077
|
}
|
|
1722
|
-
const
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
2078
|
+
const live = this.liveSessions.get(currentLocalThreadId);
|
|
2079
|
+
const workspace = live?.workspace ?? options?.workspace;
|
|
2080
|
+
const execution = live?.execution ?? options?.execution;
|
|
2081
|
+
if (!workspace || !execution) {
|
|
2082
|
+
return { ok: false, reason: "no_active_thread" };
|
|
2083
|
+
}
|
|
2084
|
+
if (execution.provider === "claude") {
|
|
2085
|
+
return {
|
|
2086
|
+
ok: false,
|
|
2087
|
+
reason: "unsupported",
|
|
2088
|
+
message: "Claude fork must be materialized by the target runner",
|
|
2089
|
+
};
|
|
1726
2090
|
}
|
|
1727
2091
|
try {
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2092
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
2093
|
+
if (existingTarget) {
|
|
2094
|
+
if (existingTarget.parentSessionId !== currentLocalThreadId) {
|
|
2095
|
+
return {
|
|
2096
|
+
ok: false,
|
|
2097
|
+
reason: "error",
|
|
2098
|
+
message: "target Session already belongs to a different fork",
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
return { ok: true, data: undefined };
|
|
2102
|
+
}
|
|
2103
|
+
const runtime = execution.provider;
|
|
2104
|
+
const client = live?.injectClient ??
|
|
2105
|
+
this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
2106
|
+
if (!client) {
|
|
2107
|
+
return { ok: false, reason: "unsupported" };
|
|
2108
|
+
}
|
|
2109
|
+
await client.ensureInitialized();
|
|
2110
|
+
if (live?.managedFork) {
|
|
2111
|
+
return {
|
|
2112
|
+
ok: false,
|
|
2113
|
+
reason: "error",
|
|
2114
|
+
message: "source Session already has a managed fork in progress",
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
const managedFork = live
|
|
2118
|
+
? {
|
|
2119
|
+
sourceThreadId: record.codexSessionId,
|
|
2120
|
+
observedThreadIds: new Set(),
|
|
2121
|
+
}
|
|
2122
|
+
: undefined;
|
|
2123
|
+
const forked = await (async () => {
|
|
2124
|
+
if (live && managedFork)
|
|
2125
|
+
live.managedFork = managedFork;
|
|
2126
|
+
try {
|
|
2127
|
+
return await client.threadFork({
|
|
2128
|
+
threadId: record.codexSessionId,
|
|
2129
|
+
excludeTurns: true,
|
|
2130
|
+
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2131
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2132
|
+
approvalPolicy: execution.approvalPolicy,
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
finally {
|
|
2136
|
+
if (live && live.managedFork === managedFork)
|
|
2137
|
+
delete live.managedFork;
|
|
2138
|
+
for (const observedThreadId of managedFork?.observedThreadIds ?? []) {
|
|
2139
|
+
this.rememberManagedForkThreadStart(observedThreadId, record.codexSessionId);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
})();
|
|
1733
2143
|
const forkedThreadId = forked.thread.id;
|
|
2144
|
+
if (live && forkedThreadId) {
|
|
2145
|
+
this.rememberManagedForkThreadStart(forkedThreadId, record.codexSessionId);
|
|
2146
|
+
}
|
|
2147
|
+
if (!forkedThreadId ||
|
|
2148
|
+
forkedThreadId === record.codexSessionId ||
|
|
2149
|
+
(forked.thread.forkedFromId !== undefined &&
|
|
2150
|
+
forked.thread.forkedFromId !== null &&
|
|
2151
|
+
forked.thread.forkedFromId !== record.codexSessionId)) {
|
|
2152
|
+
throw new Error("Provider returned an invalid forked thread");
|
|
2153
|
+
}
|
|
2154
|
+
const sourceHomeOwner = record.runtimeHomeOwnerSessionId ?? this.runtimeHomeSessionId;
|
|
1734
2155
|
await this.sessionStore.set({
|
|
1735
2156
|
localThreadId: newLocalThreadId,
|
|
1736
2157
|
codexSessionId: forkedThreadId,
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
reasoningEffort: record.reasoningEffort,
|
|
1740
|
-
runtime,
|
|
2158
|
+
parentSessionId: currentLocalThreadId,
|
|
2159
|
+
runtimeHomeOwnerSessionId: sourceHomeOwner,
|
|
1741
2160
|
updatedAt: new Date().toISOString(),
|
|
1742
2161
|
});
|
|
1743
2162
|
return { ok: true, data: undefined };
|
|
@@ -1751,6 +2170,12 @@ export class LocalAgentHost {
|
|
|
1751
2170
|
}
|
|
1752
2171
|
}
|
|
1753
2172
|
}
|
|
2173
|
+
function codexInjectionError(error) {
|
|
2174
|
+
if (error instanceof CodexTransportError) {
|
|
2175
|
+
return `Codex app-server rejected turn/start: ${error.message} (code ${error.code})`;
|
|
2176
|
+
}
|
|
2177
|
+
return `Codex turn/start failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2178
|
+
}
|
|
1754
2179
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
1755
2180
|
const normalized = output.trim();
|
|
1756
2181
|
const match = normalized.match(/Logged in using (.+)$/im);
|
|
@@ -1774,25 +2199,48 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
1774
2199
|
issues: normalized ? [normalized] : [],
|
|
1775
2200
|
};
|
|
1776
2201
|
}
|
|
1777
|
-
/** Race a live session's `ready` promise against
|
|
1778
|
-
function raceReady(ready, timeoutMs) {
|
|
2202
|
+
/** Race a live session's `ready` promise against failure / timeout. */
|
|
2203
|
+
function raceReady(ready, timeoutMs, failed) {
|
|
1779
2204
|
let timer;
|
|
1780
2205
|
const timeout = new Promise((resolve) => {
|
|
1781
2206
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1782
2207
|
});
|
|
1783
|
-
|
|
2208
|
+
const outcomes = [ready.then(() => true), timeout];
|
|
2209
|
+
if (failed)
|
|
2210
|
+
outcomes.push(failed.then(() => false));
|
|
2211
|
+
return Promise.race(outcomes).finally(() => {
|
|
1784
2212
|
if (timer)
|
|
1785
2213
|
clearTimeout(timer);
|
|
1786
2214
|
});
|
|
1787
2215
|
}
|
|
1788
|
-
function
|
|
2216
|
+
function sameSessionSnapshots(workspace, execution, opts) {
|
|
1789
2217
|
return JSON.stringify(stableValue({
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2218
|
+
workspace,
|
|
2219
|
+
execution,
|
|
2220
|
+
})) === JSON.stringify(stableValue({
|
|
2221
|
+
workspace: opts.workspace,
|
|
2222
|
+
execution: opts.execution,
|
|
1794
2223
|
}));
|
|
1795
2224
|
}
|
|
2225
|
+
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2226
|
+
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2227
|
+
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
2228
|
+
function applyClaudePermissionModeSnapshot(settings, permissionMode) {
|
|
2229
|
+
const next = { ...settings };
|
|
2230
|
+
const inherited = settings.permissions;
|
|
2231
|
+
const permissions = inherited && typeof inherited === "object" && !Array.isArray(inherited)
|
|
2232
|
+
? { ...inherited }
|
|
2233
|
+
: {};
|
|
2234
|
+
if (permissionMode)
|
|
2235
|
+
permissions.defaultMode = permissionMode;
|
|
2236
|
+
else
|
|
2237
|
+
delete permissions.defaultMode;
|
|
2238
|
+
if (Object.keys(permissions).length > 0)
|
|
2239
|
+
next.permissions = permissions;
|
|
2240
|
+
else
|
|
2241
|
+
delete next.permissions;
|
|
2242
|
+
return next;
|
|
2243
|
+
}
|
|
1796
2244
|
function stableValue(value) {
|
|
1797
2245
|
if (Array.isArray(value))
|
|
1798
2246
|
return value.map(stableValue);
|
|
@@ -1826,3 +2274,10 @@ export function isThreadNotReadyError(error) {
|
|
|
1826
2274
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1827
2275
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
1828
2276
|
}
|
|
2277
|
+
/** A persisted thread id that a freshly started app-server cannot load yet.
|
|
2278
|
+
* During startup, both errors can be transient while the rollout index catches
|
|
2279
|
+
* up. Retry the same id; never use either error as permission to replace it. */
|
|
2280
|
+
export function isRetryableThreadResumeError(error) {
|
|
2281
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2282
|
+
return message.includes("thread not found") || isThreadNotReadyError(error);
|
|
2283
|
+
}
|