@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6

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 (55) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
  3. package/dist/cli.js +3378 -3333
  4. package/dist/types/cli/gc-cli.d.ts +1 -0
  5. package/dist/types/launch/client.d.ts +9 -1
  6. package/dist/types/launch/protocol.d.ts +17 -0
  7. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  8. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +4 -1
  11. package/dist/types/modes/types.d.ts +3 -1
  12. package/dist/types/security/contracts/schemas.d.ts +405 -403
  13. package/dist/types/session/agent-session-types.d.ts +5 -0
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/launch-completion.d.ts +10 -0
  16. package/dist/types/session/session-entries.d.ts +15 -1
  17. package/dist/types/session/session-manager.d.ts +7 -0
  18. package/dist/types/session/yield-queue.d.ts +6 -1
  19. package/dist/types/tools/index.d.ts +9 -0
  20. package/package.json +12 -12
  21. package/src/cli/gc-cli.ts +641 -21
  22. package/src/config/model-discovery.ts +14 -14
  23. package/src/config/model-registry.ts +10 -8
  24. package/src/config/settings.ts +1 -1
  25. package/src/export/html/index.ts +10 -3
  26. package/src/export/share.ts +4 -0
  27. package/src/launch/broker.ts +222 -6
  28. package/src/launch/client.ts +161 -9
  29. package/src/launch/protocol.ts +52 -0
  30. package/src/main.ts +13 -6
  31. package/src/mcp/config-writer.ts +1 -1
  32. package/src/modes/components/btw-panel.ts +41 -4
  33. package/src/modes/controllers/btw-controller.ts +55 -7
  34. package/src/modes/controllers/command-controller.ts +31 -0
  35. package/src/modes/controllers/input-controller.ts +24 -7
  36. package/src/modes/interactive-mode.ts +16 -2
  37. package/src/modes/types.ts +8 -1
  38. package/src/prompts/session/launch-completion.md +1 -0
  39. package/src/sdk.ts +15 -0
  40. package/src/security/contracts/schemas.ts +205 -183
  41. package/src/security/contracts/validation.ts +5 -1
  42. package/src/security/store.ts +2 -2
  43. package/src/session/agent-session-types.ts +6 -0
  44. package/src/session/agent-session.ts +191 -14
  45. package/src/session/launch-completion.ts +37 -0
  46. package/src/session/session-context.ts +26 -0
  47. package/src/session/session-entries.ts +17 -1
  48. package/src/session/session-manager.ts +21 -0
  49. package/src/session/yield-queue.ts +121 -16
  50. package/src/slash-commands/builtin-registry.ts +10 -0
  51. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  52. package/src/tools/hub/launch.ts +122 -6
  53. package/src/tools/index.ts +9 -0
  54. package/dist/types/config/file-lock.d.ts +0 -29
  55. package/src/config/file-lock.ts +0 -164
@@ -38,25 +38,25 @@ export const DISCOVERY_DEFAULT_CONTEXT_WINDOW = OPENAI_COMPAT_DISCOVERY_DEFAULT_
38
38
  export const DISCOVERY_DEFAULT_MAX_TOKENS = OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS;
39
39
 
40
40
  /**
41
- * Run `fn` with an abort signal that fires after `timeoutMs`, clearing the
42
- * backing timer the instant the operation settles.
41
+ * Run `fn` with a hard deadline while also signalling cooperative transports
42
+ * to abort. The independent rejection keeps discovery bounded when a runtime
43
+ * leaves its fetch promise pending after `AbortSignal.abort()` (observed with
44
+ * Windows localhost probes).
43
45
  *
44
- * Unlike the built-in abort-signal timeout API, the timer never outlives the
45
- * request: on the success path it is cancelled before `fn` resolves, so the
46
- * signal is never aborted and no pending callback lingers on the heap. A leaked
47
- * abort-signal timeout (e.g. discovery against a mocked fetch that resolves
48
- * instantly) fires seconds later and sets its abort `reason` — which crashed
49
- * Bun's concurrent GC while it marked the signal's wrapped reason during an
50
- * unrelated allocation (`JSAbortSignal::visitAdditionalChildren`).
46
+ * The backing timer is cleared as soon as `fn` settles, unlike
47
+ * `AbortSignal.timeout()`, whose delayed reason previously crashed Bun's
48
+ * concurrent GC during an unrelated allocation.
51
49
  */
52
50
  async function withTimeoutSignal<T>(timeoutMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
53
51
  const controller = new AbortController();
54
- const timer = setTimeout(
55
- () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
56
- timeoutMs,
57
- );
52
+ const timeout = Promise.withResolvers<never>();
53
+ const timer = setTimeout(() => {
54
+ const error = new DOMException("The operation timed out.", "TimeoutError");
55
+ controller.abort(error);
56
+ timeout.reject(error);
57
+ }, timeoutMs);
58
58
  try {
59
- return await fn(controller.signal);
59
+ return await Promise.race([fn(controller.signal), timeout.promise]);
60
60
  } finally {
61
61
  clearTimeout(timer);
62
62
  }
@@ -68,6 +68,7 @@ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
68
68
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
69
69
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
70
70
  import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
71
+ import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry";
71
72
  import {
72
73
  getBundledModelReferenceIndex,
73
74
  inheritReferenceThinking,
@@ -2011,14 +2012,15 @@ export class ModelRegistry {
2011
2012
  this.#providerOverrides.has(descriptor.providerId) ||
2012
2013
  this.#keylessProviders.has(descriptor.providerId));
2013
2014
  if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig) {
2014
- const discoveryBaseUrl = this.#descriptorBaseUrl(descriptor.providerId);
2015
- options.push(
2016
- descriptor.createModelManagerOptions({
2017
- apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined,
2018
- baseUrl: discoveryBaseUrl,
2019
- fetch: this.#fetch,
2020
- }),
2021
- );
2015
+ const discoveryConfig = {
2016
+ apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined,
2017
+ baseUrl: this.#descriptorBaseUrl(descriptor.providerId),
2018
+ fetch: this.#fetch,
2019
+ };
2020
+ const preparedConfig =
2021
+ getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ??
2022
+ discoveryConfig;
2023
+ options.push(descriptor.createModelManagerOptions(preparedConfig));
2022
2024
  }
2023
2025
  }
2024
2026
 
@@ -30,6 +30,7 @@ import {
30
30
  setWorktreesDir,
31
31
  toError,
32
32
  } from "@oh-my-pi/pi-utils";
33
+ import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
33
34
  import { JSONC, YAML } from "bun";
34
35
  import { invalidate as invalidateCapabilityFsCache } from "../capability/fs";
35
36
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
@@ -41,7 +42,6 @@ import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-pro
41
42
  import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
42
43
  import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
43
44
  import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
44
- import { withFileLock } from "./file-lock";
45
45
  import {
46
46
  type BashInterceptorRule,
47
47
  type GroupPrefix,
@@ -181,10 +181,17 @@ export interface SessionData {
181
181
  subSessions?: Record<string, SubSession>;
182
182
  }
183
183
 
184
+ function sessionHeaderForExport(header: SessionHeader | null): SessionHeader | null {
185
+ if (!header) return null;
186
+ const exported = { ...header };
187
+ delete exported.previousSessionFiles;
188
+ return exported;
189
+ }
190
+
184
191
  /** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */
185
192
  export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData {
186
193
  return {
187
- header: sm.getHeader(),
194
+ header: sessionHeaderForExport(sm.getHeader()),
188
195
  entries: sm.getEntries(),
189
196
  leafId: sm.getLeafId(),
190
197
  systemPrompt: state?.systemPrompt.join("\n\n"),
@@ -231,7 +238,7 @@ async function collectSubSessionsFromDir(
231
238
  out[key] = {
232
239
  agentId,
233
240
  parent: parentKey,
234
- header,
241
+ header: sessionHeaderForExport(header),
235
242
  entries,
236
243
  leafId: entries.length > 0 ? entries[entries.length - 1].id : null,
237
244
  };
@@ -295,7 +302,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
295
302
  }
296
303
 
297
304
  const sessionData: SessionData = {
298
- header: sm.getHeader(),
305
+ header: sessionHeaderForExport(sm.getHeader()),
299
306
  entries: sm.getEntries(),
300
307
  leafId: sm.getLeafId(),
301
308
  };
@@ -223,6 +223,7 @@ function collectShareRegexSecretValues(o: SecretObfuscator, data: SessionData):
223
223
  if (!header) return;
224
224
  add(header.title);
225
225
  add(header.cwd);
226
+ for (const previousSessionFile of header.previousSessionFiles ?? []) add(previousSessionFile);
226
227
  };
227
228
 
228
229
  addHeader(data.header);
@@ -246,6 +247,9 @@ function redactShareHeader(
246
247
  ...header,
247
248
  title: header.title === undefined ? undefined : o.obfuscate(header.title, sharedRegexSecretValues),
248
249
  cwd: o.obfuscate(header.cwd, sharedRegexSecretValues),
250
+ previousSessionFiles: header.previousSessionFiles?.map(previousSessionFile =>
251
+ o.obfuscate(previousSessionFile, sharedRegexSecretValues),
252
+ ),
249
253
  };
250
254
  }
251
255
 
@@ -15,14 +15,17 @@ import {
15
15
  DAEMON_PTY_COLUMNS,
16
16
  DAEMON_PTY_ROWS,
17
17
  DAEMON_RUNTIME_DIR_ENV,
18
+ type DaemonCompletionNotification,
18
19
  type DaemonOperation,
19
20
  type DaemonReadySpec,
20
21
  type DaemonRpcResult,
21
22
  type DaemonSignal,
22
23
  type DaemonSnapshot,
23
24
  type DaemonSpec,
25
+ type DaemonWireRequest,
24
26
  parseDaemonSnapshot,
25
27
  parseDaemonSpec,
28
+ parseDaemonWireMessage,
26
29
  parseDaemonWireRequest,
27
30
  } from "./protocol";
28
31
  import { resolveDaemonSpawnOptions } from "./spawn-options";
@@ -81,6 +84,9 @@ interface ManagedDaemon {
81
84
  readyPattern?: RegExp;
82
85
  restartTimer?: NodeJS.Timeout;
83
86
  consecutiveFailures: number;
87
+ completionCapable: boolean;
88
+ pendingCompletions: DaemonCompletionNotification[];
89
+ completionSubscriptionId?: string;
84
90
  persistQueue: Promise<void>;
85
91
  }
86
92
 
@@ -107,6 +113,10 @@ function settledState(state: DaemonSnapshot["state"]): boolean {
107
113
  return terminalState(state) || state === "restarting";
108
114
  }
109
115
 
116
+ function publishesCompletionOwners(request: DaemonWireRequest): boolean {
117
+ return request.completionEvents === true && (request.completionAcks?.length ?? 0) === 0;
118
+ }
119
+
110
120
  /**
111
121
  * Order daemons for the `list` response: non-terminal (active) daemons first,
112
122
  * oldest to newest, so the process the user is acting on is immediately visible
@@ -352,6 +362,9 @@ class DaemonBroker {
352
362
  */
353
363
  readonly #startingNames = new Set<string>();
354
364
  readonly #clients = new Set<net.Socket>();
365
+ readonly #ownerSockets = new Map<string, { socket: net.Socket; subscriptionId: string | undefined }>();
366
+ readonly #completionSubscriptions = new Map<string, string | undefined>();
367
+ readonly #pendingCompletions = new Map<string, Map<string, DaemonCompletionNotification>>();
355
368
  readonly #finished = Promise.withResolvers<void>();
356
369
  readonly #sockets = new Set<net.Socket>();
357
370
  #server: net.Server | undefined;
@@ -393,6 +406,7 @@ class DaemonBroker {
393
406
  await record.log?.close();
394
407
  await record.persistQueue;
395
408
  }
409
+ this.#ownerSockets.clear();
396
410
  for (const socket of this.#sockets) socket.destroy();
397
411
  this.#sockets.clear();
398
412
  this.#clients.clear();
@@ -439,6 +453,9 @@ class DaemonBroker {
439
453
  if (!authenticated) return;
440
454
  this.#clients.delete(socket);
441
455
  this.#scheduleIdleShutdown();
456
+ for (const [owner, registration] of this.#ownerSockets) {
457
+ if (registration.socket === socket) this.#ownerSockets.delete(owner);
458
+ }
442
459
  });
443
460
  }
444
461
 
@@ -450,6 +467,78 @@ class DaemonBroker {
450
467
  id = request.id;
451
468
  if (request.token !== this.#token) throw new Error("Daemon broker authentication failed");
452
469
  onAuthenticated();
470
+ for (const owner of request.completionUnsubscribes ?? []) {
471
+ const subscriptionId = this.#completionSubscriptions.get(owner);
472
+ if (
473
+ !this.#completionSubscriptions.has(owner) ||
474
+ (subscriptionId !== undefined && subscriptionId !== request.completionSubscriptionId)
475
+ ) {
476
+ continue;
477
+ }
478
+ this.#ownerSockets.delete(owner);
479
+ this.#completionSubscriptions.delete(owner);
480
+ await this.#setRecordCompletionCapability(owner, false);
481
+ this.#pendingCompletions.delete(owner);
482
+ }
483
+ for (const completionId of request.completionAcks ?? []) {
484
+ for (const [owner, pending] of this.#pendingCompletions) {
485
+ const registration = this.#ownerSockets.get(owner);
486
+ if (
487
+ !registration ||
488
+ registration.socket !== socket ||
489
+ registration.subscriptionId !== request.completionSubscriptionId
490
+ ) {
491
+ continue;
492
+ }
493
+ const completion = pending.get(completionId);
494
+ if (!completion) continue;
495
+ pending.delete(completionId);
496
+ if (pending.size === 0) this.#pendingCompletions.delete(owner);
497
+ const record = this.#records.get(completion.daemon.name);
498
+ const index = record?.pendingCompletions.findIndex(item => item.completionId === completionId) ?? -1;
499
+ if (record && index >= 0) {
500
+ record.pendingCompletions.splice(index, 1);
501
+ this.#persist(record);
502
+ await record.persistQueue;
503
+ }
504
+ }
505
+ }
506
+ if (publishesCompletionOwners(request)) {
507
+ const replayOwners = new Set(request.completionReplays ?? []);
508
+ const activeOwners = new Set(request.owners ?? []);
509
+ const detachedOwners = new Set(request.detachedOwners ?? []);
510
+ const advertisedOwners = new Set([...activeOwners, ...detachedOwners]);
511
+ for (const [owner, subscriptionId] of this.#completionSubscriptions) {
512
+ if (subscriptionId !== request.completionSubscriptionId || advertisedOwners.has(owner)) continue;
513
+ this.#ownerSockets.delete(owner);
514
+ this.#completionSubscriptions.delete(owner);
515
+ await this.#setRecordCompletionCapability(owner, false);
516
+ this.#pendingCompletions.delete(owner);
517
+ }
518
+ for (const owner of activeOwners) {
519
+ this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
520
+ await this.#setRecordCompletionCapability(owner, true);
521
+ const previous = this.#ownerSockets.get(owner);
522
+ this.#ownerSockets.set(owner, {
523
+ socket,
524
+ subscriptionId: request.completionSubscriptionId,
525
+ });
526
+ if (previous?.socket === socket && !replayOwners.has(owner)) continue;
527
+ for (const completion of this.#pendingCompletions.get(owner)?.values() ?? []) {
528
+ socket.write(`${JSON.stringify(completion)}\n`);
529
+ }
530
+ }
531
+ for (const owner of detachedOwners) {
532
+ const subscriptionId = this.#completionSubscriptions.get(owner);
533
+ if (this.#completionSubscriptions.has(owner) && subscriptionId !== request.completionSubscriptionId) {
534
+ continue;
535
+ }
536
+ this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
537
+ await this.#setRecordCompletionCapability(owner, true);
538
+ const registration = this.#ownerSockets.get(owner);
539
+ if (registration?.subscriptionId === request.completionSubscriptionId) this.#ownerSockets.delete(owner);
540
+ }
541
+ }
453
542
  const result = await this.#dispatch(request.operation);
454
543
  socket.write(`${JSON.stringify({ id, ok: true, result })}\n`);
455
544
  if (request.operation.op === "shutdown") setTimeout(() => void this.shutdown(), 10);
@@ -520,6 +609,9 @@ class DaemonBroker {
520
609
  if (existing && !terminalState(existing.snapshot.state)) {
521
610
  throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
522
611
  }
612
+ if (existing && existing.pendingCompletions.length > 0) {
613
+ throw new Error(`Daemon ${spec.name} has unacknowledged completion notifications`);
614
+ }
523
615
  if (spec.ready?.log) {
524
616
  try {
525
617
  new RegExp(spec.ready.log, "u");
@@ -556,6 +648,9 @@ class DaemonBroker {
556
648
  readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
557
649
  consecutiveFailures: 0,
558
650
  persistQueue: Promise.resolve(),
651
+ completionCapable: owner !== undefined && this.#completionSubscriptions.has(owner),
652
+ completionSubscriptionId: owner === undefined ? undefined : this.#completionSubscriptions.get(owner),
653
+ pendingCompletions: [],
559
654
  };
560
655
  syncReadyPending(record);
561
656
  this.#records.set(spec.name, record);
@@ -785,6 +880,14 @@ class DaemonBroker {
785
880
  await this.#settle(record, generation);
786
881
  }
787
882
 
883
+ async #monitorRecoveredDetached(record: ManagedDaemon, generation: number): Promise<void> {
884
+ while (!this.#shuttingDown && generation === record.generation && !settledState(record.snapshot.state)) {
885
+ await Bun.sleep(100);
886
+ if (this.#shuttingDown || generation !== record.generation) return;
887
+ await this.#refreshDetached(record);
888
+ }
889
+ }
890
+
788
891
  async #pollPort(record: ManagedDaemon, generation: number, ready: DaemonReadySpec): Promise<void> {
789
892
  const host = ready.host ?? "127.0.0.1";
790
893
  const port = ready.port;
@@ -812,6 +915,15 @@ class DaemonBroker {
812
915
  return this.#settle(record, generation, result.exitCode, result.timedOut ? "timed out" : undefined);
813
916
  }
814
917
 
918
+ #notifyCompletion(completion: DaemonCompletionNotification): void {
919
+ const pending = this.#pendingCompletions.get(completion.owner) ?? new Map<string, DaemonCompletionNotification>();
920
+ pending.set(completion.completionId, completion);
921
+ this.#pendingCompletions.set(completion.owner, pending);
922
+ const registration = this.#ownerSockets.get(completion.owner);
923
+ if (!registration || registration.socket.destroyed) return;
924
+ registration.socket.write(`${JSON.stringify(completion)}\n`);
925
+ }
926
+
815
927
  async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise<void> {
816
928
  // `restarting` is a settled state (child exited, relaunch timer armed). Any op that
817
929
  // runs #refreshDetached on such a record must not re-settle it: re-entry double-counts
@@ -855,9 +967,29 @@ class DaemonBroker {
855
967
  return;
856
968
  }
857
969
  record.snapshot.state = failed && !record.stopRequested ? "failed" : "exited";
970
+ const completion =
971
+ record.snapshot.owner !== undefined &&
972
+ !record.stopRequested &&
973
+ this.#completionSubscriptions.has(record.snapshot.owner)
974
+ ? ({
975
+ event: "daemon-completed",
976
+ completionId: crypto.randomUUID(),
977
+ owner: record.snapshot.owner,
978
+ daemon: { ...record.snapshot },
979
+ } satisfies DaemonCompletionNotification)
980
+ : undefined;
981
+ if (completion) record.pendingCompletions.push(completion);
858
982
  this.#persist(record);
859
983
  await record.log?.close();
860
984
  record.log = undefined;
985
+ await record.persistQueue;
986
+ if (
987
+ completion &&
988
+ this.#completionSubscriptions.has(completion.owner) &&
989
+ record.pendingCompletions.some(pending => pending.completionId === completion.completionId)
990
+ ) {
991
+ this.#notifyCompletion(completion);
992
+ }
861
993
  }
862
994
 
863
995
  async #logs(operation: Extract<DaemonOperation, { op: "logs" }>): Promise<DaemonRpcResult> {
@@ -1027,9 +1159,21 @@ class DaemonBroker {
1027
1159
  #persist(record: ManagedDaemon): void {
1028
1160
  const metaPath = path.join(record.dir, META_FILE);
1029
1161
  const tempPath = `${metaPath}.${process.pid}.tmp`;
1162
+ const metadata = {
1163
+ daemon: { ...record.snapshot },
1164
+ spec: record.spec,
1165
+ completionEvents: record.completionCapable,
1166
+ completionSubscriptionId: record.completionSubscriptionId,
1167
+ completionPending: record.pendingCompletions.length > 0,
1168
+ pendingCompletion: record.pendingCompletions.at(-1)?.daemon,
1169
+ pendingCompletions: record.pendingCompletions.map(completion => ({
1170
+ ...completion,
1171
+ daemon: { ...completion.daemon },
1172
+ })),
1173
+ };
1030
1174
  record.persistQueue = record.persistQueue
1031
1175
  .then(async () => {
1032
- await Bun.write(tempPath, JSON.stringify({ daemon: record.snapshot, spec: record.spec }));
1176
+ await Bun.write(tempPath, JSON.stringify(metadata));
1033
1177
  await fs.rename(tempPath, metaPath);
1034
1178
  })
1035
1179
  .catch(error => {
@@ -1040,6 +1184,28 @@ class DaemonBroker {
1040
1184
  });
1041
1185
  }
1042
1186
 
1187
+ async #setRecordCompletionCapability(owner: string, capable: boolean): Promise<void> {
1188
+ const subscriptionId = capable ? this.#completionSubscriptions.get(owner) : undefined;
1189
+ const persistence: Promise<void>[] = [];
1190
+ for (const record of this.#records.values()) {
1191
+ const clearPendingCompletions = !capable && record.pendingCompletions.length > 0;
1192
+ if (
1193
+ record.snapshot.owner !== owner ||
1194
+ (record.completionCapable === capable &&
1195
+ record.completionSubscriptionId === subscriptionId &&
1196
+ !clearPendingCompletions)
1197
+ ) {
1198
+ continue;
1199
+ }
1200
+ record.completionCapable = capable;
1201
+ record.completionSubscriptionId = subscriptionId;
1202
+ if (!capable) record.pendingCompletions = [];
1203
+ this.#persist(record);
1204
+ persistence.push(record.persistQueue);
1205
+ }
1206
+ await Promise.all(persistence);
1207
+ }
1208
+
1043
1209
  async #recoverRecords(): Promise<void> {
1044
1210
  const root = path.join(this.#runtimeDir, "daemons");
1045
1211
  const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => {
@@ -1057,11 +1223,9 @@ class DaemonBroker {
1057
1223
  const snapshot = parseDaemonSnapshot(decoded.daemon);
1058
1224
  const spec = parseDaemonSpec(decoded.spec);
1059
1225
  const processRef = snapshot.pid === undefined ? null : Process.fromPid(snapshot.pid);
1060
- const detached =
1061
- spec.detached &&
1062
- !terminalState(snapshot.state) &&
1063
- snapshot.state !== "stopping" &&
1064
- processRef?.status() === "running";
1226
+ const recoverableExit = !terminalState(snapshot.state) && snapshot.state !== "stopping";
1227
+ const detached = spec.detached && recoverableExit && processRef?.status() === "running";
1228
+ const recoveredDead = recoverableExit && !detached;
1065
1229
  if (!detached) {
1066
1230
  // Reap only records that were still alive when the previous broker
1067
1231
  // exited; already-terminal records keep their real exit time so
@@ -1088,12 +1252,64 @@ class DaemonBroker {
1088
1252
  readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
1089
1253
  consecutiveFailures: 0,
1090
1254
  persistQueue: Promise.resolve(),
1255
+ completionCapable: "completionEvents" in decoded && decoded.completionEvents === true,
1256
+ completionSubscriptionId:
1257
+ "completionSubscriptionId" in decoded && typeof decoded.completionSubscriptionId === "string"
1258
+ ? decoded.completionSubscriptionId
1259
+ : undefined,
1260
+ pendingCompletions: (() => {
1261
+ if ("pendingCompletions" in decoded && Array.isArray(decoded.pendingCompletions)) {
1262
+ return decoded.pendingCompletions.map(value => {
1263
+ const message = parseDaemonWireMessage(value);
1264
+ if (!("event" in message)) throw new Error("Pending daemon completion is not an event");
1265
+ return message;
1266
+ });
1267
+ }
1268
+ const pendingSnapshot =
1269
+ "pendingCompletion" in decoded
1270
+ ? parseDaemonSnapshot(decoded.pendingCompletion)
1271
+ : "completionPending" in decoded && decoded.completionPending === true
1272
+ ? { ...snapshot }
1273
+ : undefined;
1274
+ return pendingSnapshot
1275
+ ? [
1276
+ {
1277
+ event: "daemon-completed",
1278
+ completionId: crypto.randomUUID(),
1279
+ owner: pendingSnapshot.owner ?? snapshot.owner ?? "",
1280
+ daemon: pendingSnapshot,
1281
+ },
1282
+ ]
1283
+ : [];
1284
+ })(),
1091
1285
  };
1286
+ if (recoveredDead && record.completionCapable && snapshot.owner && record.pendingCompletions.length === 0) {
1287
+ record.pendingCompletions.push({
1288
+ event: "daemon-completed",
1289
+ completionId: crypto.randomUUID(),
1290
+ owner: snapshot.owner,
1291
+ daemon: { ...snapshot },
1292
+ });
1293
+ }
1092
1294
  syncReadyPending(record);
1093
1295
  this.#records.set(snapshot.name, record);
1296
+ if (snapshot.owner && record.completionCapable && (detached || record.pendingCompletions.length > 0)) {
1297
+ this.#completionSubscriptions.set(snapshot.owner, record.completionSubscriptionId);
1298
+ }
1299
+ if (record.completionCapable) {
1300
+ for (const completion of record.pendingCompletions) this.#notifyCompletion(completion);
1301
+ }
1094
1302
  if (detached && spec.ready?.port !== undefined && snapshot.state !== "ready") {
1095
1303
  void this.#pollPort(record, record.generation, spec.ready);
1096
1304
  }
1305
+ if (detached) {
1306
+ void this.#monitorRecoveredDetached(record, record.generation).catch(error => {
1307
+ logger.warn("Failed to monitor recovered detached daemon", {
1308
+ name: record.snapshot.name,
1309
+ error: error instanceof Error ? error.message : String(error),
1310
+ });
1311
+ });
1312
+ }
1097
1313
  this.#persist(record);
1098
1314
  } catch (error) {
1099
1315
  logger.warn("Failed to recover daemon record", {