@songsid/agend 2.1.8-beta.9 → 2.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/backend/claude-code.d.ts +35 -5
  2. package/dist/backend/claude-code.js +288 -17
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/channel/mcp-tools.js +1 -1
  5. package/dist/channel/mcp-tools.js.map +1 -1
  6. package/dist/cli.js +16 -6
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config-validator.js +11 -0
  9. package/dist/config-validator.js.map +1 -1
  10. package/dist/daemon.d.ts +47 -1
  11. package/dist/daemon.js +136 -17
  12. package/dist/daemon.js.map +1 -1
  13. package/dist/delivery-outbox.d.ts +9 -1
  14. package/dist/delivery-outbox.js +25 -4
  15. package/dist/delivery-outbox.js.map +1 -1
  16. package/dist/fleet-manager.d.ts +89 -4
  17. package/dist/fleet-manager.js +361 -53
  18. package/dist/fleet-manager.js.map +1 -1
  19. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
  20. package/dist/instance-lifecycle.d.ts +78 -5
  21. package/dist/instance-lifecycle.js +187 -14
  22. package/dist/instance-lifecycle.js.map +1 -1
  23. package/dist/login-flows.d.ts +15 -0
  24. package/dist/login-flows.js +4 -1
  25. package/dist/login-flows.js.map +1 -1
  26. package/dist/outbound-handlers.d.ts +3 -0
  27. package/dist/outbound-handlers.js +3 -3
  28. package/dist/outbound-handlers.js.map +1 -1
  29. package/dist/outbound-schemas.d.ts +2 -0
  30. package/dist/outbound-schemas.js +1 -1
  31. package/dist/outbound-schemas.js.map +1 -1
  32. package/dist/pause-marker.d.ts +10 -1
  33. package/dist/pause-marker.js +26 -2
  34. package/dist/pause-marker.js.map +1 -1
  35. package/dist/settings-api.d.ts +2 -0
  36. package/dist/settings-api.js +3 -1
  37. package/dist/settings-api.js.map +1 -1
  38. package/dist/status-emojis.d.ts +9 -1
  39. package/dist/status-emojis.js +11 -1
  40. package/dist/status-emojis.js.map +1 -1
  41. package/dist/target-queue-worker.d.ts +67 -0
  42. package/dist/target-queue-worker.js +76 -0
  43. package/dist/target-queue-worker.js.map +1 -0
  44. package/dist/tool-permissions.js +7 -2
  45. package/dist/tool-permissions.js.map +1 -1
  46. package/dist/types.d.ts +26 -1
  47. package/dist/types.js +9 -1
  48. package/dist/types.js.map +1 -1
  49. package/dist/ui/settings.html +2 -2
  50. package/dist/wake-coordinator.d.ts +143 -0
  51. package/dist/wake-coordinator.js +321 -0
  52. package/dist/wake-coordinator.js.map +1 -0
  53. package/dist/web-api.d.ts +3 -1
  54. package/dist/web-api.js +1 -1
  55. package/dist/web-api.js.map +1 -1
  56. package/package.json +1 -1
@@ -45,8 +45,11 @@ persona emoji replaces your `delivered` stamp with one of your own, the way
45
45
  Don't retry the same value.
46
46
 
47
47
  Only change your own `delivered` stamp unless someone asked for more. The
48
- other statuses are how people see a message is still in progress, and
49
- `progress_prefix` is the emoji leading your progress messages.
48
+ other statuses are how people see a message is still in progress,
49
+ `progress_prefix` is the emoji leading your progress messages, and `photo` /
50
+ `attachment` are the stamps on a photo / file your bot saved (default 📸 / 📎;
51
+ 👌 / 👍 on Telegram, which only takes its fixed reaction set — the same check
52
+ applies to them).
50
53
 
51
54
  ClassicBot instances have no per-instance stamps. The tool says so, and the
52
55
  connection's emojis are set by an operator in Settings.
@@ -6,6 +6,7 @@ import type { EventLog } from "./event-log.js";
6
6
  import type { TmuxControlClient } from "./tmux-control.js";
7
7
  import type { FleetInstructionsParams } from "./instructions.js";
8
8
  import type { DeliveryStatus } from "./status-emojis.js";
9
+ import { type PauseReason } from "./pause-marker.js";
9
10
  import { type ProviderUsage } from "./usage/providers.js";
10
11
  import type { SpawnGate } from "./spawn-gate.js";
11
12
  import type { StormWindow } from "./storm-window.js";
@@ -113,8 +114,32 @@ export interface LifecycleContext {
113
114
  finishDeliveryStatus?(instanceName: string, chatId: string, messageId: string, status: DeliveryStatus, threadId?: string): void;
114
115
  /** #1005: the instance's own status emojis, for the instructions' "avoid these" line. */
115
116
  statusEmojiAvoidList?(instanceName: string): string[];
116
- startPersistedPausedInstance(name: string): Promise<void>;
117
+ startPersistedPausedInstance(name: string, transition?: TransitionHandle): Promise<void>;
118
+ /** The restart already in flight for `name`, if any — an explicit wake joins it (Phase 2a). */
119
+ restartInFlight?(name: string): Promise<void> | undefined;
120
+ /** Phase 2b: a wake that the wake coordinator did not start (operator, user message) succeeded. */
121
+ onExternalWake?(name: string): void;
117
122
  }
123
+ /**
124
+ * Proof that the caller is running inside `name`'s current transition. Only
125
+ * direct internal calls hand it on (restart → stop → start, wake → marker-only
126
+ * start); a detached callback — an event handler, timer or setImmediate fired
127
+ * while a transition is still awaiting — has none and queues behind it.
128
+ */
129
+ export interface TransitionHandle {
130
+ readonly name: string;
131
+ readonly token: symbol;
132
+ }
133
+ /** Thrown by a start whose generation was invalidated (stop/restart) before it could publish. */
134
+ export declare class SupersededStartError extends Error {
135
+ constructor(name: string);
136
+ }
137
+ /**
138
+ * How long a target just woken for queued work is held from idle auto-pause
139
+ * before its first queued delivery is claimed (Phase 2 design §1.6): the
140
+ * cross-instance idle gate (60s) plus a 60s grace.
141
+ */
142
+ export declare const PRECLAIM_LEASE_MS = 120000;
118
143
  /**
119
144
  * Working directories of the fleet's other instances on `backendName` (#984):
120
145
  * Codex consults them only to decide whether `resume --last` could take a
@@ -126,6 +151,7 @@ export interface IncidentEventSource {
126
151
  on(event: string, handler: (...args: any[]) => void): unknown;
127
152
  requestPauseWhenIdle(opts?: {
128
153
  reconfirmAuth?: boolean;
154
+ reason?: PauseReason;
129
155
  }): void;
130
156
  /** Re-checks that a Codex pane is live after an async quota probe. */
131
157
  isCodexLivePane?(): Promise<boolean>;
@@ -135,6 +161,9 @@ export interface IncidentEventSource {
135
161
  getHangDetector?(): {
136
162
  on(event: string, handler: (...args: any[]) => void): unknown;
137
163
  } | null;
164
+ /** Real daemons buffer `pty_error` raised by the startup scan until the lifecycle can handle it. */
165
+ holdStartupIncidents?(): void;
166
+ releaseStartupIncidents?(): void;
138
167
  }
139
168
  /** Arguments accepted by handleCreate — mirrors CreateInstanceArgs in outbound-schemas.ts
140
169
  * plus internal-only fields forwarded by deploy_template (profile-derived). */
@@ -195,6 +224,21 @@ export declare class InstanceLifecycle {
195
224
  private ctx;
196
225
  /** Active daemon processes: instanceName → Daemon */
197
226
  readonly daemons: Map<string, import("./daemon.js").Daemon>;
227
+ /**
228
+ * Phase 2a: one transition (start / stop / wake / restart) at a time per
229
+ * instance. A call that passes the current transition's handle runs inline;
230
+ * anything else queues.
231
+ */
232
+ private transitionTails;
233
+ private activeTransition;
234
+ /** Bumped synchronously by stop/restart: a start begun under an older epoch must not publish. */
235
+ private lifecycleEpochs;
236
+ /** Single wake flight per instance: concurrent wakes share it. */
237
+ private wakeFlights;
238
+ /** Starts that begin their idle window now instead of at the persisted last inbound. */
239
+ private activitySeedNow;
240
+ /** When each instance was last woken or started for work — the pre-claim lease clock. */
241
+ private wokeAt;
198
242
  /** backend → last auth-error alert time, so one expiry sends one alert. */
199
243
  private lastAuthAlertAt;
200
244
  /**
@@ -327,11 +371,39 @@ export declare class InstanceLifecycle {
327
371
  */
328
372
  attachDeliveryStatusHandlers(name: string, daemon: IncidentEventSource): void;
329
373
  attachIncidentHandlers(name: string, daemon: IncidentEventSource): void;
330
- start(name: string, config: InstanceConfig, topicMode: boolean, runtimeIdentity?: FleetInstructionsParams["runtimeIdentity"]): Promise<void>;
374
+ epochOf(name: string): number;
375
+ /** Invalidate every start/wake already running for `name` (they will not publish). */
376
+ invalidate(name: string): void;
377
+ /** Whether `transition` is `name`'s transition and is the one running now. */
378
+ holds(name: string, transition: TransitionHandle | undefined): transition is TransitionHandle;
379
+ /** Run `fn` as `name`'s next transition, or inline when `parent` is the transition running now. */
380
+ runTransition<T>(name: string, fn: (transition: TransitionHandle) => Promise<T>, parent?: TransitionHandle): Promise<T>;
381
+ /** The next start of `name` is a wake/restart/explicit start: begin its idle window now. */
382
+ markActivitySeedNow(name: string): void;
383
+ /** Drop a seed request that no start consumed (the start returned early or failed before it). */
384
+ clearActivitySeedNow(name: string): void;
385
+ /** Record that `name` was just woken or (re)started, opening its pre-claim lease window. */
386
+ noteWokeForWork(name: string, now?: number): void;
387
+ /**
388
+ * Phase 2a work lease (design §1.6). Held while a durable submission to the
389
+ * target is in flight (unbounded: no timer releases a writer), or for
390
+ * PRECLAIM_LEASE_MS after a wake/start while queued work is still waiting to
391
+ * be claimed. It holds only the idle-timeout pause and warm-cap eviction —
392
+ * never an auth-deferred or operator pause.
393
+ */
394
+ hasWorkLease(name: string, now?: number): boolean;
395
+ start(name: string, config: InstanceConfig, topicMode: boolean, runtimeIdentity?: FleetInstructionsParams["runtimeIdentity"], transition?: TransitionHandle): Promise<void>;
396
+ private startInTransition;
331
397
  isPaused(name: string): boolean;
332
398
  getLastPausedAt(name: string): number | null;
333
- pause(name: string): Promise<void>;
334
- wake(name: string, timeoutMs?: number): Promise<void>;
399
+ pause(name: string, reason?: PauseReason): Promise<void>;
400
+ /** When `name` was last woken or (re)started for work, if recorded. */
401
+ wokeAtFor(name: string): number | undefined;
402
+ wake(name: string, timeoutMs?: number, transition?: TransitionHandle, opts?: {
403
+ source?: "coordinator" | "external";
404
+ }): Promise<void>;
405
+ private wakeInner;
406
+ private wakeInTransition;
335
407
  /**
336
408
  * Ownership boundary for a daemon that is not yet registered: if start()
337
409
  * rejects, nothing else will ever dispose it, so do it here before
@@ -344,7 +416,8 @@ export declare class InstanceLifecycle {
344
416
  }, name: string, logger: Logger): Promise<void>;
345
417
  /** Stop the registered daemon only if it is still `daemon` (a concurrent restart may have replaced it). */
346
418
  stopIfCurrent(name: string, daemon: unknown): Promise<boolean>;
347
- stop(name: string): Promise<void>;
419
+ stop(name: string, transition?: TransitionHandle): Promise<void>;
420
+ private stopInTransition;
348
421
  remove(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
349
422
  /** Handle create_instance tool call from a daemon. */
350
423
  handleCreate(args: LifecycleCreateArgs, respond: (result: unknown, error?: string) => void, adapterId?: string): Promise<void>;
@@ -9,7 +9,7 @@ import { isModelCompatible } from "./backend/types.js";
9
9
  import { safeHandler } from "./safe-async.js";
10
10
  import { t } from "./locale.js";
11
11
  import { EMBEDDED_CREDENTIAL_REMOTE_REPAIR_FAILED, EMBEDDED_CREDENTIAL_REMOTE_WARNING, secureGitHubRemotesForWorktree, } from "./remote-credential-check.js";
12
- import { clearPausedMarker, hasPausedMarker, readPausedAt, writePausedMarker } from "./pause-marker.js";
12
+ import { clearPausedMarker, hasPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
13
13
  import { reportProviderRateLimit } from "./usage/provider-alerts.js";
14
14
  import { isFleetStartCommandLine } from "./fleet-lock.js";
15
15
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
@@ -83,6 +83,19 @@ export function getUnsafeInstanceDaemonPidReason(pid, dataDir) {
83
83
  }
84
84
  return null;
85
85
  }
86
+ /** Thrown by a start whose generation was invalidated (stop/restart) before it could publish. */
87
+ export class SupersededStartError extends Error {
88
+ constructor(name) {
89
+ super(`Start of '${name}' was superseded by a stop or restart before it registered`);
90
+ this.name = "SupersededStartError";
91
+ }
92
+ }
93
+ /**
94
+ * How long a target just woken for queued work is held from idle auto-pause
95
+ * before its first queued delivery is claimed (Phase 2 design §1.6): the
96
+ * cross-instance idle gate (60s) plus a 60s grace.
97
+ */
98
+ export const PRECLAIM_LEASE_MS = 120_000;
86
99
  /**
87
100
  * Working directories of the fleet's other instances on `backendName` (#984):
88
101
  * Codex consults them only to decide whether `resume --last` could take a
@@ -194,6 +207,21 @@ export class InstanceLifecycle {
194
207
  ctx;
195
208
  /** Active daemon processes: instanceName → Daemon */
196
209
  daemons = new Map();
210
+ /**
211
+ * Phase 2a: one transition (start / stop / wake / restart) at a time per
212
+ * instance. A call that passes the current transition's handle runs inline;
213
+ * anything else queues.
214
+ */
215
+ transitionTails = new Map();
216
+ activeTransition = new Map();
217
+ /** Bumped synchronously by stop/restart: a start begun under an older epoch must not publish. */
218
+ lifecycleEpochs = new Map();
219
+ /** Single wake flight per instance: concurrent wakes share it. */
220
+ wakeFlights = new Map();
221
+ /** Starts that begin their idle window now instead of at the persisted last inbound. */
222
+ activitySeedNow = new Set();
223
+ /** When each instance was last woken or started for work — the pre-claim lease clock. */
224
+ wokeAt = new Map();
197
225
  /** backend → last auth-error alert time, so one expiry sends one alert. */
198
226
  lastAuthAlertAt = new Map();
199
227
  /**
@@ -888,10 +916,10 @@ export class InstanceLifecycle {
888
916
  const notificationTarget = this.ptyErrorNotificationTarget(name);
889
917
  if (notificationTarget)
890
918
  this.notifyIncident(notificationTarget, "pty_error", t("inst.codex_capacity_backoff_exhausted", name, String(attempts)));
891
- void this.pause(name)
919
+ void this.pause(name, "error")
892
920
  .catch(err => this.ctx.logger.warn({ err, name }, "capacity-exhausted pause failed"))
893
921
  .finally(() => { if (!this.isPaused(name))
894
- this.daemons.get(name)?.requestPauseWhenIdle(); });
922
+ this.daemons.get(name)?.requestPauseWhenIdle({ reason: "error" }); });
895
923
  }
896
924
  else {
897
925
  const delays = InstanceLifecycle.CAPACITY_BACKOFF_DELAYS_MS;
@@ -943,15 +971,87 @@ export class InstanceLifecycle {
943
971
  daemon.requestPauseWhenIdle({ reconfirmAuth: true });
944
972
  }
945
973
  else {
946
- void this.pause(name)
974
+ // The marker must say auth (2b never auto-wakes a login failure); any
975
+ // other pause-action error is an error pause, not an operator one.
976
+ const reason = data.type === "auth_error" ? "auth" : "error";
977
+ void this.pause(name, reason)
947
978
  .catch(err => this.ctx.logger.warn({ err, name }, "auth-error pause failed"))
948
979
  .finally(() => { if (!this.isPaused(name))
949
- daemon.requestPauseWhenIdle(); });
980
+ daemon.requestPauseWhenIdle({ reason }); });
950
981
  }
951
982
  }
952
983
  }, this.ctx.logger, `daemon.pty_error[${name}]`));
953
984
  }
954
- async start(name, config, topicMode, runtimeIdentity) {
985
+ epochOf(name) {
986
+ return this.lifecycleEpochs.get(name) ?? 0;
987
+ }
988
+ /** Invalidate every start/wake already running for `name` (they will not publish). */
989
+ invalidate(name) {
990
+ this.lifecycleEpochs.set(name, this.epochOf(name) + 1);
991
+ }
992
+ /** Whether `transition` is `name`'s transition and is the one running now. */
993
+ holds(name, transition) {
994
+ return transition !== undefined && transition.name === name && this.activeTransition.get(name) === transition.token;
995
+ }
996
+ /** Run `fn` as `name`'s next transition, or inline when `parent` is the transition running now. */
997
+ async runTransition(name, fn, parent) {
998
+ if (this.holds(name, parent))
999
+ return fn(parent);
1000
+ const previous = this.transitionTails.get(name) ?? Promise.resolve();
1001
+ const handle = { name, token: Symbol(name) };
1002
+ const run = previous.then(() => {
1003
+ this.activeTransition.set(name, handle.token);
1004
+ return fn(handle);
1005
+ }).finally(() => {
1006
+ if (this.activeTransition.get(name) === handle.token)
1007
+ this.activeTransition.delete(name);
1008
+ });
1009
+ const tail = run.then(() => { }, () => { });
1010
+ this.transitionTails.set(name, tail);
1011
+ try {
1012
+ return await run;
1013
+ }
1014
+ finally {
1015
+ if (this.transitionTails.get(name) === tail)
1016
+ this.transitionTails.delete(name);
1017
+ }
1018
+ }
1019
+ /** The next start of `name` is a wake/restart/explicit start: begin its idle window now. */
1020
+ markActivitySeedNow(name) {
1021
+ this.activitySeedNow.add(name);
1022
+ }
1023
+ /** Drop a seed request that no start consumed (the start returned early or failed before it). */
1024
+ clearActivitySeedNow(name) {
1025
+ this.activitySeedNow.delete(name);
1026
+ }
1027
+ /** Record that `name` was just woken or (re)started, opening its pre-claim lease window. */
1028
+ noteWokeForWork(name, now = Date.now()) {
1029
+ this.wokeAt.set(name, now);
1030
+ }
1031
+ /**
1032
+ * Phase 2a work lease (design §1.6). Held while a durable submission to the
1033
+ * target is in flight (unbounded: no timer releases a writer), or for
1034
+ * PRECLAIM_LEASE_MS after a wake/start while queued work is still waiting to
1035
+ * be claimed. It holds only the idle-timeout pause and warm-cap eviction —
1036
+ * never an auth-deferred or operator pause.
1037
+ */
1038
+ hasWorkLease(name, now = Date.now()) {
1039
+ const outbox = this.ctx.deliveryOutbox;
1040
+ if (!outbox)
1041
+ return false;
1042
+ if (outbox.countForTarget(name, ["delivering", "submission_started"]) > 0)
1043
+ return true;
1044
+ const woke = this.wokeAt.get(name);
1045
+ if (woke === undefined || now - woke >= PRECLAIM_LEASE_MS)
1046
+ return false;
1047
+ return outbox.countForTarget(name, ["queued", "retry_wait"]) > 0;
1048
+ }
1049
+ async start(name, config, topicMode, runtimeIdentity, transition) {
1050
+ return this.runTransition(name, () => this.startInTransition(name, config, topicMode, runtimeIdentity), transition);
1051
+ }
1052
+ async startInTransition(name, config, topicMode, runtimeIdentity) {
1053
+ const epoch = this.epochOf(name);
1054
+ const seedNow = this.activitySeedNow.delete(name);
955
1055
  if (this.daemons.has(name)) {
956
1056
  this.ctx.logger.info({ name }, "Instance already running, skipping");
957
1057
  return;
@@ -990,6 +1090,9 @@ export class InstanceLifecycle {
990
1090
  model: config.model ?? "default",
991
1091
  }, this.ctx.spawnGate, this.ctx.stormWindow, this.ctx.backendOutage);
992
1092
  daemon.setDeliveryOutboxPort(this.ctx.deliveryOutbox ?? undefined);
1093
+ daemon.setWorkLeaseCheck(() => this.hasWorkLease(name));
1094
+ if (seedNow)
1095
+ daemon.seedActivityNow();
993
1096
  // Read at each spawn so a fleet.yaml reload is honoured (#984).
994
1097
  daemon.setPeerWorkingDirectories(() => peerWorkingDirectories(this.ctx.fleetConfig, name, backendName));
995
1098
  daemon.setStatusEmojiAvoidList(() => this.ctx.statusEmojiAvoidList?.(name));
@@ -1037,12 +1140,30 @@ export class InstanceLifecycle {
1037
1140
  throw new Error(message);
1038
1141
  }
1039
1142
  }
1143
+ if (this.epochOf(name) !== epoch) {
1144
+ // A stop/restart arrived while this start was reconciling: do not spawn.
1145
+ await daemon.abortStartup().catch(() => { });
1146
+ throw new SupersededStartError(name);
1147
+ }
1148
+ // The startup scan can raise an incident (sign-in screen, fatal modal)
1149
+ // before the handlers below exist and before this daemon is registered;
1150
+ // hold it and deliver it once both are true.
1151
+ daemon.holdStartupIncidents?.();
1040
1152
  await InstanceLifecycle.startOrDispose(daemon, name, this.ctx.logger);
1153
+ // Publication fence (Phase 2a): the spawn cannot be cancelled, so a stop or
1154
+ // restart requested meanwhile has been waiting behind this transition. A
1155
+ // superseded start must not register, signal ready, or keep its window.
1156
+ if (this.epochOf(name) !== epoch) {
1157
+ await daemon.abortStartup().catch(err => this.ctx.logger.warn({ err, name }, "Failed to dispose a superseded start"));
1158
+ throw new SupersededStartError(name);
1159
+ }
1041
1160
  this.capacityBackoffBaselines.delete(name);
1042
1161
  this.daemons.set(name, daemon);
1162
+ if (seedNow)
1163
+ this.noteWokeForWork(name);
1043
1164
  this.ctx.onDaemonReady?.(name, daemon.bootId);
1044
- daemon.on("auto_pause_requested", safeHandler(async () => {
1045
- await this.pause(name);
1165
+ daemon.on("auto_pause_requested", safeHandler(async (data) => {
1166
+ await this.pause(name, data?.reason ?? "idle");
1046
1167
  }, this.ctx.logger, `autoPause[${name}]`));
1047
1168
  daemon.on("auto_paused", (data) => {
1048
1169
  this.ctx.eventLog?.insert(name, "instance_paused", { reason: "idle", paused_at: data.pausedAt });
@@ -1068,6 +1189,9 @@ export class InstanceLifecycle {
1068
1189
  this.attachDeliveryStatusHandlers(name, daemon);
1069
1190
  this.ctx.setTopicIcon(name, "green");
1070
1191
  this.ctx.touchActivity(name);
1192
+ // Last: handlers attached and daemon registered, so a held startup incident
1193
+ // (pause, notify) finds both.
1194
+ daemon.releaseStartupIncidents?.();
1071
1195
  }
1072
1196
  isPaused(name) {
1073
1197
  return this.daemons.get(name)?.isPaused ?? hasPausedMarker(this.ctx.getInstanceDir(name));
@@ -1075,7 +1199,7 @@ export class InstanceLifecycle {
1075
1199
  getLastPausedAt(name) {
1076
1200
  return this.daemons.get(name)?.lastPausedAt ?? readPausedAt(this.ctx.getInstanceDir(name));
1077
1201
  }
1078
- async pause(name) {
1202
+ async pause(name, reason = "operator") {
1079
1203
  // Final backstop shared by slash, MCP, Settings, warm-cap, and any future
1080
1204
  // caller. General is the coordinator and must remain resident to route work.
1081
1205
  if (isGeneralInstance(this.ctx.fleetConfig, name)) {
@@ -1100,7 +1224,7 @@ export class InstanceLifecycle {
1100
1224
  }
1101
1225
  this.ctx.stopStatuslineWatcher(name);
1102
1226
  try {
1103
- await daemon.pause();
1227
+ await daemon.pause(reason);
1104
1228
  }
1105
1229
  finally {
1106
1230
  // A rejected/no-op pause leaves the instance active and must not strand
@@ -1109,25 +1233,65 @@ export class InstanceLifecycle {
1109
1233
  this.ctx.startStatuslineWatcher(name);
1110
1234
  }
1111
1235
  }
1112
- async wake(name, timeoutMs = 30_000) {
1236
+ /** When `name` was last woken or (re)started for work, if recorded. */
1237
+ wokeAtFor(name) {
1238
+ return this.wokeAt.get(name);
1239
+ }
1240
+ async wake(name, timeoutMs = 30_000, transition, opts = {}) {
1241
+ const woke = await this.wakeInner(name, timeoutMs, transition);
1242
+ // Only a successful operator/user wake lifts the coordinator's park.
1243
+ if (opts.source !== "coordinator")
1244
+ this.ctx.onExternalWake?.(name);
1245
+ return woke;
1246
+ }
1247
+ async wakeInner(name, timeoutMs, transition) {
1248
+ if (this.holds(name, transition))
1249
+ return this.wakeInTransition(name, timeoutMs, transition);
1250
+ // An explicit wake that meets a restart joins it: the restart of a paused
1251
+ // instance is itself a wake (Phase 2a), and a second flight would race it.
1252
+ const restarting = this.ctx.restartInFlight?.(name);
1253
+ if (restarting) {
1254
+ await restarting;
1255
+ return;
1256
+ }
1257
+ const inFlight = this.wakeFlights.get(name);
1258
+ if (inFlight)
1259
+ return inFlight;
1260
+ const flight = this.runTransition(name, handle => this.wakeInTransition(name, timeoutMs, handle))
1261
+ .finally(() => { if (this.wakeFlights.get(name) === flight)
1262
+ this.wakeFlights.delete(name); });
1263
+ this.wakeFlights.set(name, flight);
1264
+ return flight;
1265
+ }
1266
+ async wakeInTransition(name, timeoutMs, transition) {
1267
+ const epoch = this.epochOf(name);
1113
1268
  const daemon = this.daemons.get(name);
1114
1269
  if (daemon) {
1115
1270
  await daemon.wake(timeoutMs);
1271
+ // A stop/restart requested while the resident CLI was respawning is
1272
+ // queued behind this wake and will tear it down next: do not open a
1273
+ // pre-claim window or restart its watchers on the way out.
1274
+ if (this.epochOf(name) !== epoch)
1275
+ return;
1116
1276
  }
1117
1277
  else {
1118
1278
  const instanceDir = this.ctx.getInstanceDir(name);
1119
1279
  if (!hasPausedMarker(instanceDir))
1120
1280
  throw new Error(`Cannot wake stopped instance '${name}'`);
1121
1281
  const pausedAt = readPausedAt(instanceDir) ?? Date.now();
1282
+ const reason = readPauseReason(instanceDir);
1122
1283
  clearPausedMarker(instanceDir);
1284
+ this.markActivitySeedNow(name);
1123
1285
  try {
1124
- await this.ctx.startPersistedPausedInstance(name);
1286
+ await this.ctx.startPersistedPausedInstance(name, transition);
1125
1287
  }
1126
1288
  catch (err) {
1127
- writePausedMarker(instanceDir, pausedAt);
1289
+ this.clearActivitySeedNow(name);
1290
+ writePausedMarker(instanceDir, pausedAt, reason);
1128
1291
  throw err;
1129
1292
  }
1130
1293
  }
1294
+ this.noteWokeForWork(name);
1131
1295
  this.ctx.startStatuslineWatcher(name);
1132
1296
  }
1133
1297
  /**
@@ -1152,7 +1316,16 @@ export class InstanceLifecycle {
1152
1316
  await this.stop(name);
1153
1317
  return true;
1154
1318
  }
1155
- async stop(name) {
1319
+ async stop(name, transition) {
1320
+ // Synchronously, before queueing: a start still spawning must see this and
1321
+ // not publish when it finishes (Phase 2a publication fence).
1322
+ if (!this.holds(name, transition))
1323
+ this.invalidate(name);
1324
+ return this.runTransition(name, () => this.stopInTransition(name), transition);
1325
+ }
1326
+ async stopInTransition(name) {
1327
+ this.invalidate(name);
1328
+ this.wokeAt.delete(name);
1156
1329
  this.ctx.setTopicIcon(name, "remove");
1157
1330
  // B1: cancel any pending capacity backoff timer so a stopped/removed
1158
1331
  // instance is not restarted behind the user's back.