@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41
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/executor.d.ts +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +103 -1
- package/dist/claude/native-bridge.js +445 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1528 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +158 -47
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +532 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +206 -36
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +64 -7
- package/dist/codex-app-server/ws-channel.d.ts +7 -0
- package/dist/codex-app-server/ws-channel.js +104 -28
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +103 -46
- package/dist/host.js +1988 -634
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +97 -28
- package/dist/runner/child.js +1486 -100
- package/dist/runner/manager.d.ts +110 -29
- package/dist/runner/manager.js +1481 -246
- package/dist/runner/protocol.d.ts +212 -24
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
package/dist/host.js
CHANGED
|
@@ -4,29 +4,54 @@ import { cp, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import readline from "node:readline";
|
|
7
|
-
import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, } from "@rynx-ai/core";
|
|
7
|
+
import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, newSessionItemId, } from "@rynx-ai/core";
|
|
8
8
|
import { getRuntimeProfile, } from "@rynx-ai/core";
|
|
9
9
|
import { resolveRuntimeBinary, } from "@rynx-ai/core";
|
|
10
10
|
import { createCodexChildEnv } from "./codex-child-env.js";
|
|
11
|
-
import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
|
|
12
|
-
import {
|
|
11
|
+
import { applyCodexProviderRetryPolicy, codexLaunchRequiresLogin, prepareRuntimeHome, populateCodexSkills, readCodexDeveloperInstructionsState, runtimeHomePath, syncCodexDeveloperInstructions, trustCodexProject, } from "./codex-home.js";
|
|
12
|
+
import { materializeClaudePlugin, reuseClaudePlugin, } from "./claude/executor.js";
|
|
13
13
|
import { listClaudeModels } from "./claude/models.js";
|
|
14
|
+
import { validateInteractionResolution } from "./interactions.js";
|
|
14
15
|
import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
|
|
15
16
|
import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
|
|
16
17
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
18
|
+
import { reapRuntimeProcessesForStateDir } from "./codex-app-server/process-registry.js";
|
|
17
19
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
20
|
+
import { readMcpStartupPlan } from "./codex-app-server/mcp-startup.js";
|
|
21
|
+
import { runtimeSessionStateDir } from "./runtime-state-paths.js";
|
|
18
22
|
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
19
23
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
20
24
|
import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
|
|
21
25
|
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
22
26
|
import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
|
|
23
|
-
import {
|
|
27
|
+
import { prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
24
28
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
25
29
|
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
26
30
|
import { buildManagedClaudeSettings } from "./claude/settings.js";
|
|
27
31
|
import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
|
|
28
32
|
import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
|
|
29
33
|
import { newSessionId as makeSessionId } from "@rynx-ai/core";
|
|
34
|
+
function isDurableSessionEvent(event) {
|
|
35
|
+
return event.type === "response.output_item.done" ||
|
|
36
|
+
event.type === "session.input.consumed" ||
|
|
37
|
+
event.type === "session.interaction.requested" ||
|
|
38
|
+
event.type === "session.interaction.resolved" ||
|
|
39
|
+
event.type === "session.interaction.cancelled";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Events produced while a native Provider is crossing a logical Session
|
|
43
|
+
* rotation are normalized before the new Rynx id is known. Re-stamp both the
|
|
44
|
+
* envelope and any durable item before releasing that buffer into the target.
|
|
45
|
+
*/
|
|
46
|
+
function retargetSessionEvent(event, sessionId) {
|
|
47
|
+
const retargeted = structuredClone(event);
|
|
48
|
+
if (typeof retargeted.sessionId === "string")
|
|
49
|
+
retargeted.sessionId = sessionId;
|
|
50
|
+
if (retargeted.item && typeof retargeted.item.sessionId === "string") {
|
|
51
|
+
retargeted.item.sessionId = sessionId;
|
|
52
|
+
}
|
|
53
|
+
return retargeted;
|
|
54
|
+
}
|
|
30
55
|
import { resolveAllowedRoots, } from "@rynx-ai/core";
|
|
31
56
|
export { FileCodexSessionStore, resolveCodexSessionStorePath, };
|
|
32
57
|
// The runtime error type lives in @rynx-ai/core (as AgentRuntimeError) so channels
|
|
@@ -181,6 +206,8 @@ export class SpawnCodexCommandRunner {
|
|
|
181
206
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
182
207
|
* signal is the lever consumers surface to the user. */
|
|
183
208
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
209
|
+
/** Wait for native bridge/thread binding before injection gives up. */
|
|
210
|
+
const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
|
|
184
211
|
function providerPluginSkillName(namespace, name) {
|
|
185
212
|
const candidate = `${namespace}-${name}`;
|
|
186
213
|
if (candidate.length <= 128)
|
|
@@ -189,44 +216,85 @@ function providerPluginSkillName(namespace, name) {
|
|
|
189
216
|
return `${namespace.slice(0, 48)}-${name.slice(0, 65)}-${digest}`;
|
|
190
217
|
}
|
|
191
218
|
const MAX_CODEX_SETTLED_INTERACTIONS = 512;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
219
|
+
const CODEX_PLAN_IMPLEMENTATION_YES = "Yes, implement this plan";
|
|
220
|
+
const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT = "Yes, clear context and implement";
|
|
221
|
+
const CODEX_PLAN_IMPLEMENTATION_NO = "No, stay in Plan mode";
|
|
222
|
+
const CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE = "Implement the plan.";
|
|
223
|
+
const CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS = 1_500;
|
|
224
|
+
const CODEX_PLAN_PROMPT_DISMISS_POLL_MS = 50;
|
|
225
|
+
const CODEX_SUBSCRIBE_FLUSH_RETRY_MS = 1_500;
|
|
226
|
+
const CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX = "A previous agent produced the plan below to accomplish the user's task. " +
|
|
227
|
+
"Implement the plan in a fresh context. Treat the plan as the source of " +
|
|
228
|
+
"user intent, re-read files as needed, and carry the work through " +
|
|
229
|
+
"implementation and verification.";
|
|
230
|
+
function codexPlanImplementationRequest(interactionId) {
|
|
231
|
+
return {
|
|
232
|
+
interactionId,
|
|
233
|
+
kind: "question",
|
|
234
|
+
title: "Plan",
|
|
235
|
+
fields: [{
|
|
236
|
+
id: "plan_implementation",
|
|
237
|
+
type: "select",
|
|
238
|
+
label: "Implement this plan?",
|
|
239
|
+
required: true,
|
|
240
|
+
options: [
|
|
241
|
+
{
|
|
242
|
+
value: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
243
|
+
label: CODEX_PLAN_IMPLEMENTATION_YES,
|
|
244
|
+
description: "Switch to Default and start coding.",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
value: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
248
|
+
label: CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT,
|
|
249
|
+
description: "Fresh thread with this plan.",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
value: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
253
|
+
label: CODEX_PLAN_IMPLEMENTATION_NO,
|
|
254
|
+
description: "Continue planning with the model.",
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
}],
|
|
258
|
+
actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
|
|
259
|
+
createdAt: Date.now(),
|
|
260
|
+
};
|
|
214
261
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
* backend — so budget-less agents keep today's shared-process behaviour.
|
|
220
|
-
*/
|
|
221
|
-
function hashRetryEnv(env) {
|
|
222
|
-
return Object.keys(env)
|
|
223
|
-
.sort()
|
|
224
|
-
.map((key) => `${key}=${env[key]}`)
|
|
225
|
-
.join("&");
|
|
262
|
+
function codexPlanImplementationPromptVisible(pane) {
|
|
263
|
+
return pane.includes("Implement this plan?") &&
|
|
264
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_YES) &&
|
|
265
|
+
pane.includes(CODEX_PLAN_IMPLEMENTATION_NO);
|
|
226
266
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
267
|
+
/** Codex's final Plan picker is owned by the TUI and is not an app-server
|
|
268
|
+
* request that Web can resolve. Once the synthetic Web form takes ownership,
|
|
269
|
+
* close only that exact picker with its documented Escape action. The owner
|
|
270
|
+
* predicate is checked both before and after capture: app-server notifications
|
|
271
|
+
* cannot interleave between the two synchronous tmux calls, so a newly observed
|
|
272
|
+
* Turn cannot receive a blind Escape. This is deliberately best-effort — a
|
|
273
|
+
* cosmetic Terminal redraw failure must not make an otherwise valid Web Plan
|
|
274
|
+
* decision fail when the structured app-server path can still proceed. */
|
|
275
|
+
async function dismissCodexPlanImplementationPrompt(injector, ownsPrompt) {
|
|
276
|
+
if (!injector || !ownsPrompt())
|
|
277
|
+
return "not_visible";
|
|
278
|
+
try {
|
|
279
|
+
const pane = injector.capturePane();
|
|
280
|
+
if (!ownsPrompt())
|
|
281
|
+
return "superseded";
|
|
282
|
+
if (!codexPlanImplementationPromptVisible(pane))
|
|
283
|
+
return "not_visible";
|
|
284
|
+
injector.interrupt();
|
|
285
|
+
const deadline = Date.now() + CODEX_PLAN_PROMPT_DISMISS_TIMEOUT_MS;
|
|
286
|
+
while (ownsPrompt()) {
|
|
287
|
+
if (!codexPlanImplementationPromptVisible(injector.capturePane()))
|
|
288
|
+
return "dismissed";
|
|
289
|
+
if (Date.now() >= deadline)
|
|
290
|
+
return "failed";
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, CODEX_PLAN_PROMPT_DISMISS_POLL_MS));
|
|
292
|
+
}
|
|
293
|
+
return "superseded";
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return "failed";
|
|
297
|
+
}
|
|
230
298
|
}
|
|
231
299
|
export class LocalAgentHost {
|
|
232
300
|
config;
|
|
@@ -238,14 +306,19 @@ export class LocalAgentHost {
|
|
|
238
306
|
// auto-create. The command runner override backs the status probe.
|
|
239
307
|
injectedCommandRunner;
|
|
240
308
|
injectedAppServerClient;
|
|
241
|
-
clock;
|
|
242
|
-
backendIdleTtlMs;
|
|
243
309
|
// Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
|
|
244
310
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
245
311
|
// the backend's app-server; tests inject a fake.
|
|
246
312
|
forwarderClientFactory;
|
|
247
|
-
//
|
|
248
|
-
//
|
|
313
|
+
// Builds reference implementation's short-lived cold-resume preload connection. This must be
|
|
314
|
+
// distinct from both the app-server owner/injector and the background
|
|
315
|
+
// observer: the owner's initialize handshake is the readiness probe, then a
|
|
316
|
+
// freshly initialized connection performs the one resume and closes.
|
|
317
|
+
preloadClientFactory;
|
|
318
|
+
/** Builds one short-lived message/interrupt client. */
|
|
319
|
+
injectionClientFactory;
|
|
320
|
+
// One backend per runtime. The host belongs to one Session runner, and Codex
|
|
321
|
+
// retry settings live in that Session's private config rather than process env.
|
|
249
322
|
backends = new Map();
|
|
250
323
|
// The rynx Session this host serves (set from `RYNX_RUNNER_SESSION`).
|
|
251
324
|
sessionId;
|
|
@@ -263,9 +336,14 @@ export class LocalAgentHost {
|
|
|
263
336
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
264
337
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
265
338
|
liveClaudeSessions = new Map();
|
|
339
|
+
/** Exact pre-live startup failure, retained after the partial handle has been
|
|
340
|
+
* cleaned up so Core/plugin callers receive the failed phase, not a generic
|
|
341
|
+
* `live session unavailable` wrapper. */
|
|
342
|
+
liveStartupErrors = new Map();
|
|
266
343
|
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
267
344
|
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
268
345
|
pendingClaudeFinalizers = new Set();
|
|
346
|
+
pendingCleanupTasks = new Set();
|
|
269
347
|
// In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
|
|
270
348
|
// `live.ensure` racing a `term.open`) share ONE forwarder — never two.
|
|
271
349
|
liveEnsuring = new Map();
|
|
@@ -273,7 +351,7 @@ export class LocalAgentHost {
|
|
|
273
351
|
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
274
352
|
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
275
353
|
managedForkThreadStarts = new Map();
|
|
276
|
-
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient,
|
|
354
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, forwarderClientFactory, preloadClientFactory, injectionClientFactory, sessionId, runtimeHomeSessionId, }) {
|
|
277
355
|
this.config = config;
|
|
278
356
|
this.sessionId = sessionId ?? "__default__";
|
|
279
357
|
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
@@ -282,61 +360,22 @@ export class LocalAgentHost {
|
|
|
282
360
|
this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
|
|
283
361
|
this.injectedCommandRunner = commandRunner;
|
|
284
362
|
this.injectedAppServerClient = appServerClient;
|
|
285
|
-
|
|
286
|
-
this.
|
|
287
|
-
this.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// In forced exec mode (the default runtime's injected app-server is null) a
|
|
294
|
-
// retry budget must NOT fork a composite backend: there is no app-server to
|
|
295
|
-
// carry the retry env, and forking one would spawn a real app-server and
|
|
296
|
-
// defeat the forced-exec override. Fall back to the base key — codex retry is
|
|
297
|
-
// then a no-op in exec mode (consistent with the broader codex-retry caveat).
|
|
298
|
-
const execMode = runtime === this.defaultRuntime && this.injectedAppServerClient === null;
|
|
299
|
-
const retryEnv = execMode ? undefined : codexRetryEnv(runtime, budget);
|
|
300
|
-
const key = retryEnv ? `${runtime}::${hashRetryEnv(retryEnv)}` : runtime;
|
|
301
|
-
const existing = this.backends.get(key);
|
|
302
|
-
if (existing) {
|
|
303
|
-
existing.lastUsedAt = this.clock();
|
|
363
|
+
const defaultAttachedClientFactory = (appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) });
|
|
364
|
+
this.forwarderClientFactory = forwarderClientFactory ?? defaultAttachedClientFactory;
|
|
365
|
+
this.preloadClientFactory = preloadClientFactory ?? defaultAttachedClientFactory;
|
|
366
|
+
this.injectionClientFactory = injectionClientFactory ?? defaultAttachedClientFactory;
|
|
367
|
+
}
|
|
368
|
+
getBackend(runtime) {
|
|
369
|
+
const existing = this.backends.get(runtime);
|
|
370
|
+
if (existing)
|
|
304
371
|
return existing;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
backend.lastUsedAt = this.clock();
|
|
308
|
-
this.backends.set(key, backend);
|
|
372
|
+
const backend = this.createBackend(runtime);
|
|
373
|
+
this.backends.set(runtime, backend);
|
|
309
374
|
return backend;
|
|
310
375
|
}
|
|
311
|
-
|
|
312
|
-
* Stop + drop per-budget composite backends that have been idle longer than
|
|
313
|
-
* the TTL and have no in-flight run. Base per-runtime backends (key === the
|
|
314
|
-
* bare runtime id) are never reaped — they live for the process lifetime, as
|
|
315
|
-
* before. There are no per-conversation close hooks, so this lazy sweep (run
|
|
316
|
-
* on every `getBackend`) is how dedicated app-servers get reclaimed.
|
|
317
|
-
*/
|
|
318
|
-
reapIdleBackends() {
|
|
319
|
-
const now = this.clock();
|
|
320
|
-
for (const [key, backend] of this.backends) {
|
|
321
|
-
if (!key.includes("::")) {
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
if ((backend.inFlight ?? 0) > 0 || backend.lastUsedAt === undefined) {
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
327
|
-
if (now - backend.lastUsedAt < this.backendIdleTtlMs) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
void backend.appServerClient?.stop();
|
|
331
|
-
this.backends.delete(key);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
createBackend(runtime, retryEnv) {
|
|
376
|
+
createBackend(runtime) {
|
|
335
377
|
const profile = getRuntimeProfile(runtime);
|
|
336
|
-
|
|
337
|
-
// fresh app-server with the retry env, never the test-injected default deps.
|
|
338
|
-
const isComposite = retryEnv !== undefined;
|
|
339
|
-
const isDefault = runtime === this.defaultRuntime && !isComposite;
|
|
378
|
+
const isDefault = runtime === this.defaultRuntime;
|
|
340
379
|
const commandRunner = (isDefault && this.injectedCommandRunner) ||
|
|
341
380
|
new SpawnCodexCommandRunner(resolveRuntimeBinary(runtime));
|
|
342
381
|
// The claude runtime is not codex-lineage: it has no app-server (its live
|
|
@@ -351,7 +390,7 @@ export class LocalAgentHost {
|
|
|
351
390
|
? this.injectedAppServerClient === null
|
|
352
391
|
? null
|
|
353
392
|
: this.injectedAppServerClient ?? this.createAppServerClient(runtime)
|
|
354
|
-
: this.createAppServerClient(runtime
|
|
393
|
+
: this.createAppServerClient(runtime);
|
|
355
394
|
return { runtime, profile, appServerClient, commandRunner };
|
|
356
395
|
}
|
|
357
396
|
/** This session's private native home, shared by its app-server + TUI. */
|
|
@@ -365,7 +404,7 @@ export class LocalAgentHost {
|
|
|
365
404
|
this.runtimeHomes.set(runtime, prepared);
|
|
366
405
|
return prepared;
|
|
367
406
|
}
|
|
368
|
-
createAppServerClient(runtime
|
|
407
|
+
createAppServerClient(runtime) {
|
|
369
408
|
const cliPath = resolveRuntimeBinary(runtime);
|
|
370
409
|
// Per-agent knobs (resolved at live-session start) win over the daemon
|
|
371
410
|
// default — the app-server is private to this session's agent.
|
|
@@ -381,8 +420,8 @@ export class LocalAgentHost {
|
|
|
381
420
|
const channel = new WsRpcChannel({
|
|
382
421
|
cliPath,
|
|
383
422
|
baseArgs: buildAppServerBaseArgs(sandbox),
|
|
423
|
+
stateDir: runtimeSessionStateDir(this.runtimeHomeSessionId),
|
|
384
424
|
extraEnv: {
|
|
385
|
-
...(extraEnv ?? {}),
|
|
386
425
|
[profile.homeEnvVar]: this.runtimeHome(runtime),
|
|
387
426
|
},
|
|
388
427
|
});
|
|
@@ -403,36 +442,81 @@ export class LocalAgentHost {
|
|
|
403
442
|
if (live.settledInteractions.has(interactionId)) {
|
|
404
443
|
return { disposition: "already_resolved" };
|
|
405
444
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
const synthetic = live.syntheticInteractions.get(interactionId);
|
|
446
|
+
if (synthetic)
|
|
447
|
+
return synthetic.resolve(resolution);
|
|
448
|
+
return live.forwarderClient.resolveInteraction(interactionId, resolution);
|
|
449
|
+
}
|
|
450
|
+
/** Apply collaboration mode to an already-loaded Codex-lineage thread. The
|
|
451
|
+
* caller persists only after this native RPC succeeds. */
|
|
452
|
+
async updateCollaborationMode(localThreadId, mode) {
|
|
453
|
+
const live = this.liveSessions.get(localThreadId);
|
|
454
|
+
if (!live || live.stopped || live.rotationPending) {
|
|
455
|
+
throw new CodexRuntimeError("Native Session is not live", 409, "native_session_not_live");
|
|
411
456
|
}
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const previous = live.interactionSubmissions.get(interactionId);
|
|
418
|
-
live.interactionSubmissions.set(interactionId, resolution);
|
|
419
|
-
const result = client.resolveInteraction(interactionId, resolution);
|
|
420
|
-
if (result.disposition !== "applied") {
|
|
421
|
-
if (hadPrevious && previous)
|
|
422
|
-
live.interactionSubmissions.set(interactionId, previous);
|
|
423
|
-
else
|
|
424
|
-
live.interactionSubmissions.delete(interactionId);
|
|
457
|
+
const run = live.injectLock.then(async () => {
|
|
458
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
459
|
+
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
460
|
+
if (!bound || !threadId) {
|
|
461
|
+
throw new CodexRuntimeError("Native thread is not ready", 503, "native_thread_not_ready");
|
|
425
462
|
}
|
|
426
|
-
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
463
|
+
await this.applyLiveCollaborationMode(live, threadId, mode);
|
|
464
|
+
});
|
|
465
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
466
|
+
await run;
|
|
467
|
+
}
|
|
468
|
+
/** Apply one explicit collaboration mode to the current native thread and
|
|
469
|
+
* remember only a successful RPC. A supplied client is already initialized
|
|
470
|
+
* and remains owned by its caller (used by cold-resume preload). */
|
|
471
|
+
async applyLiveCollaborationMode(live, threadId, mode, suppliedClient) {
|
|
472
|
+
await this.applyLiveThreadSettings(live, threadId, mode, suppliedClient);
|
|
473
|
+
}
|
|
474
|
+
/** Apply pending explicit model/effort settings and, when requested, one
|
|
475
|
+
* collaboration mode in a single native update. */
|
|
476
|
+
async applyLiveThreadSettings(live, threadId, mode, suppliedClient) {
|
|
477
|
+
const client = suppliedClient ?? this.injectionClientFactory(live.appServerUrl);
|
|
478
|
+
try {
|
|
479
|
+
if (!suppliedClient)
|
|
480
|
+
await client.ensureInitialized();
|
|
481
|
+
const desiredModel = live.model;
|
|
482
|
+
const desiredReasoningEffort = live.reasoningEffort;
|
|
483
|
+
const modelChanged = desiredModel !== live.appliedModel;
|
|
484
|
+
const effortChanged = desiredReasoningEffort !== live.appliedReasoningEffort;
|
|
485
|
+
const collaborationMode = mode && live.appliedCollaborationMode !== mode
|
|
486
|
+
? await resolveLiveCollaborationMode(client, live, mode, live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined)
|
|
487
|
+
: undefined;
|
|
488
|
+
const settings = {
|
|
489
|
+
threadId,
|
|
490
|
+
...(modelChanged ? { model: desiredModel || null } : {}),
|
|
491
|
+
...(effortChanged
|
|
492
|
+
? { effort: desiredReasoningEffort ?? null }
|
|
493
|
+
: {}),
|
|
494
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
495
|
+
};
|
|
496
|
+
if (Object.keys(settings).length === 1)
|
|
497
|
+
return;
|
|
498
|
+
await client.threadSettingsUpdate(settings);
|
|
499
|
+
if (collaborationMode) {
|
|
500
|
+
if (!live.model)
|
|
501
|
+
live.model = collaborationMode.settings.model;
|
|
502
|
+
live.mayUseProviderDefaultModel = false;
|
|
503
|
+
live.appliedModel = collaborationMode.settings.model;
|
|
504
|
+
live.appliedReasoningEffort =
|
|
505
|
+
collaborationMode.settings.reasoning_effort ?? undefined;
|
|
506
|
+
live.appliedCollaborationMode = mode;
|
|
507
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
if (modelChanged)
|
|
511
|
+
live.appliedModel = desiredModel;
|
|
512
|
+
if (effortChanged)
|
|
513
|
+
live.appliedReasoningEffort = desiredReasoningEffort;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
if (!suppliedClient)
|
|
518
|
+
await client.stop().catch(() => undefined);
|
|
434
519
|
}
|
|
435
|
-
return submit(live.forwarderClient);
|
|
436
520
|
}
|
|
437
521
|
/**
|
|
438
522
|
* The command to run in a session's live terminal so it co-drives the codex
|
|
@@ -455,7 +539,7 @@ export class LocalAgentHost {
|
|
|
455
539
|
throw new CodexRuntimeError("session snapshots are not live", 409, "session_not_live");
|
|
456
540
|
}
|
|
457
541
|
const runtime = live.runtime;
|
|
458
|
-
const backend = this.getBackend(runtime
|
|
542
|
+
const backend = this.getBackend(runtime);
|
|
459
543
|
const client = backend.appServerClient;
|
|
460
544
|
if (!client) {
|
|
461
545
|
throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
|
|
@@ -475,6 +559,10 @@ export class LocalAgentHost {
|
|
|
475
559
|
const traexYolo = runtime === "traex" &&
|
|
476
560
|
sandbox === "danger-full-access" &&
|
|
477
561
|
approvalPolicy === "never";
|
|
562
|
+
// Runner-owned Codex terminals start detached, so nobody can answer the
|
|
563
|
+
// interactive hook-review prompt. The private CODEX_HOME is provisioned
|
|
564
|
+
// before launch; use Codex's native bypass flag for this managed TUI.
|
|
565
|
+
const bypassHookTrust = runtime === "codex" || traexYolo;
|
|
478
566
|
const configOverrides = sandbox === "workspace-write"
|
|
479
567
|
? ["sandbox_workspace_write.network_access=true"]
|
|
480
568
|
: [];
|
|
@@ -492,21 +580,26 @@ export class LocalAgentHost {
|
|
|
492
580
|
const launchEffort = live.reasoningEffort;
|
|
493
581
|
if (launchEffort)
|
|
494
582
|
configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
|
|
495
|
-
if (live?.instructions) {
|
|
583
|
+
if (runtime === "traex" && live?.instructions) {
|
|
496
584
|
configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
|
|
497
585
|
}
|
|
498
586
|
const profile = getRuntimeProfile(runtime);
|
|
499
587
|
const activeThreadId = live?.threadId ?? record?.codexSessionId;
|
|
500
588
|
return {
|
|
589
|
+
lifecycle: "auxiliary",
|
|
501
590
|
command: resolveRuntimeBinary(runtime),
|
|
502
591
|
args: buildCodexRemoteArgs({
|
|
503
592
|
remoteUrl,
|
|
504
593
|
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
505
594
|
configOverrides,
|
|
506
|
-
codexArgs:
|
|
595
|
+
codexArgs: bypassHookTrust ? ["--dangerously-bypass-hook-trust"] : [],
|
|
507
596
|
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
508
597
|
}),
|
|
509
598
|
cwd: live.workspace.cwd,
|
|
599
|
+
scrollback: 100_000,
|
|
600
|
+
tmuxAllowPassthrough: true,
|
|
601
|
+
tmuxStartOnAttach: false,
|
|
602
|
+
keepAliveAfterExit: false,
|
|
510
603
|
...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
|
|
511
604
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
512
605
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
@@ -543,16 +636,21 @@ export class LocalAgentHost {
|
|
|
543
636
|
return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
|
|
544
637
|
}
|
|
545
638
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
546
|
-
// session (two would double-mirror every turn).
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
// the thread id instead of launching a bare `codex --remote` pane.
|
|
639
|
+
// session (two would double-mirror every turn). startLiveCodexSession
|
|
640
|
+
// publishes its partially initialized session before thread/start completes,
|
|
641
|
+
// so wait for that startup and then adopt any newer mutable Turn settings.
|
|
550
642
|
const inflight = this.liveEnsuring.get(localThreadId);
|
|
551
|
-
if (inflight)
|
|
552
|
-
|
|
643
|
+
if (inflight) {
|
|
644
|
+
if (!await inflight)
|
|
645
|
+
return false;
|
|
646
|
+
const startedLive = this.liveSessions.get(localThreadId);
|
|
647
|
+
return startedLive
|
|
648
|
+
? updateLiveCodexTurnSettings(startedLive, snapshotOpts)
|
|
649
|
+
: false;
|
|
650
|
+
}
|
|
553
651
|
const live = this.liveSessions.get(localThreadId);
|
|
554
652
|
if (live)
|
|
555
|
-
return
|
|
653
|
+
return updateLiveCodexTurnSettings(live, snapshotOpts);
|
|
556
654
|
const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
|
|
557
655
|
this.liveEnsuring.set(localThreadId, started);
|
|
558
656
|
try {
|
|
@@ -563,6 +661,7 @@ export class LocalAgentHost {
|
|
|
563
661
|
}
|
|
564
662
|
}
|
|
565
663
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
664
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
566
665
|
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
567
666
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
568
667
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
@@ -571,6 +670,7 @@ export class LocalAgentHost {
|
|
|
571
670
|
this.stopLiveCodexSession(localThreadId);
|
|
572
671
|
}
|
|
573
672
|
const record = await this.sessionStore.get(localThreadId);
|
|
673
|
+
const coldResume = Boolean(record?.codexSessionId);
|
|
574
674
|
const retargetMirror = opts.retargetMirror;
|
|
575
675
|
const workspace = structuredClone(opts.workspace);
|
|
576
676
|
const execution = structuredClone(opts.execution);
|
|
@@ -586,18 +686,34 @@ export class LocalAgentHost {
|
|
|
586
686
|
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
587
687
|
}
|
|
588
688
|
catch (err) {
|
|
689
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_preparation_failed", "skill preparation failed", err));
|
|
589
690
|
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
691
|
return false;
|
|
591
692
|
}
|
|
592
693
|
}
|
|
593
|
-
// Persist
|
|
594
|
-
// the owner's immutable
|
|
694
|
+
// Persist headless project trust before the app-server boots. Fork targets
|
|
695
|
+
// reuse the owner's immutable Provider home.
|
|
595
696
|
const liveSkills = snapshotSkills.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
697
|
+
const runtimeHome = this.runtimeHome(runtime);
|
|
698
|
+
if (runtime === "codex") {
|
|
699
|
+
try {
|
|
700
|
+
trustCodexProject(runtimeHome, workspace.cwd);
|
|
701
|
+
syncCodexDeveloperInstructions(runtimeHome, nativeAuthoredInstructions(execution));
|
|
702
|
+
applyCodexProviderRetryPolicy(runtimeHome, execution.budget?.retry);
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
void snapshotSkills.skillsCleanup();
|
|
706
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_project_trust_failed", "project trust preparation failed", err));
|
|
707
|
+
console.error(`[session-snapshot] session=${localThreadId} project trust preparation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
596
711
|
try {
|
|
597
|
-
populateCodexSkills(
|
|
712
|
+
populateCodexSkills(runtimeHome, liveSkills);
|
|
598
713
|
}
|
|
599
714
|
catch (err) {
|
|
600
715
|
void snapshotSkills.skillsCleanup();
|
|
716
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_skill_persistence_failed", "skill persistence failed", err));
|
|
601
717
|
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
602
718
|
return false;
|
|
603
719
|
}
|
|
@@ -615,46 +731,99 @@ export class LocalAgentHost {
|
|
|
615
731
|
void snapshotSkills.skillsCleanup();
|
|
616
732
|
return false;
|
|
617
733
|
};
|
|
618
|
-
|
|
619
|
-
|
|
734
|
+
reapRuntimeProcessesForStateDir(runtimeSessionStateDir(this.runtimeHomeSessionId));
|
|
735
|
+
const backend = this.getBackend(runtime);
|
|
736
|
+
const appServerOwner = backend.appServerClient;
|
|
737
|
+
if (!appServerOwner) {
|
|
738
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_unavailable", "app-server is unavailable for this Session"));
|
|
620
739
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
621
740
|
return abandon();
|
|
622
741
|
}
|
|
742
|
+
let loginRequired;
|
|
743
|
+
if (!coldResume &&
|
|
744
|
+
runtime === "codex" &&
|
|
745
|
+
codexLaunchRequiresLogin(runtimeHome, createCodexChildEnv({ ...process.env, CODEX_HOME: runtimeHome }))) {
|
|
746
|
+
loginRequired = new CodexRuntimeError("Codex is not signed in and no configured Provider can run this Session, so the Terminal is waiting at the sign-in screen. Sign in from the Session Terminal, then send the message again.", 401, "login_required");
|
|
747
|
+
}
|
|
623
748
|
try {
|
|
624
|
-
await
|
|
749
|
+
await appServerOwner.ensureInitialized();
|
|
625
750
|
}
|
|
626
751
|
catch (err) {
|
|
752
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_start_failed", "app-server initialization failed during its 10s readiness phase", err));
|
|
627
753
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
628
754
|
return abandon();
|
|
629
755
|
}
|
|
630
|
-
|
|
756
|
+
// A fresh remote TUI creates the thread, so Rynx does not receive its
|
|
757
|
+
// `thread/start` response. Ask the already-initialized app-server for the
|
|
758
|
+
// effective model instead of parsing Traex's version-dependent YAML/TOML
|
|
759
|
+
// files or confusing `model/list.isDefault` with this Session's model.
|
|
760
|
+
let configModel;
|
|
761
|
+
let configReasoningEffort;
|
|
762
|
+
if (!coldResume && (!execution.model || !execution.reasoningEffort)) {
|
|
763
|
+
try {
|
|
764
|
+
const effective = await appServerOwner.configRead({
|
|
765
|
+
includeLayers: false,
|
|
766
|
+
cwd: workspace.cwd,
|
|
767
|
+
});
|
|
768
|
+
const reportedModel = effective.config.model;
|
|
769
|
+
if (typeof reportedModel === "string" && reportedModel.trim()) {
|
|
770
|
+
configModel = reportedModel.trim();
|
|
771
|
+
}
|
|
772
|
+
const reportedEffort = effective.config.model_reasoning_effort;
|
|
773
|
+
if (typeof reportedEffort === "string" && reportedEffort) {
|
|
774
|
+
configReasoningEffort = reportedEffort;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
// Explicit Session settings remain usable on an older Provider. If a
|
|
779
|
+
// requested collaboration mode later needs an unknown model, that
|
|
780
|
+
// update fails clearly without mutating the native thread.
|
|
781
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} effective config unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const appServerUrl = appServerOwner.terminalRemoteUrl();
|
|
631
785
|
if (!appServerUrl) {
|
|
786
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_app_server_endpoint_missing", "app-server started without a Terminal remote endpoint"));
|
|
632
787
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
633
788
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
634
789
|
}
|
|
635
790
|
// reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
|
|
636
791
|
// app-server, which `thread/resume`s to subscribe to the thread's item/turn
|
|
637
792
|
// notifications (the backend client injects; this one only observes).
|
|
793
|
+
// Fresh sessions must connect this listener before the TUI launches so its
|
|
794
|
+
// one-shot `thread/started` cannot race discovery. A known-thread cold resume
|
|
795
|
+
// already has its durable id: like reference implementation's `_codex_forward_known_thread`,
|
|
796
|
+
// its observer starts in the background only after the replacement TUI has
|
|
797
|
+
// launched and is never part of the resume admission boundary.
|
|
638
798
|
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
799
|
+
if (!coldResume) {
|
|
800
|
+
try {
|
|
801
|
+
await forwarderClient.ensureInitialized();
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
this.liveStartupErrors.set(localThreadId, nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach to the ready app-server", err));
|
|
805
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} fresh-thread listener init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
806
|
+
return abandon();
|
|
807
|
+
}
|
|
645
808
|
}
|
|
646
|
-
const model = execution.model;
|
|
647
|
-
const reasoningEffort = execution.reasoningEffort ??
|
|
809
|
+
const model = execution.model?.trim() || (!coldResume ? configModel : undefined) || "";
|
|
810
|
+
const reasoningEffort = execution.reasoningEffort ??
|
|
811
|
+
(!coldResume ? configReasoningEffort : undefined);
|
|
648
812
|
let markReady;
|
|
649
813
|
const ready = new Promise((resolve) => {
|
|
650
814
|
markReady = resolve;
|
|
651
815
|
});
|
|
816
|
+
let markStartupFailed;
|
|
817
|
+
const startupFailed = new Promise((resolve) => {
|
|
818
|
+
markStartupFailed = resolve;
|
|
819
|
+
});
|
|
652
820
|
let markTerminalReady;
|
|
653
821
|
const terminalReady = new Promise((resolve) => {
|
|
654
822
|
markTerminalReady = resolve;
|
|
655
823
|
});
|
|
656
824
|
const live = {
|
|
657
|
-
|
|
825
|
+
appServerOwner,
|
|
826
|
+
appServerUrl,
|
|
658
827
|
forwarderClient,
|
|
659
828
|
forwarder: null,
|
|
660
829
|
runtime,
|
|
@@ -664,27 +833,37 @@ export class LocalAgentHost {
|
|
|
664
833
|
approvalPolicy,
|
|
665
834
|
model,
|
|
666
835
|
reasoningEffort,
|
|
667
|
-
|
|
836
|
+
mayUseProviderDefaultModel: !coldResume && !model,
|
|
837
|
+
appliedModel: coldResume ? "" : model,
|
|
838
|
+
appliedReasoningEffort: coldResume ? undefined : reasoningEffort,
|
|
839
|
+
appliedCollaborationMode: undefined,
|
|
840
|
+
...(runtime === "traex" && execution.instructions
|
|
841
|
+
? { instructions: execution.instructions }
|
|
842
|
+
: {}),
|
|
668
843
|
threadId: record?.codexSessionId ?? null,
|
|
669
844
|
ready,
|
|
670
845
|
markReady,
|
|
846
|
+
startupFailed,
|
|
847
|
+
markStartupFailed,
|
|
848
|
+
...(loginRequired ? { startupError: loginRequired } : {}),
|
|
671
849
|
terminalReady,
|
|
672
850
|
markTerminalReady,
|
|
673
851
|
injectLock: Promise.resolve(),
|
|
674
852
|
pendingInjectedInputs: [],
|
|
675
|
-
|
|
853
|
+
publishTurnAdmission: () => undefined,
|
|
854
|
+
publishTurnAdmissionFailure: () => undefined,
|
|
855
|
+
publishInterrupted: () => undefined,
|
|
856
|
+
interruptedResponseId: null,
|
|
676
857
|
subscribing: false,
|
|
677
858
|
rotationPending: false,
|
|
678
859
|
stopped: false,
|
|
860
|
+
observerAvailable: !coldResume,
|
|
861
|
+
threadActive: false,
|
|
679
862
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
680
|
-
interactionOwners: new Map(),
|
|
681
|
-
interactionStandbys: new Map(),
|
|
682
|
-
interactionSubmissions: new Map(),
|
|
683
863
|
canonicalInteractions: new Set(),
|
|
684
864
|
settledInteractions: new Set(),
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
disconnectedClients: new Set(),
|
|
865
|
+
syntheticInteractions: new Map(),
|
|
866
|
+
cancelSyntheticInteractions: () => undefined,
|
|
688
867
|
};
|
|
689
868
|
let currentSessionId = localThreadId;
|
|
690
869
|
let pendingRotationEvents = null;
|
|
@@ -695,11 +874,23 @@ export class LocalAgentHost {
|
|
|
695
874
|
}
|
|
696
875
|
emit(event);
|
|
697
876
|
};
|
|
877
|
+
live.publishInterrupted = (responseId) => {
|
|
878
|
+
if (live.interruptedResponseId === responseId)
|
|
879
|
+
return;
|
|
880
|
+
live.interruptedResponseId = responseId;
|
|
881
|
+
emitCurrent({
|
|
882
|
+
type: "session.interrupted",
|
|
883
|
+
sessionId: currentSessionId,
|
|
884
|
+
responseId,
|
|
885
|
+
});
|
|
886
|
+
};
|
|
698
887
|
let normalizer = null;
|
|
699
888
|
let currentResponseId = null;
|
|
889
|
+
/** State for content-only events, keyed by Provider response so consecutive
|
|
890
|
+
* late deltas retain one canonical item id without owning lifecycle. */
|
|
891
|
+
const contentNormalizers = new Map();
|
|
700
892
|
const startNormalizer = (turnId) => {
|
|
701
|
-
const responseId =
|
|
702
|
-
(turnId ? `resp_codex_${turnId}` : "resp_codex_native");
|
|
893
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
703
894
|
if (normalizer && currentResponseId === responseId)
|
|
704
895
|
return normalizer;
|
|
705
896
|
currentResponseId = responseId;
|
|
@@ -709,26 +900,68 @@ export class LocalAgentHost {
|
|
|
709
900
|
// `turn/started` land on ONE response instead of splitting into random
|
|
710
901
|
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
711
902
|
responseId,
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
// label, so report the runtime when the exact model is unknown.
|
|
903
|
+
// A null Session model intentionally leaves selection to the Provider
|
|
904
|
+
// CLI. Canonical events still require a printable label.
|
|
715
905
|
model: model || runtime,
|
|
716
906
|
});
|
|
717
907
|
return normalizer;
|
|
718
908
|
};
|
|
719
|
-
live.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
909
|
+
live.publishTurnAdmission = () => {
|
|
910
|
+
emitCurrent({
|
|
911
|
+
type: "session.status",
|
|
912
|
+
sessionId: currentSessionId,
|
|
913
|
+
status: "running",
|
|
914
|
+
});
|
|
915
|
+
};
|
|
916
|
+
live.publishTurnAdmissionFailure = (message) => {
|
|
917
|
+
emitCurrent({
|
|
918
|
+
type: "session.status",
|
|
919
|
+
sessionId: currentSessionId,
|
|
920
|
+
status: "failed",
|
|
921
|
+
note: message,
|
|
922
|
+
});
|
|
726
923
|
};
|
|
727
924
|
const clearPendingInputsForResponse = (responseId) => {
|
|
728
925
|
if (!responseId)
|
|
729
926
|
return;
|
|
730
927
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
|
|
731
928
|
};
|
|
929
|
+
const turnContentEvents = (turnId, produce) => {
|
|
930
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
931
|
+
let contentNormalizer = contentNormalizers.get(responseId);
|
|
932
|
+
if (!contentNormalizer) {
|
|
933
|
+
contentNormalizer = new SessionNormalizer({
|
|
934
|
+
sessionId: currentSessionId,
|
|
935
|
+
responseId,
|
|
936
|
+
model: live.model || live.runtime,
|
|
937
|
+
});
|
|
938
|
+
contentNormalizers.set(responseId, contentNormalizer);
|
|
939
|
+
}
|
|
940
|
+
for (const event of produce(contentNormalizer)) {
|
|
941
|
+
// Item/transient content is attached to the Turn's response id
|
|
942
|
+
// independently of lifecycle status. Reuse Rynx's canonical item
|
|
943
|
+
// normalization but suppress its synthetic lifecycle edges.
|
|
944
|
+
if (event.type !== "response.created" && event.type !== "session.status") {
|
|
945
|
+
emitCurrent(event);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
const observedUserContent = (content) => {
|
|
950
|
+
const normalizedContent = typeof content === "string"
|
|
951
|
+
? [{ type: "input_text", text: content }]
|
|
952
|
+
: content;
|
|
953
|
+
// The Provider does not echo a caller id and may reformat the text, so the
|
|
954
|
+
// first native user record drains the oldest injected input instead of
|
|
955
|
+
// comparing message text.
|
|
956
|
+
const pending = live.pendingInjectedInputs[0];
|
|
957
|
+
if (pending) {
|
|
958
|
+
pending.observed = true;
|
|
959
|
+
if (pending.state === "accepted") {
|
|
960
|
+
live.pendingInjectedInputs.shift();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return normalizedContent;
|
|
964
|
+
};
|
|
732
965
|
const rememberSettledInteraction = (interactionId) => {
|
|
733
966
|
live.settledInteractions.add(interactionId);
|
|
734
967
|
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
@@ -737,11 +970,174 @@ export class LocalAgentHost {
|
|
|
737
970
|
if (oldest)
|
|
738
971
|
live.settledInteractions.delete(oldest);
|
|
739
972
|
};
|
|
973
|
+
const cancelSyntheticInteractions = (reason) => {
|
|
974
|
+
for (const interaction of live.syntheticInteractions.values())
|
|
975
|
+
interaction.cancel(reason);
|
|
976
|
+
};
|
|
977
|
+
live.cancelSyntheticInteractions = cancelSyntheticInteractions;
|
|
978
|
+
const publishPlanImplementationPrompt = (prompt) => {
|
|
979
|
+
const suffix = createHash("sha256")
|
|
980
|
+
.update(`${prompt.threadId}\0${prompt.turnId}`)
|
|
981
|
+
.digest("hex")
|
|
982
|
+
.slice(0, 24);
|
|
983
|
+
const interactionId = `codex_plan_implementation_${suffix}`;
|
|
984
|
+
if (live.syntheticInteractions.has(interactionId) ||
|
|
985
|
+
live.settledInteractions.has(interactionId))
|
|
986
|
+
return;
|
|
987
|
+
const responseId = `resp_codex_plan_approval_${suffix}`;
|
|
988
|
+
const request = codexPlanImplementationRequest(interactionId);
|
|
989
|
+
const interactionNormalizer = new SessionNormalizer({
|
|
990
|
+
sessionId: currentSessionId,
|
|
991
|
+
responseId,
|
|
992
|
+
model: live.model || live.runtime,
|
|
993
|
+
});
|
|
994
|
+
let submitted = false;
|
|
995
|
+
const finish = (kind, resolution, reason) => {
|
|
996
|
+
if (!live.syntheticInteractions.delete(interactionId))
|
|
997
|
+
return;
|
|
998
|
+
rememberSettledInteraction(interactionId);
|
|
999
|
+
const event = kind === "resolved"
|
|
1000
|
+
? {
|
|
1001
|
+
type: "interaction_resolved",
|
|
1002
|
+
interactionId,
|
|
1003
|
+
resolution: resolution,
|
|
1004
|
+
}
|
|
1005
|
+
: {
|
|
1006
|
+
type: "interaction_cancelled",
|
|
1007
|
+
interactionId,
|
|
1008
|
+
...(reason ? { reason } : {}),
|
|
1009
|
+
};
|
|
1010
|
+
// This interaction belongs to the Session that produced the Plan. A
|
|
1011
|
+
// clear-context choice can rotate the native thread before turn/start
|
|
1012
|
+
// returns, so never retarget its resolution to the new Session.
|
|
1013
|
+
for (const sessionEvent of interactionNormalizer.next(event))
|
|
1014
|
+
emit(sessionEvent);
|
|
1015
|
+
for (const sessionEvent of interactionNormalizer.next({ type: "done" })) {
|
|
1016
|
+
emit(sessionEvent);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
live.syntheticInteractions.set(interactionId, {
|
|
1020
|
+
cancel: (reason) => {
|
|
1021
|
+
// The selected implementation itself starts a native Turn and the
|
|
1022
|
+
// clear-context variant also starts a new thread. Their observer
|
|
1023
|
+
// notifications can beat the short-lived RPC response; they are not
|
|
1024
|
+
// competing user actions and must not cancel the card being resolved.
|
|
1025
|
+
if (submitted &&
|
|
1026
|
+
(reason === "superseded_by_native_turn" || reason === "native_thread_rotated"))
|
|
1027
|
+
return;
|
|
1028
|
+
finish("cancelled", undefined, reason);
|
|
1029
|
+
},
|
|
1030
|
+
resolve: async (resolution) => {
|
|
1031
|
+
if (submitted)
|
|
1032
|
+
return { disposition: "already_resolved" };
|
|
1033
|
+
const invalid = validateInteractionResolution(request, resolution);
|
|
1034
|
+
if (invalid)
|
|
1035
|
+
return { disposition: "invalid", message: invalid };
|
|
1036
|
+
const choice = resolution.answers?.plan_implementation;
|
|
1037
|
+
if (typeof choice !== "string") {
|
|
1038
|
+
return { disposition: "invalid", message: "plan implementation choice is required" };
|
|
1039
|
+
}
|
|
1040
|
+
submitted = true;
|
|
1041
|
+
const run = live.injectLock.then(async () => {
|
|
1042
|
+
if (live.stopped || live.rotationPending) {
|
|
1043
|
+
throw new CodexRuntimeError("Native Session is not available for plan implementation", 409, "native_session_not_live");
|
|
1044
|
+
}
|
|
1045
|
+
const promptDismissal = await dismissCodexPlanImplementationPrompt(live.injector, () => !live.stopped &&
|
|
1046
|
+
!live.rotationPending &&
|
|
1047
|
+
live.syntheticInteractions.has(interactionId) &&
|
|
1048
|
+
live.forwarder.threadId() === prompt.threadId &&
|
|
1049
|
+
!live.forwarder.isTurnOpen());
|
|
1050
|
+
if (promptDismissal === "failed") {
|
|
1051
|
+
console.warn(`[codex-live] session=${currentSessionId} native Plan picker remained visible after the Web decision; continuing through the app-server`);
|
|
1052
|
+
}
|
|
1053
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_NO) {
|
|
1054
|
+
finish("resolved", resolution);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const client = this.injectionClientFactory(live.appServerUrl);
|
|
1058
|
+
try {
|
|
1059
|
+
await client.ensureInitialized();
|
|
1060
|
+
const collaborationMode = await resolveLiveCollaborationMode(client, live, "default", live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined);
|
|
1061
|
+
let threadId = prompt.threadId;
|
|
1062
|
+
let text = CODEX_PLAN_IMPLEMENTATION_CODING_MESSAGE;
|
|
1063
|
+
if (choice === CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT) {
|
|
1064
|
+
live.nextThreadCollaborationMode = "default";
|
|
1065
|
+
let startedThread;
|
|
1066
|
+
try {
|
|
1067
|
+
startedThread = await client.threadStart({
|
|
1068
|
+
...(live.model ? { model: live.model } : {}),
|
|
1069
|
+
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1070
|
+
approvalPolicy: live.approvalPolicy,
|
|
1071
|
+
...(live.instructions ? { developerInstructions: live.instructions } : {}),
|
|
1072
|
+
sessionStartSource: "clear",
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1077
|
+
throw error;
|
|
1078
|
+
}
|
|
1079
|
+
threadId = startedThread.threadId;
|
|
1080
|
+
text = `${CODEX_PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX}\n\n${prompt.text}`;
|
|
1081
|
+
}
|
|
1082
|
+
else if (choice !== CODEX_PLAN_IMPLEMENTATION_YES) {
|
|
1083
|
+
throw new CodexRuntimeError("Unknown plan implementation choice", 400, "invalid_interaction_resolution");
|
|
1084
|
+
}
|
|
1085
|
+
const started = await client.turnStart({
|
|
1086
|
+
threadId,
|
|
1087
|
+
input: [{ type: "text", text }],
|
|
1088
|
+
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1089
|
+
approvalPolicy: live.approvalPolicy,
|
|
1090
|
+
collaborationMode,
|
|
1091
|
+
});
|
|
1092
|
+
if (!live.model)
|
|
1093
|
+
live.model = collaborationMode.settings.model;
|
|
1094
|
+
live.mayUseProviderDefaultModel = false;
|
|
1095
|
+
live.appliedModel = collaborationMode.settings.model;
|
|
1096
|
+
live.appliedReasoningEffort =
|
|
1097
|
+
collaborationMode.settings.reasoning_effort ?? undefined;
|
|
1098
|
+
live.execution = { ...live.execution, collaborationMode: "default" };
|
|
1099
|
+
live.appliedCollaborationMode = "default";
|
|
1100
|
+
emitCurrent({
|
|
1101
|
+
type: "session.collaboration_mode",
|
|
1102
|
+
sessionId: currentSessionId,
|
|
1103
|
+
mode: "default",
|
|
1104
|
+
});
|
|
1105
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1106
|
+
finish("resolved", resolution);
|
|
1107
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
await client.stop().catch(() => undefined);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
1113
|
+
try {
|
|
1114
|
+
await run;
|
|
1115
|
+
return { disposition: "applied" };
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
submitted = false;
|
|
1119
|
+
return {
|
|
1120
|
+
disposition: "invalid",
|
|
1121
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
for (const sessionEvent of interactionNormalizer.next({
|
|
1127
|
+
type: "interaction_requested",
|
|
1128
|
+
interaction: request,
|
|
1129
|
+
}))
|
|
1130
|
+
emit(sessionEvent);
|
|
1131
|
+
};
|
|
740
1132
|
const forwardInteraction = (event) => {
|
|
741
1133
|
const interactionId = event.type === "requested"
|
|
742
1134
|
? event.request.interactionId
|
|
743
1135
|
: event.interactionId;
|
|
744
1136
|
if (event.type === "requested") {
|
|
1137
|
+
if (event.request.fields.some((field) => field.id === "plan_implementation")) {
|
|
1138
|
+
live.forwarder.noteNativePlanImplementationPrompt(event.turnId);
|
|
1139
|
+
cancelSyntheticInteractions("superseded_by_native_plan_prompt");
|
|
1140
|
+
}
|
|
745
1141
|
if (live.canonicalInteractions.has(interactionId) ||
|
|
746
1142
|
live.settledInteractions.has(interactionId))
|
|
747
1143
|
return;
|
|
@@ -773,223 +1169,179 @@ export class LocalAgentHost {
|
|
|
773
1169
|
for (const se of n.next(agentEvent))
|
|
774
1170
|
emitCurrent(se);
|
|
775
1171
|
};
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
};
|
|
779
|
-
const clearInteractionRouting = (interactionId) => {
|
|
780
|
-
clearRecovery(interactionId);
|
|
781
|
-
live.interactionOwners.delete(interactionId);
|
|
782
|
-
live.interactionStandbys.delete(interactionId);
|
|
783
|
-
live.interactionSubmissions.delete(interactionId);
|
|
784
|
-
live.interactionFailedClients.delete(interactionId);
|
|
785
|
-
};
|
|
786
|
-
const markInteractionClientFailed = (interactionId, client) => {
|
|
787
|
-
const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
|
|
788
|
-
failed.add(client);
|
|
789
|
-
live.interactionFailedClients.set(interactionId, failed);
|
|
790
|
-
};
|
|
791
|
-
const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
|
|
792
|
-
live.interactionFailedClients.get(interactionId)?.has(client) === true;
|
|
793
|
-
const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
|
|
794
|
-
const finishRecovery = (interactionId, event) => {
|
|
795
|
-
clearInteractionRouting(interactionId);
|
|
796
|
-
forwardInteraction(event);
|
|
797
|
-
};
|
|
798
|
-
const beginRecovery = (interactionId, event) => {
|
|
799
|
-
live.interactionOwners.delete(interactionId);
|
|
800
|
-
live.interactionRecoveries.set(interactionId, { event });
|
|
801
|
-
};
|
|
802
|
-
const promoteInteractionOwner = (interactionId, replacement) => {
|
|
803
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
804
|
-
live.interactionOwners.set(interactionId, replacement);
|
|
805
|
-
const submission = live.interactionSubmissions.get(interactionId);
|
|
806
|
-
if (!submission) {
|
|
807
|
-
clearRecovery(interactionId);
|
|
808
|
-
return true;
|
|
809
|
-
}
|
|
810
|
-
const replayed = replacement.resolveInteraction(interactionId, submission);
|
|
811
|
-
// A write failure can synchronously re-enter the listener and remove this
|
|
812
|
-
// owner. Only clear recovery when the replacement still owns the request.
|
|
813
|
-
if (replayed.disposition === "applied" &&
|
|
814
|
-
live.interactionOwners.get(interactionId) === replacement) {
|
|
815
|
-
clearRecovery(interactionId);
|
|
816
|
-
return true;
|
|
817
|
-
}
|
|
818
|
-
if (live.interactionOwners.get(interactionId) === replacement) {
|
|
819
|
-
live.interactionOwners.delete(interactionId);
|
|
820
|
-
}
|
|
821
|
-
if (recovery &&
|
|
822
|
-
live.canonicalInteractions.has(interactionId) &&
|
|
823
|
-
!live.settledInteractions.has(interactionId) &&
|
|
824
|
-
!live.interactionRecoveries.has(interactionId)) {
|
|
825
|
-
live.interactionRecoveries.set(interactionId, recovery);
|
|
826
|
-
}
|
|
827
|
-
return false;
|
|
828
|
-
};
|
|
829
|
-
const bindInteractionClient = (client) => {
|
|
830
|
-
client.setInteractionListener((event) => {
|
|
831
|
-
const interactionId = event.type === "requested"
|
|
832
|
-
? event.request.interactionId
|
|
833
|
-
: event.interactionId;
|
|
834
|
-
const owner = live.interactionOwners.get(interactionId);
|
|
835
|
-
if (event.type === "requested") {
|
|
836
|
-
// Receiving a native request proves this connection is alive and can
|
|
837
|
-
// be retried even if an earlier copy failed on it.
|
|
838
|
-
live.disconnectedClients.delete(client);
|
|
839
|
-
const failed = live.interactionFailedClients.get(interactionId);
|
|
840
|
-
failed?.delete(client);
|
|
841
|
-
if (failed?.size === 0)
|
|
842
|
-
live.interactionFailedClients.delete(interactionId);
|
|
843
|
-
if (live.settledInteractions.has(interactionId))
|
|
844
|
-
return;
|
|
845
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
846
|
-
if (recovery) {
|
|
847
|
-
promoteInteractionOwner(interactionId, client);
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
if (owner && owner !== client) {
|
|
851
|
-
const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
|
|
852
|
-
standbys.add(client);
|
|
853
|
-
live.interactionStandbys.set(interactionId, standbys);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
live.interactionOwners.set(interactionId, client);
|
|
857
|
-
forwardInteraction(event);
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
const recoverableOwnerFailure = event.type === "cancelled" &&
|
|
861
|
-
(event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
|
|
862
|
-
if (recoverableOwnerFailure)
|
|
863
|
-
markInteractionClientFailed(interactionId, client);
|
|
864
|
-
if (!owner || owner !== client) {
|
|
865
|
-
const standbys = live.interactionStandbys.get(interactionId);
|
|
866
|
-
standbys?.delete(client);
|
|
867
|
-
if (standbys?.size === 0)
|
|
868
|
-
live.interactionStandbys.delete(interactionId);
|
|
869
|
-
const recovery = live.interactionRecoveries.get(interactionId);
|
|
870
|
-
if (recoverableOwnerFailure &&
|
|
871
|
-
live.canonicalInteractions.has(interactionId) &&
|
|
872
|
-
allInteractionClientsUnavailable(interactionId)) {
|
|
873
|
-
finishRecovery(interactionId, recovery?.event ?? event);
|
|
874
|
-
}
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
if (recoverableOwnerFailure) {
|
|
878
|
-
const standbys = live.interactionStandbys.get(interactionId);
|
|
879
|
-
while (standbys && standbys.size > 0) {
|
|
880
|
-
const replacement = standbys.values().next().value;
|
|
881
|
-
if (!replacement)
|
|
882
|
-
break;
|
|
883
|
-
standbys.delete(replacement);
|
|
884
|
-
if (interactionClientUnavailable(interactionId, replacement))
|
|
885
|
-
continue;
|
|
886
|
-
if (promoteInteractionOwner(interactionId, replacement)) {
|
|
887
|
-
if (standbys.size === 0)
|
|
888
|
-
live.interactionStandbys.delete(interactionId);
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
if (live.settledInteractions.has(interactionId))
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
if (!live.settledInteractions.has(interactionId)) {
|
|
895
|
-
if (allInteractionClientsUnavailable(interactionId)) {
|
|
896
|
-
finishRecovery(interactionId, event);
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
beginRecovery(interactionId, event);
|
|
900
|
-
}
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
clearInteractionRouting(interactionId);
|
|
905
|
-
forwardInteraction(event);
|
|
906
|
-
});
|
|
1172
|
+
const bindObserverClient = (client) => {
|
|
1173
|
+
client.setInteractionListener(forwardInteraction);
|
|
907
1174
|
client.setConnectionListener((state) => {
|
|
908
|
-
if (state === "connected")
|
|
909
|
-
live.disconnectedClients.delete(client);
|
|
1175
|
+
if (state === "connected" || live.stopped)
|
|
910
1176
|
return;
|
|
1177
|
+
live.observerAvailable = false;
|
|
1178
|
+
for (const interactionId of [...live.canonicalInteractions]) {
|
|
1179
|
+
forwardInteraction({
|
|
1180
|
+
type: "cancelled",
|
|
1181
|
+
interactionId,
|
|
1182
|
+
reason: "app_server_disconnected",
|
|
1183
|
+
});
|
|
911
1184
|
}
|
|
912
|
-
|
|
913
|
-
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
914
|
-
if (allInteractionClientsUnavailable(interactionId)) {
|
|
915
|
-
finishRecovery(interactionId, recovery.event);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
1185
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(runtime, "native_observer_disconnected", "observer connection closed"));
|
|
918
1186
|
});
|
|
919
1187
|
};
|
|
920
1188
|
const closeCanonicalInteractions = () => {
|
|
921
1189
|
for (const interactionId of live.canonicalInteractions) {
|
|
922
1190
|
rememberSettledInteraction(interactionId);
|
|
923
|
-
clearInteractionRouting(interactionId);
|
|
924
1191
|
}
|
|
925
1192
|
live.canonicalInteractions.clear();
|
|
926
1193
|
};
|
|
1194
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
1195
|
+
closeCanonicalInteractions();
|
|
1196
|
+
if (!normalizer)
|
|
1197
|
+
return;
|
|
1198
|
+
const completedResponseId = currentResponseId;
|
|
1199
|
+
if (usage) {
|
|
1200
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1201
|
+
emitCurrent(se);
|
|
1202
|
+
}
|
|
1203
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
1204
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
1205
|
+
continue;
|
|
1206
|
+
emitCurrent(se);
|
|
1207
|
+
}
|
|
1208
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
1209
|
+
normalizer = null;
|
|
1210
|
+
currentResponseId = null;
|
|
1211
|
+
};
|
|
1212
|
+
const failCurrentTurn = (error) => {
|
|
1213
|
+
closeCanonicalInteractions();
|
|
1214
|
+
if (!normalizer)
|
|
1215
|
+
return;
|
|
1216
|
+
const failedResponseId = currentResponseId;
|
|
1217
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
1218
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
1219
|
+
const message = responseStopped
|
|
1220
|
+
? "Response stopped"
|
|
1221
|
+
: runtime === "traex"
|
|
1222
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
1223
|
+
: error.message;
|
|
1224
|
+
for (const se of normalizer.fail({
|
|
1225
|
+
code: responseStopped
|
|
1226
|
+
? "response_stopped"
|
|
1227
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
1228
|
+
message,
|
|
1229
|
+
source: "execution",
|
|
1230
|
+
})) {
|
|
1231
|
+
emitCurrent(se.type === "session.status"
|
|
1232
|
+
? { ...se, status: "failed", note: message }
|
|
1233
|
+
: se);
|
|
1234
|
+
}
|
|
1235
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
1236
|
+
normalizer = null;
|
|
1237
|
+
currentResponseId = null;
|
|
1238
|
+
};
|
|
1239
|
+
const interruptCurrentTurn = () => {
|
|
1240
|
+
closeCanonicalInteractions();
|
|
1241
|
+
if (!normalizer)
|
|
1242
|
+
return;
|
|
1243
|
+
const interruptedResponseId = currentResponseId;
|
|
1244
|
+
for (const se of normalizer.interrupt()) {
|
|
1245
|
+
if (se.type === "session.interrupted" &&
|
|
1246
|
+
live.interruptedResponseId === interruptedResponseId)
|
|
1247
|
+
continue;
|
|
1248
|
+
emitCurrent(se);
|
|
1249
|
+
}
|
|
1250
|
+
live.interruptedResponseId = null;
|
|
1251
|
+
clearPendingInputsForResponse(interruptedResponseId);
|
|
1252
|
+
normalizer = null;
|
|
1253
|
+
currentResponseId = null;
|
|
1254
|
+
};
|
|
927
1255
|
const sink = {
|
|
928
1256
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
|
|
935
|
-
if (pending?.state === "optimistic" || pending?.state === "prepublished") {
|
|
936
|
-
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
937
|
-
return;
|
|
1257
|
+
onTurnObserved: (turnId) => {
|
|
1258
|
+
cancelSyntheticInteractions("superseded_by_native_turn");
|
|
1259
|
+
const n = startNormalizer(turnId);
|
|
1260
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
1261
|
+
emitCurrent(event);
|
|
938
1262
|
}
|
|
939
|
-
|
|
940
|
-
|
|
1263
|
+
},
|
|
1264
|
+
onUserMessage: (content) => {
|
|
1265
|
+
const normalizedContent = observedUserContent(content);
|
|
941
1266
|
const n = normalizer ?? startNormalizer();
|
|
942
1267
|
for (const se of n.userInput(normalizedContent))
|
|
943
1268
|
emitCurrent(se);
|
|
944
1269
|
},
|
|
1270
|
+
onTurnContentUserMessage: (turnId, content) => {
|
|
1271
|
+
const normalizedContent = observedUserContent(content);
|
|
1272
|
+
turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
|
|
1273
|
+
},
|
|
945
1274
|
onEvent: (event) => {
|
|
946
1275
|
const n = normalizer ?? startNormalizer();
|
|
947
1276
|
for (const se of n.next(event))
|
|
948
1277
|
emitCurrent(se);
|
|
949
1278
|
},
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1279
|
+
onTurnContentEvent: (turnId, event) => {
|
|
1280
|
+
turnContentEvents(turnId, (n) => n.next(event));
|
|
1281
|
+
},
|
|
1282
|
+
onStatus: (note, statusKind) => {
|
|
1283
|
+
const responseId = currentResponseId ??
|
|
1284
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
1285
|
+
emitCurrent({
|
|
1286
|
+
type: "session.status",
|
|
1287
|
+
sessionId: currentSessionId,
|
|
1288
|
+
...(responseId ? { responseId } : {}),
|
|
1289
|
+
status: "running",
|
|
1290
|
+
...(note ? { note } : {}),
|
|
1291
|
+
...(statusKind ? { statusKind } : {}),
|
|
1292
|
+
});
|
|
964
1293
|
},
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1294
|
+
// A newer authoritative turn/started completes the old canonical
|
|
1295
|
+
// response without publishing a false idle edge in a running→running
|
|
1296
|
+
// transition.
|
|
1297
|
+
onTurnEnd: completeCurrentTurn,
|
|
1298
|
+
onTurnInterrupted: interruptCurrentTurn,
|
|
1299
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1300
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1301
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
1302
|
+
if (status === "failed")
|
|
1303
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
1304
|
+
else
|
|
1305
|
+
completeCurrentTurn();
|
|
968
1306
|
return;
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
?
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
for (const se of normalizer.fail({
|
|
978
|
-
code: responseStopped
|
|
979
|
-
? "response_stopped"
|
|
980
|
-
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
981
|
-
message,
|
|
982
|
-
source: "execution",
|
|
983
|
-
}))
|
|
984
|
-
emitCurrent(se);
|
|
985
|
-
clearPendingInputsForResponse(failedResponseId);
|
|
986
|
-
normalizer = null;
|
|
987
|
-
currentResponseId = null;
|
|
1307
|
+
}
|
|
1308
|
+
emitCurrent({
|
|
1309
|
+
type: "session.status",
|
|
1310
|
+
sessionId: currentSessionId,
|
|
1311
|
+
...(responseId ? { responseId } : {}),
|
|
1312
|
+
status,
|
|
1313
|
+
...(error ? { note: error.message } : {}),
|
|
1314
|
+
});
|
|
988
1315
|
},
|
|
1316
|
+
onTurnError: failCurrentTurn,
|
|
989
1317
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
990
1318
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
991
1319
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
992
|
-
onThreadActive: () =>
|
|
1320
|
+
onThreadActive: () => {
|
|
1321
|
+
live.threadActive = true;
|
|
1322
|
+
live.releaseActive?.();
|
|
1323
|
+
},
|
|
1324
|
+
onCollaborationModeChanged: (mode) => {
|
|
1325
|
+
live.appliedCollaborationMode = mode;
|
|
1326
|
+
live.execution = { ...live.execution, collaborationMode: mode };
|
|
1327
|
+
emitCurrent({
|
|
1328
|
+
type: "session.collaboration_mode",
|
|
1329
|
+
sessionId: currentSessionId,
|
|
1330
|
+
mode,
|
|
1331
|
+
});
|
|
1332
|
+
},
|
|
1333
|
+
onThreadSettingsChanged: ({ model: reportedModel, reasoningEffort: reportedEffort }) => {
|
|
1334
|
+
if (reportedModel) {
|
|
1335
|
+
live.model = reportedModel;
|
|
1336
|
+
live.appliedModel = reportedModel;
|
|
1337
|
+
live.mayUseProviderDefaultModel = false;
|
|
1338
|
+
}
|
|
1339
|
+
if (reportedEffort !== undefined) {
|
|
1340
|
+
live.reasoningEffort = reportedEffort ?? undefined;
|
|
1341
|
+
live.appliedReasoningEffort = reportedEffort ?? undefined;
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
onPlanImplementationPrompt: publishPlanImplementationPrompt,
|
|
993
1345
|
};
|
|
994
1346
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
995
1347
|
// Traex 0.200 can publish a final reasoning item immediately after
|
|
@@ -1000,6 +1352,7 @@ export class LocalAgentHost {
|
|
|
1000
1352
|
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
1001
1353
|
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
1002
1354
|
surfaceQueueStatus: runtime === "traex",
|
|
1355
|
+
mcpStartup: readMcpStartupPlan(this.runtimeHome(runtime), runtime),
|
|
1003
1356
|
});
|
|
1004
1357
|
live.forwarder = forwarder;
|
|
1005
1358
|
live.onNativeThreadRotated = (threadId, kind) => {
|
|
@@ -1007,12 +1360,20 @@ export class LocalAgentHost {
|
|
|
1007
1360
|
live.stopped = true;
|
|
1008
1361
|
return;
|
|
1009
1362
|
}
|
|
1363
|
+
cancelSyntheticInteractions("native_thread_rotated");
|
|
1010
1364
|
const previousSessionId = currentSessionId;
|
|
1011
1365
|
const newSessionId = makeSessionId();
|
|
1366
|
+
const rotationMode = live.nextThreadCollaborationMode;
|
|
1367
|
+
live.nextThreadCollaborationMode = undefined;
|
|
1368
|
+
live.appliedCollaborationMode = undefined;
|
|
1369
|
+
if (rotationMode) {
|
|
1370
|
+
live.execution = { ...live.execution, collaborationMode: rotationMode };
|
|
1371
|
+
}
|
|
1012
1372
|
live.rotationPending = true;
|
|
1013
1373
|
currentSessionId = newSessionId;
|
|
1014
1374
|
normalizer = null;
|
|
1015
1375
|
currentResponseId = null;
|
|
1376
|
+
contentNormalizers.clear();
|
|
1016
1377
|
live.pendingInjectedInputs = [];
|
|
1017
1378
|
pendingRotationEvents = [];
|
|
1018
1379
|
void this.sessionStore
|
|
@@ -1024,6 +1385,9 @@ export class LocalAgentHost {
|
|
|
1024
1385
|
...(previous?.runtimeHomeOwnerSessionId
|
|
1025
1386
|
? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
|
|
1026
1387
|
: {}),
|
|
1388
|
+
...(previous?.bridgeOwnerSessionId
|
|
1389
|
+
? { bridgeOwnerSessionId: previous.bridgeOwnerSessionId }
|
|
1390
|
+
: {}),
|
|
1027
1391
|
updatedAt: new Date().toISOString(),
|
|
1028
1392
|
}))
|
|
1029
1393
|
.then(() => {
|
|
@@ -1034,6 +1398,7 @@ export class LocalAgentHost {
|
|
|
1034
1398
|
kind,
|
|
1035
1399
|
});
|
|
1036
1400
|
this.liveSessions.set(newSessionId, live);
|
|
1401
|
+
this.liveSessions.delete(previousSessionId);
|
|
1037
1402
|
retargetMirror?.(newSessionId, {
|
|
1038
1403
|
kind,
|
|
1039
1404
|
workspace: structuredClone(live.workspace),
|
|
@@ -1045,7 +1410,15 @@ export class LocalAgentHost {
|
|
|
1045
1410
|
live.rotationPending = false;
|
|
1046
1411
|
for (const event of queued)
|
|
1047
1412
|
emit(event);
|
|
1048
|
-
|
|
1413
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1414
|
+
if (desiredMode) {
|
|
1415
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1416
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1417
|
+
void restore.catch((error) => {
|
|
1418
|
+
this.failObserverLifecycle(newSessionId, live, nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "rotated native thread could not restore the Session collaboration mode", error));
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
void this.subscribeUntilReady(newSessionId, live, threadId);
|
|
1049
1422
|
})
|
|
1050
1423
|
.catch(() => {
|
|
1051
1424
|
pendingRotationEvents = null;
|
|
@@ -1053,9 +1426,10 @@ export class LocalAgentHost {
|
|
|
1053
1426
|
live.stopped = true;
|
|
1054
1427
|
});
|
|
1055
1428
|
};
|
|
1056
|
-
|
|
1057
|
-
bindInteractionClient(forwarderClient);
|
|
1429
|
+
bindObserverClient(forwarderClient);
|
|
1058
1430
|
this.liveSessions.set(localThreadId, live);
|
|
1431
|
+
if (loginRequired)
|
|
1432
|
+
this.liveStartupErrors.set(localThreadId, loginRequired);
|
|
1059
1433
|
forwarder.start();
|
|
1060
1434
|
// Existing bindings resume through the public app-server API. A fresh thread
|
|
1061
1435
|
// is created by the native remote TUI instead: its `thread/started`
|
|
@@ -1064,36 +1438,56 @@ export class LocalAgentHost {
|
|
|
1064
1438
|
// turn-less app-server thread resumable by the TUI.
|
|
1065
1439
|
try {
|
|
1066
1440
|
if (record?.codexSessionId) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1441
|
+
// Match reference implementation's cold-resume preload exactly: one history-free
|
|
1442
|
+
// `thread/resume` on a short-lived, separately initialized connection.
|
|
1443
|
+
// The backend owner's handshake above is the app-server readiness
|
|
1444
|
+
// probe; reusing that first connection here races Codex's startup state
|
|
1445
|
+
// and can return `Not initialized`. A failure stays attached to the
|
|
1446
|
+
// original binding and is reported by this phase; it is not polled.
|
|
1447
|
+
const preloadClient = this.preloadClientFactory(appServerUrl);
|
|
1448
|
+
try {
|
|
1449
|
+
let resumed;
|
|
1071
1450
|
try {
|
|
1072
|
-
|
|
1451
|
+
await preloadClient.ensureInitialized();
|
|
1452
|
+
resumed = await preloadClient.threadResume({
|
|
1073
1453
|
threadId: record.codexSessionId,
|
|
1074
1454
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1075
1455
|
approvalPolicy,
|
|
1076
1456
|
excludeTurns: true,
|
|
1077
1457
|
});
|
|
1078
|
-
|
|
1079
|
-
|
|
1458
|
+
adoptResumedThreadSettings(live, resumed);
|
|
1459
|
+
await this.applyLiveThreadSettings(live, resumed.threadId, execution.collaborationMode, preloadClient);
|
|
1080
1460
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
if (attempt + 1 < attempts) {
|
|
1086
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1087
|
-
}
|
|
1461
|
+
finally {
|
|
1462
|
+
await preloadClient.stop().catch((error) => {
|
|
1463
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} preload client cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1464
|
+
});
|
|
1088
1465
|
}
|
|
1466
|
+
forwarder.noteThreadBound(resumed.threadId);
|
|
1467
|
+
this.onLiveThreadStarted(live, localThreadId, resumed.threadId);
|
|
1089
1468
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
if (!isThreadNotReadyError(error) ||
|
|
1471
|
+
record.parentSessionId ||
|
|
1472
|
+
record.runtimeHomeOwnerSessionId) {
|
|
1473
|
+
throw error;
|
|
1474
|
+
}
|
|
1475
|
+
// Preserve the existing turn-less binding recovery without polling:
|
|
1476
|
+
// an ordinary native id with no rollout/turn has no Provider history
|
|
1477
|
+
// to retain. Fork bindings remain strict, and `thread not found` is
|
|
1478
|
+
// not classified here because it does not prove the history is empty.
|
|
1479
|
+
await this.sessionStore.delete(localThreadId);
|
|
1480
|
+
live.threadId = null;
|
|
1481
|
+
live.mayUseProviderDefaultModel = !live.model;
|
|
1482
|
+
try {
|
|
1483
|
+
await forwarderClient.ensureInitialized();
|
|
1484
|
+
}
|
|
1485
|
+
catch (listenerError) {
|
|
1486
|
+
throw nativeLiveFailure(runtime, "native_fresh_listener_failed", "fresh-thread listener could not attach after the turn-less binding was replaced", listenerError);
|
|
1487
|
+
}
|
|
1488
|
+
live.observerAvailable = true;
|
|
1489
|
+
live.markTerminalReady(true);
|
|
1490
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${runtime} replaced turn-less native binding ${record.codexSessionId} with fresh discovery`);
|
|
1097
1491
|
}
|
|
1098
1492
|
}
|
|
1099
1493
|
else {
|
|
@@ -1108,15 +1502,32 @@ export class LocalAgentHost {
|
|
|
1108
1502
|
live.stopped = true;
|
|
1109
1503
|
forwarder.stop();
|
|
1110
1504
|
void forwarderClient.stop().catch(() => undefined);
|
|
1505
|
+
const failure = err instanceof CodexRuntimeError
|
|
1506
|
+
? err
|
|
1507
|
+
: nativeLiveFailure(runtime, "native_resume_failed", "existing native session could not be resumed", err);
|
|
1508
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1111
1509
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1112
1510
|
return abandon();
|
|
1113
1511
|
}
|
|
1512
|
+
if (!loginRequired)
|
|
1513
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1114
1514
|
return true;
|
|
1115
1515
|
}
|
|
1116
1516
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
1117
1517
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
1118
1518
|
onLiveThreadStarted(live, localThreadId, threadId, forkedFromId) {
|
|
1519
|
+
if (live.startupError?.code === "login_required") {
|
|
1520
|
+
delete live.startupError;
|
|
1521
|
+
if (this.liveStartupErrors.get(localThreadId)?.code === "login_required") {
|
|
1522
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1119
1525
|
const previousThreadId = live.threadId;
|
|
1526
|
+
if (previousThreadId !== threadId) {
|
|
1527
|
+
live.threadActive = false;
|
|
1528
|
+
if (!live.model)
|
|
1529
|
+
live.mayUseProviderDefaultModel = true;
|
|
1530
|
+
}
|
|
1120
1531
|
if (previousThreadId && previousThreadId !== threadId) {
|
|
1121
1532
|
live.threadId = threadId;
|
|
1122
1533
|
live.subscribing = true;
|
|
@@ -1141,12 +1552,82 @@ export class LocalAgentHost {
|
|
|
1141
1552
|
...(existing?.runtimeHomeOwnerSessionId
|
|
1142
1553
|
? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
|
|
1143
1554
|
: {}),
|
|
1555
|
+
...(existing?.bridgeOwnerSessionId
|
|
1556
|
+
? { bridgeOwnerSessionId: existing.bridgeOwnerSessionId }
|
|
1557
|
+
: {}),
|
|
1144
1558
|
updatedAt: new Date().toISOString(),
|
|
1145
1559
|
});
|
|
1146
1560
|
})
|
|
1147
1561
|
.catch(() => undefined);
|
|
1148
|
-
|
|
1149
|
-
|
|
1562
|
+
const finishBinding = () => {
|
|
1563
|
+
if (live.stopped)
|
|
1564
|
+
return;
|
|
1565
|
+
live.markReady(); // thread id + explicit Session mode are ready for turns
|
|
1566
|
+
if (live.observerAvailable) {
|
|
1567
|
+
void this.subscribeUntilReady(localThreadId, live, threadId).then(live.markTerminalReady);
|
|
1568
|
+
}
|
|
1569
|
+
else {
|
|
1570
|
+
// Known-thread cold resume has already completed the dedicated preload. Its
|
|
1571
|
+
// observer is deliberately post-TUI background work, not Terminal
|
|
1572
|
+
// readiness. RunnerSession calls startLiveCodexObserver after launch.
|
|
1573
|
+
live.markTerminalReady(true);
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
const desiredMode = live.execution.collaborationMode;
|
|
1577
|
+
if (!desiredMode || live.appliedCollaborationMode === desiredMode) {
|
|
1578
|
+
finishBinding();
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
// A fresh thread does not exist until the TUI broadcasts thread/started.
|
|
1582
|
+
// Apply the Session-owned mode immediately at that boundary, before Web
|
|
1583
|
+
// injection is released. This also narrows the window in which a user can
|
|
1584
|
+
// type into the native pane before its explicit startup mode is installed.
|
|
1585
|
+
const restore = live.injectLock.then(() => this.applyLiveCollaborationMode(live, threadId, desiredMode));
|
|
1586
|
+
live.injectLock = restore.then(() => undefined, () => undefined);
|
|
1587
|
+
void restore.then(finishBinding).catch((error) => {
|
|
1588
|
+
const failure = nativeLiveFailure(live.runtime, "native_collaboration_mode_restore_failed", "native thread could not apply the Session collaboration mode", error);
|
|
1589
|
+
live.startupError = failure;
|
|
1590
|
+
live.markStartupFailed();
|
|
1591
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1592
|
+
this.teardownLiveCodexSession(localThreadId, failure);
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
/** Start the known-thread observer after the replacement TUI has launched.
|
|
1596
|
+
* Fresh sessions already connected their discovery listener before launch;
|
|
1597
|
+
* this is therefore an idempotent no-op for them and for a healthy observer. */
|
|
1598
|
+
startLiveCodexObserver(localThreadId) {
|
|
1599
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1600
|
+
if (!live ||
|
|
1601
|
+
live.stopped ||
|
|
1602
|
+
live.observerAvailable ||
|
|
1603
|
+
!live.threadId)
|
|
1604
|
+
return;
|
|
1605
|
+
void (async () => {
|
|
1606
|
+
try {
|
|
1607
|
+
await live.forwarderClient.ensureInitialized();
|
|
1608
|
+
if (live.stopped)
|
|
1609
|
+
return;
|
|
1610
|
+
live.observerAvailable = true;
|
|
1611
|
+
// reference implementation starts subscription as a sibling task after the transport
|
|
1612
|
+
// connects. A no/empty rollout remains event-driven retryable; any
|
|
1613
|
+
// other subscription rejection is diagnostic only and must not tear
|
|
1614
|
+
// down the otherwise usable Terminal/injection lifecycle.
|
|
1615
|
+
void this.subscribeUntilReady(localThreadId, live, live.threadId);
|
|
1616
|
+
}
|
|
1617
|
+
catch (error) {
|
|
1618
|
+
this.failObserverLifecycle(localThreadId, live, nativeLiveFailure(live.runtime, "native_observer_connect_failed", "observer could not attach to the ready app-server", error));
|
|
1619
|
+
}
|
|
1620
|
+
})();
|
|
1621
|
+
}
|
|
1622
|
+
/** reference implementation treats the forwarder as a required component of one native
|
|
1623
|
+
* lifecycle: if its transport dies, it closes the app-server instead of
|
|
1624
|
+
* accepting turns that can no longer reach the canonical mirror. */
|
|
1625
|
+
failObserverLifecycle(localThreadId, live, error) {
|
|
1626
|
+
if (live.stopped)
|
|
1627
|
+
return;
|
|
1628
|
+
live.startupError = error;
|
|
1629
|
+
this.liveStartupErrors.set(localThreadId, error);
|
|
1630
|
+
this.teardownLiveCodexSession(localThreadId, error);
|
|
1150
1631
|
}
|
|
1151
1632
|
shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
|
|
1152
1633
|
const pending = live.managedFork;
|
|
@@ -1170,11 +1651,13 @@ export class LocalAgentHost {
|
|
|
1170
1651
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1171
1652
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1172
1653
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1173
|
-
* thread active, then retry
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1654
|
+
* thread active, then retry. Like reference implementation, the first attempt always uses
|
|
1655
|
+
* `excludeTurns`: a known-session cold resume therefore never reconstructs
|
|
1656
|
+
* historical running/completed state. Only a fresh thread whose first attempt
|
|
1657
|
+
* failed as not-ready retries without `excludeTurns`, backfilling the newly
|
|
1658
|
+
* materialized first turn and de-duplicating it against live notifications.
|
|
1176
1659
|
*/
|
|
1177
|
-
async subscribeUntilReady(live, threadId) {
|
|
1660
|
+
async subscribeUntilReady(localThreadId, live, threadId) {
|
|
1178
1661
|
let sawNotReady = false;
|
|
1179
1662
|
while (!live.stopped) {
|
|
1180
1663
|
try {
|
|
@@ -1182,30 +1665,43 @@ export class LocalAgentHost {
|
|
|
1182
1665
|
threadId,
|
|
1183
1666
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1184
1667
|
approvalPolicy: live.approvalPolicy,
|
|
1185
|
-
excludeTurns:
|
|
1668
|
+
...(!sawNotReady ? { excludeTurns: true } : {}),
|
|
1186
1669
|
});
|
|
1187
|
-
|
|
1188
|
-
|
|
1670
|
+
adoptResumedThreadSettings(live, resp);
|
|
1671
|
+
if (sawNotReady) {
|
|
1672
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1673
|
+
? resp.thread.turns
|
|
1674
|
+
: [];
|
|
1675
|
+
live.forwarder.replayBackfill(turns);
|
|
1189
1676
|
}
|
|
1190
1677
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1191
1678
|
}
|
|
1192
1679
|
catch (error) {
|
|
1193
1680
|
if (!isThreadNotReadyError(error)) {
|
|
1194
|
-
|
|
1681
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} observer failed to subscribe to thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1682
|
+
return false;
|
|
1195
1683
|
}
|
|
1196
1684
|
sawNotReady = true;
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1685
|
+
if (!live.threadActive) {
|
|
1686
|
+
// An idle fresh thread has no rollout until its first Turn. Wait only
|
|
1687
|
+
// for live activity instead of polling the app-server indefinitely.
|
|
1688
|
+
await new Promise((resolve) => {
|
|
1689
|
+
live.releaseActive = resolve;
|
|
1690
|
+
});
|
|
1691
|
+
live.releaseActive = undefined;
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
// Activity can precede rollout persistence by a short interval.
|
|
1695
|
+
await new Promise((resolve) => {
|
|
1696
|
+
setTimeout(resolve, CODEX_SUBSCRIBE_FLUSH_RETRY_MS);
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1204
1699
|
}
|
|
1205
1700
|
}
|
|
1206
1701
|
return false;
|
|
1207
1702
|
}
|
|
1208
|
-
/** Await a live session's thread binding
|
|
1703
|
+
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1704
|
+
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1209
1705
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
1210
1706
|
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
1211
1707
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
@@ -1214,18 +1710,22 @@ export class LocalAgentHost {
|
|
|
1214
1710
|
const live = this.liveSessions.get(localThreadId);
|
|
1215
1711
|
if (!live)
|
|
1216
1712
|
return false;
|
|
1713
|
+
const ready = live.ready.then(() => true);
|
|
1714
|
+
const failed = live.startupFailed.then(() => false);
|
|
1715
|
+
if (timeoutMs === null)
|
|
1716
|
+
return Promise.race([ready, failed]);
|
|
1217
1717
|
let timer;
|
|
1218
1718
|
const timeout = new Promise((resolve) => {
|
|
1219
1719
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1220
1720
|
});
|
|
1221
|
-
const ok = await Promise.race([
|
|
1721
|
+
const ok = await Promise.race([ready, failed, timeout]);
|
|
1222
1722
|
if (timer)
|
|
1223
1723
|
clearTimeout(timer);
|
|
1224
1724
|
return ok;
|
|
1225
1725
|
}
|
|
1226
|
-
/** Await the
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1726
|
+
/** Await the observer subscription diagnostic. Codex/Traex Terminal startup
|
|
1727
|
+
* deliberately does not gate on this promise: the dedicated preload owns resume,
|
|
1728
|
+
* while the independent observer attaches in the background. */
|
|
1229
1729
|
async waitTerminalReady(localThreadId, timeoutMs = 20_000) {
|
|
1230
1730
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
1231
1731
|
// Claude has no separate app-server indexing gate. Its TUI must launch first;
|
|
@@ -1235,6 +1735,8 @@ export class LocalAgentHost {
|
|
|
1235
1735
|
const live = this.liveSessions.get(localThreadId);
|
|
1236
1736
|
if (!live)
|
|
1237
1737
|
return false;
|
|
1738
|
+
if (timeoutMs === null)
|
|
1739
|
+
return live.terminalReady;
|
|
1238
1740
|
let timer;
|
|
1239
1741
|
const timeout = new Promise((resolve) => {
|
|
1240
1742
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
@@ -1246,7 +1748,40 @@ export class LocalAgentHost {
|
|
|
1246
1748
|
}
|
|
1247
1749
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1248
1750
|
liveSessionError(localThreadId) {
|
|
1249
|
-
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1751
|
+
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1752
|
+
?? this.liveSessions.get(localThreadId)?.startupError?.message
|
|
1753
|
+
?? this.liveStartupErrors.get(localThreadId)?.message;
|
|
1754
|
+
}
|
|
1755
|
+
/** Structured phase + concrete cause used by the runner wire protocol. */
|
|
1756
|
+
liveSessionFailure(localThreadId) {
|
|
1757
|
+
const failure = this.liveSessions.get(localThreadId)?.startupError
|
|
1758
|
+
?? this.liveStartupErrors.get(localThreadId);
|
|
1759
|
+
if (failure) {
|
|
1760
|
+
return {
|
|
1761
|
+
message: failure.message,
|
|
1762
|
+
code: failure.code,
|
|
1763
|
+
statusCode: failure.statusCode,
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
const claudeError = this.liveClaudeSessions.get(localThreadId)?.error;
|
|
1767
|
+
return claudeError
|
|
1768
|
+
? { message: claudeError, code: "claude_native_start_failed", statusCode: 503 }
|
|
1769
|
+
: undefined;
|
|
1770
|
+
}
|
|
1771
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
1772
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
1773
|
+
* 30s discovery cause. */
|
|
1774
|
+
failLiveStartup(localThreadId, error) {
|
|
1775
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1776
|
+
if (!live || live.threadId || live.stopped)
|
|
1777
|
+
return false;
|
|
1778
|
+
const failure = error instanceof CodexRuntimeError
|
|
1779
|
+
? error
|
|
1780
|
+
: new CodexRuntimeError(error.message, 503, "native_thread_discovery_failed");
|
|
1781
|
+
live.startupError = failure;
|
|
1782
|
+
this.liveStartupErrors.set(localThreadId, failure);
|
|
1783
|
+
live.markStartupFailed();
|
|
1784
|
+
return true;
|
|
1250
1785
|
}
|
|
1251
1786
|
/**
|
|
1252
1787
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -1255,13 +1790,13 @@ export class LocalAgentHost {
|
|
|
1255
1790
|
* resumed from the persisted native id. Serialized per session so two
|
|
1256
1791
|
* injects can't double-open a turn.
|
|
1257
1792
|
*
|
|
1258
|
-
* Returns an {@link
|
|
1793
|
+
* Returns an {@link InjectResult}: `notLive` when this session has no live
|
|
1259
1794
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
1260
1795
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
1261
1796
|
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
1262
1797
|
* the bridge instead of a short race that falls back to a second output path.
|
|
1263
1798
|
*/
|
|
1264
|
-
async injectMessage(localThreadId, input) {
|
|
1799
|
+
async injectMessage(localThreadId, input, options) {
|
|
1265
1800
|
const runtimeInput = typeof input === "string"
|
|
1266
1801
|
? { content: [{ type: "text", text: input }] }
|
|
1267
1802
|
: input;
|
|
@@ -1270,11 +1805,8 @@ export class LocalAgentHost {
|
|
|
1270
1805
|
const text = claudeInputText(runtimeInput);
|
|
1271
1806
|
const content = runtimeUserContent(runtimeInput);
|
|
1272
1807
|
const pendingInput = {
|
|
1273
|
-
|
|
1274
|
-
signature: JSON.stringify(content),
|
|
1275
|
-
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1808
|
+
state: "awaiting",
|
|
1276
1809
|
observed: false,
|
|
1277
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1278
1810
|
};
|
|
1279
1811
|
claude.pendingInjectedInputs.push(pendingInput);
|
|
1280
1812
|
const forgetPendingInput = () => {
|
|
@@ -1288,98 +1820,183 @@ export class LocalAgentHost {
|
|
|
1288
1820
|
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1289
1821
|
expiry.unref?.();
|
|
1290
1822
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1823
|
+
let result;
|
|
1824
|
+
try {
|
|
1825
|
+
result = await this.injectClaude(claude, localThreadId, text, pendingInput);
|
|
1826
|
+
}
|
|
1827
|
+
catch (error) {
|
|
1293
1828
|
forgetPendingInput();
|
|
1294
1829
|
if (token)
|
|
1295
1830
|
claude.pendingImageInputs.delete(token);
|
|
1831
|
+
throw error;
|
|
1296
1832
|
}
|
|
1297
|
-
|
|
1833
|
+
if (result.outcome !== "injected" && result.outcome !== "steered") {
|
|
1298
1834
|
forgetPendingInput();
|
|
1835
|
+
if (token)
|
|
1836
|
+
claude.pendingImageInputs.delete(token);
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
if (result.responseId)
|
|
1840
|
+
pendingInput.responseId = result.responseId;
|
|
1841
|
+
if (pendingInput.observed)
|
|
1842
|
+
forgetPendingInput();
|
|
1843
|
+
else
|
|
1844
|
+
pendingInput.state = "accepted";
|
|
1299
1845
|
}
|
|
1300
|
-
return
|
|
1846
|
+
return result;
|
|
1301
1847
|
}
|
|
1302
1848
|
const live = this.liveSessions.get(localThreadId);
|
|
1303
1849
|
if (!live)
|
|
1304
|
-
return "notLive";
|
|
1850
|
+
return { outcome: "notLive" };
|
|
1851
|
+
if (live.startupError?.code === "login_required")
|
|
1852
|
+
return { outcome: "notReady" };
|
|
1305
1853
|
const run = live.injectLock.then(async () => {
|
|
1306
1854
|
if (live.rotationPending || live.stopped)
|
|
1307
|
-
return "failed";
|
|
1855
|
+
return { outcome: "failed" };
|
|
1308
1856
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1309
1857
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1310
|
-
const bound = await this.waitLiveReady(localThreadId,
|
|
1858
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1311
1859
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1312
1860
|
if (!bound || !threadId)
|
|
1313
|
-
return "notReady";
|
|
1861
|
+
return { outcome: "notReady" };
|
|
1314
1862
|
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
1315
|
-
const content = runtimeUserContent(runtimeInput);
|
|
1316
1863
|
const pendingInput = {
|
|
1317
|
-
|
|
1318
|
-
signature: JSON.stringify(content),
|
|
1319
|
-
state: runtimeInput.responseId ? "prepublished" : "awaiting",
|
|
1864
|
+
state: "awaiting",
|
|
1320
1865
|
observed: false,
|
|
1321
|
-
...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
|
|
1322
1866
|
};
|
|
1323
1867
|
live.pendingInjectedInputs.push(pendingInput);
|
|
1868
|
+
let injectionMethod = "turn/start";
|
|
1869
|
+
let admissionPublished = false;
|
|
1870
|
+
const publishAdmission = () => {
|
|
1871
|
+
if (admissionPublished)
|
|
1872
|
+
return;
|
|
1873
|
+
admissionPublished = true;
|
|
1874
|
+
live.publishTurnAdmission();
|
|
1875
|
+
};
|
|
1324
1876
|
const forgetPendingInput = () => {
|
|
1325
1877
|
const index = live.pendingInjectedInputs.indexOf(pendingInput);
|
|
1326
1878
|
if (index >= 0)
|
|
1327
1879
|
live.pendingInjectedInputs.splice(index, 1);
|
|
1328
1880
|
};
|
|
1881
|
+
const injectionClient = this.injectionClientFactory(live.appServerUrl);
|
|
1329
1882
|
try {
|
|
1330
|
-
//
|
|
1883
|
+
// reference implementation publishes codex-native running only after the native Terminal
|
|
1884
|
+
// is ready and the runner has accepted the message, but before the
|
|
1885
|
+
// short-lived app-server client initializes and starts the Turn. Claude
|
|
1886
|
+
// deliberately has no matching synthesized edge.
|
|
1887
|
+
if (!live.forwarder.isTurnOpen())
|
|
1888
|
+
publishAdmission();
|
|
1889
|
+
await injectionClient.ensureInitialized();
|
|
1890
|
+
// Match reference implementation's executor: each message uses one initialized client
|
|
1891
|
+
// that closes as soon as turn/start or turn/steer is acknowledged.
|
|
1331
1892
|
if (live.forwarder.isTurnOpen()) {
|
|
1332
1893
|
const turnId = live.forwarder.currentTurnId();
|
|
1333
1894
|
if (turnId) {
|
|
1334
|
-
|
|
1895
|
+
injectionMethod = "turn/steer";
|
|
1896
|
+
const steered = await injectionClient.turnSteer({
|
|
1335
1897
|
threadId,
|
|
1336
1898
|
expectedTurnId: turnId,
|
|
1337
1899
|
input: nativeInput,
|
|
1338
1900
|
});
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
// before waiting for the native Terminal to become ready.
|
|
1342
|
-
}
|
|
1343
|
-
else if (pendingInput.observed) {
|
|
1901
|
+
pendingInput.responseId = `resp_codex_${steered.turnId}`;
|
|
1902
|
+
if (pendingInput.observed)
|
|
1344
1903
|
forgetPendingInput();
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
return "injected";
|
|
1904
|
+
else
|
|
1905
|
+
pendingInput.state = "accepted";
|
|
1906
|
+
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1907
|
+
return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
|
|
1351
1908
|
}
|
|
1352
1909
|
}
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
|
|
1910
|
+
// Match reference implementation's turn boundary: change the native thread settings
|
|
1911
|
+
// under the same lock immediately before starting the next Turn. Never
|
|
1912
|
+
// put settings on turn/start or mutate a Turn that is already open.
|
|
1913
|
+
const desiredModel = live.model;
|
|
1914
|
+
const desiredReasoningEffort = live.reasoningEffort;
|
|
1915
|
+
// A Session-owned mode survives runner restarts. Normal Web messages do
|
|
1916
|
+
// not carry per-Turn options, so a cold-resumed native thread must fall
|
|
1917
|
+
// back to the persisted execution snapshot before its first new Turn.
|
|
1918
|
+
// An explicit session.run option still wins for that invocation.
|
|
1919
|
+
const desiredCollaborationMode = options?.collaborationMode ?? live.execution.collaborationMode;
|
|
1920
|
+
const collaborationMode = desiredCollaborationMode &&
|
|
1921
|
+
desiredCollaborationMode !== live.appliedCollaborationMode
|
|
1922
|
+
? await resolveLiveCollaborationMode(injectionClient, live, desiredCollaborationMode, live.runtime === "codex" ? this.runtimeHome(live.runtime) : undefined)
|
|
1923
|
+
: undefined;
|
|
1924
|
+
const settings = {
|
|
1925
|
+
threadId,
|
|
1926
|
+
...(desiredModel !== live.appliedModel
|
|
1927
|
+
? { model: desiredModel || null }
|
|
1928
|
+
: {}),
|
|
1929
|
+
...(desiredReasoningEffort !== live.appliedReasoningEffort
|
|
1930
|
+
? { effort: desiredReasoningEffort ?? null }
|
|
1931
|
+
: {}),
|
|
1932
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
1933
|
+
};
|
|
1934
|
+
if (Object.keys(settings).length > 1) {
|
|
1935
|
+
await injectionClient.threadSettingsUpdate(settings);
|
|
1936
|
+
// Record exactly what this RPC carried. A concurrent settings save may
|
|
1937
|
+
// already have advanced the desired fields and must remain pending for
|
|
1938
|
+
// the following new Turn.
|
|
1939
|
+
if (collaborationMode && !live.model) {
|
|
1940
|
+
live.model = collaborationMode.settings.model;
|
|
1941
|
+
}
|
|
1942
|
+
if (collaborationMode)
|
|
1943
|
+
live.mayUseProviderDefaultModel = false;
|
|
1944
|
+
live.appliedModel = collaborationMode?.settings.model ?? desiredModel;
|
|
1945
|
+
live.appliedReasoningEffort = collaborationMode
|
|
1946
|
+
? collaborationMode.settings.reasoning_effort ?? undefined
|
|
1947
|
+
: desiredReasoningEffort;
|
|
1948
|
+
if (desiredCollaborationMode) {
|
|
1949
|
+
live.appliedCollaborationMode = desiredCollaborationMode;
|
|
1950
|
+
live.execution = {
|
|
1951
|
+
...live.execution,
|
|
1952
|
+
collaborationMode: desiredCollaborationMode,
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
publishAdmission();
|
|
1957
|
+
const started = await injectionClient.turnStart({
|
|
1356
1958
|
threadId,
|
|
1357
1959
|
input: nativeInput,
|
|
1358
1960
|
...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1359
1961
|
approvalPolicy: live.approvalPolicy,
|
|
1360
|
-
...(live.model ? { model: live.model } : {}),
|
|
1361
|
-
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1362
1962
|
});
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
// waiting for the native Terminal to become ready.
|
|
1366
|
-
}
|
|
1367
|
-
else if (pendingInput.observed) {
|
|
1963
|
+
pendingInput.responseId = `resp_codex_${started.turnId}`;
|
|
1964
|
+
if (pendingInput.observed)
|
|
1368
1965
|
forgetPendingInput();
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1966
|
+
else
|
|
1967
|
+
pendingInput.state = "accepted";
|
|
1968
|
+
// The injection RPC and active-turn write are one serialized operation.
|
|
1969
|
+
// Do not wait for the independent observer connection's `turn/started`:
|
|
1970
|
+
// a second message accepted in that window must steer, not double-start.
|
|
1971
|
+
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1972
|
+
return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
|
|
1375
1973
|
}
|
|
1376
1974
|
catch (error) {
|
|
1377
1975
|
forgetPendingInput();
|
|
1976
|
+
if (error instanceof CodexRuntimeError &&
|
|
1977
|
+
(error.code === "collaboration_mode_model_unknown" ||
|
|
1978
|
+
error.code === "collaboration_mode_instructions_unknown")) {
|
|
1979
|
+
throw error;
|
|
1980
|
+
}
|
|
1378
1981
|
// Preserve the app-server's error instead of collapsing every failure
|
|
1379
1982
|
// into the unactionable `live injection failed` string.
|
|
1380
|
-
const
|
|
1983
|
+
const baseDetail = codexRpcError(error, injectionMethod);
|
|
1984
|
+
const startupDetail = live.forwarder.mcpStartupDetail();
|
|
1985
|
+
const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
|
|
1986
|
+
// A turn may open on the observer while this short-lived client is
|
|
1987
|
+
// initializing, converting the submission to steer. A rejected steer
|
|
1988
|
+
// does not fail that already-running Turn; only a new-Turn admission
|
|
1989
|
+
// owns the terminal failed edge.
|
|
1990
|
+
if (admissionPublished && injectionMethod === "turn/start") {
|
|
1991
|
+
live.publishTurnAdmissionFailure(detail);
|
|
1992
|
+
}
|
|
1381
1993
|
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
|
|
1382
|
-
throw
|
|
1994
|
+
throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
|
|
1995
|
+
}
|
|
1996
|
+
finally {
|
|
1997
|
+
await injectionClient.stop().catch((stopError) => {
|
|
1998
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection client cleanup failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1999
|
+
});
|
|
1383
2000
|
}
|
|
1384
2001
|
});
|
|
1385
2002
|
live.injectLock = run.then(() => undefined, () => undefined);
|
|
@@ -1422,20 +2039,42 @@ export class LocalAgentHost {
|
|
|
1422
2039
|
const live = this.liveSessions.get(localThreadId);
|
|
1423
2040
|
if (!live)
|
|
1424
2041
|
return false;
|
|
1425
|
-
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
1426
|
-
// reference implementation no-ops (204) when no turn is active.
|
|
1427
|
-
if (!live.forwarder.isTurnOpen())
|
|
1428
|
-
return false;
|
|
1429
2042
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
2043
|
+
const pendingMcp = live.forwarder.cancelMcpStartup();
|
|
2044
|
+
const turnId = live.forwarder.isTurnOpen()
|
|
2045
|
+
? live.forwarder.currentTurnId()
|
|
2046
|
+
: null;
|
|
2047
|
+
if (!threadId)
|
|
2048
|
+
return pendingMcp.length > 0;
|
|
2049
|
+
let handled = pendingMcp.length > 0;
|
|
2050
|
+
const interruptClient = this.injectionClientFactory(live.appServerUrl);
|
|
1433
2051
|
try {
|
|
1434
|
-
await
|
|
1435
|
-
|
|
2052
|
+
await interruptClient.ensureInitialized();
|
|
2053
|
+
if (pendingMcp.length > 0) {
|
|
2054
|
+
// Codex's TUI cancels a provider-owned MCP startup round with an empty
|
|
2055
|
+
// turn id. Best-effort for Traex: it shares the app-server surface, while
|
|
2056
|
+
// the active-turn interrupt below remains authoritative if it rejects this.
|
|
2057
|
+
try {
|
|
2058
|
+
await interruptClient.turnInterrupt({ threadId, turnId: "" });
|
|
2059
|
+
}
|
|
2060
|
+
catch (error) {
|
|
2061
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} MCP startup interrupt failed: ${codexRpcError(error, "turn/interrupt")}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
if (turnId) {
|
|
2065
|
+
try {
|
|
2066
|
+
await interruptClient.turnInterrupt({ threadId, turnId });
|
|
2067
|
+
live.publishInterrupted(`resp_codex_${turnId}`);
|
|
2068
|
+
handled = true;
|
|
2069
|
+
}
|
|
2070
|
+
catch {
|
|
2071
|
+
// The startup cancellation above is still a handled Stop operation.
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
return handled;
|
|
1436
2075
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
2076
|
+
finally {
|
|
2077
|
+
await interruptClient.stop().catch(() => undefined);
|
|
1439
2078
|
}
|
|
1440
2079
|
}
|
|
1441
2080
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
@@ -1446,6 +2085,7 @@ export class LocalAgentHost {
|
|
|
1446
2085
|
this.liveClaudeSessions.delete(localThreadId);
|
|
1447
2086
|
claude.stopped = true;
|
|
1448
2087
|
claude.forwarder.stop();
|
|
2088
|
+
this.trackCleanup(claude.forwarder.waitForStop());
|
|
1449
2089
|
if (opts.deferClaudeInteractionCleanup) {
|
|
1450
2090
|
this.pendingClaudeFinalizers.add(claude.forwarder);
|
|
1451
2091
|
}
|
|
@@ -1453,8 +2093,6 @@ export class LocalAgentHost {
|
|
|
1453
2093
|
claude.forwarder.finalizeStop();
|
|
1454
2094
|
}
|
|
1455
2095
|
removeManagedClaudeSettings(claude.bridgeDir);
|
|
1456
|
-
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
1457
|
-
void claude.skillCleanup?.();
|
|
1458
2096
|
return;
|
|
1459
2097
|
}
|
|
1460
2098
|
const live = this.liveSessions.get(localThreadId);
|
|
@@ -1462,29 +2100,56 @@ export class LocalAgentHost {
|
|
|
1462
2100
|
return;
|
|
1463
2101
|
this.liveSessions.delete(localThreadId);
|
|
1464
2102
|
live.stopped = true;
|
|
2103
|
+
if (!live.threadId) {
|
|
2104
|
+
live.startupError ??= nativeLiveFailure(live.runtime, "native_thread_discovery_stopped", "Session stopped before native thread discovery completed");
|
|
2105
|
+
live.markStartupFailed();
|
|
2106
|
+
}
|
|
1465
2107
|
live.releaseActive?.();
|
|
1466
|
-
live.
|
|
2108
|
+
live.cancelSyntheticInteractions("session_stopped");
|
|
2109
|
+
live.appServerOwner.cancelInteractions("session_stopped");
|
|
1467
2110
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1468
|
-
live.
|
|
2111
|
+
live.appServerOwner.setInteractionListener(null);
|
|
1469
2112
|
live.forwarderClient.setInteractionListener(null);
|
|
1470
|
-
live.
|
|
2113
|
+
live.appServerOwner.setConnectionListener(null);
|
|
1471
2114
|
live.forwarderClient.setConnectionListener(null);
|
|
1472
|
-
live.interactionOwners.clear();
|
|
1473
|
-
live.interactionStandbys.clear();
|
|
1474
|
-
live.interactionSubmissions.clear();
|
|
1475
2115
|
live.forwarder.stop();
|
|
1476
|
-
live.interactionRecoveries.clear();
|
|
1477
|
-
live.interactionFailedClients.clear();
|
|
1478
|
-
live.disconnectedClients.clear();
|
|
1479
2116
|
live.canonicalInteractions.clear();
|
|
1480
2117
|
live.settledInteractions.clear();
|
|
1481
|
-
|
|
2118
|
+
live.syntheticInteractions.clear();
|
|
2119
|
+
this.trackCleanup(live.forwarderClient.stop().catch(() => undefined));
|
|
1482
2120
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
1483
|
-
|
|
2121
|
+
if (live.skillsCleanup)
|
|
2122
|
+
this.trackCleanup(live.skillsCleanup());
|
|
2123
|
+
}
|
|
2124
|
+
/** Tear down one codex-lineage native runtime without deleting its durable
|
|
2125
|
+
* session-store binding. reference implementation couples its auxiliary Terminal, observer,
|
|
2126
|
+
* forwarder and per-session app-server as one disposable runtime envelope;
|
|
2127
|
+
* the next message recreates that envelope and cold-resumes the native id. */
|
|
2128
|
+
teardownLiveCodexSession(localThreadId, error) {
|
|
2129
|
+
const live = this.liveSessions.get(localThreadId);
|
|
2130
|
+
if (!live)
|
|
2131
|
+
return false;
|
|
2132
|
+
const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
|
|
2133
|
+
const backendKey = live.runtime;
|
|
2134
|
+
const backend = this.backends.get(backendKey);
|
|
2135
|
+
const appServerOwner = live.appServerOwner;
|
|
2136
|
+
this.stopLiveCodexSession(localThreadId);
|
|
2137
|
+
if (backend?.appServerClient === appServerOwner) {
|
|
2138
|
+
this.backends.delete(backendKey);
|
|
2139
|
+
}
|
|
2140
|
+
this.trackCleanup(appServerOwner.stop().catch((stopError) => {
|
|
2141
|
+
console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
2142
|
+
}));
|
|
2143
|
+
return turnFailed;
|
|
2144
|
+
}
|
|
2145
|
+
async teardownAuxiliaryTerminalRuntime(localThreadId) {
|
|
2146
|
+
const owner = this.liveSessions.get(localThreadId)?.appServerOwner;
|
|
2147
|
+
this.teardownLiveCodexSession(localThreadId);
|
|
2148
|
+
await owner?.stop().catch(() => undefined);
|
|
1484
2149
|
}
|
|
1485
2150
|
/** Complete the second shutdown phase after the runner has killed all native
|
|
1486
2151
|
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1487
|
-
finalizeStoppedLiveSessions() {
|
|
2152
|
+
async finalizeStoppedLiveSessions() {
|
|
1488
2153
|
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1489
2154
|
forwarder.finalizeStop();
|
|
1490
2155
|
this.pendingClaudeFinalizers.clear();
|
|
@@ -1493,10 +2158,20 @@ export class LocalAgentHost {
|
|
|
1493
2158
|
// that can only reconnect to a dead session. `stop()` sends SIGTERM before
|
|
1494
2159
|
// its returned promise yields, so this remains safe in the synchronous
|
|
1495
2160
|
// runner shutdown path.
|
|
1496
|
-
|
|
1497
|
-
void backend.appServerClient?.stop().catch(() => undefined);
|
|
1498
|
-
}
|
|
2161
|
+
const backendStops = [...this.backends.values()].map((backend) => backend.appServerClient?.stop().catch(() => undefined));
|
|
1499
2162
|
this.backends.clear();
|
|
2163
|
+
await Promise.all([
|
|
2164
|
+
...backendStops,
|
|
2165
|
+
...this.pendingCleanupTasks,
|
|
2166
|
+
]);
|
|
2167
|
+
this.pendingCleanupTasks.clear();
|
|
2168
|
+
}
|
|
2169
|
+
trackCleanup(task) {
|
|
2170
|
+
const guarded = task.catch((error) => {
|
|
2171
|
+
console.warn(`[native-runtime] cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2172
|
+
});
|
|
2173
|
+
this.pendingCleanupTasks.add(guarded);
|
|
2174
|
+
void guarded.then(() => this.pendingCleanupTasks.delete(guarded));
|
|
1500
2175
|
}
|
|
1501
2176
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
1502
2177
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
@@ -1520,14 +2195,14 @@ export class LocalAgentHost {
|
|
|
1520
2195
|
const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
|
|
1521
2196
|
const hookPermissionMode = live.permissionMode;
|
|
1522
2197
|
const hookSettings = buildClaudeHookSettings({
|
|
1523
|
-
bridgeDir:
|
|
2198
|
+
bridgeDir: live.bridgeDir,
|
|
1524
2199
|
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
1525
2200
|
messageDisplay: true,
|
|
1526
2201
|
statusLine: true,
|
|
1527
2202
|
});
|
|
1528
2203
|
const settings = { ...effectiveSettings, ...hookSettings };
|
|
1529
|
-
const settingsPath = writeManagedClaudeSettings(
|
|
1530
|
-
// The live session carries the
|
|
2204
|
+
const settingsPath = writeManagedClaudeSettings(live.bridgeDir, settings);
|
|
2205
|
+
// The live session carries the daemon-owned execution state used by Chat.
|
|
1531
2206
|
const args = buildClaudeTuiArgs({
|
|
1532
2207
|
settingsJson: settingsPath,
|
|
1533
2208
|
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
@@ -1535,26 +2210,32 @@ export class LocalAgentHost {
|
|
|
1535
2210
|
// resolve the same native request while rynx still exposes it as pending.
|
|
1536
2211
|
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1537
2212
|
settingSources: "",
|
|
1538
|
-
model: live.execution.model,
|
|
1539
|
-
...(live.execution.
|
|
1540
|
-
? {
|
|
2213
|
+
...(live.execution.model ? { model: live.execution.model } : {}),
|
|
2214
|
+
...(live.execution.reasoningEffort
|
|
2215
|
+
? { reasoningEffort: live.execution.reasoningEffort }
|
|
2216
|
+
: {}),
|
|
2217
|
+
...(nativeAuthoredInstructions(live.execution)
|
|
2218
|
+
? { appendSystemPrompt: nativeAuthoredInstructions(live.execution) }
|
|
1541
2219
|
: {}),
|
|
1542
2220
|
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
1543
2221
|
...(live.forkIntent
|
|
1544
|
-
?
|
|
1545
|
-
resume: live.forkIntent.
|
|
1546
|
-
|
|
1547
|
-
sessionId: live.forkIntent.targetClaudeSessionId,
|
|
1548
|
-
}
|
|
2222
|
+
? live.forkTranscriptPrepared
|
|
2223
|
+
? { resume: live.forkIntent.targetClaudeSessionId }
|
|
2224
|
+
: { sessionId: live.forkIntent.targetClaudeSessionId }
|
|
1549
2225
|
: record?.codexSessionId
|
|
1550
2226
|
? { resume: record.codexSessionId }
|
|
1551
2227
|
: {}),
|
|
1552
2228
|
...(live.launchExtraArgs.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
1553
2229
|
});
|
|
1554
2230
|
return {
|
|
2231
|
+
lifecycle: "required",
|
|
1555
2232
|
command: resolveRuntimeBinary("claude"),
|
|
1556
2233
|
args,
|
|
1557
2234
|
cwd,
|
|
2235
|
+
scrollback: 50_000,
|
|
2236
|
+
tmuxAllowPassthrough: false,
|
|
2237
|
+
tmuxStartOnAttach: false,
|
|
2238
|
+
keepAliveAfterExit: true,
|
|
1558
2239
|
// Same env the pane would inherit by default, plus the session scope for
|
|
1559
2240
|
// agent-run CLIs (rynx-emulator resolves device-less commands via it).
|
|
1560
2241
|
env: { ...process.env, RYNX_SESSION_ID: localThreadId },
|
|
@@ -1656,30 +2337,117 @@ export class LocalAgentHost {
|
|
|
1656
2337
|
throw error;
|
|
1657
2338
|
}
|
|
1658
2339
|
}
|
|
2340
|
+
/** Repair the only cross-process crash window in native rotation. A target
|
|
2341
|
+
* started through the Session registry proves that logical publication is
|
|
2342
|
+
* durable; a published target in turn proves its source must no longer own
|
|
2343
|
+
* the shared live bridge. */
|
|
2344
|
+
async reconcileClaudeNativeRotation(localThreadId, record) {
|
|
2345
|
+
const records = await this.sessionStore.listAll();
|
|
2346
|
+
let current = record;
|
|
2347
|
+
const ownTarget = current?.nativeRotationSourceSessionId
|
|
2348
|
+
? current
|
|
2349
|
+
: undefined;
|
|
2350
|
+
let promotedOwnTarget = false;
|
|
2351
|
+
const ownTargetSharedBridgeOwner = ownTarget?.bridgeOwnerSessionId;
|
|
2352
|
+
if (ownTarget && !ownTarget.nativeRotationPublished) {
|
|
2353
|
+
promotedOwnTarget = true;
|
|
2354
|
+
current = {
|
|
2355
|
+
...ownTarget,
|
|
2356
|
+
// Recovery cannot resume the interrupted physical handoff. Give the
|
|
2357
|
+
// visible target its own bridge before publishing it, so a source that
|
|
2358
|
+
// was reopened first can never share hooks/cursors with this process.
|
|
2359
|
+
// The persisted initial transcript offset remains the replay boundary.
|
|
2360
|
+
bridgeOwnerSessionId: ownTarget.localThreadId,
|
|
2361
|
+
nativeRotationPublished: true,
|
|
2362
|
+
updatedAt: new Date().toISOString(),
|
|
2363
|
+
};
|
|
2364
|
+
await this.sessionStore.set(current);
|
|
2365
|
+
}
|
|
2366
|
+
const linkedTarget = current?.nativeRotationTargetSessionId
|
|
2367
|
+
? records.find((candidate) => candidate.localThreadId === current?.nativeRotationTargetSessionId &&
|
|
2368
|
+
candidate.nativeRotationPublished &&
|
|
2369
|
+
candidate.nativeRotationSourceSessionId === localThreadId)
|
|
2370
|
+
: undefined;
|
|
2371
|
+
const legacyLatestTarget = records
|
|
2372
|
+
.filter((candidate) => candidate.nativeRotationPublished &&
|
|
2373
|
+
candidate.nativeRotationSourceSessionId === localThreadId)
|
|
2374
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) ||
|
|
2375
|
+
b.localThreadId.localeCompare(a.localThreadId))[0];
|
|
2376
|
+
// A Session can be both an inbound target (A→B) and later an outbound
|
|
2377
|
+
// source (B→C). Its latest outbound edge is authoritative for reopening B;
|
|
2378
|
+
// choosing B's older inbound marker would reopen the bridge now owned by C.
|
|
2379
|
+
const publishedTarget = linkedTarget ?? legacyLatestTarget ??
|
|
2380
|
+
(current?.nativeRotationSourceSessionId && current.nativeRotationPublished
|
|
2381
|
+
? current
|
|
2382
|
+
: undefined);
|
|
2383
|
+
if (publishedTarget) {
|
|
2384
|
+
const source = await this.sessionStore.get(publishedTarget.nativeRotationSourceSessionId);
|
|
2385
|
+
const kind = publishedTarget.nativeRotationKind ?? "clear";
|
|
2386
|
+
const retiredBridgeOwner = `${source?.localThreadId ?? localThreadId}-${kind}-retired-${publishedTarget.localThreadId}`;
|
|
2387
|
+
// A source can rotate more than once after its old conversation is
|
|
2388
|
+
// reopened. Never let opening an historical target roll the source's
|
|
2389
|
+
// authoritative outbound link back from S->T2 to the older S->T1 edge.
|
|
2390
|
+
// Records written before nativeRotationTargetSessionId existed are only
|
|
2391
|
+
// repairable when source and target still prove they share one bridge.
|
|
2392
|
+
const sharedBridgeProof = promotedOwnTarget && publishedTarget.localThreadId === current?.localThreadId
|
|
2393
|
+
? ownTargetSharedBridgeOwner
|
|
2394
|
+
: publishedTarget.bridgeOwnerSessionId;
|
|
2395
|
+
const isAuthoritativeTarget = source && (source.nativeRotationTargetSessionId === publishedTarget.localThreadId ||
|
|
2396
|
+
(!source.nativeRotationTargetSessionId &&
|
|
2397
|
+
source.bridgeOwnerSessionId === sharedBridgeProof));
|
|
2398
|
+
if (isAuthoritativeTarget && source.bridgeOwnerSessionId !== retiredBridgeOwner) {
|
|
2399
|
+
const retired = {
|
|
2400
|
+
...source,
|
|
2401
|
+
bridgeOwnerSessionId: retiredBridgeOwner,
|
|
2402
|
+
nativeRotationTargetSessionId: publishedTarget.localThreadId,
|
|
2403
|
+
updatedAt: new Date().toISOString(),
|
|
2404
|
+
};
|
|
2405
|
+
await this.sessionStore.set(retired);
|
|
2406
|
+
if (source.localThreadId === localThreadId)
|
|
2407
|
+
current = retired;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
return current;
|
|
2411
|
+
}
|
|
1659
2412
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
1660
2413
|
const retargetMirror = opts.retargetMirror;
|
|
2414
|
+
record = await this.reconcileClaudeNativeRotation(localThreadId, record);
|
|
1661
2415
|
const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
|
|
1662
2416
|
if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
|
|
1663
2417
|
throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
|
|
1664
2418
|
}
|
|
1665
|
-
const
|
|
2419
|
+
const runtimeHomeOwnerSessionId = record?.runtimeHomeOwnerSessionId ?? localThreadId;
|
|
2420
|
+
const bridgeOwnerSessionId = record?.bridgeOwnerSessionId ?? runtimeHomeOwnerSessionId;
|
|
2421
|
+
const bridgeDir = prepareClaudeBridgeDir(bridgeOwnerSessionId);
|
|
1666
2422
|
const workspace = structuredClone(opts.workspace);
|
|
1667
2423
|
const execution = structuredClone(opts.execution);
|
|
1668
2424
|
if (execution.provider !== "claude") {
|
|
1669
2425
|
throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
|
|
1670
2426
|
}
|
|
1671
2427
|
const cwd = workspace.cwd;
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
2428
|
+
const forkResumePrefixBytes = forkIntent?.forkTranscriptPrefixBytes;
|
|
2429
|
+
const forkTranscriptPrepared = Boolean(forkIntent?.forkTranscriptPath);
|
|
2430
|
+
const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
|
|
2431
|
+
let skillPlugin = hasSkillSnapshot
|
|
2432
|
+
? await reuseClaudePlugin(localThreadId)
|
|
2433
|
+
: null;
|
|
2434
|
+
if (!skillPlugin && hasSkillSnapshot) {
|
|
2435
|
+
let snapshotSkills;
|
|
2436
|
+
try {
|
|
2437
|
+
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
2438
|
+
}
|
|
2439
|
+
catch (err) {
|
|
2440
|
+
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
try {
|
|
2444
|
+
skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
|
|
2445
|
+
}
|
|
2446
|
+
finally {
|
|
2447
|
+
await snapshotSkills.skillsCleanup();
|
|
2448
|
+
}
|
|
1679
2449
|
}
|
|
1680
|
-
const model = execution.model;
|
|
1681
|
-
const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
|
|
1682
|
-
void snapshotSkills.skillsCleanup();
|
|
2450
|
+
const model = execution.model ?? "";
|
|
1683
2451
|
const permissionMode = execution.permissionMode;
|
|
1684
2452
|
const launchExtraArgs = [
|
|
1685
2453
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
@@ -1690,28 +2458,118 @@ export class LocalAgentHost {
|
|
|
1690
2458
|
let currentSessionId = localThreadId;
|
|
1691
2459
|
let normalizer = null;
|
|
1692
2460
|
let currentResponseId;
|
|
2461
|
+
const subagentParentResponseIds = new Map();
|
|
2462
|
+
const lateSubagentNormalizers = new Map();
|
|
1693
2463
|
let pendingRotationEvents = null;
|
|
1694
|
-
|
|
2464
|
+
let rotationPublication = null;
|
|
2465
|
+
let transcriptDelivery = null;
|
|
2466
|
+
const emitRotationAware = (event, delivery) => {
|
|
1695
2467
|
if (pendingRotationEvents) {
|
|
1696
|
-
pendingRotationEvents.push(event);
|
|
2468
|
+
pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
|
|
1697
2469
|
return;
|
|
1698
2470
|
}
|
|
1699
|
-
emit(event);
|
|
2471
|
+
return emit(event, delivery);
|
|
2472
|
+
};
|
|
2473
|
+
const publishForwarderFailure = (reason, responseId) => {
|
|
2474
|
+
void Promise.resolve().then(() => emitRotationAware({
|
|
2475
|
+
type: "session.status",
|
|
2476
|
+
sessionId: currentSessionId,
|
|
2477
|
+
...(responseId ? { responseId } : {}),
|
|
2478
|
+
status: "failed",
|
|
2479
|
+
backgroundTaskCount: 0,
|
|
2480
|
+
note: reason,
|
|
2481
|
+
}, { policy: "best-effort", lane: "claude-forwarder-failure" })).catch((error) => {
|
|
2482
|
+
console.error("[claude-forwarder] failed to publish poison-item status:", error);
|
|
2483
|
+
});
|
|
2484
|
+
};
|
|
2485
|
+
const emitCurrent = (event, delivery) => {
|
|
2486
|
+
if (pendingRotationEvents) {
|
|
2487
|
+
pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
const source = transcriptDelivery;
|
|
2491
|
+
if (source && isDurableSessionEvent(event)) {
|
|
2492
|
+
const sourceId = `${source.sourceBase}:${source.nextItemIndex}:${event.item.type}`;
|
|
2493
|
+
source.nextItemIndex += 1;
|
|
2494
|
+
source.sourceIds.push(sourceId);
|
|
2495
|
+
if (source.isHandled(sourceId))
|
|
2496
|
+
return;
|
|
2497
|
+
const policy = delivery?.policy ?? "ordinary";
|
|
2498
|
+
const pending = Promise.resolve(emit(event, {
|
|
2499
|
+
policy,
|
|
2500
|
+
sourceId,
|
|
2501
|
+
lane: delivery?.lane ?? source.lane,
|
|
2502
|
+
...(policy === "ordinary"
|
|
2503
|
+
? { deadLetterPath: delivery?.deadLetterPath ?? source.deadLetterPath }
|
|
2504
|
+
: {}),
|
|
2505
|
+
onOutcome: (outcome) => {
|
|
2506
|
+
delivery?.onOutcome?.(outcome);
|
|
2507
|
+
if (policy === "ordinary" &&
|
|
2508
|
+
outcome === "dropped" &&
|
|
2509
|
+
!source.lane.startsWith("claude-subagent:")) {
|
|
2510
|
+
publishForwarderFailure(`transcript item ${sourceId} rejected`, event.item.responseId);
|
|
2511
|
+
}
|
|
2512
|
+
},
|
|
2513
|
+
})).then(() => {
|
|
2514
|
+
source.onHandled?.(sourceId);
|
|
2515
|
+
});
|
|
2516
|
+
source.pending.push(pending);
|
|
2517
|
+
return pending;
|
|
2518
|
+
}
|
|
2519
|
+
return emit(event, delivery ?? { policy: "best-effort" });
|
|
1700
2520
|
};
|
|
1701
2521
|
const startNormalizer = (turnId) => {
|
|
1702
2522
|
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1703
|
-
const responseId =
|
|
1704
|
-
(turnId ? `resp_claude_${turnId}` : "resp_claude_native");
|
|
2523
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1705
2524
|
if (normalizer && currentResponseId === responseId)
|
|
1706
2525
|
return normalizer;
|
|
1707
2526
|
currentResponseId = responseId;
|
|
1708
2527
|
normalizer = new SessionNormalizer({
|
|
1709
2528
|
sessionId: currentSessionId,
|
|
1710
2529
|
responseId,
|
|
1711
|
-
model,
|
|
2530
|
+
model: model || "claude",
|
|
1712
2531
|
});
|
|
1713
2532
|
return normalizer;
|
|
1714
2533
|
};
|
|
2534
|
+
const rememberSubagentParentResponse = (parentToolCallId, responseId) => {
|
|
2535
|
+
if (subagentParentResponseIds.has(parentToolCallId))
|
|
2536
|
+
return;
|
|
2537
|
+
subagentParentResponseIds.set(parentToolCallId, responseId);
|
|
2538
|
+
while (subagentParentResponseIds.size > 2_000) {
|
|
2539
|
+
const oldest = subagentParentResponseIds.keys().next().value;
|
|
2540
|
+
if (!oldest)
|
|
2541
|
+
break;
|
|
2542
|
+
subagentParentResponseIds.delete(oldest);
|
|
2543
|
+
lateSubagentNormalizers.delete(oldest);
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
const subagentNormalizer = (parentToolCallId) => {
|
|
2547
|
+
let value = lateSubagentNormalizers.get(parentToolCallId);
|
|
2548
|
+
if (!value) {
|
|
2549
|
+
const responseId = subagentParentResponseIds.get(parentToolCallId) ??
|
|
2550
|
+
`resp_claude_parent_${createHash("sha256")
|
|
2551
|
+
.update(parentToolCallId)
|
|
2552
|
+
.digest("hex")
|
|
2553
|
+
.slice(0, 20)}`;
|
|
2554
|
+
value = new SessionNormalizer({
|
|
2555
|
+
sessionId: currentSessionId,
|
|
2556
|
+
responseId,
|
|
2557
|
+
model: model || "claude",
|
|
2558
|
+
});
|
|
2559
|
+
lateSubagentNormalizers.set(parentToolCallId, value);
|
|
2560
|
+
}
|
|
2561
|
+
return value;
|
|
2562
|
+
};
|
|
2563
|
+
const emitSubagentEvents = (events) => {
|
|
2564
|
+
for (const event of events) {
|
|
2565
|
+
// Rynx keeps native child items nested in the parent Session, but its
|
|
2566
|
+
// child normalizer remains
|
|
2567
|
+
// lifecycle-isolated and must never open/reopen a parent Response.
|
|
2568
|
+
if ((event.type === "response.created" || event.type === "session.status"))
|
|
2569
|
+
continue;
|
|
2570
|
+
emitCurrent(event);
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
1715
2573
|
const forwardInteraction = (event) => {
|
|
1716
2574
|
const n = normalizer ?? startNormalizer(event.turnId);
|
|
1717
2575
|
const agentEvent = event.type === "requested"
|
|
@@ -1727,8 +2585,20 @@ export class LocalAgentHost {
|
|
|
1727
2585
|
interactionId: event.interactionId,
|
|
1728
2586
|
...(event.reason ? { reason: event.reason } : {}),
|
|
1729
2587
|
};
|
|
1730
|
-
|
|
1731
|
-
|
|
2588
|
+
// Permission/question elicitation stays on a separate live hook
|
|
2589
|
+
// side-channel rather than the durable transcript forwarder. Rynx stores
|
|
2590
|
+
// a delivered card for UI replay, but intentionally does not make this
|
|
2591
|
+
// bridge spool advance or block the transcript cursor.
|
|
2592
|
+
for (const se of n.next(agentEvent)) {
|
|
2593
|
+
try {
|
|
2594
|
+
void Promise.resolve(emitRotationAware(se, { policy: "best-effort", lane: "claude-interaction" })).catch((error) => {
|
|
2595
|
+
console.error("[claude-forwarder] interaction mirror failed:", error);
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
catch (error) {
|
|
2599
|
+
console.error("[claude-forwarder] interaction mirror failed:", error);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
1732
2602
|
};
|
|
1733
2603
|
let markReady;
|
|
1734
2604
|
const ready = new Promise((resolve) => {
|
|
@@ -1743,9 +2613,14 @@ export class LocalAgentHost {
|
|
|
1743
2613
|
workspace,
|
|
1744
2614
|
execution,
|
|
1745
2615
|
bridgeDir,
|
|
2616
|
+
runtimeHomeOwnerSessionId,
|
|
2617
|
+
bridgeOwnerSessionId,
|
|
1746
2618
|
injectLock: Promise.resolve(),
|
|
2619
|
+
rotationPending: false,
|
|
1747
2620
|
pendingImageInputs: new Map(),
|
|
1748
2621
|
pendingInjectedInputs: [],
|
|
2622
|
+
currentResponseId: () => currentResponseId,
|
|
2623
|
+
publishInterrupted: () => undefined,
|
|
1749
2624
|
ready,
|
|
1750
2625
|
markReady,
|
|
1751
2626
|
failed,
|
|
@@ -1755,9 +2630,93 @@ export class LocalAgentHost {
|
|
|
1755
2630
|
launchExtraArgs,
|
|
1756
2631
|
permissionMode,
|
|
1757
2632
|
...(forkIntent ? { forkIntent } : {}),
|
|
1758
|
-
...(
|
|
2633
|
+
...(forkResumePrefixBytes !== undefined ? { forkResumePrefixBytes } : {}),
|
|
2634
|
+
...(forkTranscriptPrepared ? { forkTranscriptPrepared: true } : {}),
|
|
2635
|
+
};
|
|
2636
|
+
live.publishInterrupted = (responseId) => {
|
|
2637
|
+
if (live.interruptedResponseId === responseId)
|
|
2638
|
+
return;
|
|
2639
|
+
live.interruptedResponseId = responseId;
|
|
2640
|
+
emitCurrent({
|
|
2641
|
+
type: "session.interrupted",
|
|
2642
|
+
sessionId: currentSessionId,
|
|
2643
|
+
responseId,
|
|
2644
|
+
});
|
|
2645
|
+
};
|
|
2646
|
+
const settleClaudeTurn = (interrupted, usage, backgroundTaskCount, reason) => {
|
|
2647
|
+
if (!normalizer)
|
|
2648
|
+
return;
|
|
2649
|
+
const settlementDelivery = reason === "rotation"
|
|
2650
|
+
? { policy: "best-effort", lane: "control" }
|
|
2651
|
+
: undefined;
|
|
2652
|
+
const publishSettlement = (event) => {
|
|
2653
|
+
void emitCurrent(event, settlementDelivery);
|
|
2654
|
+
};
|
|
2655
|
+
const rid = currentResponseId;
|
|
2656
|
+
if (interrupted) {
|
|
2657
|
+
for (const se of normalizer.interrupt()) {
|
|
2658
|
+
if (se.type === "session.interrupted" &&
|
|
2659
|
+
live.interruptedResponseId === rid)
|
|
2660
|
+
continue;
|
|
2661
|
+
publishSettlement(se);
|
|
2662
|
+
}
|
|
2663
|
+
live.interruptedResponseId = undefined;
|
|
2664
|
+
}
|
|
2665
|
+
else {
|
|
2666
|
+
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
2667
|
+
if (usage) {
|
|
2668
|
+
for (const se of normalizer.next({ type: "turn_completed", usage })) {
|
|
2669
|
+
publishSettlement(se);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
2673
|
+
publishSettlement(se.type === "session.status" && backgroundTaskCount !== undefined
|
|
2674
|
+
? { ...se, backgroundTaskCount }
|
|
2675
|
+
: se);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2679
|
+
normalizer = null;
|
|
2680
|
+
currentResponseId = undefined;
|
|
2681
|
+
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
2682
|
+
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
2683
|
+
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
2684
|
+
if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
2685
|
+
live.contextWarned = true;
|
|
2686
|
+
publishSettlement({
|
|
2687
|
+
type: "session.status",
|
|
2688
|
+
sessionId: currentSessionId,
|
|
2689
|
+
responseId: rid,
|
|
2690
|
+
status: "idle",
|
|
2691
|
+
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
1759
2694
|
};
|
|
1760
2695
|
const sink = {
|
|
2696
|
+
onTranscriptRecordStart: (sourceBase, isHandled, delivery) => {
|
|
2697
|
+
if (transcriptDelivery) {
|
|
2698
|
+
throw new Error("Claude transcript delivery record overlapped");
|
|
2699
|
+
}
|
|
2700
|
+
transcriptDelivery = {
|
|
2701
|
+
sourceBase,
|
|
2702
|
+
nextItemIndex: 0,
|
|
2703
|
+
isHandled,
|
|
2704
|
+
sourceIds: [],
|
|
2705
|
+
pending: [],
|
|
2706
|
+
lane: delivery?.lane ?? "claude-main",
|
|
2707
|
+
deadLetterPath: delivery?.deadLetterPath ??
|
|
2708
|
+
path.join(bridgeDir, "dead_letter.jsonl"),
|
|
2709
|
+
...(delivery?.onHandled ? { onHandled: delivery.onHandled } : {}),
|
|
2710
|
+
};
|
|
2711
|
+
},
|
|
2712
|
+
onTranscriptRecordEnd: async () => {
|
|
2713
|
+
const source = transcriptDelivery;
|
|
2714
|
+
if (!source)
|
|
2715
|
+
return [];
|
|
2716
|
+
transcriptDelivery = null;
|
|
2717
|
+
await Promise.all(source.pending);
|
|
2718
|
+
return source.sourceIds;
|
|
2719
|
+
},
|
|
1761
2720
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1762
2721
|
onUserMessage: (text) => {
|
|
1763
2722
|
const n = normalizer ?? startNormalizer();
|
|
@@ -1766,141 +2725,361 @@ export class LocalAgentHost {
|
|
|
1766
2725
|
if (token && content)
|
|
1767
2726
|
live.pendingImageInputs.delete(token);
|
|
1768
2727
|
const normalizedContent = content ?? [{ type: "input_text", text }];
|
|
1769
|
-
const
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
|
|
1773
|
-
return;
|
|
1774
|
-
}
|
|
1775
|
-
if (pending)
|
|
2728
|
+
const pending = live.pendingInjectedInputs[0];
|
|
2729
|
+
if (pending) {
|
|
2730
|
+
pending.responseId = currentResponseId;
|
|
1776
2731
|
pending.observed = true;
|
|
2732
|
+
if (pending.state === "accepted") {
|
|
2733
|
+
live.pendingInjectedInputs.shift();
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
1777
2736
|
for (const se of n.userInput(normalizedContent))
|
|
1778
2737
|
emitCurrent(se);
|
|
1779
2738
|
},
|
|
2739
|
+
onMetaUserMessage: (text, sourceKey, parentToolCallId) => {
|
|
2740
|
+
const responseId = parentToolCallId
|
|
2741
|
+
? subagentParentResponseIds.get(parentToolCallId) ??
|
|
2742
|
+
`resp_claude_parent_${createHash("sha256")
|
|
2743
|
+
.update(parentToolCallId)
|
|
2744
|
+
.digest("hex")
|
|
2745
|
+
.slice(0, 20)}`
|
|
2746
|
+
: `resp_claude_meta_${createHash("sha256")
|
|
2747
|
+
.update(sourceKey ?? text)
|
|
2748
|
+
.digest("hex")
|
|
2749
|
+
.slice(0, 20)}`;
|
|
2750
|
+
const metaNormalizer = new SessionNormalizer({
|
|
2751
|
+
sessionId: currentSessionId,
|
|
2752
|
+
responseId,
|
|
2753
|
+
model: model || "claude",
|
|
2754
|
+
});
|
|
2755
|
+
for (const event of metaNormalizer.userInput(text, parentToolCallId)) {
|
|
2756
|
+
if (event.type !== "session.input.consumed" || event.item.type !== "message")
|
|
2757
|
+
continue;
|
|
2758
|
+
emitCurrent({
|
|
2759
|
+
...event,
|
|
2760
|
+
item: {
|
|
2761
|
+
...event.item,
|
|
2762
|
+
data: { ...event.item.data, isMeta: true },
|
|
2763
|
+
},
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
},
|
|
2767
|
+
onSubagentUserMessage: (text, parentToolCallId) => {
|
|
2768
|
+
const nested = subagentNormalizer(parentToolCallId);
|
|
2769
|
+
emitSubagentEvents(nested.userInput(text, parentToolCallId));
|
|
2770
|
+
},
|
|
2771
|
+
onSubagentTerminalCommand: (command, parentToolCallId) => {
|
|
2772
|
+
const nested = subagentNormalizer(parentToolCallId);
|
|
2773
|
+
emitSubagentEvents(nested.terminalCommand(command, parentToolCallId));
|
|
2774
|
+
},
|
|
2775
|
+
resolveSubagentParentResponseId: (parentToolCallId) => subagentParentResponseIds.get(parentToolCallId),
|
|
2776
|
+
onSubagentParentResponse: (parentToolCallId, responseId) => {
|
|
2777
|
+
rememberSubagentParentResponse(parentToolCallId, responseId);
|
|
2778
|
+
},
|
|
1780
2779
|
onTerminalCommand: (cmd) => {
|
|
1781
2780
|
const n = normalizer ?? startNormalizer();
|
|
1782
2781
|
for (const se of n.terminalCommand(cmd))
|
|
1783
2782
|
emitCurrent(se);
|
|
1784
2783
|
},
|
|
1785
2784
|
onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
|
|
2785
|
+
onCompactionStatus: (status) => {
|
|
2786
|
+
void emitCurrent({
|
|
2787
|
+
type: `response.compaction.${status}`,
|
|
2788
|
+
sessionId: currentSessionId,
|
|
2789
|
+
...(currentResponseId ? { responseId: currentResponseId } : {}),
|
|
2790
|
+
}, { policy: "best-effort", lane: "claude-compaction" });
|
|
2791
|
+
},
|
|
2792
|
+
onCompactionBoundary: (summary, sequence, source) => {
|
|
2793
|
+
let outcome;
|
|
2794
|
+
const responseId = currentResponseId ?? "resp_claude_native";
|
|
2795
|
+
const delivery = emitCurrent({
|
|
2796
|
+
type: "response.output_item.done",
|
|
2797
|
+
responseId,
|
|
2798
|
+
item: {
|
|
2799
|
+
id: newSessionItemId("compaction"),
|
|
2800
|
+
sessionId: currentSessionId,
|
|
2801
|
+
position: 0,
|
|
2802
|
+
responseId,
|
|
2803
|
+
status: "completed",
|
|
2804
|
+
createdAt: Date.now(),
|
|
2805
|
+
type: "compaction",
|
|
2806
|
+
data: { summary, sequence },
|
|
2807
|
+
},
|
|
2808
|
+
}, {
|
|
2809
|
+
policy: source === "transcript" ? "compaction" : "compaction-hook",
|
|
2810
|
+
// A transcript compaction is still one ordered transcript record.
|
|
2811
|
+
// Only the independent hook boundary gets its own delivery lane.
|
|
2812
|
+
lane: source === "transcript" ? "claude-main" : "claude-compaction",
|
|
2813
|
+
onOutcome: (value) => { outcome = value; },
|
|
2814
|
+
});
|
|
2815
|
+
return Promise.resolve(delivery).then(() => outcome === undefined || outcome === "confirmed" || outcome === "ambiguous");
|
|
2816
|
+
},
|
|
1786
2817
|
onEvent: (event) => {
|
|
2818
|
+
const parentToolCallId = "parentToolUseId" in event &&
|
|
2819
|
+
typeof event.parentToolUseId === "string"
|
|
2820
|
+
? event.parentToolUseId
|
|
2821
|
+
: undefined;
|
|
2822
|
+
if (parentToolCallId) {
|
|
2823
|
+
const nested = subagentNormalizer(parentToolCallId);
|
|
2824
|
+
emitSubagentEvents(nested.next(event));
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
1787
2827
|
const n = normalizer ?? startNormalizer();
|
|
2828
|
+
if (currentResponseId &&
|
|
2829
|
+
event.type === "tool" &&
|
|
2830
|
+
event.event === "on_tool_start") {
|
|
2831
|
+
const input = typeof event.input === "object" && event.input !== null
|
|
2832
|
+
? event.input
|
|
2833
|
+
: {};
|
|
2834
|
+
const data = typeof event.data === "object" && event.data !== null
|
|
2835
|
+
? event.data
|
|
2836
|
+
: {};
|
|
2837
|
+
const parentId = typeof input.id === "string"
|
|
2838
|
+
? input.id
|
|
2839
|
+
: typeof data.id === "string"
|
|
2840
|
+
? data.id
|
|
2841
|
+
: undefined;
|
|
2842
|
+
if (parentId)
|
|
2843
|
+
rememberSubagentParentResponse(parentId, currentResponseId);
|
|
2844
|
+
}
|
|
1788
2845
|
for (const se of n.next(event))
|
|
1789
2846
|
emitCurrent(se);
|
|
1790
2847
|
},
|
|
1791
2848
|
onInteraction: forwardInteraction,
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
emitCurrent(se);
|
|
1803
|
-
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1804
|
-
normalizer = null;
|
|
1805
|
-
currentResponseId = undefined;
|
|
1806
|
-
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1807
|
-
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1808
|
-
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1809
|
-
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1810
|
-
live.contextWarned = true;
|
|
1811
|
-
emitCurrent({
|
|
1812
|
-
type: "session.status",
|
|
1813
|
-
sessionId: currentSessionId,
|
|
1814
|
-
responseId: rid,
|
|
1815
|
-
status: "idle",
|
|
1816
|
-
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
2849
|
+
onStatus: (status, blockedOn) => {
|
|
2850
|
+
const responseId = currentResponseId ??
|
|
2851
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
2852
|
+
emitCurrent({
|
|
2853
|
+
type: "session.status",
|
|
2854
|
+
sessionId: currentSessionId,
|
|
2855
|
+
...(responseId ? { responseId } : {}),
|
|
2856
|
+
status,
|
|
2857
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
2858
|
+
});
|
|
1819
2859
|
},
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2860
|
+
onTurnEnd: (usage, backgroundTaskCount, reason) => settleClaudeTurn(false, usage, backgroundTaskCount, reason),
|
|
2861
|
+
onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
|
|
2862
|
+
onTurnInterruptRequested: () => {
|
|
2863
|
+
const responseId = currentResponseId;
|
|
2864
|
+
if (responseId)
|
|
2865
|
+
live.publishInterrupted(responseId);
|
|
2866
|
+
},
|
|
2867
|
+
onIdle: (backgroundTaskCount, sourceId) => {
|
|
2868
|
+
// Surface Stop even after its Turn has already closed: a later
|
|
2869
|
+
// authoritative zero is what clears a sticky background-shell tally.
|
|
2870
|
+
// When the Turn is still open, retain its identity without finalizing it
|
|
2871
|
+
// so a late assistant record continues to join the same Response.
|
|
2872
|
+
return emitCurrent({
|
|
2873
|
+
type: "session.status",
|
|
2874
|
+
sessionId: currentSessionId,
|
|
2875
|
+
...(currentResponseId ? { responseId: currentResponseId } : {}),
|
|
2876
|
+
status: "idle",
|
|
2877
|
+
...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
|
|
2878
|
+
}, {
|
|
2879
|
+
policy: "terminal-status",
|
|
2880
|
+
lane: "claude-status",
|
|
2881
|
+
...(sourceId ? { sourceId } : {}),
|
|
2882
|
+
onOutcome: (outcome) => {
|
|
2883
|
+
if (outcome === "dropped") {
|
|
2884
|
+
publishForwarderFailure(`hook status idle rejected`, currentResponseId);
|
|
2885
|
+
}
|
|
2886
|
+
},
|
|
2887
|
+
});
|
|
2888
|
+
},
|
|
2889
|
+
onTurnError: (error, sourceId) => {
|
|
2890
|
+
const message = error.message || "Agent turn failed";
|
|
2891
|
+
if (!normalizer) {
|
|
2892
|
+
// Native pane/forwarder death is Session-level even when Claude is
|
|
2893
|
+
// between Turns. Emit a bare failed edge; the
|
|
2894
|
+
// explicit zero also retires any sticky background-shell tally.
|
|
2895
|
+
return emitCurrent({
|
|
1825
2896
|
type: "session.status",
|
|
1826
2897
|
sessionId: currentSessionId,
|
|
1827
|
-
|
|
1828
|
-
|
|
2898
|
+
status: "failed",
|
|
2899
|
+
backgroundTaskCount: 0,
|
|
2900
|
+
note: message,
|
|
2901
|
+
}, {
|
|
2902
|
+
policy: "terminal-status",
|
|
2903
|
+
lane: "claude-status",
|
|
2904
|
+
...(sourceId ? { sourceId } : {}),
|
|
1829
2905
|
});
|
|
1830
2906
|
}
|
|
1831
|
-
},
|
|
1832
|
-
onTurnError: () => {
|
|
1833
|
-
if (!normalizer)
|
|
1834
|
-
return;
|
|
1835
2907
|
const rid = currentResponseId;
|
|
2908
|
+
const deliveries = [];
|
|
2909
|
+
let itemIndex = 0;
|
|
1836
2910
|
for (const se of normalizer.fail({
|
|
1837
2911
|
code: "agent_error",
|
|
1838
|
-
message
|
|
2912
|
+
message,
|
|
1839
2913
|
source: "execution",
|
|
1840
|
-
}))
|
|
1841
|
-
|
|
2914
|
+
})) {
|
|
2915
|
+
const event = se.type === "session.status"
|
|
2916
|
+
? { ...se, status: "failed", note: message }
|
|
2917
|
+
: se;
|
|
2918
|
+
const durable = isDurableSessionEvent(event);
|
|
2919
|
+
const eventSourceId = sourceId
|
|
2920
|
+
? `${sourceId}:${itemIndex++}:${durable && "item" in event ? event.item.type : event.type}`
|
|
2921
|
+
: undefined;
|
|
2922
|
+
const delivered = emitCurrent(event, {
|
|
2923
|
+
policy: durable ? "ordinary" : "terminal-status",
|
|
2924
|
+
lane: "claude-status",
|
|
2925
|
+
...(eventSourceId ? { sourceId: eventSourceId } : {}),
|
|
2926
|
+
...(durable
|
|
2927
|
+
? { deadLetterPath: path.join(bridgeDir, "dead_letter.jsonl") }
|
|
2928
|
+
: {}),
|
|
2929
|
+
});
|
|
2930
|
+
if (delivered)
|
|
2931
|
+
deliveries.push(Promise.resolve(delivered));
|
|
2932
|
+
}
|
|
1842
2933
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
1843
2934
|
normalizer = null;
|
|
1844
2935
|
currentResponseId = undefined;
|
|
2936
|
+
return Promise.all(deliveries).then(() => undefined);
|
|
1845
2937
|
},
|
|
1846
2938
|
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
|
|
1847
2939
|
onSessionResumeError: (error) => {
|
|
1848
2940
|
live.error = error.message;
|
|
1849
2941
|
live.markFailed();
|
|
1850
2942
|
},
|
|
1851
|
-
onSessionRotated: (kind, claudeSessionId) => {
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2943
|
+
onSessionRotated: (kind, claudeSessionId, _transcriptPath, initialTranscriptOffset) => {
|
|
2944
|
+
let publication = rotationPublication?.claudeSessionId === claudeSessionId &&
|
|
2945
|
+
rotationPublication.kind === kind
|
|
2946
|
+
? rotationPublication
|
|
2947
|
+
: null;
|
|
2948
|
+
if (publication?.operation)
|
|
2949
|
+
return publication.operation;
|
|
2950
|
+
if (publication?.completed)
|
|
2951
|
+
return Promise.resolve();
|
|
2952
|
+
if (!publication && pendingRotationEvents) {
|
|
2953
|
+
return Promise.reject(new Error("Claude reported another Session rotation before publication completed"));
|
|
1856
2954
|
}
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
// the OLD
|
|
1860
|
-
//
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
void this.sessionStore
|
|
1870
|
-
.set({
|
|
1871
|
-
localThreadId: newSessionId,
|
|
1872
|
-
codexSessionId: claudeSessionId,
|
|
1873
|
-
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1874
|
-
updatedAt: new Date().toISOString(),
|
|
1875
|
-
})
|
|
1876
|
-
.then(() => {
|
|
1877
|
-
emit({
|
|
1878
|
-
type: "session.rotated",
|
|
1879
|
-
sessionId: previousSessionId,
|
|
1880
|
-
newSessionId,
|
|
2955
|
+
// Persist the Provider binding, then wait for the runner manager to
|
|
2956
|
+
// publish and ACK the new alias. The parent announces the rotation on
|
|
2957
|
+
// the OLD Session only after that target publication succeeds.
|
|
2958
|
+
// The forwarder does not consume the SessionStart hook or switch source
|
|
2959
|
+
// state until this operation resolves.
|
|
2960
|
+
if (!publication) {
|
|
2961
|
+
// Claude has already crossed its physical clear/fork boundary by the
|
|
2962
|
+
// time this hook arrives. Fence injection synchronously, before the
|
|
2963
|
+
// first asynchronous target-store operation or IPC publication.
|
|
2964
|
+
live.rotationPending = true;
|
|
2965
|
+
publication = {
|
|
2966
|
+
claudeSessionId,
|
|
1881
2967
|
kind,
|
|
2968
|
+
initialTranscriptOffset,
|
|
2969
|
+
previousSessionId: currentSessionId,
|
|
2970
|
+
newSessionId: makeSessionId(),
|
|
2971
|
+
};
|
|
2972
|
+
rotationPublication = publication;
|
|
2973
|
+
}
|
|
2974
|
+
const { previousSessionId } = publication;
|
|
2975
|
+
// An exact publication retry keeps events buffered during the failed
|
|
2976
|
+
// attempt; they already belong to this same post-rotation target.
|
|
2977
|
+
pendingRotationEvents ??= [];
|
|
2978
|
+
const operation = this.sessionStore
|
|
2979
|
+
.findByCodexSessionId(claudeSessionId)
|
|
2980
|
+
.then(async (existingTarget) => {
|
|
2981
|
+
if (existingTarget &&
|
|
2982
|
+
existingTarget.localThreadId !== previousSessionId) {
|
|
2983
|
+
publication.newSessionId = existingTarget.localThreadId;
|
|
2984
|
+
}
|
|
2985
|
+
if (existingTarget?.nativeRotationInitialTranscriptOffset !== undefined) {
|
|
2986
|
+
publication.initialTranscriptOffset =
|
|
2987
|
+
existingTarget.nativeRotationInitialTranscriptOffset;
|
|
2988
|
+
}
|
|
2989
|
+
await this.sessionStore.set({
|
|
2990
|
+
localThreadId: publication.newSessionId,
|
|
2991
|
+
codexSessionId: claudeSessionId,
|
|
2992
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
2993
|
+
runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
|
|
2994
|
+
bridgeOwnerSessionId: live.bridgeOwnerSessionId,
|
|
2995
|
+
nativeRotationSourceSessionId: previousSessionId,
|
|
2996
|
+
nativeRotationKind: kind,
|
|
2997
|
+
nativeRotationInitialTranscriptOffset: publication.initialTranscriptOffset,
|
|
2998
|
+
...(existingTarget?.nativeRotationPublished
|
|
2999
|
+
? { nativeRotationPublished: true }
|
|
3000
|
+
: {}),
|
|
3001
|
+
updatedAt: new Date().toISOString(),
|
|
1882
3002
|
});
|
|
1883
|
-
|
|
3003
|
+
})
|
|
3004
|
+
.then(async () => {
|
|
3005
|
+
const applyHostTarget = async () => {
|
|
3006
|
+
if (publication.hostApplied)
|
|
3007
|
+
return;
|
|
3008
|
+
const newSessionId = publication.newSessionId;
|
|
3009
|
+
const targetBinding = await this.sessionStore.get(newSessionId);
|
|
3010
|
+
if (targetBinding && !targetBinding.nativeRotationPublished) {
|
|
3011
|
+
await this.sessionStore.set({
|
|
3012
|
+
...targetBinding,
|
|
3013
|
+
nativeRotationPublished: true,
|
|
3014
|
+
updatedAt: new Date().toISOString(),
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
const sourceBinding = await this.sessionStore.get(previousSessionId);
|
|
3018
|
+
const retiredBridgeOwner = `${previousSessionId}-${kind}-retired-${newSessionId}`;
|
|
3019
|
+
if (sourceBinding &&
|
|
3020
|
+
sourceBinding.bridgeOwnerSessionId !== retiredBridgeOwner) {
|
|
3021
|
+
await this.sessionStore.set({
|
|
3022
|
+
...sourceBinding,
|
|
3023
|
+
bridgeOwnerSessionId: retiredBridgeOwner,
|
|
3024
|
+
nativeRotationTargetSessionId: newSessionId,
|
|
3025
|
+
updatedAt: new Date().toISOString(),
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
currentSessionId = newSessionId;
|
|
3029
|
+
normalizer = null;
|
|
3030
|
+
currentResponseId = undefined;
|
|
3031
|
+
live.contextWarned = false;
|
|
3032
|
+
this.liveClaudeSessions.set(newSessionId, live);
|
|
3033
|
+
this.liveClaudeSessions.delete(previousSessionId);
|
|
3034
|
+
const queued = pendingRotationEvents ?? [];
|
|
3035
|
+
pendingRotationEvents = null;
|
|
3036
|
+
live.rotationPending = false;
|
|
3037
|
+
publication.hostApplied = true;
|
|
3038
|
+
for (const entry of queued) {
|
|
3039
|
+
emit(retargetSessionEvent(entry.event, newSessionId), entry.delivery);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
await Promise.resolve(retargetMirror?.(publication.newSessionId, {
|
|
1884
3043
|
kind,
|
|
1885
3044
|
workspace: structuredClone(live.workspace),
|
|
1886
3045
|
execution: structuredClone(live.execution),
|
|
1887
3046
|
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
3047
|
+
beforeApplied: applyHostTarget,
|
|
3048
|
+
}));
|
|
3049
|
+
if (!publication.hostApplied) {
|
|
3050
|
+
throw new Error("Claude retarget completed without applying the host target");
|
|
3051
|
+
}
|
|
3052
|
+
publication.completed = true;
|
|
1893
3053
|
})
|
|
1894
3054
|
.catch((error) => {
|
|
1895
|
-
|
|
3055
|
+
// Keep the fence and buffered events across an exact publication
|
|
3056
|
+
// retry. Re-opening either here would leak post-clear work back to
|
|
3057
|
+
// the retired logical Session.
|
|
1896
3058
|
live.error = error instanceof Error ? error.message : String(error);
|
|
1897
3059
|
live.markFailed();
|
|
3060
|
+
if (typeof error === "object" &&
|
|
3061
|
+
error !== null &&
|
|
3062
|
+
"classification" in error &&
|
|
3063
|
+
error.classification === "permanent") {
|
|
3064
|
+
// A deterministic target-publication rejection cannot recover by
|
|
3065
|
+
// polling the same hook forever. Keep injection fenced and stop
|
|
3066
|
+
// the forwarder so restart/operator action is explicit.
|
|
3067
|
+
queueMicrotask(() => live.forwarder.stop());
|
|
3068
|
+
}
|
|
3069
|
+
throw error;
|
|
3070
|
+
})
|
|
3071
|
+
.finally(() => {
|
|
3072
|
+
if (rotationPublication === publication) {
|
|
3073
|
+
delete publication.operation;
|
|
3074
|
+
}
|
|
1898
3075
|
});
|
|
3076
|
+
publication.operation = operation;
|
|
3077
|
+
return operation;
|
|
1899
3078
|
},
|
|
1900
3079
|
};
|
|
1901
|
-
// Claude itself resolves `--resume <id>`.
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
3080
|
+
// Claude itself resolves `--resume <id>`. A managed fork pre-writes and
|
|
3081
|
+
// measures its target prefix, but SessionStart remains authoritative for
|
|
3082
|
+
// the transcript path the forwarder actually tails.
|
|
1904
3083
|
live.forwarder = new ClaudeLiveSession({
|
|
1905
3084
|
bridgeDir,
|
|
1906
3085
|
sink,
|
|
@@ -1909,7 +3088,16 @@ export class LocalAgentHost {
|
|
|
1909
3088
|
: record?.codexSessionId
|
|
1910
3089
|
? { claudeSessionId: record.codexSessionId }
|
|
1911
3090
|
: {}),
|
|
1912
|
-
...((
|
|
3091
|
+
...((record?.codexSessionId || forkTranscriptPrepared)
|
|
3092
|
+
? { resumeAtEndOnDiscovery: true }
|
|
3093
|
+
: {}),
|
|
3094
|
+
...(forkResumePrefixBytes !== undefined
|
|
3095
|
+
? { initialTranscriptOffsetOnDiscovery: forkResumePrefixBytes }
|
|
3096
|
+
: record?.nativeRotationInitialTranscriptOffset !== undefined
|
|
3097
|
+
? {
|
|
3098
|
+
initialTranscriptOffsetOnDiscovery: record.nativeRotationInitialTranscriptOffset,
|
|
3099
|
+
}
|
|
3100
|
+
: {}),
|
|
1913
3101
|
});
|
|
1914
3102
|
this.liveClaudeSessions.set(localThreadId, live);
|
|
1915
3103
|
live.forwarder.start();
|
|
@@ -1929,12 +3117,16 @@ export class LocalAgentHost {
|
|
|
1929
3117
|
}
|
|
1930
3118
|
live.discoveredReady = true;
|
|
1931
3119
|
void this.sessionStore
|
|
1932
|
-
.
|
|
3120
|
+
.get(localThreadId)
|
|
3121
|
+
.then((existing) => this.sessionStore.set({
|
|
3122
|
+
...existing,
|
|
1933
3123
|
localThreadId,
|
|
1934
3124
|
codexSessionId: claudeSessionId,
|
|
1935
3125
|
...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
|
|
3126
|
+
runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
|
|
3127
|
+
bridgeOwnerSessionId: live.bridgeOwnerSessionId,
|
|
1936
3128
|
updatedAt: new Date().toISOString(),
|
|
1937
|
-
})
|
|
3129
|
+
}))
|
|
1938
3130
|
.then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
|
|
1939
3131
|
.then(() => live.markReady())
|
|
1940
3132
|
.catch((error) => {
|
|
@@ -1946,17 +3138,27 @@ export class LocalAgentHost {
|
|
|
1946
3138
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1947
3139
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1948
3140
|
* appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
|
|
1949
|
-
* fall-through-to-run signal. Returns {@link
|
|
1950
|
-
injectClaude(live, localThreadId, text) {
|
|
3141
|
+
* fall-through-to-run signal. Returns {@link InjectResult}. */
|
|
3142
|
+
injectClaude(live, localThreadId, text, pendingInput) {
|
|
3143
|
+
if (live.rotationPending || live.stopped) {
|
|
3144
|
+
return Promise.resolve({ outcome: "notReady" });
|
|
3145
|
+
}
|
|
1951
3146
|
const run = live.injectLock.then(async () => {
|
|
3147
|
+
if (live.rotationPending || live.stopped)
|
|
3148
|
+
return { outcome: "notReady" };
|
|
1952
3149
|
const ready = await this.waitLiveReady(localThreadId, 60_000);
|
|
1953
3150
|
if (!ready)
|
|
1954
|
-
return "notReady";
|
|
3151
|
+
return { outcome: "notReady" };
|
|
3152
|
+
if (live.rotationPending || live.stopped)
|
|
3153
|
+
return { outcome: "notReady" };
|
|
1955
3154
|
// Pane may have just relaunched — park until its injector re-attaches
|
|
1956
3155
|
// (attachTerminalInjector resets it) instead of returning false → fallback.
|
|
1957
3156
|
const injector = await this.waitInjector(live, 60_000);
|
|
1958
3157
|
if (!injector)
|
|
1959
|
-
return "notReady";
|
|
3158
|
+
return { outcome: "notReady" };
|
|
3159
|
+
if (live.rotationPending || live.stopped)
|
|
3160
|
+
return { outcome: "notReady" };
|
|
3161
|
+
const steered = live.forwarder.isTurnOpen();
|
|
1960
3162
|
// Abortable: the web Stop button cancels an in-flight paste/submit (before
|
|
1961
3163
|
// the message reaches claude) via interruptLive → injectAbort.abort().
|
|
1962
3164
|
const abort = new AbortController();
|
|
@@ -1967,10 +3169,17 @@ export class LocalAgentHost {
|
|
|
1967
3169
|
signal: abort.signal,
|
|
1968
3170
|
submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
|
|
1969
3171
|
});
|
|
1970
|
-
|
|
3172
|
+
if (!ok)
|
|
3173
|
+
return { outcome: "failed" };
|
|
3174
|
+
const responseId = pendingInput.responseId ?? live.currentResponseId();
|
|
3175
|
+
return {
|
|
3176
|
+
outcome: steered ? "steered" : "injected",
|
|
3177
|
+
...(responseId ? { responseId } : {}),
|
|
3178
|
+
};
|
|
1971
3179
|
}
|
|
1972
|
-
catch {
|
|
1973
|
-
|
|
3180
|
+
catch (error) {
|
|
3181
|
+
live.error = error instanceof Error ? error.message : String(error);
|
|
3182
|
+
return { outcome: "failed" };
|
|
1974
3183
|
}
|
|
1975
3184
|
finally {
|
|
1976
3185
|
if (live.injectAbort === abort)
|
|
@@ -1992,12 +3201,31 @@ export class LocalAgentHost {
|
|
|
1992
3201
|
return live.injector ?? null;
|
|
1993
3202
|
}
|
|
1994
3203
|
/** Attach a session's tmux pane injector (from the runner-child, which owns the
|
|
1995
|
-
* terminal registry — the host does not hold tmux).
|
|
1996
|
-
*
|
|
3204
|
+
* terminal registry — the host does not hold tmux). Claude uses it for normal
|
|
3205
|
+
* message injection; Codex uses it only to dismiss the exact TUI-local Plan
|
|
3206
|
+
* picker after its synthetic Web form is submitted. Idempotent. */
|
|
1997
3207
|
attachTerminalInjector(localThreadId, injector) {
|
|
1998
3208
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1999
|
-
if (live)
|
|
3209
|
+
if (live) {
|
|
3210
|
+
if (live.injector === injector)
|
|
3211
|
+
return;
|
|
2000
3212
|
live.injector = injector;
|
|
3213
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
3217
|
+
if (codex)
|
|
3218
|
+
codex.injector = injector;
|
|
3219
|
+
}
|
|
3220
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
3221
|
+
failLiveSession(localThreadId, error) {
|
|
3222
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
3223
|
+
if (claude)
|
|
3224
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
3225
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
3226
|
+
if (!codex)
|
|
3227
|
+
return false;
|
|
3228
|
+
return codex.forwarder.failOpenTurn(error);
|
|
2001
3229
|
}
|
|
2002
3230
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
2003
3231
|
async listModels(runtime) {
|
|
@@ -2027,7 +3255,7 @@ export class LocalAgentHost {
|
|
|
2027
3255
|
return { ok: false, reason: "unsupported" };
|
|
2028
3256
|
}
|
|
2029
3257
|
try {
|
|
2030
|
-
return { ok: true, data: await fn(live.
|
|
3258
|
+
return { ok: true, data: await fn(live.appServerOwner, record.codexSessionId) };
|
|
2031
3259
|
}
|
|
2032
3260
|
catch (error) {
|
|
2033
3261
|
return {
|
|
@@ -2099,8 +3327,8 @@ export class LocalAgentHost {
|
|
|
2099
3327
|
return { ok: true, data: undefined };
|
|
2100
3328
|
}
|
|
2101
3329
|
const runtime = execution.provider;
|
|
2102
|
-
const client = live?.
|
|
2103
|
-
this.getBackend(runtime
|
|
3330
|
+
const client = live?.appServerOwner ??
|
|
3331
|
+
this.getBackend(runtime).appServerClient;
|
|
2104
3332
|
if (!client) {
|
|
2105
3333
|
return { ok: false, reason: "unsupported" };
|
|
2106
3334
|
}
|
|
@@ -2126,7 +3354,7 @@ export class LocalAgentHost {
|
|
|
2126
3354
|
threadId: record.codexSessionId,
|
|
2127
3355
|
excludeTurns: true,
|
|
2128
3356
|
...threadWorkspaceParams(runtime, workspace, execution.sandbox ?? "workspace-write"),
|
|
2129
|
-
model: execution.model,
|
|
3357
|
+
...(execution.model ? { model: execution.model } : {}),
|
|
2130
3358
|
approvalPolicy: execution.approvalPolicy,
|
|
2131
3359
|
});
|
|
2132
3360
|
}
|
|
@@ -2155,6 +3383,9 @@ export class LocalAgentHost {
|
|
|
2155
3383
|
codexSessionId: forkedThreadId,
|
|
2156
3384
|
parentSessionId: currentLocalThreadId,
|
|
2157
3385
|
runtimeHomeOwnerSessionId: sourceHomeOwner,
|
|
3386
|
+
...(record.bridgeOwnerSessionId
|
|
3387
|
+
? { bridgeOwnerSessionId: record.bridgeOwnerSessionId }
|
|
3388
|
+
: {}),
|
|
2158
3389
|
updatedAt: new Date().toISOString(),
|
|
2159
3390
|
});
|
|
2160
3391
|
return { ok: true, data: undefined };
|
|
@@ -2168,11 +3399,11 @@ export class LocalAgentHost {
|
|
|
2168
3399
|
}
|
|
2169
3400
|
}
|
|
2170
3401
|
}
|
|
2171
|
-
function
|
|
3402
|
+
function codexRpcError(error, method) {
|
|
2172
3403
|
if (error instanceof CodexTransportError) {
|
|
2173
|
-
return `Codex app-server rejected
|
|
3404
|
+
return `Codex app-server rejected ${method}: ${error.message} (code ${error.code})`;
|
|
2174
3405
|
}
|
|
2175
|
-
return `Codex
|
|
3406
|
+
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2176
3407
|
}
|
|
2177
3408
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2178
3409
|
const normalized = output.trim();
|
|
@@ -2197,20 +3428,100 @@ export function parseCodexLoginStatus(exitCode, output) {
|
|
|
2197
3428
|
issues: normalized ? [normalized] : [],
|
|
2198
3429
|
};
|
|
2199
3430
|
}
|
|
2200
|
-
/** Race a live session's `ready` promise against failure
|
|
3431
|
+
/** Race a live session's `ready` promise against failure and, when supplied,
|
|
3432
|
+
* a timeout. */
|
|
2201
3433
|
function raceReady(ready, timeoutMs, failed) {
|
|
2202
3434
|
let timer;
|
|
2203
|
-
const
|
|
2204
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2205
|
-
});
|
|
2206
|
-
const outcomes = [ready.then(() => true), timeout];
|
|
3435
|
+
const outcomes = [ready.then(() => true)];
|
|
2207
3436
|
if (failed)
|
|
2208
3437
|
outcomes.push(failed.then(() => false));
|
|
3438
|
+
if (timeoutMs !== null) {
|
|
3439
|
+
outcomes.push(new Promise((resolve) => {
|
|
3440
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
3441
|
+
}));
|
|
3442
|
+
}
|
|
2209
3443
|
return Promise.race(outcomes).finally(() => {
|
|
2210
3444
|
if (timer)
|
|
2211
3445
|
clearTimeout(timer);
|
|
2212
3446
|
});
|
|
2213
3447
|
}
|
|
3448
|
+
/** Record the authoritative settings returned while binding an existing
|
|
3449
|
+
* native thread. A Session-level explicit selection remains a pending desired
|
|
3450
|
+
* override; otherwise the resumed thread itself is the source of truth. */
|
|
3451
|
+
function adoptResumedThreadSettings(live, settings) {
|
|
3452
|
+
const model = settings.model?.trim();
|
|
3453
|
+
if (model) {
|
|
3454
|
+
live.mayUseProviderDefaultModel = false;
|
|
3455
|
+
live.appliedModel = model;
|
|
3456
|
+
if (!live.execution.model)
|
|
3457
|
+
live.model = model;
|
|
3458
|
+
}
|
|
3459
|
+
if (settings.reasoningEffort !== undefined) {
|
|
3460
|
+
live.appliedReasoningEffort = settings.reasoningEffort ?? undefined;
|
|
3461
|
+
if (!live.execution.reasoningEffort) {
|
|
3462
|
+
live.reasoningEffort = settings.reasoningEffort ?? undefined;
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
/** Resolve the model required by a full collaboration-mode snapshot. Only a
|
|
3467
|
+
* fresh thread with no explicit/config model may use the catalog default; a
|
|
3468
|
+
* resumed thread must report its own authoritative model. */
|
|
3469
|
+
async function resolveLiveCollaborationMode(client, live, mode, codexHome) {
|
|
3470
|
+
let model = live.model.trim();
|
|
3471
|
+
if (!model && live.mayUseProviderDefaultModel) {
|
|
3472
|
+
let catalog;
|
|
3473
|
+
try {
|
|
3474
|
+
catalog = await client.modelList();
|
|
3475
|
+
}
|
|
3476
|
+
catch (error) {
|
|
3477
|
+
throw new CodexRuntimeError(`collaboration mode requires the Provider default model: ${error instanceof Error ? error.message : String(error)}`, 503, "collaboration_mode_model_unknown");
|
|
3478
|
+
}
|
|
3479
|
+
const defaults = [...new Set(catalog.data
|
|
3480
|
+
.filter((candidate) => candidate.isDefault === true)
|
|
3481
|
+
.map((candidate) => (candidate.model || candidate.id).trim())
|
|
3482
|
+
.filter(Boolean))];
|
|
3483
|
+
if (defaults.length !== 1) {
|
|
3484
|
+
throw new CodexRuntimeError(`collaboration mode requires exactly one Provider default model; Provider advertised ${defaults.length}`, 503, "collaboration_mode_model_unknown");
|
|
3485
|
+
}
|
|
3486
|
+
model = defaults[0];
|
|
3487
|
+
}
|
|
3488
|
+
let developerInstructions = null;
|
|
3489
|
+
if (live.runtime === "codex") {
|
|
3490
|
+
const state = codexHome
|
|
3491
|
+
? readCodexDeveloperInstructionsState(codexHome)
|
|
3492
|
+
: { state: "unreadable" };
|
|
3493
|
+
if (state.state === "unreadable") {
|
|
3494
|
+
throw new CodexRuntimeError("collaboration mode requires the current developer instructions, but the private Codex config could not be read", 503, "collaboration_mode_instructions_unknown");
|
|
3495
|
+
}
|
|
3496
|
+
developerInstructions = state.state === "present" ? state.value : null;
|
|
3497
|
+
}
|
|
3498
|
+
return buildCollaborationMode(mode, model, live.reasoningEffort, developerInstructions);
|
|
3499
|
+
}
|
|
3500
|
+
/** Expand the public mode enum into the App Server's required settings
|
|
3501
|
+
* snapshot using the Session's tracked effective model. */
|
|
3502
|
+
function buildCollaborationMode(mode, desiredModel, desiredReasoningEffort, developerInstructions) {
|
|
3503
|
+
const model = desiredModel.trim();
|
|
3504
|
+
if (!model) {
|
|
3505
|
+
throw new CodexRuntimeError("collaboration mode requires the current model, but the Provider did not report one", 503, "collaboration_mode_model_unknown");
|
|
3506
|
+
}
|
|
3507
|
+
return {
|
|
3508
|
+
mode,
|
|
3509
|
+
settings: {
|
|
3510
|
+
model,
|
|
3511
|
+
reasoning_effort: desiredReasoningEffort ?? null,
|
|
3512
|
+
developer_instructions: developerInstructions,
|
|
3513
|
+
},
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
/** New snapshots distinguish authored Agent text from framework-rendered
|
|
3517
|
+
* instructions. Older persisted snapshots fall back to their only available
|
|
3518
|
+
* field so existing sessions preserve their launch behavior. */
|
|
3519
|
+
function nativeAuthoredInstructions(execution) {
|
|
3520
|
+
const value = execution.authoredInstructions === undefined
|
|
3521
|
+
? execution.instructions
|
|
3522
|
+
: execution.authoredInstructions;
|
|
3523
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
3524
|
+
}
|
|
2214
3525
|
function sameSessionSnapshots(workspace, execution, opts) {
|
|
2215
3526
|
return JSON.stringify(stableValue({
|
|
2216
3527
|
workspace,
|
|
@@ -2220,6 +3531,40 @@ function sameSessionSnapshots(workspace, execution, opts) {
|
|
|
2220
3531
|
execution: opts.execution,
|
|
2221
3532
|
}));
|
|
2222
3533
|
}
|
|
3534
|
+
function sameImmutableSessionSnapshots(workspace, execution, opts) {
|
|
3535
|
+
const current = {
|
|
3536
|
+
...execution,
|
|
3537
|
+
model: null,
|
|
3538
|
+
reasoningEffort: null,
|
|
3539
|
+
collaborationMode: undefined,
|
|
3540
|
+
};
|
|
3541
|
+
const requested = {
|
|
3542
|
+
...opts.execution,
|
|
3543
|
+
model: null,
|
|
3544
|
+
reasoningEffort: null,
|
|
3545
|
+
collaborationMode: undefined,
|
|
3546
|
+
};
|
|
3547
|
+
return JSON.stringify(stableValue({ workspace, execution: current })) ===
|
|
3548
|
+
JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
|
|
3549
|
+
}
|
|
3550
|
+
function updateLiveCodexTurnSettings(live, opts) {
|
|
3551
|
+
if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
|
|
3552
|
+
return false;
|
|
3553
|
+
}
|
|
3554
|
+
const previousModel = live.execution.model;
|
|
3555
|
+
const previousReasoningEffort = live.execution.reasoningEffort;
|
|
3556
|
+
live.execution = structuredClone(opts.execution);
|
|
3557
|
+
// Repeated calls normally carry the same immutable Session snapshot. Preserve
|
|
3558
|
+
// a newer native `/model` or effort change unless Core explicitly changed the
|
|
3559
|
+
// corresponding Session setting between calls.
|
|
3560
|
+
if (opts.execution.model !== previousModel) {
|
|
3561
|
+
live.model = opts.execution.model?.trim() ?? "";
|
|
3562
|
+
}
|
|
3563
|
+
if (opts.execution.reasoningEffort !== previousReasoningEffort) {
|
|
3564
|
+
live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
|
|
3565
|
+
}
|
|
3566
|
+
return true;
|
|
3567
|
+
}
|
|
2223
3568
|
/** Freeze the Session's permission choice into the explicit managed settings.
|
|
2224
3569
|
* Host/project settings may still contribute allow/deny rules, but changing
|
|
2225
3570
|
* their defaultMode cannot silently mutate an existing Session on resume. */
|
|
@@ -2233,6 +3578,12 @@ function applyClaudePermissionModeSnapshot(settings, permissionMode) {
|
|
|
2233
3578
|
permissions.defaultMode = permissionMode;
|
|
2234
3579
|
else
|
|
2235
3580
|
delete permissions.defaultMode;
|
|
3581
|
+
if (permissionMode === "bypassPermissions") {
|
|
3582
|
+
next.skipDangerousModePermissionPrompt = true;
|
|
3583
|
+
}
|
|
3584
|
+
else {
|
|
3585
|
+
delete next.skipDangerousModePermissionPrompt;
|
|
3586
|
+
}
|
|
2236
3587
|
if (Object.keys(permissions).length > 0)
|
|
2237
3588
|
next.permissions = permissions;
|
|
2238
3589
|
else
|
|
@@ -2272,10 +3623,13 @@ export function isThreadNotReadyError(error) {
|
|
|
2272
3623
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
2273
3624
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
2274
3625
|
}
|
|
2275
|
-
/**
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
|
|
2279
|
-
const
|
|
2280
|
-
|
|
3626
|
+
/** Build the user-facing native lifecycle failure once, at the phase that owns
|
|
3627
|
+
* it. Downstream layers preserve this code/message verbatim instead of replacing
|
|
3628
|
+
* it with a generic "failed to start; see logs" wrapper. */
|
|
3629
|
+
function nativeLiveFailure(runtime, code, phase, cause, statusCode = 503) {
|
|
3630
|
+
const provider = runtime === "traex" ? "Traex" : "Codex";
|
|
3631
|
+
const detail = cause === undefined
|
|
3632
|
+
? ""
|
|
3633
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
3634
|
+
return new CodexRuntimeError(`${provider} ${phase}${detail}`, statusCode, code);
|
|
2281
3635
|
}
|