@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/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 +327 -38
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +123 -16
- package/dist/claude/native-integration.js +624 -81
- 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 +14 -3
- package/dist/codex/rollout-synth.d.ts +8 -3
- package/dist/codex/rollout-synth.js +65 -32
- package/dist/codex-app-server/client.d.ts +27 -40
- package/dist/codex-app-server/client.js +1134 -99
- package/dist/codex-app-server/forwarder.d.ts +36 -10
- package/dist/codex-app-server/forwarder.js +146 -28
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +64 -5
- package/dist/codex-app-server/protocol.d.ts +269 -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 +16 -6
- package/dist/codex-home.js +46 -15
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +38 -38
- package/dist/host.js +626 -121
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +13 -0
- package/dist/input-resources.js +67 -0
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +5 -13
- package/dist/models-catalog.js +60 -9
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +100 -19
- package/dist/runner/manager.d.ts +79 -11
- package/dist/runner/manager.js +423 -43
- package/dist/runner/protocol.d.ts +30 -11
- 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 +8 -3
package/dist/host.js
CHANGED
|
@@ -7,10 +7,10 @@ import { resolveAgent, resolveAgentExecution, buildAgentSkillEnvironment, Sessio
|
|
|
7
7
|
import { getRuntimeProfile, } from "@rynx-ai/core";
|
|
8
8
|
import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
|
|
9
9
|
import { createCodexChildEnv } from "./codex-child-env.js";
|
|
10
|
-
import {
|
|
10
|
+
import { prepareRuntimeHome, populateCodexSkills, } from "./codex-home.js";
|
|
11
11
|
import { materializeSkillPlugin } from "./claude/executor.js";
|
|
12
12
|
import { listClaudeModels } from "./claude/models.js";
|
|
13
|
-
import { CodexAppServerClient,
|
|
13
|
+
import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
|
|
14
14
|
import { buildAppServerBaseArgs } from "./codex-app-server/transport.js";
|
|
15
15
|
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
16
16
|
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
@@ -18,9 +18,11 @@ 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 {
|
|
21
|
+
import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
|
|
22
|
+
import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
|
|
22
23
|
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
23
24
|
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
25
|
+
import { buildManagedClaudeSettings } from "./claude/settings.js";
|
|
24
26
|
import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
|
|
25
27
|
import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
|
|
26
28
|
import { newSessionId as makeSessionId } from "@rynx-ai/core";
|
|
@@ -161,11 +163,12 @@ export class SpawnCodexCommandRunner {
|
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
165
|
/** Fraction of an agent's `contextWindow` budget at which the orchestrator
|
|
164
|
-
* emits a one-time `context.threshold` observability signal. Mirrors
|
|
166
|
+
* emits a one-time `context.threshold` observability signal. Mirrors reference implementation's
|
|
165
167
|
* compaction `trigger_threshold` (0.8). codex self-compacts via its token
|
|
166
168
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
167
169
|
* signal is the lever consumers surface to the user. */
|
|
168
170
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
171
|
+
const MAX_CODEX_SETTLED_INTERACTIONS = 512;
|
|
169
172
|
/**
|
|
170
173
|
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
171
174
|
* when the budget declares no retry or the runtime is claude. claude applies
|
|
@@ -217,7 +220,7 @@ export class LocalAgentHost {
|
|
|
217
220
|
injectedAppServerClient;
|
|
218
221
|
clock;
|
|
219
222
|
backendIdleTtlMs;
|
|
220
|
-
// Builds the SEPARATE forwarder connection for a live codex session (
|
|
223
|
+
// Builds the SEPARATE forwarder connection for a live codex session (reference implementation's
|
|
221
224
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
222
225
|
// the backend's app-server; tests inject a fake.
|
|
223
226
|
forwarderClientFactory;
|
|
@@ -232,15 +235,18 @@ export class LocalAgentHost {
|
|
|
232
235
|
// this agent). Absent ⇒ fall back to the daemon `config.*` default.
|
|
233
236
|
sessionSandbox;
|
|
234
237
|
sessionApprovalPolicy;
|
|
235
|
-
//
|
|
236
|
-
// inherited) for this session's
|
|
237
|
-
|
|
238
|
+
// Private CODEX_HOME / TRAE_HOME directories (login symlinked, settings
|
|
239
|
+
// copied, update/NUX state NOT inherited) for this session's app-server + TUI.
|
|
240
|
+
runtimeHomes = new Map();
|
|
238
241
|
// Per-session codex-native live forwarders, keyed by localThreadId. The
|
|
239
242
|
// single-writer of the session's canonical events + the target of turn injection.
|
|
240
243
|
liveSessions = new Map();
|
|
241
244
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
242
245
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
243
246
|
liveClaudeSessions = new Map();
|
|
247
|
+
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
248
|
+
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
249
|
+
pendingClaudeFinalizers = new Set();
|
|
244
250
|
// In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
|
|
245
251
|
// `live.ensure` racing a `term.open`) share ONE forwarder — never two.
|
|
246
252
|
liveEnsuring = new Map();
|
|
@@ -324,9 +330,14 @@ export class LocalAgentHost {
|
|
|
324
330
|
: this.createAppServerClient(runtime, retryEnv);
|
|
325
331
|
return { runtime, profile, appServerClient, commandRunner };
|
|
326
332
|
}
|
|
327
|
-
/** This session's private
|
|
328
|
-
|
|
329
|
-
|
|
333
|
+
/** This session's private native home, shared by its app-server + TUI. */
|
|
334
|
+
runtimeHome(runtime) {
|
|
335
|
+
const existing = this.runtimeHomes.get(runtime);
|
|
336
|
+
if (existing)
|
|
337
|
+
return existing;
|
|
338
|
+
const prepared = prepareRuntimeHome(this.sessionId, runtime);
|
|
339
|
+
this.runtimeHomes.set(runtime, prepared);
|
|
340
|
+
return prepared;
|
|
330
341
|
}
|
|
331
342
|
createAppServerClient(runtime, extraEnv) {
|
|
332
343
|
const cliPath = resolveRuntimeBinary(runtime);
|
|
@@ -337,34 +348,65 @@ export class LocalAgentHost {
|
|
|
337
348
|
const policy = approvalPolicy === "never" ? "auto-approve-session" : "auto-decline";
|
|
338
349
|
// Native co-drive: run the app-server on a loopback ws (multi-client) so a
|
|
339
350
|
// `codex --remote` TUI can attach to the same thread (stdio admits only one
|
|
340
|
-
// client). This is the sole transport — always native, aligning with
|
|
341
|
-
// Point it at the private
|
|
342
|
-
// never trips the real home's update/NUX state.
|
|
351
|
+
// client). This is the sole transport — always native, aligning with reference implementation.
|
|
352
|
+
// Point it at the runtime's private home so it shares the TUI's
|
|
353
|
+
// login/settings and never trips the real home's update/NUX state.
|
|
354
|
+
const profile = getRuntimeProfile(runtime);
|
|
343
355
|
const channel = new WsRpcChannel({
|
|
344
356
|
cliPath,
|
|
345
357
|
baseArgs: buildAppServerBaseArgs(sandbox),
|
|
346
|
-
extraEnv: {
|
|
358
|
+
extraEnv: {
|
|
359
|
+
...(extraEnv ?? {}),
|
|
360
|
+
[profile.homeEnvVar]: this.runtimeHome(runtime),
|
|
361
|
+
},
|
|
347
362
|
});
|
|
348
363
|
return new CodexAppServerClient({
|
|
349
364
|
channel,
|
|
350
365
|
approvalDecisionPolicy: policy,
|
|
351
|
-
interactiveApprovals: this.config.AGENT_INTERACTIVE_APPROVALS,
|
|
352
366
|
});
|
|
353
367
|
}
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
return
|
|
368
|
+
/** Resolve a pending native question/approval without opening a new Turn. */
|
|
369
|
+
async resolveInteraction(localThreadId, interactionId, resolution) {
|
|
370
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
371
|
+
if (claude) {
|
|
372
|
+
return claude.forwarder.resolveInteraction(interactionId, resolution);
|
|
373
|
+
}
|
|
374
|
+
const live = this.liveSessions.get(localThreadId);
|
|
375
|
+
if (!live)
|
|
376
|
+
return { disposition: "not_found" };
|
|
377
|
+
if (live.settledInteractions.has(interactionId)) {
|
|
378
|
+
return { disposition: "already_resolved" };
|
|
379
|
+
}
|
|
380
|
+
if (live.interactionRecoveries.has(interactionId)) {
|
|
381
|
+
return {
|
|
382
|
+
disposition: "invalid",
|
|
383
|
+
message: "interaction delivery is recovering; retry shortly",
|
|
384
|
+
};
|
|
365
385
|
}
|
|
366
|
-
const
|
|
367
|
-
|
|
386
|
+
const submit = (client) => {
|
|
387
|
+
// Store before entering the client: a transport can report a synchronous
|
|
388
|
+
// write failure, and failover must already have the exact accepted answer
|
|
389
|
+
// available for replay. A synchronous terminal event may delete it again.
|
|
390
|
+
const hadPrevious = live.interactionSubmissions.has(interactionId);
|
|
391
|
+
const previous = live.interactionSubmissions.get(interactionId);
|
|
392
|
+
live.interactionSubmissions.set(interactionId, resolution);
|
|
393
|
+
const result = client.resolveInteraction(interactionId, resolution);
|
|
394
|
+
if (result.disposition !== "applied") {
|
|
395
|
+
if (hadPrevious && previous)
|
|
396
|
+
live.interactionSubmissions.set(interactionId, previous);
|
|
397
|
+
else
|
|
398
|
+
live.interactionSubmissions.delete(interactionId);
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
401
|
+
};
|
|
402
|
+
const owner = live.interactionOwners.get(interactionId);
|
|
403
|
+
if (owner)
|
|
404
|
+
return submit(owner);
|
|
405
|
+
const injected = submit(live.injectClient);
|
|
406
|
+
if (injected.disposition !== "not_found") {
|
|
407
|
+
return injected;
|
|
408
|
+
}
|
|
409
|
+
return submit(live.forwarderClient);
|
|
368
410
|
}
|
|
369
411
|
/**
|
|
370
412
|
* The command to run in a session's live terminal so it co-drives the codex
|
|
@@ -382,7 +424,8 @@ export class LocalAgentHost {
|
|
|
382
424
|
if (this.liveClaudeSessions.has(localThreadId)) {
|
|
383
425
|
return this.claudeTerminalSpec(localThreadId, record);
|
|
384
426
|
}
|
|
385
|
-
const
|
|
427
|
+
const live = this.liveSessions.get(localThreadId);
|
|
428
|
+
const runtime = record?.runtime ?? live?.runtime ?? this.defaultRuntime;
|
|
386
429
|
if (runtime === "claude") {
|
|
387
430
|
return this.claudeTerminalSpec(localThreadId, record);
|
|
388
431
|
}
|
|
@@ -392,7 +435,7 @@ export class LocalAgentHost {
|
|
|
392
435
|
throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
|
|
393
436
|
}
|
|
394
437
|
// Native-only: an app-server failure surfaces as a terminal error, never a
|
|
395
|
-
// silent bash fallback (aligns with
|
|
438
|
+
// silent bash fallback (aligns with reference implementation). ensureInitialized throws on its own.
|
|
396
439
|
await client.ensureInitialized();
|
|
397
440
|
const remoteUrl = client.terminalRemoteUrl();
|
|
398
441
|
if (!remoteUrl) {
|
|
@@ -406,9 +449,16 @@ export class LocalAgentHost {
|
|
|
406
449
|
: [];
|
|
407
450
|
// Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
|
|
408
451
|
// shared per-runtime, so a per-session model rides a launch `-c model=` instead).
|
|
409
|
-
const launchModel =
|
|
452
|
+
const launchModel = live?.model;
|
|
410
453
|
if (launchModel)
|
|
411
454
|
configOverrides.push(`model=${JSON.stringify(launchModel)}`);
|
|
455
|
+
const launchEffort = live?.reasoningEffort ?? record?.reasoningEffort;
|
|
456
|
+
if (launchEffort)
|
|
457
|
+
configOverrides.push(`model_reasoning_effort=${JSON.stringify(launchEffort)}`);
|
|
458
|
+
if (live?.instructions) {
|
|
459
|
+
configOverrides.push(`developer_instructions=${JSON.stringify(live.instructions)}`);
|
|
460
|
+
}
|
|
461
|
+
const profile = getRuntimeProfile(runtime);
|
|
412
462
|
return {
|
|
413
463
|
command: resolveRuntimeBinary(runtime),
|
|
414
464
|
args: buildCodexRemoteArgs({
|
|
@@ -416,20 +466,20 @@ export class LocalAgentHost {
|
|
|
416
466
|
...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
|
|
417
467
|
configOverrides,
|
|
418
468
|
}),
|
|
419
|
-
cwd: record?.cwd ?? process.cwd(),
|
|
469
|
+
cwd: record?.cwd ?? live?.cwd ?? process.cwd(),
|
|
420
470
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
421
471
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
422
472
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
423
473
|
env: {
|
|
424
474
|
...createCodexChildEnv(process.env),
|
|
425
|
-
|
|
475
|
+
[profile.homeEnvVar]: this.runtimeHome(runtime),
|
|
426
476
|
RYNX_SESSION_ID: localThreadId,
|
|
427
477
|
},
|
|
428
478
|
};
|
|
429
479
|
}
|
|
430
480
|
/**
|
|
431
481
|
* Bring up (idempotently) a session's persistent codex forwarder and emit its
|
|
432
|
-
* mirrored {@link SessionEvent}s.
|
|
482
|
+
* mirrored {@link SessionEvent}s. reference implementation's codex-native single-writer model:
|
|
433
483
|
* this forwarder is the SOLE producer of the session's canonical events — every
|
|
434
484
|
* turn on the thread, web-composer-injected AND co-driving-`codex --remote`-TUI
|
|
435
485
|
* initiated. Each turn gets a {@link SessionNormalizer} whose `responseId` is
|
|
@@ -444,7 +494,10 @@ export class LocalAgentHost {
|
|
|
444
494
|
* to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
|
|
445
495
|
*/
|
|
446
496
|
async ensureLiveCodexSession(localThreadId, emit, opts) {
|
|
447
|
-
|
|
497
|
+
const live = this.liveSessions.get(localThreadId);
|
|
498
|
+
if (live)
|
|
499
|
+
return this.refreshLiveCodexSession(localThreadId, live, opts);
|
|
500
|
+
if (this.liveClaudeSessions.has(localThreadId))
|
|
448
501
|
return true;
|
|
449
502
|
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
450
503
|
// session (two would double-mirror every turn).
|
|
@@ -461,7 +514,7 @@ export class LocalAgentHost {
|
|
|
461
514
|
}
|
|
462
515
|
}
|
|
463
516
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
464
|
-
// cancel-before-recreate (
|
|
517
|
+
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
465
518
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
466
519
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
467
520
|
// could otherwise leave a half-dead forwarder mirroring in parallel.
|
|
@@ -475,7 +528,8 @@ export class LocalAgentHost {
|
|
|
475
528
|
const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
|
|
476
529
|
if (runtime === "claude")
|
|
477
530
|
return this.startLiveClaudeSession(localThreadId, emit, record, opts);
|
|
478
|
-
const cwd = record?.cwd ?? process.cwd();
|
|
531
|
+
const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
|
|
532
|
+
const configSignature = liveConfigSignature(opts, runtime, cwd);
|
|
479
533
|
// Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
|
|
480
534
|
// logged with the agent context and fail the live start loudly — never a
|
|
481
535
|
// bare mystery `false`.
|
|
@@ -489,14 +543,14 @@ export class LocalAgentHost {
|
|
|
489
543
|
}
|
|
490
544
|
// Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
|
|
491
545
|
// app-server boots (and before the `--remote` TUI attaches) so the native Codex
|
|
492
|
-
// discovers them —
|
|
546
|
+
// discovers them — reference implementation's filesystem mechanism. The live thread is created by
|
|
493
547
|
// the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
|
|
494
548
|
// the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
|
|
495
549
|
// The agent spec is the single skill source: its refs were replayed into a
|
|
496
550
|
// session-scoped dir (see resolveLiveAgentConfig); no spec / no `skills` means
|
|
497
551
|
// zero skills — the owner's catalog is never consulted.
|
|
498
552
|
const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
499
|
-
populateCodexSkills(this.
|
|
553
|
+
populateCodexSkills(this.runtimeHome(runtime), liveSkills);
|
|
500
554
|
// Apply this agent's sandbox / approval policy to the session's app-server
|
|
501
555
|
// BEFORE it boots (getBackend below lazily creates it). Host is per-session,
|
|
502
556
|
// so these knobs are the agent's own — no cross-session bleed. Set even when
|
|
@@ -510,32 +564,42 @@ export class LocalAgentHost {
|
|
|
510
564
|
return false;
|
|
511
565
|
};
|
|
512
566
|
const injectClient = this.getBackend(runtime).appServerClient;
|
|
513
|
-
if (!injectClient)
|
|
567
|
+
if (!injectClient) {
|
|
568
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
514
569
|
return abandon();
|
|
570
|
+
}
|
|
515
571
|
try {
|
|
516
572
|
await injectClient.ensureInitialized();
|
|
517
573
|
}
|
|
518
|
-
catch {
|
|
574
|
+
catch (err) {
|
|
575
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
519
576
|
return abandon();
|
|
520
577
|
}
|
|
521
578
|
const appServerUrl = injectClient.terminalRemoteUrl();
|
|
522
|
-
if (!appServerUrl)
|
|
579
|
+
if (!appServerUrl) {
|
|
580
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
523
581
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
524
|
-
|
|
582
|
+
}
|
|
583
|
+
// reference implementation multi-connection: a SEPARATE forwarder connection to the SAME
|
|
525
584
|
// app-server, which `thread/resume`s to subscribe to the thread's item/turn
|
|
526
585
|
// notifications (the backend client injects; this one only observes).
|
|
527
586
|
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
528
587
|
try {
|
|
529
588
|
await forwarderClient.ensureInitialized();
|
|
530
589
|
}
|
|
531
|
-
catch {
|
|
590
|
+
catch (err) {
|
|
591
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
532
592
|
return abandon();
|
|
533
593
|
}
|
|
534
594
|
// Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
|
|
535
595
|
// A resumed thread keeps its stored model; otherwise use the agent-spec model (or
|
|
536
596
|
// the runtime default). The model rides a launch `-c model=` on the TUI (see
|
|
537
597
|
// `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
|
|
538
|
-
const model =
|
|
598
|
+
const model = resolveRuntimeModel(this.config, runtime, record?.model ?? liveCfg.model);
|
|
599
|
+
const hasCurrentEffortSource = Boolean(opts?.agentName || opts?.agentSpec || opts?.reasoningEffort);
|
|
600
|
+
const reasoningEffort = hasCurrentEffortSource
|
|
601
|
+
? liveCfg.reasoningEffort
|
|
602
|
+
: record?.reasoningEffort ?? liveCfg.reasoningEffort;
|
|
539
603
|
let markReady;
|
|
540
604
|
const ready = new Promise((resolve) => {
|
|
541
605
|
markReady = resolve;
|
|
@@ -547,6 +611,8 @@ export class LocalAgentHost {
|
|
|
547
611
|
runtime,
|
|
548
612
|
cwd,
|
|
549
613
|
model,
|
|
614
|
+
reasoningEffort,
|
|
615
|
+
...(liveCfg.instructions ? { instructions: liveCfg.instructions } : {}),
|
|
550
616
|
threadId: record?.codexSessionId ?? null,
|
|
551
617
|
ready,
|
|
552
618
|
markReady,
|
|
@@ -554,24 +620,236 @@ export class LocalAgentHost {
|
|
|
554
620
|
subscribing: false,
|
|
555
621
|
stopped: false,
|
|
556
622
|
skillsCleanup: liveCfg.skillsCleanup,
|
|
623
|
+
configSignature,
|
|
624
|
+
interactionOwners: new Map(),
|
|
625
|
+
interactionStandbys: new Map(),
|
|
626
|
+
interactionSubmissions: new Map(),
|
|
627
|
+
canonicalInteractions: new Set(),
|
|
628
|
+
settledInteractions: new Set(),
|
|
629
|
+
interactionRecoveries: new Map(),
|
|
630
|
+
interactionFailedClients: new Map(),
|
|
631
|
+
disconnectedClients: new Set(),
|
|
557
632
|
};
|
|
558
633
|
let normalizer = null;
|
|
634
|
+
let currentResponseId = null;
|
|
559
635
|
const startNormalizer = (turnId) => {
|
|
636
|
+
const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
|
|
637
|
+
if (normalizer && currentResponseId === responseId)
|
|
638
|
+
return normalizer;
|
|
639
|
+
currentResponseId = responseId;
|
|
560
640
|
normalizer = new SessionNormalizer({
|
|
561
641
|
sessionId: localThreadId,
|
|
562
642
|
// turnId unknown → fixed literal (NEVER random): items arriving before
|
|
563
643
|
// `turn/started` land on ONE response instead of splitting into random
|
|
564
|
-
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors
|
|
565
|
-
responseId
|
|
566
|
-
model
|
|
644
|
+
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors reference implementation `_response_id`.
|
|
645
|
+
responseId,
|
|
646
|
+
// Traex intentionally leaves the launch model empty to use its own
|
|
647
|
+
// config. Canonical/Direct events still require a printable producer
|
|
648
|
+
// label, so report the runtime when the exact model is unknown.
|
|
649
|
+
model: model || runtime,
|
|
567
650
|
});
|
|
568
651
|
return normalizer;
|
|
569
652
|
};
|
|
653
|
+
const rememberSettledInteraction = (interactionId) => {
|
|
654
|
+
live.settledInteractions.add(interactionId);
|
|
655
|
+
if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
|
|
656
|
+
return;
|
|
657
|
+
const oldest = live.settledInteractions.values().next().value;
|
|
658
|
+
if (oldest)
|
|
659
|
+
live.settledInteractions.delete(oldest);
|
|
660
|
+
};
|
|
661
|
+
const forwardInteraction = (event) => {
|
|
662
|
+
const interactionId = event.type === "requested"
|
|
663
|
+
? event.request.interactionId
|
|
664
|
+
: event.interactionId;
|
|
665
|
+
if (event.type === "requested") {
|
|
666
|
+
if (live.canonicalInteractions.has(interactionId) ||
|
|
667
|
+
live.settledInteractions.has(interactionId))
|
|
668
|
+
return;
|
|
669
|
+
live.canonicalInteractions.add(interactionId);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
// The forwarder may have completed the Turn before another app-server
|
|
673
|
+
// connection publishes its terminal copy. Never create a fresh
|
|
674
|
+
// normalizer/response for that late duplicate.
|
|
675
|
+
if (!live.canonicalInteractions.delete(interactionId))
|
|
676
|
+
return;
|
|
677
|
+
rememberSettledInteraction(interactionId);
|
|
678
|
+
}
|
|
679
|
+
const turnId = event.turnId ?? live.forwarder?.currentTurnId() ?? undefined;
|
|
680
|
+
const n = normalizer ?? startNormalizer(turnId);
|
|
681
|
+
const agentEvent = event.type === "requested"
|
|
682
|
+
? { type: "interaction_requested", interaction: event.request }
|
|
683
|
+
: event.type === "resolved"
|
|
684
|
+
? {
|
|
685
|
+
type: "interaction_resolved",
|
|
686
|
+
interactionId: event.interactionId,
|
|
687
|
+
resolution: event.resolution,
|
|
688
|
+
}
|
|
689
|
+
: {
|
|
690
|
+
type: "interaction_cancelled",
|
|
691
|
+
interactionId: event.interactionId,
|
|
692
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
693
|
+
};
|
|
694
|
+
for (const se of n.next(agentEvent))
|
|
695
|
+
emit(se);
|
|
696
|
+
};
|
|
697
|
+
const clearRecovery = (interactionId) => {
|
|
698
|
+
live.interactionRecoveries.delete(interactionId);
|
|
699
|
+
};
|
|
700
|
+
const clearInteractionRouting = (interactionId) => {
|
|
701
|
+
clearRecovery(interactionId);
|
|
702
|
+
live.interactionOwners.delete(interactionId);
|
|
703
|
+
live.interactionStandbys.delete(interactionId);
|
|
704
|
+
live.interactionSubmissions.delete(interactionId);
|
|
705
|
+
live.interactionFailedClients.delete(interactionId);
|
|
706
|
+
};
|
|
707
|
+
const markInteractionClientFailed = (interactionId, client) => {
|
|
708
|
+
const failed = live.interactionFailedClients.get(interactionId) ?? new Set();
|
|
709
|
+
failed.add(client);
|
|
710
|
+
live.interactionFailedClients.set(interactionId, failed);
|
|
711
|
+
};
|
|
712
|
+
const interactionClientUnavailable = (interactionId, client) => live.disconnectedClients.has(client) ||
|
|
713
|
+
live.interactionFailedClients.get(interactionId)?.has(client) === true;
|
|
714
|
+
const allInteractionClientsUnavailable = (interactionId) => [live.injectClient, live.forwarderClient].every((client) => interactionClientUnavailable(interactionId, client));
|
|
715
|
+
const finishRecovery = (interactionId, event) => {
|
|
716
|
+
clearInteractionRouting(interactionId);
|
|
717
|
+
forwardInteraction(event);
|
|
718
|
+
};
|
|
719
|
+
const beginRecovery = (interactionId, event) => {
|
|
720
|
+
live.interactionOwners.delete(interactionId);
|
|
721
|
+
live.interactionRecoveries.set(interactionId, { event });
|
|
722
|
+
};
|
|
723
|
+
const promoteInteractionOwner = (interactionId, replacement) => {
|
|
724
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
725
|
+
live.interactionOwners.set(interactionId, replacement);
|
|
726
|
+
const submission = live.interactionSubmissions.get(interactionId);
|
|
727
|
+
if (!submission) {
|
|
728
|
+
clearRecovery(interactionId);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
const replayed = replacement.resolveInteraction(interactionId, submission);
|
|
732
|
+
// A write failure can synchronously re-enter the listener and remove this
|
|
733
|
+
// owner. Only clear recovery when the replacement still owns the request.
|
|
734
|
+
if (replayed.disposition === "applied" &&
|
|
735
|
+
live.interactionOwners.get(interactionId) === replacement) {
|
|
736
|
+
clearRecovery(interactionId);
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
if (live.interactionOwners.get(interactionId) === replacement) {
|
|
740
|
+
live.interactionOwners.delete(interactionId);
|
|
741
|
+
}
|
|
742
|
+
if (recovery &&
|
|
743
|
+
live.canonicalInteractions.has(interactionId) &&
|
|
744
|
+
!live.settledInteractions.has(interactionId) &&
|
|
745
|
+
!live.interactionRecoveries.has(interactionId)) {
|
|
746
|
+
live.interactionRecoveries.set(interactionId, recovery);
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
};
|
|
750
|
+
const bindInteractionClient = (client) => {
|
|
751
|
+
client.setInteractionListener((event) => {
|
|
752
|
+
const interactionId = event.type === "requested"
|
|
753
|
+
? event.request.interactionId
|
|
754
|
+
: event.interactionId;
|
|
755
|
+
const owner = live.interactionOwners.get(interactionId);
|
|
756
|
+
if (event.type === "requested") {
|
|
757
|
+
// Receiving a native request proves this connection is alive and can
|
|
758
|
+
// be retried even if an earlier copy failed on it.
|
|
759
|
+
live.disconnectedClients.delete(client);
|
|
760
|
+
const failed = live.interactionFailedClients.get(interactionId);
|
|
761
|
+
failed?.delete(client);
|
|
762
|
+
if (failed?.size === 0)
|
|
763
|
+
live.interactionFailedClients.delete(interactionId);
|
|
764
|
+
if (live.settledInteractions.has(interactionId))
|
|
765
|
+
return;
|
|
766
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
767
|
+
if (recovery) {
|
|
768
|
+
promoteInteractionOwner(interactionId, client);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (owner && owner !== client) {
|
|
772
|
+
const standbys = live.interactionStandbys.get(interactionId) ?? new Set();
|
|
773
|
+
standbys.add(client);
|
|
774
|
+
live.interactionStandbys.set(interactionId, standbys);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
live.interactionOwners.set(interactionId, client);
|
|
778
|
+
forwardInteraction(event);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const recoverableOwnerFailure = event.type === "cancelled" &&
|
|
782
|
+
(event.reason === "app_server_disconnected" || event.reason === "response_write_failed");
|
|
783
|
+
if (recoverableOwnerFailure)
|
|
784
|
+
markInteractionClientFailed(interactionId, client);
|
|
785
|
+
if (!owner || owner !== client) {
|
|
786
|
+
const standbys = live.interactionStandbys.get(interactionId);
|
|
787
|
+
standbys?.delete(client);
|
|
788
|
+
if (standbys?.size === 0)
|
|
789
|
+
live.interactionStandbys.delete(interactionId);
|
|
790
|
+
const recovery = live.interactionRecoveries.get(interactionId);
|
|
791
|
+
if (recoverableOwnerFailure &&
|
|
792
|
+
live.canonicalInteractions.has(interactionId) &&
|
|
793
|
+
allInteractionClientsUnavailable(interactionId)) {
|
|
794
|
+
finishRecovery(interactionId, recovery?.event ?? event);
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (recoverableOwnerFailure) {
|
|
799
|
+
const standbys = live.interactionStandbys.get(interactionId);
|
|
800
|
+
while (standbys && standbys.size > 0) {
|
|
801
|
+
const replacement = standbys.values().next().value;
|
|
802
|
+
if (!replacement)
|
|
803
|
+
break;
|
|
804
|
+
standbys.delete(replacement);
|
|
805
|
+
if (interactionClientUnavailable(interactionId, replacement))
|
|
806
|
+
continue;
|
|
807
|
+
if (promoteInteractionOwner(interactionId, replacement)) {
|
|
808
|
+
if (standbys.size === 0)
|
|
809
|
+
live.interactionStandbys.delete(interactionId);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (live.settledInteractions.has(interactionId))
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!live.settledInteractions.has(interactionId)) {
|
|
816
|
+
if (allInteractionClientsUnavailable(interactionId)) {
|
|
817
|
+
finishRecovery(interactionId, event);
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
beginRecovery(interactionId, event);
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
clearInteractionRouting(interactionId);
|
|
826
|
+
forwardInteraction(event);
|
|
827
|
+
});
|
|
828
|
+
client.setConnectionListener((state) => {
|
|
829
|
+
if (state === "connected") {
|
|
830
|
+
live.disconnectedClients.delete(client);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
live.disconnectedClients.add(client);
|
|
834
|
+
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
835
|
+
if (allInteractionClientsUnavailable(interactionId)) {
|
|
836
|
+
finishRecovery(interactionId, recovery.event);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
};
|
|
841
|
+
const closeCanonicalInteractions = () => {
|
|
842
|
+
for (const interactionId of live.canonicalInteractions) {
|
|
843
|
+
rememberSettledInteraction(interactionId);
|
|
844
|
+
clearInteractionRouting(interactionId);
|
|
845
|
+
}
|
|
846
|
+
live.canonicalInteractions.clear();
|
|
847
|
+
};
|
|
570
848
|
const sink = {
|
|
571
849
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
572
|
-
onUserMessage: (
|
|
850
|
+
onUserMessage: (content) => {
|
|
573
851
|
const n = normalizer ?? startNormalizer();
|
|
574
|
-
for (const se of n.userInput(
|
|
852
|
+
for (const se of n.userInput(content))
|
|
575
853
|
emit(se);
|
|
576
854
|
},
|
|
577
855
|
onEvent: (event) => {
|
|
@@ -580,6 +858,7 @@ export class LocalAgentHost {
|
|
|
580
858
|
emit(se);
|
|
581
859
|
},
|
|
582
860
|
onTurnEnd: (usage) => {
|
|
861
|
+
closeCanonicalInteractions();
|
|
583
862
|
if (!normalizer)
|
|
584
863
|
return;
|
|
585
864
|
if (usage)
|
|
@@ -588,13 +867,29 @@ export class LocalAgentHost {
|
|
|
588
867
|
for (const se of normalizer.next({ type: "done" }))
|
|
589
868
|
emit(se);
|
|
590
869
|
normalizer = null;
|
|
870
|
+
currentResponseId = null;
|
|
591
871
|
},
|
|
592
872
|
onTurnError: (error) => {
|
|
873
|
+
closeCanonicalInteractions();
|
|
593
874
|
if (!normalizer)
|
|
594
875
|
return;
|
|
595
|
-
|
|
876
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
877
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
878
|
+
const message = responseStopped
|
|
879
|
+
? "Response stopped"
|
|
880
|
+
: runtime === "traex"
|
|
881
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
882
|
+
: error.message;
|
|
883
|
+
for (const se of normalizer.fail({
|
|
884
|
+
code: responseStopped
|
|
885
|
+
? "response_stopped"
|
|
886
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
887
|
+
message,
|
|
888
|
+
source: "execution",
|
|
889
|
+
}))
|
|
596
890
|
emit(se);
|
|
597
891
|
normalizer = null;
|
|
892
|
+
currentResponseId = null;
|
|
598
893
|
},
|
|
599
894
|
// The TUI creates the thread; the forwarder connection sees its broadcast
|
|
600
895
|
// `thread/started`. Persist the id, unblock injection, and start the
|
|
@@ -602,8 +897,19 @@ export class LocalAgentHost {
|
|
|
602
897
|
onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
|
|
603
898
|
onThreadActive: () => live.releaseActive?.(),
|
|
604
899
|
};
|
|
605
|
-
const forwarder = new CodexSessionForwarder(forwarderClient, sink
|
|
900
|
+
const forwarder = new CodexSessionForwarder(forwarderClient, sink, {
|
|
901
|
+
// Traex 0.200 can publish a final reasoning item immediately after
|
|
902
|
+
// `turn/completed`; without this grace frame it reopens the response and
|
|
903
|
+
// leaves Chat permanently running even though the TUI is idle. It also
|
|
904
|
+
// completes the final message before that preceding reasoning item, so
|
|
905
|
+
// hold the message over the same window to preserve semantic order.
|
|
906
|
+
turnCompletionGraceMs: runtime === "traex" ? 150 : 0,
|
|
907
|
+
assistantMessageGraceMs: runtime === "traex" ? 150 : 0,
|
|
908
|
+
surfaceQueueStatus: runtime === "traex",
|
|
909
|
+
});
|
|
606
910
|
live.forwarder = forwarder;
|
|
911
|
+
bindInteractionClient(injectClient);
|
|
912
|
+
bindInteractionClient(forwarderClient);
|
|
607
913
|
this.liveSessions.set(localThreadId, live);
|
|
608
914
|
forwarder.start();
|
|
609
915
|
// A resumed session already has a thread id — bind immediately; a fresh
|
|
@@ -613,6 +919,65 @@ export class LocalAgentHost {
|
|
|
613
919
|
}
|
|
614
920
|
return true;
|
|
615
921
|
}
|
|
922
|
+
async refreshLiveCodexSession(localThreadId, live, opts) {
|
|
923
|
+
const desiredRuntime = opts?.runtime ?? live.runtime;
|
|
924
|
+
if (desiredRuntime !== live.runtime)
|
|
925
|
+
return false;
|
|
926
|
+
const configSignature = liveConfigSignature(opts, live.runtime, live.cwd);
|
|
927
|
+
if (live.configSignature === configSignature)
|
|
928
|
+
return true;
|
|
929
|
+
let liveCfg;
|
|
930
|
+
try {
|
|
931
|
+
liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, live.runtime, live.cwd);
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to refresh: ${err instanceof Error ? err.message : String(err)}`);
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
const previousCleanup = live.skillsCleanup;
|
|
938
|
+
try {
|
|
939
|
+
const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
940
|
+
populateCodexSkills(this.runtimeHome(live.runtime), liveSkills);
|
|
941
|
+
void previousCleanup?.().catch(() => undefined);
|
|
942
|
+
this.sessionSandbox = liveCfg.sandbox;
|
|
943
|
+
this.sessionApprovalPolicy = liveCfg.approvalPolicy;
|
|
944
|
+
live.model = resolveRuntimeModel(this.config, live.runtime, liveCfg.model);
|
|
945
|
+
live.reasoningEffort = liveCfg.reasoningEffort;
|
|
946
|
+
live.instructions = liveCfg.instructions;
|
|
947
|
+
live.skillsCleanup = liveCfg.skillsCleanup;
|
|
948
|
+
live.configSignature = configSignature;
|
|
949
|
+
if (live.threadId) {
|
|
950
|
+
// Keep already-running TUI turns aligned too; turn/start below carries
|
|
951
|
+
// the same overrides for web injection. Older app-servers may not have
|
|
952
|
+
// thread/settings/update, so refresh remains best-effort.
|
|
953
|
+
await live.injectClient
|
|
954
|
+
.threadSettingsUpdate({
|
|
955
|
+
threadId: live.threadId,
|
|
956
|
+
...(live.model ? { model: live.model } : {}),
|
|
957
|
+
effort: live.reasoningEffort ?? null,
|
|
958
|
+
})
|
|
959
|
+
.catch(() => undefined);
|
|
960
|
+
void this.sessionStore
|
|
961
|
+
.set({
|
|
962
|
+
localThreadId,
|
|
963
|
+
codexSessionId: live.threadId,
|
|
964
|
+
cwd: live.cwd,
|
|
965
|
+
model: live.model,
|
|
966
|
+
reasoningEffort: live.reasoningEffort,
|
|
967
|
+
runtime: live.runtime,
|
|
968
|
+
agent: opts?.agentName,
|
|
969
|
+
updatedAt: new Date().toISOString(),
|
|
970
|
+
})
|
|
971
|
+
.catch(() => undefined);
|
|
972
|
+
}
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
void liveCfg.skillsCleanup().catch(() => undefined);
|
|
977
|
+
console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} skill refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
616
981
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
617
982
|
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
618
983
|
onLiveThreadStarted(live, localThreadId, agent, threadId) {
|
|
@@ -628,6 +993,7 @@ export class LocalAgentHost {
|
|
|
628
993
|
// Persist the agent-spec-resolved model (was the config default), so a
|
|
629
994
|
// resume/reconnect keeps running the agent's model, not the global one.
|
|
630
995
|
model: live.model,
|
|
996
|
+
reasoningEffort: live.reasoningEffort,
|
|
631
997
|
runtime: live.runtime,
|
|
632
998
|
agent,
|
|
633
999
|
updatedAt: new Date().toISOString(),
|
|
@@ -637,7 +1003,7 @@ export class LocalAgentHost {
|
|
|
637
1003
|
void this.subscribeUntilReady(live, threadId);
|
|
638
1004
|
}
|
|
639
1005
|
/**
|
|
640
|
-
* Subscribe the forwarder connection to a thread (
|
|
1006
|
+
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
641
1007
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
642
1008
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
643
1009
|
* thread active, then retry WITHOUT `excludeTurns` so the response backfills the
|
|
@@ -692,7 +1058,7 @@ export class LocalAgentHost {
|
|
|
692
1058
|
return ok;
|
|
693
1059
|
}
|
|
694
1060
|
/**
|
|
695
|
-
* Inject a user turn into a session's live codex thread —
|
|
1061
|
+
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
696
1062
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
697
1063
|
* supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
|
|
698
1064
|
* this targets the id the forwarder captured). Serialized per session so two
|
|
@@ -701,41 +1067,66 @@ export class LocalAgentHost {
|
|
|
701
1067
|
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
702
1068
|
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
703
1069
|
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
704
|
-
* forwarder). Park-until-ready (~60s), aligning
|
|
1070
|
+
* forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
|
|
705
1071
|
* the bridge instead of a short race that falls back to a second output path.
|
|
706
1072
|
*/
|
|
707
|
-
async injectMessage(localThreadId,
|
|
1073
|
+
async injectMessage(localThreadId, input) {
|
|
1074
|
+
const runtimeInput = typeof input === "string"
|
|
1075
|
+
? { content: [{ type: "text", text: input }] }
|
|
1076
|
+
: input;
|
|
708
1077
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
709
|
-
if (claude)
|
|
710
|
-
|
|
1078
|
+
if (claude) {
|
|
1079
|
+
const text = claudeInputText(runtimeInput);
|
|
1080
|
+
const token = claudeAttachmentToken(text);
|
|
1081
|
+
if (token) {
|
|
1082
|
+
claude.pendingImageInputs.set(token, runtimeUserContent(runtimeInput));
|
|
1083
|
+
const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
|
|
1084
|
+
expiry.unref?.();
|
|
1085
|
+
}
|
|
1086
|
+
const outcome = await this.injectClaude(claude, localThreadId, text);
|
|
1087
|
+
if (token && (outcome === "notLive" || outcome === "notReady")) {
|
|
1088
|
+
claude.pendingImageInputs.delete(token);
|
|
1089
|
+
}
|
|
1090
|
+
return outcome;
|
|
1091
|
+
}
|
|
711
1092
|
const live = this.liveSessions.get(localThreadId);
|
|
712
1093
|
if (!live)
|
|
713
1094
|
return "notLive";
|
|
714
1095
|
const run = live.injectLock.then(async () => {
|
|
715
|
-
// Park until the thread binds (~60s,
|
|
1096
|
+
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
716
1097
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
717
1098
|
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
718
1099
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
719
1100
|
if (!bound || !threadId)
|
|
720
1101
|
return "notReady";
|
|
721
|
-
const
|
|
1102
|
+
const nativeInput = buildRuntimeUserInput(runtimeInput);
|
|
722
1103
|
try {
|
|
723
1104
|
// Inject via the BACKEND client (the forwarder connection only observes).
|
|
724
1105
|
if (live.forwarder.isTurnOpen()) {
|
|
725
1106
|
const turnId = live.forwarder.currentTurnId();
|
|
726
1107
|
if (turnId) {
|
|
727
|
-
await live.injectClient.turnSteer({
|
|
1108
|
+
await live.injectClient.turnSteer({
|
|
1109
|
+
threadId,
|
|
1110
|
+
expectedTurnId: turnId,
|
|
1111
|
+
input: nativeInput,
|
|
1112
|
+
});
|
|
728
1113
|
return "injected";
|
|
729
1114
|
}
|
|
730
1115
|
}
|
|
731
1116
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
732
1117
|
// agent's model even if the TUI's config default differs.
|
|
733
|
-
await live.injectClient.turnStart({
|
|
1118
|
+
await live.injectClient.turnStart({
|
|
1119
|
+
threadId,
|
|
1120
|
+
input: nativeInput,
|
|
1121
|
+
cwd: live.cwd,
|
|
1122
|
+
...(live.model ? { model: live.model } : {}),
|
|
1123
|
+
...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
|
|
1124
|
+
});
|
|
734
1125
|
return "injected";
|
|
735
1126
|
}
|
|
736
1127
|
catch {
|
|
737
1128
|
// RPC failed → hard error; caller reports it and does NOT re-run
|
|
738
|
-
// (
|
|
1129
|
+
// (reference implementation: inject failure ⇒ response.failed, never local re-run).
|
|
739
1130
|
return "failed";
|
|
740
1131
|
}
|
|
741
1132
|
});
|
|
@@ -745,8 +1136,8 @@ export class LocalAgentHost {
|
|
|
745
1136
|
/**
|
|
746
1137
|
* Interrupt the session's active turn — the web Stop button. codex: the
|
|
747
1138
|
* 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 —
|
|
1139
|
+
* codex's own TUI interrupt key sends — reference implementation `_handle_codex_native_interrupt`).
|
|
1140
|
+
* claude: an Escape keystroke into the pane (no app-server — reference implementation
|
|
750
1141
|
* `inject_interrupt`). Returns false as a no-op when there is no active turn.
|
|
751
1142
|
*/
|
|
752
1143
|
async interruptLive(localThreadId) {
|
|
@@ -780,7 +1171,7 @@ export class LocalAgentHost {
|
|
|
780
1171
|
if (!live)
|
|
781
1172
|
return false;
|
|
782
1173
|
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
783
|
-
//
|
|
1174
|
+
// reference implementation no-ops (204) when no turn is active.
|
|
784
1175
|
if (!live.forwarder.isTurnOpen())
|
|
785
1176
|
return false;
|
|
786
1177
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
@@ -797,12 +1188,19 @@ export class LocalAgentHost {
|
|
|
797
1188
|
}
|
|
798
1189
|
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
799
1190
|
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
800
|
-
stopLiveCodexSession(localThreadId) {
|
|
1191
|
+
stopLiveCodexSession(localThreadId, opts = {}) {
|
|
801
1192
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
802
1193
|
if (claude) {
|
|
803
1194
|
this.liveClaudeSessions.delete(localThreadId);
|
|
804
1195
|
claude.stopped = true;
|
|
805
1196
|
claude.forwarder.stop();
|
|
1197
|
+
if (opts.deferClaudeInteractionCleanup) {
|
|
1198
|
+
this.pendingClaudeFinalizers.add(claude.forwarder);
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
claude.forwarder.finalizeStop();
|
|
1202
|
+
}
|
|
1203
|
+
removeManagedClaudeSettings(claude.bridgeDir);
|
|
806
1204
|
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
807
1205
|
void claude.skillCleanup?.();
|
|
808
1206
|
return;
|
|
@@ -813,37 +1211,75 @@ export class LocalAgentHost {
|
|
|
813
1211
|
this.liveSessions.delete(localThreadId);
|
|
814
1212
|
live.stopped = true;
|
|
815
1213
|
live.releaseActive?.();
|
|
1214
|
+
live.injectClient.cancelInteractions("session_stopped");
|
|
1215
|
+
live.forwarderClient.cancelInteractions("session_stopped");
|
|
1216
|
+
live.injectClient.setInteractionListener(null);
|
|
1217
|
+
live.forwarderClient.setInteractionListener(null);
|
|
1218
|
+
live.injectClient.setConnectionListener(null);
|
|
1219
|
+
live.forwarderClient.setConnectionListener(null);
|
|
1220
|
+
live.interactionOwners.clear();
|
|
1221
|
+
live.interactionStandbys.clear();
|
|
1222
|
+
live.interactionSubmissions.clear();
|
|
816
1223
|
live.forwarder.stop();
|
|
1224
|
+
live.interactionRecoveries.clear();
|
|
1225
|
+
live.interactionFailedClients.clear();
|
|
1226
|
+
live.disconnectedClients.clear();
|
|
1227
|
+
live.canonicalInteractions.clear();
|
|
1228
|
+
live.settledInteractions.clear();
|
|
817
1229
|
void live.forwarderClient.stop().catch(() => undefined);
|
|
818
1230
|
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
819
1231
|
void live.skillsCleanup?.();
|
|
820
1232
|
}
|
|
1233
|
+
/** Complete the second shutdown phase after the runner has killed all native
|
|
1234
|
+
* terminals and hook subprocesses. Must run before the runner process exits. */
|
|
1235
|
+
finalizeStoppedLiveSessions() {
|
|
1236
|
+
for (const forwarder of this.pendingClaudeFinalizers)
|
|
1237
|
+
forwarder.finalizeStop();
|
|
1238
|
+
this.pendingClaudeFinalizers.clear();
|
|
1239
|
+
}
|
|
821
1240
|
// --- claude-native live session (parallel to the codex methods above) -----
|
|
822
1241
|
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
823
1242
|
* `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
|
|
824
1243
|
* Trust is pre-seeded so the daemon-spawned TUI never blocks on a first-run
|
|
825
|
-
* prompt.
|
|
826
|
-
*
|
|
1244
|
+
* prompt. Questions and PermissionRequest approvals use the runtime-local
|
|
1245
|
+
* generic interaction bridge and remain inside the active Turn. */
|
|
827
1246
|
claudeTerminalSpec(localThreadId, record) {
|
|
828
|
-
const
|
|
1247
|
+
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1248
|
+
const cwd = record?.cwd ?? live?.cwd ?? process.cwd();
|
|
829
1249
|
try {
|
|
830
1250
|
ensureProjectTrusted(cwd);
|
|
831
1251
|
}
|
|
832
1252
|
catch {
|
|
833
1253
|
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
834
1254
|
}
|
|
835
|
-
const
|
|
1255
|
+
const inheritedSettings = buildManagedClaudeSettings(cwd, {});
|
|
1256
|
+
const inheritedPermissions = inheritedSettings.permissions;
|
|
1257
|
+
const inheritedPermissionMode = inheritedPermissions &&
|
|
1258
|
+
typeof inheritedPermissions === "object" &&
|
|
1259
|
+
!Array.isArray(inheritedPermissions) &&
|
|
1260
|
+
typeof inheritedPermissions.defaultMode === "string"
|
|
1261
|
+
? inheritedPermissions.defaultMode
|
|
1262
|
+
: undefined;
|
|
1263
|
+
const hookPermissionMode = live?.permissionMode ?? inheritedPermissionMode;
|
|
1264
|
+
const hookSettings = buildClaudeHookSettings({
|
|
836
1265
|
bridgeDir: claudeBridgeDir(localThreadId),
|
|
837
|
-
|
|
1266
|
+
...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
|
|
838
1267
|
messageDisplay: true,
|
|
839
1268
|
statusLine: true,
|
|
840
1269
|
});
|
|
1270
|
+
const settings = { ...inheritedSettings, ...hookSettings };
|
|
1271
|
+
const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
|
|
841
1272
|
// 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);
|
|
1273
|
+
// carries the agent-spec launch config: model, instructions, and skills.
|
|
844
1274
|
const args = buildClaudeTuiArgs({
|
|
845
|
-
settingsJson:
|
|
1275
|
+
settingsJson: settingsPath,
|
|
1276
|
+
// A rynx-managed Session must have one interaction owner. Loading host or
|
|
1277
|
+
// project hooks here would let a second PermissionRequest/PreToolUse hook
|
|
1278
|
+
// resolve the same native request while rynx still exposes it as pending.
|
|
1279
|
+
// Explicit rynx hooks and selected-skill plugin dirs remain enabled.
|
|
1280
|
+
settingSources: "",
|
|
846
1281
|
model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
|
|
1282
|
+
...(live?.launchInstructions ? { appendSystemPrompt: live.launchInstructions } : {}),
|
|
847
1283
|
...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
|
|
848
1284
|
...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
849
1285
|
});
|
|
@@ -859,19 +1295,17 @@ export class LocalAgentHost {
|
|
|
859
1295
|
/** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
|
|
860
1296
|
* hooks write into it), then tail bridge + transcript and drive a per-turn
|
|
861
1297
|
* {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
|
|
862
|
-
/** Resolve an agent spec's launch config — model
|
|
1298
|
+
/** Resolve an agent spec's launch config — model, instructions, and the session skill env —
|
|
863
1299
|
* for a LIVE native session, reusing the same core resolvers the non-live
|
|
864
1300
|
* 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).
|
|
1301
|
+
* {@link resolveAgent} for instructions and skills).
|
|
868
1302
|
*
|
|
869
1303
|
* Skills are spec-rooted: each declared ref is replayed into a
|
|
870
1304
|
* 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
|
-
*
|
|
1305
|
+
* role, and no spec / no `skills` means ZERO skills. Every declared skill
|
|
1306
|
+
* must resolve at its declared content hash; missing, failed, or drifted
|
|
1307
|
+
* materialization aborts launch before a native session starts.
|
|
1308
|
+
* `skillsCleanup` removes the session dir (call on session stop). */
|
|
875
1309
|
async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
|
|
876
1310
|
// Inline spec needs an id only so a relative instructions-file path can
|
|
877
1311
|
// resolve (mirrors runLocked); a preset agent loads by name.
|
|
@@ -883,6 +1317,7 @@ export class LocalAgentHost {
|
|
|
883
1317
|
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
884
1318
|
...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
|
|
885
1319
|
runtime,
|
|
1320
|
+
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
886
1321
|
});
|
|
887
1322
|
const resolved = await resolveAgent({
|
|
888
1323
|
config: this.config,
|
|
@@ -891,31 +1326,53 @@ export class LocalAgentHost {
|
|
|
891
1326
|
...(spec ? { spec } : {}),
|
|
892
1327
|
});
|
|
893
1328
|
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
894
|
-
|
|
1329
|
+
let skillEnv;
|
|
1330
|
+
try {
|
|
1331
|
+
skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
|
|
1332
|
+
const declaredNames = (resolved.skills ?? []).map((skill) => skill.name);
|
|
1333
|
+
const resolvedNames = skillEnv.resolved.map((skill) => skill.name);
|
|
1334
|
+
const { report } = skillEnv;
|
|
1335
|
+
if (report.errors.length > 0 ||
|
|
1336
|
+
report.missing.length > 0 ||
|
|
1337
|
+
report.drift.length > 0 ||
|
|
1338
|
+
resolvedNames.length !== declaredNames.length ||
|
|
1339
|
+
resolvedNames.some((name, index) => name !== declaredNames[index])) {
|
|
1340
|
+
const details = [
|
|
1341
|
+
`declared=[${declaredNames.join(", ")}]`,
|
|
1342
|
+
`resolved=[${resolvedNames.join(", ")}]`,
|
|
1343
|
+
...(report.missing.length > 0 ? [`missing=[${report.missing.join(", ")}]`] : []),
|
|
1344
|
+
...(report.drift.length > 0 ? [`drift=[${report.drift.join("; ")}]`] : []),
|
|
1345
|
+
...report.errors,
|
|
1346
|
+
].join("; ");
|
|
1347
|
+
throw new Error(`failed to provision declared skills: ${details}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
catch (error) {
|
|
1351
|
+
await rm(sessionSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1352
|
+
throw error;
|
|
1353
|
+
}
|
|
895
1354
|
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
896
1355
|
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
1356
|
if (report.installed.length > 0) {
|
|
901
1357
|
console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
|
|
902
1358
|
}
|
|
903
|
-
for (const note of report.drift) {
|
|
904
|
-
console.error(`[skills] session=${localThreadId} agent=${agent} drift: ${note}`);
|
|
905
|
-
}
|
|
906
1359
|
return {
|
|
907
1360
|
model: exec.model,
|
|
1361
|
+
...((opts?.agentName || opts?.agentSpec) && resolved.instructions
|
|
1362
|
+
? { instructions: resolved.instructions }
|
|
1363
|
+
: {}),
|
|
908
1364
|
selectedSkills: skillEnv.resolved,
|
|
909
1365
|
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
910
1366
|
...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
|
|
911
1367
|
...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
|
|
912
1368
|
...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
|
|
1369
|
+
...(exec.reasoningEffort ? { reasoningEffort: exec.reasoningEffort } : {}),
|
|
913
1370
|
};
|
|
914
1371
|
}
|
|
915
1372
|
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
916
1373
|
const retargetMirror = opts?.retargetMirror;
|
|
917
|
-
const bridgeDir = prepareClaudeBridgeDir(localThreadId
|
|
918
|
-
const cwd = record?.cwd ?? process.cwd();
|
|
1374
|
+
const bridgeDir = prepareClaudeBridgeDir(localThreadId);
|
|
1375
|
+
const cwd = record?.cwd ?? opts?.cwd ?? process.cwd();
|
|
919
1376
|
// Apply the agent spec (model / skills / instructions) to the live TUI launch —
|
|
920
1377
|
// NOT just the config default. The stored record model wins if present (a
|
|
921
1378
|
// resumed thread), else the agent-spec resolution. Spec-load errors are logged
|
|
@@ -929,18 +1386,16 @@ export class LocalAgentHost {
|
|
|
929
1386
|
return false;
|
|
930
1387
|
}
|
|
931
1388
|
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.
|
|
1389
|
+
// Skills → throwaway `--plugin-dir`; instructions → Claude Code's native
|
|
1390
|
+
// `--append-system-prompt`. The plugin dir gets its own copy, so the session
|
|
1391
|
+
// skills dir is disposable here.
|
|
937
1392
|
const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
|
|
938
1393
|
void liveCfg.skillsCleanup();
|
|
939
1394
|
// Agent-spec permission mode → claude's `--permission-mode` (claude-private
|
|
940
1395
|
// approval axis). Unset sends no flag (claude's own default); any set value
|
|
941
1396
|
// is passed through (`bypassPermissions` is how an agent opts out of the
|
|
942
1397
|
// approval prompt). The shared `sandbox` axis is intentionally NOT applied
|
|
943
|
-
// here — the live co-drive TUI runs unsandboxed (matches
|
|
1398
|
+
// here — the live co-drive TUI runs unsandboxed (matches reference implementation's native).
|
|
944
1399
|
const permissionMode = liveCfg.permissionMode;
|
|
945
1400
|
const launchExtraArgs = [
|
|
946
1401
|
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
@@ -952,15 +1407,36 @@ export class LocalAgentHost {
|
|
|
952
1407
|
let normalizer = null;
|
|
953
1408
|
let currentResponseId;
|
|
954
1409
|
const startNormalizer = (turnId) => {
|
|
955
|
-
// turnId unknown → fixed literal (never random), aligning
|
|
956
|
-
|
|
1410
|
+
// turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
|
|
1411
|
+
const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
1412
|
+
if (normalizer && currentResponseId === responseId)
|
|
1413
|
+
return normalizer;
|
|
1414
|
+
currentResponseId = responseId;
|
|
957
1415
|
normalizer = new SessionNormalizer({
|
|
958
1416
|
sessionId: currentSessionId,
|
|
959
|
-
responseId
|
|
1417
|
+
responseId,
|
|
960
1418
|
model,
|
|
961
1419
|
});
|
|
962
1420
|
return normalizer;
|
|
963
1421
|
};
|
|
1422
|
+
const forwardInteraction = (event) => {
|
|
1423
|
+
const n = normalizer ?? startNormalizer(event.turnId);
|
|
1424
|
+
const agentEvent = event.type === "requested"
|
|
1425
|
+
? { type: "interaction_requested", interaction: event.request }
|
|
1426
|
+
: event.type === "resolved"
|
|
1427
|
+
? {
|
|
1428
|
+
type: "interaction_resolved",
|
|
1429
|
+
interactionId: event.interactionId,
|
|
1430
|
+
resolution: event.resolution,
|
|
1431
|
+
}
|
|
1432
|
+
: {
|
|
1433
|
+
type: "interaction_cancelled",
|
|
1434
|
+
interactionId: event.interactionId,
|
|
1435
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
1436
|
+
};
|
|
1437
|
+
for (const se of n.next(agentEvent))
|
|
1438
|
+
emit(se);
|
|
1439
|
+
};
|
|
964
1440
|
let markReady;
|
|
965
1441
|
const ready = new Promise((resolve) => {
|
|
966
1442
|
markReady = resolve;
|
|
@@ -970,19 +1446,26 @@ export class LocalAgentHost {
|
|
|
970
1446
|
cwd,
|
|
971
1447
|
bridgeDir,
|
|
972
1448
|
injectLock: Promise.resolve(),
|
|
1449
|
+
pendingImageInputs: new Map(),
|
|
973
1450
|
ready,
|
|
974
1451
|
markReady,
|
|
975
1452
|
discoveredReady: false,
|
|
976
1453
|
stopped: false,
|
|
977
1454
|
launchModel: model,
|
|
1455
|
+
...(liveCfg.instructions ? { launchInstructions: liveCfg.instructions } : {}),
|
|
978
1456
|
launchExtraArgs,
|
|
1457
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
979
1458
|
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
980
1459
|
};
|
|
981
1460
|
const sink = {
|
|
982
1461
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
983
1462
|
onUserMessage: (text) => {
|
|
984
1463
|
const n = normalizer ?? startNormalizer();
|
|
985
|
-
|
|
1464
|
+
const token = claudeAttachmentToken(text);
|
|
1465
|
+
const content = token ? live.pendingImageInputs.get(token) : undefined;
|
|
1466
|
+
if (token && content)
|
|
1467
|
+
live.pendingImageInputs.delete(token);
|
|
1468
|
+
for (const se of n.userInput(content ?? text))
|
|
986
1469
|
emit(se);
|
|
987
1470
|
},
|
|
988
1471
|
onTerminalCommand: (cmd) => {
|
|
@@ -996,6 +1479,7 @@ export class LocalAgentHost {
|
|
|
996
1479
|
for (const se of n.next(event))
|
|
997
1480
|
emit(se);
|
|
998
1481
|
},
|
|
1482
|
+
onInteraction: forwardInteraction,
|
|
999
1483
|
onTurnEnd: (usage) => {
|
|
1000
1484
|
if (!normalizer)
|
|
1001
1485
|
return;
|
|
@@ -1007,6 +1491,7 @@ export class LocalAgentHost {
|
|
|
1007
1491
|
for (const se of normalizer.next({ type: "done" }))
|
|
1008
1492
|
emit(se);
|
|
1009
1493
|
normalizer = null;
|
|
1494
|
+
currentResponseId = undefined;
|
|
1010
1495
|
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1011
1496
|
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1012
1497
|
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
@@ -1033,12 +1518,17 @@ export class LocalAgentHost {
|
|
|
1033
1518
|
});
|
|
1034
1519
|
}
|
|
1035
1520
|
},
|
|
1036
|
-
onTurnError: (
|
|
1521
|
+
onTurnError: () => {
|
|
1037
1522
|
if (!normalizer)
|
|
1038
1523
|
return;
|
|
1039
|
-
for (const se of normalizer.fail({
|
|
1524
|
+
for (const se of normalizer.fail({
|
|
1525
|
+
code: "agent_error",
|
|
1526
|
+
message: "Agent turn failed",
|
|
1527
|
+
source: "execution",
|
|
1528
|
+
}))
|
|
1040
1529
|
emit(se);
|
|
1041
1530
|
normalizer = null;
|
|
1531
|
+
currentResponseId = undefined;
|
|
1042
1532
|
},
|
|
1043
1533
|
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
|
|
1044
1534
|
onSessionRotated: (kind, claudeSessionId) => {
|
|
@@ -1113,10 +1603,10 @@ export class LocalAgentHost {
|
|
|
1113
1603
|
.catch(() => undefined);
|
|
1114
1604
|
live.markReady();
|
|
1115
1605
|
}
|
|
1116
|
-
/** Inject a web message into a claude-native pane via tmux (
|
|
1606
|
+
/** Inject a web message into a claude-native pane via tmux (reference implementation recipe),
|
|
1117
1607
|
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1118
1608
|
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1119
|
-
* appears (
|
|
1609
|
+
* appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
|
|
1120
1610
|
* fall-through-to-run signal. Returns {@link InjectOutcome}. */
|
|
1121
1611
|
injectClaude(live, localThreadId, text) {
|
|
1122
1612
|
const run = live.injectLock.then(async () => {
|
|
@@ -1133,7 +1623,11 @@ export class LocalAgentHost {
|
|
|
1133
1623
|
const abort = new AbortController();
|
|
1134
1624
|
live.injectAbort = abort;
|
|
1135
1625
|
try {
|
|
1136
|
-
const
|
|
1626
|
+
const submissionCheckpoint = live.forwarder.beginSubmissionObservation();
|
|
1627
|
+
const ok = await injectViaTerminal(injector, text, {
|
|
1628
|
+
signal: abort.signal,
|
|
1629
|
+
submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
|
|
1630
|
+
});
|
|
1137
1631
|
return ok ? "injected" : "failed";
|
|
1138
1632
|
}
|
|
1139
1633
|
catch {
|
|
@@ -1150,7 +1644,7 @@ export class LocalAgentHost {
|
|
|
1150
1644
|
/** Park until the claude session's tmux injector is (re)attached by the
|
|
1151
1645
|
* runner-child, or the deadline passes. Pane relaunch re-attaches it via
|
|
1152
1646
|
* {@link attachTerminalInjector}; without this a message during that window
|
|
1153
|
-
* would fail and (pre-
|
|
1647
|
+
* would fail and (pre-reference implementation) fall through to a second output path. */
|
|
1154
1648
|
async waitInjector(live, timeoutMs) {
|
|
1155
1649
|
const deadline = Date.now() + timeoutMs;
|
|
1156
1650
|
while (!live.injector && Date.now() < deadline) {
|
|
@@ -1166,15 +1660,6 @@ export class LocalAgentHost {
|
|
|
1166
1660
|
if (live)
|
|
1167
1661
|
live.injector = injector;
|
|
1168
1662
|
}
|
|
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
1663
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1179
1664
|
async listModels(runtime) {
|
|
1180
1665
|
const resolved = runtime ?? this.defaultRuntime;
|
|
@@ -1251,6 +1736,7 @@ export class LocalAgentHost {
|
|
|
1251
1736
|
codexSessionId: forkedThreadId,
|
|
1252
1737
|
cwd: record.cwd,
|
|
1253
1738
|
model: record.model,
|
|
1739
|
+
reasoningEffort: record.reasoningEffort,
|
|
1254
1740
|
runtime,
|
|
1255
1741
|
updatedAt: new Date().toISOString(),
|
|
1256
1742
|
});
|
|
@@ -1299,8 +1785,27 @@ function raceReady(ready, timeoutMs) {
|
|
|
1299
1785
|
clearTimeout(timer);
|
|
1300
1786
|
});
|
|
1301
1787
|
}
|
|
1302
|
-
|
|
1303
|
-
return
|
|
1788
|
+
function liveConfigSignature(opts, runtime, cwd) {
|
|
1789
|
+
return JSON.stringify(stableValue({
|
|
1790
|
+
runtime,
|
|
1791
|
+
cwd,
|
|
1792
|
+
agentName: opts?.agentName,
|
|
1793
|
+
agentSpec: opts?.agentSpec,
|
|
1794
|
+
}));
|
|
1795
|
+
}
|
|
1796
|
+
function stableValue(value) {
|
|
1797
|
+
if (Array.isArray(value))
|
|
1798
|
+
return value.map(stableValue);
|
|
1799
|
+
if (value && typeof value === "object") {
|
|
1800
|
+
const out = {};
|
|
1801
|
+
for (const key of Object.keys(value).sort()) {
|
|
1802
|
+
const item = value[key];
|
|
1803
|
+
if (item !== undefined)
|
|
1804
|
+
out[key] = stableValue(item);
|
|
1805
|
+
}
|
|
1806
|
+
return out;
|
|
1807
|
+
}
|
|
1808
|
+
return value;
|
|
1304
1809
|
}
|
|
1305
1810
|
export { createCodexChildEnv };
|
|
1306
1811
|
export function isUnsupportedMethodError(error) {
|
|
@@ -1316,7 +1821,7 @@ export function isUnsupportedMethodError(error) {
|
|
|
1316
1821
|
}
|
|
1317
1822
|
/** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
|
|
1318
1823
|
* — a fresh TUI thread before its first turn. Retryable (park until active).
|
|
1319
|
-
* Mirrors
|
|
1824
|
+
* Mirrors reference implementation's `_is_thread_not_ready_error`. */
|
|
1320
1825
|
export function isThreadNotReadyError(error) {
|
|
1321
1826
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1322
1827
|
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|