@songsid/agend 2.1.12-beta.2 → 2.1.12-beta.3

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.
Files changed (46) hide show
  1. package/dist/backend/claude-code.d.ts +20 -0
  2. package/dist/backend/claude-code.js +84 -25
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/codex.d.ts +20 -0
  5. package/dist/backend/codex.js +55 -5
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend/muse.d.ts +6 -0
  8. package/dist/backend/muse.js +8 -0
  9. package/dist/backend/muse.js.map +1 -1
  10. package/dist/backend/types.d.ts +14 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/daemon.d.ts +71 -2
  13. package/dist/daemon.js +308 -20
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/locale.js +4 -4
  16. package/dist/locale.js.map +1 -1
  17. package/dist/outbound-handlers.js +11 -2
  18. package/dist/outbound-handlers.js.map +1 -1
  19. package/dist/transcript-jsonl.d.ts +8 -0
  20. package/dist/transcript-jsonl.js +27 -0
  21. package/dist/transcript-jsonl.js.map +1 -0
  22. package/dist/transcript-monitor.js +13 -24
  23. package/dist/transcript-monitor.js.map +1 -1
  24. package/dist/transcript-sources.js +2 -17
  25. package/dist/transcript-sources.js.map +1 -1
  26. package/dist/turn-reply-guard.d.ts +25 -1
  27. package/dist/turn-reply-guard.js +36 -1
  28. package/dist/turn-reply-guard.js.map +1 -1
  29. package/dist/ui/view.html +17 -10
  30. package/dist/ui/web-terminal/terminal.css +1 -1
  31. package/dist/ui/web-terminal/terminal.js +5 -1
  32. package/dist/usage/format-rich.js +3 -7
  33. package/dist/usage/format-rich.js.map +1 -1
  34. package/dist/usage/i18n-keys.d.ts +1 -1
  35. package/dist/usage/i18n-keys.js +1 -1
  36. package/dist/usage/i18n-keys.js.map +1 -1
  37. package/dist/usage/i18n.d.ts +5 -2
  38. package/dist/usage/i18n.js +9 -5
  39. package/dist/usage/i18n.js.map +1 -1
  40. package/dist/usage/providers.d.ts +11 -5
  41. package/dist/usage/providers.js +55 -1
  42. package/dist/usage/providers.js.map +1 -1
  43. package/dist/usage/usage-api.d.ts +0 -9
  44. package/dist/usage/usage-api.js +7 -32
  45. package/dist/usage/usage-api.js.map +1 -1
  46. package/package.json +1 -1
package/dist/daemon.d.ts CHANGED
@@ -219,6 +219,8 @@ export declare class PendingWorkTracker {
219
219
  private lastIdleOrder;
220
220
  constructor(now?: number);
221
221
  recordInbound(now?: number): void;
222
+ /** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
223
+ lastInboundTimestamp(): number;
222
224
  recordIdle(now?: number): boolean;
223
225
  hasPendingWork(): boolean;
224
226
  }
@@ -561,6 +563,11 @@ export declare class Daemon extends EventEmitter {
561
563
  private instanceStateIdleDebounceMs;
562
564
  private instanceStateStuckTimeoutMs;
563
565
  private instanceStateReadyPattern;
566
+ /**
567
+ * #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
568
+ * When the block lifts, the silence since counts as a fresh interval.
569
+ */
570
+ private sawBlockedInput;
564
571
  private instanceStateBusyPattern;
565
572
  private instanceStateMonitorActive;
566
573
  private sessionCheckpointWarningEmitted;
@@ -570,6 +577,35 @@ export declare class Daemon extends EventEmitter {
570
577
  private lastMalformedToolCallSignature;
571
578
  private proxyReplySeq;
572
579
  private lastReplyDropWarningAt;
580
+ /**
581
+ * #1241: an idle edge that has not yet proven the turn ended. Armed instead
582
+ * of recovering immediately; a steady idle snapshot past confirmAt promotes
583
+ * it to a real recovery, while a delivered reply, cancel, or newly observed
584
+ * work dissolves it. Scoped to one guard generation. Split clock domains
585
+ * (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
586
+ * event ordering, never a deadline), while confirmAt is monotonic
587
+ * (performance.now, same domain as the setTimeout firing the deadline), so
588
+ * a wall jump can neither shorten nor stretch the window.
589
+ */
590
+ private replyGuardIdleConfirm;
591
+ /**
592
+ * #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
593
+ * to 60 s apart, so without this the real delay could approach two windows.
594
+ * At the deadline a fresh, fenced capture re-observes the pane instead.
595
+ */
596
+ private replyGuardConfirmTimer;
597
+ /** #1241: dissolve a pending confirmation and its deadline, if any. */
598
+ private clearReplyGuardConfirm;
599
+ /** #1241: arm a confirmation window with its own deadline capture. */
600
+ private armReplyGuardConfirm;
601
+ /**
602
+ * #1241 P3: the window elapsed with no verdict from monitor ticks — drive
603
+ * one fresh observation. Generation-fenced twice: a stale record returns
604
+ * before capturing, and the resulting snapshot still flows through the
605
+ * normal confirmed evaluation. When the capture cannot run (paused, frozen,
606
+ * spawning), the record stays and the next monitor tick promotes instead.
607
+ */
608
+ private fireReplyGuardConfirm;
573
609
  private autoPauseController;
574
610
  private pauseRequested;
575
611
  /**
@@ -1122,6 +1158,15 @@ export declare class Daemon extends EventEmitter {
1122
1158
  * bounded SIGTERM -> SIGKILL fallback instead of typing into an unknown pane.
1123
1159
  */
1124
1160
  private drainBusyKiroForStop;
1161
+ /**
1162
+ * #1217: the CLI answered its quit command with a confirmation that a stray
1163
+ * key would answer for us (Claude's "Background work is running": Enter
1164
+ * picks "Exit and stop tasks"). Never choose an option: cancel the prompt
1165
+ * with Escape and let the caller stop the process itself (SIGTERM), which is
1166
+ * what the bounded grace would have come to anyway — just without waiting
1167
+ * it out. True when that prompt was on screen.
1168
+ */
1169
+ private cancelQuitConfirmation;
1125
1170
  stop(): Promise<void>;
1126
1171
  getHangDetector(): HangDetector | null;
1127
1172
  getInstanceState(): InstanceState | "paused";
@@ -1197,6 +1242,12 @@ export declare class Daemon extends EventEmitter {
1197
1242
  * manager does not pass through the dead MCP server. Consuming the turn state
1198
1243
  * here (edge-triggered, then reset) is what makes it at most once per turn.
1199
1244
  */
1245
+ /**
1246
+ * #1241: promote a pending idle-edge confirmation once the window has
1247
+ * elapsed. Steady idle with no reply and no work since the edge is proven
1248
+ * idle; anything else dissolves the confirmation and leaves the turn armed.
1249
+ */
1250
+ private maybeConfirmReplyGuardIdle;
1200
1251
  private maybeProxyReplyOnTurnEnd;
1201
1252
  private isClaudeCodeBackend;
1202
1253
  private replyCompletionGuardEnabled;
@@ -1810,10 +1861,28 @@ export declare class Daemon extends EventEmitter {
1810
1861
  * the conversation is gone.
1811
1862
  */
1812
1863
  private paneSaysNoConversation;
1813
- /** How many consecutive startups have failed without proving the session is gone. */
1814
- private unprovenResumeFailures;
1815
1864
  /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
1816
1865
  private static readonly MAX_UNPROVEN_RESUME_FAILURES;
1866
+ /**
1867
+ * #1217: the unproven-failure count lives in the instance directory, keyed by
1868
+ * the session id. Each start builds a new Daemon, so a count kept on the
1869
+ * object was always "attempt 1/3": the escape hatch could never fire, and an
1870
+ * unresumable session failed every start forever. A different session id (or
1871
+ * none) starts the count again; a successful resume or a set-aside ends it.
1872
+ */
1873
+ private bumpUnprovenResumeFailures;
1874
+ private clearUnprovenResumeFailures;
1875
+ /**
1876
+ * #1217: a session id belongs to the backend that made it. After a backend
1877
+ * switch the old id names nothing the new CLI knows (a Claude id handed to
1878
+ * muse is "retained session not found"), so resuming it can only fail. The
1879
+ * owner is recorded on every successful start; an id recorded for another
1880
+ * backend is set aside (kept on disk, as always) and this start is fresh.
1881
+ * An id with no recorded owner (from before this was recorded) is left to
1882
+ * the resume itself — its failure is proven or counted like any other.
1883
+ */
1884
+ private setAsideForeignSession;
1885
+ private recordSessionOwner;
1817
1886
  /**
1818
1887
  * Set the session aside instead of deleting it.
1819
1888
  *
package/dist/daemon.js CHANGED
@@ -36,6 +36,7 @@ import { deliveryContentDigest } from "./delivery-outbox.js";
36
36
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
37
37
  import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
38
38
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
39
+ import { codexLiveRowMatches } from "./backend/codex.js";
39
40
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
40
41
  const __filename = fileURLToPath(import.meta.url);
41
42
  const __dirname = dirname(__filename);
@@ -54,7 +55,16 @@ const TURN_OUTBOUND_TOOLS = new Set([
54
55
  // a deliberate reaction or edit is a valid response even without new text.
55
56
  const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
56
57
  const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
57
- const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. Do not reply to this system instruction except through the react or reply tool.";
58
+ /**
59
+ * #1241: how long an idle edge must persist — with no reply and no further
60
+ * observed work — before the reply guard declares the turn a miss. A claude
61
+ * turn can repaint idle mid-turn (long thinking, subagent delegation) while
62
+ * the first reply is still minutes out; recovering on the first edge injects
63
+ * a prompt the agent later answers twice. A genuine miss stays idle, so the
64
+ * backstop is delayed by at most this window, never disabled.
65
+ */
66
+ const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
67
+ const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
58
68
  /** Point a resumed CLI at its one backend-native instruction source. */
59
69
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
60
70
  const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
@@ -424,6 +434,10 @@ export class PendingWorkTracker {
424
434
  this.lastInboundAt = now;
425
435
  this.lastInboundOrder = ++this.sequence;
426
436
  }
437
+ /** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
438
+ lastInboundTimestamp() {
439
+ return this.lastInboundAt;
440
+ }
427
441
  recordIdle(now = Date.now()) {
428
442
  // An async pane poll can finish after a newer inbound. Do not let its stale
429
443
  // observation clear work which had not arrived when the pane was captured.
@@ -565,6 +579,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
565
579
  const INPUT_TRANSIENT_POLL_MS = 250;
566
580
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
567
581
  const STRANDED_INPUT_MAX_ROUNDS = 3;
582
+ /** The Claude phrasing every backend was judged by before #1217; still the default for a backend that names none. */
583
+ const LEGACY_NO_CONVERSATION = /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i;
584
+ /** #1217: unproven resume failures, kept across Daemon instances, keyed by session id. */
585
+ const RESUME_FAILURES_FILE = "resume-failures.json";
586
+ /** #1217: the backend that owns the stored session id. */
587
+ const SESSION_OWNER_FILE = "session-id.backend";
568
588
  /** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
569
589
  const INPUT_RESIDUE_MAX_ROUNDS = 60;
570
590
  const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
@@ -1157,6 +1177,11 @@ export class Daemon extends EventEmitter {
1157
1177
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1158
1178
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
1159
1179
  instanceStateReadyPattern = null;
1180
+ /**
1181
+ * #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
1182
+ * When the block lifts, the silence since counts as a fresh interval.
1183
+ */
1184
+ sawBlockedInput = false;
1160
1185
  instanceStateBusyPattern = null;
1161
1186
  instanceStateMonitorActive = false;
1162
1187
  sessionCheckpointWarningEmitted = false;
@@ -1169,6 +1194,62 @@ export class Daemon extends EventEmitter {
1169
1194
  lastMalformedToolCallSignature;
1170
1195
  proxyReplySeq = 0;
1171
1196
  lastReplyDropWarningAt = 0;
1197
+ /**
1198
+ * #1241: an idle edge that has not yet proven the turn ended. Armed instead
1199
+ * of recovering immediately; a steady idle snapshot past confirmAt promotes
1200
+ * it to a real recovery, while a delivered reply, cancel, or newly observed
1201
+ * work dissolves it. Scoped to one guard generation. Split clock domains
1202
+ * (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
1203
+ * event ordering, never a deadline), while confirmAt is monotonic
1204
+ * (performance.now, same domain as the setTimeout firing the deadline), so
1205
+ * a wall jump can neither shorten nor stretch the window.
1206
+ */
1207
+ replyGuardIdleConfirm = null;
1208
+ /**
1209
+ * #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
1210
+ * to 60 s apart, so without this the real delay could approach two windows.
1211
+ * At the deadline a fresh, fenced capture re-observes the pane instead.
1212
+ */
1213
+ replyGuardConfirmTimer = null;
1214
+ /** #1241: dissolve a pending confirmation and its deadline, if any. */
1215
+ clearReplyGuardConfirm() {
1216
+ this.replyGuardIdleConfirm = null;
1217
+ if (this.replyGuardConfirmTimer) {
1218
+ clearTimeout(this.replyGuardConfirmTimer);
1219
+ this.replyGuardConfirmTimer = null;
1220
+ }
1221
+ }
1222
+ /** #1241: arm a confirmation window with its own deadline capture. */
1223
+ armReplyGuardConfirm(generation) {
1224
+ this.clearReplyGuardConfirm();
1225
+ this.replyGuardIdleConfirm = {
1226
+ generation,
1227
+ edgeAt: Date.now(),
1228
+ confirmAt: performance.now() + REPLY_GUARD_IDLE_CONFIRM_MS,
1229
+ };
1230
+ this.replyGuardConfirmTimer = setTimeout(() => {
1231
+ this.replyGuardConfirmTimer = null;
1232
+ this.fireReplyGuardConfirm();
1233
+ }, REPLY_GUARD_IDLE_CONFIRM_MS);
1234
+ }
1235
+ /**
1236
+ * #1241 P3: the window elapsed with no verdict from monitor ticks — drive
1237
+ * one fresh observation. Generation-fenced twice: a stale record returns
1238
+ * before capturing, and the resulting snapshot still flows through the
1239
+ * normal confirmed evaluation. When the capture cannot run (paused, frozen,
1240
+ * spawning), the record stays and the next monitor tick promotes instead.
1241
+ */
1242
+ fireReplyGuardConfirm() {
1243
+ const pending = this.replyGuardIdleConfirm;
1244
+ if (!pending)
1245
+ return;
1246
+ const turn = this.turnReplyGuard.snapshot();
1247
+ if (!turn || turn.generation !== pending.generation || turn.phase !== "awaiting") {
1248
+ this.clearReplyGuardConfirm();
1249
+ return;
1250
+ }
1251
+ void this.captureAndEvaluateInstanceState("reply_guard_confirm", this.instanceStateLastOutputAt);
1252
+ }
1172
1253
  autoPauseController;
1173
1254
  pauseRequested = false;
1174
1255
  /**
@@ -3179,12 +3260,9 @@ export class Daemon extends EventEmitter {
3179
3260
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
3180
3261
  evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
3181
3262
  // Count occurrences across the WHOLE pane (not just text after the last
3182
- // ready prompt). Clone with `g` so stateful backend regexes cannot leak
3183
- // lastIndex between monitor cycles.
3184
- const countMatches = (pattern) => {
3185
- const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
3186
- return (pane.match(new RegExp(pattern.source, flags)) || []).length;
3187
- };
3263
+ // ready prompt). One shared counter (the static below): arm requires
3264
+ // count > baseline, so a drifted clone here would silently gate nudges.
3265
+ const countMatches = (pattern) => Daemon.countOccurrences(pattern, pane);
3188
3266
  // Same veto as the state machine: a backend whose ready marker is permanently
3189
3267
  // on screen would otherwise "recover" on the very first tick after the error,
3190
3268
  // rebaselining the occurrence count while the error is still displayed — one
@@ -3413,6 +3491,9 @@ export class Daemon extends EventEmitter {
3413
3491
  // FleetManager calls this synchronously before sendEscape. Cancel only the
3414
3492
  // current human turn; leave already-forwarded tool requests to settle.
3415
3493
  this.turnReplyGuard.cancelByUser();
3494
+ // #1241: a cancelled turn never confirms — its next edge completes it as
3495
+ // intentional. Drop a pending confirmation so it cannot promote afterwards.
3496
+ this.clearReplyGuardConfirm();
3416
3497
  // #1209: a cancelled turn must never resume (#1199) — drop its marker.
3417
3498
  // (Completion paths clear via the guard's onComplete; a cancel with no
3418
3499
  // later idle edge would otherwise leave the marker behind.)
@@ -3743,11 +3824,38 @@ export class Daemon extends EventEmitter {
3743
3824
  return false;
3744
3825
  return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
3745
3826
  }
3827
+ /**
3828
+ * #1217: the CLI answered its quit command with a confirmation that a stray
3829
+ * key would answer for us (Claude's "Background work is running": Enter
3830
+ * picks "Exit and stop tasks"). Never choose an option: cancel the prompt
3831
+ * with Escape and let the caller stop the process itself (SIGTERM), which is
3832
+ * what the bounded grace would have come to anyway — just without waiting
3833
+ * it out. True when that prompt was on screen.
3834
+ */
3835
+ async cancelQuitConfirmation(reason) {
3836
+ const backend = this.backend;
3837
+ if (!backend?.quitBlockedByDialog || !this.tmux)
3838
+ return false;
3839
+ let pane;
3840
+ try {
3841
+ pane = await this.tmux.capturePane();
3842
+ }
3843
+ catch {
3844
+ return false;
3845
+ }
3846
+ if (!backend.quitBlockedByDialog(pane))
3847
+ return false;
3848
+ this.logger.warn({ reason }, "The CLI asked to confirm its exit (background work is running) — cancelling that prompt and stopping the process, not answering it");
3849
+ await this.tmux.sendSpecialKey("Escape").catch(() => false);
3850
+ return true;
3851
+ }
3746
3852
  async stop() {
3747
3853
  this.logger.info("Stopping daemon instance");
3748
3854
  this.clearQuitRelaunchWatch();
3749
3855
  this.fenceDeliveryWritesForStop();
3750
3856
  this.turnReplyGuard.reset();
3857
+ this.clearReplyGuardConfirm();
3858
+ this.sawBlockedInput = false;
3751
3859
  // Invalidate any bounded pre-Enter wait from the process generation being
3752
3860
  // stopped. It must fail closed, not press Enter in a replacement pane.
3753
3861
  this.inputTransientGuardGeneration = null;
@@ -3789,6 +3897,8 @@ export class Daemon extends EventEmitter {
3789
3897
  killed = true;
3790
3898
  break;
3791
3899
  }
3900
+ if (await this.cancelQuitConfirmation("stop"))
3901
+ break;
3792
3902
  }
3793
3903
  }
3794
3904
  if (!killed) {
@@ -4052,6 +4162,8 @@ export class Daemon extends EventEmitter {
4052
4162
  exited = true;
4053
4163
  break;
4054
4164
  }
4165
+ if (await this.cancelQuitConfirmation(pauseReason))
4166
+ break;
4055
4167
  }
4056
4168
  if (!exited) {
4057
4169
  await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
@@ -4212,7 +4324,24 @@ export class Daemon extends EventEmitter {
4212
4324
  // dedicated input_blocked event carries the reason to observers.
4213
4325
  if (this.inputBlockedDialogKey !== null) {
4214
4326
  this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
4215
- return;
4327
+ // #1241 P2-2: stdin-owned work is not idle evidence. A pending window
4328
+ // measured through a dialog would recover on pre-dialog idle time, so
4329
+ // the block dissolves it; the silence after the dialog clears starts a
4330
+ // fresh interval below.
4331
+ this.sawBlockedInput = true;
4332
+ this.clearReplyGuardConfirm();
4333
+ return;
4334
+ }
4335
+ // #1241 P2-2: the block just lifted. With the pane idle, evaluate like an
4336
+ // edge so a genuine miss re-accumulates a full window instead of inheriting
4337
+ // pre-dialog idle time (or never re-arming at all when the daemon froze at
4338
+ // idle). Only arming runs here: recovery-phase turns keep their lifecycle.
4339
+ const justUnblocked = this.sawBlockedInput;
4340
+ this.sawBlockedInput = false;
4341
+ if (justUnblocked && snapshot.state === "idle") {
4342
+ const unblockedTurn = this.turnReplyGuard.snapshot();
4343
+ if (unblockedTurn && unblockedTurn.phase === "awaiting")
4344
+ this.maybeProxyReplyOnTurnEnd(pane);
4216
4345
  }
4217
4346
  this.instanceState = snapshot.state;
4218
4347
  this.acceptedInstanceSnapshot = { ...snapshot };
@@ -4226,6 +4355,18 @@ export class Daemon extends EventEmitter {
4226
4355
  if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
4227
4356
  this.checkpointSessionIdInBackground();
4228
4357
  }
4358
+ // #1241: the turn demonstrably continued — work observed after an idle
4359
+ // edge dissolves a pending guard confirmation. A later genuine end
4360
+ // re-arms it with a fresh edge.
4361
+ if (snapshot.state !== "idle") {
4362
+ this.turnReplyGuard.noteTurnActivity();
4363
+ const pending = this.replyGuardIdleConfirm;
4364
+ const turn = this.turnReplyGuard.snapshot();
4365
+ if (pending && turn && turn.generation === pending.generation && turn.phase === "awaiting") {
4366
+ this.clearReplyGuardConfirm();
4367
+ this.logger.debug({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
4368
+ }
4369
+ }
4229
4370
  // Only a transition back to idle completes pending work. Repeated idle
4230
4371
  // observations between enqueue and paste must not clear a newer inbound.
4231
4372
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -4240,6 +4381,12 @@ export class Daemon extends EventEmitter {
4240
4381
  if (acceptedIdle)
4241
4382
  this.maybeProxyReplyOnTurnEnd(pane);
4242
4383
  }
4384
+ else if (snapshot.state === "idle") {
4385
+ // #1241: steady idle between edges. A pending confirmation whose window
4386
+ // has elapsed is promoted to a real evaluation here — proven idle,
4387
+ // not the first output gap.
4388
+ this.maybeConfirmReplyGuardIdle(pane);
4389
+ }
4243
4390
  if (snapshot.state !== previous) {
4244
4391
  this.logStateEdge(previous, snapshot);
4245
4392
  this.emit("instance_state", { name: this.name, ...snapshot });
@@ -4309,6 +4456,8 @@ export class Daemon extends EventEmitter {
4309
4456
  // The last non-empty line of what we pasted: everything on screen after it
4310
4457
  // is the agent's own output.
4311
4458
  const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
4459
+ // #1241 P2-1: carry pre-arm work evidence over the arm, bounded by this
4460
+ // delivery's ingress — output the paste produced before it confirmed.
4312
4461
  this.turnReplyGuard.arm({
4313
4462
  adapterId: meta.adapter_id || undefined,
4314
4463
  chatId: meta.chat_id,
@@ -4316,7 +4465,7 @@ export class Daemon extends EventEmitter {
4316
4465
  messageId: meta.message_id || undefined,
4317
4466
  correlationId: meta.correlation_id || undefined,
4318
4467
  inboundMarker,
4319
- });
4468
+ }, this.pendingWork.lastInboundTimestamp());
4320
4469
  // #1209: persist the in-flight turn with a seam checkpoint. A marker
4321
4470
  // that survives a restart is an interrupted turn by construction —
4322
4471
  // completion (via the guard's onComplete) and cancel paths delete it.
@@ -4444,7 +4593,18 @@ export class Daemon extends EventEmitter {
4444
4593
  * manager does not pass through the dead MCP server. Consuming the turn state
4445
4594
  * here (edge-triggered, then reset) is what makes it at most once per turn.
4446
4595
  */
4447
- maybeProxyReplyOnTurnEnd(pane) {
4596
+ /**
4597
+ * #1241: promote a pending idle-edge confirmation once the window has
4598
+ * elapsed. Steady idle with no reply and no work since the edge is proven
4599
+ * idle; anything else dissolves the confirmation and leaves the turn armed.
4600
+ */
4601
+ maybeConfirmReplyGuardIdle(pane) {
4602
+ const pending = this.replyGuardIdleConfirm;
4603
+ if (!pending || this.isPaused || performance.now() < pending.confirmAt)
4604
+ return;
4605
+ this.maybeProxyReplyOnTurnEnd(pane, true);
4606
+ }
4607
+ maybeProxyReplyOnTurnEnd(pane, confirmed = false) {
4448
4608
  const turn = this.turnReplyGuard.snapshot();
4449
4609
  if (!turn || this.isPaused)
4450
4610
  return;
@@ -4456,18 +4616,21 @@ export class Daemon extends EventEmitter {
4456
4616
  generation: turn.generation,
4457
4617
  });
4458
4618
  }
4619
+ this.clearReplyGuardConfirm();
4459
4620
  this.turnReplyGuard.complete(turn.generation);
4460
4621
  return;
4461
4622
  }
4462
4623
  // An intentional stop is not a dropped reply. Successful in-flight replies
4463
4624
  // still settle above, but cancellation cannot start another model turn.
4464
4625
  if (turn.cancelledByUser) {
4626
+ this.clearReplyGuardConfirm();
4465
4627
  this.turnReplyGuard.complete(turn.generation);
4466
4628
  return;
4467
4629
  }
4468
4630
  // A second idle edge ends the one permitted recovery turn. Never create a
4469
4631
  // third turn or guess at terminal text; make the failure visible instead.
4470
4632
  if (turn.phase === "recovering") {
4633
+ this.clearReplyGuardConfirm();
4471
4634
  this.turnReplyGuard.complete(turn.generation);
4472
4635
  this.reportUnrecoveredReplyDrop(turn);
4473
4636
  return;
@@ -4528,10 +4691,50 @@ export class Daemon extends EventEmitter {
4528
4691
  if (turn.replyAttempted) {
4529
4692
  // A provider timeout can be "applied, then timed out". Retrying it would
4530
4693
  // risk a duplicate; report the unknown result and stop here.
4694
+ this.clearReplyGuardConfirm();
4531
4695
  this.turnReplyGuard.complete(turn.generation);
4532
4696
  this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4533
4697
  return;
4534
4698
  }
4699
+ // #1241: an idle edge is not proof the turn ended. Two gates before the
4700
+ // recovery prompt goes anywhere near the CLI's input:
4701
+ // 1. work must have been observed after this generation armed — otherwise
4702
+ // the "edge" is a pane flicker (or a stale working state) mid-turn,
4703
+ // and the turn stays armed for its real end;
4704
+ // 2. the edge must persist: the first qualifying edge only arms a
4705
+ // confirmation window, and recovery starts only when steady idle past
4706
+ // the window still shows no reply and no further work.
4707
+ if (!turn.busyObserved) {
4708
+ this.logger.info({
4709
+ correlationId: turn.target.correlationId,
4710
+ generation: turn.generation,
4711
+ reason: "no_busy_since_arm",
4712
+ }, "Reply guard holding — idle edge with no work observed since the turn armed, not a turn end");
4713
+ return;
4714
+ }
4715
+ if (!confirmed) {
4716
+ if (this.replyGuardIdleConfirm?.generation === turn.generation)
4717
+ return;
4718
+ this.armReplyGuardConfirm(turn.generation);
4719
+ this.logger.info({
4720
+ correlationId: turn.target.correlationId,
4721
+ generation: turn.generation,
4722
+ reason: "idle_edge_pending_confirm",
4723
+ confirmMs: REPLY_GUARD_IDLE_CONFIRM_MS,
4724
+ }, "Reply guard idle edge pending confirmation — holding recovery until idle persists");
4725
+ return;
4726
+ }
4727
+ if (this.replyGuardIdleConfirm && turn.lastBusyAt > this.replyGuardIdleConfirm.edgeAt) {
4728
+ this.clearReplyGuardConfirm();
4729
+ this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
4730
+ return;
4731
+ }
4732
+ // A stale confirmation for another generation proves nothing about this one.
4733
+ if (this.replyGuardIdleConfirm && this.replyGuardIdleConfirm.generation !== turn.generation) {
4734
+ this.clearReplyGuardConfirm();
4735
+ return;
4736
+ }
4737
+ this.clearReplyGuardConfirm();
4535
4738
  this.startReplyRecovery(turn, "no_valid_call");
4536
4739
  }
4537
4740
  isClaudeCodeBackend() {
@@ -4544,6 +4747,9 @@ export class Daemon extends EventEmitter {
4544
4747
  && this.backend?.replyCompletionGuard === true;
4545
4748
  }
4546
4749
  startReplyRecovery(turn, reason) {
4750
+ // The confirmation served its purpose: recovery has its own lifecycle now
4751
+ // (a second edge ends it), so a pending window must not linger past this.
4752
+ this.clearReplyGuardConfirm();
4547
4753
  const current = this.turnReplyGuard.snapshot();
4548
4754
  if (current?.generation === turn.generation && current.cancelledByUser) {
4549
4755
  this.turnReplyGuard.complete(turn.generation);
@@ -6359,8 +6565,12 @@ export class Daemon extends EventEmitter {
6359
6565
  const baseline = this.nudgeBaselines?.get(episode.key);
6360
6566
  if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
6361
6567
  return false;
6568
+ // And only when the row is the live item, not a quotation or scrollback above a newer turn. (Never a regex
6569
+ // lookahead on the counting pattern: whole-pane dedup would max at 1 and miss later hits. See #1215.)
6570
+ if (!codexLiveRowMatches(episode.pane, pattern))
6571
+ return false;
6362
6572
  this.capacityNudge = {
6363
- pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
6573
+ pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
6364
6574
  epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
6365
6575
  };
6366
6576
  this.logger.info({ delayMs }, "Model-capacity nudge armed");
@@ -6434,7 +6644,8 @@ export class Daemon extends EventEmitter {
6434
6644
  // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
6435
6645
  const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
6436
6646
  current,
6437
- accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
6647
+ accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
6648
+ && codexLiveRowMatches(pane, nudge.pattern),
6438
6649
  });
6439
6650
  this.logger.info({ sent }, "Model-capacity nudge attempted");
6440
6651
  });
@@ -8605,6 +8816,8 @@ export class Daemon extends EventEmitter {
8605
8816
  // warning is safer than treating the replacement's startup idle as the old
8606
8817
  // turn ending and injecting a stale recovery prompt.
8607
8818
  this.turnReplyGuard.reset();
8819
+ this.clearReplyGuardConfirm();
8820
+ this.sawBlockedInput = false;
8608
8821
  if (this.spawnDepth === 0) {
8609
8822
  this.spawnGeneration++;
8610
8823
  this.interactionObservation.reset(this.interactionOwner());
@@ -8688,13 +8901,80 @@ export class Daemon extends EventEmitter {
8688
8901
  paneSaysNoConversation(paneText) {
8689
8902
  if (!paneText)
8690
8903
  return false;
8691
- return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
8692
- .test(paneText);
8904
+ // Each CLI says it in its own words (#1217): muse's "retained session not
8905
+ // found" never matched the Claude phrasing, so a muse session that was not
8906
+ // there could never be proven gone. Backends that name no pattern keep the
8907
+ // phrasing this always used.
8908
+ const pattern = this.backend?.resumeMissingPattern?.() ?? LEGACY_NO_CONVERSATION;
8909
+ return pattern.test(paneText);
8693
8910
  }
8694
- /** How many consecutive startups have failed without proving the session is gone. */
8695
- unprovenResumeFailures = 0;
8696
8911
  /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
8697
8912
  static MAX_UNPROVEN_RESUME_FAILURES = 3;
8913
+ /**
8914
+ * #1217: the unproven-failure count lives in the instance directory, keyed by
8915
+ * the session id. Each start builds a new Daemon, so a count kept on the
8916
+ * object was always "attempt 1/3": the escape hatch could never fire, and an
8917
+ * unresumable session failed every start forever. A different session id (or
8918
+ * none) starts the count again; a successful resume or a set-aside ends it.
8919
+ */
8920
+ bumpUnprovenResumeFailures() {
8921
+ const path = join(this.instanceDir, RESUME_FAILURES_FILE);
8922
+ let sessionId = "";
8923
+ try {
8924
+ sessionId = readFileSync(join(this.instanceDir, "session-id"), "utf-8").trim();
8925
+ }
8926
+ catch { /* none */ }
8927
+ let previous = null;
8928
+ try {
8929
+ previous = JSON.parse(readFileSync(path, "utf-8"));
8930
+ }
8931
+ catch { /* first failure, or unreadable */ }
8932
+ const count = previous && previous.sessionId === sessionId && Number.isInteger(previous.count) && previous.count > 0
8933
+ ? previous.count + 1
8934
+ : 1;
8935
+ try {
8936
+ writeFileSync(path, JSON.stringify({ sessionId, count }));
8937
+ }
8938
+ catch { /* best effort: counts as before */ }
8939
+ return count;
8940
+ }
8941
+ clearUnprovenResumeFailures() {
8942
+ try {
8943
+ unlinkSync(join(this.instanceDir, RESUME_FAILURES_FILE));
8944
+ }
8945
+ catch { /* none */ }
8946
+ }
8947
+ /**
8948
+ * #1217: a session id belongs to the backend that made it. After a backend
8949
+ * switch the old id names nothing the new CLI knows (a Claude id handed to
8950
+ * muse is "retained session not found"), so resuming it can only fail. The
8951
+ * owner is recorded on every successful start; an id recorded for another
8952
+ * backend is set aside (kept on disk, as always) and this start is fresh.
8953
+ * An id with no recorded owner (from before this was recorded) is left to
8954
+ * the resume itself — its failure is proven or counted like any other.
8955
+ */
8956
+ setAsideForeignSession() {
8957
+ if (!existsSync(join(this.instanceDir, "session-id")))
8958
+ return;
8959
+ let owner = "";
8960
+ try {
8961
+ owner = readFileSync(join(this.instanceDir, SESSION_OWNER_FILE), "utf-8").trim();
8962
+ }
8963
+ catch {
8964
+ return;
8965
+ }
8966
+ const current = this.config.backend ?? "claude-code";
8967
+ if (!owner || owner === current)
8968
+ return;
8969
+ this.logger.warn({ from: owner, to: current }, "The stored session belongs to another backend — not resuming it");
8970
+ this.setSessionAside(`backend_switched:${owner}->${current}`);
8971
+ }
8972
+ recordSessionOwner() {
8973
+ try {
8974
+ writeFileSync(join(this.instanceDir, SESSION_OWNER_FILE), this.config.backend ?? "claude-code");
8975
+ }
8976
+ catch { /* best effort */ }
8977
+ }
8698
8978
  /**
8699
8979
  * Set the session aside instead of deleting it.
8700
8980
  *
@@ -8710,6 +8990,7 @@ export class Daemon extends EventEmitter {
8710
8990
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
8711
8991
  renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
8712
8992
  this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
8993
+ this.clearUnprovenResumeFailures();
8713
8994
  this.pruneAbandonedSessions();
8714
8995
  }
8715
8996
  catch (err) {
@@ -8744,6 +9025,10 @@ export class Daemon extends EventEmitter {
8744
9025
  if (!this.backend) {
8745
9026
  throw new Error("No backend configured — cannot spawn CLI window");
8746
9027
  }
9028
+ // Fresh start or not: the owner is recorded below once this CLI is up, so an
9029
+ // id it does not own must be out of the way first, or it would be recorded
9030
+ // as this backend's and resumed by the next start.
9031
+ this.setAsideForeignSession();
8747
9032
  const attemptedResume = !this.skipResume;
8748
9033
  // A resume launch may get a longer budget than a fresh one (kiro: the
8749
9034
  // conversation must come back from the backend before anything paints).
@@ -8784,18 +9069,18 @@ export class Daemon extends EventEmitter {
8784
9069
  catch { /* pane may be gone */ }
8785
9070
  const proven = this.paneSaysNoConversation(paneText);
8786
9071
  if (!proven) {
8787
- this.unprovenResumeFailures++;
8788
- if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
9072
+ const failures = this.bumpUnprovenResumeFailures();
9073
+ if (failures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
8789
9074
  // Keep the session and fail this attempt; the fleet retries with
8790
9075
  // backoff, which is also how the backend-outage path behaves.
8791
9076
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
8792
9077
  await this.tmux.killWindow();
8793
- throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
9078
+ throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
8794
9079
  + "— session kept, will retry");
8795
9080
  }
8796
9081
  // Escape hatch: a genuinely broken session must still recover. Say so
8797
9082
  // out loud — this start does NOT continue the previous conversation.
8798
- this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
9083
+ this.logger.error({ attempts: failures }, "Giving up on resuming after repeated startup failures — starting fresh. "
8799
9084
  + "The previous conversation is NOT continued; its context is lost to this session.");
8800
9085
  this.emit("context_lost", this.name, "resume_repeatedly_failed");
8801
9086
  }
@@ -8816,11 +9101,14 @@ export class Daemon extends EventEmitter {
8816
9101
  }
8817
9102
  else if (attemptedResume) {
8818
9103
  resumedSuccessfully = true;
9104
+ this.clearUnprovenResumeFailures();
8819
9105
  // A resume needs the backend: its success is positive proof the backend
8820
9106
  // is reachable again (a fresh prompt is local and proves nothing).
8821
9107
  this.backendOutage?.clear(this.backendKey());
8822
9108
  }
8823
9109
  this.lastSpawnAt = Date.now();
9110
+ // #1217: whatever session this CLI writes from here on is this backend's.
9111
+ this.recordSessionOwner();
8824
9112
  this.skipResume = false; // CLI started successfully — reset for next spawn
8825
9113
  this.backgroundSessionRecoveryAttempted = false;
8826
9114
  }