@songsid/agend 2.3.4-beta.7 → 2.3.4-beta.9

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 (146) hide show
  1. package/THIRD_PARTY_NOTICES.md +37 -0
  2. package/dist/agent-cli-instructions.md +1 -1
  3. package/dist/agent-credentials.d.ts +24 -0
  4. package/dist/agent-credentials.js +66 -0
  5. package/dist/agent-credentials.js.map +1 -0
  6. package/dist/agent-endpoint.d.ts +3 -1
  7. package/dist/agent-endpoint.js +13 -0
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/authority/session-authorizations.d.ts +28 -4
  10. package/dist/authority/session-authorizations.js +63 -17
  11. package/dist/authority/session-authorizations.js.map +1 -1
  12. package/dist/backend/antigravity.js +3 -1
  13. package/dist/backend/antigravity.js.map +1 -1
  14. package/dist/backend/codex-footer-hints.d.ts +4 -0
  15. package/dist/backend/codex-footer-hints.js +27 -0
  16. package/dist/backend/codex-footer-hints.js.map +1 -0
  17. package/dist/backend/codex.js +8 -3
  18. package/dist/backend/codex.js.map +1 -1
  19. package/dist/backend/kiro.d.ts +3 -0
  20. package/dist/backend/kiro.js +39 -1
  21. package/dist/backend/kiro.js.map +1 -1
  22. package/dist/backend/muse.js +5 -3
  23. package/dist/backend/muse.js.map +1 -1
  24. package/dist/backend/types.d.ts +6 -1
  25. package/dist/backend/types.js +3 -0
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/cache-ledger.d.ts +85 -0
  28. package/dist/cache-ledger.js +236 -1
  29. package/dist/cache-ledger.js.map +1 -1
  30. package/dist/cache-service.d.ts +5 -0
  31. package/dist/cache-service.js +16 -4
  32. package/dist/cache-service.js.map +1 -1
  33. package/dist/channel/adapters/discord.js +11 -3
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.js +4 -3
  36. package/dist/channel/adapters/telegram.js.map +1 -1
  37. package/dist/channel/mcp-tools.js +1 -1
  38. package/dist/channel/mcp-tools.js.map +1 -1
  39. package/dist/cross-instance-notice.d.ts +10 -0
  40. package/dist/cross-instance-notice.js +67 -5
  41. package/dist/cross-instance-notice.js.map +1 -1
  42. package/dist/daemon.d.ts +43 -0
  43. package/dist/daemon.js +361 -17
  44. package/dist/daemon.js.map +1 -1
  45. package/dist/delivery-consumed-watch.d.ts +23 -1
  46. package/dist/delivery-consumed-watch.js +30 -3
  47. package/dist/delivery-consumed-watch.js.map +1 -1
  48. package/dist/delivery-reconciliation.d.ts +7 -0
  49. package/dist/delivery-reconciliation.js +9 -1
  50. package/dist/delivery-reconciliation.js.map +1 -1
  51. package/dist/export-import.d.ts +7 -0
  52. package/dist/export-import.js +44 -10
  53. package/dist/export-import.js.map +1 -1
  54. package/dist/fleet-context.d.ts +2 -0
  55. package/dist/fleet-manager.d.ts +85 -4
  56. package/dist/fleet-manager.js +375 -30
  57. package/dist/fleet-manager.js.map +1 -1
  58. package/dist/general-knowledge/skills/concepts/SKILL.md +390 -0
  59. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
  60. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  61. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  62. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  63. package/dist/instance-lifecycle.d.ts +8 -2
  64. package/dist/instance-lifecycle.js +37 -6
  65. package/dist/instance-lifecycle.js.map +1 -1
  66. package/dist/instance-topic-delete.d.ts +77 -0
  67. package/dist/instance-topic-delete.js +123 -0
  68. package/dist/instance-topic-delete.js.map +1 -0
  69. package/dist/instructions.d.ts +1 -1
  70. package/dist/instructions.js +1 -1
  71. package/dist/instructions.js.map +1 -1
  72. package/dist/kiro-engine-status.d.ts +11 -0
  73. package/dist/kiro-engine-status.js +6 -0
  74. package/dist/kiro-engine-status.js.map +1 -1
  75. package/dist/locale.js +47 -1
  76. package/dist/locale.js.map +1 -1
  77. package/dist/log-steps.d.ts +4 -3
  78. package/dist/log-steps.js +32 -7
  79. package/dist/log-steps.js.map +1 -1
  80. package/dist/login-flows.d.ts +2 -0
  81. package/dist/login-flows.js +5 -2
  82. package/dist/login-flows.js.map +1 -1
  83. package/dist/needs-you-hub.js +1 -1
  84. package/dist/needs-you-hub.js.map +1 -1
  85. package/dist/needs-you.d.ts +6 -0
  86. package/dist/needs-you.js +8 -2
  87. package/dist/needs-you.js.map +1 -1
  88. package/dist/outbound-handlers.js +3 -3
  89. package/dist/outbound-handlers.js.map +1 -1
  90. package/dist/outbound-schemas.js +5 -2
  91. package/dist/outbound-schemas.js.map +1 -1
  92. package/dist/queued-handoffs.d.ts +33 -0
  93. package/dist/queued-handoffs.js +75 -0
  94. package/dist/queued-handoffs.js.map +1 -0
  95. package/dist/scheduler/db.d.ts +2 -1
  96. package/dist/scheduler/db.js +23 -9
  97. package/dist/scheduler/db.js.map +1 -1
  98. package/dist/settings-api.d.ts +13 -0
  99. package/dist/settings-api.js +12 -1
  100. package/dist/settings-api.js.map +1 -1
  101. package/dist/tool-permissions.d.ts +5 -2
  102. package/dist/tool-permissions.js +6 -5
  103. package/dist/tool-permissions.js.map +1 -1
  104. package/dist/tool-progress.d.ts +2 -0
  105. package/dist/tool-progress.js +19 -3
  106. package/dist/tool-progress.js.map +1 -1
  107. package/dist/topic-commands.js +4 -0
  108. package/dist/topic-commands.js.map +1 -1
  109. package/dist/ui/chat-strings.js +2 -2
  110. package/dist/ui/fleet-usage.js +90 -27
  111. package/dist/ui/panel-chat.js +2 -0
  112. package/dist/ui/panel-needs.js +4 -3
  113. package/dist/ui/settings-strings.js +2 -2
  114. package/dist/ui/shared/app-i18n.js +2 -2
  115. package/dist/ui/shared/app-needs.js +1 -1
  116. package/dist/ui/shared/app.css +15 -0
  117. package/dist/ui/shared/usage-dialog.js +18 -1
  118. package/dist/ui/shared/view-strings.js +16 -0
  119. package/dist/usage/capacity.d.ts +69 -0
  120. package/dist/usage/capacity.js +119 -0
  121. package/dist/usage/capacity.js.map +1 -0
  122. package/dist/usage/i18n-keys.d.ts +1 -1
  123. package/dist/usage/i18n-keys.js +3 -0
  124. package/dist/usage/i18n-keys.js.map +1 -1
  125. package/dist/usage/profile-timeline.d.ts +16 -0
  126. package/dist/usage/profile-timeline.js +58 -0
  127. package/dist/usage/profile-timeline.js.map +1 -0
  128. package/dist/usage/quota-history.d.ts +43 -0
  129. package/dist/usage/quota-history.js +168 -0
  130. package/dist/usage/quota-history.js.map +1 -0
  131. package/dist/usage/usage-api.d.ts +31 -0
  132. package/dist/usage/usage-api.js +80 -1
  133. package/dist/usage/usage-api.js.map +1 -1
  134. package/dist/usage-report.d.ts +25 -1
  135. package/dist/usage-report.js +55 -4
  136. package/dist/usage-report.js.map +1 -1
  137. package/dist/usage-rollup.d.ts +4 -1
  138. package/dist/usage-rollup.js +12 -3
  139. package/dist/usage-rollup.js.map +1 -1
  140. package/dist/web-api.d.ts +6 -0
  141. package/dist/web-api.js +8 -2
  142. package/dist/web-api.js.map +1 -1
  143. package/dist/web-session-authorization.d.ts +13 -2
  144. package/dist/web-session-authorization.js +10 -3
  145. package/dist/web-session-authorization.js.map +1 -1
  146. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -33,14 +33,14 @@ import { scopedFleetConfigEnvironment } from "./web-experimental.js";
33
33
  import { readAgentFile } from "./agent-file-read.js";
34
34
  import { ASSET_UPLOAD_MAX_BYTES, receivePreview, sendAssetInParts, writePreview } from "./web-asset-parts.js";
35
35
  import { LOGIN_FLOWS } from "./login-flows.js";
36
- import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
36
+ import { ProgressAccumulator, isSearchProgram, summarizeProgress } from "./tool-progress.js";
37
37
  import { assembleSystemPrompt, resolveWorkflowText } from "./prompt-file-ref.js";
38
38
  import { isCrossInstanceVisibility } from "./cross-instance-notice.js";
39
39
  import { ContextGuardian } from "./context-guardian.js";
40
40
  import { IpcServer } from "./channel/ipc-bridge.js";
41
41
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
42
42
  import { MessageBus } from "./channel/message-bus.js";
43
- import { shellQuote, UnsupportedCliError, ResumeBlockedError } from "./backend/types.js";
43
+ import { shellQuote, KiroCompatibilityError, UnsupportedCliError, ResumeBlockedError } from "./backend/types.js";
44
44
  import { getTmuxSession } from "./config.js";
45
45
  import { routeToolCall } from "./channel/tool-router.js";
46
46
  import { parseReplyButtons, replyButtonsFallbackText } from "./reply-buttons.js";
@@ -60,7 +60,8 @@ import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
60
60
  import { deliveryContentDigest } from "./delivery-outbox.js";
61
61
  import { consumedWatches } from "./delivery-consumed-watch.js";
62
62
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
63
- import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
63
+ import { markerConsumed, scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
64
+ import { QUEUED_HANDOFF_MAX_REDELIVERIES, QueuedHandoffStore } from "./queued-handoffs.js";
64
65
  import { measureSyncWork, noteSyncWork } from "./sync-work-attribution.js";
65
66
  import { TranscriptDeltaReader } from "./transcript-delta-reader.js";
66
67
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
@@ -151,6 +152,8 @@ export const HANDOFF_RECHECK_MS = 1_000;
151
152
  export const WAIT_STOP_POLL_MS = 250;
152
153
  /** #2770: human messages a cancel dropped are told in one notice, this long after the last of them. */
153
154
  export const CANCELLED_HUMANS_NOTICE_MS = 1_000;
155
+ /** #3030: a queued hand-off's watch outlives a long turn (the record outlives the watch in any case). */
156
+ export const QUEUED_HANDOFF_WATCH_LIFETIME_MS = 12 * 60 * 60_000;
154
157
  /**
155
158
  * An instance publishing more than this many execution-state edges inside the
156
159
  * window is flapping; its edges stop being logged one by one (#1116). One
@@ -343,7 +346,8 @@ export function shellCommandLabel(command) {
343
346
  if (!program)
344
347
  return "";
345
348
  const isBareWord = (t) => !!t && /^[A-Za-z][A-Za-z0-9_-]*$/.test(t);
346
- const label = isBareWord(tokens[1]) ? `${program} ${tokens[1]}` : program;
349
+ // A search program's next word is what it searches for, not a subcommand: `grep <token>` keeps only `grep`.
350
+ const label = isBareWord(tokens[1]) && !isSearchProgram(program) ? `${program} ${tokens[1]}` : program;
347
351
  return label.length > 40 ? `${label.slice(0, 39)}…` : label;
348
352
  }
349
353
  export class PaneStateMachine {
@@ -1580,6 +1584,12 @@ export class Daemon extends EventEmitter {
1580
1584
  pendingWhatsNew = null;
1581
1585
  pasteQueueDepth = 0;
1582
1586
  /** #2770: a person's messages a cancel dropped before they reached the CLI, told together (noteCancelledHuman). */
1587
+ /** #3030: people's messages a CLI never took (or that a restart left unverifiable), told once per batch. */
1588
+ unsettledHandoffs = { "not-taken": [], unverifiable: [] };
1589
+ unsettledHandoffsTimer = null;
1590
+ /** #3030: since when (monotonic) the CLI has been idle without a break; null while it is not. */
1591
+ instanceIdleSinceMono = null;
1592
+ queuedHandoffStore = null;
1583
1593
  cancelledHumans = [];
1584
1594
  cancelledHumansTimer = null;
1585
1595
  firstDeliveryDelay = new FirstDeliveryDelay();
@@ -1991,6 +2001,9 @@ export class Daemon extends EventEmitter {
1991
2001
  this.publishWorkingState("turn_closed");
1992
2002
  };
1993
2003
  }
2004
+ /** Register each issued HTTP credential with the same-process lifecycle owner. */
2005
+ agentCredentialIssuer;
2006
+ setAgentCredentialIssuer(issue) { this.agentCredentialIssuer = issue; }
1994
2007
  /** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
1995
2008
  setDeliveryOutboxPort(port) {
1996
2009
  this.deliveryOutbox = port;
@@ -2127,6 +2140,243 @@ export class Daemon extends EventEmitter {
2127
2140
  logger: this.logger,
2128
2141
  });
2129
2142
  }
2143
+ handoffStore() {
2144
+ return this.queuedHandoffStore ??= new QueuedHandoffStore(this.instanceDir);
2145
+ }
2146
+ /** #3030: the CLI that holds a queued hand-off; a record is settled only by a later one. */
2147
+ handoffHolder() { return `${this.bootId}:${this.spawnGeneration}`; }
2148
+ /**
2149
+ * #3030: before the paste. A hand-off into a busy Claude Code / Codex queue gets its record — the transcript
2150
+ * checkpoint before the paste, the unique leading marker and what a redelivery needs — or, for a redelivery written
2151
+ * as an ordinary idle submission, its record moves its checkpoint here, so a crash right after this paste is still
2152
+ * judged from the transcript that will show it. A write that did not keep a record runs as before.
2153
+ */
2154
+ async recordQueuedHandoff(formatted, handingOff, verdict, durableAttempt, humanVerificationId) {
2155
+ const track = verdict.trackHandoff;
2156
+ if (!track)
2157
+ return;
2158
+ if (!handingOff && !track.id) {
2159
+ // An earlier attempt of this delivery was a hand-off; this one is an idle submission.
2160
+ if (verdict.handoff)
2161
+ this.dropQueuedHandoff(verdict);
2162
+ return;
2163
+ }
2164
+ const backend = this.config.backend ?? this.backend?.binaryName ?? "";
2165
+ if (!["claude-code", "codex"].includes(backend))
2166
+ return;
2167
+ const marker = durableAttempt ? `[agend-delivery-id:${durableAttempt.deliveryId}]`
2168
+ : humanVerificationId ? `[agend-human-id:${humanVerificationId}]` : null;
2169
+ if (!marker || !formatted.startsWith(marker))
2170
+ return;
2171
+ let checkpoint = verdict.transcriptCheckpoint ?? null;
2172
+ if (!checkpoint) {
2173
+ try {
2174
+ checkpoint = await this.transcriptMonitor?.reconciliationCheckpoint() ?? null;
2175
+ }
2176
+ catch (err) {
2177
+ this.logger.debug({ err }, "Could not checkpoint the transcript before a queued hand-off");
2178
+ }
2179
+ }
2180
+ if (!checkpoint)
2181
+ return; // nothing could judge it later: it runs as before (✅ at the hand-off)
2182
+ const entry = {
2183
+ id: track.id ?? randomUUID(), marker, backend, path: checkpoint.path, offset: checkpoint.offset,
2184
+ content: track.content, meta: track.meta, holder: this.handoffHolder(), createdAt: Date.now(),
2185
+ redeliveries: track.redeliveries ?? 0,
2186
+ };
2187
+ try {
2188
+ const dropped = this.handoffStore().put(entry);
2189
+ if (handingOff)
2190
+ verdict.handoff = entry;
2191
+ for (const old of dropped)
2192
+ this.logger.warn({ handoff: old.id, createdAt: old.createdAt }, "Queued hand-off record dropped: too many on record");
2193
+ }
2194
+ catch (err) {
2195
+ this.logger.warn({ err }, "Could not record a queued hand-off; it runs as before (✅ at the hand-off)");
2196
+ }
2197
+ }
2198
+ /**
2199
+ * #3030: after pushChannelMessage's write. A hand-off on record whose paste went out is watched (✅ when a turn
2200
+ * takes it); one that was never written goes. A redelivery that ended as an ordinary submission is done; one that a
2201
+ * cancel dropped goes too (a cancel is the person's); one stopped before its write stays for the next CLI.
2202
+ */
2203
+ afterTrackedWrite(meta, verdict, sent, deliveryEpoch) {
2204
+ try {
2205
+ // A stale epoch with writes still allowed is a cancel (the person's); a stop fences writes too, and keeps records.
2206
+ const cancelled = !this.isDeliveryEpochCurrent(deliveryEpoch) && !this.deliveryWritesStopping;
2207
+ if (verdict.handoff) {
2208
+ if (sent || verdict.paneWriteStarted)
2209
+ this.watchQueuedHandoff(verdict.handoff);
2210
+ else if (!verdict.trackHandoff?.id || cancelled)
2211
+ this.dropQueuedHandoff(verdict);
2212
+ return;
2213
+ }
2214
+ const id = meta.handoff_redelivery;
2215
+ if (!id)
2216
+ return;
2217
+ if (sent || verdict.paneWriteStarted || cancelled) {
2218
+ this.handoffStore().remove(id);
2219
+ this.logger.info({ handoff: id, sent, proof: verdict.proof ?? null }, "Queued hand-off redelivered as an ordinary submission");
2220
+ }
2221
+ }
2222
+ catch (err) {
2223
+ this.logger.warn({ err }, "Could not update the queued hand-off record after its write");
2224
+ }
2225
+ }
2226
+ redeliveryStillOurs(meta) {
2227
+ const holder = this.handoffHolder();
2228
+ if (meta.handoff_holder !== holder)
2229
+ return false;
2230
+ try {
2231
+ return this.handoffStore().get(meta.handoff_redelivery)?.holder === holder;
2232
+ }
2233
+ catch {
2234
+ return false;
2235
+ }
2236
+ }
2237
+ dropQueuedHandoff(verdict) {
2238
+ const entry = verdict.handoff;
2239
+ verdict.handoff = undefined;
2240
+ if (!entry)
2241
+ return;
2242
+ try {
2243
+ this.handoffStore().remove(entry.id);
2244
+ }
2245
+ catch (err) {
2246
+ this.logger.warn({ err, handoff: entry.id }, "Could not remove a queued hand-off record");
2247
+ }
2248
+ }
2249
+ /**
2250
+ * #3030: the hand-off's CLI is the current one: watch its transcript for the turn that takes it (✅ then), and for an
2251
+ * idle CLI that never did (reported). A stop ends the watch; the record stays for the next CLI to settle.
2252
+ */
2253
+ watchQueuedHandoff(entry) {
2254
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, epoch = this.deliveryEpoch;
2255
+ const started = consumedWatches.start({
2256
+ deliveryId: `handoff:${entry.id}`,
2257
+ attemptNo: entry.redeliveries,
2258
+ backend: entry.backend,
2259
+ path: entry.path,
2260
+ offset: entry.offset,
2261
+ marker: entry.marker,
2262
+ // A cancel or stop moves the epoch: the watch ends quietly (a cancel cleared the record, a stop leaves it).
2263
+ current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch
2264
+ && epoch === this.deliveryEpoch,
2265
+ consumed: (via) => this.settleQueuedHandoff(entry, "taken", `transcript:${via}`),
2266
+ idleForMs: () => this.instanceIdleSinceMono === null ? null : performance.now() - this.instanceIdleSinceMono,
2267
+ notTaken: () => this.settleQueuedHandoff(entry, "not-taken", "transcript:absent-while-idle"),
2268
+ // A queued message waits for the running turn, however long that is.
2269
+ lifetimeMs: QUEUED_HANDOFF_WATCH_LIFETIME_MS,
2270
+ logger: this.logger,
2271
+ });
2272
+ if (!started) {
2273
+ // No watch (the fleet's cap): ✅ as before; the record still lets a restart judge it.
2274
+ const status = channelStatus(entry.meta);
2275
+ if (status)
2276
+ this.emit("message_confirmed", status);
2277
+ }
2278
+ }
2279
+ /** #3030: a record's end — taken (✅), or never taken / not judgeable (❌, told once per batch). */
2280
+ settleQueuedHandoff(entry, outcome, proof) {
2281
+ try {
2282
+ this.handoffStore().remove(entry.id);
2283
+ }
2284
+ catch (err) {
2285
+ this.logger.warn({ err, handoff: entry.id }, "Could not remove a settled queued hand-off record");
2286
+ }
2287
+ const meta = entry.meta;
2288
+ const status = channelStatus(meta);
2289
+ if (outcome === "taken") {
2290
+ this.logger.info({ handoff: entry.id, proof, redeliveries: entry.redeliveries }, "Queued hand-off taken by the CLI");
2291
+ if (status)
2292
+ this.emit("message_confirmed", status);
2293
+ return;
2294
+ }
2295
+ this.logger.warn({ handoff: entry.id, outcome, proof, redeliveries: entry.redeliveries }, "Queued hand-off was not taken by the CLI");
2296
+ if (meta.from_instance) {
2297
+ this.reportCrossInstanceDeliveryFailure(meta, { reached: true, phase: "handoff-not-consumed", proof: outcome === "not-taken" ? "not-taken" : "unverifiable" });
2298
+ return;
2299
+ }
2300
+ if (!status)
2301
+ return;
2302
+ this.unsettledHandoffs[outcome].push({ chatId: status.chatId, messageId: status.messageId, ...(status.threadId ? { threadId: status.threadId } : {}) });
2303
+ if (this.unsettledHandoffsTimer)
2304
+ clearTimeout(this.unsettledHandoffsTimer);
2305
+ this.unsettledHandoffsTimer = setTimeout(() => {
2306
+ this.unsettledHandoffsTimer = null;
2307
+ for (const reason of ["not-taken", "unverifiable"]) {
2308
+ const messages = this.unsettledHandoffs[reason].splice(0);
2309
+ if (messages.length)
2310
+ this.emit("human_handoffs_unsettled", { reason, messages });
2311
+ }
2312
+ }, CANCELLED_HUMANS_NOTICE_MS);
2313
+ this.unsettledHandoffsTimer.unref?.();
2314
+ }
2315
+ /**
2316
+ * #3030: right after a CLI came up — the records left by CLIs that are gone. Their queue went with them, so each is
2317
+ * judged by its own transcript from its checkpoint: taken there (✅, nothing pasted); proven not taken (the whole
2318
+ * delta read, the marker absent or only enqueued) → redelivered once, with the same marker, through the ordinary
2319
+ * queue; anything that cannot be judged → never pasted, reported. Ownership is evidence: never pasted twice.
2320
+ */
2321
+ async reconcileQueuedHandoffs() {
2322
+ let entries;
2323
+ try {
2324
+ entries = this.handoffStore().list().filter(e => e.holder !== this.handoffHolder());
2325
+ }
2326
+ catch (err) {
2327
+ this.logger.warn({ err }, "Could not read the queued hand-off records");
2328
+ return;
2329
+ }
2330
+ if (!entries.length)
2331
+ return;
2332
+ // Captured before any await: a reconciliation that outlives its CLI can never claim the next CLI's holder.
2333
+ const holder = this.handoffHolder();
2334
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, epoch = this.deliveryEpoch;
2335
+ const fresh = () => spawn === this.spawnGeneration && fence === this.launchFenceEpoch && epoch === this.deliveryEpoch
2336
+ && !this.deliveryWritesStopping;
2337
+ let redelivered = 0, taken = 0, unverifiable = 0;
2338
+ for (const entry of entries) {
2339
+ const kind = await scanTranscriptForDeliveryMarker(entry.path, entry.offset, entry.backend, { marker: entry.marker });
2340
+ // This CLI is gone already, or a cancel came: the next CLI settles them (a cancel cleared them).
2341
+ if (!fresh())
2342
+ return;
2343
+ // …and the record is still the one that was read (another reconciliation or a cancel did not change it meanwhile).
2344
+ let current;
2345
+ try {
2346
+ current = this.handoffStore().get(entry.id);
2347
+ }
2348
+ catch {
2349
+ current = undefined;
2350
+ }
2351
+ if (!current || current.holder !== entry.holder || current.redeliveries !== entry.redeliveries)
2352
+ continue;
2353
+ if (markerConsumed(kind)) {
2354
+ taken++;
2355
+ this.settleQueuedHandoff(entry, "taken", `restart-transcript:${kind}`);
2356
+ continue;
2357
+ }
2358
+ if (kind === "unavailable" || entry.redeliveries >= QUEUED_HANDOFF_MAX_REDELIVERIES) {
2359
+ unverifiable++;
2360
+ this.settleQueuedHandoff(entry, "unverifiable", kind === "unavailable" ? "restart-transcript:unavailable" : "redelivery-limit");
2361
+ continue;
2362
+ }
2363
+ // Proven not taken: the CLI that queued it is gone and its transcript, read to the end, never took it.
2364
+ const next = { ...entry, holder, redeliveries: entry.redeliveries + 1 };
2365
+ try {
2366
+ this.handoffStore().put(next);
2367
+ }
2368
+ catch (err) {
2369
+ this.logger.warn({ err, handoff: entry.id }, "Could not update a queued hand-off record; not redelivered now");
2370
+ continue;
2371
+ }
2372
+ redelivered++;
2373
+ // The person's receipt was settled by the first write: the redelivery carries its marker, not its receipt binding.
2374
+ const { human_receipt_id: _r, human_owner_key: _o, human_daemon_owner: _d, human_wire_sha256: _w, ...meta } = entry.meta;
2375
+ this.pushChannelMessage(entry.content, { ...meta, handoff_redelivery: entry.id,
2376
+ handoff_redeliveries: String(next.redeliveries), handoff_holder: next.holder }, undefined, epoch);
2377
+ }
2378
+ this.logger.info({ instance: this.name, redelivered, taken, unverifiable, proof: "transcript" }, "Queued hand-offs left by the previous CLI settled");
2379
+ }
2130
2380
  finishDurableSubmission(delivery, verdict) {
2131
2381
  // Legacy backends may accept a pane write without enough pane evidence to
2132
2382
  // claim submission. Keep that row inspectable instead of recording a false
@@ -2635,6 +2885,8 @@ export class Daemon extends EventEmitter {
2635
2885
  this.startErrorMonitor();
2636
2886
  }
2637
2887
  this.startInstanceStateMonitor();
2888
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
2889
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
2638
2890
  this.logger.info(`${this.name} ready`);
2639
2891
  }
2640
2892
  /**
@@ -3109,8 +3361,6 @@ export class Daemon extends EventEmitter {
3109
3361
  scheduleNext();
3110
3362
  return;
3111
3363
  }
3112
- // The CLI owns the MCP server process, so the daemon can only observe it.
3113
- measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
3114
3364
  // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
3115
3365
  const cliLabel = this.backend?.binaryName ?? "CLI";
3116
3366
  const healthTmux = this.tmux;
@@ -3131,6 +3381,9 @@ export class Daemon extends EventEmitter {
3131
3381
  if (discardStaleHealth())
3132
3382
  return;
3133
3383
  if (paneStatus?.alive) {
3384
+ // A CLI shutdown also closes its MCP children. Revive tools only for
3385
+ // a positively live pane, never while its exit is being classified.
3386
+ measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
3134
3387
  // Capture only a known primary while it lives. No extra tmux call: PID comes
3135
3388
  // from this same status read. Fast/unobserved deaths remain ordinary crashes.
3136
3389
  const key = this.oomSessionKey();
@@ -3177,6 +3430,29 @@ export class Daemon extends EventEmitter {
3177
3430
  return;
3178
3431
  }
3179
3432
  }
3433
+ // tmux marks the PTY dead when its fd closes, before SIGCHLD can fill
3434
+ // pane_dead_status. Claude /exit (and any other CLI) can hit this gap.
3435
+ // Give the status four bounded re-probes; missing is never exit zero.
3436
+ if (paneStatus && !paneStatus.alive && paneStatus.exitCode === undefined) {
3437
+ for (let attempt = 0; attempt < 4; attempt++) {
3438
+ await new Promise(resolve => setTimeout(resolve, 250));
3439
+ if (discardStaleHealth())
3440
+ return;
3441
+ const observed = await healthTmux.getPaneStatus();
3442
+ if (discardStaleHealth())
3443
+ return;
3444
+ if (observed)
3445
+ paneStatus = observed;
3446
+ if (paneStatus.alive || paneStatus.exitCode !== undefined)
3447
+ break;
3448
+ }
3449
+ if (paneStatus.alive) {
3450
+ this.windowQueryFailureTicks = 0;
3451
+ this.stormWindow?.noteWindowAlive(this.name);
3452
+ scheduleNext();
3453
+ return;
3454
+ }
3455
+ }
3180
3456
  // paneStatus === null → window gone entirely (e.g. tmux server crash)
3181
3457
  // paneStatus.alive === false → pane dead, exit code available
3182
3458
  const exitCode = paneStatus?.exitCode;
@@ -3360,6 +3636,8 @@ export class Daemon extends EventEmitter {
3360
3636
  try {
3361
3637
  await this.spawnClaudeWindow();
3362
3638
  this.setProcessStatus("running");
3639
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
3640
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
3363
3641
  this.logger.info("Recovered from background session conflict");
3364
3642
  this.emit("crash_respawn", this.name);
3365
3643
  }
@@ -3519,6 +3797,8 @@ export class Daemon extends EventEmitter {
3519
3797
  catch { /* may not exist */ }
3520
3798
  }
3521
3799
  this.setProcessStatus("running");
3800
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
3801
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
3522
3802
  this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
3523
3803
  this.emit("crash_respawn", this.name);
3524
3804
  // Emit the per-instance audit event while the storm is still active
@@ -4215,6 +4495,14 @@ export class Daemon extends EventEmitter {
4215
4495
  // (Completion paths clear via the guard's onComplete; a cancel with no
4216
4496
  // later idle edge would otherwise leave the marker behind.)
4217
4497
  clearInFlightTurnMarker(this.instanceDir);
4498
+ // #3030: what the person cancelled is not kept for a redelivery (nor reported as not taken).
4499
+ try {
4500
+ for (const entry of this.handoffStore().list())
4501
+ this.handoffStore().remove(entry.id);
4502
+ }
4503
+ catch (err) {
4504
+ this.logger.warn({ err }, "Could not clear queued hand-off records on cancel");
4505
+ }
4218
4506
  this.deliveryEpoch = fleetEpoch === undefined
4219
4507
  ? this.deliveryEpoch + 1
4220
4508
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -5511,6 +5799,10 @@ export class Daemon extends EventEmitter {
5511
5799
  if (snapshot.state === "working" && this.recoveryBootSettled
5512
5800
  && this.workingRecoveryInputOwner !== JSON.stringify(this.interactionOwner()))
5513
5801
  this.recoveryNativeWork = true;
5802
+ if (snapshot.state !== "idle")
5803
+ this.instanceIdleSinceMono = null;
5804
+ else if (this.instanceState !== "idle" || this.instanceIdleSinceMono === null)
5805
+ this.instanceIdleSinceMono = performance.now();
5514
5806
  this.instanceState = snapshot.state;
5515
5807
  this.acceptedInstanceSnapshot = { ...snapshot };
5516
5808
  // Reuse an already-captured pane and the backend's pure parser, never capture for the journal.
@@ -5622,7 +5914,7 @@ export class Daemon extends EventEmitter {
5622
5914
  * message can arrive mid-turn and sit queued, and marking at arrival would let
5623
5915
  * the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
5624
5916
  */
5625
- markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch) {
5917
+ markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch, queuedHandoff = false) {
5626
5918
  // Cancel may have interrupted an in-flight paste before its promise settled.
5627
5919
  // Its late success must not re-arm the turn that the user just cancelled.
5628
5920
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
@@ -5670,7 +5962,9 @@ export class Daemon extends EventEmitter {
5670
5962
  // during the continuation boots clean instead of chaining resumes.
5671
5963
  // #2544: a reply that settled before this arm already answered the person —
5672
5964
  // writing a marker would re-inject a continuation for a finished turn.
5673
- if (!meta.resumedContinuationOf && !armed.completionDelivered) {
5965
+ // #3030: a message only queued in the busy CLI has not started a turn: the interrupted turn is still the running one,
5966
+ // and a queued message lost to a restart is the hand-off record's to redeliver, not a continuation's.
5967
+ if (!meta.resumedContinuationOf && !armed.completionDelivered && !queuedHandoff) {
5674
5968
  const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
5675
5969
  const resumeCwd = this.config.working_directory ?? "";
5676
5970
  writeInFlightTurnMarker(this.instanceDir, {
@@ -7075,10 +7369,12 @@ export class Daemon extends EventEmitter {
7075
7369
  // Command name only — never the arguments. See shellCommandLabel.
7076
7370
  if (name === "Bash")
7077
7371
  return `$ ${shellCommandLabel(String(inp.command ?? ""))}`;
7372
+ // The tool name only — never the search pattern: it can be a literal secret someone is looking for, and this label
7373
+ // reaches the channel's progress line.
7078
7374
  if (name === "Glob")
7079
- return `Glob ${inp.pattern ?? ""}`;
7375
+ return "Glob";
7080
7376
  if (name === "Grep")
7081
- return `Grep ${inp.pattern ?? ""}`;
7377
+ return "Grep";
7082
7378
  if (name === "Agent")
7083
7379
  return "Agent (subagent)";
7084
7380
  if (name.startsWith("mcp__agend__"))
@@ -7449,6 +7745,13 @@ export class Daemon extends EventEmitter {
7449
7745
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7450
7746
  this.logger.info("Pending channel delivery dropped by user cancel");
7451
7747
  this.deferDurableDelivery(meta, "delivery epoch changed while queued");
7748
+ // #3030: a cancel is the person's — a queued redelivery it dropped is not redelivered later.
7749
+ if (meta.handoff_redelivery && !this.deliveryWritesStopping) {
7750
+ try {
7751
+ this.handoffStore().remove(meta.handoff_redelivery);
7752
+ }
7753
+ catch { /* the next CLI judges it */ }
7754
+ }
7452
7755
  return;
7453
7756
  }
7454
7757
  if (this.config.pre_task_command) {
@@ -7459,13 +7762,26 @@ export class Daemon extends EventEmitter {
7459
7762
  // #2800: with this real message, in the same paste — never a paste or turn of its own: after its delivery marker
7460
7763
  // (transcript proof anchors on it), before the message. A person's message with a verified receipt is pasted
7461
7764
  // byte for byte (its proof is the wire's hash), so the notice waits for the next delivery without one.
7462
- const whatsNew = this.pendingWhatsNew && !meta.human_receipt_id ? this.currentWhatsNew(this.pendingWhatsNew) : null;
7765
+ const whatsNew = this.pendingWhatsNew && !meta.human_receipt_id && !meta.handoff_redelivery
7766
+ ? this.currentWhatsNew(this.pendingWhatsNew) : null;
7463
7767
  const pasted = whatsNew ? withWhatsNew(formatted, whatsNew.notice) : formatted;
7464
7768
  const status = channelStatus(meta);
7465
7769
  // A fresh delivery begins a fresh turn — its bubble must not inherit
7466
7770
  // the previous turn's tool list.
7467
7771
  this.resetToolProgress();
7468
7772
  verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
7773
+ // #3030: a hand-off into the CLI's own queue stays on record until a turn takes it (redeliveries included).
7774
+ if (!content.startsWith("/raw ") || meta.from_instance) {
7775
+ const { handoff_redelivery: redeliveryOf, handoff_redeliveries: redeliveries, handoff_holder: _holder, ...kept } = meta;
7776
+ verdict.trackHandoff = { content, meta: kept,
7777
+ ...(redeliveryOf ? { id: redeliveryOf, redeliveries: Number(redeliveries) || 0 } : {}) };
7778
+ }
7779
+ // #3030: a redelivery is written only by the CLI it was issued for, and only while its record still says so —
7780
+ // one queued for a CLI that was replaced meanwhile is that next CLI's reconciliation's to judge again.
7781
+ if (meta.handoff_redelivery && !this.redeliveryStillOurs(meta)) {
7782
+ this.logger.info({ handoff: meta.handoff_redelivery }, "Queued hand-off redelivery dropped: its CLI was replaced before the write");
7783
+ return;
7784
+ }
7469
7785
  const durableAttempt = this.durableDeliveryAttempt(meta, content);
7470
7786
  if (durableAttempt === false) {
7471
7787
  this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
@@ -7488,10 +7804,11 @@ export class Daemon extends EventEmitter {
7488
7804
  if (whatsNew && (sent || verdict.paneWriteStarted))
7489
7805
  this.recordWhatsNewSent(whatsNew);
7490
7806
  }
7807
+ this.afterTrackedWrite(meta, verdict, !!sent, deliveryEpoch);
7491
7808
  if (sent) {
7492
7809
  delivered = true;
7493
7810
  this.finishDurableSubmission(durable, verdict);
7494
- this.markTurnStarted(meta, formatted, deliveryEpoch);
7811
+ this.markTurnStarted(meta, formatted, deliveryEpoch, !!verdict.handoff);
7495
7812
  }
7496
7813
  else if (durableAttempt && verdict.paneWriteStarted) {
7497
7814
  this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
@@ -9830,6 +10147,17 @@ export class Daemon extends EventEmitter {
9830
10147
  }
9831
10148
  }
9832
10149
  }
10150
+ // #3030: a hand-off into a busy CLI's own queue is on record before its paste (write-ahead): that queue lives only
10151
+ // in the CLI process, and a stop before a turn takes it would lose it without a trace.
10152
+ if (verdict.trackHandoff && !rawPaste) {
10153
+ await this.recordQueuedHandoff(formatted, handingOffToNativeQueue, verdict, durableAttempt, humanVerificationId);
10154
+ if (stillCurrent && !stillCurrent()) {
10155
+ if (!verdict.trackHandoff.id)
10156
+ this.dropQueuedHandoff(verdict);
10157
+ verdict.fenced = !verdict.durableBeginCommitted;
10158
+ return false;
10159
+ }
10160
+ }
9833
10161
  if (lookBeforePaste) {
9834
10162
  await lookBeforePaste();
9835
10163
  // The look awaited: this attempt is still this write's — the same fence, tmux and launch — or nothing is written.
@@ -10066,8 +10394,9 @@ export class Daemon extends EventEmitter {
10066
10394
  }
10067
10395
  const proof = await this.confirmSubmitted(signature, pasteBaseline);
10068
10396
  if (proof === "submitted") {
10069
- if (status)
10070
- this.emit("message_confirmed", status); // ✅ native queue accepted
10397
+ // ✅ native queue accepted — unless it is on record (#3030): then ✅ waits for the turn that takes it.
10398
+ if (status && !verdict.handoff)
10399
+ this.emit("message_confirmed", status);
10071
10400
  return true;
10072
10401
  }
10073
10402
  if (proof === "unverifiable") {
@@ -10099,8 +10428,8 @@ export class Daemon extends EventEmitter {
10099
10428
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
10100
10429
  verdict.phase = "best-effort-submission";
10101
10430
  verdict.proof = "unverified";
10102
- if (status)
10103
- this.emit("message_confirmed", status); // ✅ (best-effort)
10431
+ if (status && !verdict.handoff)
10432
+ this.emit("message_confirmed", status); // ✅ (best-effort; on record: when taken, #3030)
10104
10433
  return true;
10105
10434
  }
10106
10435
  // raw_paste has no unique marker and is not allowed to re-paste or
@@ -10115,6 +10444,10 @@ export class Daemon extends EventEmitter {
10115
10444
  // confirmation ladder. "stranded" is the case that used to be a silent
10116
10445
  // ✅ — the text was visible, so the old check called it delivered.
10117
10446
  this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
10447
+ // #3030: what follows is an idle submission (or a failure the caller reports): no longer a queued hand-off.
10448
+ if (verdict.handoff && !verdict.trackHandoff?.id)
10449
+ this.dropQueuedHandoff(verdict);
10450
+ verdict.handoff = undefined;
10118
10451
  if (windowId && this.controlClient && !(await this.controlClient.waitUntilIdle(windowId))) {
10119
10452
  verdict.phase = "native-queue-idle-wait";
10120
10453
  verdict.proof = "idle-wait-failed";
@@ -12513,7 +12846,12 @@ export class Daemon extends EventEmitter {
12513
12846
  || this.startupAborted || this.deliveryWritesStopping || this.runtimeMonitorsFrozen || this.healthCheckPaused)) {
12514
12847
  throw new Error("OOM recovery cancelled: the instance was stopped, paused or replaced while memory was unavailable");
12515
12848
  }
12516
- return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
12849
+ return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs).catch(err => {
12850
+ // Includes an in-place binary replacement during asynchronous launch preparation.
12851
+ if (err instanceof KiroCompatibilityError)
12852
+ this.emit("kiro_compatibility_hold", err.message);
12853
+ throw err;
12854
+ });
12517
12855
  };
12518
12856
  const ready = !this.spawnGate ? await enter() : await this.spawnGate.run({
12519
12857
  instanceName: this.name,
@@ -12736,6 +13074,11 @@ export class Daemon extends EventEmitter {
12736
13074
  this.startupAdmission?.();
12737
13075
  const launchFence = this.launchFenceEpoch;
12738
13076
  const backendConfig = this.buildBackendConfig();
13077
+ // A compatibility refusal is not a failed resume: no configuration, pin, history or pane may change.
13078
+ if (this.backend?.assertLaunchSupported) {
13079
+ this.backend.assertLaunchSupported(backendConfig);
13080
+ this.emit("kiro_compatibility_hold", null);
13081
+ }
12739
13082
  // #1633: decided before prev-instructions is first written — its absence is what says "never launched before".
12740
13083
  this.prepareWhatsNew(!existsSync(join(this.instanceDir, "prev-instructions")));
12741
13084
  // Record what this launch writes. #2787: a change is never announced in the pane; the CLI takes the new
@@ -12772,6 +13115,7 @@ export class Daemon extends EventEmitter {
12772
13115
  const agentTokenPath = join(this.instanceDir, "agent.token");
12773
13116
  const agentToken = randomBytes(32).toString("hex");
12774
13117
  const tokenWrite = writeSecretFile(agentTokenPath, agentToken);
13118
+ this.agentCredentialIssuer?.(agentToken);
12775
13119
  if (!tokenWrite.ok) {
12776
13120
  // Do not fail the spawn over it — an instance that cannot start is worse
12777
13121
  // than one whose token is readable. But say so, loudly: the whole point of