@songsid/agend 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/muse.d.ts +20 -2
  4. package/dist/backend/muse.js +154 -16
  5. package/dist/backend/muse.js.map +1 -1
  6. package/dist/channel/adapters/telegram.d.ts +1 -1
  7. package/dist/channel/adapters/telegram.js +3 -35
  8. package/dist/channel/adapters/telegram.js.map +1 -1
  9. package/dist/cli.js +6 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config-validator.js +2 -2
  12. package/dist/config-validator.js.map +1 -1
  13. package/dist/daemon.d.ts +13 -0
  14. package/dist/daemon.js +155 -16
  15. package/dist/daemon.js.map +1 -1
  16. package/dist/delivery-outbox.d.ts +11 -0
  17. package/dist/delivery-outbox.js +47 -2
  18. package/dist/delivery-outbox.js.map +1 -1
  19. package/dist/fleet-context.d.ts +1 -1
  20. package/dist/fleet-lock.d.ts +6 -0
  21. package/dist/fleet-lock.js +19 -0
  22. package/dist/fleet-lock.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +34 -2
  24. package/dist/fleet-manager.js +264 -39
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +3 -2
  28. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -2
  29. package/dist/instance-lifecycle.d.ts +1 -0
  30. package/dist/instance-lifecycle.js +11 -0
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/locale.js +24 -2
  33. package/dist/locale.js.map +1 -1
  34. package/dist/log-service.js +1 -0
  35. package/dist/log-service.js.map +1 -1
  36. package/dist/needs-you.js +1 -0
  37. package/dist/needs-you.js.map +1 -1
  38. package/dist/outbound-handlers.d.ts +2 -2
  39. package/dist/outbound-handlers.js +5 -5
  40. package/dist/outbound-handlers.js.map +1 -1
  41. package/dist/search/search-index.d.ts +10 -2
  42. package/dist/search/search-index.js +17 -4
  43. package/dist/search/search-index.js.map +1 -1
  44. package/dist/service-installer.d.ts +23 -1
  45. package/dist/service-installer.js +40 -11
  46. package/dist/service-installer.js.map +1 -1
  47. package/dist/session-oom-stop.d.ts +45 -0
  48. package/dist/session-oom-stop.js +259 -0
  49. package/dist/session-oom-stop.js.map +1 -0
  50. package/dist/session-oom.d.ts +13 -0
  51. package/dist/session-oom.js +119 -7
  52. package/dist/session-oom.js.map +1 -1
  53. package/dist/settings-api.d.ts +1 -1
  54. package/dist/settings-api.js +1 -1
  55. package/dist/settings-api.js.map +1 -1
  56. package/dist/spawn-gate.d.ts +2 -0
  57. package/dist/spawn-gate.js +17 -4
  58. package/dist/spawn-gate.js.map +1 -1
  59. package/dist/start-attempt.d.ts +11 -1
  60. package/dist/start-attempt.js +16 -5
  61. package/dist/start-attempt.js.map +1 -1
  62. package/dist/start-failure-chat.d.ts +37 -0
  63. package/dist/start-failure-chat.js +142 -0
  64. package/dist/start-failure-chat.js.map +1 -0
  65. package/dist/start-failure.d.ts +24 -0
  66. package/dist/start-failure.js +68 -1
  67. package/dist/start-failure.js.map +1 -1
  68. package/dist/telegram-api-root.d.ts +1 -0
  69. package/dist/telegram-api-root.js +40 -0
  70. package/dist/telegram-api-root.js.map +1 -0
  71. package/dist/types.d.ts +3 -2
  72. package/dist/ui/panel-chat.js +19 -6
  73. package/dist/ui/panel-log.js +3 -3
  74. package/dist/ui/panel-settings.js +8 -1
  75. package/dist/ui/settings-strings.js +6 -0
  76. package/dist/ui/shared/app-shell.js +21 -4
  77. package/dist/ui/shared/app.css +9 -3
  78. package/dist/ui/shared/usage-dialog.js +21 -11
  79. package/dist/ui/shared/view-strings.js +2 -0
  80. package/dist/web-api.d.ts +1 -1
  81. package/dist/web-api.js +1 -1
  82. package/dist/web-api.js.map +1 -1
  83. package/dist/working-state-journal.d.ts +68 -0
  84. package/dist/working-state-journal.js +93 -0
  85. package/dist/working-state-journal.js.map +1 -0
  86. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -10,7 +10,8 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
10
10
  import { EventEmitter } from "node:events";
11
11
  import { rotateLogIfNeededAsync } from "./logger.js";
12
12
  import { coredumpFilterLaunchPrefix } from "./coredump-filter.js";
13
- import { observeCliOomIdentity, confirmedCliOom, consecutiveSessionOoms } from "./session-oom.js";
13
+ import { observeCliOomIdentity, confirmedCliOom, consecutiveSessionOoms, describeSelfOom } from "./session-oom.js";
14
+ import { assertNoSessionOomStop, readSessionOomStop, writeSessionOomStop, SessionOomStoppedError, sessionOomStopHint } from "./session-oom-stop.js";
14
15
  import { mcpServerState } from "./mcp-liveness.js";
15
16
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
16
17
  import { writeBackgroundWorkEndedMarker } from "./background-work-ended.js";
@@ -58,6 +59,7 @@ import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMa
58
59
  import { codexLiveRowMatches } from "./backend/codex.js";
59
60
  import { codexPackageProblemMessage } from "./backend/codex-package.js";
60
61
  import { claudeConfigDirFor, claudeProfileConfigDir, claudeSwitchDialog, claudeSwitchTargetMatches } from "./backend/claude-code.js";
62
+ import { MuseBackend } from "./backend/muse.js";
61
63
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
62
64
  import { unrecognisedPromptShape } from "./unrecognised-prompt.js";
63
65
  /** #2140: deliveries that may carry an unwritten what's-new notice before it is recorded anyway. */
@@ -1163,6 +1165,25 @@ export class Daemon extends EventEmitter {
1163
1165
  crashCount = 0;
1164
1166
  cliOomObservation = null;
1165
1167
  consecutiveOoms = null;
1168
+ collateralOomRecovery = false;
1169
+ unclearOomStopped = false;
1170
+ sessionOomStopUnpersisted = false;
1171
+ assertSessionOomAdmission() {
1172
+ if (this.sessionOomStopUnpersisted)
1173
+ throw new SessionOomStoppedError(false, { blocked: true, record: null, revision: null, confirmedOom: true });
1174
+ assertNoSessionOomStop(this.instanceDir);
1175
+ }
1176
+ isSessionOomStopped() {
1177
+ return this.sessionOomStopUnpersisted || readSessionOomStop(this.instanceDir).blocked;
1178
+ }
1179
+ isUnclearOomStopped() {
1180
+ return this.unclearOomStopped && this.healthCheckPaused && this.processStatus === "crashed";
1181
+ }
1182
+ stopUnclearOom(exitCode) {
1183
+ this.unclearOomStopped = true;
1184
+ this.healthCheckPaused = true;
1185
+ this.emitSupervisionEnded(t("inst.oom_unclear_stopped"), t("inst.session_oom_remedy"), exitCode, "oom_unclear");
1186
+ }
1166
1187
  oomSessionKey() {
1167
1188
  // Claude's per-instance statusline owns its ID. Other current getters
1168
1189
  // either return null or select by cwd (Muse/Grok): those are not proof
@@ -1810,6 +1831,12 @@ export class Daemon extends EventEmitter {
1810
1831
  model: config.model ?? "default",
1811
1832
  };
1812
1833
  this.logger = rootLogger.child({ instance: name }, { level: config.log_level });
1834
+ if (backend instanceof MuseBackend) {
1835
+ const log = this.logger;
1836
+ backend.setPlaceholderDebug((rec) => {
1837
+ log.debug(rec, "muse empty-composer placeholder classified as empty");
1838
+ });
1839
+ }
1813
1840
  this.tmuxSessionName = getTmuxSession();
1814
1841
  this.messageBus = new MessageBus();
1815
1842
  this.messageBus.setLogger(this.logger);
@@ -1822,7 +1849,10 @@ export class Daemon extends EventEmitter {
1822
1849
  // finished turn can never resume after a later restart. reset() is
1823
1850
  // deliberately NOT hooked: stop/spawn abandon the in-memory turn while
1824
1851
  // the on-disk interruption must survive for the resume gate.
1825
- this.turnReplyGuard.onComplete = () => clearInFlightTurnMarker(this.instanceDir);
1852
+ this.turnReplyGuard.onComplete = () => {
1853
+ clearInFlightTurnMarker(this.instanceDir);
1854
+ this.publishWorkingState("turn_closed");
1855
+ };
1826
1856
  }
1827
1857
  /** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
1828
1858
  setDeliveryOutboxPort(port) {
@@ -2117,6 +2147,11 @@ export class Daemon extends EventEmitter {
2117
2147
  * daemon's own state only; it never inspects the pane itself.
2118
2148
  */
2119
2149
  notAcceptingReason() {
2150
+ if (this.sessionOomStopUnpersisted)
2151
+ return t("inst.session_oom_hold");
2152
+ const oomStop = readSessionOomStop(this.instanceDir);
2153
+ if (oomStop.blocked)
2154
+ return sessionOomStopHint(oomStop);
2120
2155
  if (this.pauseWakeState !== "active")
2121
2156
  return t("delivery.hold_state", this.pauseWakeState);
2122
2157
  if (this.inputBlockedDialogKey !== null)
@@ -2167,6 +2202,7 @@ export class Daemon extends EventEmitter {
2167
2202
  }
2168
2203
  }
2169
2204
  async startAdmitted() {
2205
+ this.assertSessionOomAdmission();
2170
2206
  ensureInstanceDir(this.instanceDir);
2171
2207
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
2172
2208
  this.logger.info(`Starting ${this.name}`);
@@ -3094,19 +3130,36 @@ export class Daemon extends EventEmitter {
3094
3130
  return;
3095
3131
  this.cliOomObservation = null;
3096
3132
  const decisionKey = this.oomSessionKey();
3097
- this.consecutiveOoms = consecutiveSessionOoms(this.consecutiveOoms, decisionKey, decisionKey === key && oom !== null);
3133
+ this.consecutiveOoms = consecutiveSessionOoms(this.consecutiveOoms, decisionKey, decisionKey === key && oom?.attribution === "self");
3134
+ const collateralOom = oom?.attribution === "collateral";
3135
+ if (collateralOom)
3136
+ this.logger.warn({ attribution: oom.attribution, pid: oom.pid }, "OOM victim without self-caused proof — preserving session; waiting for memory recovery");
3098
3137
  if (this.consecutiveOoms && this.consecutiveOoms.count >= 2) {
3099
3138
  // Stop supervision, not an idle pause: queued work must not auto-wake
3100
3139
  // the same session. An explicit restart is the person's retry choice.
3140
+ // No await after the final captured health owner/session check. The
3141
+ // stop is authoritative before observers can see supervision end.
3142
+ try {
3143
+ writeSessionOomStop(this.instanceDir, { owner: healthOwner, sessionKey: decisionKey,
3144
+ configuration: this.getWorkingStateObservation().configuration, stoppedAt: Date.now(),
3145
+ count: this.consecutiveOoms.count, victim: { pid: oom.pid, at: oom.at }, evidence: oom.evidence });
3146
+ }
3147
+ catch {
3148
+ // Storage failure cannot revive this CLI in the current process.
3149
+ // Say explicitly that a fleet restart cannot yet rely on durability.
3150
+ this.sessionOomStopUnpersisted = true;
3151
+ this.logger.error("Could not persist session OOM stop; fix storage before restarting the fleet");
3152
+ }
3101
3153
  this.healthCheckPaused = true;
3102
3154
  try {
3103
- this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: "session_oom" });
3155
+ this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: "session_oom", oomEvidence: oom.evidence });
3104
3156
  }
3105
3157
  catch (error) {
3106
3158
  this.logger.warn({ error }, "Could not append session OOM history");
3107
3159
  }
3108
3160
  this.logger.error({ pid: oom.pid, count: this.consecutiveOoms.count }, "Repeated confirmed CLI OOM — automatic resume stopped; session preserved");
3109
- this.emitSupervisionEnded(t("inst.session_oom_stopped"), t("inst.session_oom_remedy"), exitCode, "session_oom");
3161
+ this.emitSupervisionEnded(`${t("inst.session_oom_stopped")} (${describeSelfOom(oom.evidence)})`, t("inst.session_oom_remedy")
3162
+ + (this.sessionOomStopUnpersisted ? ` ${t("inst.session_oom_save_failed")}` : ""), exitCode, "session_oom");
3110
3163
  return;
3111
3164
  }
3112
3165
  // Kill the dead window (remain-on-exit keeps it around) before respawn
@@ -3116,7 +3169,7 @@ export class Daemon extends EventEmitter {
3116
3169
  return;
3117
3170
  }
3118
3171
  // Detect claude-code background session conflict — recover without counting as crash
3119
- if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
3172
+ if (!oom && lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
3120
3173
  if (!this.backgroundSessionRecoveryAttempted) {
3121
3174
  this.backgroundSessionRecoveryAttempted = true;
3122
3175
  this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
@@ -3156,7 +3209,7 @@ export class Daemon extends EventEmitter {
3156
3209
  // --continue again and crash in the same way → loop. Clear the session id
3157
3210
  // and skip resume so the next spawn starts fresh. (skipResume also stops
3158
3211
  // saveSessionId below from resurrecting the id from statusline.json.)
3159
- if (this.paneSaysNoConversation(lastOutput)) {
3212
+ if (!oom && this.paneSaysNoConversation(lastOutput)) {
3160
3213
  this.setSessionAside("cli_reported_no_conversation");
3161
3214
  this.skipResume = true;
3162
3215
  }
@@ -3177,12 +3230,21 @@ export class Daemon extends EventEmitter {
3177
3230
  return;
3178
3231
  }
3179
3232
  // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
3180
- this.crashTimestamps.push(Date.now());
3233
+ if (!collateralOom)
3234
+ this.crashTimestamps.push(Date.now());
3235
+ else
3236
+ this.crashTimestamps = [];
3181
3237
  const crashWindowMs = 5 * 60_000;
3182
3238
  this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
3183
3239
  if (this.crashTimestamps.length >= 3) {
3184
3240
  this.healthCheckPaused = true;
3185
3241
  this.logger.error({ crashesInWindow: this.crashTimestamps.length, lastExitCode: exitCode ?? null, lastOutput: paneTail(lastOutput) ?? null }, "3+ crashes in 5 minutes — pausing respawn");
3242
+ // Unknown cause is bounded like an ordinary crash, never a
3243
+ // self-OOM verdict. Preserve the session for the person's choice.
3244
+ if (oom?.attribution === "unknown") {
3245
+ this.stopUnclearOom(exitCode);
3246
+ return;
3247
+ }
3186
3248
  // P1: Persist crash state so next process restart skips resume
3187
3249
  try {
3188
3250
  writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
@@ -3199,18 +3261,23 @@ export class Daemon extends EventEmitter {
3199
3261
  if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
3200
3262
  this.crashCount = 0;
3201
3263
  }
3202
- this.crashCount++;
3264
+ if (!collateralOom)
3265
+ this.crashCount++;
3203
3266
  this.lastCrashAt = Date.now();
3204
- if (this.crashCount > max_retries) {
3267
+ if (!collateralOom && this.crashCount > max_retries) {
3205
3268
  this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries, lastExitCode: exitCode ?? null, lastOutput: paneTail(lastOutput) ?? null }, "Max crash retries exceeded — not respawning");
3206
3269
  this.healthCheckPaused = true;
3270
+ if (oom?.attribution === "unknown") {
3271
+ this.stopUnclearOom(exitCode);
3272
+ return;
3273
+ }
3207
3274
  this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
3208
3275
  return; // don't schedule next — given up
3209
3276
  }
3210
3277
  // Calculate backoff delay
3211
3278
  const delay = backoff === "exponential"
3212
- ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
3213
- : 1000 * this.crashCount;
3279
+ ? Math.min(1000 * Math.pow(2, Math.max(1, this.crashCount) - 1), 60_000)
3280
+ : 1000 * Math.max(1, this.crashCount);
3214
3281
  this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
3215
3282
  await new Promise(r => setTimeout(r, delay));
3216
3283
  try {
@@ -3255,7 +3322,14 @@ export class Daemon extends EventEmitter {
3255
3322
  // Write snapshot before spawn — consumed only if resume fails
3256
3323
  this.writeRotationSnapshot("crash");
3257
3324
  // Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
3258
- const resumed = await this.spawnClaudeWindow();
3325
+ this.collateralOomRecovery = collateralOom;
3326
+ let resumed;
3327
+ try {
3328
+ resumed = await this.spawnClaudeWindow();
3329
+ }
3330
+ finally {
3331
+ this.collateralOomRecovery = false;
3332
+ }
3259
3333
  if (!resumed) {
3260
3334
  // Resume failed → fresh session → inject snapshot for context
3261
3335
  await this.injectSnapshotMessage();
@@ -3349,6 +3423,7 @@ export class Daemon extends EventEmitter {
3349
3423
  * four, carrying a human-readable cause and the operator's next step.
3350
3424
  */
3351
3425
  emitSupervisionEnded(reason, remedy, exitCode, cause) {
3426
+ this.publishWorkingState("supervision_ended");
3352
3427
  this.emit("supervision_ended", {
3353
3428
  name: this.name,
3354
3429
  reason,
@@ -3361,6 +3436,7 @@ export class Daemon extends EventEmitter {
3361
3436
  if (this.processStatus === status)
3362
3437
  return;
3363
3438
  this.processStatus = status;
3439
+ this.publishWorkingState("process");
3364
3440
  if (status === "running") {
3365
3441
  // A successful crash respawn is a new pane generation. It is ready by
3366
3442
  // this point, so an error gate inherited from the dead pane must not keep
@@ -3909,6 +3985,7 @@ export class Daemon extends EventEmitter {
3909
3985
  this.deliveryEpoch = fleetEpoch === undefined
3910
3986
  ? this.deliveryEpoch + 1
3911
3987
  : Math.max(this.deliveryEpoch, fleetEpoch);
3988
+ this.publishWorkingState("cancelled");
3912
3989
  this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
3913
3990
  }
3914
3991
  /** Board 907c7899: the layout a web /compact's end can be read from on this pane (kiro classic or TUI V2), or null. */
@@ -4477,6 +4554,7 @@ export class Daemon extends EventEmitter {
4477
4554
  return true;
4478
4555
  }
4479
4556
  async stop() {
4557
+ this.publishWorkingState("before_stop");
4480
4558
  this.logger.info("Stopping daemon instance");
4481
4559
  this.clearQuitRelaunchWatch();
4482
4560
  this.fenceDeliveryWritesForStop();
@@ -4625,6 +4703,31 @@ export class Daemon extends EventEmitter {
4625
4703
  getInteractionSnapshot() {
4626
4704
  return this.interactionObservation.snapshot(this.interactionOwner());
4627
4705
  }
4706
+ workingTurnDelivery = null;
4707
+ workingBackgroundObservation = null;
4708
+ /** Pure metadata read: never discovers sessions, captures a pane or reads backend files. */
4709
+ getWorkingStateObservation() {
4710
+ const turn = this.turnReplyGuard.snapshot();
4711
+ const backend = this.config.backend ?? this.backend?.binaryName ?? "unknown";
4712
+ const profile = resolveCredentialProfile(this.config.backend_options?.[backend]);
4713
+ return {
4714
+ owner: this.interactionOwner(), backend,
4715
+ configuration: createHash("sha256").update(JSON.stringify([backend, this.config.working_directory, profile])).digest("hex"),
4716
+ executionState: this.processStatus === "running" ? this.isPaused ? "paused" : this.instanceState : null,
4717
+ processStatus: this.getProcessStatus(), sessionId: null, backendTurnId: null,
4718
+ backgroundWork: this.workingBackgroundObservation,
4719
+ turn: turn ? { generation: turn.generation,
4720
+ deliveryId: this.workingTurnDelivery?.generation === turn.generation ? this.workingTurnDelivery.deliveryId : null,
4721
+ correlationId: turn.target.correlationId ?? null, messageId: turn.target.messageId ?? null,
4722
+ cancelled: turn.cancelledByUser, completionDelivered: turn.completionDelivered } : null,
4723
+ };
4724
+ }
4725
+ publishWorkingState(reason) {
4726
+ try {
4727
+ this.emit("working_state", { reason, observation: this.getWorkingStateObservation() });
4728
+ }
4729
+ catch { /* Journal observers cannot change daemon behavior. */ }
4730
+ }
4628
4731
  /** Key of the last interaction snapshot actually emitted + broadcast (#1219). */
4629
4732
  lastPublishedInteractionKey = null;
4630
4733
  publishInteraction() {
@@ -4877,6 +4980,7 @@ export class Daemon extends EventEmitter {
4877
4980
  this.pauseWakeState = "paused";
4878
4981
  this.autoPauseController.markPaused();
4879
4982
  writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
4983
+ this.publishWorkingState("execution");
4880
4984
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
4881
4985
  this.ipcServer?.broadcast({
4882
4986
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -4902,6 +5006,7 @@ export class Daemon extends EventEmitter {
4902
5006
  }
4903
5007
  /** Respawn the CLI in the preserved window and block until its prompt is ready. */
4904
5008
  async wake(timeoutMs = 30_000) {
5009
+ this.assertSessionOomAdmission();
4905
5010
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
4906
5011
  // auth pause — otherwise the instance would pause again the moment it idles.
4907
5012
  this.pausePending = false;
@@ -4969,6 +5074,7 @@ export class Daemon extends EventEmitter {
4969
5074
  this.clearErrorRecoveryGate();
4970
5075
  clearPausedMarker(this.instanceDir);
4971
5076
  this.resumeRuntimeMonitors();
5077
+ this.publishWorkingState("execution");
4972
5078
  this.logger.info("Instance auto-woke");
4973
5079
  this.ipcServer?.broadcast({
4974
5080
  type: "instance_state", instanceName: this.name, state: this.instanceState, pausedAt: null,
@@ -5070,6 +5176,17 @@ export class Daemon extends EventEmitter {
5070
5176
  }
5071
5177
  this.instanceState = snapshot.state;
5072
5178
  this.acceptedInstanceSnapshot = { ...snapshot };
5179
+ // Reuse an already-captured pane and the backend's pure parser, never capture for the journal.
5180
+ if (typeof pane === "string" && this.backend?.backgroundWorkRunning) {
5181
+ try {
5182
+ const background = this.backend.backgroundWorkRunning(pane);
5183
+ if (background !== this.workingBackgroundObservation) {
5184
+ this.workingBackgroundObservation = background;
5185
+ this.publishWorkingState("background");
5186
+ }
5187
+ }
5188
+ catch { /* Observational parsing cannot change accepted execution state. */ }
5189
+ }
5073
5190
  // OpenCode creates its session lazily on the first submitted message.
5074
5191
  // Waiting until stop/pause to persist that id loses resume state when the
5075
5192
  // fleet is SIGKILLed or the host reboots.
@@ -5114,6 +5231,7 @@ export class Daemon extends EventEmitter {
5114
5231
  }
5115
5232
  if (snapshot.state !== previous) {
5116
5233
  this.logStateEdge(previous, snapshot);
5234
+ this.publishWorkingState("execution");
5117
5235
  this.emit("instance_state", { name: this.name, ...snapshot });
5118
5236
  this.ipcServer?.broadcast({ type: "instance_state", instanceName: this.name, ...snapshot });
5119
5237
  if (snapshot.state === "stuck" && pane && this.instanceStateReadyPattern) {
@@ -5196,6 +5314,8 @@ export class Daemon extends EventEmitter {
5196
5314
  deliveredText,
5197
5315
  deliveredAt,
5198
5316
  }, this.pendingWork.lastInboundTimestamp());
5317
+ this.workingTurnDelivery = { generation: this.turnReplyGuard.snapshot().generation, deliveryId: meta.delivery_id || null };
5318
+ this.publishWorkingState("turn_started");
5199
5319
  // #1209: persist the in-flight turn with a seam checkpoint. A marker
5200
5320
  // that survives a restart is an interrupted turn by construction —
5201
5321
  // completion (via the guard's onComplete) and cancel paths delete it.
@@ -10095,8 +10215,10 @@ export class Daemon extends EventEmitter {
10095
10215
  // purposes even before the idle edge completes the guard. Without
10096
10216
  // this, a restart in the reply-to-idle window would re-inject a
10097
10217
  // continuation for an already-answered turn.
10098
- if (replyAttempt?.completionAction)
10218
+ if (replyAttempt?.completionAction) {
10099
10219
  clearInFlightTurnMarker(this.instanceDir);
10220
+ this.publishWorkingState("completion_delivered");
10221
+ }
10100
10222
  // #1377: a confirmed completion action (reply/react/edit_message) ends
10101
10223
  // the human-facing obligation immediately — the idle edge does not need
10102
10224
  // to confirm it. Clear any pending confirmation window so a later
@@ -10759,6 +10881,9 @@ export class Daemon extends EventEmitter {
10759
10881
  * into exactly the window this exists to close.
10760
10882
  */
10761
10883
  beginSpawn() {
10884
+ this.unclearOomStopped = false;
10885
+ this.publishWorkingState("before_spawn");
10886
+ this.workingBackgroundObservation = null;
10762
10887
  this.dialogAnswers = null;
10763
10888
  this.capacityNudge = null;
10764
10889
  this.lastErrorEpisode = null;
@@ -10974,6 +11099,7 @@ export class Daemon extends EventEmitter {
10974
11099
  catch { /* best effort */ }
10975
11100
  }
10976
11101
  async spawnClaudeWindow() {
11102
+ this.assertSessionOomAdmission();
10977
11103
  this.beginSpawn();
10978
11104
  let resumedSuccessfully = false;
10979
11105
  try {
@@ -11364,11 +11490,20 @@ export class Daemon extends EventEmitter {
11364
11490
  * Returns true if CLI is ready, false if it failed or got stuck.
11365
11491
  */
11366
11492
  async trySpawn(reuseWindow = false, startupTimeoutMs) {
11367
- const ready = !this.spawnGate ? await this.trySpawnInsideGate(reuseWindow, startupTimeoutMs) : await this.spawnGate.run({
11493
+ const recoveryOwner = this.collateralOomRecovery ? this.interactionOwner() : null;
11494
+ const enter = () => {
11495
+ if (recoveryOwner && (!sameInteractionOwner(recoveryOwner, this.interactionOwner())
11496
+ || this.startupAborted || this.deliveryWritesStopping || this.runtimeMonitorsFrozen || this.healthCheckPaused)) {
11497
+ throw new Error("OOM recovery cancelled: the instance was stopped, paused or replaced while memory was unavailable");
11498
+ }
11499
+ return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
11500
+ };
11501
+ const ready = !this.spawnGate ? await enter() : await this.spawnGate.run({
11368
11502
  instanceName: this.name,
11369
11503
  workingDirectory: this.config.working_directory,
11370
11504
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
11371
- }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
11505
+ requireNormalMemory: this.collateralOomRecovery,
11506
+ }, enter);
11372
11507
  // #906: a resumed kiro conversation comes back as the agent it was saved under. Every launch path that reaches a
11373
11508
  // ready CLI (start, recovery, wake) switches it to this instance's own agent here, while the spawn still holds
11374
11509
  // deliveries — and outside the spawn gate, which it does not need.
@@ -11650,6 +11785,7 @@ export class Daemon extends EventEmitter {
11650
11785
  }
11651
11786
  }
11652
11787
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
11788
+ this.assertSessionOomAdmission();
11653
11789
  this.startupAdmission?.();
11654
11790
  const launchFence = this.launchFenceEpoch;
11655
11791
  const backendConfig = this.buildBackendConfig();
@@ -11698,6 +11834,7 @@ export class Daemon extends EventEmitter {
11698
11834
  // Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
11699
11835
  // into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
11700
11836
  this.startupAdmission?.();
11837
+ this.assertSessionOomAdmission();
11701
11838
  if (launchFence !== this.launchFenceEpoch)
11702
11839
  throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
11703
11840
  this.backend.writeConfig(backendConfig);
@@ -11784,6 +11921,7 @@ export class Daemon extends EventEmitter {
11784
11921
  }
11785
11922
  this.startupAdmission?.();
11786
11923
  let windowId;
11924
+ this.assertSessionOomAdmission();
11787
11925
  if (reuseWindow) {
11788
11926
  this.controlClient?.unregisterWindow(this.tmux.getWindowId());
11789
11927
  await this.tmux.respawnWindow(cmd, resolvedCwd);
@@ -12418,6 +12556,7 @@ export class Daemon extends EventEmitter {
12418
12556
  crashType: data.crashType,
12419
12557
  exitCode: data.exitCode,
12420
12558
  reason: data.reason,
12559
+ ...(data.oomEvidence ? { oomEvidence: data.oomEvidence } : {}),
12421
12560
  lastOutput: data.lastOutput,
12422
12561
  crashCount: this.crashCount + 1,
12423
12562
  crashesInWindow: this.crashTimestamps.length,