@songsid/agend 2.1.12-beta.1 → 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 (92) hide show
  1. package/dist/agent-cli.js +36 -4
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +3 -1
  4. package/dist/agent-endpoint.js +7 -1
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +22 -0
  7. package/dist/backend/claude-code.js +86 -27
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/codex.d.ts +20 -0
  10. package/dist/backend/codex.js +55 -5
  11. package/dist/backend/codex.js.map +1 -1
  12. package/dist/backend/muse.d.ts +6 -0
  13. package/dist/backend/muse.js +8 -0
  14. package/dist/backend/muse.js.map +1 -1
  15. package/dist/backend/session-signals.d.ts +154 -0
  16. package/dist/backend/session-signals.js +430 -0
  17. package/dist/backend/session-signals.js.map +1 -0
  18. package/dist/backend/types.d.ts +14 -0
  19. package/dist/backend/types.js.map +1 -1
  20. package/dist/channel/adapters/discord.d.ts +38 -1
  21. package/dist/channel/adapters/discord.js +131 -8
  22. package/dist/channel/adapters/discord.js.map +1 -1
  23. package/dist/channel/adapters/telegram.d.ts +19 -1
  24. package/dist/channel/adapters/telegram.js +55 -0
  25. package/dist/channel/adapters/telegram.js.map +1 -1
  26. package/dist/channel/mcp-tools.js +5 -1
  27. package/dist/channel/mcp-tools.js.map +1 -1
  28. package/dist/channel/message-queue.d.ts +6 -0
  29. package/dist/channel/message-queue.js +21 -0
  30. package/dist/channel/message-queue.js.map +1 -1
  31. package/dist/channel/sticker-download.d.ts +14 -0
  32. package/dist/channel/sticker-download.js +42 -0
  33. package/dist/channel/sticker-download.js.map +1 -0
  34. package/dist/channel/tool-router.js +12 -5
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +52 -0
  37. package/dist/cli-flags.d.ts +8 -0
  38. package/dist/cli-flags.js +22 -0
  39. package/dist/cli-flags.js.map +1 -0
  40. package/dist/config-validator.js +25 -0
  41. package/dist/config-validator.js.map +1 -1
  42. package/dist/daemon.d.ts +91 -2
  43. package/dist/daemon.js +462 -22
  44. package/dist/daemon.js.map +1 -1
  45. package/dist/delivery-outbox.d.ts +8 -0
  46. package/dist/delivery-outbox.js.map +1 -1
  47. package/dist/event-loop-watch.d.ts +25 -0
  48. package/dist/event-loop-watch.js +40 -0
  49. package/dist/event-loop-watch.js.map +1 -0
  50. package/dist/fleet-manager.d.ts +38 -3
  51. package/dist/fleet-manager.js +248 -12
  52. package/dist/fleet-manager.js.map +1 -1
  53. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +4 -2
  54. package/dist/locale.js +6 -2
  55. package/dist/locale.js.map +1 -1
  56. package/dist/outbound-handlers.js +11 -2
  57. package/dist/outbound-handlers.js.map +1 -1
  58. package/dist/outbound-schemas.d.ts +15 -1
  59. package/dist/outbound-schemas.js +19 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/tool-permissions.js +8 -1
  62. package/dist/tool-permissions.js.map +1 -1
  63. package/dist/transcript-jsonl.d.ts +8 -0
  64. package/dist/transcript-jsonl.js +27 -0
  65. package/dist/transcript-jsonl.js.map +1 -0
  66. package/dist/transcript-monitor.js +13 -24
  67. package/dist/transcript-monitor.js.map +1 -1
  68. package/dist/transcript-sources.js +2 -17
  69. package/dist/transcript-sources.js.map +1 -1
  70. package/dist/turn-reply-guard.d.ts +32 -1
  71. package/dist/turn-reply-guard.js +47 -1
  72. package/dist/turn-reply-guard.js.map +1 -1
  73. package/dist/turn-resume.d.ts +80 -0
  74. package/dist/turn-resume.js +217 -0
  75. package/dist/turn-resume.js.map +1 -0
  76. package/dist/ui/view.html +17 -4
  77. package/dist/ui/web-terminal/terminal.css +1 -1
  78. package/dist/ui/web-terminal/terminal.js +5 -1
  79. package/dist/usage/format-rich.js +3 -2
  80. package/dist/usage/format-rich.js.map +1 -1
  81. package/dist/usage/i18n-keys.d.ts +1 -1
  82. package/dist/usage/i18n-keys.js +1 -1
  83. package/dist/usage/i18n-keys.js.map +1 -1
  84. package/dist/usage/i18n.d.ts +5 -0
  85. package/dist/usage/i18n.js +18 -1
  86. package/dist/usage/i18n.js.map +1 -1
  87. package/dist/usage/providers.d.ts +16 -0
  88. package/dist/usage/providers.js +57 -1
  89. package/dist/usage/providers.js.map +1 -1
  90. package/dist/usage/usage-api.js +6 -2
  91. package/dist/usage/usage-api.js.map +1 -1
  92. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -35,6 +35,8 @@ import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
35
35
  import { deliveryContentDigest } from "./delivery-outbox.js";
36
36
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
37
37
  import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
38
+ import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
39
+ import { codexLiveRowMatches } from "./backend/codex.js";
38
40
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
39
41
  const __filename = fileURLToPath(import.meta.url);
40
42
  const __dirname = dirname(__filename);
@@ -53,7 +55,16 @@ const TURN_OUTBOUND_TOOLS = new Set([
53
55
  // a deliberate reaction or edit is a valid response even without new text.
54
56
  const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
55
57
  const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
56
- 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.";
57
68
  /** Point a resumed CLI at its one backend-native instruction source. */
58
69
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
59
70
  const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
@@ -423,6 +434,10 @@ export class PendingWorkTracker {
423
434
  this.lastInboundAt = now;
424
435
  this.lastInboundOrder = ++this.sequence;
425
436
  }
437
+ /** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
438
+ lastInboundTimestamp() {
439
+ return this.lastInboundAt;
440
+ }
426
441
  recordIdle(now = Date.now()) {
427
442
  // An async pane poll can finish after a newer inbound. Do not let its stale
428
443
  // observation clear work which had not arrived when the pane was captured.
@@ -564,6 +579,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
564
579
  const INPUT_TRANSIENT_POLL_MS = 250;
565
580
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
566
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";
567
588
  /** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
568
589
  const INPUT_RESIDUE_MAX_ROUNDS = 60;
569
590
  const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
@@ -1067,6 +1088,12 @@ export class Daemon extends EventEmitter {
1067
1088
  */
1068
1089
  launchAttempt = 0;
1069
1090
  skipResume = false;
1091
+ /**
1092
+ * #1209: whether THIS boot was asked to skip resume by a crash-loop
1093
+ * (crash-state.json). Captured at boot before spawnClaudeWindow resets
1094
+ * skipResume — the #1209 gate reads this, never the resettable flag.
1095
+ */
1096
+ bootSkippedResume = false;
1070
1097
  startupAborted = false;
1071
1098
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
1072
1099
  dialogParkedSince = 0;
@@ -1150,6 +1177,11 @@ export class Daemon extends EventEmitter {
1150
1177
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1151
1178
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
1152
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;
1153
1185
  instanceStateBusyPattern = null;
1154
1186
  instanceStateMonitorActive = false;
1155
1187
  sessionCheckpointWarningEmitted = false;
@@ -1162,6 +1194,62 @@ export class Daemon extends EventEmitter {
1162
1194
  lastMalformedToolCallSignature;
1163
1195
  proxyReplySeq = 0;
1164
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
+ }
1165
1253
  autoPauseController;
1166
1254
  pauseRequested = false;
1167
1255
  /**
@@ -1542,6 +1630,11 @@ export class Daemon extends EventEmitter {
1542
1630
  const isGeneral = config.general_topic === true || name === "general";
1543
1631
  const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
1544
1632
  this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
1633
+ // #1209: every real completion clears the in-flight marker, so a
1634
+ // finished turn can never resume after a later restart. reset() is
1635
+ // deliberately NOT hooked: stop/spawn abandon the in-memory turn while
1636
+ // the on-disk interruption must survive for the resume gate.
1637
+ this.turnReplyGuard.onComplete = () => clearInFlightTurnMarker(this.instanceDir);
1545
1638
  }
1546
1639
  /** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
1547
1640
  setDeliveryOutboxPort(port) {
@@ -1811,6 +1904,7 @@ export class Daemon extends EventEmitter {
1811
1904
  // an unreadable marker for a later start; clear successfully read bytes
1812
1905
  // even when the JSON is corrupt so it cannot remain forever.
1813
1906
  const crashStatePath = join(this.instanceDir, "crash-state.json");
1907
+ this.bootSkippedResume = false;
1814
1908
  try {
1815
1909
  if (existsSync(crashStatePath)) {
1816
1910
  const content = readFileSync(crashStatePath, "utf-8");
@@ -1818,6 +1912,7 @@ export class Daemon extends EventEmitter {
1818
1912
  const state = JSON.parse(content);
1819
1913
  if (state?.resumeDisabled === true) {
1820
1914
  this.skipResume = true;
1915
+ this.bootSkippedResume = true;
1821
1916
  this.logger.warn("Previous crash loop detected — starting without resume");
1822
1917
  }
1823
1918
  }
@@ -1956,6 +2051,10 @@ export class Daemon extends EventEmitter {
1956
2051
  }
1957
2052
  catch { /* may not exist */ }
1958
2053
  }
2054
+ // #1209: conditional continuation for the turn interrupted by the last
2055
+ // restart, if the gates allow. Detached like the warmup notice; the gate
2056
+ // consumes its one-shot marker first and never throws into boot.
2057
+ void this.maybeResumeInterruptedTurn().catch(() => { });
1959
2058
  // Warmup: wait for CLI idle, then trigger steering reload — but only when
1960
2059
  // the instructions actually changed since the agent last saw them.
1961
2060
  // Skipping the no-op reload saves 10-30s of agent time on every restart
@@ -3161,12 +3260,9 @@ export class Daemon extends EventEmitter {
3161
3260
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
3162
3261
  evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
3163
3262
  // Count occurrences across the WHOLE pane (not just text after the last
3164
- // ready prompt). Clone with `g` so stateful backend regexes cannot leak
3165
- // lastIndex between monitor cycles.
3166
- const countMatches = (pattern) => {
3167
- const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
3168
- return (pane.match(new RegExp(pattern.source, flags)) || []).length;
3169
- };
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);
3170
3266
  // Same veto as the state machine: a backend whose ready marker is permanently
3171
3267
  // on screen would otherwise "recover" on the very first tick after the error,
3172
3268
  // rebaselining the occurrence count while the error is still displayed — one
@@ -3395,6 +3491,13 @@ export class Daemon extends EventEmitter {
3395
3491
  // FleetManager calls this synchronously before sendEscape. Cancel only the
3396
3492
  // current human turn; leave already-forwarded tool requests to settle.
3397
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();
3497
+ // #1209: a cancelled turn must never resume (#1199) — drop its marker.
3498
+ // (Completion paths clear via the guard's onComplete; a cancel with no
3499
+ // later idle edge would otherwise leave the marker behind.)
3500
+ clearInFlightTurnMarker(this.instanceDir);
3398
3501
  this.deliveryEpoch = fleetEpoch === undefined
3399
3502
  ? this.deliveryEpoch + 1
3400
3503
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -3721,11 +3824,38 @@ export class Daemon extends EventEmitter {
3721
3824
  return false;
3722
3825
  return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
3723
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
+ }
3724
3852
  async stop() {
3725
3853
  this.logger.info("Stopping daemon instance");
3726
3854
  this.clearQuitRelaunchWatch();
3727
3855
  this.fenceDeliveryWritesForStop();
3728
3856
  this.turnReplyGuard.reset();
3857
+ this.clearReplyGuardConfirm();
3858
+ this.sawBlockedInput = false;
3729
3859
  // Invalidate any bounded pre-Enter wait from the process generation being
3730
3860
  // stopped. It must fail closed, not press Enter in a replacement pane.
3731
3861
  this.inputTransientGuardGeneration = null;
@@ -3767,6 +3897,8 @@ export class Daemon extends EventEmitter {
3767
3897
  killed = true;
3768
3898
  break;
3769
3899
  }
3900
+ if (await this.cancelQuitConfirmation("stop"))
3901
+ break;
3770
3902
  }
3771
3903
  }
3772
3904
  if (!killed) {
@@ -4030,6 +4162,8 @@ export class Daemon extends EventEmitter {
4030
4162
  exited = true;
4031
4163
  break;
4032
4164
  }
4165
+ if (await this.cancelQuitConfirmation(pauseReason))
4166
+ break;
4033
4167
  }
4034
4168
  if (!exited) {
4035
4169
  await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
@@ -4190,7 +4324,24 @@ export class Daemon extends EventEmitter {
4190
4324
  // dedicated input_blocked event carries the reason to observers.
4191
4325
  if (this.inputBlockedDialogKey !== null) {
4192
4326
  this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
4193
- 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);
4194
4345
  }
4195
4346
  this.instanceState = snapshot.state;
4196
4347
  this.acceptedInstanceSnapshot = { ...snapshot };
@@ -4204,6 +4355,18 @@ export class Daemon extends EventEmitter {
4204
4355
  if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
4205
4356
  this.checkpointSessionIdInBackground();
4206
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
+ }
4207
4370
  // Only a transition back to idle completes pending work. Repeated idle
4208
4371
  // observations between enqueue and paste must not clear a newer inbound.
4209
4372
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -4218,6 +4381,12 @@ export class Daemon extends EventEmitter {
4218
4381
  if (acceptedIdle)
4219
4382
  this.maybeProxyReplyOnTurnEnd(pane);
4220
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
+ }
4221
4390
  if (snapshot.state !== previous) {
4222
4391
  this.logStateEdge(previous, snapshot);
4223
4392
  this.emit("instance_state", { name: this.name, ...snapshot });
@@ -4287,6 +4456,8 @@ export class Daemon extends EventEmitter {
4287
4456
  // The last non-empty line of what we pasted: everything on screen after it
4288
4457
  // is the agent's own output.
4289
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.
4290
4461
  this.turnReplyGuard.arm({
4291
4462
  adapterId: meta.adapter_id || undefined,
4292
4463
  chatId: meta.chat_id,
@@ -4294,7 +4465,126 @@ export class Daemon extends EventEmitter {
4294
4465
  messageId: meta.message_id || undefined,
4295
4466
  correlationId: meta.correlation_id || undefined,
4296
4467
  inboundMarker,
4468
+ }, this.pendingWork.lastInboundTimestamp());
4469
+ // #1209: persist the in-flight turn with a seam checkpoint. A marker
4470
+ // that survives a restart is an interrupted turn by construction —
4471
+ // completion (via the guard's onComplete) and cancel paths delete it.
4472
+ // Synchronous by necessity: a crash between arm and write must not lose
4473
+ // the interruption. Reads are windowed/bounded (see turn-resume.ts).
4474
+ // A resumed continuation never re-arms (P3a): episode-once means a crash
4475
+ // during the continuation boots clean instead of chaining resumes.
4476
+ if (!meta.resumedContinuationOf) {
4477
+ const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
4478
+ const resumeCwd = this.config.working_directory ?? "";
4479
+ writeInFlightTurnMarker(this.instanceDir, {
4480
+ version: 1,
4481
+ deliveryId: meta.delivery_id || undefined,
4482
+ correlationId: meta.correlation_id || undefined,
4483
+ messageId: meta.message_id || undefined,
4484
+ chatId: meta.chat_id,
4485
+ threadId: meta.thread_id || undefined,
4486
+ adapterId: meta.adapter_id || undefined,
4487
+ backend: resumeBackend ?? "unknown",
4488
+ cwd: resumeCwd,
4489
+ armedAt: Date.now(),
4490
+ before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), {
4491
+ instanceDir: this.instanceDir,
4492
+ workingDirectory: resumeCwd,
4493
+ }),
4494
+ });
4495
+ }
4496
+ }
4497
+ /** #1209: which seam reader serves this instance (live config preferred). */
4498
+ resolveResumeBackend() {
4499
+ return normalizeResumeBackend(this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend);
4500
+ }
4501
+ /** #1209: whether the marker's delivery is still owned by the durable path. */
4502
+ isMarkerDeliveryPending(marker) {
4503
+ const id = marker.deliveryId;
4504
+ if (!id)
4505
+ return false;
4506
+ try {
4507
+ const row = this.deliveryOutbox?.get?.(id);
4508
+ return !!row && RESUME_BLOCKING_DELIVERY_STATES.includes(row.state);
4509
+ }
4510
+ catch {
4511
+ return false;
4512
+ }
4513
+ }
4514
+ /**
4515
+ * #1209 ACP-resume-like gate: one bounded continuation for the turn that
4516
+ * was in flight when the process died. The one-shot marker is consumed
4517
+ * FIRST (episode-once: at most one continuation per interrupted turn, even
4518
+ * if the inject below throws or the process dies holding the grace wait),
4519
+ * then the pure gate in turn-resume.ts decides. A hold inside the flush
4520
+ * grace sleeps once and re-gates with a fresh read; a second hold denies
4521
+ * rather than waiting forever. Returns a short outcome for logs/tests.
4522
+ */
4523
+ async maybeResumeInterruptedTurn() {
4524
+ const marker = consumeInFlightTurnMarker(this.instanceDir);
4525
+ if (!marker)
4526
+ return "none";
4527
+ // P1 fence: capture before any await. A cancel (deliveryEpoch),
4528
+ // respawn (spawnGeneration) or stop/pause (launchFenceEpoch) during the
4529
+ // grace hold must stale this gate — the push below rechecks, and the
4530
+ // push path itself carries the then-current epoch, so a post-push
4531
+ // cancel still drops the queued paste. Fence follows the write (#1192).
4532
+ const gateEpoch = this.deliveryEpoch;
4533
+ const gateSpawn = this.spawnGeneration;
4534
+ const gateFence = this.launchFenceEpoch;
4535
+ const gateFresh = () => this.deliveryEpoch === gateEpoch
4536
+ && this.spawnGeneration === gateSpawn
4537
+ && this.launchFenceEpoch === gateFence;
4538
+ const backend = this.resolveResumeBackend() ?? normalizeResumeBackend(marker.backend);
4539
+ const roots = { instanceDir: this.instanceDir, workingDirectory: this.config.working_directory ?? "" };
4540
+ // This boot's CLI spawn is what the daemon itself caused: pre-restart
4541
+ // writes (including pre-crash progress) are excluded, and the flush
4542
+ // grace holds the decision until post-spawn writes are observable.
4543
+ const daemonCausedMtimeMs = this.lastSpawnAt > 0 ? this.lastSpawnAt : -1;
4544
+ const gate = (after, nowMs) => decideTurnResume({
4545
+ marker,
4546
+ before: marker.before,
4547
+ after,
4548
+ daemonCausedMtimeMs,
4549
+ nowMs,
4550
+ crashLoopBoot: this.bootSkippedResume,
4551
+ deliveryPending: this.isMarkerDeliveryPending(marker),
4297
4552
  });
4553
+ const readAfter = () => backend ? readResumeFingerprint(backend, roots) : null;
4554
+ let decision = gate(readAfter(), Date.now());
4555
+ if (decision.action === "wait") {
4556
+ const waitMs = decision.waitMs;
4557
+ await new Promise(resolve => setTimeout(resolve, waitMs));
4558
+ const recheck = gate(readAfter(), Date.now());
4559
+ decision = recheck.action === "wait"
4560
+ ? {
4561
+ action: "skip",
4562
+ reason: "unobservable",
4563
+ detail: "still inside the flush grace after the hold; default-deny",
4564
+ }
4565
+ : recheck;
4566
+ }
4567
+ // The hold above is the only await on this path: recheck the fence
4568
+ // after it (and before the write). Stale → skip quietly, never an
4569
+ // error notice — the cancel/stop owner already reported itself.
4570
+ if (!gateFresh()) {
4571
+ this.logger.info("Interrupted-turn continuation dropped — cancel/stop/pause/respawn during the grace hold");
4572
+ return "skipped-stale";
4573
+ }
4574
+ if (decision.action === "inject") {
4575
+ const continuation = buildResumeContinuation(marker);
4576
+ try {
4577
+ this.pushChannelMessage(continuation.text, continuation.meta);
4578
+ }
4579
+ catch (err) {
4580
+ this.logger.warn({ err }, "Interrupted-turn continuation failed to queue");
4581
+ return "inject-failed";
4582
+ }
4583
+ this.logger.info({ correlationId: marker.correlationId }, "Injected one bounded continuation for the interrupted turn");
4584
+ return "injected";
4585
+ }
4586
+ this.logger.info({ reason: decision.reason, detail: decision.detail }, "Interrupted turn NOT resumed");
4587
+ return `skipped-${decision.reason}`;
4298
4588
  }
4299
4589
  /**
4300
4590
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -4303,7 +4593,18 @@ export class Daemon extends EventEmitter {
4303
4593
  * manager does not pass through the dead MCP server. Consuming the turn state
4304
4594
  * here (edge-triggered, then reset) is what makes it at most once per turn.
4305
4595
  */
4306
- 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) {
4307
4608
  const turn = this.turnReplyGuard.snapshot();
4308
4609
  if (!turn || this.isPaused)
4309
4610
  return;
@@ -4315,18 +4616,21 @@ export class Daemon extends EventEmitter {
4315
4616
  generation: turn.generation,
4316
4617
  });
4317
4618
  }
4619
+ this.clearReplyGuardConfirm();
4318
4620
  this.turnReplyGuard.complete(turn.generation);
4319
4621
  return;
4320
4622
  }
4321
4623
  // An intentional stop is not a dropped reply. Successful in-flight replies
4322
4624
  // still settle above, but cancellation cannot start another model turn.
4323
4625
  if (turn.cancelledByUser) {
4626
+ this.clearReplyGuardConfirm();
4324
4627
  this.turnReplyGuard.complete(turn.generation);
4325
4628
  return;
4326
4629
  }
4327
4630
  // A second idle edge ends the one permitted recovery turn. Never create a
4328
4631
  // third turn or guess at terminal text; make the failure visible instead.
4329
4632
  if (turn.phase === "recovering") {
4633
+ this.clearReplyGuardConfirm();
4330
4634
  this.turnReplyGuard.complete(turn.generation);
4331
4635
  this.reportUnrecoveredReplyDrop(turn);
4332
4636
  return;
@@ -4387,10 +4691,50 @@ export class Daemon extends EventEmitter {
4387
4691
  if (turn.replyAttempted) {
4388
4692
  // A provider timeout can be "applied, then timed out". Retrying it would
4389
4693
  // risk a duplicate; report the unknown result and stop here.
4694
+ this.clearReplyGuardConfirm();
4390
4695
  this.turnReplyGuard.complete(turn.generation);
4391
4696
  this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4392
4697
  return;
4393
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();
4394
4738
  this.startReplyRecovery(turn, "no_valid_call");
4395
4739
  }
4396
4740
  isClaudeCodeBackend() {
@@ -4403,6 +4747,9 @@ export class Daemon extends EventEmitter {
4403
4747
  && this.backend?.replyCompletionGuard === true;
4404
4748
  }
4405
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();
4406
4753
  const current = this.turnReplyGuard.snapshot();
4407
4754
  if (current?.generation === turn.generation && current.cancelledByUser) {
4408
4755
  this.turnReplyGuard.complete(turn.generation);
@@ -6218,8 +6565,12 @@ export class Daemon extends EventEmitter {
6218
6565
  const baseline = this.nudgeBaselines?.get(episode.key);
6219
6566
  if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
6220
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;
6221
6572
  this.capacityNudge = {
6222
- pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
6573
+ pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
6223
6574
  epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
6224
6575
  };
6225
6576
  this.logger.info({ delayMs }, "Model-capacity nudge armed");
@@ -6293,7 +6644,8 @@ export class Daemon extends EventEmitter {
6293
6644
  // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
6294
6645
  const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
6295
6646
  current,
6296
- accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
6647
+ accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
6648
+ && codexLiveRowMatches(pane, nudge.pattern),
6297
6649
  });
6298
6650
  this.logger.info({ sent }, "Model-capacity nudge attempted");
6299
6651
  });
@@ -8004,6 +8356,12 @@ export class Daemon extends EventEmitter {
8004
8356
  // for this turn: the agent proved it can still speak for itself.
8005
8357
  if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
8006
8358
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
8359
+ // #1209 P2: the user got an answer — the turn is over for resume
8360
+ // purposes even before the idle edge completes the guard. Without
8361
+ // this, a restart in the reply-to-idle window would re-inject a
8362
+ // continuation for an already-answered turn.
8363
+ if (replyAttempt?.completionAction)
8364
+ clearInFlightTurnMarker(this.instanceDir);
8007
8365
  }
8008
8366
  const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
8009
8367
  if (!sent) {
@@ -8022,16 +8380,21 @@ export class Daemon extends EventEmitter {
8022
8380
  this.handleReleaseRepo(args, respond);
8023
8381
  return;
8024
8382
  }
8025
- if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis") {
8383
+ if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis"
8384
+ || tool === "list_stickers" || tool === "preview_stickers") {
8026
8385
  const type = tool === "set_display_name" ? "fleet_set_display_name"
8027
8386
  : tool === "set_description" ? "fleet_set_description"
8028
8387
  : tool === "list_emojis" ? "fleet_list_emojis"
8029
- : tool === "preview_emojis" ? "fleet_preview_emojis" : "fleet_set_persona_emoji";
8388
+ : tool === "preview_emojis" ? "fleet_preview_emojis"
8389
+ : tool === "list_stickers" ? "fleet_list_stickers"
8390
+ : tool === "preview_stickers" ? "fleet_preview_stickers" : "fleet_set_persona_emoji";
8030
8391
  const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
8031
8392
  const timeout = setTimeout(() => {
8032
8393
  this.pendingIpcRequests.delete(fleetReqId);
8033
8394
  respond(null, `${tool} timed out`);
8034
- }, 10_000);
8395
+ // The shared budget (ipc-timeouts.ts), so the MCP ceiling stays above it and this message is the one seen;
8396
+ // a preview downloads pictures and needs more than the old fixed 10s.
8397
+ }, daemonBudgetMs(tool));
8035
8398
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
8036
8399
  clearTimeout(timeout);
8037
8400
  respond(respMsg.result, respMsg.error);
@@ -8453,6 +8816,8 @@ export class Daemon extends EventEmitter {
8453
8816
  // warning is safer than treating the replacement's startup idle as the old
8454
8817
  // turn ending and injecting a stale recovery prompt.
8455
8818
  this.turnReplyGuard.reset();
8819
+ this.clearReplyGuardConfirm();
8820
+ this.sawBlockedInput = false;
8456
8821
  if (this.spawnDepth === 0) {
8457
8822
  this.spawnGeneration++;
8458
8823
  this.interactionObservation.reset(this.interactionOwner());
@@ -8536,13 +8901,80 @@ export class Daemon extends EventEmitter {
8536
8901
  paneSaysNoConversation(paneText) {
8537
8902
  if (!paneText)
8538
8903
  return false;
8539
- return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
8540
- .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);
8541
8910
  }
8542
- /** How many consecutive startups have failed without proving the session is gone. */
8543
- unprovenResumeFailures = 0;
8544
8911
  /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
8545
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
+ }
8546
8978
  /**
8547
8979
  * Set the session aside instead of deleting it.
8548
8980
  *
@@ -8558,6 +8990,7 @@ export class Daemon extends EventEmitter {
8558
8990
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
8559
8991
  renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
8560
8992
  this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
8993
+ this.clearUnprovenResumeFailures();
8561
8994
  this.pruneAbandonedSessions();
8562
8995
  }
8563
8996
  catch (err) {
@@ -8592,6 +9025,10 @@ export class Daemon extends EventEmitter {
8592
9025
  if (!this.backend) {
8593
9026
  throw new Error("No backend configured — cannot spawn CLI window");
8594
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();
8595
9032
  const attemptedResume = !this.skipResume;
8596
9033
  // A resume launch may get a longer budget than a fresh one (kiro: the
8597
9034
  // conversation must come back from the backend before anything paints).
@@ -8632,18 +9069,18 @@ export class Daemon extends EventEmitter {
8632
9069
  catch { /* pane may be gone */ }
8633
9070
  const proven = this.paneSaysNoConversation(paneText);
8634
9071
  if (!proven) {
8635
- this.unprovenResumeFailures++;
8636
- if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
9072
+ const failures = this.bumpUnprovenResumeFailures();
9073
+ if (failures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
8637
9074
  // Keep the session and fail this attempt; the fleet retries with
8638
9075
  // backoff, which is also how the backend-outage path behaves.
8639
9076
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
8640
9077
  await this.tmux.killWindow();
8641
- 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}) `
8642
9079
  + "— session kept, will retry");
8643
9080
  }
8644
9081
  // Escape hatch: a genuinely broken session must still recover. Say so
8645
9082
  // out loud — this start does NOT continue the previous conversation.
8646
- 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. "
8647
9084
  + "The previous conversation is NOT continued; its context is lost to this session.");
8648
9085
  this.emit("context_lost", this.name, "resume_repeatedly_failed");
8649
9086
  }
@@ -8664,11 +9101,14 @@ export class Daemon extends EventEmitter {
8664
9101
  }
8665
9102
  else if (attemptedResume) {
8666
9103
  resumedSuccessfully = true;
9104
+ this.clearUnprovenResumeFailures();
8667
9105
  // A resume needs the backend: its success is positive proof the backend
8668
9106
  // is reachable again (a fresh prompt is local and proves nothing).
8669
9107
  this.backendOutage?.clear(this.backendKey());
8670
9108
  }
8671
9109
  this.lastSpawnAt = Date.now();
9110
+ // #1217: whatever session this CLI writes from here on is this backend's.
9111
+ this.recordSessionOwner();
8672
9112
  this.skipResume = false; // CLI started successfully — reset for next spawn
8673
9113
  this.backgroundSessionRecoveryAttempted = false;
8674
9114
  }