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

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 (201) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-1p8yvde7.md} +35 -1
  3. package/dist/cli.js +3340 -3339
  4. package/dist/types/advisor/advise-tool.d.ts +10 -4
  5. package/dist/types/autoresearch/tools/init-experiment.d.ts +19 -7
  6. package/dist/types/autoresearch/tools/log-experiment.d.ts +19 -11
  7. package/dist/types/autoresearch/tools/run-experiment.d.ts +4 -2
  8. package/dist/types/autoresearch/tools/update-notes.d.ts +5 -2
  9. package/dist/types/cli/gc-cli.d.ts +1 -0
  10. package/dist/types/commit/agentic/tools/analyze-file.d.ts +5 -2
  11. package/dist/types/commit/agentic/tools/git-file-diff.d.ts +5 -2
  12. package/dist/types/commit/agentic/tools/git-hunk.d.ts +6 -2
  13. package/dist/types/commit/agentic/tools/git-overview.d.ts +5 -2
  14. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +29 -7
  15. package/dist/types/commit/agentic/tools/propose-commit.d.ts +14 -4
  16. package/dist/types/commit/agentic/tools/recent-commits.d.ts +4 -2
  17. package/dist/types/commit/agentic/tools/schemas.d.ts +7 -3
  18. package/dist/types/commit/agentic/tools/split-commit.d.ts +37 -10
  19. package/dist/types/commit/changelog/generate.d.ts +13 -3
  20. package/dist/types/commit/shared-llm.d.ts +13 -4
  21. package/dist/types/config/config-file.d.ts +1 -1
  22. package/dist/types/config/models-config-schema-bundle.d.ts +1992 -981
  23. package/dist/types/config/models-config-schema.d.ts +1201 -580
  24. package/dist/types/config/models-config.d.ts +355 -371
  25. package/dist/types/edit/hashline/params.d.ts +4 -2
  26. package/dist/types/edit/modes/apply-patch.d.ts +4 -2
  27. package/dist/types/edit/modes/patch.d.ts +16 -5
  28. package/dist/types/edit/modes/replace.d.ts +8 -3
  29. package/dist/types/eval/bridge-timeout.d.ts +5 -2
  30. package/dist/types/eval/completion-bridge.d.ts +13 -0
  31. package/dist/types/eval/py/tool-bridge.d.ts +12 -0
  32. package/dist/types/extensibility/custom-commands/types.d.ts +4 -4
  33. package/dist/types/extensibility/custom-tools/types.d.ts +4 -4
  34. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  35. package/dist/types/extensibility/hooks/types.d.ts +4 -4
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  38. package/dist/types/extensibility/legacy-typebox.d.ts +8 -0
  39. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +6 -13
  40. package/dist/types/goals/tools/goal-tool.d.ts +12 -4
  41. package/dist/types/launch/client.d.ts +9 -1
  42. package/dist/types/launch/protocol.d.ts +17 -0
  43. package/dist/types/lsp/index.d.ts +15 -5
  44. package/dist/types/lsp/types.d.ts +15 -5
  45. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  46. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  48. package/dist/types/modes/interactive-mode.d.ts +4 -1
  49. package/dist/types/modes/types.d.ts +3 -1
  50. package/dist/types/security/contracts/schemas.d.ts +1115 -537
  51. package/dist/types/security/publication.d.ts +92 -34
  52. package/dist/types/session/agent-session-types.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +26 -2
  54. package/dist/types/session/launch-completion.d.ts +10 -0
  55. package/dist/types/session/session-entries.d.ts +15 -1
  56. package/dist/types/session/session-manager.d.ts +7 -0
  57. package/dist/types/session/yield-queue.d.ts +6 -1
  58. package/dist/types/task/types.d.ts +63 -18
  59. package/dist/types/tools/ask.d.ts +34 -8
  60. package/dist/types/tools/ast-edit.d.ts +16 -4
  61. package/dist/types/tools/ast-grep.d.ts +12 -4
  62. package/dist/types/tools/bash.d.ts +20 -11
  63. package/dist/types/tools/browser.d.ts +64 -20
  64. package/dist/types/tools/checkpoint.d.ts +16 -8
  65. package/dist/types/tools/computer.d.ts +1 -1
  66. package/dist/types/tools/debug.d.ts +118 -48
  67. package/dist/types/tools/eval.d.ts +11 -5
  68. package/dist/types/tools/gh.d.ts +74 -26
  69. package/dist/types/tools/glob.d.ts +16 -6
  70. package/dist/types/tools/grep.d.ts +18 -6
  71. package/dist/types/tools/hub/index.d.ts +116 -46
  72. package/dist/types/tools/image-gen.d.ts +25 -8
  73. package/dist/types/tools/index.d.ts +9 -0
  74. package/dist/types/tools/inspect-image.d.ts +10 -4
  75. package/dist/types/tools/learn.d.ts +26 -8
  76. package/dist/types/tools/manage-skill.d.ts +16 -6
  77. package/dist/types/tools/memory-edit.d.ts +18 -6
  78. package/dist/types/tools/memory-recall.d.ts +8 -4
  79. package/dist/types/tools/memory-reflect.d.ts +10 -4
  80. package/dist/types/tools/memory-retain.d.ts +14 -4
  81. package/dist/types/tools/read.d.ts +8 -4
  82. package/dist/types/tools/security-scan.d.ts +82 -30
  83. package/dist/types/tools/todo.d.ts +30 -10
  84. package/dist/types/tools/tts.d.ts +12 -5
  85. package/dist/types/tools/vibe.d.ts +42 -18
  86. package/dist/types/tools/write.d.ts +10 -4
  87. package/dist/types/web/search/index.d.ts +20 -6
  88. package/package.json +13 -13
  89. package/scripts/legacy-pi-virtual-module.ts +2 -2
  90. package/src/advisor/advise-tool.ts +1 -1
  91. package/src/advisor/config.ts +1 -1
  92. package/src/autoresearch/tools/init-experiment.ts +1 -2
  93. package/src/autoresearch/tools/log-experiment.ts +1 -2
  94. package/src/autoresearch/tools/run-experiment.ts +1 -1
  95. package/src/autoresearch/tools/update-notes.ts +1 -1
  96. package/src/cli/gc-cli.ts +641 -21
  97. package/src/commit/agentic/tools/analyze-file.ts +1 -1
  98. package/src/commit/agentic/tools/git-file-diff.ts +1 -1
  99. package/src/commit/agentic/tools/git-hunk.ts +1 -1
  100. package/src/commit/agentic/tools/git-overview.ts +1 -1
  101. package/src/commit/agentic/tools/propose-changelog.ts +1 -1
  102. package/src/commit/agentic/tools/propose-commit.ts +1 -1
  103. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  104. package/src/commit/agentic/tools/schemas.ts +1 -1
  105. package/src/commit/agentic/tools/split-commit.ts +1 -1
  106. package/src/commit/analysis/summary.ts +1 -1
  107. package/src/commit/changelog/generate.ts +1 -1
  108. package/src/config/config-file.ts +2 -2
  109. package/src/config/model-discovery.ts +14 -14
  110. package/src/config/model-registry.ts +10 -8
  111. package/src/config/models-config-schema-bundle.ts +1 -8
  112. package/src/config/settings.ts +1 -1
  113. package/src/discovery/codex.ts +22 -9
  114. package/src/edit/hashline/params.ts +1 -1
  115. package/src/edit/modes/apply-patch.ts +1 -1
  116. package/src/edit/modes/patch.ts +1 -1
  117. package/src/edit/modes/replace.ts +2 -1
  118. package/src/eval/agent-bridge.ts +4 -2
  119. package/src/eval/bridge-timeout.ts +5 -2
  120. package/src/eval/completion-bridge.ts +3 -2
  121. package/src/eval/executor-base.ts +20 -1
  122. package/src/eval/js/context-manager.ts +94 -6
  123. package/src/eval/py/tool-bridge.ts +29 -9
  124. package/src/export/html/index.ts +10 -3
  125. package/src/export/share.ts +4 -0
  126. package/src/extensibility/custom-commands/loader.ts +3 -3
  127. package/src/extensibility/custom-commands/types.ts +4 -4
  128. package/src/extensibility/custom-tools/loader.ts +3 -3
  129. package/src/extensibility/custom-tools/types.ts +5 -4
  130. package/src/extensibility/extensions/loader.ts +3 -3
  131. package/src/extensibility/extensions/types.ts +5 -4
  132. package/src/extensibility/hooks/loader.ts +3 -3
  133. package/src/extensibility/hooks/types.ts +4 -4
  134. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  135. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  136. package/src/extensibility/legacy-typebox.ts +101 -0
  137. package/src/extensibility/plugins/legacy-pi-compat.ts +13 -22
  138. package/src/goals/tools/goal-tool.ts +1 -1
  139. package/src/launch/broker.ts +222 -6
  140. package/src/launch/client.ts +161 -9
  141. package/src/launch/protocol.ts +52 -0
  142. package/src/lsp/types.ts +1 -1
  143. package/src/main.ts +13 -6
  144. package/src/mcp/config-writer.ts +1 -1
  145. package/src/modes/components/btw-panel.ts +41 -4
  146. package/src/modes/controllers/btw-controller.ts +55 -7
  147. package/src/modes/controllers/command-controller.ts +31 -0
  148. package/src/modes/controllers/input-controller.ts +24 -7
  149. package/src/modes/interactive-mode.ts +16 -2
  150. package/src/modes/theme/theme.ts +3 -3
  151. package/src/modes/types.ts +8 -1
  152. package/src/prompts/session/launch-completion.md +1 -0
  153. package/src/sdk.ts +15 -0
  154. package/src/security/contracts/schemas.ts +202 -184
  155. package/src/security/contracts/validation.ts +6 -2
  156. package/src/security/publication.ts +1 -1
  157. package/src/security/store.ts +2 -2
  158. package/src/session/agent-session-types.ts +6 -0
  159. package/src/session/agent-session.ts +190 -14
  160. package/src/session/launch-completion.ts +37 -0
  161. package/src/session/session-context.ts +26 -0
  162. package/src/session/session-entries.ts +17 -1
  163. package/src/session/session-manager.ts +21 -0
  164. package/src/session/yield-queue.ts +121 -16
  165. package/src/slash-commands/builtin-registry.ts +10 -1
  166. package/src/task/types.ts +1 -1
  167. package/src/tools/ask.ts +1 -1
  168. package/src/tools/ast-edit.ts +1 -1
  169. package/src/tools/ast-grep.ts +1 -1
  170. package/src/tools/bash.ts +61 -3
  171. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  172. package/src/tools/browser.ts +1 -1
  173. package/src/tools/checkpoint.ts +1 -1
  174. package/src/tools/computer.ts +1 -1
  175. package/src/tools/debug.ts +1 -1
  176. package/src/tools/eval.ts +1 -1
  177. package/src/tools/gh.ts +1 -2
  178. package/src/tools/glob.ts +1 -1
  179. package/src/tools/grep.ts +1 -1
  180. package/src/tools/hub/index.ts +1 -1
  181. package/src/tools/hub/launch.ts +122 -6
  182. package/src/tools/image-gen.ts +1 -1
  183. package/src/tools/index.ts +9 -0
  184. package/src/tools/inspect-image.ts +1 -1
  185. package/src/tools/learn.ts +1 -1
  186. package/src/tools/manage-skill.ts +1 -1
  187. package/src/tools/memory-edit.ts +1 -1
  188. package/src/tools/memory-recall.ts +1 -1
  189. package/src/tools/memory-reflect.ts +1 -1
  190. package/src/tools/memory-retain.ts +1 -1
  191. package/src/tools/read.ts +1 -1
  192. package/src/tools/security-scan.ts +1 -1
  193. package/src/tools/todo.ts +1 -1
  194. package/src/tools/tts.ts +1 -1
  195. package/src/tools/vibe.ts +2 -1
  196. package/src/tools/write.ts +1 -1
  197. package/src/web/search/index.ts +2 -1
  198. package/dist/types/config/file-lock.d.ts +0 -29
  199. package/dist/types/extensibility/typebox.d.ts +0 -181
  200. package/src/config/file-lock.ts +0 -164
  201. package/src/extensibility/typebox.ts +0 -958
@@ -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", {
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
5
+ import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
6
6
  import { hostHasInheritableConsole } from "../eval/py/spawn-options";
7
7
  import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
8
8
  import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
@@ -11,11 +11,12 @@ import {
11
11
  DAEMON_IDLE_GRACE_ENV,
12
12
  DAEMON_PROJECT_DIR_ENV,
13
13
  DAEMON_RUNTIME_DIR_ENV,
14
+ type DaemonCompletionNotification,
14
15
  type DaemonOperation,
15
16
  type DaemonRpcResult,
16
- type DaemonWireResponse,
17
+ type DaemonWireMessage,
17
18
  parseDaemonRpcResult,
18
- parseDaemonWireResponse,
19
+ parseDaemonWireMessage,
19
20
  } from "./protocol";
20
21
  import { resolveDaemonSpawnOptions } from "./spawn-options";
21
22
 
@@ -43,14 +44,26 @@ export interface DaemonBrokerClientOptions {
43
44
  idleGraceMs?: number;
44
45
  }
45
46
 
47
+ export interface DaemonCompletionUnregisterOptions {
48
+ /** Detach this process without deleting broker-persisted pending notifications. */
49
+ preservePending?: boolean;
50
+ }
51
+
46
52
  /** Persistent per-process connection to one project or global daemon broker. */
47
53
  export interface DaemonBrokerClient {
54
+ onCompletion(
55
+ owner: string,
56
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
57
+ ): (options?: DaemonCompletionUnregisterOptions) => void;
48
58
  /** Canonical project directory or synthetic directory identifying a global scope. */
49
59
  readonly projectDir: string;
50
60
  request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
51
61
  close(): void;
52
62
  }
53
63
 
64
+ /** A request reached the broker and the broker rejected the operation. */
65
+ export class DaemonBrokerRejectedError extends Error {}
66
+
54
67
  async function canonicalProjectDir(projectDir: string): Promise<string> {
55
68
  const resolved = path.resolve(projectDir);
56
69
  try {
@@ -134,12 +147,20 @@ class SocketDaemonClient implements DaemonBrokerClient {
134
147
  readonly #runtimeDir: string;
135
148
  readonly #endpoint: string;
136
149
  readonly #token: string;
150
+ readonly #seenCompletionIds = new Set<string>();
137
151
  readonly #idleGraceMs: number | undefined;
138
152
  readonly #pending = new Map<string, PendingRequest>();
153
+ readonly #completionSinks = new Map<string, (notification: DaemonCompletionNotification) => Promise<void> | void>();
154
+ readonly #completionUnsubscribes = new Set<string>();
155
+ readonly #preservedCompletionOwners = new Set<string>();
156
+ readonly #completionReplays = new Set<string>();
157
+ readonly #inFlightCompletionIds = new Set<string>();
158
+ readonly #completionSubscriptionId = crypto.randomUUID();
139
159
  #socket: net.Socket | undefined;
140
160
  #connectPromise: Promise<void> | undefined;
141
161
  #buffer = "";
142
162
  #closed = false;
163
+ #completionReconnectTimer: NodeJS.Timeout | undefined;
143
164
 
144
165
  constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
145
166
  this.projectDir = projectDir;
@@ -156,6 +177,8 @@ class SocketDaemonClient implements DaemonBrokerClient {
156
177
  const socket = this.#socket;
157
178
  if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
158
179
 
180
+ const completionUnsubscribes = [...this.#completionUnsubscribes];
181
+ const completionReplays = [...this.#completionReplays];
159
182
  const id = crypto.randomUUID();
160
183
  const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
161
184
  const timer = setTimeout(() => {
@@ -176,18 +199,88 @@ class SocketDaemonClient implements DaemonBrokerClient {
176
199
  pending.removeAbort = () => signal.removeEventListener("abort", abort);
177
200
  }
178
201
  this.#pending.set(id, pending);
179
- socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
180
- return promise;
202
+ socket.write(
203
+ `${JSON.stringify({
204
+ id,
205
+ token: this.#token,
206
+ owners: [...this.#completionSinks.keys()],
207
+ detachedOwners: [...this.#preservedCompletionOwners],
208
+ completionEvents: true,
209
+ completionUnsubscribes,
210
+ completionReplays,
211
+ completionSubscriptionId: this.#completionSubscriptionId,
212
+ operation,
213
+ })}\n`,
214
+ );
215
+ const result = await promise;
216
+ for (const owner of completionUnsubscribes) {
217
+ if (!this.#completionSinks.has(owner)) this.#completionUnsubscribes.delete(owner);
218
+ }
219
+ for (const owner of completionReplays) {
220
+ if (this.#completionSinks.has(owner)) this.#completionReplays.delete(owner);
221
+ }
222
+ return result;
181
223
  }
182
224
 
183
225
  close(): void {
184
226
  if (this.#closed) return;
185
227
  this.#closed = true;
228
+ clearTimeout(this.#completionReconnectTimer);
229
+ this.#completionReconnectTimer = undefined;
186
230
  this.#socket?.destroy();
231
+ this.#completionSinks.clear();
232
+ this.#preservedCompletionOwners.clear();
233
+ this.#completionReplays.clear();
187
234
  this.#socket = undefined;
188
235
  this.#rejectPending(new Error("Daemon broker client closed"));
189
236
  }
190
237
 
238
+ onCompletion(
239
+ owner: string,
240
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
241
+ ): (options?: DaemonCompletionUnregisterOptions) => void {
242
+ this.#completionUnsubscribes.delete(owner);
243
+ if (this.#preservedCompletionOwners.delete(owner)) this.#completionReplays.add(owner);
244
+ this.#completionSinks.set(owner, sink);
245
+ this.#publishCompletionOwners();
246
+ return options => {
247
+ if (this.#completionSinks.get(owner) !== sink) return;
248
+ this.#completionSinks.delete(owner);
249
+ if (options?.preservePending) {
250
+ this.#preservedCompletionOwners.add(owner);
251
+ } else {
252
+ this.#preservedCompletionOwners.delete(owner);
253
+ this.#completionUnsubscribes.add(owner);
254
+ }
255
+ if (this.#completionSinks.size === 0 && this.#completionReconnectTimer) {
256
+ clearTimeout(this.#completionReconnectTimer);
257
+ this.#completionReconnectTimer = undefined;
258
+ }
259
+ this.#publishCompletionOwners();
260
+ };
261
+ }
262
+
263
+ #publishCompletionOwners(): void {
264
+ if (this.#closed) return;
265
+ void this.request({ op: "ping" }).catch(() => this.#scheduleCompletionReconnect());
266
+ }
267
+
268
+ #scheduleCompletionReconnect(): void {
269
+ if (
270
+ this.#closed ||
271
+ this.#completionSinks.size === 0 ||
272
+ this.#completionReconnectTimer !== undefined ||
273
+ (this.#socket !== undefined && !this.#socket.destroyed)
274
+ ) {
275
+ return;
276
+ }
277
+ this.#completionReconnectTimer = setTimeout(() => {
278
+ this.#completionReconnectTimer = undefined;
279
+ this.#publishCompletionOwners();
280
+ }, CONNECT_RETRY_MS);
281
+ this.#completionReconnectTimer.unref();
282
+ }
283
+
191
284
  async #connect(): Promise<void> {
192
285
  if (this.#socket && !this.#socket.destroyed) return;
193
286
  if (this.#connectPromise) return this.#connectPromise;
@@ -251,6 +344,7 @@ class SocketDaemonClient implements DaemonBrokerClient {
251
344
  socket.on("close", () => {
252
345
  if (this.#socket === socket) this.#socket = undefined;
253
346
  this.#rejectPending(new Error("Daemon broker connection closed"));
347
+ this.#scheduleCompletionReconnect();
254
348
  });
255
349
  }
256
350
 
@@ -262,21 +356,32 @@ class SocketDaemonClient implements DaemonBrokerClient {
262
356
  const line = this.#buffer.slice(0, newline);
263
357
  this.#buffer = this.#buffer.slice(newline + 1);
264
358
  if (line.length === 0) continue;
265
- let response: DaemonWireResponse;
359
+ let decoded: unknown;
360
+ try {
361
+ decoded = JSON.parse(line);
362
+ } catch (error) {
363
+ this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
364
+ continue;
365
+ }
366
+ let message: DaemonWireMessage;
266
367
  try {
267
- const decoded: unknown = JSON.parse(line);
268
- response = parseDaemonWireResponse(decoded);
368
+ message = parseDaemonWireMessage(decoded);
269
369
  } catch (error) {
270
370
  this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
271
371
  continue;
272
372
  }
373
+ if ("event" in message) {
374
+ void this.#deliverCompletion(message);
375
+ continue;
376
+ }
377
+ const response = message;
273
378
  const pending = this.#pending.get(response.id);
274
379
  if (!pending) continue;
275
380
  this.#pending.delete(response.id);
276
381
  clearTimeout(pending.timer);
277
382
  pending.removeAbort?.();
278
383
  if (!response.ok) {
279
- pending.reject(new Error(response.error));
384
+ pending.reject(new DaemonBrokerRejectedError(response.error));
280
385
  continue;
281
386
  }
282
387
  try {
@@ -287,6 +392,53 @@ class SocketDaemonClient implements DaemonBrokerClient {
287
392
  }
288
393
  }
289
394
 
395
+ async #deliverCompletion(message: DaemonCompletionNotification): Promise<void> {
396
+ if (this.#seenCompletionIds.has(message.completionId)) {
397
+ this.#ackCompletion(message.completionId);
398
+ return;
399
+ }
400
+ if (this.#inFlightCompletionIds.has(message.completionId)) return;
401
+ const sink = this.#completionSinks.get(message.owner);
402
+ if (!sink) return;
403
+ this.#inFlightCompletionIds.add(message.completionId);
404
+ try {
405
+ await sink(message);
406
+ if (this.#seenCompletionIds.size >= 512) {
407
+ const oldest = this.#seenCompletionIds.values().next().value;
408
+ if (oldest !== undefined) this.#seenCompletionIds.delete(oldest);
409
+ }
410
+ this.#seenCompletionIds.add(message.completionId);
411
+ this.#ackCompletion(message.completionId);
412
+ } catch (error) {
413
+ logger.warn("Daemon completion sink failed", {
414
+ owner: message.owner,
415
+ completionId: message.completionId,
416
+ error: error instanceof Error ? error.message : String(error),
417
+ });
418
+ this.#socket?.destroy();
419
+ } finally {
420
+ this.#inFlightCompletionIds.delete(message.completionId);
421
+ }
422
+ }
423
+
424
+ #ackCompletion(completionId: string): void {
425
+ const socket = this.#socket;
426
+ if (!socket || socket.destroyed) return;
427
+ socket.write(
428
+ `${JSON.stringify({
429
+ id: crypto.randomUUID(),
430
+ token: this.#token,
431
+ owners: [...this.#completionSinks.keys()],
432
+ detachedOwners: [...this.#preservedCompletionOwners],
433
+ completionEvents: true,
434
+ completionAcks: [completionId],
435
+ completionUnsubscribes: [...this.#completionUnsubscribes],
436
+ completionSubscriptionId: this.#completionSubscriptionId,
437
+ operation: { op: "ping" },
438
+ })}\n`,
439
+ );
440
+ }
441
+
290
442
  #rejectPending(error: Error): void {
291
443
  for (const pending of this.#pending.values()) {
292
444
  clearTimeout(pending.timer);