@songsid/agend 2.3.2 → 2.3.3-beta.1

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 (98) hide show
  1. package/dist/agent-cli-instructions.md +2 -2
  2. package/dist/agent-whats-new.d.ts +21 -0
  3. package/dist/agent-whats-new.js +52 -23
  4. package/dist/agent-whats-new.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +13 -1
  6. package/dist/backend/claude-code.js +20 -1
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/kiro-reconcile-store.js +8 -1
  9. package/dist/backend/kiro-reconcile-store.js.map +1 -1
  10. package/dist/backend/kiro.d.ts +0 -5
  11. package/dist/backend/kiro.js +0 -10
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/backend/types.d.ts +7 -4
  14. package/dist/backend/types.js.map +1 -1
  15. package/dist/cli.js +2 -2
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config-validator.js +3 -1
  18. package/dist/config-validator.js.map +1 -1
  19. package/dist/cross-instance-envelope.d.ts +10 -5
  20. package/dist/cross-instance-envelope.js +20 -17
  21. package/dist/cross-instance-envelope.js.map +1 -1
  22. package/dist/daemon.d.ts +59 -63
  23. package/dist/daemon.js +275 -313
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/delivery-outbox.d.ts +27 -22
  26. package/dist/delivery-outbox.js +109 -161
  27. package/dist/delivery-outbox.js.map +1 -1
  28. package/dist/delivery-outbox.test.js +10 -25
  29. package/dist/delivery-outbox.test.js.map +1 -1
  30. package/dist/fleet-manager.d.ts +4 -12
  31. package/dist/fleet-manager.js +28 -92
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +1 -1
  34. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -1
  36. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +1 -1
  37. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +1 -1
  38. package/dist/instance-lifecycle.js +21 -5
  39. package/dist/instance-lifecycle.js.map +1 -1
  40. package/dist/instructions.js +6 -7
  41. package/dist/instructions.js.map +1 -1
  42. package/dist/kiro-reconcile.js +3 -3
  43. package/dist/kiro-reconcile.js.map +1 -1
  44. package/dist/locale.js +4 -16
  45. package/dist/locale.js.map +1 -1
  46. package/dist/log-service.js +1 -1
  47. package/dist/log-service.js.map +1 -1
  48. package/dist/outbound-handlers.js +1 -1
  49. package/dist/outbound-handlers.js.map +1 -1
  50. package/dist/outbound-schemas.js +1 -1
  51. package/dist/outbound-schemas.js.map +1 -1
  52. package/dist/scheduler/task-board.d.ts +17 -12
  53. package/dist/scheduler/task-board.js +29 -25
  54. package/dist/scheduler/task-board.js.map +1 -1
  55. package/dist/sd-notify.d.ts +13 -14
  56. package/dist/sd-notify.js +62 -28
  57. package/dist/sd-notify.js.map +1 -1
  58. package/dist/settings-api.d.ts +0 -1
  59. package/dist/settings-api.js +1 -14
  60. package/dist/settings-api.js.map +1 -1
  61. package/dist/settings-effect.js +0 -2
  62. package/dist/settings-effect.js.map +1 -1
  63. package/dist/task-board-waker.d.ts +40 -1
  64. package/dist/task-board-waker.js +110 -9
  65. package/dist/task-board-waker.js.map +1 -1
  66. package/dist/tmux-control.d.ts +1 -1
  67. package/dist/tmux-control.js +3 -2
  68. package/dist/tmux-control.js.map +1 -1
  69. package/dist/tmux-manager.js +18 -16
  70. package/dist/tmux-manager.js.map +1 -1
  71. package/dist/tmux-read.d.ts +2 -0
  72. package/dist/tmux-read.js +7 -1
  73. package/dist/tmux-read.js.map +1 -1
  74. package/dist/topic-commands.d.ts +2 -0
  75. package/dist/topic-commands.js +2 -1
  76. package/dist/topic-commands.js.map +1 -1
  77. package/dist/types.d.ts +1 -5
  78. package/dist/ui/panel-log.js +2 -2
  79. package/dist/ui/settings-appearance.js +3 -35
  80. package/dist/ui/settings-registry.js +0 -1
  81. package/dist/ui/settings-strings.js +2 -2
  82. package/dist/update-check.d.ts +13 -4
  83. package/dist/update-check.js +17 -4
  84. package/dist/update-check.js.map +1 -1
  85. package/dist/web-experimental.d.ts +6 -2
  86. package/dist/web-experimental.js +8 -4
  87. package/dist/web-experimental.js.map +1 -1
  88. package/dist/web-settings-patch.d.ts +0 -1
  89. package/dist/web-settings-patch.js +3 -4
  90. package/dist/web-settings-patch.js.map +1 -1
  91. package/dist/workflow-templates/default.md +2 -2
  92. package/dist/working-recovery.d.ts +0 -2
  93. package/dist/working-recovery.js +2 -23
  94. package/dist/working-recovery.js.map +1 -1
  95. package/package.json +1 -1
  96. package/dist/upgrade-notices.d.ts +0 -17
  97. package/dist/upgrade-notices.js +0 -93
  98. package/dist/upgrade-notices.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readActiveKiroModel } from "./backend/kiro.js";
2
2
  import { dashboardAccessOf } from "./dashboard-permissions.js";
3
3
  import { sendPackSources } from "./web-office-pack-read.js";
4
- import { agentWhatsNew, agendVersion, planWhatsNew, readWhatsNewRecord, recordWhatsNew, whatsNewNotice } from "./agent-whats-new.js";
4
+ import { agendVersion, planWhatsNew, readWhatsNewRecord, recordWhatsNew, withWhatsNew } from "./agent-whats-new.js";
5
5
  import { join, dirname, resolve } from "node:path";
6
6
  import { checkoutTarget, readCheckouts, releaseCandidate, reposRoot, verifyWorktree, writeCheckouts } from "./repo-worktree-guard.js";
7
7
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
@@ -24,8 +24,9 @@ import { TranscriptMonitor } from "./transcript-monitor.js";
24
24
  import { createTranscriptSource } from "./transcript-sources.js";
25
25
  import { TranscriptTurnLedger } from "./transcript-turns.js";
26
26
  import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
27
- import { resolveToolSet } from "./tool-permissions.js";
28
- import { dashboardAccessProblem, dashboardFeaturesOff } from "./dashboard-permissions.js";
27
+ import { resolveToolSet, toolsFor } from "./tool-permissions.js";
28
+ import { TOOLS, toolsWithCanvasWorker } from "./channel/mcp-tools.js";
29
+ import { dashboardAccessProblem, dashboardFeaturesOff, withoutFeatures } from "./dashboard-permissions.js";
29
30
  import { getAgendHome } from "./paths.js";
30
31
  import { readCanvasWorkerEnabled, scopedFleetConfigEnvironment } from "./web-experimental.js";
31
32
  import { readAgentEffect, sendEffectInParts } from "./web-effect-upload.js";
@@ -67,8 +68,6 @@ import { claudeConfigDirFor, claudeProfileConfigDir, claudeSwitchDialog, claudeS
67
68
  import { MuseBackend } from "./backend/muse.js";
68
69
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
69
70
  import { unrecognisedPromptShape } from "./unrecognised-prompt.js";
70
- /** #2140: deliveries that may carry an unwritten what's-new notice before it is recorded anyway. */
71
- const WHATS_NEW_MAX_UNWRITTEN = 3;
72
71
  const __filename = fileURLToPath(import.meta.url);
73
72
  const __dirname = dirname(__filename);
74
73
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -113,7 +112,6 @@ const TURN_OUTBOUND_TOOLS = new Set([
113
112
  // These actions reach the human-facing channel and satisfy reply-completion:
114
113
  // a deliberate reaction or edit is a valid response even without new text.
115
114
  const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
116
- const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
117
115
  /**
118
116
  * #1241: how long an idle edge must persist — with no reply and no further
119
117
  * observed work — before the reply guard declares the turn a miss. A claude
@@ -136,50 +134,12 @@ const TRANSCRIPT_RUNNING_HOLD_MAX_MS = 30 * 60_000;
136
134
  */
137
135
  export const TRANSCRIPT_OPEN_TURN_QUIET_MS = 15 * 60_000;
138
136
  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.";
139
- /** Point a resumed CLI at its one backend-native instruction source. */
140
137
  /** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed; #1647 a raw paste's text). */
141
138
  function transcriptMarkerEvidence(kind, rawPaste = false) {
142
139
  if (rawPaste)
143
140
  return "transcript-turn-user-entry-raw-paste-text";
144
141
  return kind === "user" ? "transcript-marker" : kind === "absorbed" ? "transcript-marker-absorbed" : "transcript-marker-queued";
145
142
  }
146
- export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir, backendSource) {
147
- const source = backendSource ? backendSource : binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
148
- ? "AGENTS.md"
149
- : binaryName === "kiro-cli"
150
- ? `.kiro/steering/agend-${instanceName}.md`
151
- : binaryName === "agy"
152
- ? ".agents/AGENTS.md"
153
- : join(instanceDir, "fleet-instructions.md");
154
- return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
155
- }
156
- /**
157
- * Whether this backend has to be told in its pane that its instructions moved.
158
- *
159
- * A backend that re-reads them on resume already received the new ones from the
160
- * resume itself, so the notice is pure interruption. Both places that can send
161
- * it — the warmup paste and the deferred pendingInstructionsNotice — ask through
162
- * here, so the two cannot drift apart again: the warmup path used to omit this
163
- * check entirely and told claude-code to reload instructions it had just read.
164
- */
165
- export function backendNeedsPaneReloadNotice(backend) {
166
- return !backend?.instructionsReloadedOnResume;
167
- }
168
- /**
169
- * What the warmup should do about changed instructions.
170
- *
171
- * `skip` — nothing to say, or this backend learns of the change by itself.
172
- * `defer` — worth saying, but nobody is talking to the instance; say it when a
173
- * real message next arrives rather than provoking an unsolicited reply.
174
- * `paste` — say it now, into a pane that already has a delivery in flight.
175
- */
176
- export function warmupNoticeAction(opts) {
177
- if (!opts.warmupNeeded)
178
- return "skip";
179
- if (!backendNeedsPaneReloadNotice(opts.backend))
180
- return "skip";
181
- return opts.pasteQueueDepth === 0 ? "defer" : "paste";
182
- }
183
143
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
184
144
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
185
145
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
@@ -189,6 +149,14 @@ export const PERIODIC_REDRAW_PROBE_MS = 25;
189
149
  const KIRO_COMPACT_PANE_POLL_MS = 1_000;
190
150
  /** Do not turn a streaming Codex turn into one capture-pane per burst. */
191
151
  export const STRUCTURED_IDLE_PROBE_MS = 500;
152
+ /** #2770 (ii): how often output may schedule a look for a layout that repaints while idle (CliBackend.periodicRedrawFrom). */
153
+ export const PERIODIC_LAYOUT_LOOK_MS = 5_000;
154
+ /** #2770: how often a delivery waiting for idle asks again whether it can be handed off instead. */
155
+ export const HANDOFF_RECHECK_MS = 1_000;
156
+ /** #2770: how often a wait for idle asks its stop condition (a cancel, a hand-off that became possible). */
157
+ export const WAIT_STOP_POLL_MS = 250;
158
+ /** #2770: human messages a cancel dropped are told in one notice, this long after the last of them. */
159
+ export const CANCELLED_HUMANS_NOTICE_MS = 1_000;
192
160
  /**
193
161
  * An instance publishing more than this many execution-state edges inside the
194
162
  * window is flapping; its edges stop being logged one by one (#1116). One
@@ -1398,6 +1366,10 @@ export class Daemon extends EventEmitter {
1398
1366
  /** Consecutive structural idle captures for a noisy, periodically-redrawing
1399
1367
  * pane. Two are required before a working -> idle transition. */
1400
1368
  instanceStatePeriodicIdleConfirmations = 0;
1369
+ /** #2770 (ii): the latest capture showed a layout that repaints while idle (CliBackend.periodicRedrawFrom). */
1370
+ periodicRedrawLayout = false;
1371
+ /** When output last scheduled a look for that layout (monotonic ms), so a busy pane is not captured on every chunk. */
1372
+ periodicLayoutLookAt = -Infinity;
1401
1373
  /** Recent execution-state edge times, for the flap log limiter (#1116). */
1402
1374
  stateEdgeTimes = [];
1403
1375
  stateEdgesNotLogged = 0;
@@ -1439,7 +1411,6 @@ export class Daemon extends EventEmitter {
1439
1411
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
1440
1412
  lastMalformedToolCallSignature;
1441
1413
  proxyReplySeq = 0;
1442
- lastReplyDropWarningAt = 0;
1443
1414
  /**
1444
1415
  * #1241: an idle edge that has not yet proven the turn ended. Armed instead
1445
1416
  * of recovering immediately; a steady idle snapshot past confirmAt promotes
@@ -1525,6 +1496,8 @@ export class Daemon extends EventEmitter {
1525
1496
  authFailureUnresolved = false;
1526
1497
  /** One login-screen auth report per spawn — the screen persists across polls. */
1527
1498
  loginScreenReported = false;
1499
+ /** #2795: the auth pattern match the token-free check is still to judge; it arms nothing until confirmAuthFailure(). */
1500
+ unverifiedAuthMatch = null;
1528
1501
  /**
1529
1502
  * While the lifecycle is still starting this daemon, `pty_error` has no
1530
1503
  * listener and `InstanceLifecycle.daemons` has no entry — so an incident the
@@ -1583,19 +1556,12 @@ export class Daemon extends EventEmitter {
1583
1556
  deliveryWritesStopping = false;
1584
1557
  /** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
1585
1558
  recentPastes = [];
1586
- pendingInstructionsUpdate;
1587
- pendingInstructionsNotice = false;
1588
1559
  /** #1633: the one-time "what's new for you" notice, pasted before the next real delivery; recorded once pasted. */
1589
1560
  pendingWhatsNew = null;
1590
- /** #2140: deliveries tried for the pending what's-new notice that never reached the pane (capped: never before every message). */
1591
- whatsNewUnwritten = 0;
1592
- // Whether the warmup steering-reload notice should be injected after spawn.
1593
- // Set in trySpawn by comparing the freshly-built instructions against the
1594
- // last value the agent was told about (prev-instructions). Skipped when
1595
- // unchanged so agents don't waste 10-30s re-reading identical steering.
1596
- warmupNeeded = false;
1597
- lastBuiltInstructions = "";
1598
1561
  pasteQueueDepth = 0;
1562
+ /** #2770: a person's messages a cancel dropped before they reached the CLI, told together (noteCancelledHuman). */
1563
+ cancelledHumans = [];
1564
+ cancelledHumansTimer = null;
1599
1565
  firstDeliveryDelay = new FirstDeliveryDelay();
1600
1566
  /** Orders /steer pastes against each other. Deliberately NOT pasteLock:
1601
1567
  * a steer must not queue behind the normal deliveries it exists to
@@ -1838,21 +1804,14 @@ export class Daemon extends EventEmitter {
1838
1804
  return super.emit(event, ...args);
1839
1805
  }
1840
1806
  /**
1841
- * Undo the auth suspicion a pattern match armed, once the backend's token-free
1842
- * probe has said the credentials are fine.
1843
- *
1844
- * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1845
- * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1846
- * daemon permanently suspicious of an auth failure that never existed. That
1847
- * state is not inert: authFailureUnresolved suppresses the stuck/hang
1848
- * notification and holds MCP auto-restart (a later real MCP death is then read
1849
- * as already-confirmed auth trouble rather than re-verified), and the recovery
1850
- * gate suppresses further error detection until a ready pattern shows up.
1851
- *
1852
- * Only what an auth match armed is rolled back. If some other pattern has
1853
- * since armed the recovery gate, that one is still live and stays.
1807
+ * Undo an auth suspicion once the backend's token-free probe has said the credentials are fine. Since #2795 a pane
1808
+ * pattern match arms nothing before that probe (its pending match is just dropped); what is left to undo is a
1809
+ * login-screen report, which arms authFailureUnresolved itself. That state is not inert: it suppresses the
1810
+ * stuck/hang notification and holds MCP auto-restart. If some other pattern has armed the recovery gate, that one
1811
+ * is still live and stays.
1854
1812
  */
1855
1813
  clearSuspectedAuthFailure() {
1814
+ this.unverifiedAuthMatch = null;
1856
1815
  if (!this.authFailureUnresolved && !this.loginScreenReported)
1857
1816
  return false;
1858
1817
  this.authFailureUnresolved = false;
@@ -1864,6 +1823,29 @@ export class Daemon extends EventEmitter {
1864
1823
  this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1865
1824
  return true;
1866
1825
  }
1826
+ armErrorRecoveryGate(key, type, now) {
1827
+ this.errorWaitingForRecovery = true;
1828
+ this.errorDetectedAt = now;
1829
+ this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
1830
+ this.activeErrorPatternKey = key;
1831
+ this.lastDetectedErrorType = type;
1832
+ }
1833
+ /**
1834
+ * #2795: the token-free check did not vouch for the credentials behind an auth pattern match. Only now does the
1835
+ * match become an auth failure: the state the detector used to arm at match time. Remembered for as long as it is
1836
+ * unresolved — an auth-broken CLI cannot finish the turn it is holding, so every other detector would otherwise
1837
+ * keep reporting the same dead instance. A login-screen report arms its own state and has no match to confirm.
1838
+ */
1839
+ confirmAuthFailure() {
1840
+ const match = this.unverifiedAuthMatch;
1841
+ if (!match)
1842
+ return false;
1843
+ this.unverifiedAuthMatch = null;
1844
+ if (match.gate)
1845
+ this.armErrorRecoveryGate(match.key, "auth_error", match.at);
1846
+ this.authFailureUnresolved = true;
1847
+ return true;
1848
+ }
1867
1849
  clearErrorRecoveryGate() {
1868
1850
  this.errorWaitingForRecovery = false;
1869
1851
  this.errorDetectedAt = 0;
@@ -2441,16 +2423,11 @@ export class Daemon extends EventEmitter {
2441
2423
  catch { /* may not exist */ }
2442
2424
  }
2443
2425
  // #1209: conditional continuation for the turn interrupted by the last
2444
- // restart, if the gates allow. Detached like the warmup notice; the gate
2426
+ // restart, if the gates allow. Detached; the gate
2445
2427
  // consumes its one-shot marker first and never throws into boot.
2446
2428
  const legacyResume = this.maybeResumeInterruptedTurn().catch(() => "unknown");
2447
- // Warmup: wait for CLI idle, then trigger steering reload — but only when
2448
- // the instructions actually changed since the agent last saw them.
2449
- // Skipping the no-op reload saves 10-30s of agent time on every restart
2450
- // where instructions are unchanged.
2451
- const warmup = this.runWarmupInstructionNotice();
2452
2429
  const recoveryOwner = JSON.stringify(this.interactionOwner());
2453
- void Promise.allSettled([legacyResume, warmup]).then(() => {
2430
+ void Promise.allSettled([legacyResume]).then(() => {
2454
2431
  if (JSON.stringify(this.interactionOwner()) === recoveryOwner) {
2455
2432
  this.recoveryBootWorkPending = false;
2456
2433
  this.recoveryBootSettled = true;
@@ -3848,6 +3825,14 @@ export class Daemon extends EventEmitter {
3848
3825
  rebasedSeededBaselines.add(key);
3849
3826
  }
3850
3827
  }
3828
+ // #2795: an auth match the token-free check has not judged yet holds the other detectors, as the armed gate did
3829
+ // before: a real auth failure's text also matches generic patterns (kiro's "having trouble responding"). It is not
3830
+ // an error state — nobody is warned — and it is bounded like the gate.
3831
+ if (this.unverifiedAuthMatch?.gate) {
3832
+ if (now - this.unverifiedAuthMatch.at < Daemon.ERROR_RECOVERY_TIMEOUT_MS)
3833
+ return;
3834
+ this.unverifiedAuthMatch = null;
3835
+ }
3851
3836
  // State: waiting for recovery. A missing/outdated ready pattern must not
3852
3837
  // suppress every future error forever, so the gate has a hard deadline.
3853
3838
  if (this.errorWaitingForRecovery) {
@@ -3993,25 +3978,20 @@ export class Daemon extends EventEmitter {
3993
3978
  }
3994
3979
  /** Arm the recovery gates and emit one detected error pattern. */
3995
3980
  emitErrorPattern(ep, key, pane, now) {
3981
+ // #2795: auth text on the pane proves nothing about the credentials (22 of 22 such matches on the main host were
3982
+ // conversation text). It arms nothing here; it only asks the lifecycle's token-free check, and a failing check
3983
+ // arms the auth state through confirmAuthFailure().
3984
+ if (ep.type === "auth_error")
3985
+ this.unverifiedAuthMatch = { key, at: now, gate: !ep.skipRecoveryWait };
3996
3986
  // skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
3997
3987
  // back at its prompt immediately). Keep monitoring so the next occurrence
3998
3988
  // can fire without waiting for a possibly startup-only ready pattern.
3999
- if (!ep.skipRecoveryWait) {
4000
- this.errorWaitingForRecovery = true;
4001
- this.errorDetectedAt = now;
4002
- this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
4003
- this.activeErrorPatternKey = key;
4004
- this.lastDetectedErrorType = ep.type;
4005
- }
3989
+ else if (!ep.skipRecoveryWait)
3990
+ this.armErrorRecoveryGate(key, ep.type, now);
4006
3991
  this.lastErrorNotifiedAt.set(key, now);
4007
3992
  if (ep.action === "failover")
4008
3993
  this.lastFailoverAt = now;
4009
3994
  const message = this.resolveErrorMessage(pane, ep);
4010
- // Remember an auth failure for as long as it is unresolved. An auth-broken
4011
- // CLI cannot finish the turn it is holding, so every other detector in this
4012
- // scan would otherwise keep reporting the same dead instance forever.
4013
- if (ep.type === "auth_error")
4014
- this.authFailureUnresolved = true;
4015
3995
  this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
4016
3996
  this.lastErrorEpisode = { key, pattern: ep.pattern, pane, spawn: this.spawnGeneration, fence: this.launchFenceEpoch };
4017
3997
  this.emit("pty_error", { name: this.name, ...ep, message });
@@ -5186,6 +5166,7 @@ export class Daemon extends EventEmitter {
5186
5166
  this.pausePending = false;
5187
5167
  this.pauseReconfirmAuth = false;
5188
5168
  this.authFailureUnresolved = false;
5169
+ this.unverifiedAuthMatch = null;
5189
5170
  this.loginScreenReported = false;
5190
5171
  this.fatalStartupReported = false;
5191
5172
  this.fatalStartupBlocked = false;
@@ -6030,20 +6011,7 @@ export class Daemon extends EventEmitter {
6030
6011
  reason,
6031
6012
  message: formatReplyDropDetection(turn, reason),
6032
6013
  });
6033
- this.deliverReplyDropNotice("inst.reply_drop_unrecovered", "replydropwarn", "Reply-drop recovery warning", turn, reason, true);
6034
- }
6035
- /** Channel notice for a reply-drop. `rateLimit` shares the per-instance cooldown (#2544). */
6036
- deliverReplyDropNotice(key, kind, title, turn, reason, rateLimit) {
6037
- if (rateLimit) {
6038
- const now = Date.now();
6039
- if (this.lastReplyDropWarningAt !== 0
6040
- && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS) {
6041
- this.logger.debug({ reason, kind }, "Reply-drop channel notice rate-limited");
6042
- return;
6043
- }
6044
- this.lastReplyDropWarningAt = now;
6045
- }
6046
- void this.deliverDaemonReply(t(key), kind, title, turn.target, true);
6014
+ // #2802: the event stays (Log tab); the person is no longer told in the topic or the web chat.
6047
6015
  }
6048
6016
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
6049
6017
  async sendProxyReply(pane, target) {
@@ -6076,7 +6044,7 @@ export class Daemon extends EventEmitter {
6076
6044
  }
6077
6045
  }
6078
6046
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
6079
- deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
6047
+ deliverDaemonReply(body, requestPrefix, logLabel, target) {
6080
6048
  const args = { text: body };
6081
6049
  const chatId = target?.chatId ?? this.lastChatId;
6082
6050
  const threadId = target?.threadId ?? this.lastThreadId;
@@ -6153,7 +6121,6 @@ export class Daemon extends EventEmitter {
6153
6121
  args,
6154
6122
  fleetRequestId: fleetReqId,
6155
6123
  adapterId,
6156
- statusOnly,
6157
6124
  });
6158
6125
  }
6159
6126
  catch {
@@ -6329,7 +6296,9 @@ export class Daemon extends EventEmitter {
6329
6296
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
6330
6297
  // decisions about *now*. The old double-observe expressed that by calling
6331
6298
  // observe twice, which silently disabled the "content moved" branch.
6332
- const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
6299
+ if (this.backend?.periodicRedrawFrom)
6300
+ this.periodicRedrawLayout = this.backend.periodicRedrawFrom(pane);
6301
+ const structuredPeriodicIdle = this.periodicIdleProof();
6333
6302
  // #1641: the transcript outranks the pane: while it shows the turn running, no reading of this pane is idle (codex
6334
6303
  // hides its status row while streaming, leaving the idle composer and footer in view mid-turn).
6335
6304
  const transcriptTurn = this.transcriptTurnState();
@@ -6521,7 +6490,7 @@ export class Daemon extends EventEmitter {
6521
6490
  return;
6522
6491
  this.instanceStateLastOutputAt = event.at;
6523
6492
  this.interactionOutputRevision++;
6524
- const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
6493
+ const canProvePeriodicIdle = !!this.periodicIdleProof();
6525
6494
  if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
6526
6495
  // Do not emit idle→working solely because agy repainted an identical
6527
6496
  // footer. Codex additionally uses a structural probe while working: two
@@ -6539,6 +6508,25 @@ export class Daemon extends EventEmitter {
6539
6508
  this.applyInstanceStateSnapshot(snapshot);
6540
6509
  this.scheduleInstanceStateIdleCapture();
6541
6510
  this.scheduleInstanceStateStuckDeadline(event.at);
6511
+ // #2770 (ii): a layout that repaints while idle (Claude's background-agent list) never leaves the quiet window the
6512
+ // idle capture waits for, so nothing would look at the pane again before the 60 s sweep. Look now and then.
6513
+ if (this.backend?.periodicRedrawFrom && performance.now() - this.periodicLayoutLookAt >= PERIODIC_LAYOUT_LOOK_MS) {
6514
+ this.periodicLayoutLookAt = performance.now();
6515
+ this.scheduleInstanceStateOutputProbe(STRUCTURED_IDLE_PROBE_MS);
6516
+ }
6517
+ }
6518
+ /**
6519
+ * The backend's structural idle proof for periodically repainting panes, when it applies to the current layout: always
6520
+ * for a backend that repaints in every layout (Codex, Muse), only while the latest capture showed such a layout for one
6521
+ * that names it (CliBackend.periodicRedrawFrom — Claude's background-agent list, #2770 (ii)).
6522
+ */
6523
+ periodicIdleProof() {
6524
+ const proof = this.backend?.isPeriodicRedrawIdlePane;
6525
+ if (!proof)
6526
+ return undefined;
6527
+ if (this.backend?.periodicRedrawFrom && !this.periodicRedrawLayout)
6528
+ return undefined;
6529
+ return proof;
6542
6530
  }
6543
6531
  bindInstanceStateOutputListener(windowId) {
6544
6532
  if (!this.controlClient || !this.instanceStateMonitorActive)
@@ -6555,6 +6543,7 @@ export class Daemon extends EventEmitter {
6555
6543
  startInstanceStateMonitor() {
6556
6544
  if (this.runtimeMonitorsFrozen || !this.tmux || !this.backend || this.instanceStateMonitorActive)
6557
6545
  return;
6546
+ this.periodicRedrawLayout = false; // a new launch's layout is not the old one's
6558
6547
  const rawConfig = this.config.hang_detector;
6559
6548
  const timeoutMinutes = rawConfig?.timeout_minutes;
6560
6549
  this.instanceStateStuckTimeoutMs = typeof timeoutMinutes === "number" && timeoutMinutes > 0
@@ -6971,6 +6960,26 @@ export class Daemon extends EventEmitter {
6971
6960
  return { receiptId: id, verificationId: meta.verification_id, expectedWireHash: meta.human_wire_sha256,
6972
6961
  ownerKey: meta.human_owner_key, current: () => this.captureHumanReceiptOwner() === daemonOwner };
6973
6962
  }
6963
+ /**
6964
+ * #2770: a person's message a cancel dropped before it reached the CLI is told, not left as a silent "uncertain":
6965
+ * the messages dropped together go out as one `human_messages_cancelled` (the fleet marks each ❌ and says so once).
6966
+ * Agents' messages are not a person's, and a durable row (delivery_id) is retried by the outbox: neither is ❌'d. A
6967
+ * message with no chat to mark has nowhere to say it.
6968
+ */
6969
+ noteCancelledHuman(meta) {
6970
+ if (meta.from_instance || meta.delivery_id || !meta.chat_id || !meta.message_id)
6971
+ return;
6972
+ this.cancelledHumans.push({ chatId: meta.chat_id, messageId: meta.message_id, ...(meta.thread_id ? { threadId: meta.thread_id } : {}) });
6973
+ if (this.cancelledHumansTimer)
6974
+ clearTimeout(this.cancelledHumansTimer);
6975
+ this.cancelledHumansTimer = setTimeout(() => {
6976
+ this.cancelledHumansTimer = null;
6977
+ const messages = this.cancelledHumans.splice(0);
6978
+ if (messages.length)
6979
+ this.emit("human_messages_cancelled", { messages });
6980
+ }, CANCELLED_HUMANS_NOTICE_MS);
6981
+ this.cancelledHumansTimer.unref?.();
6982
+ }
6974
6983
  finishHumanReceipt(meta, verdict) {
6975
6984
  if (!meta.human_receipt_id || !meta.human_owner_key)
6976
6985
  return;
@@ -7045,6 +7054,7 @@ export class Daemon extends EventEmitter {
7045
7054
  steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
7046
7055
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7047
7056
  this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
7057
+ this.noteCancelledHuman(meta);
7048
7058
  return;
7049
7059
  }
7050
7060
  // The fleet facade prevents a steer from overtaking an item still in its
@@ -7068,14 +7078,17 @@ export class Daemon extends EventEmitter {
7068
7078
  this.steerLock = this.steerLock.then(async () => {
7069
7079
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7070
7080
  this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
7081
+ this.noteCancelledHuman(meta);
7071
7082
  return;
7072
7083
  }
7073
7084
  await this.wake();
7074
7085
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7075
7086
  this.deferDurableDelivery(meta, "delivery epoch changed while waking for steer");
7087
+ this.noteCancelledHuman(meta);
7076
7088
  return;
7077
7089
  }
7078
7090
  const verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
7091
+ let delivered = false;
7079
7092
  try {
7080
7093
  if (await this.deliverMessage(formatted, status, {
7081
7094
  steer: true,
@@ -7085,6 +7098,7 @@ export class Daemon extends EventEmitter {
7085
7098
  verdict,
7086
7099
  durableAttempt: durableAttempt || undefined,
7087
7100
  })) {
7101
+ delivered = true;
7088
7102
  this.finishDurableSubmission(durableAttempt, verdict);
7089
7103
  this.markTurnStarted(meta, formatted, deliveryEpoch);
7090
7104
  }
@@ -7114,6 +7128,8 @@ export class Daemon extends EventEmitter {
7114
7128
  }
7115
7129
  finally {
7116
7130
  this.finishHumanReceipt(meta, verdict);
7131
+ if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
7132
+ this.noteCancelledHuman(meta);
7117
7133
  }
7118
7134
  }).catch(err => {
7119
7135
  this.deferDurableDelivery(meta, `target wake/steer failed before pane submission: ${err.message}`);
@@ -7132,6 +7148,7 @@ export class Daemon extends EventEmitter {
7132
7148
  btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
7133
7149
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7134
7150
  this.finishHumanReceipt(meta, { reached: false });
7151
+ this.noteCancelledHuman(meta);
7135
7152
  return;
7136
7153
  }
7137
7154
  this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
@@ -7142,22 +7159,28 @@ export class Daemon extends EventEmitter {
7142
7159
  this.steerLock = this.steerLock.then(async () => {
7143
7160
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7144
7161
  this.finishHumanReceipt(meta, { reached: false });
7162
+ this.noteCancelledHuman(meta);
7145
7163
  return;
7146
7164
  }
7147
7165
  await this.wake();
7148
7166
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7149
7167
  this.finishHumanReceipt(meta, { reached: false });
7168
+ this.noteCancelledHuman(meta);
7150
7169
  return;
7151
7170
  }
7152
7171
  const verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
7172
+ let delivered = false;
7153
7173
  try {
7154
7174
  if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id,
7155
7175
  ...(meta.verification_id ? { humanVerificationId: meta.verification_id } : {}), verdict })) {
7176
+ delivered = true;
7156
7177
  this.markTurnStarted(meta, formatted, deliveryEpoch);
7157
7178
  }
7158
7179
  }
7159
7180
  finally {
7160
7181
  this.finishHumanReceipt(meta, verdict);
7182
+ if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
7183
+ this.noteCancelledHuman(meta);
7161
7184
  }
7162
7185
  }).catch(err => {
7163
7186
  this.finishHumanReceipt(meta, { reached: false });
@@ -7173,6 +7196,7 @@ export class Daemon extends EventEmitter {
7173
7196
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7174
7197
  this.logger.info("Pending channel delivery dropped by user cancel");
7175
7198
  this.deferDurableDelivery(meta, "delivery epoch changed before queue admission");
7199
+ this.noteCancelledHuman(meta);
7176
7200
  return;
7177
7201
  }
7178
7202
  if (!this.tmux) {
@@ -7194,10 +7218,6 @@ export class Daemon extends EventEmitter {
7194
7218
  this.logger.warn({ err: err.message }, "Failed to persist last inbound timestamp");
7195
7219
  }
7196
7220
  }
7197
- if (this.pendingInstructionsUpdate) {
7198
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
7199
- this.pendingInstructionsUpdate = undefined;
7200
- }
7201
7221
  this.pendingWork.recordInbound();
7202
7222
  // v3: record user messages for rotation snapshot
7203
7223
  this.recordRecentUserMessage(content, meta);
@@ -7236,6 +7256,7 @@ export class Daemon extends EventEmitter {
7236
7256
  this.pasteLock = this.pasteLock.then(async () => {
7237
7257
  let durable = null;
7238
7258
  let verdict = { reached: false };
7259
+ let delivered = false;
7239
7260
  try {
7240
7261
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7241
7262
  this.logger.info("Pending channel delivery dropped by user cancel");
@@ -7247,44 +7268,11 @@ export class Daemon extends EventEmitter {
7247
7268
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
7248
7269
  return;
7249
7270
  }
7250
- if (this.pendingInstructionsNotice) {
7251
- this.pendingInstructionsNotice = false;
7252
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), undefined, { deliveryEpoch });
7253
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
7254
- return;
7255
- }
7256
- const whatsNew = this.pendingWhatsNew ? this.currentWhatsNew(this.pendingWhatsNew) : null;
7257
- if (whatsNew) {
7258
- // #1633: here, inside the delivery lock and ahead of a real delivery — never into a busy turn, never to an
7259
- // instance nobody writes to. #2140: told at most once. Once its paste reached the pane it is recorded, proven
7260
- // submitted or not. A system paste has no unique id: its only pane proof is its opening line on the screen, and
7261
- // a long notice (every entry since an old session began) scrolls that line above the captured rows once Claude
7262
- // paints it — unproven, though the agent read it. One that never reached the pane (cancelled, never idle) is
7263
- // tried again, at most WHATS_NEW_MAX_UNWRITTEN times, then recorded with a warning.
7264
- const attempt = { reached: false };
7265
- const pasted = await this.deliverMessage(whatsNew.notice, undefined, { deliveryEpoch, verdict: attempt });
7266
- if (this.pendingWhatsNew === whatsNew) {
7267
- const written = pasted || attempt.paneWriteStarted === true;
7268
- if (!written)
7269
- this.whatsNewUnwritten++;
7270
- if (written || this.whatsNewUnwritten >= WHATS_NEW_MAX_UNWRITTEN) {
7271
- if (!pasted) {
7272
- this.logger.warn({ entries: whatsNew.ids, written, attempts: this.whatsNewUnwritten, phase: attempt.phase, proof: attempt.proof }, written ? "what's-new notice pasted but its submission could not be proven — recorded so it is not sent again"
7273
- : "what's-new notice did not reach the pane after several deliveries — recorded so it is not sent again");
7274
- }
7275
- this.pendingWhatsNew = null;
7276
- this.whatsNewUnwritten = 0;
7277
- try {
7278
- recordWhatsNew(this.instanceDir, whatsNew.ids, whatsNew.version);
7279
- }
7280
- catch (err) {
7281
- this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
7282
- }
7283
- }
7284
- }
7285
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
7286
- return;
7287
- }
7271
+ // #2800: with this real message, in the same paste — never a paste or turn of its own: after its delivery marker
7272
+ // (transcript proof anchors on it), before the message. A person's message with a verified receipt is pasted
7273
+ // byte for byte (its proof is the wire's hash), so the notice waits for the next delivery without one.
7274
+ const whatsNew = this.pendingWhatsNew && !meta.human_receipt_id ? this.currentWhatsNew(this.pendingWhatsNew) : null;
7275
+ const pasted = whatsNew ? withWhatsNew(formatted, whatsNew.notice) : formatted;
7288
7276
  const status = channelStatus(meta);
7289
7277
  // A fresh delivery begins a fresh turn — its bubble must not inherit
7290
7278
  // the previous turn's tool list.
@@ -7296,13 +7284,24 @@ export class Daemon extends EventEmitter {
7296
7284
  return;
7297
7285
  }
7298
7286
  durable = durableAttempt;
7299
- if (await this.deliverMessage(formatted, status, {
7300
- deliveryEpoch,
7301
- submissionId: meta.message_id,
7302
- ...(meta.verification_id ? { humanVerificationId: meta.verification_id } : {}),
7303
- verdict,
7304
- durableAttempt: durableAttempt || undefined,
7305
- })) {
7287
+ let sent = false;
7288
+ try {
7289
+ sent = await this.deliverMessage(pasted, status, {
7290
+ deliveryEpoch,
7291
+ submissionId: meta.message_id,
7292
+ ...(meta.verification_id ? { humanVerificationId: meta.verification_id } : {}),
7293
+ verdict,
7294
+ durableAttempt: durableAttempt || undefined,
7295
+ });
7296
+ }
7297
+ finally {
7298
+ // #2800: told once — recorded as soon as it went out with this message, proven submitted or not, also when
7299
+ // a later step of the write threw; a paste that never reached the pane keeps it for the next message.
7300
+ if (whatsNew && (sent || verdict.paneWriteStarted))
7301
+ this.recordWhatsNewSent(whatsNew);
7302
+ }
7303
+ if (sent) {
7304
+ delivered = true;
7306
7305
  this.finishDurableSubmission(durable, verdict);
7307
7306
  this.markTurnStarted(meta, formatted, deliveryEpoch);
7308
7307
  }
@@ -7348,6 +7347,8 @@ export class Daemon extends EventEmitter {
7348
7347
  }
7349
7348
  finally {
7350
7349
  this.finishHumanReceipt(meta, verdict);
7350
+ if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
7351
+ this.noteCancelledHuman(meta);
7351
7352
  this.pasteQueueDepth--;
7352
7353
  }
7353
7354
  }).catch(err => {
@@ -7419,6 +7420,22 @@ export class Daemon extends EventEmitter {
7419
7420
  // normal Enter input has steering/interrupt semantics in several other CLIs.
7420
7421
  let readiness = "ready";
7421
7422
  const gateWindowId = windowId;
7423
+ const handOffEligible = !!(supportsQueuedInput || opts?.steer || composerQueue);
7424
+ const handOffAdmissible = async () => (await this.probeBlockingDialog()).state === "clear"
7425
+ && await this.hasPositiveDeliveryInput(true)
7426
+ && ((!opts?.steer && !composerQueue) || (this.launchTakesSteer() && await this.steerComposerAdmits(false, opts?.steer === true)));
7427
+ // #2770: a delivery waiting for idle holds the paste queue, and every message behind it. It stops waiting at once
7428
+ // on a cancel, and as soon as the busy pane can take it the way it would have on arrival (a box that reads again, a
7429
+ // dialog gone) — rather than for the end of whatever keeps the pane moving (2026-10-27: 11 minutes).
7430
+ let handOffCheckedAt = -Infinity;
7431
+ const waitStop = async () => {
7432
+ if (cancelled())
7433
+ return true;
7434
+ if (!handOffEligible || !gateWindowId || performance.now() - handOffCheckedAt < HANDOFF_RECHECK_MS)
7435
+ return false;
7436
+ handOffCheckedAt = performance.now();
7437
+ return (await this.paneReadinessForDelivery(gateWindowId)) === "busy" && await handOffAdmissible() && !cancelled();
7438
+ };
7422
7439
  if (gateWindowId && this.controlClient)
7423
7440
  readiness = await this.paneReadinessForDelivery(gateWindowId);
7424
7441
  if (readiness !== "ready") {
@@ -7431,11 +7448,7 @@ export class Daemon extends EventEmitter {
7431
7448
  // fall through to the wait.
7432
7449
  // #2676: an ordinary message goes into a mode-switched composer (kiro TUI) mid-turn too, on a launch whose busy
7433
7450
  // composer AgEnD drives, by the steer path's own gates and proof — when its box can show it is this paste.
7434
- const canHandOff = (supportsQueuedInput || opts?.steer || composerQueue)
7435
- && readiness === "busy"
7436
- && (await this.probeBlockingDialog()).state === "clear"
7437
- && await this.hasPositiveDeliveryInput(true)
7438
- && ((!opts?.steer && !composerQueue) || (this.launchTakesSteer() && await this.steerComposerAdmits(false, opts?.steer === true)));
7451
+ const canHandOff = handOffEligible && readiness === "busy" && await handOffAdmissible();
7439
7452
  if (canHandOff) {
7440
7453
  // Native queue (codex), or an explicit /steer: hand the complete
7441
7454
  // paste+Enter transaction to the busy CLI now. For steer this is the
@@ -7449,10 +7462,14 @@ export class Daemon extends EventEmitter {
7449
7462
  }
7450
7463
  else {
7451
7464
  this.logger.debug("CLI busy — queuing message until idle");
7452
- const becameIdle = await this.waitForPaneReadyForDelivery(gateWindowId);
7465
+ const becameIdle = await this.waitForPaneReadyForDelivery(gateWindowId, undefined, waitStop);
7453
7466
  if (cancelled())
7454
7467
  return false;
7455
- if (!becameIdle) {
7468
+ if (becameIdle === "stopped") {
7469
+ handingOffToNativeQueue = true;
7470
+ this.logger.info("The busy CLI can take this delivery now — handing it off instead of waiting for idle (#2770)");
7471
+ }
7472
+ else if (!becameIdle) {
7456
7473
  // The pane never freed up. Report the failure instead of pasting into a
7457
7474
  // wedged CLI (where the text would sit unsubmitted and the next message
7458
7475
  // would land on top of it) — and instead of holding the queue silently.
@@ -7597,9 +7614,13 @@ export class Daemon extends EventEmitter {
7597
7614
  return this.failDelivery(verdict, status, "pre-write", "dialog-or-input-not-ready");
7598
7615
  }
7599
7616
  this.logger.info("Dialog or input transition appeared before the pane write — waiting for readiness");
7600
- const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
7617
+ const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId, undefined, waitStop) : false;
7601
7618
  if (cancelled())
7602
7619
  return false;
7620
+ if (clear === "stopped") {
7621
+ handingOffToNativeQueue = true;
7622
+ continue;
7623
+ }
7603
7624
  if (!clear) {
7604
7625
  return this.failDelivery(verdict, status, "pre-write", "readiness-timeout");
7605
7626
  }
@@ -8736,13 +8757,7 @@ export class Daemon extends EventEmitter {
8736
8757
  return "busy";
8737
8758
  }
8738
8759
  }
8739
- /**
8740
- * Like waitForPaneIdleForDelivery, plus the bottom-anchored prompt check for
8741
- * Enter-dropping TUIs. Polls at a coarse cadence (each poll is a capture-pane)
8742
- * only after the pane is already quiet, so the common fast-idle delivery still
8743
- * costs a single capture.
8744
- */
8745
- async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000) {
8760
+ async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000, stop) {
8746
8761
  // Every backend polls: readiness now also means "no blocking dialog on
8747
8762
  // screen", and a dialog disappears without any output edge the silence
8748
8763
  // gate could see.
@@ -8755,9 +8770,10 @@ export class Daemon extends EventEmitter {
8755
8770
  const remaining = deadline - Date.now();
8756
8771
  if (remaining <= 0)
8757
8772
  return false;
8758
- const idle = await this.waitForPaneIdleForDelivery(windowId, remaining);
8759
- if (!idle)
8760
- return false;
8773
+ const idle = stop ? await this.waitForPaneIdleForDelivery(windowId, remaining, stop)
8774
+ : await this.waitForPaneIdleForDelivery(windowId, remaining);
8775
+ if (idle !== true)
8776
+ return idle;
8761
8777
  // The idle wait just resolved, so re-reading the silence gate would only
8762
8778
  // add a stale-cache race; ask the pane the remaining questions directly.
8763
8779
  const transient = await this.probeInputTransient();
@@ -8777,6 +8793,8 @@ export class Daemon extends EventEmitter {
8777
8793
  this.retireInputTransients("transient stalled");
8778
8794
  return false;
8779
8795
  }
8796
+ if (stop && await stop())
8797
+ return "stopped";
8780
8798
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
8781
8799
  continue;
8782
8800
  }
@@ -8786,6 +8804,8 @@ export class Daemon extends EventEmitter {
8786
8804
  this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
8787
8805
  return false;
8788
8806
  }
8807
+ if (stop && await stop())
8808
+ return "stopped";
8789
8809
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
8790
8810
  continue;
8791
8811
  }
@@ -8807,6 +8827,8 @@ export class Daemon extends EventEmitter {
8807
8827
  unknownStreak = 0;
8808
8828
  }
8809
8829
  }
8830
+ if (stop && await stop())
8831
+ return "stopped";
8810
8832
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
8811
8833
  }
8812
8834
  }
@@ -9214,9 +9236,11 @@ export class Daemon extends EventEmitter {
9214
9236
  return observedAt >= lastOutputAt
9215
9237
  && observedAt >= this.controlClient.getObservationResetAt();
9216
9238
  }
9217
- waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
9239
+ waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000, stop) {
9218
9240
  if (!this.controlClient)
9219
9241
  return Promise.resolve(true);
9242
+ if (stop)
9243
+ return this.waitForPaneIdleOrStop(windowId, timeoutMs, stop);
9220
9244
  if (!this.canTrustRedrawIdleState()) {
9221
9245
  return this.controlClient.waitUntilIdle(windowId, timeoutMs);
9222
9246
  }
@@ -9236,6 +9260,44 @@ export class Daemon extends EventEmitter {
9236
9260
  }, timeoutMs);
9237
9261
  });
9238
9262
  }
9263
+ /**
9264
+ * #2770: waitForPaneIdleForDelivery that also ends as soon as `stop` says so ("stopped"). The same idle readings
9265
+ * (tmux silence, or the state machine where its idle is trusted), polled here so nothing outlives a stopped wait.
9266
+ */
9267
+ waitForPaneIdleOrStop(windowId, timeoutMs, stop) {
9268
+ const control = this.controlClient;
9269
+ const trusted = this.canTrustRedrawIdleState();
9270
+ if (trusted && this.isPaneIdleForDelivery(windowId))
9271
+ return Promise.resolve(true);
9272
+ return new Promise(resolve => {
9273
+ let done = false, asking = false, askedAt = -Infinity;
9274
+ const finish = (outcome) => {
9275
+ if (done)
9276
+ return;
9277
+ done = true;
9278
+ clearInterval(check);
9279
+ clearTimeout(timer);
9280
+ resolve(outcome);
9281
+ };
9282
+ // The same idle readings as the plain wait: tmux silence (abandoned once this wait ends), or the state machine.
9283
+ if (!trusted) {
9284
+ control.waitUntilIdle(windowId, timeoutMs, () => done).then(idle => finish(idle), () => finish(false));
9285
+ }
9286
+ const check = setInterval(() => {
9287
+ if (trusted && this.isPaneIdleForDelivery(windowId)) {
9288
+ finish(true);
9289
+ return;
9290
+ }
9291
+ if (asking || performance.now() - askedAt < WAIT_STOP_POLL_MS)
9292
+ return;
9293
+ asking = true;
9294
+ askedAt = performance.now();
9295
+ stop().then(stopNow => { asking = false; if (stopNow)
9296
+ finish("stopped"); }, err => { asking = false; this.logger.debug({ err }, "Delivery wait stop check failed — still waiting"); });
9297
+ }, 50);
9298
+ const timer = setTimeout(() => finish(false), timeoutMs);
9299
+ });
9300
+ }
9239
9301
  /**
9240
9302
  * Antigravity has always opted into pane-state delivery readiness because its
9241
9303
  * footer redraws while idle. Codex's structural idle proof is useful only
@@ -9248,7 +9310,7 @@ export class Daemon extends EventEmitter {
9248
9310
  canTrustRedrawIdleState() {
9249
9311
  if (this.backend?.hasPeriodicPaneRedraw?.() === true)
9250
9312
  return true;
9251
- return this.instanceStateMonitorActive && !!this.backend?.isPeriodicRedrawIdlePane;
9313
+ return this.instanceStateMonitorActive && !!this.periodicIdleProof();
9252
9314
  }
9253
9315
  /**
9254
9316
  * The write half of a delivery: paste → Enter → confirm, with retries.
@@ -11229,10 +11291,6 @@ export class Daemon extends EventEmitter {
11229
11291
  const snapshot = this.buildSnapshotPrompt();
11230
11292
  if (!snapshot || !this.tmux)
11231
11293
  return;
11232
- if (this.pendingInstructionsUpdate) {
11233
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
11234
- this.pendingInstructionsUpdate = undefined;
11235
- }
11236
11294
  // Small delay to let the CLI fully render its ready prompt
11237
11295
  await new Promise(r => setTimeout(r, 1_000));
11238
11296
  admission?.();
@@ -12064,10 +12122,13 @@ export class Daemon extends EventEmitter {
12064
12122
  }
12065
12123
  /** Kill the entire process tree of the current tmux pane (CLI + MCP server). */
12066
12124
  async killProcessTree(signal = "SIGTERM", reason = "unspecified") {
12067
- if (!this.tmux)
12125
+ const windowId = this.tmux?.getWindowId();
12126
+ // Before createWindow succeeds there is no pane owned by this daemon.
12127
+ // tmux interprets an empty selector as the session's active window.
12128
+ if (!windowId?.trim())
12068
12129
  return;
12069
12130
  try {
12070
- const pid = await TmuxManager.getPanePid(this.tmuxSessionName, this.tmux.getWindowId());
12131
+ const pid = await TmuxManager.getPanePid(this.tmuxSessionName, windowId);
12071
12132
  if (pid) {
12072
12133
  this.noteStopRequest(signal, reason);
12073
12134
  process.kill(-pid, signal);
@@ -12239,113 +12300,40 @@ export class Daemon extends EventEmitter {
12239
12300
  return this.museRelayFallbackInFlight;
12240
12301
  }
12241
12302
  /**
12242
- * Tell the agent its instructions moved, once the CLI is idle.
12243
- *
12244
- * A named method rather than an inline closure so the wiring itself is
12245
- * testable: warmupNoticeAction's own tests pass even if this call site stops
12246
- * consulting it, which is exactly the bug this path had — the decision was
12247
- * made inline and never asked whether the backend needed telling at all.
12248
- *
12249
- * Never throws: a missed reload notice must not fail a spawn.
12250
- */
12251
- async runWarmupInstructionNotice() {
12252
- try {
12253
- const action = warmupNoticeAction({
12254
- warmupNeeded: this.warmupNeeded,
12255
- backend: this.backend ?? undefined,
12256
- pasteQueueDepth: this.pasteQueueDepth,
12257
- });
12258
- if (action === "skip") {
12259
- this.logger.debug(this.warmupNeeded
12260
- ? "Warmup skipped — this backend reloads instructions on resume"
12261
- : "Warmup skipped — instructions unchanged");
12262
- // When the backend learns of the change on its own, record what it now
12263
- // holds: leaving prev-instructions behind would keep every later
12264
- // restart comparing against a value the agent has long since replaced.
12265
- if (this.warmupNeeded) {
12266
- try {
12267
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
12268
- }
12269
- catch { /* best effort */ }
12270
- }
12271
- return;
12272
- }
12273
- if (action === "defer") {
12274
- this.logger.debug("Warmup deferred — no pending inbound messages");
12275
- // Convert to pendingInstructionsNotice so it fires on next real message.
12276
- this.pendingInstructionsNotice = true;
12277
- try {
12278
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
12279
- }
12280
- catch { }
12281
- return;
12282
- }
12283
- const wid = existsSync(join(this.instanceDir, "window-id"))
12284
- ? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
12285
- if (wid && this.controlClient) {
12286
- await this.controlClient.waitForIdle(wid, 120_000);
12287
- }
12288
- else {
12289
- await new Promise(r => setTimeout(r, 5000));
12290
- }
12291
- // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
12292
- // delivery is already in flight or queued. Without the lock the notice and
12293
- // that delivery race into the same pane.
12294
- const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
12295
- if (!told) {
12296
- // Recording the new instructions here would mark the agent as told about
12297
- // a notice it never received, and every later restart would then skip
12298
- // the reload. Leave the snapshot behind and hand it to the deferred
12299
- // notice, which fires on the next real message.
12300
- this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
12301
- this.pendingInstructionsNotice = true;
12302
- return;
12303
- }
12304
- // Record the value the agent has now been told about so the next
12305
- // unchanged restart skips the reload.
12306
- try {
12307
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
12308
- }
12309
- catch { /* best effort */ }
12310
- this.logger.debug("Warmup sent after idle");
12311
- }
12312
- catch { /* non-fatal */ }
12313
- }
12314
- /**
12315
- * #1633 (#1639 review): the pending notice as the record stands NOW, just before the paste — it was decided at launch,
12316
- * and the record may have changed while the delivery waited. Unreadable → nothing is sent (and nothing pending);
12317
- * entries recorded meanwhile are dropped from it; none left → nothing.
12303
+ * #1633 (#1639 review): the pending notice as things stand NOW, just before the paste — it was decided at launch, and
12304
+ * the record, a switch or the instance's tools may have changed while the delivery waited. Planned again from the
12305
+ * record (#2800: the running release only, tools it has now); nothing left to tell → recorded, nothing pending.
12318
12306
  */
12319
12307
  currentWhatsNew(pending) {
12320
- const record = readWhatsNewRecord(this.instanceDir);
12321
- if (record === "unreadable") {
12308
+ const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh: false, version: pending.version,
12309
+ roles: this.whatsNewRoles(), tools: this.whatsNewTools(), canvasWorker: readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")) });
12310
+ if (!plan || "silent" in plan) {
12322
12311
  this.pendingWhatsNew = null;
12312
+ if (plan)
12313
+ this.recordWhatsNewSent({ ids: plan.silent, version: plan.version });
12323
12314
  return null;
12324
12315
  }
12325
- const known = new Set(record?.announced ?? []);
12326
- const ids = pending.ids.filter(id => !known.has(id));
12327
- if (!ids.length) {
12316
+ this.pendingWhatsNew = plan;
12317
+ return plan;
12318
+ }
12319
+ recordWhatsNewSent(told) {
12320
+ if (this.pendingWhatsNew && told.ids.every(id => this.pendingWhatsNew.ids.includes(id)))
12328
12321
  this.pendingWhatsNew = null;
12329
- return null;
12322
+ try {
12323
+ recordWhatsNew(this.instanceDir, told.ids, told.version);
12330
12324
  }
12331
- // Rebuild even when ids are unchanged: an experimental switch may have changed
12332
- // while this delivery waited. Never paste the old enabled capability text.
12333
- // #2163: rebuilt as planned — only the entries this instance's roles are told; the rest are recorded with them.
12334
- const roles = this.whatsNewRoles();
12335
- const shown = agentWhatsNew(readCanvasWorkerEnabled(join(this.instanceDir, "..", ".."))).filter(e => ids.includes(e.id) && (!e.audience || e.audience.some(r => roles.includes(r))));
12336
- if (!shown.length) {
12337
- this.pendingWhatsNew = null;
12338
- try {
12339
- recordWhatsNew(this.instanceDir, ids, pending.version);
12340
- }
12341
- catch (err) {
12342
- this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
12343
- }
12344
- return null;
12325
+ catch (err) {
12326
+ this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
12345
12327
  }
12346
- const narrowed = { notice: whatsNewNotice(pending.version, shown), ids, version: pending.version };
12347
- this.pendingWhatsNew = narrowed;
12348
- return narrowed;
12328
+ }
12329
+ /**
12330
+ * #2800: the tools this instance's agend MCP server lists now — the same filters (tool set, dashboard switches, the
12331
+ * Canvas Worker switch) — so a notice names only changes to tools it has.
12332
+ */
12333
+ whatsNewTools() {
12334
+ const allowed = toolsFor(resolveToolSet(this.config, this.name));
12335
+ const listed = withoutFeatures(TOOLS.filter(t => allowed.has(t.name)), dashboardFeaturesOff(dashboardAccessOf(this.config)));
12336
+ return new Set(toolsWithCanvasWorker(readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")), listed).map(t => t.name));
12349
12337
  }
12350
12338
  /**
12351
12339
  * #2163: who this instance is to the what's-new notices — a ClassicBot room's agent, General, or a fleet worker (with
@@ -12362,7 +12350,8 @@ export class Daemon extends EventEmitter {
12362
12350
  /** #1633: what this instance has not been told about its AgEnD version yet (see agent-whats-new.ts). */
12363
12351
  prepareWhatsNew(fresh, version = agendVersion()) {
12364
12352
  try {
12365
- const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh, version, roles: this.whatsNewRoles(), canvasWorker: readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")) });
12353
+ const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh, version, roles: this.whatsNewRoles(),
12354
+ tools: this.whatsNewTools(), canvasWorker: readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")) });
12366
12355
  if (!plan) {
12367
12356
  this.pendingWhatsNew = null;
12368
12357
  return;
@@ -12373,8 +12362,7 @@ export class Daemon extends EventEmitter {
12373
12362
  return;
12374
12363
  }
12375
12364
  this.pendingWhatsNew = plan;
12376
- this.whatsNewUnwritten = 0;
12377
- this.logger.info({ entries: plan.ids }, "what's-new notice pending for the next delivery");
12365
+ this.logger.debug({ entries: plan.ids }, "what's-new notice pending for the next delivery");
12378
12366
  }
12379
12367
  catch (err) {
12380
12368
  this.logger.debug({ err }, "what's-new not prepared");
@@ -12385,41 +12373,15 @@ export class Daemon extends EventEmitter {
12385
12373
  this.startupAdmission?.();
12386
12374
  const launchFence = this.launchFenceEpoch;
12387
12375
  const backendConfig = this.buildBackendConfig();
12388
- // Compare freshly-built instructions against the last value the agent was
12389
- // told about. Computed for ALL backends (not gated by
12390
- // instructionsReloadedOnResume) so the warmup steering-reload can be
12391
- // skipped when nothing changed.
12392
- this.lastBuiltInstructions = backendConfig.instructions ?? "";
12393
12376
  // #1633: decided before prev-instructions is first written — its absence is what says "never launched before".
12394
12377
  this.prepareWhatsNew(!existsSync(join(this.instanceDir, "prev-instructions")));
12395
- {
12396
- const prevFile = join(this.instanceDir, "prev-instructions");
12397
- let prev = "";
12378
+ // Record what this launch writes. #2787: a change is never announced in the pane; the CLI takes the new
12379
+ // instructions whenever it next reads its own file.
12380
+ if (backendConfig.instructions) {
12398
12381
  try {
12399
- prev = readFileSync(prevFile, "utf-8");
12400
- }
12401
- catch { }
12402
- if (!prev && backendConfig.instructions) {
12403
- // First time (no prev-instructions file): write current hash, skip warmup.
12404
- try {
12405
- writeFileSync(prevFile, backendConfig.instructions);
12406
- }
12407
- catch { }
12408
- this.warmupNeeded = false;
12409
- }
12410
- else {
12411
- this.warmupNeeded = !!backendConfig.instructions && prev !== backendConfig.instructions;
12412
- }
12413
- // For backends that don't re-read instructions on resume (kiro/codex),
12414
- // also notify the agent on next message instead of forcing a new
12415
- // session. Resume is preserved so context isn't lost.
12416
- if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
12417
- if (prev) {
12418
- this.logger.info("Instructions changed — will notify agent on next message");
12419
- this.pendingInstructionsNotice = true;
12420
- }
12421
- this.pendingInstructionsUpdate = backendConfig.instructions;
12382
+ writeFileSync(join(this.instanceDir, "prev-instructions"), backendConfig.instructions);
12422
12383
  }
12384
+ catch { /* best effort */ }
12423
12385
  }
12424
12386
  // A backend that has to ask its CLI something before the launch command exists (OpenCode: does
12425
12387
  // this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.