@songsid/agend 2.3.4-beta.6 → 2.3.4-beta.8

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 (111) hide show
  1. package/dist/agend-home.d.ts +13 -0
  2. package/dist/agend-home.js +42 -0
  3. package/dist/agend-home.js.map +1 -1
  4. package/dist/auth-api.js +2 -2
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/kiro-identity.d.ts +2 -0
  7. package/dist/backend/kiro-identity.js +13 -6
  8. package/dist/backend/kiro-identity.js.map +1 -1
  9. package/dist/backend/kiro.d.ts +3 -0
  10. package/dist/backend/kiro.js +57 -4
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/types.d.ts +6 -1
  13. package/dist/backend/types.js +3 -0
  14. package/dist/backend/types.js.map +1 -1
  15. package/dist/backend-availability.d.ts +6 -0
  16. package/dist/backend-availability.js +13 -5
  17. package/dist/backend-availability.js.map +1 -1
  18. package/dist/channel/mcp-tools.js +1 -1
  19. package/dist/channel/mcp-tools.js.map +1 -1
  20. package/dist/cli.js +4 -6
  21. package/dist/cli.js.map +1 -1
  22. package/dist/daemon.d.ts +40 -0
  23. package/dist/daemon.js +354 -15
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/delivery-consumed-watch.d.ts +23 -1
  26. package/dist/delivery-consumed-watch.js +30 -3
  27. package/dist/delivery-consumed-watch.js.map +1 -1
  28. package/dist/delivery-reconciliation.d.ts +7 -0
  29. package/dist/delivery-reconciliation.js +9 -1
  30. package/dist/delivery-reconciliation.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +33 -7
  32. package/dist/fleet-manager.js +196 -67
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/fleet-yaml-patch.d.ts +3 -0
  35. package/dist/fleet-yaml-patch.js +103 -0
  36. package/dist/fleet-yaml-patch.js.map +1 -0
  37. package/dist/general-knowledge/skills/concepts/SKILL.md +390 -0
  38. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  39. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  40. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  41. package/dist/instance-lifecycle.d.ts +2 -0
  42. package/dist/instance-lifecycle.js +38 -4
  43. package/dist/instance-lifecycle.js.map +1 -1
  44. package/dist/kiro-engine-status.d.ts +11 -0
  45. package/dist/kiro-engine-status.js +6 -0
  46. package/dist/kiro-engine-status.js.map +1 -1
  47. package/dist/kiro-resume-summary.d.ts +37 -0
  48. package/dist/kiro-resume-summary.js +85 -0
  49. package/dist/kiro-resume-summary.js.map +1 -0
  50. package/dist/locale.js +32 -0
  51. package/dist/locale.js.map +1 -1
  52. package/dist/login-controller.js +9 -1
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +2 -0
  55. package/dist/login-flows.js +5 -2
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/needs-you-hub.js +1 -1
  58. package/dist/needs-you-hub.js.map +1 -1
  59. package/dist/needs-you.d.ts +6 -0
  60. package/dist/needs-you.js +8 -2
  61. package/dist/needs-you.js.map +1 -1
  62. package/dist/package-preimage.js +27 -2
  63. package/dist/package-preimage.js.map +1 -1
  64. package/dist/queued-handoffs.d.ts +33 -0
  65. package/dist/queued-handoffs.js +75 -0
  66. package/dist/queued-handoffs.js.map +1 -0
  67. package/dist/reply-buttons-controller.js +10 -0
  68. package/dist/reply-buttons-controller.js.map +1 -1
  69. package/dist/stall-reporter.d.ts +21 -0
  70. package/dist/stall-reporter.js +82 -0
  71. package/dist/stall-reporter.js.map +1 -0
  72. package/dist/tmux-control.d.ts +2 -2
  73. package/dist/tmux-control.js +4 -2
  74. package/dist/tmux-control.js.map +1 -1
  75. package/dist/tmux-exec-helper.d.ts +6 -3
  76. package/dist/tmux-exec-helper.js +115 -53
  77. package/dist/tmux-exec-helper.js.map +1 -1
  78. package/dist/tool-permissions.d.ts +5 -2
  79. package/dist/tool-permissions.js +6 -5
  80. package/dist/tool-permissions.js.map +1 -1
  81. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  82. package/dist/tunnel/cloudflared-install.js +20 -1
  83. package/dist/tunnel/cloudflared-install.js.map +1 -1
  84. package/dist/tunnel/cloudflared.d.ts +8 -1
  85. package/dist/tunnel/cloudflared.js +34 -6
  86. package/dist/tunnel/cloudflared.js.map +1 -1
  87. package/dist/tunnel/lease.d.ts +3 -3
  88. package/dist/tunnel/lease.js +23 -19
  89. package/dist/tunnel/lease.js.map +1 -1
  90. package/dist/tunnel/manager.js +6 -7
  91. package/dist/tunnel/manager.js.map +1 -1
  92. package/dist/ui/panel-needs.js +4 -3
  93. package/dist/ui/shared/app-i18n.js +2 -2
  94. package/dist/ui/shared/app-needs.js +1 -1
  95. package/dist/ui/shared/app.css +16 -1
  96. package/dist/ui/shared/panel-view.js +266 -17
  97. package/dist/ui/shared/view-strings.js +26 -0
  98. package/dist/unattended-install.d.ts +1 -1
  99. package/dist/unattended-install.js +19 -5
  100. package/dist/unattended-install.js.map +1 -1
  101. package/dist/view-api.d.ts +15 -0
  102. package/dist/view-api.js +94 -33
  103. package/dist/view-api.js.map +1 -1
  104. package/dist/web-login.d.ts +4 -3
  105. package/dist/web-login.js +23 -14
  106. package/dist/web-login.js.map +1 -1
  107. package/dist/web-session.d.ts +5 -0
  108. package/dist/web-session.js +20 -8
  109. package/dist/web-session.js.map +1 -1
  110. package/launcher/agend-install.cjs +16 -2
  111. package/package.json +1 -1
package/dist/daemon.d.ts CHANGED
@@ -38,6 +38,8 @@ export declare const HANDOFF_RECHECK_MS = 1000;
38
38
  export declare const WAIT_STOP_POLL_MS = 250;
39
39
  /** #2770: human messages a cancel dropped are told in one notice, this long after the last of them. */
40
40
  export declare const CANCELLED_HUMANS_NOTICE_MS = 1000;
41
+ /** #3030: a queued hand-off's watch outlives a long turn (the record outlives the watch in any case). */
42
+ export declare const QUEUED_HANDOFF_WATCH_LIFETIME_MS: number;
41
43
  /**
42
44
  * An instance publishing more than this many execution-state edges inside the
43
45
  * window is flapping; its edges stop being logged one by one (#1116). One
@@ -806,6 +808,12 @@ export declare class Daemon extends EventEmitter {
806
808
  private pendingWhatsNew;
807
809
  private pasteQueueDepth;
808
810
  /** #2770: a person's messages a cancel dropped before they reached the CLI, told together (noteCancelledHuman). */
811
+ /** #3030: people's messages a CLI never took (or that a restart left unverifiable), told once per batch. */
812
+ private unsettledHandoffs;
813
+ private unsettledHandoffsTimer;
814
+ /** #3030: since when (monotonic) the CLI has been idle without a break; null while it is not. */
815
+ private instanceIdleSinceMono;
816
+ private queuedHandoffStore;
809
817
  private cancelledHumans;
810
818
  private cancelledHumansTimer;
811
819
  private firstDeliveryDelay;
@@ -1036,6 +1044,38 @@ export declare class Daemon extends EventEmitter {
1036
1044
  * watch belongs to this CLI: a stop, pause or respawn ends it (restart reconciliation owns the row after that).
1037
1045
  */
1038
1046
  private startConsumedWatch;
1047
+ private handoffStore;
1048
+ /** #3030: the CLI that holds a queued hand-off; a record is settled only by a later one. */
1049
+ private handoffHolder;
1050
+ /**
1051
+ * #3030: before the paste. A hand-off into a busy Claude Code / Codex queue gets its record — the transcript
1052
+ * checkpoint before the paste, the unique leading marker and what a redelivery needs — or, for a redelivery written
1053
+ * as an ordinary idle submission, its record moves its checkpoint here, so a crash right after this paste is still
1054
+ * judged from the transcript that will show it. A write that did not keep a record runs as before.
1055
+ */
1056
+ private recordQueuedHandoff;
1057
+ /**
1058
+ * #3030: after pushChannelMessage's write. A hand-off on record whose paste went out is watched (✅ when a turn
1059
+ * takes it); one that was never written goes. A redelivery that ended as an ordinary submission is done; one that a
1060
+ * cancel dropped goes too (a cancel is the person's); one stopped before its write stays for the next CLI.
1061
+ */
1062
+ private afterTrackedWrite;
1063
+ private redeliveryStillOurs;
1064
+ private dropQueuedHandoff;
1065
+ /**
1066
+ * #3030: the hand-off's CLI is the current one: watch its transcript for the turn that takes it (✅ then), and for an
1067
+ * idle CLI that never did (reported). A stop ends the watch; the record stays for the next CLI to settle.
1068
+ */
1069
+ private watchQueuedHandoff;
1070
+ /** #3030: a record's end — taken (✅), or never taken / not judgeable (❌, told once per batch). */
1071
+ private settleQueuedHandoff;
1072
+ /**
1073
+ * #3030: right after a CLI came up — the records left by CLIs that are gone. Their queue went with them, so each is
1074
+ * judged by its own transcript from its checkpoint: taken there (✅, nothing pasted); proven not taken (the whole
1075
+ * delta read, the marker absent or only enqueued) → redelivered once, with the same marker, through the ordinary
1076
+ * queue; anything that cannot be judged → never pasted, reported. Ownership is evidence: never pasted twice.
1077
+ */
1078
+ private reconcileQueuedHandoffs;
1039
1079
  private finishDurableSubmission;
1040
1080
  /**
1041
1081
  * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
package/dist/daemon.js CHANGED
@@ -40,7 +40,7 @@ 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
@@ -1580,6 +1583,12 @@ export class Daemon extends EventEmitter {
1580
1583
  pendingWhatsNew = null;
1581
1584
  pasteQueueDepth = 0;
1582
1585
  /** #2770: a person's messages a cancel dropped before they reached the CLI, told together (noteCancelledHuman). */
1586
+ /** #3030: people's messages a CLI never took (or that a restart left unverifiable), told once per batch. */
1587
+ unsettledHandoffs = { "not-taken": [], unverifiable: [] };
1588
+ unsettledHandoffsTimer = null;
1589
+ /** #3030: since when (monotonic) the CLI has been idle without a break; null while it is not. */
1590
+ instanceIdleSinceMono = null;
1591
+ queuedHandoffStore = null;
1583
1592
  cancelledHumans = [];
1584
1593
  cancelledHumansTimer = null;
1585
1594
  firstDeliveryDelay = new FirstDeliveryDelay();
@@ -2127,6 +2136,243 @@ export class Daemon extends EventEmitter {
2127
2136
  logger: this.logger,
2128
2137
  });
2129
2138
  }
2139
+ handoffStore() {
2140
+ return this.queuedHandoffStore ??= new QueuedHandoffStore(this.instanceDir);
2141
+ }
2142
+ /** #3030: the CLI that holds a queued hand-off; a record is settled only by a later one. */
2143
+ handoffHolder() { return `${this.bootId}:${this.spawnGeneration}`; }
2144
+ /**
2145
+ * #3030: before the paste. A hand-off into a busy Claude Code / Codex queue gets its record — the transcript
2146
+ * checkpoint before the paste, the unique leading marker and what a redelivery needs — or, for a redelivery written
2147
+ * as an ordinary idle submission, its record moves its checkpoint here, so a crash right after this paste is still
2148
+ * judged from the transcript that will show it. A write that did not keep a record runs as before.
2149
+ */
2150
+ async recordQueuedHandoff(formatted, handingOff, verdict, durableAttempt, humanVerificationId) {
2151
+ const track = verdict.trackHandoff;
2152
+ if (!track)
2153
+ return;
2154
+ if (!handingOff && !track.id) {
2155
+ // An earlier attempt of this delivery was a hand-off; this one is an idle submission.
2156
+ if (verdict.handoff)
2157
+ this.dropQueuedHandoff(verdict);
2158
+ return;
2159
+ }
2160
+ const backend = this.config.backend ?? this.backend?.binaryName ?? "";
2161
+ if (!["claude-code", "codex"].includes(backend))
2162
+ return;
2163
+ const marker = durableAttempt ? `[agend-delivery-id:${durableAttempt.deliveryId}]`
2164
+ : humanVerificationId ? `[agend-human-id:${humanVerificationId}]` : null;
2165
+ if (!marker || !formatted.startsWith(marker))
2166
+ return;
2167
+ let checkpoint = verdict.transcriptCheckpoint ?? null;
2168
+ if (!checkpoint) {
2169
+ try {
2170
+ checkpoint = await this.transcriptMonitor?.reconciliationCheckpoint() ?? null;
2171
+ }
2172
+ catch (err) {
2173
+ this.logger.debug({ err }, "Could not checkpoint the transcript before a queued hand-off");
2174
+ }
2175
+ }
2176
+ if (!checkpoint)
2177
+ return; // nothing could judge it later: it runs as before (✅ at the hand-off)
2178
+ const entry = {
2179
+ id: track.id ?? randomUUID(), marker, backend, path: checkpoint.path, offset: checkpoint.offset,
2180
+ content: track.content, meta: track.meta, holder: this.handoffHolder(), createdAt: Date.now(),
2181
+ redeliveries: track.redeliveries ?? 0,
2182
+ };
2183
+ try {
2184
+ const dropped = this.handoffStore().put(entry);
2185
+ if (handingOff)
2186
+ verdict.handoff = entry;
2187
+ for (const old of dropped)
2188
+ this.logger.warn({ handoff: old.id, createdAt: old.createdAt }, "Queued hand-off record dropped: too many on record");
2189
+ }
2190
+ catch (err) {
2191
+ this.logger.warn({ err }, "Could not record a queued hand-off; it runs as before (✅ at the hand-off)");
2192
+ }
2193
+ }
2194
+ /**
2195
+ * #3030: after pushChannelMessage's write. A hand-off on record whose paste went out is watched (✅ when a turn
2196
+ * takes it); one that was never written goes. A redelivery that ended as an ordinary submission is done; one that a
2197
+ * cancel dropped goes too (a cancel is the person's); one stopped before its write stays for the next CLI.
2198
+ */
2199
+ afterTrackedWrite(meta, verdict, sent, deliveryEpoch) {
2200
+ try {
2201
+ // A stale epoch with writes still allowed is a cancel (the person's); a stop fences writes too, and keeps records.
2202
+ const cancelled = !this.isDeliveryEpochCurrent(deliveryEpoch) && !this.deliveryWritesStopping;
2203
+ if (verdict.handoff) {
2204
+ if (sent || verdict.paneWriteStarted)
2205
+ this.watchQueuedHandoff(verdict.handoff);
2206
+ else if (!verdict.trackHandoff?.id || cancelled)
2207
+ this.dropQueuedHandoff(verdict);
2208
+ return;
2209
+ }
2210
+ const id = meta.handoff_redelivery;
2211
+ if (!id)
2212
+ return;
2213
+ if (sent || verdict.paneWriteStarted || cancelled) {
2214
+ this.handoffStore().remove(id);
2215
+ this.logger.info({ handoff: id, sent, proof: verdict.proof ?? null }, "Queued hand-off redelivered as an ordinary submission");
2216
+ }
2217
+ }
2218
+ catch (err) {
2219
+ this.logger.warn({ err }, "Could not update the queued hand-off record after its write");
2220
+ }
2221
+ }
2222
+ redeliveryStillOurs(meta) {
2223
+ const holder = this.handoffHolder();
2224
+ if (meta.handoff_holder !== holder)
2225
+ return false;
2226
+ try {
2227
+ return this.handoffStore().get(meta.handoff_redelivery)?.holder === holder;
2228
+ }
2229
+ catch {
2230
+ return false;
2231
+ }
2232
+ }
2233
+ dropQueuedHandoff(verdict) {
2234
+ const entry = verdict.handoff;
2235
+ verdict.handoff = undefined;
2236
+ if (!entry)
2237
+ return;
2238
+ try {
2239
+ this.handoffStore().remove(entry.id);
2240
+ }
2241
+ catch (err) {
2242
+ this.logger.warn({ err, handoff: entry.id }, "Could not remove a queued hand-off record");
2243
+ }
2244
+ }
2245
+ /**
2246
+ * #3030: the hand-off's CLI is the current one: watch its transcript for the turn that takes it (✅ then), and for an
2247
+ * idle CLI that never did (reported). A stop ends the watch; the record stays for the next CLI to settle.
2248
+ */
2249
+ watchQueuedHandoff(entry) {
2250
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, epoch = this.deliveryEpoch;
2251
+ const started = consumedWatches.start({
2252
+ deliveryId: `handoff:${entry.id}`,
2253
+ attemptNo: entry.redeliveries,
2254
+ backend: entry.backend,
2255
+ path: entry.path,
2256
+ offset: entry.offset,
2257
+ marker: entry.marker,
2258
+ // A cancel or stop moves the epoch: the watch ends quietly (a cancel cleared the record, a stop leaves it).
2259
+ current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch
2260
+ && epoch === this.deliveryEpoch,
2261
+ consumed: (via) => this.settleQueuedHandoff(entry, "taken", `transcript:${via}`),
2262
+ idleForMs: () => this.instanceIdleSinceMono === null ? null : performance.now() - this.instanceIdleSinceMono,
2263
+ notTaken: () => this.settleQueuedHandoff(entry, "not-taken", "transcript:absent-while-idle"),
2264
+ // A queued message waits for the running turn, however long that is.
2265
+ lifetimeMs: QUEUED_HANDOFF_WATCH_LIFETIME_MS,
2266
+ logger: this.logger,
2267
+ });
2268
+ if (!started) {
2269
+ // No watch (the fleet's cap): ✅ as before; the record still lets a restart judge it.
2270
+ const status = channelStatus(entry.meta);
2271
+ if (status)
2272
+ this.emit("message_confirmed", status);
2273
+ }
2274
+ }
2275
+ /** #3030: a record's end — taken (✅), or never taken / not judgeable (❌, told once per batch). */
2276
+ settleQueuedHandoff(entry, outcome, proof) {
2277
+ try {
2278
+ this.handoffStore().remove(entry.id);
2279
+ }
2280
+ catch (err) {
2281
+ this.logger.warn({ err, handoff: entry.id }, "Could not remove a settled queued hand-off record");
2282
+ }
2283
+ const meta = entry.meta;
2284
+ const status = channelStatus(meta);
2285
+ if (outcome === "taken") {
2286
+ this.logger.info({ handoff: entry.id, proof, redeliveries: entry.redeliveries }, "Queued hand-off taken by the CLI");
2287
+ if (status)
2288
+ this.emit("message_confirmed", status);
2289
+ return;
2290
+ }
2291
+ this.logger.warn({ handoff: entry.id, outcome, proof, redeliveries: entry.redeliveries }, "Queued hand-off was not taken by the CLI");
2292
+ if (meta.from_instance) {
2293
+ this.reportCrossInstanceDeliveryFailure(meta, { reached: true, phase: "handoff-not-consumed", proof: outcome === "not-taken" ? "not-taken" : "unverifiable" });
2294
+ return;
2295
+ }
2296
+ if (!status)
2297
+ return;
2298
+ this.unsettledHandoffs[outcome].push({ chatId: status.chatId, messageId: status.messageId, ...(status.threadId ? { threadId: status.threadId } : {}) });
2299
+ if (this.unsettledHandoffsTimer)
2300
+ clearTimeout(this.unsettledHandoffsTimer);
2301
+ this.unsettledHandoffsTimer = setTimeout(() => {
2302
+ this.unsettledHandoffsTimer = null;
2303
+ for (const reason of ["not-taken", "unverifiable"]) {
2304
+ const messages = this.unsettledHandoffs[reason].splice(0);
2305
+ if (messages.length)
2306
+ this.emit("human_handoffs_unsettled", { reason, messages });
2307
+ }
2308
+ }, CANCELLED_HUMANS_NOTICE_MS);
2309
+ this.unsettledHandoffsTimer.unref?.();
2310
+ }
2311
+ /**
2312
+ * #3030: right after a CLI came up — the records left by CLIs that are gone. Their queue went with them, so each is
2313
+ * judged by its own transcript from its checkpoint: taken there (✅, nothing pasted); proven not taken (the whole
2314
+ * delta read, the marker absent or only enqueued) → redelivered once, with the same marker, through the ordinary
2315
+ * queue; anything that cannot be judged → never pasted, reported. Ownership is evidence: never pasted twice.
2316
+ */
2317
+ async reconcileQueuedHandoffs() {
2318
+ let entries;
2319
+ try {
2320
+ entries = this.handoffStore().list().filter(e => e.holder !== this.handoffHolder());
2321
+ }
2322
+ catch (err) {
2323
+ this.logger.warn({ err }, "Could not read the queued hand-off records");
2324
+ return;
2325
+ }
2326
+ if (!entries.length)
2327
+ return;
2328
+ // Captured before any await: a reconciliation that outlives its CLI can never claim the next CLI's holder.
2329
+ const holder = this.handoffHolder();
2330
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, epoch = this.deliveryEpoch;
2331
+ const fresh = () => spawn === this.spawnGeneration && fence === this.launchFenceEpoch && epoch === this.deliveryEpoch
2332
+ && !this.deliveryWritesStopping;
2333
+ let redelivered = 0, taken = 0, unverifiable = 0;
2334
+ for (const entry of entries) {
2335
+ const kind = await scanTranscriptForDeliveryMarker(entry.path, entry.offset, entry.backend, { marker: entry.marker });
2336
+ // This CLI is gone already, or a cancel came: the next CLI settles them (a cancel cleared them).
2337
+ if (!fresh())
2338
+ return;
2339
+ // …and the record is still the one that was read (another reconciliation or a cancel did not change it meanwhile).
2340
+ let current;
2341
+ try {
2342
+ current = this.handoffStore().get(entry.id);
2343
+ }
2344
+ catch {
2345
+ current = undefined;
2346
+ }
2347
+ if (!current || current.holder !== entry.holder || current.redeliveries !== entry.redeliveries)
2348
+ continue;
2349
+ if (markerConsumed(kind)) {
2350
+ taken++;
2351
+ this.settleQueuedHandoff(entry, "taken", `restart-transcript:${kind}`);
2352
+ continue;
2353
+ }
2354
+ if (kind === "unavailable" || entry.redeliveries >= QUEUED_HANDOFF_MAX_REDELIVERIES) {
2355
+ unverifiable++;
2356
+ this.settleQueuedHandoff(entry, "unverifiable", kind === "unavailable" ? "restart-transcript:unavailable" : "redelivery-limit");
2357
+ continue;
2358
+ }
2359
+ // Proven not taken: the CLI that queued it is gone and its transcript, read to the end, never took it.
2360
+ const next = { ...entry, holder, redeliveries: entry.redeliveries + 1 };
2361
+ try {
2362
+ this.handoffStore().put(next);
2363
+ }
2364
+ catch (err) {
2365
+ this.logger.warn({ err, handoff: entry.id }, "Could not update a queued hand-off record; not redelivered now");
2366
+ continue;
2367
+ }
2368
+ redelivered++;
2369
+ // The person's receipt was settled by the first write: the redelivery carries its marker, not its receipt binding.
2370
+ const { human_receipt_id: _r, human_owner_key: _o, human_daemon_owner: _d, human_wire_sha256: _w, ...meta } = entry.meta;
2371
+ this.pushChannelMessage(entry.content, { ...meta, handoff_redelivery: entry.id,
2372
+ handoff_redeliveries: String(next.redeliveries), handoff_holder: next.holder }, undefined, epoch);
2373
+ }
2374
+ this.logger.info({ instance: this.name, redelivered, taken, unverifiable, proof: "transcript" }, "Queued hand-offs left by the previous CLI settled");
2375
+ }
2130
2376
  finishDurableSubmission(delivery, verdict) {
2131
2377
  // Legacy backends may accept a pane write without enough pane evidence to
2132
2378
  // claim submission. Keep that row inspectable instead of recording a false
@@ -2635,6 +2881,8 @@ export class Daemon extends EventEmitter {
2635
2881
  this.startErrorMonitor();
2636
2882
  }
2637
2883
  this.startInstanceStateMonitor();
2884
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
2885
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
2638
2886
  this.logger.info(`${this.name} ready`);
2639
2887
  }
2640
2888
  /**
@@ -3109,8 +3357,6 @@ export class Daemon extends EventEmitter {
3109
3357
  scheduleNext();
3110
3358
  return;
3111
3359
  }
3112
- // The CLI owns the MCP server process, so the daemon can only observe it.
3113
- measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
3114
3360
  // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
3115
3361
  const cliLabel = this.backend?.binaryName ?? "CLI";
3116
3362
  const healthTmux = this.tmux;
@@ -3131,6 +3377,9 @@ export class Daemon extends EventEmitter {
3131
3377
  if (discardStaleHealth())
3132
3378
  return;
3133
3379
  if (paneStatus?.alive) {
3380
+ // A CLI shutdown also closes its MCP children. Revive tools only for
3381
+ // a positively live pane, never while its exit is being classified.
3382
+ measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
3134
3383
  // Capture only a known primary while it lives. No extra tmux call: PID comes
3135
3384
  // from this same status read. Fast/unobserved deaths remain ordinary crashes.
3136
3385
  const key = this.oomSessionKey();
@@ -3177,6 +3426,29 @@ export class Daemon extends EventEmitter {
3177
3426
  return;
3178
3427
  }
3179
3428
  }
3429
+ // tmux marks the PTY dead when its fd closes, before SIGCHLD can fill
3430
+ // pane_dead_status. Claude /exit (and any other CLI) can hit this gap.
3431
+ // Give the status four bounded re-probes; missing is never exit zero.
3432
+ if (paneStatus && !paneStatus.alive && paneStatus.exitCode === undefined) {
3433
+ for (let attempt = 0; attempt < 4; attempt++) {
3434
+ await new Promise(resolve => setTimeout(resolve, 250));
3435
+ if (discardStaleHealth())
3436
+ return;
3437
+ const observed = await healthTmux.getPaneStatus();
3438
+ if (discardStaleHealth())
3439
+ return;
3440
+ if (observed)
3441
+ paneStatus = observed;
3442
+ if (paneStatus.alive || paneStatus.exitCode !== undefined)
3443
+ break;
3444
+ }
3445
+ if (paneStatus.alive) {
3446
+ this.windowQueryFailureTicks = 0;
3447
+ this.stormWindow?.noteWindowAlive(this.name);
3448
+ scheduleNext();
3449
+ return;
3450
+ }
3451
+ }
3180
3452
  // paneStatus === null → window gone entirely (e.g. tmux server crash)
3181
3453
  // paneStatus.alive === false → pane dead, exit code available
3182
3454
  const exitCode = paneStatus?.exitCode;
@@ -3360,6 +3632,8 @@ export class Daemon extends EventEmitter {
3360
3632
  try {
3361
3633
  await this.spawnClaudeWindow();
3362
3634
  this.setProcessStatus("running");
3635
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
3636
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
3363
3637
  this.logger.info("Recovered from background session conflict");
3364
3638
  this.emit("crash_respawn", this.name);
3365
3639
  }
@@ -3519,6 +3793,8 @@ export class Daemon extends EventEmitter {
3519
3793
  catch { /* may not exist */ }
3520
3794
  }
3521
3795
  this.setProcessStatus("running");
3796
+ // #3030: messages a gone CLI still had queued — judged by its transcript, redelivered once or reported.
3797
+ void this.reconcileQueuedHandoffs().catch(err => this.logger.warn({ err }, "Queued hand-off reconciliation failed"));
3522
3798
  this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
3523
3799
  this.emit("crash_respawn", this.name);
3524
3800
  // Emit the per-instance audit event while the storm is still active
@@ -4215,6 +4491,14 @@ export class Daemon extends EventEmitter {
4215
4491
  // (Completion paths clear via the guard's onComplete; a cancel with no
4216
4492
  // later idle edge would otherwise leave the marker behind.)
4217
4493
  clearInFlightTurnMarker(this.instanceDir);
4494
+ // #3030: what the person cancelled is not kept for a redelivery (nor reported as not taken).
4495
+ try {
4496
+ for (const entry of this.handoffStore().list())
4497
+ this.handoffStore().remove(entry.id);
4498
+ }
4499
+ catch (err) {
4500
+ this.logger.warn({ err }, "Could not clear queued hand-off records on cancel");
4501
+ }
4218
4502
  this.deliveryEpoch = fleetEpoch === undefined
4219
4503
  ? this.deliveryEpoch + 1
4220
4504
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -5511,6 +5795,10 @@ export class Daemon extends EventEmitter {
5511
5795
  if (snapshot.state === "working" && this.recoveryBootSettled
5512
5796
  && this.workingRecoveryInputOwner !== JSON.stringify(this.interactionOwner()))
5513
5797
  this.recoveryNativeWork = true;
5798
+ if (snapshot.state !== "idle")
5799
+ this.instanceIdleSinceMono = null;
5800
+ else if (this.instanceState !== "idle" || this.instanceIdleSinceMono === null)
5801
+ this.instanceIdleSinceMono = performance.now();
5514
5802
  this.instanceState = snapshot.state;
5515
5803
  this.acceptedInstanceSnapshot = { ...snapshot };
5516
5804
  // Reuse an already-captured pane and the backend's pure parser, never capture for the journal.
@@ -5622,7 +5910,7 @@ export class Daemon extends EventEmitter {
5622
5910
  * message can arrive mid-turn and sit queued, and marking at arrival would let
5623
5911
  * the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
5624
5912
  */
5625
- markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch) {
5913
+ markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch, queuedHandoff = false) {
5626
5914
  // Cancel may have interrupted an in-flight paste before its promise settled.
5627
5915
  // Its late success must not re-arm the turn that the user just cancelled.
5628
5916
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
@@ -5670,7 +5958,9 @@ export class Daemon extends EventEmitter {
5670
5958
  // during the continuation boots clean instead of chaining resumes.
5671
5959
  // #2544: a reply that settled before this arm already answered the person —
5672
5960
  // writing a marker would re-inject a continuation for a finished turn.
5673
- if (!meta.resumedContinuationOf && !armed.completionDelivered) {
5961
+ // #3030: a message only queued in the busy CLI has not started a turn: the interrupted turn is still the running one,
5962
+ // and a queued message lost to a restart is the hand-off record's to redeliver, not a continuation's.
5963
+ if (!meta.resumedContinuationOf && !armed.completionDelivered && !queuedHandoff) {
5674
5964
  const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
5675
5965
  const resumeCwd = this.config.working_directory ?? "";
5676
5966
  writeInFlightTurnMarker(this.instanceDir, {
@@ -7449,6 +7739,13 @@ export class Daemon extends EventEmitter {
7449
7739
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
7450
7740
  this.logger.info("Pending channel delivery dropped by user cancel");
7451
7741
  this.deferDurableDelivery(meta, "delivery epoch changed while queued");
7742
+ // #3030: a cancel is the person's — a queued redelivery it dropped is not redelivered later.
7743
+ if (meta.handoff_redelivery && !this.deliveryWritesStopping) {
7744
+ try {
7745
+ this.handoffStore().remove(meta.handoff_redelivery);
7746
+ }
7747
+ catch { /* the next CLI judges it */ }
7748
+ }
7452
7749
  return;
7453
7750
  }
7454
7751
  if (this.config.pre_task_command) {
@@ -7459,13 +7756,26 @@ export class Daemon extends EventEmitter {
7459
7756
  // #2800: with this real message, in the same paste — never a paste or turn of its own: after its delivery marker
7460
7757
  // (transcript proof anchors on it), before the message. A person's message with a verified receipt is pasted
7461
7758
  // 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;
7759
+ const whatsNew = this.pendingWhatsNew && !meta.human_receipt_id && !meta.handoff_redelivery
7760
+ ? this.currentWhatsNew(this.pendingWhatsNew) : null;
7463
7761
  const pasted = whatsNew ? withWhatsNew(formatted, whatsNew.notice) : formatted;
7464
7762
  const status = channelStatus(meta);
7465
7763
  // A fresh delivery begins a fresh turn — its bubble must not inherit
7466
7764
  // the previous turn's tool list.
7467
7765
  this.resetToolProgress();
7468
7766
  verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
7767
+ // #3030: a hand-off into the CLI's own queue stays on record until a turn takes it (redeliveries included).
7768
+ if (!content.startsWith("/raw ") || meta.from_instance) {
7769
+ const { handoff_redelivery: redeliveryOf, handoff_redeliveries: redeliveries, handoff_holder: _holder, ...kept } = meta;
7770
+ verdict.trackHandoff = { content, meta: kept,
7771
+ ...(redeliveryOf ? { id: redeliveryOf, redeliveries: Number(redeliveries) || 0 } : {}) };
7772
+ }
7773
+ // #3030: a redelivery is written only by the CLI it was issued for, and only while its record still says so —
7774
+ // one queued for a CLI that was replaced meanwhile is that next CLI's reconciliation's to judge again.
7775
+ if (meta.handoff_redelivery && !this.redeliveryStillOurs(meta)) {
7776
+ this.logger.info({ handoff: meta.handoff_redelivery }, "Queued hand-off redelivery dropped: its CLI was replaced before the write");
7777
+ return;
7778
+ }
7469
7779
  const durableAttempt = this.durableDeliveryAttempt(meta, content);
7470
7780
  if (durableAttempt === false) {
7471
7781
  this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
@@ -7488,10 +7798,11 @@ export class Daemon extends EventEmitter {
7488
7798
  if (whatsNew && (sent || verdict.paneWriteStarted))
7489
7799
  this.recordWhatsNewSent(whatsNew);
7490
7800
  }
7801
+ this.afterTrackedWrite(meta, verdict, !!sent, deliveryEpoch);
7491
7802
  if (sent) {
7492
7803
  delivered = true;
7493
7804
  this.finishDurableSubmission(durable, verdict);
7494
- this.markTurnStarted(meta, formatted, deliveryEpoch);
7805
+ this.markTurnStarted(meta, formatted, deliveryEpoch, !!verdict.handoff);
7495
7806
  }
7496
7807
  else if (durableAttempt && verdict.paneWriteStarted) {
7497
7808
  this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
@@ -9830,6 +10141,17 @@ export class Daemon extends EventEmitter {
9830
10141
  }
9831
10142
  }
9832
10143
  }
10144
+ // #3030: a hand-off into a busy CLI's own queue is on record before its paste (write-ahead): that queue lives only
10145
+ // in the CLI process, and a stop before a turn takes it would lose it without a trace.
10146
+ if (verdict.trackHandoff && !rawPaste) {
10147
+ await this.recordQueuedHandoff(formatted, handingOffToNativeQueue, verdict, durableAttempt, humanVerificationId);
10148
+ if (stillCurrent && !stillCurrent()) {
10149
+ if (!verdict.trackHandoff.id)
10150
+ this.dropQueuedHandoff(verdict);
10151
+ verdict.fenced = !verdict.durableBeginCommitted;
10152
+ return false;
10153
+ }
10154
+ }
9833
10155
  if (lookBeforePaste) {
9834
10156
  await lookBeforePaste();
9835
10157
  // The look awaited: this attempt is still this write's — the same fence, tmux and launch — or nothing is written.
@@ -10066,8 +10388,9 @@ export class Daemon extends EventEmitter {
10066
10388
  }
10067
10389
  const proof = await this.confirmSubmitted(signature, pasteBaseline);
10068
10390
  if (proof === "submitted") {
10069
- if (status)
10070
- this.emit("message_confirmed", status); // ✅ native queue accepted
10391
+ // ✅ native queue accepted — unless it is on record (#3030): then ✅ waits for the turn that takes it.
10392
+ if (status && !verdict.handoff)
10393
+ this.emit("message_confirmed", status);
10071
10394
  return true;
10072
10395
  }
10073
10396
  if (proof === "unverifiable") {
@@ -10099,8 +10422,8 @@ export class Daemon extends EventEmitter {
10099
10422
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
10100
10423
  verdict.phase = "best-effort-submission";
10101
10424
  verdict.proof = "unverified";
10102
- if (status)
10103
- this.emit("message_confirmed", status); // ✅ (best-effort)
10425
+ if (status && !verdict.handoff)
10426
+ this.emit("message_confirmed", status); // ✅ (best-effort; on record: when taken, #3030)
10104
10427
  return true;
10105
10428
  }
10106
10429
  // raw_paste has no unique marker and is not allowed to re-paste or
@@ -10115,8 +10438,14 @@ export class Daemon extends EventEmitter {
10115
10438
  // confirmation ladder. "stranded" is the case that used to be a silent
10116
10439
  // ✅ — the text was visible, so the old check called it delivered.
10117
10440
  this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
10118
- if (windowId && this.controlClient) {
10119
- await this.controlClient.waitUntilIdle(windowId);
10441
+ // #3030: what follows is an idle submission (or a failure the caller reports): no longer a queued hand-off.
10442
+ if (verdict.handoff && !verdict.trackHandoff?.id)
10443
+ this.dropQueuedHandoff(verdict);
10444
+ verdict.handoff = undefined;
10445
+ if (windowId && this.controlClient && !(await this.controlClient.waitUntilIdle(windowId))) {
10446
+ verdict.phase = "native-queue-idle-wait";
10447
+ verdict.proof = "idle-wait-failed";
10448
+ return false;
10120
10449
  }
10121
10450
  // Re-read before doing anything: the CLI had a whole turn to flush its
10122
10451
  // queue while we waited, and the right recovery differs per outcome.
@@ -12511,7 +12840,12 @@ export class Daemon extends EventEmitter {
12511
12840
  || this.startupAborted || this.deliveryWritesStopping || this.runtimeMonitorsFrozen || this.healthCheckPaused)) {
12512
12841
  throw new Error("OOM recovery cancelled: the instance was stopped, paused or replaced while memory was unavailable");
12513
12842
  }
12514
- return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
12843
+ return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs).catch(err => {
12844
+ // Includes an in-place binary replacement during asynchronous launch preparation.
12845
+ if (err instanceof KiroCompatibilityError)
12846
+ this.emit("kiro_compatibility_hold", err.message);
12847
+ throw err;
12848
+ });
12515
12849
  };
12516
12850
  const ready = !this.spawnGate ? await enter() : await this.spawnGate.run({
12517
12851
  instanceName: this.name,
@@ -12734,6 +13068,11 @@ export class Daemon extends EventEmitter {
12734
13068
  this.startupAdmission?.();
12735
13069
  const launchFence = this.launchFenceEpoch;
12736
13070
  const backendConfig = this.buildBackendConfig();
13071
+ // A compatibility refusal is not a failed resume: no configuration, pin, history or pane may change.
13072
+ if (this.backend?.assertLaunchSupported) {
13073
+ this.backend.assertLaunchSupported(backendConfig);
13074
+ this.emit("kiro_compatibility_hold", null);
13075
+ }
12737
13076
  // #1633: decided before prev-instructions is first written — its absence is what says "never launched before".
12738
13077
  this.prepareWhatsNew(!existsSync(join(this.instanceDir, "prev-instructions")));
12739
13078
  // Record what this launch writes. #2787: a change is never announced in the pane; the CLI takes the new