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

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 (133) hide show
  1. package/README.md +54 -8
  2. package/README.zh-TW.md +104 -11
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +38 -29
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +20 -0
  7. package/dist/backend/claude-code.js +84 -25
  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/factory.js +5 -5
  13. package/dist/backend/factory.js.map +1 -1
  14. package/dist/backend/index.d.ts +0 -1
  15. package/dist/backend/index.js +0 -1
  16. package/dist/backend/index.js.map +1 -1
  17. package/dist/backend/kiro.d.ts +10 -1
  18. package/dist/backend/kiro.js +13 -0
  19. package/dist/backend/kiro.js.map +1 -1
  20. package/dist/backend/mock.d.ts +1 -1
  21. package/dist/backend/mock.js +1 -1
  22. package/dist/backend/muse.d.ts +20 -0
  23. package/dist/backend/muse.js +68 -11
  24. package/dist/backend/muse.js.map +1 -1
  25. package/dist/backend/removed.d.ts +12 -0
  26. package/dist/backend/removed.js +18 -0
  27. package/dist/backend/removed.js.map +1 -0
  28. package/dist/backend/session-signals.js +5 -10
  29. package/dist/backend/session-signals.js.map +1 -1
  30. package/dist/backend/types.d.ts +15 -1
  31. package/dist/backend/types.js +0 -1
  32. package/dist/backend/types.js.map +1 -1
  33. package/dist/channel/adapters/discord.js +9 -0
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.d.ts +14 -8
  36. package/dist/channel/adapters/telegram.js +80 -26
  37. package/dist/channel/adapters/telegram.js.map +1 -1
  38. package/dist/channel/mcp-tools.js +2 -2
  39. package/dist/channel/mcp-tools.js.map +1 -1
  40. package/dist/classic-channel-manager.js +2 -2
  41. package/dist/classic-channel-manager.js.map +1 -1
  42. package/dist/cli.js +27 -53
  43. package/dist/cli.js.map +1 -1
  44. package/dist/command-table.d.ts +1 -1
  45. package/dist/command-table.js +4 -2
  46. package/dist/command-table.js.map +1 -1
  47. package/dist/config-validator.js +48 -10
  48. package/dist/config-validator.js.map +1 -1
  49. package/dist/config.js +7 -5
  50. package/dist/config.js.map +1 -1
  51. package/dist/context-guardian.d.ts +1 -1
  52. package/dist/context-guardian.js +1 -1
  53. package/dist/cross-instance-notice.d.ts +39 -0
  54. package/dist/cross-instance-notice.js +84 -0
  55. package/dist/cross-instance-notice.js.map +1 -0
  56. package/dist/daemon.d.ts +73 -2
  57. package/dist/daemon.js +333 -31
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/daily-summary.js +0 -3
  60. package/dist/daily-summary.js.map +1 -1
  61. package/dist/fleet-manager.d.ts +16 -3
  62. package/dist/fleet-manager.js +110 -53
  63. package/dist/fleet-manager.js.map +1 -1
  64. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  65. package/dist/instance-config-impact.js +3 -0
  66. package/dist/instance-config-impact.js.map +1 -1
  67. package/dist/instance-lifecycle.d.ts +9 -1
  68. package/dist/instance-lifecycle.js +62 -7
  69. package/dist/instance-lifecycle.js.map +1 -1
  70. package/dist/locale.js +30 -6
  71. package/dist/locale.js.map +1 -1
  72. package/dist/memory-pressure.d.ts +8 -2
  73. package/dist/memory-pressure.js +10 -4
  74. package/dist/memory-pressure.js.map +1 -1
  75. package/dist/outbound-handlers.js +47 -25
  76. package/dist/outbound-handlers.js.map +1 -1
  77. package/dist/outbound-schemas.d.ts +0 -1
  78. package/dist/outbound-schemas.js +3 -3
  79. package/dist/outbound-schemas.js.map +1 -1
  80. package/dist/reply-dedup.d.ts +5 -0
  81. package/dist/reply-dedup.js +9 -0
  82. package/dist/reply-dedup.js.map +1 -1
  83. package/dist/settings-api.js +5 -1
  84. package/dist/settings-api.js.map +1 -1
  85. package/dist/setup-wizard.js +0 -4
  86. package/dist/setup-wizard.js.map +1 -1
  87. package/dist/spawn-gate.js +4 -3
  88. package/dist/spawn-gate.js.map +1 -1
  89. package/dist/target-queue-worker.d.ts +1 -1
  90. package/dist/topic-commands.d.ts +64 -5
  91. package/dist/topic-commands.js +172 -14
  92. package/dist/topic-commands.js.map +1 -1
  93. package/dist/transcript-jsonl.d.ts +20 -0
  94. package/dist/transcript-jsonl.js +64 -0
  95. package/dist/transcript-jsonl.js.map +1 -0
  96. package/dist/transcript-monitor.js +21 -29
  97. package/dist/transcript-monitor.js.map +1 -1
  98. package/dist/transcript-sources.d.ts +1 -1
  99. package/dist/transcript-sources.js +29 -26
  100. package/dist/transcript-sources.js.map +1 -1
  101. package/dist/turn-reply-guard.d.ts +25 -1
  102. package/dist/turn-reply-guard.js +36 -1
  103. package/dist/turn-reply-guard.js.map +1 -1
  104. package/dist/types.d.ts +13 -4
  105. package/dist/ui/dashboard.html +3 -3
  106. package/dist/ui/settings.html +58 -16
  107. package/dist/ui/view.html +17 -10
  108. package/dist/ui/web-terminal/terminal.css +1 -1
  109. package/dist/ui/web-terminal/terminal.js +5 -1
  110. package/dist/update-check.d.ts +15 -6
  111. package/dist/update-check.js +20 -7
  112. package/dist/update-check.js.map +1 -1
  113. package/dist/usage/format-rich.js +3 -7
  114. package/dist/usage/format-rich.js.map +1 -1
  115. package/dist/usage/i18n-keys.d.ts +1 -1
  116. package/dist/usage/i18n-keys.js +1 -1
  117. package/dist/usage/i18n-keys.js.map +1 -1
  118. package/dist/usage/i18n.d.ts +5 -2
  119. package/dist/usage/i18n.js +9 -5
  120. package/dist/usage/i18n.js.map +1 -1
  121. package/dist/usage/providers.d.ts +11 -5
  122. package/dist/usage/providers.js +55 -1
  123. package/dist/usage/providers.js.map +1 -1
  124. package/dist/usage/usage-api.d.ts +0 -9
  125. package/dist/usage/usage-api.js +7 -32
  126. package/dist/usage/usage-api.js.map +1 -1
  127. package/dist/wake-coordinator.js +1 -1
  128. package/dist/web-api.js +1 -2
  129. package/dist/web-api.js.map +1 -1
  130. package/package.json +1 -1
  131. package/dist/backend/gemini-cli.d.ts +0 -20
  132. package/dist/backend/gemini-cli.js +0 -169
  133. package/dist/backend/gemini-cli.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -16,6 +16,7 @@ import { resolveToolSet } from "./tool-permissions.js";
16
16
  import { getAgendHome } from "./paths.js";
17
17
  import { LOGIN_FLOWS } from "./login-flows.js";
18
18
  import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
19
+ import { isCrossInstanceVisibility } from "./cross-instance-notice.js";
19
20
  import { ContextGuardian } from "./context-guardian.js";
20
21
  import { IpcServer } from "./channel/ipc-bridge.js";
21
22
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
@@ -36,6 +37,7 @@ import { deliveryContentDigest } from "./delivery-outbox.js";
36
37
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
37
38
  import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
38
39
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
40
+ import { codexLiveRowMatches } from "./backend/codex.js";
39
41
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
40
42
  const __filename = fileURLToPath(import.meta.url);
41
43
  const __dirname = dirname(__filename);
@@ -54,7 +56,16 @@ const TURN_OUTBOUND_TOOLS = new Set([
54
56
  // a deliberate reaction or edit is a valid response even without new text.
55
57
  const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
56
58
  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.";
59
+ /**
60
+ * #1241: how long an idle edge must persist — with no reply and no further
61
+ * observed work — before the reply guard declares the turn a miss. A claude
62
+ * turn can repaint idle mid-turn (long thinking, subagent delegation) while
63
+ * the first reply is still minutes out; recovering on the first edge injects
64
+ * a prompt the agent later answers twice. A genuine miss stays idle, so the
65
+ * backstop is delayed by at most this window, never disabled.
66
+ */
67
+ const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
68
+ 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
69
  /** Point a resumed CLI at its one backend-native instruction source. */
59
70
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
60
71
  const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
@@ -63,9 +74,7 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
63
74
  ? `.kiro/steering/agend-${instanceName}.md`
64
75
  : binaryName === "agy"
65
76
  ? ".agents/agents.md"
66
- : binaryName === "gemini"
67
- ? "GEMINI.md"
68
- : join(instanceDir, "fleet-instructions.md");
77
+ : join(instanceDir, "fleet-instructions.md");
69
78
  return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
70
79
  }
71
80
  /**
@@ -424,6 +433,10 @@ export class PendingWorkTracker {
424
433
  this.lastInboundAt = now;
425
434
  this.lastInboundOrder = ++this.sequence;
426
435
  }
436
+ /** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
437
+ lastInboundTimestamp() {
438
+ return this.lastInboundAt;
439
+ }
427
440
  recordIdle(now = Date.now()) {
428
441
  // An async pane poll can finish after a newer inbound. Do not let its stale
429
442
  // observation clear work which had not arrived when the pane was captured.
@@ -565,6 +578,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
565
578
  const INPUT_TRANSIENT_POLL_MS = 250;
566
579
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
567
580
  const STRANDED_INPUT_MAX_ROUNDS = 3;
581
+ /** The Claude phrasing every backend was judged by before #1217; still the default for a backend that names none. */
582
+ const LEGACY_NO_CONVERSATION = /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i;
583
+ /** #1217: unproven resume failures, kept across Daemon instances, keyed by session id. */
584
+ const RESUME_FAILURES_FILE = "resume-failures.json";
585
+ /** #1217: the backend that owns the stored session id. */
586
+ const SESSION_OWNER_FILE = "session-id.backend";
568
587
  /** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
569
588
  const INPUT_RESIDUE_MAX_ROUNDS = 60;
570
589
  const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
@@ -1157,6 +1176,11 @@ export class Daemon extends EventEmitter {
1157
1176
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1158
1177
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
1159
1178
  instanceStateReadyPattern = null;
1179
+ /**
1180
+ * #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
1181
+ * When the block lifts, the silence since counts as a fresh interval.
1182
+ */
1183
+ sawBlockedInput = false;
1160
1184
  instanceStateBusyPattern = null;
1161
1185
  instanceStateMonitorActive = false;
1162
1186
  sessionCheckpointWarningEmitted = false;
@@ -1169,6 +1193,62 @@ export class Daemon extends EventEmitter {
1169
1193
  lastMalformedToolCallSignature;
1170
1194
  proxyReplySeq = 0;
1171
1195
  lastReplyDropWarningAt = 0;
1196
+ /**
1197
+ * #1241: an idle edge that has not yet proven the turn ended. Armed instead
1198
+ * of recovering immediately; a steady idle snapshot past confirmAt promotes
1199
+ * it to a real recovery, while a delivered reply, cancel, or newly observed
1200
+ * work dissolves it. Scoped to one guard generation. Split clock domains
1201
+ * (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
1202
+ * event ordering, never a deadline), while confirmAt is monotonic
1203
+ * (performance.now, same domain as the setTimeout firing the deadline), so
1204
+ * a wall jump can neither shorten nor stretch the window.
1205
+ */
1206
+ replyGuardIdleConfirm = null;
1207
+ /**
1208
+ * #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
1209
+ * to 60 s apart, so without this the real delay could approach two windows.
1210
+ * At the deadline a fresh, fenced capture re-observes the pane instead.
1211
+ */
1212
+ replyGuardConfirmTimer = null;
1213
+ /** #1241: dissolve a pending confirmation and its deadline, if any. */
1214
+ clearReplyGuardConfirm() {
1215
+ this.replyGuardIdleConfirm = null;
1216
+ if (this.replyGuardConfirmTimer) {
1217
+ clearTimeout(this.replyGuardConfirmTimer);
1218
+ this.replyGuardConfirmTimer = null;
1219
+ }
1220
+ }
1221
+ /** #1241: arm a confirmation window with its own deadline capture. */
1222
+ armReplyGuardConfirm(generation) {
1223
+ this.clearReplyGuardConfirm();
1224
+ this.replyGuardIdleConfirm = {
1225
+ generation,
1226
+ edgeAt: Date.now(),
1227
+ confirmAt: performance.now() + REPLY_GUARD_IDLE_CONFIRM_MS,
1228
+ };
1229
+ this.replyGuardConfirmTimer = setTimeout(() => {
1230
+ this.replyGuardConfirmTimer = null;
1231
+ this.fireReplyGuardConfirm();
1232
+ }, REPLY_GUARD_IDLE_CONFIRM_MS);
1233
+ }
1234
+ /**
1235
+ * #1241 P3: the window elapsed with no verdict from monitor ticks — drive
1236
+ * one fresh observation. Generation-fenced twice: a stale record returns
1237
+ * before capturing, and the resulting snapshot still flows through the
1238
+ * normal confirmed evaluation. When the capture cannot run (paused, frozen,
1239
+ * spawning), the record stays and the next monitor tick promotes instead.
1240
+ */
1241
+ fireReplyGuardConfirm() {
1242
+ const pending = this.replyGuardIdleConfirm;
1243
+ if (!pending)
1244
+ return;
1245
+ const turn = this.turnReplyGuard.snapshot();
1246
+ if (!turn || turn.generation !== pending.generation || turn.phase !== "awaiting") {
1247
+ this.clearReplyGuardConfirm();
1248
+ return;
1249
+ }
1250
+ void this.captureAndEvaluateInstanceState("reply_guard_confirm", this.instanceStateLastOutputAt);
1251
+ }
1172
1252
  autoPauseController;
1173
1253
  pauseRequested = false;
1174
1254
  /**
@@ -2031,7 +2111,7 @@ export class Daemon extends EventEmitter {
2031
2111
  }
2032
2112
  // 8. Context guardian
2033
2113
  const statusFile = join(this.instanceDir, "statusline.json");
2034
- this.guardian = new ContextGuardian(this.config.context_guardian, this.logger, statusFile);
2114
+ this.guardian = new ContextGuardian(this.config.context_guardian ?? {}, this.logger, statusFile);
2035
2115
  this.guardian.startWatching();
2036
2116
  this.guardian.on("status_update", () => {
2037
2117
  this.saveSessionId();
@@ -3179,12 +3259,9 @@ export class Daemon extends EventEmitter {
3179
3259
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
3180
3260
  evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
3181
3261
  // 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
- };
3262
+ // ready prompt). One shared counter (the static below): arm requires
3263
+ // count > baseline, so a drifted clone here would silently gate nudges.
3264
+ const countMatches = (pattern) => Daemon.countOccurrences(pattern, pane);
3188
3265
  // Same veto as the state machine: a backend whose ready marker is permanently
3189
3266
  // on screen would otherwise "recover" on the very first tick after the error,
3190
3267
  // rebaselining the occurrence count while the error is still displayed — one
@@ -3413,6 +3490,9 @@ export class Daemon extends EventEmitter {
3413
3490
  // FleetManager calls this synchronously before sendEscape. Cancel only the
3414
3491
  // current human turn; leave already-forwarded tool requests to settle.
3415
3492
  this.turnReplyGuard.cancelByUser();
3493
+ // #1241: a cancelled turn never confirms — its next edge completes it as
3494
+ // intentional. Drop a pending confirmation so it cannot promote afterwards.
3495
+ this.clearReplyGuardConfirm();
3416
3496
  // #1209: a cancelled turn must never resume (#1199) — drop its marker.
3417
3497
  // (Completion paths clear via the guard's onComplete; a cancel with no
3418
3498
  // later idle edge would otherwise leave the marker behind.)
@@ -3743,11 +3823,38 @@ export class Daemon extends EventEmitter {
3743
3823
  return false;
3744
3824
  return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
3745
3825
  }
3826
+ /**
3827
+ * #1217: the CLI answered its quit command with a confirmation that a stray
3828
+ * key would answer for us (Claude's "Background work is running": Enter
3829
+ * picks "Exit and stop tasks"). Never choose an option: cancel the prompt
3830
+ * with Escape and let the caller stop the process itself (SIGTERM), which is
3831
+ * what the bounded grace would have come to anyway — just without waiting
3832
+ * it out. True when that prompt was on screen.
3833
+ */
3834
+ async cancelQuitConfirmation(reason) {
3835
+ const backend = this.backend;
3836
+ if (!backend?.quitBlockedByDialog || !this.tmux)
3837
+ return false;
3838
+ let pane;
3839
+ try {
3840
+ pane = await this.tmux.capturePane();
3841
+ }
3842
+ catch {
3843
+ return false;
3844
+ }
3845
+ if (!backend.quitBlockedByDialog(pane))
3846
+ return false;
3847
+ 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");
3848
+ await this.tmux.sendSpecialKey("Escape").catch(() => false);
3849
+ return true;
3850
+ }
3746
3851
  async stop() {
3747
3852
  this.logger.info("Stopping daemon instance");
3748
3853
  this.clearQuitRelaunchWatch();
3749
3854
  this.fenceDeliveryWritesForStop();
3750
3855
  this.turnReplyGuard.reset();
3856
+ this.clearReplyGuardConfirm();
3857
+ this.sawBlockedInput = false;
3751
3858
  // Invalidate any bounded pre-Enter wait from the process generation being
3752
3859
  // stopped. It must fail closed, not press Enter in a replacement pane.
3753
3860
  this.inputTransientGuardGeneration = null;
@@ -3789,6 +3896,8 @@ export class Daemon extends EventEmitter {
3789
3896
  killed = true;
3790
3897
  break;
3791
3898
  }
3899
+ if (await this.cancelQuitConfirmation("stop"))
3900
+ break;
3792
3901
  }
3793
3902
  }
3794
3903
  if (!killed) {
@@ -3871,8 +3980,20 @@ export class Daemon extends EventEmitter {
3871
3980
  getInteractionSnapshot() {
3872
3981
  return this.interactionObservation.snapshot(this.interactionOwner());
3873
3982
  }
3983
+ /** Key of the last interaction snapshot actually emitted + broadcast (#1219). */
3984
+ lastPublishedInteractionKey = null;
3874
3985
  publishInteraction() {
3875
3986
  const interaction = this.getInteractionSnapshot();
3987
+ // The runtime monitor and every state capture observe the same unchanged
3988
+ // dialog for minutes; re-emitting an identical snapshot each time is
3989
+ // needless IPC traffic. Skip only exact duplicates — any change in the
3990
+ // presentation-relevant fields publishes, so the final state is never
3991
+ // lost (clocks like ageMs are excluded on purpose: they always change).
3992
+ const key = JSON.stringify([interaction.phase, interaction.kind, interaction.reason,
3993
+ interaction.episode, interaction.stale, interaction.suspected]);
3994
+ if (key === this.lastPublishedInteractionKey)
3995
+ return;
3996
+ this.lastPublishedInteractionKey = key;
3876
3997
  // Presentation telemetry must not interrupt the execution-state/hold path.
3877
3998
  try {
3878
3999
  this.emit("instance_interaction", { name: this.name, interaction });
@@ -4052,6 +4173,8 @@ export class Daemon extends EventEmitter {
4052
4173
  exited = true;
4053
4174
  break;
4054
4175
  }
4176
+ if (await this.cancelQuitConfirmation(pauseReason))
4177
+ break;
4055
4178
  }
4056
4179
  if (!exited) {
4057
4180
  await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
@@ -4212,7 +4335,24 @@ export class Daemon extends EventEmitter {
4212
4335
  // dedicated input_blocked event carries the reason to observers.
4213
4336
  if (this.inputBlockedDialogKey !== null) {
4214
4337
  this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
4215
- return;
4338
+ // #1241 P2-2: stdin-owned work is not idle evidence. A pending window
4339
+ // measured through a dialog would recover on pre-dialog idle time, so
4340
+ // the block dissolves it; the silence after the dialog clears starts a
4341
+ // fresh interval below.
4342
+ this.sawBlockedInput = true;
4343
+ this.clearReplyGuardConfirm();
4344
+ return;
4345
+ }
4346
+ // #1241 P2-2: the block just lifted. With the pane idle, evaluate like an
4347
+ // edge so a genuine miss re-accumulates a full window instead of inheriting
4348
+ // pre-dialog idle time (or never re-arming at all when the daemon froze at
4349
+ // idle). Only arming runs here: recovery-phase turns keep their lifecycle.
4350
+ const justUnblocked = this.sawBlockedInput;
4351
+ this.sawBlockedInput = false;
4352
+ if (justUnblocked && snapshot.state === "idle") {
4353
+ const unblockedTurn = this.turnReplyGuard.snapshot();
4354
+ if (unblockedTurn && unblockedTurn.phase === "awaiting")
4355
+ this.maybeProxyReplyOnTurnEnd(pane);
4216
4356
  }
4217
4357
  this.instanceState = snapshot.state;
4218
4358
  this.acceptedInstanceSnapshot = { ...snapshot };
@@ -4226,6 +4366,18 @@ export class Daemon extends EventEmitter {
4226
4366
  if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
4227
4367
  this.checkpointSessionIdInBackground();
4228
4368
  }
4369
+ // #1241: the turn demonstrably continued — work observed after an idle
4370
+ // edge dissolves a pending guard confirmation. A later genuine end
4371
+ // re-arms it with a fresh edge.
4372
+ if (snapshot.state !== "idle") {
4373
+ this.turnReplyGuard.noteTurnActivity();
4374
+ const pending = this.replyGuardIdleConfirm;
4375
+ const turn = this.turnReplyGuard.snapshot();
4376
+ if (pending && turn && turn.generation === pending.generation && turn.phase === "awaiting") {
4377
+ this.clearReplyGuardConfirm();
4378
+ this.logger.debug({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
4379
+ }
4380
+ }
4229
4381
  // Only a transition back to idle completes pending work. Repeated idle
4230
4382
  // observations between enqueue and paste must not clear a newer inbound.
4231
4383
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -4240,6 +4392,12 @@ export class Daemon extends EventEmitter {
4240
4392
  if (acceptedIdle)
4241
4393
  this.maybeProxyReplyOnTurnEnd(pane);
4242
4394
  }
4395
+ else if (snapshot.state === "idle") {
4396
+ // #1241: steady idle between edges. A pending confirmation whose window
4397
+ // has elapsed is promoted to a real evaluation here — proven idle,
4398
+ // not the first output gap.
4399
+ this.maybeConfirmReplyGuardIdle(pane);
4400
+ }
4243
4401
  if (snapshot.state !== previous) {
4244
4402
  this.logStateEdge(previous, snapshot);
4245
4403
  this.emit("instance_state", { name: this.name, ...snapshot });
@@ -4309,6 +4467,8 @@ export class Daemon extends EventEmitter {
4309
4467
  // The last non-empty line of what we pasted: everything on screen after it
4310
4468
  // is the agent's own output.
4311
4469
  const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
4470
+ // #1241 P2-1: carry pre-arm work evidence over the arm, bounded by this
4471
+ // delivery's ingress — output the paste produced before it confirmed.
4312
4472
  this.turnReplyGuard.arm({
4313
4473
  adapterId: meta.adapter_id || undefined,
4314
4474
  chatId: meta.chat_id,
@@ -4316,7 +4476,7 @@ export class Daemon extends EventEmitter {
4316
4476
  messageId: meta.message_id || undefined,
4317
4477
  correlationId: meta.correlation_id || undefined,
4318
4478
  inboundMarker,
4319
- });
4479
+ }, this.pendingWork.lastInboundTimestamp());
4320
4480
  // #1209: persist the in-flight turn with a seam checkpoint. A marker
4321
4481
  // that survives a restart is an interrupted turn by construction —
4322
4482
  // completion (via the guard's onComplete) and cancel paths delete it.
@@ -4444,7 +4604,18 @@ export class Daemon extends EventEmitter {
4444
4604
  * manager does not pass through the dead MCP server. Consuming the turn state
4445
4605
  * here (edge-triggered, then reset) is what makes it at most once per turn.
4446
4606
  */
4447
- maybeProxyReplyOnTurnEnd(pane) {
4607
+ /**
4608
+ * #1241: promote a pending idle-edge confirmation once the window has
4609
+ * elapsed. Steady idle with no reply and no work since the edge is proven
4610
+ * idle; anything else dissolves the confirmation and leaves the turn armed.
4611
+ */
4612
+ maybeConfirmReplyGuardIdle(pane) {
4613
+ const pending = this.replyGuardIdleConfirm;
4614
+ if (!pending || this.isPaused || performance.now() < pending.confirmAt)
4615
+ return;
4616
+ this.maybeProxyReplyOnTurnEnd(pane, true);
4617
+ }
4618
+ maybeProxyReplyOnTurnEnd(pane, confirmed = false) {
4448
4619
  const turn = this.turnReplyGuard.snapshot();
4449
4620
  if (!turn || this.isPaused)
4450
4621
  return;
@@ -4456,18 +4627,21 @@ export class Daemon extends EventEmitter {
4456
4627
  generation: turn.generation,
4457
4628
  });
4458
4629
  }
4630
+ this.clearReplyGuardConfirm();
4459
4631
  this.turnReplyGuard.complete(turn.generation);
4460
4632
  return;
4461
4633
  }
4462
4634
  // An intentional stop is not a dropped reply. Successful in-flight replies
4463
4635
  // still settle above, but cancellation cannot start another model turn.
4464
4636
  if (turn.cancelledByUser) {
4637
+ this.clearReplyGuardConfirm();
4465
4638
  this.turnReplyGuard.complete(turn.generation);
4466
4639
  return;
4467
4640
  }
4468
4641
  // A second idle edge ends the one permitted recovery turn. Never create a
4469
4642
  // third turn or guess at terminal text; make the failure visible instead.
4470
4643
  if (turn.phase === "recovering") {
4644
+ this.clearReplyGuardConfirm();
4471
4645
  this.turnReplyGuard.complete(turn.generation);
4472
4646
  this.reportUnrecoveredReplyDrop(turn);
4473
4647
  return;
@@ -4528,10 +4702,50 @@ export class Daemon extends EventEmitter {
4528
4702
  if (turn.replyAttempted) {
4529
4703
  // A provider timeout can be "applied, then timed out". Retrying it would
4530
4704
  // risk a duplicate; report the unknown result and stop here.
4705
+ this.clearReplyGuardConfirm();
4531
4706
  this.turnReplyGuard.complete(turn.generation);
4532
4707
  this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4533
4708
  return;
4534
4709
  }
4710
+ // #1241: an idle edge is not proof the turn ended. Two gates before the
4711
+ // recovery prompt goes anywhere near the CLI's input:
4712
+ // 1. work must have been observed after this generation armed — otherwise
4713
+ // the "edge" is a pane flicker (or a stale working state) mid-turn,
4714
+ // and the turn stays armed for its real end;
4715
+ // 2. the edge must persist: the first qualifying edge only arms a
4716
+ // confirmation window, and recovery starts only when steady idle past
4717
+ // the window still shows no reply and no further work.
4718
+ if (!turn.busyObserved) {
4719
+ this.logger.info({
4720
+ correlationId: turn.target.correlationId,
4721
+ generation: turn.generation,
4722
+ reason: "no_busy_since_arm",
4723
+ }, "Reply guard holding — idle edge with no work observed since the turn armed, not a turn end");
4724
+ return;
4725
+ }
4726
+ if (!confirmed) {
4727
+ if (this.replyGuardIdleConfirm?.generation === turn.generation)
4728
+ return;
4729
+ this.armReplyGuardConfirm(turn.generation);
4730
+ this.logger.info({
4731
+ correlationId: turn.target.correlationId,
4732
+ generation: turn.generation,
4733
+ reason: "idle_edge_pending_confirm",
4734
+ confirmMs: REPLY_GUARD_IDLE_CONFIRM_MS,
4735
+ }, "Reply guard idle edge pending confirmation — holding recovery until idle persists");
4736
+ return;
4737
+ }
4738
+ if (this.replyGuardIdleConfirm && turn.lastBusyAt > this.replyGuardIdleConfirm.edgeAt) {
4739
+ this.clearReplyGuardConfirm();
4740
+ this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
4741
+ return;
4742
+ }
4743
+ // A stale confirmation for another generation proves nothing about this one.
4744
+ if (this.replyGuardIdleConfirm && this.replyGuardIdleConfirm.generation !== turn.generation) {
4745
+ this.clearReplyGuardConfirm();
4746
+ return;
4747
+ }
4748
+ this.clearReplyGuardConfirm();
4535
4749
  this.startReplyRecovery(turn, "no_valid_call");
4536
4750
  }
4537
4751
  isClaudeCodeBackend() {
@@ -4544,6 +4758,9 @@ export class Daemon extends EventEmitter {
4544
4758
  && this.backend?.replyCompletionGuard === true;
4545
4759
  }
4546
4760
  startReplyRecovery(turn, reason) {
4761
+ // The confirmation served its purpose: recovery has its own lifecycle now
4762
+ // (a second edge ends it), so a pending window must not linger past this.
4763
+ this.clearReplyGuardConfirm();
4547
4764
  const current = this.turnReplyGuard.snapshot();
4548
4765
  if (current?.generation === turn.generation && current.cancelledByUser) {
4549
4766
  this.turnReplyGuard.complete(turn.generation);
@@ -4873,15 +5090,14 @@ export class Daemon extends EventEmitter {
4873
5090
  ? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
4874
5091
  if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
4875
5092
  return;
4876
- // An old capture must not retire a new launch's transient guard either.
4877
- if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4878
- this.resetFooterFallback();
4879
- return;
4880
- }
4881
5093
  // Delivery's unknown-footer proof also awaits the TTY mode. Validate
4882
5094
  // output and launch freshness AFTER both awaits, before accepting it.
4883
5095
  const deliveryCandidate = reason === "delivery_idle_gate"
4884
5096
  ? await this.probeDeliveryIdleFallback(pane) : null;
5097
+ // An old capture must not retire a new launch's transient guard either.
5098
+ // This check is only live here, after the probe's await: the identical
5099
+ // check before the probe was dead (no await in between, so the early
5100
+ // return above had already settled it) and is gone (#1219).
4885
5101
  if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4886
5102
  this.resetFooterFallback();
4887
5103
  return;
@@ -5377,6 +5593,10 @@ export class Daemon extends EventEmitter {
5377
5593
  this.config.tags = [...update.tags];
5378
5594
  else if (update.tags === null)
5379
5595
  delete this.config.tags;
5596
+ if (isCrossInstanceVisibility(update.cross_instance_visibility))
5597
+ this.config.cross_instance_visibility = update.cross_instance_visibility;
5598
+ else if (update.cross_instance_visibility === null)
5599
+ delete this.config.cross_instance_visibility;
5380
5600
  if (["trace", "debug", "info", "warn", "error"].includes(String(update.log_level))) {
5381
5601
  const level = update.log_level;
5382
5602
  if (this.config.log_level !== level) {
@@ -6359,8 +6579,12 @@ export class Daemon extends EventEmitter {
6359
6579
  const baseline = this.nudgeBaselines?.get(episode.key);
6360
6580
  if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
6361
6581
  return false;
6582
+ // And only when the row is the live item, not a quotation or scrollback above a newer turn. (Never a regex
6583
+ // lookahead on the counting pattern: whole-pane dedup would max at 1 and miss later hits. See #1215.)
6584
+ if (!codexLiveRowMatches(episode.pane, pattern))
6585
+ return false;
6362
6586
  this.capacityNudge = {
6363
- pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
6587
+ pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
6364
6588
  epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
6365
6589
  };
6366
6590
  this.logger.info({ delayMs }, "Model-capacity nudge armed");
@@ -6434,7 +6658,8 @@ export class Daemon extends EventEmitter {
6434
6658
  // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
6435
6659
  const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
6436
6660
  current,
6437
- accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
6661
+ accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
6662
+ && codexLiveRowMatches(pane, nudge.pattern),
6438
6663
  });
6439
6664
  this.logger.info({ sent }, "Model-capacity nudge attempted");
6440
6665
  });
@@ -8605,6 +8830,8 @@ export class Daemon extends EventEmitter {
8605
8830
  // warning is safer than treating the replacement's startup idle as the old
8606
8831
  // turn ending and injecting a stale recovery prompt.
8607
8832
  this.turnReplyGuard.reset();
8833
+ this.clearReplyGuardConfirm();
8834
+ this.sawBlockedInput = false;
8608
8835
  if (this.spawnDepth === 0) {
8609
8836
  this.spawnGeneration++;
8610
8837
  this.interactionObservation.reset(this.interactionOwner());
@@ -8688,13 +8915,80 @@ export class Daemon extends EventEmitter {
8688
8915
  paneSaysNoConversation(paneText) {
8689
8916
  if (!paneText)
8690
8917
  return false;
8691
- return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
8692
- .test(paneText);
8918
+ // Each CLI says it in its own words (#1217): muse's "retained session not
8919
+ // found" never matched the Claude phrasing, so a muse session that was not
8920
+ // there could never be proven gone. Backends that name no pattern keep the
8921
+ // phrasing this always used.
8922
+ const pattern = this.backend?.resumeMissingPattern?.() ?? LEGACY_NO_CONVERSATION;
8923
+ return pattern.test(paneText);
8693
8924
  }
8694
- /** How many consecutive startups have failed without proving the session is gone. */
8695
- unprovenResumeFailures = 0;
8696
8925
  /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
8697
8926
  static MAX_UNPROVEN_RESUME_FAILURES = 3;
8927
+ /**
8928
+ * #1217: the unproven-failure count lives in the instance directory, keyed by
8929
+ * the session id. Each start builds a new Daemon, so a count kept on the
8930
+ * object was always "attempt 1/3": the escape hatch could never fire, and an
8931
+ * unresumable session failed every start forever. A different session id (or
8932
+ * none) starts the count again; a successful resume or a set-aside ends it.
8933
+ */
8934
+ bumpUnprovenResumeFailures() {
8935
+ const path = join(this.instanceDir, RESUME_FAILURES_FILE);
8936
+ let sessionId = "";
8937
+ try {
8938
+ sessionId = readFileSync(join(this.instanceDir, "session-id"), "utf-8").trim();
8939
+ }
8940
+ catch { /* none */ }
8941
+ let previous = null;
8942
+ try {
8943
+ previous = JSON.parse(readFileSync(path, "utf-8"));
8944
+ }
8945
+ catch { /* first failure, or unreadable */ }
8946
+ const count = previous && previous.sessionId === sessionId && Number.isInteger(previous.count) && previous.count > 0
8947
+ ? previous.count + 1
8948
+ : 1;
8949
+ try {
8950
+ writeFileSync(path, JSON.stringify({ sessionId, count }));
8951
+ }
8952
+ catch { /* best effort: counts as before */ }
8953
+ return count;
8954
+ }
8955
+ clearUnprovenResumeFailures() {
8956
+ try {
8957
+ unlinkSync(join(this.instanceDir, RESUME_FAILURES_FILE));
8958
+ }
8959
+ catch { /* none */ }
8960
+ }
8961
+ /**
8962
+ * #1217: a session id belongs to the backend that made it. After a backend
8963
+ * switch the old id names nothing the new CLI knows (a Claude id handed to
8964
+ * muse is "retained session not found"), so resuming it can only fail. The
8965
+ * owner is recorded on every successful start; an id recorded for another
8966
+ * backend is set aside (kept on disk, as always) and this start is fresh.
8967
+ * An id with no recorded owner (from before this was recorded) is left to
8968
+ * the resume itself — its failure is proven or counted like any other.
8969
+ */
8970
+ setAsideForeignSession() {
8971
+ if (!existsSync(join(this.instanceDir, "session-id")))
8972
+ return;
8973
+ let owner = "";
8974
+ try {
8975
+ owner = readFileSync(join(this.instanceDir, SESSION_OWNER_FILE), "utf-8").trim();
8976
+ }
8977
+ catch {
8978
+ return;
8979
+ }
8980
+ const current = this.config.backend ?? "claude-code";
8981
+ if (!owner || owner === current)
8982
+ return;
8983
+ this.logger.warn({ from: owner, to: current }, "The stored session belongs to another backend — not resuming it");
8984
+ this.setSessionAside(`backend_switched:${owner}->${current}`);
8985
+ }
8986
+ recordSessionOwner() {
8987
+ try {
8988
+ writeFileSync(join(this.instanceDir, SESSION_OWNER_FILE), this.config.backend ?? "claude-code");
8989
+ }
8990
+ catch { /* best effort */ }
8991
+ }
8698
8992
  /**
8699
8993
  * Set the session aside instead of deleting it.
8700
8994
  *
@@ -8710,6 +9004,7 @@ export class Daemon extends EventEmitter {
8710
9004
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
8711
9005
  renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
8712
9006
  this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
9007
+ this.clearUnprovenResumeFailures();
8713
9008
  this.pruneAbandonedSessions();
8714
9009
  }
8715
9010
  catch (err) {
@@ -8744,6 +9039,10 @@ export class Daemon extends EventEmitter {
8744
9039
  if (!this.backend) {
8745
9040
  throw new Error("No backend configured — cannot spawn CLI window");
8746
9041
  }
9042
+ // Fresh start or not: the owner is recorded below once this CLI is up, so an
9043
+ // id it does not own must be out of the way first, or it would be recorded
9044
+ // as this backend's and resumed by the next start.
9045
+ this.setAsideForeignSession();
8747
9046
  const attemptedResume = !this.skipResume;
8748
9047
  // A resume launch may get a longer budget than a fresh one (kiro: the
8749
9048
  // conversation must come back from the backend before anything paints).
@@ -8784,18 +9083,18 @@ export class Daemon extends EventEmitter {
8784
9083
  catch { /* pane may be gone */ }
8785
9084
  const proven = this.paneSaysNoConversation(paneText);
8786
9085
  if (!proven) {
8787
- this.unprovenResumeFailures++;
8788
- if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
9086
+ const failures = this.bumpUnprovenResumeFailures();
9087
+ if (failures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
8789
9088
  // Keep the session and fail this attempt; the fleet retries with
8790
9089
  // backoff, which is also how the backend-outage path behaves.
8791
9090
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
8792
9091
  await this.tmux.killWindow();
8793
- throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
9092
+ throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
8794
9093
  + "— session kept, will retry");
8795
9094
  }
8796
9095
  // Escape hatch: a genuinely broken session must still recover. Say so
8797
9096
  // 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. "
9097
+ this.logger.error({ attempts: failures }, "Giving up on resuming after repeated startup failures — starting fresh. "
8799
9098
  + "The previous conversation is NOT continued; its context is lost to this session.");
8800
9099
  this.emit("context_lost", this.name, "resume_repeatedly_failed");
8801
9100
  }
@@ -8816,11 +9115,14 @@ export class Daemon extends EventEmitter {
8816
9115
  }
8817
9116
  else if (attemptedResume) {
8818
9117
  resumedSuccessfully = true;
9118
+ this.clearUnprovenResumeFailures();
8819
9119
  // A resume needs the backend: its success is positive proof the backend
8820
9120
  // is reachable again (a fresh prompt is local and proves nothing).
8821
9121
  this.backendOutage?.clear(this.backendKey());
8822
9122
  }
8823
9123
  this.lastSpawnAt = Date.now();
9124
+ // #1217: whatever session this CLI writes from here on is this backend's.
9125
+ this.recordSessionOwner();
8824
9126
  this.skipResume = false; // CLI started successfully — reset for next spawn
8825
9127
  this.backgroundSessionRecoveryAttempted = false;
8826
9128
  }
@@ -9206,8 +9508,8 @@ export class Daemon extends EventEmitter {
9206
9508
  else {
9207
9509
  this.warmupNeeded = !!backendConfig.instructions && prev !== backendConfig.instructions;
9208
9510
  }
9209
- // For backends that don't re-read instructions on resume (kiro/codex/
9210
- // gemini), also notify the agent on next message instead of forcing a new
9511
+ // For backends that don't re-read instructions on resume (kiro/codex),
9512
+ // also notify the agent on next message instead of forcing a new
9211
9513
  // session. Resume is preserved so context isn't lost.
9212
9514
  if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
9213
9515
  if (prev) {