@songsid/agend 2.3.1 → 2.3.2-beta.2

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 (160) hide show
  1. package/dist/authority/authority-state.d.ts +4 -0
  2. package/dist/authority/authority-state.js +16 -10
  3. package/dist/authority/authority-state.js.map +1 -1
  4. package/dist/authority/session-authorizations.d.ts +7 -1
  5. package/dist/authority/session-authorizations.js +6 -4
  6. package/dist/authority/session-authorizations.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +19 -6
  8. package/dist/authority/web-authority.js +22 -8
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/channel/adapters/discord.d.ts +2 -0
  11. package/dist/channel/adapters/discord.js +33 -3
  12. package/dist/channel/adapters/discord.js.map +1 -1
  13. package/dist/channel/mcp-tools.js +5 -5
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/channel/types.d.ts +11 -0
  16. package/dist/config-validator.js +40 -3
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.d.ts +34 -3
  19. package/dist/daemon.js +227 -46
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/delivery-outbox.d.ts +12 -1
  22. package/dist/delivery-outbox.js +67 -15
  23. package/dist/delivery-outbox.js.map +1 -1
  24. package/dist/doctor.d.ts +3 -0
  25. package/dist/doctor.js +9 -0
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/event-log.d.ts +5 -0
  28. package/dist/event-log.js +13 -0
  29. package/dist/event-log.js.map +1 -1
  30. package/dist/fleet-manager.d.ts +140 -0
  31. package/dist/fleet-manager.js +414 -29
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/fleet-process-handlers.d.ts +1 -0
  34. package/dist/fleet-process-handlers.js +9 -1
  35. package/dist/fleet-process-handlers.js.map +1 -1
  36. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
  37. package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
  38. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
  39. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
  40. package/dist/guest-seen-people.d.ts +38 -0
  41. package/dist/guest-seen-people.js +78 -0
  42. package/dist/guest-seen-people.js.map +1 -0
  43. package/dist/instance-config-impact.js +4 -1
  44. package/dist/instance-config-impact.js.map +1 -1
  45. package/dist/instance-lifecycle.d.ts +1 -0
  46. package/dist/instance-lifecycle.js +16 -1
  47. package/dist/instance-lifecycle.js.map +1 -1
  48. package/dist/locale.js +38 -10
  49. package/dist/locale.js.map +1 -1
  50. package/dist/mac-auto-restore.d.ts +117 -0
  51. package/dist/mac-auto-restore.js +294 -0
  52. package/dist/mac-auto-restore.js.map +1 -0
  53. package/dist/outbound-schemas.d.ts +2 -0
  54. package/dist/outbound-schemas.js +2 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-web-gateway.js +9 -1
  57. package/dist/public-web-gateway.js.map +1 -1
  58. package/dist/settings-api.d.ts +26 -0
  59. package/dist/settings-api.js +107 -1
  60. package/dist/settings-api.js.map +1 -1
  61. package/dist/settings-change.js +37 -3
  62. package/dist/settings-change.js.map +1 -1
  63. package/dist/settings-effect.js +5 -2
  64. package/dist/settings-effect.js.map +1 -1
  65. package/dist/start-attempt.d.ts +9 -5
  66. package/dist/start-attempt.js +27 -8
  67. package/dist/start-attempt.js.map +1 -1
  68. package/dist/start-failure-chat.d.ts +4 -0
  69. package/dist/start-failure-chat.js +23 -6
  70. package/dist/start-failure-chat.js.map +1 -1
  71. package/dist/start-failure-notice.d.ts +4 -0
  72. package/dist/start-failure-notice.js +8 -0
  73. package/dist/start-failure-notice.js.map +1 -1
  74. package/dist/start-failure.d.ts +25 -0
  75. package/dist/start-failure.js +75 -2
  76. package/dist/start-failure.js.map +1 -1
  77. package/dist/turn-reply-guard.d.ts +25 -0
  78. package/dist/turn-reply-guard.js +111 -12
  79. package/dist/turn-reply-guard.js.map +1 -1
  80. package/dist/types.d.ts +18 -1
  81. package/dist/ui/backend-signin.js +5 -2
  82. package/dist/ui/chat-ambient.js +1 -1
  83. package/dist/ui/chat-effects.js +4 -4
  84. package/dist/ui/chat-render.js +5 -2
  85. package/dist/ui/chat-strings.js +2 -2
  86. package/dist/ui/chat-thread.js +2 -2
  87. package/dist/ui/first-run.js +2 -2
  88. package/dist/ui/fleet-canvas-view.js +114 -0
  89. package/dist/ui/fleet-canvas.js +15 -77
  90. package/dist/ui/fleet-office-editor.js +80 -5
  91. package/dist/ui/fleet-office-layout.js +197 -0
  92. package/dist/ui/fleet-office-model.js +6 -3
  93. package/dist/ui/fleet-office-overview.js +167 -0
  94. package/dist/ui/fleet-office-pack.d.ts +39 -0
  95. package/dist/ui/fleet-office-pack.js +240 -29
  96. package/dist/ui/fleet-office-skin.js +140 -29
  97. package/dist/ui/fleet-office-store.js +84 -5
  98. package/dist/ui/fleet-office-view.js +154 -0
  99. package/dist/ui/fleet-office-zones.js +95 -33
  100. package/dist/ui/fleet-office.js +779 -140
  101. package/dist/ui/fleet-usage.js +7 -6
  102. package/dist/ui/panel-chat.js +1 -1
  103. package/dist/ui/panel-details.js +2 -2
  104. package/dist/ui/panel-fleet.js +11 -77
  105. package/dist/ui/panel-log.js +2 -2
  106. package/dist/ui/panel-settings.js +578 -163
  107. package/dist/ui/search-dialog.js +26 -7
  108. package/dist/ui/settings-appearance.js +82 -29
  109. package/dist/ui/settings-apply.js +42 -7
  110. package/dist/ui/settings-dialogs.js +68 -22
  111. package/dist/ui/settings-draft.js +51 -0
  112. package/dist/ui/settings-guests.js +244 -78
  113. package/dist/ui/settings-learn.js +2 -2
  114. package/dist/ui/settings-model.js +2 -0
  115. package/dist/ui/settings-registry.js +142 -0
  116. package/dist/ui/settings-strings.js +102 -44
  117. package/dist/ui/settings-web-authority.js +34 -23
  118. package/dist/ui/shared/app-i18n.js +14 -0
  119. package/dist/ui/shared/app-nav.js +10 -5
  120. package/dist/ui/shared/app-prefs.js +80 -2
  121. package/dist/ui/shared/app-route.js +39 -5
  122. package/dist/ui/shared/app-shell.js +59 -4
  123. package/dist/ui/shared/app-stream.js +2 -1
  124. package/dist/ui/shared/app.css +254 -17
  125. package/dist/ui/shared/app.js +3 -0
  126. package/dist/ui/shared/effect-stock-trust.js +1 -1
  127. package/dist/ui/shared/identity-color.js +4 -4
  128. package/dist/ui/shared/signin.js +2 -2
  129. package/dist/ui/shared/ui-dialog.js +6 -1
  130. package/dist/ui/shared/ui-icons.js +2 -0
  131. package/dist/ui/shared/web-authorize.js +13 -12
  132. package/dist/web-api.d.ts +8 -2
  133. package/dist/web-api.js +160 -5
  134. package/dist/web-api.js.map +1 -1
  135. package/dist/web-auth.js +7 -1
  136. package/dist/web-auth.js.map +1 -1
  137. package/dist/web-experimental.d.ts +1 -1
  138. package/dist/web-experimental.js +1 -1
  139. package/dist/web-experimental.js.map +1 -1
  140. package/dist/web-mod-agent.js +2 -2
  141. package/dist/web-mod-agent.js.map +1 -1
  142. package/dist/web-office-skins.d.ts +108 -0
  143. package/dist/web-office-skins.js +298 -0
  144. package/dist/web-office-skins.js.map +1 -0
  145. package/dist/web-office-store.d.ts +8 -2
  146. package/dist/web-office-store.js +37 -7
  147. package/dist/web-office-store.js.map +1 -1
  148. package/dist/web-settings-patch.d.ts +6 -0
  149. package/dist/web-settings-patch.js +61 -4
  150. package/dist/web-settings-patch.js.map +1 -1
  151. package/dist/web-shell-routes.d.ts +4 -2
  152. package/dist/web-shell-routes.js +17 -7
  153. package/dist/web-shell-routes.js.map +1 -1
  154. package/dist/working-recovery.d.ts +104 -0
  155. package/dist/working-recovery.js +263 -0
  156. package/dist/working-recovery.js.map +1 -0
  157. package/dist/working-state-journal.d.ts +3 -1
  158. package/dist/working-state-journal.js +15 -2
  159. package/dist/working-state-journal.js.map +1 -1
  160. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -46,7 +46,7 @@ import { buildFleetInstructions } from "./instructions.js";
46
46
  import { formatHumanInboundMessage, prepareDeliveryWire, steerInboundEnvelope, BTW_INBOUND_PREFIX, humanBodyHash, humanOwnerObjectId } from "./human-delivery-receipts.js";
47
47
  import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
48
48
  import { agendMessageInInput, bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
49
- import { TurnReplyGuard } from "./turn-reply-guard.js";
49
+ import { TurnReplyGuard, formatReplyDropDetection, turnExpectsPersonReply } from "./turn-reply-guard.js";
50
50
  import { t } from "./locale.js";
51
51
  import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
52
52
  import { deliveryContentDigest } from "./delivery-outbox.js";
@@ -1245,6 +1245,8 @@ export class Daemon extends EventEmitter {
1245
1245
  * skipResume — the #1209 gate reads this, never the resettable flag.
1246
1246
  */
1247
1247
  bootSkippedResume = false;
1248
+ recoveryCrashLoopFresh = false;
1249
+ recoveryFreshChoice = false;
1248
1250
  startupAborted = false;
1249
1251
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
1250
1252
  dialogParkedSince = 0;
@@ -2203,6 +2205,7 @@ export class Daemon extends EventEmitter {
2203
2205
  }
2204
2206
  async startAdmitted() {
2205
2207
  this.assertSessionOomAdmission();
2208
+ this.recoveryBootWorkPending = true;
2206
2209
  ensureInstanceDir(this.instanceDir);
2207
2210
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
2208
2211
  this.logger.info(`Starting ${this.name}`);
@@ -2211,6 +2214,8 @@ export class Daemon extends EventEmitter {
2211
2214
  // even when the JSON is corrupt so it cannot remain forever.
2212
2215
  const crashStatePath = join(this.instanceDir, "crash-state.json");
2213
2216
  this.bootSkippedResume = false;
2217
+ this.recoveryCrashLoopFresh = false;
2218
+ this.recoveryFreshChoice = false;
2214
2219
  try {
2215
2220
  if (existsSync(crashStatePath)) {
2216
2221
  const content = readFileSync(crashStatePath, "utf-8");
@@ -2219,6 +2224,10 @@ export class Daemon extends EventEmitter {
2219
2224
  if (state?.resumeDisabled === true) {
2220
2225
  this.skipResume = true;
2221
2226
  this.bootSkippedResume = true;
2227
+ this.recoveryCrashLoopFresh = (state.reason === undefined || state.reason === "crash_loop")
2228
+ && Number.isInteger(state.crashesInWindow) && state.crashesInWindow >= 3
2229
+ && Number.isFinite(state.lastCrashAt) && state.lastCrashAt > 0;
2230
+ this.recoveryFreshChoice = !this.recoveryCrashLoopFresh;
2222
2231
  this.logger.warn("Previous crash loop detected — starting without resume");
2223
2232
  }
2224
2233
  }
@@ -2367,12 +2376,19 @@ export class Daemon extends EventEmitter {
2367
2376
  // #1209: conditional continuation for the turn interrupted by the last
2368
2377
  // restart, if the gates allow. Detached like the warmup notice; the gate
2369
2378
  // consumes its one-shot marker first and never throws into boot.
2370
- void this.maybeResumeInterruptedTurn().catch(() => { });
2379
+ const legacyResume = this.maybeResumeInterruptedTurn().catch(() => "unknown");
2371
2380
  // Warmup: wait for CLI idle, then trigger steering reload — but only when
2372
2381
  // the instructions actually changed since the agent last saw them.
2373
2382
  // Skipping the no-op reload saves 10-30s of agent time on every restart
2374
2383
  // where instructions are unchanged.
2375
- void this.runWarmupInstructionNotice();
2384
+ const warmup = this.runWarmupInstructionNotice();
2385
+ const recoveryOwner = JSON.stringify(this.interactionOwner());
2386
+ void Promise.allSettled([legacyResume, warmup]).then(() => {
2387
+ if (JSON.stringify(this.interactionOwner()) === recoveryOwner) {
2388
+ this.recoveryBootWorkPending = false;
2389
+ this.recoveryBootSettled = true;
2390
+ }
2391
+ });
2376
2392
  if (!this.config.lightweight) {
2377
2393
  // 3. Pipe-pane for prompt detection. Rotate first so a ballooned log from a
2378
2394
  // previous stuck splash (hundreds of MB of ANSI frames) is truncated before
@@ -3974,6 +3990,7 @@ export class Daemon extends EventEmitter {
3974
3990
  clearPendingDeliveries(fleetEpoch) {
3975
3991
  // FleetManager calls this synchronously before sendEscape. Cancel only the
3976
3992
  // current human turn; leave already-forwarded tool requests to settle.
3993
+ this.recoveryCancelled = true;
3977
3994
  this.turnReplyGuard.cancelByUser();
3978
3995
  // #1241: a cancelled turn never confirms — its next edge completes it as
3979
3996
  // intentional. Drop a pending confirmation so it cannot promote afterwards.
@@ -3985,6 +4002,7 @@ export class Daemon extends EventEmitter {
3985
4002
  this.deliveryEpoch = fleetEpoch === undefined
3986
4003
  ? this.deliveryEpoch + 1
3987
4004
  : Math.max(this.deliveryEpoch, fleetEpoch);
4005
+ this.cancelCapacityNudge("the user cancelled");
3988
4006
  this.publishWorkingState("cancelled");
3989
4007
  this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
3990
4008
  }
@@ -4703,6 +4721,88 @@ export class Daemon extends EventEmitter {
4703
4721
  getInteractionSnapshot() {
4704
4722
  return this.interactionObservation.snapshot(this.interactionOwner());
4705
4723
  }
4724
+ ownedWorkingSession = null;
4725
+ recoveryBootSettled = false;
4726
+ recoveryBootWorkPending = false;
4727
+ recoveryLegacyCovered = false;
4728
+ recoveryNativeWork = false;
4729
+ recoveryContinuationId = null;
4730
+ recoveryCancelled = false;
4731
+ workingRecoveryInputOwner = null;
4732
+ noteLegacyRestartReminder() { this.recoveryLegacyCovered = true; }
4733
+ getWorkingRecoveryState() {
4734
+ const observation = this.getWorkingStateObservation();
4735
+ const owner = JSON.stringify(observation.owner), ownsInput = this.workingRecoveryInputOwner === owner;
4736
+ return { owner, configuration: observation.configuration,
4737
+ sessionId: this.ownedWorkingSession, idle: (ownsInput || this.instanceState === "idle") && this.pasteQueueDepth === 0,
4738
+ paused: this.isPaused, stopped: this.isSessionOomStopped(), legacyPending: !this.recoveryBootSettled,
4739
+ legacyCovered: this.recoveryLegacyCovered, nativeWork: !ownsInput && this.recoveryNativeWork,
4740
+ freshChoice: this.recoveryFreshChoice || this.isNewSession && !this.bootSkippedResume, crashLoopFresh: this.recoveryCrashLoopFresh,
4741
+ cancelled: this.recoveryCancelled, background: this.workingBackgroundObservation };
4742
+ }
4743
+ /** Local system work: no user turn, channel reply target, wake or replay of an original request. */
4744
+ async deliverWorkingRecovery(content, delivery, admitted) {
4745
+ const tmux = this.tmux, owner = JSON.stringify(this.interactionOwner()), epoch = this.deliveryEpoch;
4746
+ const current = () => this.tmux === tmux && owner === JSON.stringify(this.interactionOwner())
4747
+ && this.deliveryEpoch === epoch && !this.deliveryWritesStopping && !this.runtimeMonitorsFrozen
4748
+ && !this.spawning && !this.startupAborted && !this.isPaused && !this.healthCheckPaused
4749
+ && this.processStatus === "running" && this.pasteQueueDepth === 0
4750
+ && (this.workingRecoveryInputOwner === owner || this.instanceState === "idle" && !this.recoveryNativeWork)
4751
+ && this.recoveryBootSettled && !this.recoveryCancelled && !this.isSessionOomStopped() && admitted();
4752
+ let begun = false;
4753
+ try {
4754
+ await this.paneWriteLock.run(async () => {
4755
+ if (!tmux || !current())
4756
+ return;
4757
+ const pane = await tmux.capturePane();
4758
+ const input = this.backend?.readInputRow?.(pane);
4759
+ if (!current() || !input || input.text !== "" || input.collapsedPastes !== 0 || input.queued
4760
+ || !this.paneAuthoritativelyIdle(pane) || this.inputTransientInPane(pane)
4761
+ || this.backend?.backgroundWorkRunning?.(pane) === true)
4762
+ return;
4763
+ if (await tmux.getPaneInputMode?.() !== "raw" || !current())
4764
+ return;
4765
+ const evidence = await this.durableAttemptEvidence(tmux.getWindowId(), false, false);
4766
+ if (!current())
4767
+ return;
4768
+ const signature = this.submissionSignature(content), baseline = this.paneEvidence(pane, signature);
4769
+ const attempt = { ...delivery, contentSha256: deliveryContentDigest(content) };
4770
+ if (!this.beginDurableDelivery(attempt, evidence))
4771
+ return;
4772
+ begun = true;
4773
+ // Own PTY input immediately produces a working observation. It must not
4774
+ // cancel this same fenced write; external queue/cancel/owner guards stay.
4775
+ this.workingRecoveryInputOwner = owner;
4776
+ // Conservatively commit possible input BEFORE paste, not merely before Enter.
4777
+ // A process death at any later boundary must never replay this continuation.
4778
+ if (!this.deliveryOutbox?.markEnterStarted(delivery.deliveryId, this.bootId, delivery.attemptNo))
4779
+ return;
4780
+ if (!current() || !await tmux.pasteBuffer(content, { guard: current }) || !current())
4781
+ return;
4782
+ await new Promise(resolve => setTimeout(resolve, NORMAL_ENTER_SETTLE_MS));
4783
+ if (!current() || !await this.sendDeliveryEnterAfterAvailability("working-recovery", current, attempt))
4784
+ return;
4785
+ const proof = await this.confirmSubmitted(signature, baseline);
4786
+ const committed = this.deliveryOutbox.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, proof === "submitted" ? "delivered" : "uncertain", "local continuation Enter attempted once; no replay");
4787
+ if (committed && proof === "submitted" && current()) {
4788
+ this.recoveryContinuationId = delivery.deliveryId;
4789
+ this.publishWorkingState("execution");
4790
+ }
4791
+ });
4792
+ }
4793
+ catch { /* The durable fence below owns ambiguity; never retry a begun write. */ }
4794
+ finally {
4795
+ if (this.workingRecoveryInputOwner === owner) {
4796
+ this.workingRecoveryInputOwner = null;
4797
+ if (this.tmux === tmux && owner === JSON.stringify(this.interactionOwner()))
4798
+ this.recoveryNativeWork = true;
4799
+ }
4800
+ if (begun)
4801
+ this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, "uncertain", "local continuation may have reached input; not replayed");
4802
+ else
4803
+ this.deliveryOutbox?.retryBeforeBegin(delivery.deliveryId, this.bootId, delivery.attemptNo, "working recovery prerequisite changed before input", 5_000);
4804
+ }
4805
+ }
4706
4806
  workingTurnDelivery = null;
4707
4807
  workingBackgroundObservation = null;
4708
4808
  /** Pure metadata read: never discovers sessions, captures a pane or reads backend files. */
@@ -4714,7 +4814,8 @@ export class Daemon extends EventEmitter {
4714
4814
  owner: this.interactionOwner(), backend,
4715
4815
  configuration: createHash("sha256").update(JSON.stringify([backend, this.config.working_directory, profile])).digest("hex"),
4716
4816
  executionState: this.processStatus === "running" ? this.isPaused ? "paused" : this.instanceState : null,
4717
- processStatus: this.getProcessStatus(), sessionId: null, backendTurnId: null,
4817
+ processStatus: this.getProcessStatus(), sessionId: this.ownedWorkingSession, backendTurnId: null,
4818
+ ...(this.recoveryContinuationId ? { recoveryDeliveryId: this.recoveryContinuationId } : {}),
4718
4819
  backgroundWork: this.workingBackgroundObservation,
4719
4820
  turn: turn ? { generation: turn.generation,
4720
4821
  deliveryId: this.workingTurnDelivery?.generation === turn.generation ? this.workingTurnDelivery.deliveryId : null,
@@ -5174,6 +5275,9 @@ export class Daemon extends EventEmitter {
5174
5275
  if (unblockedTurn && unblockedTurn.phase === "awaiting")
5175
5276
  this.maybeProxyReplyOnTurnEnd(pane);
5176
5277
  }
5278
+ if (snapshot.state === "working" && this.recoveryBootSettled
5279
+ && this.workingRecoveryInputOwner !== JSON.stringify(this.interactionOwner()))
5280
+ this.recoveryNativeWork = true;
5177
5281
  this.instanceState = snapshot.state;
5178
5282
  this.acceptedInstanceSnapshot = { ...snapshot };
5179
5283
  // Reuse an already-captured pane and the backend's pure parser, never capture for the journal.
@@ -5290,14 +5394,21 @@ export class Daemon extends EventEmitter {
5290
5394
  // Its late success must not re-arm the turn that the user just cancelled.
5291
5395
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
5292
5396
  return;
5293
- // Channel turns only. A cross-instance inbound (from_instance, empty
5294
- // chat_id) does not update lastChatId, so its proxy reply would land in
5295
- // whatever USER topic spoke to this instance last — the wrong audience for
5296
- // a task result, and a stale one (sol's review of #515).
5297
- // A web message on a fleet with no chat platform has no chat_id (its reply goes to the web chat through the fleet's
5298
- // web-only sink), but it is a person waiting for an answer all the same: the guard arms for it (alpha.2).
5299
- if (meta.from_instance || (!meta.chat_id && meta.source !== "web"))
5397
+ // Channel turns started by a person. Cross-instance, schedules (silent or
5398
+ // not), and AgEnD system notices must not expect a reply to a person (#2544):
5399
+ // a proxy or recovery notice would land in the last USER topic, or flag a
5400
+ // heartbeat that was told not to report. A web message on a fleet with no
5401
+ // chat platform has no chat_id (its reply goes to the web chat through the
5402
+ // fleet's web-only sink), but it is a person waiting for an answer all the
5403
+ // same: the guard arms for it (alpha.2).
5404
+ if (!turnExpectsPersonReply(meta)) {
5405
+ this.logger.debug({
5406
+ source: meta.source || null,
5407
+ user: meta.user || null,
5408
+ from_instance: meta.from_instance || null,
5409
+ }, "Reply guard skipped — turn was not started by a person");
5300
5410
  return;
5411
+ }
5301
5412
  // The last non-empty line of what we pasted: everything on screen after it
5302
5413
  // is the agent's own output.
5303
5414
  const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
@@ -5314,7 +5425,8 @@ export class Daemon extends EventEmitter {
5314
5425
  deliveredText,
5315
5426
  deliveredAt,
5316
5427
  }, this.pendingWork.lastInboundTimestamp());
5317
- this.workingTurnDelivery = { generation: this.turnReplyGuard.snapshot().generation, deliveryId: meta.delivery_id || null };
5428
+ const armed = this.turnReplyGuard.snapshot();
5429
+ this.workingTurnDelivery = { generation: armed.generation, deliveryId: meta.delivery_id || null };
5318
5430
  this.publishWorkingState("turn_started");
5319
5431
  // #1209: persist the in-flight turn with a seam checkpoint. A marker
5320
5432
  // that survives a restart is an interrupted turn by construction —
@@ -5323,7 +5435,9 @@ export class Daemon extends EventEmitter {
5323
5435
  // the interruption. Reads are windowed/bounded (see turn-resume.ts).
5324
5436
  // A resumed continuation never re-arms (P3a): episode-once means a crash
5325
5437
  // during the continuation boots clean instead of chaining resumes.
5326
- if (!meta.resumedContinuationOf) {
5438
+ // #2544: a reply that settled before this arm already answered the person —
5439
+ // writing a marker would re-inject a continuation for a finished turn.
5440
+ if (!meta.resumedContinuationOf && !armed.completionDelivered) {
5327
5441
  const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
5328
5442
  const resumeCwd = this.config.working_directory ?? "";
5329
5443
  writeInFlightTurnMarker(this.instanceDir, {
@@ -5371,6 +5485,7 @@ export class Daemon extends EventEmitter {
5371
5485
  const marker = consumeInFlightTurnMarker(this.instanceDir);
5372
5486
  if (!marker)
5373
5487
  return "none";
5488
+ this.recoveryLegacyCovered = true;
5374
5489
  // P1 fence: capture before any await. A cancel (deliveryEpoch),
5375
5490
  // respawn (spawnGeneration) or stop/pause (launchFenceEpoch) during the
5376
5491
  // grace hold must stale this gate — the push below rechecks, and the
@@ -5737,7 +5852,7 @@ export class Daemon extends EventEmitter {
5737
5852
  reason,
5738
5853
  recoveryStarted: true,
5739
5854
  });
5740
- void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
5855
+ this.deliverReplyDropNotice("inst.reply_drop_retrying", "replydrop", "Reply-drop status", turn, reason, false);
5741
5856
  this.queueReplyRecoveryPrompt(turn);
5742
5857
  }
5743
5858
  reportReplyDropWithoutRetry(turn, reason) {
@@ -5748,7 +5863,7 @@ export class Daemon extends EventEmitter {
5748
5863
  reason,
5749
5864
  recoveryStarted: false,
5750
5865
  });
5751
- void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
5866
+ this.deliverReplyDropNotice("inst.reply_drop_unknown", "replydrop", "Unconfirmed reply status", turn, reason, true);
5752
5867
  }
5753
5868
  queueReplyRecoveryPrompt(turn) {
5754
5869
  const deliveryEpoch = this.deliveryEpoch;
@@ -5841,12 +5956,20 @@ export class Daemon extends EventEmitter {
5841
5956
  generation: turn.generation,
5842
5957
  reason,
5843
5958
  });
5844
- const now = Date.now();
5845
- if (this.lastReplyDropWarningAt !== 0
5846
- && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
5847
- return;
5848
- this.lastReplyDropWarningAt = now;
5849
- void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
5959
+ this.deliverReplyDropNotice("inst.reply_drop_unrecovered", "replydropwarn", "Reply-drop recovery warning", turn, reason, true);
5960
+ }
5961
+ /** Channel notice for a reply-drop. `rateLimit` shares the per-instance cooldown (#2544). */
5962
+ deliverReplyDropNotice(key, kind, title, turn, reason, rateLimit) {
5963
+ if (rateLimit) {
5964
+ const now = Date.now();
5965
+ if (this.lastReplyDropWarningAt !== 0
5966
+ && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS) {
5967
+ this.logger.debug({ reason, kind }, "Reply-drop channel notice rate-limited");
5968
+ return;
5969
+ }
5970
+ this.lastReplyDropWarningAt = now;
5971
+ }
5972
+ void this.deliverDaemonReply(t(key, formatReplyDropDetection(turn, reason)), kind, title, turn.target, true);
5850
5973
  }
5851
5974
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
5852
5975
  async sendProxyReply(pane, target) {
@@ -6444,6 +6567,7 @@ export class Daemon extends EventEmitter {
6444
6567
  }
6445
6568
  /** Stop every runtime poller/watcher while preserving IPC and daemon state. */
6446
6569
  freezeRuntimeMonitors() {
6570
+ this.cancelCapacityNudge("the instance was stopped, paused or respawned");
6447
6571
  this.runtimeMonitorsFrozen = true;
6448
6572
  this.launchFenceEpoch++;
6449
6573
  this.interactionObservation.reset(this.interactionOwner());
@@ -6556,6 +6680,10 @@ export class Daemon extends EventEmitter {
6556
6680
  this.config.warm_cap = update.warm_cap;
6557
6681
  else if (update.warm_cap === null)
6558
6682
  delete this.config.warm_cap;
6683
+ if (typeof update.resume_interrupted_work === "boolean")
6684
+ this.config.resume_interrupted_work = update.resume_interrupted_work;
6685
+ else if (update.resume_interrupted_work === null)
6686
+ delete this.config.resume_interrupted_work;
6559
6687
  const nextAutoPause = typeof update.auto_pause_after === "number"
6560
6688
  && Number.isFinite(update.auto_pause_after)
6561
6689
  && update.auto_pause_after >= 0
@@ -7828,7 +7956,8 @@ export class Daemon extends EventEmitter {
7828
7956
  /**
7829
7957
  * Arm the "keep going" nudge for the error occurrence just reported (`pattern` must be the one the lifecycle was
7830
7958
  * handed). The nudge is sent once, `delayMs` from now on the MONOTONIC clock (a wall-clock step changes nothing),
7831
- * and only if nothing has changed by then — see {@link tickCapacityNudge}. Returns false when there is nothing to arm.
7959
+ * and only if the failed turn still owns an empty idle composer — see {@link tickCapacityNudge}.
7960
+ * Cosmetic footer redraws are not new work. Returns false when there is nothing to arm.
7832
7961
  */
7833
7962
  armCapacityNudge(pattern, delayMs) {
7834
7963
  const episode = this.lastErrorEpisode;
@@ -7847,9 +7976,12 @@ export class Daemon extends EventEmitter {
7847
7976
  // lookahead on the counting pattern: whole-pane dedup would max at 1 and miss later hits. See #1215.)
7848
7977
  if (!codexLiveRowMatches(episode.pane, pattern))
7849
7978
  return false;
7979
+ this.cancelCapacityNudge("the instance has work of its own");
7850
7980
  this.capacityNudge = {
7851
7981
  pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
7852
7982
  epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
7983
+ episode,
7984
+ promiseTarget: null,
7853
7985
  };
7854
7986
  this.logger.info({ delayMs }, "Model-capacity nudge armed");
7855
7987
  return true;
@@ -7864,7 +7996,7 @@ export class Daemon extends EventEmitter {
7864
7996
  const nudge = this.capacityNudge, episode = this.lastErrorEpisode;
7865
7997
  const tmux = this.tmux, attempt = this.launchAttempt, boot = this.bootId;
7866
7998
  this.cancelCapacityNudge("retries exhausted");
7867
- if (!nudge || !episode || !tmux)
7999
+ if (!nudge || !episode || nudge.episode !== episode || !tmux)
7868
8000
  return null;
7869
8001
  const window = tmux.getWindowId?.();
7870
8002
  const current = () => this.tmux === tmux && tmux.getWindowId?.() === window
@@ -7872,9 +8004,7 @@ export class Daemon extends EventEmitter {
7872
8004
  && episode === this.lastErrorEpisode && this.isDeliveryEpochCurrent(nudge.epoch)
7873
8005
  && !this.startupAborted && !this.spawning && !this.isPaused
7874
8006
  && this.getProcessStatus() === "running" && this.instanceState === "idle" && this.pasteQueueDepth === 0;
7875
- const accepts = (pane) => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
7876
- && this.backend?.isPeriodicRedrawIdlePane?.(pane) === true
7877
- && codexLiveRowMatches(pane, nudge.pattern) && !this.backend?.backgroundWorkRunning?.(pane);
8007
+ const accepts = (pane) => this.capacityNudgePaneCurrent(nudge, pane);
7878
8008
  const revalidate = () => this.paneWriteLock.run(async () => {
7879
8009
  if (!current())
7880
8010
  return false;
@@ -7896,10 +8026,35 @@ export class Daemon extends EventEmitter {
7896
8026
  }
7897
8027
  /** Drop an armed nudge (the user stopped / paused the instance, or the lifecycle gave up on it). */
7898
8028
  cancelCapacityNudge(reason) {
7899
- if (!this.capacityNudge)
8029
+ const nudge = this.capacityNudge;
8030
+ if (!nudge)
7900
8031
  return;
7901
8032
  this.capacityNudge = null;
7902
8033
  this.logger.info({ reason }, "Model-capacity nudge cancelled");
8034
+ this.reportCapacityNudgeCancelled(nudge, reason);
8035
+ }
8036
+ /** Only a dispatched channel promise needs a later cancellation notice. */
8037
+ markCapacityNudgePromised(target) {
8038
+ if (this.capacityNudge)
8039
+ this.capacityNudge.promiseTarget = target;
8040
+ }
8041
+ reportCapacityNudgeCancelled(nudge, reason) {
8042
+ const target = nudge.promiseTarget;
8043
+ if (!target)
8044
+ return;
8045
+ nudge.promiseTarget = null;
8046
+ this.emit("capacity_nudge_cancelled", { name: this.name, reason, target });
8047
+ }
8048
+ /**
8049
+ * A failed turn must still be the last live transcript item, with no new
8050
+ * error occurrence, draft, queue, busy status or background work. Codex's
8051
+ * own periodic-redraw idle classifier recognises its empty composer/footer;
8052
+ * never infer inactivity from byte equality or a cached idle state alone.
8053
+ */
8054
+ capacityNudgePaneCurrent(nudge, pane) {
8055
+ return this.paneAuthoritativelyIdle(pane) && this.backend?.isPeriodicRedrawIdlePane?.(pane) === true
8056
+ && !this.backend?.backgroundWorkRunning?.(pane) && codexLiveRowMatches(pane, nudge.pattern)
8057
+ && Daemon.countOccurrences(nudge.pattern, pane) === Daemon.countOccurrences(nudge.pattern, nudge.pane);
7903
8058
  }
7904
8059
  /** The first live composer of a spawn fixes what is history (see {@link nudgeBaselines}). */
7905
8060
  takeNudgeBaselines(pane, patterns) {
@@ -7922,8 +8077,8 @@ export class Daemon extends EventEmitter {
7922
8077
  }
7923
8078
  /**
7924
8079
  * One monitor poll's look at an armed nudge. It is CANCELLED — never postponed — when anything about the instance
7925
- * moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane no
7926
- * longer being exactly the one the error was seen on (it moved on by itself, a person typed, another dialog), the
8080
+ * moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane
8081
+ * no longer belonging to the failed turn (new output, a draft, another dialog), the
7927
8082
  * agent working, or a message waiting for delivery. Only when it is due and still untouched is it fired — once — and
7928
8083
  * the injection re-verifies everything under the pane-write lock.
7929
8084
  */
@@ -7933,10 +8088,11 @@ export class Daemon extends EventEmitter {
7933
8088
  return;
7934
8089
  const gone = nudge.spawn !== this.spawnGeneration || nudge.fence !== this.launchFenceEpoch ? "the instance was stopped, paused or respawned"
7935
8090
  : !this.isDeliveryEpochCurrent(nudge.epoch) ? "the user cancelled"
7936
- : this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" ? "the instance is not running"
7937
- : pane !== nudge.pane ? "the screen changed"
8091
+ : nudge.episode !== this.lastErrorEpisode ? "the instance has work of its own"
8092
+ : this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" ? "the instance is not running"
7938
8093
  : this.instanceState === "working" || this.pasteQueueDepth > 0 ? "the instance has work of its own"
7939
- : null;
8094
+ : !this.capacityNudgePaneCurrent(nudge, pane) ? "the failed turn is no longer idle with an empty input"
8095
+ : null;
7940
8096
  if (gone) {
7941
8097
  this.cancelCapacityNudge(gone);
7942
8098
  return;
@@ -7944,29 +8100,38 @@ export class Daemon extends EventEmitter {
7944
8100
  if (performance.now() < nudge.dueAt)
7945
8101
  return;
7946
8102
  this.capacityNudge = null; // fired: once per episode
7947
- void this.sendCapacityNudge(nudge).catch(err => this.logger.warn({ err: err.message }, "Model-capacity nudge failed"));
8103
+ void this.sendCapacityNudge(nudge).catch(err => {
8104
+ this.logger.warn({ err: err.message }, "Model-capacity nudge failed");
8105
+ this.reportCapacityNudgeCancelled(nudge, "the retry could not be submitted");
8106
+ });
7948
8107
  }
7949
8108
  async sendCapacityNudge(nudge) {
7950
8109
  // Everything the nudge was armed under, asked again after EVERY await that precedes a write — inside the paste
7951
8110
  // primitive too (its capture, its paste, its pause, its Enter, its retry): once any of it is false nothing more is
7952
8111
  // written. A message queued behind the lock, or the agent starting a turn, is "work of its own" like at the poll.
7953
8112
  const current = () => nudge.spawn === this.spawnGeneration && nudge.fence === this.launchFenceEpoch
8113
+ && nudge.episode === this.lastErrorEpisode
7954
8114
  && this.isDeliveryEpochCurrent(nudge.epoch) && !this.isPaused && !this.healthCheckPaused
7955
8115
  && !this.startupAborted && this.getProcessStatus() !== "stopped"
7956
8116
  && this.pasteQueueDepth === 0 && this.instanceState !== "working";
7957
8117
  // Exclusive with every delivery: nothing can paste a turn between the verdict below and the nudge.
7958
- await this.paneWriteLock.run(async () => {
7959
- if (!this.tmux)
7960
- return;
7961
- // ONE capture decides: it must be exactly the screen the error was seen on AND an idle composer. (A second picture
7962
- // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
7963
- const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
7964
- current,
7965
- accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
7966
- && codexLiveRowMatches(pane, nudge.pattern),
8118
+ let sent = false;
8119
+ try {
8120
+ await this.paneWriteLock.run(async () => {
8121
+ if (!this.tmux)
8122
+ return;
8123
+ // ONE capture decides failed-turn ownership and empty/idle input.
8124
+ sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
8125
+ current,
8126
+ accept: pane => this.capacityNudgePaneCurrent(nudge, pane),
8127
+ });
8128
+ this.logger.info({ sent }, "Model-capacity nudge attempted");
7967
8129
  });
7968
- this.logger.info({ sent }, "Model-capacity nudge attempted");
7969
- });
8130
+ }
8131
+ finally {
8132
+ if (!sent)
8133
+ this.reportCapacityNudgeCancelled(nudge, "the retry could not be submitted");
8134
+ }
7970
8135
  }
7971
8136
  /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
7972
8137
  static screenOf(dialog, pane) {
@@ -10233,7 +10398,9 @@ export class Daemon extends EventEmitter {
10233
10398
  // false and must not clear the window either.
10234
10399
  if (replyAttempt?.completionAction) {
10235
10400
  const snap = this.turnReplyGuard.snapshot();
10236
- if (snap?.generation === replyAttempt.generation && snap.phase === "awaiting"
10401
+ // generation 0 is a pre-arm token applied to the live generation (#2544).
10402
+ if (snap && (snap.generation === replyAttempt.generation || replyAttempt.generation === 0)
10403
+ && snap.phase === "awaiting"
10237
10404
  && snap.completionDelivered && !snap.cancelledByUser) {
10238
10405
  this.clearReplyGuardConfirm();
10239
10406
  }
@@ -10883,9 +11050,14 @@ export class Daemon extends EventEmitter {
10883
11050
  beginSpawn() {
10884
11051
  this.unclearOomStopped = false;
10885
11052
  this.publishWorkingState("before_spawn");
11053
+ this.recoveryContinuationId = null;
10886
11054
  this.workingBackgroundObservation = null;
11055
+ this.ownedWorkingSession = null;
11056
+ this.recoveryBootSettled = false;
11057
+ this.recoveryNativeWork = false;
11058
+ this.recoveryCancelled = false;
10887
11059
  this.dialogAnswers = null;
10888
- this.capacityNudge = null;
11060
+ this.cancelCapacityNudge("the instance was stopped, paused or respawned");
10889
11061
  this.lastErrorEpisode = null;
10890
11062
  this.nudgeBaselines = null;
10891
11063
  // A relaunch settles any outstanding quit watch: the CLI came back.
@@ -10916,6 +11088,8 @@ export class Daemon extends EventEmitter {
10916
11088
  this.spawnDepth = Math.max(0, this.spawnDepth - 1);
10917
11089
  if (this.spawnDepth > 0)
10918
11090
  return; // an enclosing spawn is still running
11091
+ if (!this.recoveryBootWorkPending)
11092
+ this.recoveryBootSettled = true;
10919
11093
  this.spawning = false;
10920
11094
  const resolve = this.resolveSpawnSettled;
10921
11095
  this.spawnSettled = null;
@@ -12369,6 +12543,13 @@ export class Daemon extends EventEmitter {
12369
12543
  const sid = this.backend?.getSessionId();
12370
12544
  if (!sid)
12371
12545
  return;
12546
+ if ((this.config.backend ?? "claude-code") === "claude-code" && Buffer.byteLength(sid) <= 256
12547
+ && !/[\u0000-\u001f\u007f]/.test(sid) && this.ownedWorkingSession !== sid) {
12548
+ this.ownedWorkingSession = sid;
12549
+ // Reuse this existing owned checkpoint. An idle instance may have no
12550
+ // later state edge before an OOM; its journal must not stay unknown.
12551
+ this.publishWorkingState("process");
12552
+ }
12372
12553
  const path = join(this.instanceDir, "session-id");
12373
12554
  // Idle observations happen after turns. Avoid needless writes (and mtime
12374
12555
  // churn) while still updating the marker when /new changes the session.