@rynx-ai/runtime 0.1.0 → 0.1.9
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 +3 -5
- package/dist/claude/executor.js +3 -5
- package/dist/claude/native-bridge.d.ts +74 -17
- package/dist/claude/native-bridge.js +225 -30
- package/dist/claude/native-hook-main.js +291 -39
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +78 -5
- package/dist/claude/native-integration.js +417 -26
- package/dist/claude/settings.d.ts +8 -0
- package/dist/claude/settings.js +50 -0
- package/dist/claude/transcript.d.ts +2 -2
- package/dist/claude/transcript.js +3 -3
- package/dist/codex/rollout-synth.js +1 -1
- package/dist/codex-app-server/client.d.ts +26 -40
- package/dist/codex-app-server/client.js +1128 -99
- package/dist/codex-app-server/forwarder.d.ts +7 -7
- package/dist/codex-app-server/forwarder.js +11 -5
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +27 -2
- package/dist/codex-app-server/protocol.d.ts +238 -4
- package/dist/codex-app-server/transport.d.ts +20 -5
- package/dist/codex-app-server/transport.js +93 -40
- package/dist/codex-app-server/ws-channel.d.ts +3 -3
- package/dist/codex-app-server/ws-channel.js +23 -7
- package/dist/codex-child-env.js +33 -0
- package/dist/codex-home.d.ts +6 -6
- package/dist/codex-home.js +8 -9
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +34 -33
- package/dist/host.js +531 -91
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +1 -1
- package/dist/models-catalog.js +1 -1
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +93 -15
- package/dist/runner/manager.d.ts +59 -10
- package/dist/runner/manager.js +385 -41
- package/dist/runner/protocol.d.ts +18 -7
- package/dist/runner-main.js +9 -6
- package/dist/runtime-status.js +1 -1
- package/dist/terminal/claude-tui.d.ts +8 -3
- package/dist/terminal/claude-tui.js +6 -2
- package/dist/terminal/codex-tui.d.ts +3 -3
- package/dist/terminal/codex-tui.js +1 -1
- package/dist/terminal/registry.d.ts +1 -1
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +6 -6
- package/dist/terminal/tmux.js +10 -10
- package/package.json +3 -3
package/dist/host.js
CHANGED
|
@@ -18,9 +18,10 @@ import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
|
18
18
|
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
19
19
|
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
20
20
|
import { claudeTranscriptPath } from "./claude/transcript.js";
|
|
21
|
-
import { claudeBridgeDir, prepareClaudeBridgeDir } from "./claude/native-bridge.js";
|
|
21
|
+
import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
22
22
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
23
23
|
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
24
|
+
import { buildManagedClaudeSettings } from "./claude/settings.js";
|
|
24
25
|
import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
|
|
25
26
|
import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
|
|
26
27
|
import { newSessionId as makeSessionId } from "@rynx-ai/core";
|
|
@@ -161,11 +162,12 @@ export class SpawnCodexCommandRunner {
|
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
164
|
/** Fraction of an agent's `contextWindow` budget at which the orchestrator
|
|
164
|
-
* emits a one-time `context.threshold` observability signal. Mirrors
|
|
165
|
+
* emits a one-time `context.threshold` observability signal. Mirrors reference implementation's
|
|
165
166
|
* compaction `trigger_threshold` (0.8). codex self-compacts via its token
|
|
166
167
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
167
168
|
* signal is the lever consumers surface to the user. */
|
|
168
169
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
170
|
+
const MAX_CODEX_SETTLED_INTERACTIONS = 512;
|
|
169
171
|
/**
|
|
170
172
|
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
171
173
|
* when the budget declares no retry or the runtime is claude. claude applies
|
|
@@ -217,7 +219,7 @@ export class LocalAgentHost {
|
|
|
217
219
|
injectedAppServerClient;
|
|
218
220
|
clock;
|
|
219
221
|
backendIdleTtlMs;
|
|
220
|
-
// Builds the SEPARATE forwarder connection for a live codex session (
|
|
222
|
+
// Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
|
|
221
223
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
222
224
|
// the backend's app-server; tests inject a fake.
|
|
223
225
|
forwarderClientFactory;
|
|
@@ -241,6 +243,9 @@ export class LocalAgentHost {
|
|
|
241
243
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
242
244
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
243
245
|
liveClaudeSessions = new Map();
|
|
246
|
+
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
247
|
+
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
248
|
+
pendingClaudeFinalizers = new Set();
|
|
244
249
|
// In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
|
|
245
250
|
// `live.ensure` racing a `term.open`) share ONE forwarder — never two.
|
|
246
251
|
liveEnsuring = new Map();
|
|
@@ -337,7 +342,7 @@ export class LocalAgentHost {
|
|
|
337
342
|
const policy = approvalPolicy === "never" ? "auto-approve-session" : "auto-decline";
|
|
338
343
|
// Native co-drive: run the app-server on a loopback ws (multi-client) so a
|
|
339
344
|
// `codex --remote` TUI can attach to the same thread (stdio admits only one
|
|
340
|
-
// client). This is the sole transport — always native, aligning with
|
|
345
|
+
// client). This is the sole transport — always native, aligning with reference implementation.
|
|
341
346
|
// Point it at the private CODEX_HOME so it shares the TUI's login/settings and
|
|
342
347
|
// never trips the real home's update/NUX state.
|
|
343
348
|
const channel = new WsRpcChannel({
|
|
@@ -348,23 +353,50 @@ export class LocalAgentHost {
|
|
|
348
353
|
return new CodexAppServerClient({
|
|
349
354
|
channel,
|
|
350
355
|
approvalDecisionPolicy: policy,
|
|
351
|
-
interactiveApprovals: this.config.AGENT_INTERACTIVE_APPROVALS,
|
|
352
356
|
});
|
|
353
357
|
}
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
*/
|
|
360
|
-
async resolveApproval(localThreadId, approvalId, decision) {
|
|
361
|
-
const record = await this.sessionStore.get(localThreadId);
|
|
362
|
-
const runtime = record?.runtime ?? this.defaultRuntime;
|
|
363
|
-
if (runtime === "claude") {
|
|
364
|
-
return false;
|
|
358
|
+
/** Resolve a pending native question/approval without opening a new Turn. */
|
|
359
|
+
async resolveInteraction(localThreadId, interactionId, resolution) {
|
|
360
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
361
|
+
if (claude) {
|
|
362
|
+
return claude.forwarder.resolveInteraction(interactionId, resolution);
|
|
365
363
|
}
|
|
366
|
-
const
|
|
367
|
-
|
|
364
|
+
const live = this.liveSessions.get(localThreadId);
|
|
365
|
+
if (!live)
|
|
366
|
+
return { disposition: "not_found" };
|
|
367
|
+
if (live.settledInteractions.has(interactionId)) {
|
|
368
|
+
return { disposition: "already_resolved" };
|
|
369
|
+
}
|
|
370
|
+
if (live.interactionRecoveries.has(interactionId)) {
|
|
371
|
+
return {
|
|
372
|
+
disposition: "invalid",
|
|
373
|
+
message: "interaction delivery is recovering; retry shortly",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const submit = (client) => {
|
|
377
|
+
// Store before entering the client: a transport can report a synchronous
|
|
378
|
+
// write failure, and failover must already have the exact accepted answer
|
|
379
|
+
// available for replay. A synchronous terminal event may delete it again.
|
|
380
|
+
const hadPrevious = live.interactionSubmissions.has(interactionId);
|
|
381
|
+
const previous = live.interactionSubmissions.get(interactionId);
|
|
382
|
+
live.interactionSubmissions.set(interactionId, resolution);
|
|
383
|
+
const result = client.resolveInteraction(interactionId, resolution);
|
|
384
|
+
if (result.disposition !== "applied") {
|
|
385
|
+
if (hadPrevious && previous)
|
|
386
|
+
live.interactionSubmissions.set(interactionId, previous);
|
|
387
|
+
else
|
|
388
|
+
live.interactionSubmissions.delete(interactionId);
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
};
|
|
392
|
+
const owner = live.interactionOwners.get(interactionId);
|
|
393
|
+
if (owner)
|
|
394
|
+
return submit(owner);
|
|
395
|
+
const injected = submit(live.injectClient);
|
|
396
|
+
if (injected.disposition !== "not_found") {
|
|
397
|
+
return injected;
|
|
398
|
+
}
|
|
399
|
+
return submit(live.forwarderClient);
|
|
368
400
|
}
|
|
369
401
|
/**
|
|
370
402
|
* The command to run in a session's live terminal so it co-drives the codex
|
|
@@ -382,7 +414,8 @@ export class LocalAgentHost {
|
|
|
382
414
|
if (this.liveClaudeSessions.has(localThreadId)) {
|
|
383
415
|
return this.claudeTerminalSpec(localThreadId, record);
|
|
384
416
|
}
|
|
385
|
-
const
|
|
417
|
+
const live = this.liveSessions.get(localThreadId);
|
|
418
|
+
const runtime = record?.runtime ?? live?.runtime ?? this.defaultRuntime;
|
|
386
419
|
if (runtime === "claude") {
|
|
387
420
|
return this.claudeTerminalSpec(localThreadId, record);
|
|
388
421
|
}
|
|
@@ -392,7 +425,7 @@ export class LocalAgentHost {
|
|
|
392
425
|
throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
|
|
393
426
|
}
|
|
394
427
|
// Native-only: an app-server failure surfaces as a terminal error, never a
|
|
395
|
-
// silent bash fallback (aligns with
|
|
428
|
+
// silent bash fallback (aligns with reference implementation). ensureInitialized throws on its own.
|
|
396
429
|
await client.ensureInitialized();
|
|
397
430
|
const remoteUrl = client.terminalRemoteUrl();
|
|
398
431
|
if (!remoteUrl) {
|
|
@@ -406,9 +439,15 @@ export class LocalAgentHost {
|
|
|
406
439
|
: [];
|
|
407
440
|
// Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
|
|
408
441
|
// shared per-runtime, so a per-session model rides a launch `-c model=` instead).
|
|
409
|
-
const launchModel =
|
|
442
|
+
const launchModel = live?.model;
|
|
410
443
|
if (launchModel)
|
|
411
444
|
configOverrides.push(`model=${JSON.stringify(launchModel)}`);
|
|
445
|
+
const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
|
|
446
|
+
if (launchEffort)
|
|
447
|
+
configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
|
|
448
|
+
if (live?.instructions) {
|
|
449
|
+
configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
|
|
450
|
+
}
|
|
412
451
|
return {
|
|
413
452
|
command: resolveRuntimeBinary(runtime),
|
|
414
453
|
args: buildCodexRemoteArgs({
|
|
@@ -416,7 +455,7 @@ export class LocalAgentHost {
|
|
|
416
455
|
...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
|
|
417
456
|
configOverrides,
|
|
418
457
|
}),
|
|
419
|
-
cwd: record?.cwd ?? process.cwd(),
|
|
458
|
+
cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
|
|
420
459
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
421
460
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
422
461
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -429,7 +468,7 @@ export class LocalAgentHost {
|
|
|
429
468
|
}
|
|
430
469
|
/**
|
|
431
470
|
* Bring up (idempotently) a session's persistent codex forwarder and emit its
|
|
432
|
-
* mirrored {@link SessionEvent}s.
|
|
471
|
+
* mirrored {@link SessionEvent}s. reference implementation's codex-native single-writer model:
|
|
433
472
|
* this forwarder is the SOLE producer of the session's canonical events — every
|
|
434
473
|
* turn on the thread, web-composer-injected AND co-driving-`codex --remote`-TUI
|
|
435
474
|
* initiated. Each turn gets a {@link SessionNormalizer} whose `responseId` is
|
|
@@ -444,7 +483,10 @@ export class LocalAgentHost {
|
|
|
444
483
|
* to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
|
|
445
484
|
*/
|
|
446
485
|
async ensureLiveCodexSession(localThreadId, emit, opts) {
|
|
447
|
-
|
|
486
|
+
const live = this.liveSessions.get(localThreadId);
|
|
487
|
+
if (live)
|
|
488
|
+
return this.refreshLiveCodexSession(localThreadId, live, opts);
|
|
489
|
+
if (this.liveClaudeSessions.has(localThreadId))
|
|
448
490
|
return true;
|
|
449
491
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
450
492
|
// session (two would double-mirror every turn).
|
|
@@ -461,7 +503,7 @@ export class LocalAgentHost {
|
|
|
461
503
|
}
|
|
462
504
|
}
|
|
463
505
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
464
|
-
// cancel-before-recreate (
|
|
506
|
+
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
465
507
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
466
508
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
467
509
|
// could otherwise leave a half-dead forwarder mirroring in parallel.
|
|
@@ -475,7 +517,8 @@ export class LocalAgentHost {
|
|
|
475
517
|
const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
|
|
476
518
|
if (runtime === "claude")
|
|
477
519
|
return this.startLiveClaudeSession(localThreadId, emit, record, opts);
|
|
478
|
-
const cwd = record?.cwd ?? process.cwd();
|
|
520
|
+
const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
|
|
521
|
+
const configSignature = liveConfigSignature(opts, runtime, cwd);
|
|
479
522
|
// Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
|
|
480
523
|
// logged with the agent context and fail the live start loudly — never a
|
|
481
524
|
// bare mystery `false`.
|
|
@@ -489,7 +532,7 @@ export class LocalAgentHost {
|
|
|
489
532
|
}
|
|
490
533
|
// Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
|
|
491
534
|
// app-server boots (and before the `--remote` TUI attaches) so the native Codex
|
|
492
|
-
// discovers them —
|
|
535
|
+
// discovers them — reference implementation's filesystem mechanism. The live thread is created by
|
|
493
536
|
// the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
|
|
494
537
|
// the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
|
|
495
538
|
// The agent spec is the single skill source: its refs were replayed into a
|
|
@@ -510,25 +553,31 @@ export class LocalAgentHost {
|
|
|
510
553
|
return false;
|
|
511
554
|
};
|
|
512
555
|
const injectClient = this.getBackend(runtime).appServerClient;
|
|
513
|
-
if (!injectClient)
|
|
556
|
+
if (!injectClient) {
|
|
557
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
514
558
|
return abandon();
|
|
559
|
+
}
|
|
515
560
|
try {
|
|
516
561
|
await injectClient.ensureInitialized();
|
|
517
562
|
}
|
|
518
|
-
catch {
|
|
563
|
+
catch (err) {
|
|
564
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
519
565
|
return abandon();
|
|
520
566
|
}
|
|
521
567
|
const appServerUrl = injectClient.terminalRemoteUrl();
|
|
522
|
-
if (!appServerUrl)
|
|
568
|
+
if (!appServerUrl) {
|
|
569
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
523
570
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
524
|
-
|
|
571
|
+
}
|
|
572
|
+
// reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
|
|
525
573
|
// app-server, which `thread/resume`s to subscribe to the thread's item/turn
|
|
526
574
|
// notifications (the backend client injects; this one only observes).
|
|
527
575
|
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
528
576
|
try {
|
|
529
577
|
await forwarderClient.ensureInitialized();
|
|
530
578
|
}
|
|
531
|
-
catch {
|
|
579
|
+
catch (err) {
|
|
580
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
532
581
|
return abandon();
|
|
533
582
|
}
|
|
534
583
|
// Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
|
|
@@ -536,6 +585,10 @@ export class LocalAgentHost {
|
|
|
536
585
|
// the runtime default). The model rides a launch `-c model=` on the TUI (see
|
|
537
586
|
// `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
|
|
538
587
|
const model = getCodexModel(this.config, record?.model ?? liveCfg.model);
|
|
588
|
+
const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
|
|
589
|
+
const reasoningEffort = hasCurrentEffortSource
|
|
590
|
+
? liveCfg.reasoningEffort
|
|
591
|
+
: record?.reasoningEffort ?? liveCfg.reasoningEffort;
|
|
539
592
|
let markReady;
|
|
540
593
|
const ready = new Promise((resolve) => {
|
|
541
594
|
markReady = resolve;
|
|
@@ -547,6 +600,8 @@ export class LocalAgentHost {
|
|
|
547
600
|
runtime,
|
|
548
601
|
cwd,
|
|
549
602
|
model,
|
|
603
|
+
reasoningEffort,
|
|
604
|
+
...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
|
|
550
605
|
threadId: record?.codexSessionId ?? null,
|
|
551
606
|
ready,
|
|
552
607
|
markReady,
|
|
@@ -554,19 +609,228 @@ export class LocalAgentHost {
|
|
|
554
609
|
subscribing: false,
|
|
555
610
|
stopped: false,
|
|
556
611
|
skillsCleanup: liveCfg.skillsCleanup,
|
|
612
|
+
configSignature,
|
|
613
|
+
interactionOwners: new Map(),
|
|
614
|
+
interactionStandbys: new Map(),
|
|
615
|
+
interactionSubmissions: new Map(),
|
|
616
|
+
canonicalInteractions: new Set(),
|
|
617
|
+
settledInteractions: new Set(),
|
|
618
|
+
interactionRecoveries: new Map(),
|
|
619
|
+
interactionFailedClients: new Map(),
|
|
620
|
+
disconnectedClients: new Set(),
|
|
557
621
|
};
|
|
558
622
|
let normalizer = null;
|
|
623
|
+
let currentResponseId = null;
|
|
559
624
|
const startNormalizer = (turnId) => {
|
|
625
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
626
|
+
if (normalizer && currentResponseId === responseId)
|
|
627
|
+
return normalizer;
|
|
628
|
+
currentResponseId = responseId;
|
|
560
629
|
normalizer = new SessionNormalizer({
|
|
561
630
|
sessionId: localThreadId,
|
|
562
631
|
// turnId unknown → fixed literal (NEVER random): items arriving before
|
|
563
632
|
// `turn/started` land on ONE response instead of splitting into random
|
|
564
|
-
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors
|
|
565
|
-
responseId
|
|
633
|
+
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
634
|
+
responseId,
|
|
566
635
|
model,
|
|
567
636
|
});
|
|
568
637
|
return normalizer;
|
|
569
638
|
};
|
|
639
|
+
const rememberSettledInteraction = (interactionId) => {
|
|
640
|
+
live.settledInteractions.add(interactionId);
|
|
641
|
+
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
642
|
+
return;
|
|
643
|
+
const oldest = live.settledInteractions.values().next().value;
|
|
644
|
+
if (oldest)
|
|
645
|
+
live.settledInteractions.delete(oldest);
|
|
646
|
+
};
|
|
647
|
+
const forwardInteraction = (event) => {
|
|
648
|
+
const interactionId = event.type === "requested"
|
|
649
|
+
? event.request.interactionId
|
|
650
|
+
: event.interactionId;
|
|
651
|
+
if (event.type === "requested") {
|
|
652
|
+
if (live.canonicalInteractions.has(interactionId) ||
|
|
653
|
+
live.settledInteractions.has(interactionId))
|
|
654
|
+
return;
|
|
655
|
+
live.canonicalInteractions.add(interactionId);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
// The forwarder may have completed the Turn before another app-server
|
|
659
|
+
// connection publishes its terminal copy. Never create a fresh
|
|
660
|
+
// normalizer/response for that late duplicate.
|
|
661
|
+
if (!live.canonicalInteractions.delete(interactionId))
|
|
662
|
+
return;
|
|
663
|
+
rememberSettledInteraction(interactionId);
|
|
664
|
+
}
|
|
665
|
+
const turnId = event.turnId ?? live.forwarder?.currentTurnId() ?? undefined;
|
|
666
|
+
const n = normalizer ?? startNormalizer(turnId);
|
|
667
|
+
const agentEvent = event.type === "requested"
|
|
668
|
+
? { type: "interaction_requested", interaction: event.request }
|
|
669
|
+
: event.type === "resolved"
|
|
670
|
+
? {
|
|
671
|
+
type: "interaction_resolved",
|
|
672
|
+
interactionId: event.interactionId,
|
|
673
|
+
resolution: event.resolution,
|
|
674
|
+
}
|
|
675
|
+
: {
|
|
676
|
+
type: "interaction_cancelled",
|
|
677
|
+
interactionId: event.interactionId,
|
|
678
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
679
|
+
};
|
|
680
|
+
for (const se of n.next(agentEvent))
|
|
681
|
+
emit(se);
|
|
682
|
+
};
|
|
683
|
+
const clearRecovery = (interactionId) => {
|
|
684
|
+
live.interactionRecoveries.delete(interactionId);
|
|
685
|
+
};
|
|
686
|
+
const clearInteractionRouting = (interactionId) => {
|
|
687
|
+
clearRecovery(interactionId);
|
|
688
|
+
live.interactionOwners.delete(interactionId);
|
|
689
|
+
live.interactionStandbys.delete(interactionId);
|
|
690
|
+
live.interactionSubmissions.delete(interactionId);
|
|
691
|
+
live.interactionFailedClients.delete(interactionId);
|
|
692
|
+
};
|
|
693
|
+
const markInteractionClientFailed = (interactionId, client) => {
|
|
694
|
+
const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
|
|
695
|
+
failed.add(client);
|
|
696
|
+
live.interactionFailedClients.set(interactionId, failed);
|
|
697
|
+
};
|
|
698
|
+
const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
|
|
699
|
+
live.interactionFailedClients.get(interactionId)?.has(client) === true;
|
|
700
|
+
const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
|
|
701
|
+
const finishRecovery = (interactionId, event) => {
|
|
702
|
+
clearInteractionRouting(interactionId);
|
|
703
|
+
forwardInteraction(event);
|
|
704
|
+
};
|
|
705
|
+
const beginRecovery = (interactionId, event) => {
|
|
706
|
+
live.interactionOwners.delete(interactionId);
|
|
707
|
+
live.interactionRecoveries.set(interactionId, { event });
|
|
708
|
+
};
|
|
709
|
+
const promoteInteractionOwner = (interactionId, replacement) => {
|
|
710
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
711
|
+
live.interactionOwners.set(interactionId, replacement);
|
|
712
|
+
const submission = live.interactionSubmissions.get(interactionId);
|
|
713
|
+
if (!submission) {
|
|
714
|
+
clearRecovery(interactionId);
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
const replayed = replacement.resolveInteraction(interactionId, submission);
|
|
718
|
+
// A write failure can synchronously re-enter the listener and remove this
|
|
719
|
+
// owner. Only clear recovery when the replacement still owns the request.
|
|
720
|
+
if (replayed.disposition === "applied" &&
|
|
721
|
+
live.interactionOwners.get(interactionId) === replacement) {
|
|
722
|
+
clearRecovery(interactionId);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
if (live.interactionOwners.get(interactionId) === replacement) {
|
|
726
|
+
live.interactionOwners.delete(interactionId);
|
|
727
|
+
}
|
|
728
|
+
if (recovery &&
|
|
729
|
+
live.canonicalInteractions.has(interactionId) &&
|
|
730
|
+
!live.settledInteractions.has(interactionId) &&
|
|
731
|
+
!live.interactionRecoveries.has(interactionId)) {
|
|
732
|
+
live.interactionRecoveries.set(interactionId, recovery);
|
|
733
|
+
}
|
|
734
|
+
return false;
|
|
735
|
+
};
|
|
736
|
+
const bindInteractionClient = (client) => {
|
|
737
|
+
client.setInteractionListener((event) => {
|
|
738
|
+
const interactionId = event.type === "requested"
|
|
739
|
+
? event.request.interactionId
|
|
740
|
+
: event.interactionId;
|
|
741
|
+
const owner = live.interactionOwners.get(interactionId);
|
|
742
|
+
if (event.type === "requested") {
|
|
743
|
+
// Receiving a native request proves this connection is alive and can
|
|
744
|
+
// be retried even if an earlier copy failed on it.
|
|
745
|
+
live.disconnectedClients.delete(client);
|
|
746
|
+
const failed = live.interactionFailedClients.get(interactionId);
|
|
747
|
+
failed?.delete(client);
|
|
748
|
+
if (failed?.size === 0)
|
|
749
|
+
live.interactionFailedClients.delete(interactionId);
|
|
750
|
+
if (live.settledInteractions.has(interactionId))
|
|
751
|
+
return;
|
|
752
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
753
|
+
if (recovery) {
|
|
754
|
+
promoteInteractionOwner(interactionId, client);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (owner && owner !== client) {
|
|
758
|
+
const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
|
|
759
|
+
standbys.add(client);
|
|
760
|
+
live.interactionStandbys.set(interactionId, standbys);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
live.interactionOwners.set(interactionId, client);
|
|
764
|
+
forwardInteraction(event);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const recoverableOwnerFailure = event.type === "cancelled" &&
|
|
768
|
+
(event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
|
|
769
|
+
if (recoverableOwnerFailure)
|
|
770
|
+
markInteractionClientFailed(interactionId, client);
|
|
771
|
+
if (!owner || owner !== client) {
|
|
772
|
+
const standbys = live.interactionStandbys.get(interactionId);
|
|
773
|
+
standbys?.delete(client);
|
|
774
|
+
if (standbys?.size === 0)
|
|
775
|
+
live.interactionStandbys.delete(interactionId);
|
|
776
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
777
|
+
if (recoverableOwnerFailure &&
|
|
778
|
+
live.canonicalInteractions.has(interactionId) &&
|
|
779
|
+
allInteractionClientsUnavailable(interactionId)) {
|
|
780
|
+
finishRecovery(interactionId, recovery?.event ?? event);
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (recoverableOwnerFailure) {
|
|
785
|
+
const standbys = live.interactionStandbys.get(interactionId);
|
|
786
|
+
while (standbys && standbys.size > 0) {
|
|
787
|
+
const replacement = standbys.values().next().value;
|
|
788
|
+
if (!replacement)
|
|
789
|
+
break;
|
|
790
|
+
standbys.delete(replacement);
|
|
791
|
+
if (interactionClientUnavailable(interactionId, replacement))
|
|
792
|
+
continue;
|
|
793
|
+
if (promoteInteractionOwner(interactionId, replacement)) {
|
|
794
|
+
if (standbys.size === 0)
|
|
795
|
+
live.interactionStandbys.delete(interactionId);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (live.settledInteractions.has(interactionId))
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (!live.settledInteractions.has(interactionId)) {
|
|
802
|
+
if (allInteractionClientsUnavailable(interactionId)) {
|
|
803
|
+
finishRecovery(interactionId, event);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
beginRecovery(interactionId, event);
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
clearInteractionRouting(interactionId);
|
|
812
|
+
forwardInteraction(event);
|
|
813
|
+
});
|
|
814
|
+
client.setConnectionListener((state) => {
|
|
815
|
+
if (state === "connected") {
|
|
816
|
+
live.disconnectedClients.delete(client);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
live.disconnectedClients.add(client);
|
|
820
|
+
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
821
|
+
if (allInteractionClientsUnavailable(interactionId)) {
|
|
822
|
+
finishRecovery(interactionId, recovery.event);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
};
|
|
827
|
+
const closeCanonicalInteractions = () => {
|
|
828
|
+
for (const interactionId of live.canonicalInteractions) {
|
|
829
|
+
rememberSettledInteraction(interactionId);
|
|
830
|
+
clearInteractionRouting(interactionId);
|
|
831
|
+
}
|
|
832
|
+
live.canonicalInteractions.clear();
|
|
833
|
+
};
|
|
570
834
|
const sink = {
|
|
571
835
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
572
836
|
onUserMessage: (text) => {
|
|
@@ -580,6 +844,7 @@ export class LocalAgentHost {
|
|
|
580
844
|
emit(se);
|
|
581
845
|
},
|
|
582
846
|
onTurnEnd: (usage) => {
|
|
847
|
+
closeCanonicalInteractions();
|
|
583
848
|
if (!normalizer)
|
|
584
849
|
return;
|
|
585
850
|
if (usage)
|
|
@@ -588,13 +853,16 @@ export class LocalAgentHost {
|
|
|
588
853
|
for (const se of normalizer.next({ type: "done" }))
|
|
589
854
|
emit(se);
|
|
590
855
|
normalizer = null;
|
|
856
|
+
currentResponseId = null;
|
|
591
857
|
},
|
|
592
858
|
onTurnError: (error) => {
|
|
859
|
+
closeCanonicalInteractions();
|
|
593
860
|
if (!normalizer)
|
|
594
861
|
return;
|
|
595
862
|
for (const se of normalizer.fail({ code: "codex_error", message: error.message, source: "execution" }))
|
|
596
863
|
emit(se);
|
|
597
864
|
normalizer = null;
|
|
865
|
+
currentResponseId = null;
|
|
598
866
|
},
|
|
599
867
|
// The TUI creates the thread; the forwarder connection sees its broadcast
|
|
600
868
|
// `thread/started`. Persist the id, unblock injection, and start the
|
|
@@ -604,6 +872,8 @@ export class LocalAgentHost {
|
|
|
604
872
|
};
|
|
605
873
|
const forwarder = new CodexSessionForwarder(forwarderClient, sink);
|
|
606
874
|
live.forwarder = forwarder;
|
|
875
|
+
bindInteractionClient(injectClient);
|
|
876
|
+
bindInteractionClient(forwarderClient);
|
|
607
877
|
this.liveSessions.set(localThreadId, live);
|
|
608
878
|
forwarder.start();
|
|
609
879
|
// A resumed session already has a thread id — bind immediately; a fresh
|
|
@@ -613,6 +883,65 @@ export class LocalAgentHost {
|
|
|
613
883
|
}
|
|
614
884
|
return true;
|
|
615
885
|
}
|
|
886
|
+
async refreshLiveCodexSession(localThreadId, live, opts) {
|
|
887
|
+
const desiredRuntime = opts?.runtime ?? live.runtime;
|
|
888
|
+
if (desiredRuntime !== live.runtime)
|
|
889
|
+
return false;
|
|
890
|
+
const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
|
|
891
|
+
if (live.configSignature === configSignature)
|
|
892
|
+
return true;
|
|
893
|
+
let liveCfg;
|
|
894
|
+
try {
|
|
895
|
+
liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
const previousCleanup = live.skillsCleanup;
|
|
902
|
+
try {
|
|
903
|
+
const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
904
|
+
populateCodexSkills(this.codexHome(), liveSkills);
|
|
905
|
+
void previousCleanup?.().catch(() => undefined);
|
|
906
|
+
this.sessionSandbox = liveCfg.sandbox;
|
|
907
|
+
this.sessionApprovalPolicy = liveCfg.approvalPolicy;
|
|
908
|
+
live.model = getCodexModel(this.config, liveCfg.model);
|
|
909
|
+
live.reasoningEffort = liveCfg.reasoningEffort;
|
|
910
|
+
live.instructions = liveCfg.instructions;
|
|
911
|
+
live.skillsCleanup = liveCfg.skillsCleanup;
|
|
912
|
+
live.configSignature = configSignature;
|
|
913
|
+
if (live.threadId) {
|
|
914
|
+
// Keep already-running TUI turns aligned too; turn/start below carries
|
|
915
|
+
// the same overrides for web injection. Older app-servers may not have
|
|
916
|
+
// thread/settings/update, so refresh remains best-effort.
|
|
917
|
+
await live.injectClient
|
|
918
|
+
.threadSettingsUpdate({
|
|
919
|
+
threadId: live.threadId,
|
|
920
|
+
model: live.model,
|
|
921
|
+
effort: live.reasoningEffort ?? null,
|
|
922
|
+
})
|
|
923
|
+
.catch(() => undefined);
|
|
924
|
+
void this.sessionStore
|
|
925
|
+
.set({
|
|
926
|
+
localThreadId,
|
|
927
|
+
codexSessionId: live.threadId,
|
|
928
|
+
cwd: live.cwd,
|
|
929
|
+
model: live.model,
|
|
930
|
+
reasoningEffort: live.reasoningEffort,
|
|
931
|
+
runtime: live.runtime,
|
|
932
|
+
agent: opts?.agentName,
|
|
933
|
+
updatedAt: new Date().toISOString(),
|
|
934
|
+
})
|
|
935
|
+
.catch(() => undefined);
|
|
936
|
+
}
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
void liveCfg.skillsCleanup().catch(() => undefined);
|
|
941
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} skill refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
616
945
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
617
946
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
618
947
|
onLiveThreadStarted(live, localThreadId, agent, threadId) {
|
|
@@ -628,6 +957,7 @@ export class LocalAgentHost {
|
|
|
628
957
|
// Persist the agent-spec-resolved model (was the config default), so a
|
|
629
958
|
// resume/reconnect keeps running the agent's model, not the global one.
|
|
630
959
|
model: live.model,
|
|
960
|
+
reasoningEffort: live.reasoningEffort,
|
|
631
961
|
runtime: live.runtime,
|
|
632
962
|
agent,
|
|
633
963
|
updatedAt: new Date().toISOString(),
|
|
@@ -637,7 +967,7 @@ export class LocalAgentHost {
|
|
|
637
967
|
void this.subscribeUntilReady(live, threadId);
|
|
638
968
|
}
|
|
639
969
|
/**
|
|
640
|
-
* Subscribe the forwarder connection to a thread (
|
|
970
|
+
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
641
971
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
642
972
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
643
973
|
* thread active, then retry WITHOUT `excludeTurns` so the response backfills the
|
|
@@ -692,7 +1022,7 @@ export class LocalAgentHost {
|
|
|
692
1022
|
return ok;
|
|
693
1023
|
}
|
|
694
1024
|
/**
|
|
695
|
-
* Inject a user turn into a session's live codex thread —
|
|
1025
|
+
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
696
1026
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
697
1027
|
* supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
|
|
698
1028
|
* this targets the id the forwarder captured). Serialized per session so two
|
|
@@ -701,7 +1031,7 @@ export class LocalAgentHost {
|
|
|
701
1031
|
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
702
1032
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
703
1033
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
704
|
-
* forwarder). Park-until-ready (~60s), aligning
|
|
1034
|
+
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
705
1035
|
* the bridge instead of a short race that falls back to a second output path.
|
|
706
1036
|
*/
|
|
707
1037
|
async injectMessage(localThreadId, text) {
|
|
@@ -712,7 +1042,7 @@ export class LocalAgentHost {
|
|
|
712
1042
|
if (!live)
|
|
713
1043
|
return "notLive";
|
|
714
1044
|
const run = live.injectLock.then(async () => {
|
|
715
|
-
// Park until the thread binds (~60s,
|
|
1045
|
+
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
716
1046
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
717
1047
|
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
718
1048
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
@@ -730,12 +1060,18 @@ export class LocalAgentHost {
|
|
|
730
1060
|
}
|
|
731
1061
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
732
1062
|
// agent's model even if the TUI's config default differs.
|
|
733
|
-
await live.injectClient.turnStart({
|
|
1063
|
+
await live.injectClient.turnStart({
|
|
1064
|
+
threadId,
|
|
1065
|
+
input,
|
|
1066
|
+
cwd: live.cwd,
|
|
1067
|
+
model: live.model,
|
|
1068
|
+
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1069
|
+
});
|
|
734
1070
|
return "injected";
|
|
735
1071
|
}
|
|
736
1072
|
catch {
|
|
737
1073
|
// RPC failed → hard error; caller reports it and does NOT re-run
|
|
738
|
-
// (
|
|
1074
|
+
// (reference implementation: inject failure ⇒ response.failed, never local re-run).
|
|
739
1075
|
return "failed";
|
|
740
1076
|
}
|
|
741
1077
|
});
|
|
@@ -745,8 +1081,8 @@ export class LocalAgentHost {
|
|
|
745
1081
|
/**
|
|
746
1082
|
* Interrupt the session's active turn — the web Stop button. codex: the
|
|
747
1083
|
* app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
|
|
748
|
-
* codex's own TUI interrupt key sends —
|
|
749
|
-
* claude: an Escape keystroke into the pane (no app-server —
|
|
1084
|
+
* codex's own TUI interrupt key sends — reference implementation `_handle_codex_native_interrupt`).
|
|
1085
|
+
* claude: an Escape keystroke into the pane (no app-server — reference implementation
|
|
750
1086
|
* `inject_interrupt`). Returns false as a no-op when there is no active turn.
|
|
751
1087
|
*/
|
|
752
1088
|
async interruptLive(localThreadId) {
|
|
@@ -780,7 +1116,7 @@ export class LocalAgentHost {
|
|
|
780
1116
|
if (!live)
|
|
781
1117
|
return false;
|
|
782
1118
|
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
783
|
-
//
|
|
1119
|
+
// reference implementation no-ops (204) when no turn is active.
|
|
784
1120
|
if (!live.forwarder.isTurnOpen())
|
|
785
1121
|
return false;
|
|
786
1122
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
@@ -797,12 +1133,19 @@ export class LocalAgentHost {
|
|
|
797
1133
|
}
|
|
798
1134
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
799
1135
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
800
|
-
stopLiveCodexSession(localThreadId) {
|
|
1136
|
+
stopLiveCodexSession(localThreadId, opts = {}) {
|
|
801
1137
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
802
1138
|
if (claude) {
|
|
803
1139
|
this.liveClaudeSessions.delete(localThreadId);
|
|
804
1140
|
claude.stopped = true;
|
|
805
1141
|
claude.forwarder.stop();
|
|
1142
|
+
if (opts.deferClaudeInteractionCleanup) {
|
|
1143
|
+
this.pendingClaudeFinalizers.add(claude.forwarder);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
claude.forwarder.finalizeStop();
|
|
1147
|
+
}
|
|
1148
|
+
removeManagedClaudeSettings(claude.bridgeDir);
|
|
806
1149
|
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
807
1150
|
void claude.skillCleanup?.();
|
|
808
1151
|
return;
|
|
@@ -813,37 +1156,75 @@ export class LocalAgentHost {
|
|
|
813
1156
|
this.liveSessions.delete(localThreadId);
|
|
814
1157
|
live.stopped = true;
|
|
815
1158
|
live.releaseActive?.();
|
|
1159
|
+
live.injectClient.cancelInteractions("session_stopped");
|
|
1160
|
+
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1161
|
+
live.injectClient.setInteractionListener(null);
|
|
1162
|
+
live.forwarderClient.setInteractionListener(null);
|
|
1163
|
+
live.injectClient.setConnectionListener(null);
|
|
1164
|
+
live.forwarderClient.setConnectionListener(null);
|
|
1165
|
+
live.interactionOwners.clear();
|
|
1166
|
+
live.interactionStandbys.clear();
|
|
1167
|
+
live.interactionSubmissions.clear();
|
|
816
1168
|
live.forwarder.stop();
|
|
1169
|
+
live.interactionRecoveries.clear();
|
|
1170
|
+
live.interactionFailedClients.clear();
|
|
1171
|
+
live.disconnectedClients.clear();
|
|
1172
|
+
live.canonicalInteractions.clear();
|
|
1173
|
+
live.settledInteractions.clear();
|
|
817
1174
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
818
1175
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
819
1176
|
void live.skillsCleanup?.();
|
|
820
1177
|
}
|
|
1178
|
+
/** Complete the second shutdown phase after the runner has killed all native
|
|
1179
|
+
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1180
|
+
finalizeStoppedLiveSessions() {
|
|
1181
|
+
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1182
|
+
forwarder.finalizeStop();
|
|
1183
|
+
this.pendingClaudeFinalizers.clear();
|
|
1184
|
+
}
|
|
821
1185
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
822
1186
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
823
1187
|
* `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
|
|
824
1188
|
* Trust is pre-seeded so the daemon-spawned TUI never blocks on a first-run
|
|
825
|
-
* prompt.
|
|
826
|
-
*
|
|
1189
|
+
* prompt. Questions and PermissionRequest approvals use the runtime-local
|
|
1190
|
+
* generic interaction bridge and remain inside the active Turn. */
|
|
827
1191
|
claudeTerminalSpec(localThreadId, record) {
|
|
828
|
-
const
|
|
1192
|
+
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1193
|
+
const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
|
|
829
1194
|
try {
|
|
830
1195
|
ensureProjectTrusted(cwd);
|
|
831
1196
|
}
|
|
832
1197
|
catch {
|
|
833
1198
|
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
834
1199
|
}
|
|
835
|
-
const
|
|
1200
|
+
const inheritedSettings = buildManagedClaudeSettings(cwd, {});
|
|
1201
|
+
const inheritedPermissions = inheritedSettings.permissions;
|
|
1202
|
+
const inheritedPermissionMode = inheritedPermissions &&
|
|
1203
|
+
typeof inheritedPermissions === "object" &&
|
|
1204
|
+
!Array.isArray(inheritedPermissions) &&
|
|
1205
|
+
typeof inheritedPermissions.defaultMode === "string"
|
|
1206
|
+
? inheritedPermissions.defaultMode
|
|
1207
|
+
: undefined;
|
|
1208
|
+
const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
|
|
1209
|
+
const hookSettings = buildClaudeHookSettings({
|
|
836
1210
|
bridgeDir: claudeBridgeDir(localThreadId),
|
|
837
|
-
|
|
1211
|
+
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
838
1212
|
messageDisplay: true,
|
|
839
1213
|
statusLine: true,
|
|
840
1214
|
});
|
|
1215
|
+
const settings = { ...inheritedSettings, ...hookSettings };
|
|
1216
|
+
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
841
1217
|
// The live session (created by startLiveClaudeSession before the pane launches)
|
|
842
|
-
// carries the agent-spec launch config: model
|
|
843
|
-
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1218
|
+
// carries the agent-spec launch config: model, instructions, and skills.
|
|
844
1219
|
const args = buildClaudeTuiArgs({
|
|
845
|
-
settingsJson:
|
|
1220
|
+
settingsJson: settingsPath,
|
|
1221
|
+
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
1222
|
+
// project hooks here would let a second PermissionRequest/PreToolUse hook
|
|
1223
|
+
// resolve the same native request while rynx still exposes it as pending.
|
|
1224
|
+
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1225
|
+
settingSources: "",
|
|
846
1226
|
model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
|
|
1227
|
+
...(live?.launchInstructions ? { appendSystemPrompt: live.launchInstructions } : {}),
|
|
847
1228
|
...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
|
|
848
1229
|
...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
849
1230
|
});
|
|
@@ -859,19 +1240,17 @@ export class LocalAgentHost {
|
|
|
859
1240
|
/** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
|
|
860
1241
|
* hooks write into it), then tail bridge + transcript and drive a per-turn
|
|
861
1242
|
* {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
|
|
862
|
-
/** Resolve an agent spec's launch config — model
|
|
1243
|
+
/** Resolve an agent spec's launch config — model, instructions, and the session skill env —
|
|
863
1244
|
* for a LIVE native session, reusing the same core resolvers the non-live
|
|
864
1245
|
* run path uses ({@link resolveAgentExecution} for the model,
|
|
865
|
-
* {@link resolveAgent} for skills).
|
|
866
|
-
* returned: the co-driven TUI runs on vendor defaults (see
|
|
867
|
-
* startLiveClaudeSession).
|
|
1246
|
+
* {@link resolveAgent} for instructions and skills).
|
|
868
1247
|
*
|
|
869
1248
|
* Skills are spec-rooted: each declared ref is replayed into a
|
|
870
1249
|
* SESSION-scoped temp dir (cache-accelerated); the owner's catalog plays no
|
|
871
|
-
* role, and no spec / no `skills` means ZERO skills.
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
1250
|
+
* role, and no spec / no `skills` means ZERO skills. Every declared skill
|
|
1251
|
+
* must resolve at its declared content hash; missing, failed, or drifted
|
|
1252
|
+
* materialization aborts launch before a native session starts.
|
|
1253
|
+
* `skillsCleanup` removes the session dir (call on session stop). */
|
|
875
1254
|
async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
|
|
876
1255
|
// Inline spec needs an id only so a relative instructions-file path can
|
|
877
1256
|
// resolve (mirrors runLocked); a preset agent loads by name.
|
|
@@ -883,6 +1262,7 @@ export class LocalAgentHost {
|
|
|
883
1262
|
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
884
1263
|
...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
|
|
885
1264
|
runtime,
|
|
1265
|
+
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
886
1266
|
});
|
|
887
1267
|
const resolved = await resolveAgent({
|
|
888
1268
|
config: this.config,
|
|
@@ -891,31 +1271,53 @@ export class LocalAgentHost {
|
|
|
891
1271
|
...(spec ? { spec } : {}),
|
|
892
1272
|
});
|
|
893
1273
|
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
894
|
-
|
|
1274
|
+
let skillEnv;
|
|
1275
|
+
try {
|
|
1276
|
+
skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
|
|
1277
|
+
const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
|
|
1278
|
+
const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
|
|
1279
|
+
const { report } = skillEnv;
|
|
1280
|
+
if (report.errors.length > 0 ||
|
|
1281
|
+
report.missing.length > 0 ||
|
|
1282
|
+
report.drift.length > 0 ||
|
|
1283
|
+
resolvedNames.length !== declaredNames.length ||
|
|
1284
|
+
resolvedNames.some((name, index) => name !== declaredNames[index])) {
|
|
1285
|
+
const details = [
|
|
1286
|
+
`declared=[${declaredNames.join(", ")}]`,
|
|
1287
|
+
`resolved=[${resolvedNames.join(", ")}]`,
|
|
1288
|
+
...(report.missing.length > 0 ? [`missing=[${report.missing.join(", ")}]`] : []),
|
|
1289
|
+
...(report.drift.length > 0 ? [`drift=[${report.drift.join("; ")}]`] : []),
|
|
1290
|
+
...report.errors,
|
|
1291
|
+
].join("; ");
|
|
1292
|
+
throw new Error(`failed to provision declared skills: ${details}`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
catch (error) {
|
|
1296
|
+
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1297
|
+
throw error;
|
|
1298
|
+
}
|
|
895
1299
|
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
896
1300
|
const { report } = skillEnv;
|
|
897
|
-
if (report.errors.length > 0 || report.missing.length > 0) {
|
|
898
|
-
console.error(`[skills] session=${localThreadId} agent=${agent} missing=[${report.missing.join(", ")}] ${report.errors.join("; ")}`);
|
|
899
|
-
}
|
|
900
1301
|
if (report.installed.length > 0) {
|
|
901
1302
|
console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
|
|
902
1303
|
}
|
|
903
|
-
for (const note of report.drift) {
|
|
904
|
-
console.error(`[skills] session=${localThreadId} agent=${agent} drift: ${note}`);
|
|
905
|
-
}
|
|
906
1304
|
return {
|
|
907
1305
|
model: exec.model,
|
|
1306
|
+
...((opts?.agentName || opts?.agentSpec) && resolved.instructions
|
|
1307
|
+
? { instructions: resolved.instructions }
|
|
1308
|
+
: {}),
|
|
908
1309
|
selectedSkills: skillEnv.resolved,
|
|
909
1310
|
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
910
1311
|
...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
|
|
911
1312
|
...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
|
|
912
1313
|
...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
|
|
1314
|
+
...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
|
|
913
1315
|
};
|
|
914
1316
|
}
|
|
915
1317
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
916
1318
|
const retargetMirror = opts?.retargetMirror;
|
|
917
|
-
const bridgeDir = prepareClaudeBridgeDir(localThreadId
|
|
918
|
-
const cwd = record?.cwd ?? process.cwd();
|
|
1319
|
+
const bridgeDir = prepareClaudeBridgeDir(localThreadId);
|
|
1320
|
+
const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
|
|
919
1321
|
// Apply the agent spec (model / skills / instructions) to the live TUI launch —
|
|
920
1322
|
// NOT just the config default. The stored record model wins if present (a
|
|
921
1323
|
// resumed thread), else the agent-spec resolution. Spec-load errors are logged
|
|
@@ -929,18 +1331,16 @@ export class LocalAgentHost {
|
|
|
929
1331
|
return false;
|
|
930
1332
|
}
|
|
931
1333
|
const model = resolveRuntimeModel(this.config, "claude", record?.model ?? liveCfg.model);
|
|
932
|
-
// Skills → throwaway `--plugin-dir
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
// so rynx matches (the non-live run path still injects persona for both runtimes).
|
|
936
|
-
// The plugin dir gets its own copy, so the session skills dir is disposable here.
|
|
1334
|
+
// Skills → throwaway `--plugin-dir`; instructions → Claude Code's native
|
|
1335
|
+
// `--append-system-prompt`. The plugin dir gets its own copy, so the session
|
|
1336
|
+
// skills dir is disposable here.
|
|
937
1337
|
const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
|
|
938
1338
|
void liveCfg.skillsCleanup();
|
|
939
1339
|
// Agent-spec permission mode → claude's `--permission-mode` (claude-private
|
|
940
1340
|
// approval axis). Unset sends no flag (claude's own default); any set value
|
|
941
1341
|
// is passed through (`bypassPermissions` is how an agent opts out of the
|
|
942
1342
|
// approval prompt). The shared `sandbox` axis is intentionally NOT applied
|
|
943
|
-
// here — the live co-drive TUI runs unsandboxed (matches
|
|
1343
|
+
// here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
|
|
944
1344
|
const permissionMode = liveCfg.permissionMode;
|
|
945
1345
|
const launchExtraArgs = [
|
|
946
1346
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
@@ -952,15 +1352,36 @@ export class LocalAgentHost {
|
|
|
952
1352
|
let normalizer = null;
|
|
953
1353
|
let currentResponseId;
|
|
954
1354
|
const startNormalizer = (turnId) => {
|
|
955
|
-
// turnId unknown → fixed literal (never random), aligning
|
|
956
|
-
|
|
1355
|
+
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1356
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1357
|
+
if (normalizer && currentResponseId === responseId)
|
|
1358
|
+
return normalizer;
|
|
1359
|
+
currentResponseId = responseId;
|
|
957
1360
|
normalizer = new SessionNormalizer({
|
|
958
1361
|
sessionId: currentSessionId,
|
|
959
|
-
responseId
|
|
1362
|
+
responseId,
|
|
960
1363
|
model,
|
|
961
1364
|
});
|
|
962
1365
|
return normalizer;
|
|
963
1366
|
};
|
|
1367
|
+
const forwardInteraction = (event) => {
|
|
1368
|
+
const n = normalizer ?? startNormalizer(event.turnId);
|
|
1369
|
+
const agentEvent = event.type === "requested"
|
|
1370
|
+
? { type: "interaction_requested", interaction: event.request }
|
|
1371
|
+
: event.type === "resolved"
|
|
1372
|
+
? {
|
|
1373
|
+
type: "interaction_resolved",
|
|
1374
|
+
interactionId: event.interactionId,
|
|
1375
|
+
resolution: event.resolution,
|
|
1376
|
+
}
|
|
1377
|
+
: {
|
|
1378
|
+
type: "interaction_cancelled",
|
|
1379
|
+
interactionId: event.interactionId,
|
|
1380
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
1381
|
+
};
|
|
1382
|
+
for (const se of n.next(agentEvent))
|
|
1383
|
+
emit(se);
|
|
1384
|
+
};
|
|
964
1385
|
let markReady;
|
|
965
1386
|
const ready = new Promise((resolve) => {
|
|
966
1387
|
markReady = resolve;
|
|
@@ -975,7 +1396,9 @@ export class LocalAgentHost {
|
|
|
975
1396
|
discoveredReady: false,
|
|
976
1397
|
stopped: false,
|
|
977
1398
|
launchModel: model,
|
|
1399
|
+
...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
|
|
978
1400
|
launchExtraArgs,
|
|
1401
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
979
1402
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
980
1403
|
};
|
|
981
1404
|
const sink = {
|
|
@@ -996,6 +1419,7 @@ export class LocalAgentHost {
|
|
|
996
1419
|
for (const se of n.next(event))
|
|
997
1420
|
emit(se);
|
|
998
1421
|
},
|
|
1422
|
+
onInteraction: forwardInteraction,
|
|
999
1423
|
onTurnEnd: (usage) => {
|
|
1000
1424
|
if (!normalizer)
|
|
1001
1425
|
return;
|
|
@@ -1007,6 +1431,7 @@ export class LocalAgentHost {
|
|
|
1007
1431
|
for (const se of normalizer.next({ type: "done" }))
|
|
1008
1432
|
emit(se);
|
|
1009
1433
|
normalizer = null;
|
|
1434
|
+
currentResponseId = undefined;
|
|
1010
1435
|
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1011
1436
|
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1012
1437
|
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
@@ -1039,6 +1464,7 @@ export class LocalAgentHost {
|
|
|
1039
1464
|
for (const se of normalizer.fail({ code: "claude_error", message: error.message, source: "execution" }))
|
|
1040
1465
|
emit(se);
|
|
1041
1466
|
normalizer = null;
|
|
1467
|
+
currentResponseId = undefined;
|
|
1042
1468
|
},
|
|
1043
1469
|
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
|
|
1044
1470
|
onSessionRotated: (kind, claudeSessionId) => {
|
|
@@ -1113,10 +1539,10 @@ export class LocalAgentHost {
|
|
|
1113
1539
|
.catch(() => undefined);
|
|
1114
1540
|
live.markReady();
|
|
1115
1541
|
}
|
|
1116
|
-
/** Inject a web message into a claude-native pane via tmux (
|
|
1542
|
+
/** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
|
|
1117
1543
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1118
1544
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1119
|
-
* appears (
|
|
1545
|
+
* appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
|
|
1120
1546
|
* fall-through-to-run signal. Returns {@link InjectOutcome}. */
|
|
1121
1547
|
injectClaude(live, localThreadId, text) {
|
|
1122
1548
|
const run = live.injectLock.then(async () => {
|
|
@@ -1150,7 +1576,7 @@ export class LocalAgentHost {
|
|
|
1150
1576
|
/** Park until the claude session's tmux injector is (re)attached by the
|
|
1151
1577
|
* runner-child, or the deadline passes. Pane relaunch re-attaches it via
|
|
1152
1578
|
* {@link attachTerminalInjector}; without this a message during that window
|
|
1153
|
-
* would fail and (pre-
|
|
1579
|
+
* would fail and (pre-reference implementation) fall through to a second output path. */
|
|
1154
1580
|
async waitInjector(live, timeoutMs) {
|
|
1155
1581
|
const deadline = Date.now() + timeoutMs;
|
|
1156
1582
|
while (!live.injector && Date.now() < deadline) {
|
|
@@ -1166,15 +1592,6 @@ export class LocalAgentHost {
|
|
|
1166
1592
|
if (live)
|
|
1167
1593
|
live.injector = injector;
|
|
1168
1594
|
}
|
|
1169
|
-
/** The PermissionRequest hook's server target from the runner-child env (the
|
|
1170
|
-
* daemon control URL + token), or undefined when unset — then no permission
|
|
1171
|
-
* hook is registered and claude surfaces its own TUI prompt in the pane. */
|
|
1172
|
-
claudePermissionConfig(localThreadId) {
|
|
1173
|
-
const serverUrl = process.env.RYNX_CONTROL_URL?.trim();
|
|
1174
|
-
if (!serverUrl)
|
|
1175
|
-
return undefined;
|
|
1176
|
-
return { sessionId: localThreadId, serverUrl };
|
|
1177
|
-
}
|
|
1178
1595
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1179
1596
|
async listModels(runtime) {
|
|
1180
1597
|
const resolved = runtime ?? this.defaultRuntime;
|
|
@@ -1251,6 +1668,7 @@ export class LocalAgentHost {
|
|
|
1251
1668
|
codexSessionId: forkedThreadId,
|
|
1252
1669
|
cwd: record.cwd,
|
|
1253
1670
|
model: record.model,
|
|
1671
|
+
reasoningEffort: record.reasoningEffort,
|
|
1254
1672
|
runtime,
|
|
1255
1673
|
updatedAt: new Date().toISOString(),
|
|
1256
1674
|
});
|
|
@@ -1299,6 +1717,28 @@ function raceReady(ready, timeoutMs) {
|
|
|
1299
1717
|
clearTimeout(timer);
|
|
1300
1718
|
});
|
|
1301
1719
|
}
|
|
1720
|
+
function liveConfigSignature(opts, runtime, cwd) {
|
|
1721
|
+
return JSON.stringify(stableValue({
|
|
1722
|
+
runtime,
|
|
1723
|
+
cwd,
|
|
1724
|
+
agentName: opts?.agentName,
|
|
1725
|
+
agentSpec: opts?.agentSpec,
|
|
1726
|
+
}));
|
|
1727
|
+
}
|
|
1728
|
+
function stableValue(value) {
|
|
1729
|
+
if (Array.isArray(value))
|
|
1730
|
+
return value.map(stableValue);
|
|
1731
|
+
if (value && typeof value === "object") {
|
|
1732
|
+
const out = {};
|
|
1733
|
+
for (const key of Object.keys(value).sort()) {
|
|
1734
|
+
const item = value[key];
|
|
1735
|
+
if (item !== undefined)
|
|
1736
|
+
out[key] = stableValue(item);
|
|
1737
|
+
}
|
|
1738
|
+
return out;
|
|
1739
|
+
}
|
|
1740
|
+
return value;
|
|
1741
|
+
}
|
|
1302
1742
|
export function getCodexModel(config, override) {
|
|
1303
1743
|
return override?.trim() || config.CODEX_MODEL;
|
|
1304
1744
|
}
|
|
@@ -1316,7 +1756,7 @@ export function isUnsupportedMethodError(error) {
|
|
|
1316
1756
|
}
|
|
1317
1757
|
/** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
|
|
1318
1758
|
* — a fresh TUI thread before its first turn. Retryable (park until active).
|
|
1319
|
-
* Mirrors
|
|
1759
|
+
* Mirrors reference implementation's `_is_thread_not_ready_error`. */
|
|
1320
1760
|
export function isThreadNotReadyError(error) {
|
|
1321
1761
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1322
1762
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|