@rynx-ai/runtime 0.1.11-beta.22 → 0.1.11-beta.23

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.
@@ -22,6 +22,11 @@ export interface ClaudeForwarderSink {
22
22
  /** The current turn finished (a new user prompt, or the inactivity backstop).
23
23
  * `usage` carries the latest statusLine context/cost snapshot, when captured. */
24
24
  onTurnEnd(usage?: Record<string, unknown>): void;
25
+ /** The current turn ended because the user explicitly interrupted it. */
26
+ onTurnInterrupted?(usage?: Record<string, unknown>): void;
27
+ /** Escape was sent for the open Turn. Publish cancelled UI state immediately;
28
+ * the final close remains delayed so a late transcript record can join it. */
29
+ onTurnInterruptRequested?(): void;
25
30
  /** The runtime status changed according to Claude's session metadata. */
26
31
  onStatus?(status: ClaudeRunnerStatus, blockedOn?: string): void;
27
32
  /** The runtime went idle on the legacy hook fallback — surface idle WITHOUT finalizing
@@ -102,6 +107,8 @@ export declare class ClaudeLiveSession {
102
107
  private readonly seenClaudeSessionIds;
103
108
  private turnOpen;
104
109
  private currentTurnId?;
110
+ /** The open turn received an explicit Escape/Stop and must close cancelled. */
111
+ private turnInterrupted;
105
112
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
106
113
  * id keeps responses distinct until the real prompt uuid can be adopted. */
107
114
  private syntheticTurn;
@@ -27,6 +27,10 @@ const MAX_SETTLED_INTERACTIONS = 512;
27
27
  const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
28
28
  const MAX_SUBMISSION_OBSERVATIONS = 64;
29
29
  const INTERACTION_ACK_TIMEOUT_MS = 5_000;
30
+ // Claude writes this synthetic user record after Escape (including a tool-use
31
+ // interruption). It is lifecycle, not a new prompt. Kept aligned with
32
+ // Omnigent's `_CLAUDE_INTERRUPT_RECORD_RE`.
33
+ const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
30
34
  const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
31
35
  function processIsAlive(pid) {
32
36
  try {
@@ -165,6 +169,8 @@ export class ClaudeLiveSession {
165
169
  seenClaudeSessionIds = new Set();
166
170
  turnOpen = false;
167
171
  currentTurnId;
172
+ /** The open turn received an explicit Escape/Stop and must close cancelled. */
173
+ turnInterrupted = false;
168
174
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
169
175
  * id keeps responses distinct until the real prompt uuid can be adopted. */
170
176
  syntheticTurn = false;
@@ -846,6 +852,12 @@ export class ClaudeLiveSession {
846
852
  }
847
853
  const content = userStringContent(rec);
848
854
  if (content !== undefined) {
855
+ const firstLine = content.trim().split("\n", 1)[0] ?? "";
856
+ if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
857
+ this.noteInterrupted();
858
+ this.lastActivityAt = this.now();
859
+ return;
860
+ }
849
861
  const candidates = submissionCandidates(content);
850
862
  const queuedPromotion = rec.promptSource !== "typed" &&
851
863
  candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
@@ -890,6 +902,7 @@ export class ClaudeLiveSession {
890
902
  this.closeTurn();
891
903
  this.currentTurnId = turnId;
892
904
  this.turnOpen = true;
905
+ this.turnInterrupted = false;
893
906
  this.syntheticTurn = false;
894
907
  this.providerIdleAt = null;
895
908
  this.openToolIds.clear();
@@ -1100,6 +1113,7 @@ export class ClaudeLiveSession {
1100
1113
  }
1101
1114
  this.providerIdleAt = null;
1102
1115
  this.turnOpen = true;
1116
+ this.turnInterrupted = false;
1103
1117
  this.sink.onTurnStart(this.currentTurnId);
1104
1118
  }
1105
1119
  /** Mirror a local `!` command as its own mini-turn: close any open turn, then
@@ -1120,8 +1134,10 @@ export class ClaudeLiveSession {
1120
1134
  * turn's close via the same short grace. The Escape has stopped claude, so this
1121
1135
  * does not race a still-running response. */
1122
1136
  noteInterrupted() {
1123
- if (!this.turnOpen)
1137
+ if (!this.turnOpen || this.turnInterrupted)
1124
1138
  return;
1139
+ this.turnInterrupted = true;
1140
+ this.sink.onTurnInterruptRequested?.();
1125
1141
  this.cancelPendingInteractions("turn_interrupted");
1126
1142
  this.stopSignalPending = false;
1127
1143
  if (this.statusPoller?.active)
@@ -1160,7 +1176,13 @@ export class ClaudeLiveSession {
1160
1176
  this.stopSignalPending = false;
1161
1177
  this.stopPendingAt = null;
1162
1178
  this.resetMessageCorrelation();
1163
- this.sink.onTurnEnd(this.statusUsage());
1179
+ const interrupted = this.turnInterrupted;
1180
+ this.turnInterrupted = false;
1181
+ const usage = this.statusUsage();
1182
+ if (interrupted && this.sink.onTurnInterrupted)
1183
+ this.sink.onTurnInterrupted(usage);
1184
+ else
1185
+ this.sink.onTurnEnd(usage);
1164
1186
  }
1165
1187
  closeTurnError(error) {
1166
1188
  if (!this.turnOpen)
@@ -1171,6 +1193,7 @@ export class ClaudeLiveSession {
1171
1193
  this.currentTurnId = undefined;
1172
1194
  this.syntheticTurn = false;
1173
1195
  this.openToolIds.clear();
1196
+ this.turnInterrupted = false;
1174
1197
  this.stopSignalPending = false;
1175
1198
  this.stopPendingAt = null;
1176
1199
  this.resetMessageCorrelation();
@@ -39,6 +39,8 @@ export interface CodexForwarderSink {
39
39
  onStatus?(note: string | undefined, statusKind?: "startup"): void;
40
40
  /** The current turn finished; `usage` is the runtime's raw snapshot if any. */
41
41
  onTurnEnd(usage?: Record<string, unknown>, reason?: "superseded"): void;
42
+ /** The provider confirmed that the active turn was explicitly interrupted. */
43
+ onTurnInterrupted?(usage?: Record<string, unknown>): void;
42
44
  /** Resume proved that the newest turn is terminal even though its live edge
43
45
  * was missed. This updates session state without replaying historical items. */
44
46
  onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
@@ -299,7 +299,9 @@ export class CodexSessionForwarder {
299
299
  }
300
300
  this.scheduleCompletion(mapped.fatalError
301
301
  ? { kind: "error", error: mapped.fatalError }
302
- : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
302
+ : mapped.turnInterrupted
303
+ ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
304
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
303
305
  return;
304
306
  }
305
307
  // A late event from an older turn must not replace the app-server's active
@@ -359,7 +361,9 @@ export class CodexSessionForwarder {
359
361
  return;
360
362
  }
361
363
  if (mapped.turnCompleted) {
362
- this.scheduleCompletion({ kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
364
+ this.scheduleCompletion(mapped.turnInterrupted
365
+ ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
366
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
363
367
  }
364
368
  else {
365
369
  this.refreshCompletionGrace();
@@ -401,6 +405,12 @@ export class CodexSessionForwarder {
401
405
  this.pendingCompletionTurnId = null;
402
406
  if (completion.kind === "error")
403
407
  this.sink.onTurnError(completion.error);
408
+ else if (completion.kind === "interrupted") {
409
+ if (this.sink.onTurnInterrupted)
410
+ this.sink.onTurnInterrupted(completion.usage);
411
+ else
412
+ this.sink.onTurnEnd(completion.usage);
413
+ }
404
414
  else
405
415
  this.sink.onTurnEnd(completion.usage);
406
416
  }
@@ -11,6 +11,7 @@ export interface CodexMapResult {
11
11
  finalText?: string;
12
12
  usage?: Record<string, unknown>;
13
13
  turnCompleted?: boolean;
14
+ turnInterrupted?: boolean;
14
15
  fatalError?: Error;
15
16
  }
16
17
  export declare function codexTurnStatus(turn: unknown): string | undefined;
@@ -241,7 +241,14 @@ export function mapCodexNotification(method, params) {
241
241
  case "turn/failed": {
242
242
  const turnPayload = typed.params?.turn;
243
243
  const fatalError = terminalTurnError(turnPayload, typed.method);
244
- return { events, ...(fatalError ? { fatalError } : {}), turnCompleted: true };
244
+ const turnInterrupted = typed.method === "turn/completed" &&
245
+ ["interrupted", "cancelled", "canceled"].includes(codexTurnStatus(turnPayload) ?? "");
246
+ return {
247
+ events,
248
+ ...(fatalError ? { fatalError } : {}),
249
+ turnCompleted: true,
250
+ ...(turnInterrupted ? { turnInterrupted: true } : {}),
251
+ };
245
252
  }
246
253
  case "turn/plan/updated": {
247
254
  const planParams = typed.params;
package/dist/host.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type ResolvedExecutionSnapshot, type ResolvedExecutionBudget, type Runt
2
2
  import { type AgentRuntimeId } from "@rynx-ai/core";
3
3
  import { type AppConfig } from "@rynx-ai/core";
4
4
  import { createCodexChildEnv } from "./codex-child-env.js";
5
- import type { InjectOutcome } from "./runner/protocol.js";
5
+ import type { InjectResult } from "./runner/protocol.js";
6
6
  import type { ResolveInteractionResult } from "./interactions.js";
7
7
  import { CodexAppServerClient } from "./codex-app-server/client.js";
8
8
  import type { ModelListResponse, ThreadGoal } from "./codex-app-server/protocol.js";
@@ -267,13 +267,13 @@ export declare class LocalAgentHost implements CodexCapabilities {
267
267
  * resumed from the persisted native id. Serialized per session so two
268
268
  * injects can't double-open a turn.
269
269
  *
270
- * Returns an {@link InjectOutcome}: `notLive` when this session has no live
270
+ * Returns an {@link InjectResult}: `notLive` when this session has no live
271
271
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
272
272
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
273
273
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
274
274
  * the bridge instead of a short race that falls back to a second output path.
275
275
  */
276
- injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
276
+ injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectResult>;
277
277
  /**
278
278
  * Interrupt the session's active turn — the web Stop button. codex: the
279
279
  * app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
@@ -287,6 +287,11 @@ export declare class LocalAgentHost implements CodexCapabilities {
287
287
  stopLiveCodexSession(localThreadId: string, opts?: {
288
288
  deferClaudeInteractionCleanup?: boolean;
289
289
  }): void;
290
+ /** Tear down one codex-lineage native runtime without deleting its durable
291
+ * session-store binding. Omnigent couples its auxiliary Terminal, observer,
292
+ * forwarder and per-session app-server as one disposable runtime envelope;
293
+ * the next message recreates that envelope and cold-resumes the native id. */
294
+ teardownLiveCodexSession(localThreadId: string, error?: Error): boolean;
290
295
  /** Complete the second shutdown phase after the runner has killed all native
291
296
  * terminals and hook subprocesses. Must run before the runner process exits. */
292
297
  finalizeStoppedLiveSessions(): void;
@@ -308,7 +313,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
308
313
  * serialized per session. Parks until the thread is ready AND the tmux injector
309
314
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
310
315
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
311
- * fall-through-to-run signal. Returns {@link InjectOutcome}. */
316
+ * fall-through-to-run signal. Returns {@link InjectResult}. */
312
317
  private injectClaude;
313
318
  /** Park until the claude session's tmux injector is (re)attached by the
314
319
  * runner-child, or the deadline passes. Pane relaunch re-attaches it via
package/dist/host.js CHANGED
@@ -681,6 +681,8 @@ export class LocalAgentHost {
681
681
  injectLock: Promise.resolve(),
682
682
  pendingInjectedInputs: [],
683
683
  publishInjectedInput: () => undefined,
684
+ publishInterrupted: () => undefined,
685
+ interruptedResponseId: null,
684
686
  subscribing: false,
685
687
  rotationPending: false,
686
688
  stopped: false,
@@ -698,11 +700,20 @@ export class LocalAgentHost {
698
700
  }
699
701
  emit(event);
700
702
  };
703
+ live.publishInterrupted = (responseId) => {
704
+ if (live.interruptedResponseId === responseId)
705
+ return;
706
+ live.interruptedResponseId = responseId;
707
+ emitCurrent({
708
+ type: "session.interrupted",
709
+ sessionId: currentSessionId,
710
+ responseId,
711
+ });
712
+ };
701
713
  let normalizer = null;
702
714
  let currentResponseId = null;
703
715
  const startNormalizer = (turnId) => {
704
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
705
- (turnId ? `resp_codex_${turnId}` : "resp_codex_native");
716
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
706
717
  if (normalizer && currentResponseId === responseId)
707
718
  return normalizer;
708
719
  currentResponseId = responseId;
@@ -842,6 +853,22 @@ export class LocalAgentHost {
842
853
  normalizer = null;
843
854
  currentResponseId = null;
844
855
  };
856
+ const interruptCurrentTurn = () => {
857
+ closeCanonicalInteractions();
858
+ if (!normalizer)
859
+ return;
860
+ const interruptedResponseId = currentResponseId;
861
+ for (const se of normalizer.interrupt()) {
862
+ if (se.type === "session.interrupted" &&
863
+ live.interruptedResponseId === interruptedResponseId)
864
+ continue;
865
+ emitCurrent(se);
866
+ }
867
+ live.interruptedResponseId = null;
868
+ clearPendingInputsForResponse(interruptedResponseId);
869
+ normalizer = null;
870
+ currentResponseId = null;
871
+ };
845
872
  const sink = {
846
873
  onTurnStart: (turnId) => startNormalizer(turnId),
847
874
  onTurnObserved: (turnId) => {
@@ -856,7 +883,7 @@ export class LocalAgentHost {
856
883
  : content;
857
884
  const signature = JSON.stringify(normalizedContent);
858
885
  const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
859
- if (pending?.state === "optimistic" || pending?.state === "prepublished") {
886
+ if (pending?.state === "optimistic") {
860
887
  live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
861
888
  return;
862
889
  }
@@ -887,6 +914,7 @@ export class LocalAgentHost {
887
914
  // response without publishing a false idle edge in a running→running
888
915
  // transition.
889
916
  onTurnEnd: completeCurrentTurn,
917
+ onTurnInterrupted: interruptCurrentTurn,
890
918
  onRecoveredTurnStatus: (status, turnId, error) => {
891
919
  const responseId = turnId ? `resp_codex_${turnId}` : undefined;
892
920
  if (normalizer && (!responseId || currentResponseId === responseId)) {
@@ -1132,12 +1160,7 @@ export class LocalAgentHost {
1132
1160
  return;
1133
1161
  live.startupError = error;
1134
1162
  this.liveStartupErrors.set(localThreadId, error);
1135
- live.forwarder.failOpenTurn(error);
1136
- const appServerOwner = live.appServerOwner;
1137
- this.stopLiveCodexSession(localThreadId);
1138
- void appServerOwner.stop().catch((stopError) => {
1139
- console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server cleanup after observer failure failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1140
- });
1163
+ this.teardownLiveCodexSession(localThreadId, error);
1141
1164
  }
1142
1165
  shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1143
1166
  const pending = live.managedFork;
@@ -1292,7 +1315,7 @@ export class LocalAgentHost {
1292
1315
  * resumed from the persisted native id. Serialized per session so two
1293
1316
  * injects can't double-open a turn.
1294
1317
  *
1295
- * Returns an {@link InjectOutcome}: `notLive` when this session has no live
1318
+ * Returns an {@link InjectResult}: `notLive` when this session has no live
1296
1319
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
1297
1320
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
1298
1321
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
@@ -1309,9 +1332,8 @@ export class LocalAgentHost {
1309
1332
  const pendingInput = {
1310
1333
  content,
1311
1334
  signature: JSON.stringify(content),
1312
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1335
+ state: "awaiting",
1313
1336
  observed: false,
1314
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1315
1337
  };
1316
1338
  claude.pendingInjectedInputs.push(pendingInput);
1317
1339
  const forgetPendingInput = () => {
@@ -1325,8 +1347,17 @@ export class LocalAgentHost {
1325
1347
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1326
1348
  expiry.unref?.();
1327
1349
  }
1328
- const outcome = await this.injectClaude(claude, localThreadId, text);
1329
- if (outcome !== "injected") {
1350
+ let result;
1351
+ try {
1352
+ result = await this.injectClaude(claude, localThreadId, text, pendingInput);
1353
+ }
1354
+ catch (error) {
1355
+ forgetPendingInput();
1356
+ if (token)
1357
+ claude.pendingImageInputs.delete(token);
1358
+ throw error;
1359
+ }
1360
+ if (result.outcome !== "injected" && result.outcome !== "steered") {
1330
1361
  forgetPendingInput();
1331
1362
  if (token)
1332
1363
  claude.pendingImageInputs.delete(token);
@@ -1334,28 +1365,27 @@ export class LocalAgentHost {
1334
1365
  else if (pendingInput.observed) {
1335
1366
  forgetPendingInput();
1336
1367
  }
1337
- return outcome;
1368
+ return result;
1338
1369
  }
1339
1370
  const live = this.liveSessions.get(localThreadId);
1340
1371
  if (!live)
1341
- return "notLive";
1372
+ return { outcome: "notLive" };
1342
1373
  const run = live.injectLock.then(async () => {
1343
1374
  if (live.rotationPending || live.stopped)
1344
- return "failed";
1375
+ return { outcome: "failed" };
1345
1376
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1346
1377
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1347
1378
  const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1348
1379
  const threadId = live.threadId ?? live.forwarder.threadId();
1349
1380
  if (!bound || !threadId)
1350
- return "notReady";
1381
+ return { outcome: "notReady" };
1351
1382
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1352
1383
  const content = runtimeUserContent(runtimeInput);
1353
1384
  const pendingInput = {
1354
1385
  content,
1355
1386
  signature: JSON.stringify(content),
1356
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1387
+ state: "awaiting",
1357
1388
  observed: false,
1358
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1359
1389
  };
1360
1390
  live.pendingInjectedInputs.push(pendingInput);
1361
1391
  let injectionMethod = "turn/start";
@@ -1378,11 +1408,7 @@ export class LocalAgentHost {
1378
1408
  expectedTurnId: turnId,
1379
1409
  input: nativeInput,
1380
1410
  });
1381
- if (pendingInput.state === "prepublished") {
1382
- // The caller already persisted and published this user input
1383
- // before waiting for the native Terminal to become ready.
1384
- }
1385
- else if (pendingInput.observed) {
1411
+ if (pendingInput.observed) {
1386
1412
  forgetPendingInput();
1387
1413
  }
1388
1414
  else {
@@ -1390,7 +1416,7 @@ export class LocalAgentHost {
1390
1416
  pendingInput.state = "optimistic";
1391
1417
  }
1392
1418
  live.forwarder.noteTurnAccepted(steered.turnId);
1393
- return "steered";
1419
+ return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1394
1420
  }
1395
1421
  }
1396
1422
  // Carry the agent-spec model on the turn so a web-injected turn runs the
@@ -1403,11 +1429,7 @@ export class LocalAgentHost {
1403
1429
  ...(live.model ? { model: live.model } : {}),
1404
1430
  ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1405
1431
  });
1406
- if (pendingInput.state === "prepublished") {
1407
- // The caller already persisted and published this user input before
1408
- // waiting for the native Terminal to become ready.
1409
- }
1410
- else if (pendingInput.observed) {
1432
+ if (pendingInput.observed) {
1411
1433
  forgetPendingInput();
1412
1434
  }
1413
1435
  else {
@@ -1418,7 +1440,7 @@ export class LocalAgentHost {
1418
1440
  // Do not wait for the independent observer connection's `turn/started`:
1419
1441
  // a second message accepted in that window must steer, not double-start.
1420
1442
  live.forwarder.noteTurnAccepted(started.turnId);
1421
- return "injected";
1443
+ return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
1422
1444
  }
1423
1445
  catch (error) {
1424
1446
  forgetPendingInput();
@@ -1501,6 +1523,7 @@ export class LocalAgentHost {
1501
1523
  if (turnId) {
1502
1524
  try {
1503
1525
  await interruptClient.turnInterrupt({ threadId, turnId });
1526
+ live.publishInterrupted(`resp_codex_${turnId}`);
1504
1527
  handled = true;
1505
1528
  }
1506
1529
  catch {
@@ -1555,6 +1578,27 @@ export class LocalAgentHost {
1555
1578
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
1556
1579
  void live.skillsCleanup?.();
1557
1580
  }
1581
+ /** Tear down one codex-lineage native runtime without deleting its durable
1582
+ * session-store binding. Omnigent couples its auxiliary Terminal, observer,
1583
+ * forwarder and per-session app-server as one disposable runtime envelope;
1584
+ * the next message recreates that envelope and cold-resumes the native id. */
1585
+ teardownLiveCodexSession(localThreadId, error) {
1586
+ const live = this.liveSessions.get(localThreadId);
1587
+ if (!live)
1588
+ return false;
1589
+ const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
1590
+ const backendKey = codexBackendKey(live.runtime, live.execution.budget ?? undefined);
1591
+ const backend = this.backends.get(backendKey);
1592
+ const appServerOwner = live.appServerOwner;
1593
+ this.stopLiveCodexSession(localThreadId);
1594
+ if (backend?.appServerClient === appServerOwner) {
1595
+ this.backends.delete(backendKey);
1596
+ }
1597
+ void appServerOwner.stop().catch((stopError) => {
1598
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1599
+ });
1600
+ return turnFailed;
1601
+ }
1558
1602
  /** Complete the second shutdown phase after the runner has killed all native
1559
1603
  * terminals and hook subprocesses. Must run before the runner process exits. */
1560
1604
  finalizeStoppedLiveSessions() {
@@ -1776,8 +1820,7 @@ export class LocalAgentHost {
1776
1820
  };
1777
1821
  const startNormalizer = (turnId) => {
1778
1822
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1779
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1780
- (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
1823
+ const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1781
1824
  if (normalizer && currentResponseId === responseId)
1782
1825
  return normalizer;
1783
1826
  currentResponseId = responseId;
@@ -1822,6 +1865,8 @@ export class LocalAgentHost {
1822
1865
  injectLock: Promise.resolve(),
1823
1866
  pendingImageInputs: new Map(),
1824
1867
  pendingInjectedInputs: [],
1868
+ currentResponseId: () => currentResponseId,
1869
+ publishInterrupted: () => undefined,
1825
1870
  ready,
1826
1871
  markReady,
1827
1872
  failed,
@@ -1833,6 +1878,55 @@ export class LocalAgentHost {
1833
1878
  ...(forkIntent ? { forkIntent } : {}),
1834
1879
  ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1835
1880
  };
1881
+ live.publishInterrupted = (responseId) => {
1882
+ if (live.interruptedResponseId === responseId)
1883
+ return;
1884
+ live.interruptedResponseId = responseId;
1885
+ emitCurrent({
1886
+ type: "session.interrupted",
1887
+ sessionId: currentSessionId,
1888
+ responseId,
1889
+ });
1890
+ };
1891
+ const settleClaudeTurn = (interrupted, usage) => {
1892
+ if (!normalizer)
1893
+ return;
1894
+ const rid = currentResponseId;
1895
+ if (interrupted) {
1896
+ for (const se of normalizer.interrupt()) {
1897
+ if (se.type === "session.interrupted" &&
1898
+ live.interruptedResponseId === rid)
1899
+ continue;
1900
+ emitCurrent(se);
1901
+ }
1902
+ live.interruptedResponseId = undefined;
1903
+ }
1904
+ else {
1905
+ // statusLine usage (context/cost) rides the turn's response.completed.
1906
+ if (usage) {
1907
+ for (const se of normalizer.next({ type: "turn_completed", usage }))
1908
+ emitCurrent(se);
1909
+ }
1910
+ for (const se of normalizer.next({ type: "done" }))
1911
+ emitCurrent(se);
1912
+ }
1913
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1914
+ normalizer = null;
1915
+ currentResponseId = undefined;
1916
+ // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1917
+ // cleared by the next turn's first item). statusLine pre-computes the %.
1918
+ const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1919
+ if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1920
+ live.contextWarned = true;
1921
+ emitCurrent({
1922
+ type: "session.status",
1923
+ sessionId: currentSessionId,
1924
+ responseId: rid,
1925
+ status: "idle",
1926
+ note: `context ${Math.round(pct)}% full — consider /compact`,
1927
+ });
1928
+ }
1929
+ };
1836
1930
  const sink = {
1837
1931
  onTurnStart: (turnId) => startNormalizer(turnId),
1838
1932
  onUserMessage: (text) => {
@@ -1844,12 +1938,13 @@ export class LocalAgentHost {
1844
1938
  const normalizedContent = content ?? [{ type: "input_text", text }];
1845
1939
  const signature = JSON.stringify(normalizedContent);
1846
1940
  const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1847
- if (pending?.state === "prepublished" || pending?.state === "optimistic") {
1941
+ if (pending) {
1942
+ pending.responseId = currentResponseId;
1848
1943
  live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1849
- return;
1850
- }
1851
- if (pending)
1944
+ if (pending.state === "optimistic")
1945
+ return;
1852
1946
  pending.observed = true;
1947
+ }
1853
1948
  for (const se of n.userInput(normalizedContent))
1854
1949
  emitCurrent(se);
1855
1950
  },
@@ -1876,33 +1971,12 @@ export class LocalAgentHost {
1876
1971
  ...(blockedOn ? { note: blockedOn } : {}),
1877
1972
  });
1878
1973
  },
1879
- onTurnEnd: (usage) => {
1880
- if (!normalizer)
1881
- return;
1882
- const rid = currentResponseId;
1883
- // statusLine usage (context/cost) rides the turn's response.completed.
1884
- if (usage) {
1885
- for (const se of normalizer.next({ type: "turn_completed", usage }))
1886
- emitCurrent(se);
1887
- }
1888
- for (const se of normalizer.next({ type: "done" }))
1889
- emitCurrent(se);
1890
- live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1891
- normalizer = null;
1892
- currentResponseId = undefined;
1893
- // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1894
- // cleared by the next turn's first item). statusLine pre-computes the %.
1895
- const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1896
- if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1897
- live.contextWarned = true;
1898
- emitCurrent({
1899
- type: "session.status",
1900
- sessionId: currentSessionId,
1901
- responseId: rid,
1902
- status: "idle",
1903
- note: `context ${Math.round(pct)}% full — consider /compact`,
1904
- });
1905
- }
1974
+ onTurnEnd: (usage) => settleClaudeTurn(false, usage),
1975
+ onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
1976
+ onTurnInterruptRequested: () => {
1977
+ const responseId = currentResponseId;
1978
+ if (responseId)
1979
+ live.publishInterrupted(responseId);
1906
1980
  },
1907
1981
  onIdle: () => {
1908
1982
  // Surface idle on the current turn WITHOUT finalizing it (see the sink's
@@ -2037,17 +2111,18 @@ export class LocalAgentHost {
2037
2111
  * serialized per session. Parks until the thread is ready AND the tmux injector
2038
2112
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
2039
2113
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
2040
- * fall-through-to-run signal. Returns {@link InjectOutcome}. */
2041
- injectClaude(live, localThreadId, text) {
2114
+ * fall-through-to-run signal. Returns {@link InjectResult}. */
2115
+ injectClaude(live, localThreadId, text, pendingInput) {
2042
2116
  const run = live.injectLock.then(async () => {
2043
2117
  const ready = await this.waitLiveReady(localThreadId, 60_000);
2044
2118
  if (!ready)
2045
- return "notReady";
2119
+ return { outcome: "notReady" };
2046
2120
  // Pane may have just relaunched — park until its injector re-attaches
2047
2121
  // (attachTerminalInjector resets it) instead of returning false → fallback.
2048
2122
  const injector = await this.waitInjector(live, 60_000);
2049
2123
  if (!injector)
2050
- return "notReady";
2124
+ return { outcome: "notReady" };
2125
+ const steered = live.forwarder.isTurnOpen();
2051
2126
  // Abortable: the web Stop button cancels an in-flight paste/submit (before
2052
2127
  // the message reaches claude) via interruptLive → injectAbort.abort().
2053
2128
  const abort = new AbortController();
@@ -2058,11 +2133,18 @@ export class LocalAgentHost {
2058
2133
  signal: abort.signal,
2059
2134
  submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
2060
2135
  });
2061
- return ok ? "injected" : "failed";
2136
+ if (!ok)
2137
+ return { outcome: "failed" };
2138
+ const responseId = pendingInput.responseId ?? live.currentResponseId();
2139
+ if (!responseId) {
2140
+ live.error = "Claude accepted the message but did not publish its native Turn identity";
2141
+ return { outcome: "failed" };
2142
+ }
2143
+ return { outcome: steered ? "steered" : "injected", responseId };
2062
2144
  }
2063
2145
  catch (error) {
2064
2146
  live.error = error instanceof Error ? error.message : String(error);
2065
- return "failed";
2147
+ return { outcome: "failed" };
2066
2148
  }
2067
2149
  finally {
2068
2150
  if (live.injectAbort === abort)
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { CodexCapabilities, CapabilityResult, CodexRuntimeStatus, } from ".
9
9
  export type { ClaudeForkIntent, CodexSessionStore, CodexSessionRecord, } from "./codex-session-store.js";
10
10
  export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
11
11
  export type { RunnerManagerOptions, RunnerSessionContext, RunnerSessionContextProvider, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
12
- export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
12
+ export type { InjectOutcome, InjectResult, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
13
13
  export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
14
  export { probeRuntimeStatus } from "./runtime-status.js";
15
15
  export { listRuntimeModels } from "./models-catalog.js";