@fitchmultz/pi-subagents 0.50.0 → 0.52.0

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 (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -1,12 +1,8 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Usage } from "@earendil-works/pi-ai";
3
1
  import * as path from "node:path";
4
2
  import * as fs from "node:fs";
5
3
  import { journalStamp } from "../shared/native-journal.ts";
6
- import { isDeepStrictEqual } from "node:util";
7
4
  import { nativeProjection } from "../shared/journal-projections.ts";
8
5
  import { scanJournal } from "../shared/journal-frames.ts";
9
- import type { Projection } from "../shared/json-projection.ts";
10
6
  import { isRecord } from "../shared/unknown.ts";
11
7
  import { Assert } from "./typebox.ts";
12
8
  import {
@@ -18,132 +14,6 @@ import {
18
14
  } from "./contracts.ts";
19
15
  import { read, runDirectory, write } from "./storage.ts";
20
16
 
21
- export function sumUsage(values: readonly Contribution[]): Usage {
22
- const total: Usage = {
23
- input: 0,
24
- output: 0,
25
- cacheRead: 0,
26
- cacheWrite: 0,
27
- totalTokens: 0,
28
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
29
- };
30
- for (const { usage } of values) {
31
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
32
- total[key] += usage[key];
33
- }
34
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
35
- total.cost[key] += usage.cost[key];
36
- }
37
- for (const key of ["reasoning", "cacheWrite1h"] as const) {
38
- if (usage[key] !== undefined) {
39
- total[key] = (total[key] ?? 0) + usage[key];
40
- }
41
- }
42
- }
43
- return total;
44
- }
45
- function intents(details: unknown): Contribution[] | undefined {
46
- if (!isRecord(details) || details.agentUsage === undefined) {
47
- return;
48
- }
49
- const values: unknown = details.agentUsage;
50
- Assert(contributionsSchema, values);
51
- return values;
52
- }
53
- function publishedContributions(entry: unknown): Contribution[] {
54
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) {
55
- return [];
56
- }
57
- const message = entry.message;
58
- if (
59
- message.role !== "toolResult" ||
60
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")
61
- ) {
62
- return [];
63
- }
64
- const values = intents(message.details);
65
- if (values === undefined || !isDeepStrictEqual(sumUsage(values), message.usage)) {
66
- return [];
67
- }
68
- return values;
69
- }
70
- const receiptProjection: Projection = (keys) => {
71
- if (keys.length === 0 || keys[0] === "type") {
72
- return true;
73
- }
74
- if (keys[0] !== "message") {
75
- return false;
76
- }
77
- if (keys.length === 1 || ["role", "toolName", "usage"].includes(String(keys[1]))) {
78
- return true;
79
- }
80
- return keys[1] === "details" && (keys.length === 2 || keys[2] === "agentUsage");
81
- };
82
- function priorUsage(ctx: ExtensionContext): Map<string, Contribution> {
83
- const values = new Map<string, Contribution>();
84
- const file = ctx.sessionManager.getSessionFile();
85
- if (file !== undefined) {
86
- scanJournal(
87
- file,
88
- receiptProjection,
89
- ({ value: entry }) => {
90
- for (const value of publishedContributions(entry)) {
91
- values.set(value.id, value);
92
- }
93
- },
94
- { policy: "live", requireNewline: true },
95
- );
96
- }
97
- for (const entry of ctx.sessionManager.getEntries()) {
98
- for (const value of publishedContributions(entry)) {
99
- values.set(value.id, value);
100
- }
101
- }
102
- return values;
103
- }
104
- function uncharged(
105
- values: readonly Contribution[],
106
- prior: Map<string, Contribution>,
107
- ): Contribution[] {
108
- const pending: Contribution[] = [];
109
- for (const value of values) {
110
- const earlier = prior.get(value.id);
111
- if (earlier && !isDeepStrictEqual(earlier, value)) {
112
- throw new Error(`Conflicting native usage contribution ${value.id}`);
113
- }
114
- if (!earlier) {
115
- pending.push(value);
116
- prior.set(value.id, value);
117
- }
118
- }
119
- return pending;
120
- }
121
- export function registerUsage(pi: ExtensionAPI): void {
122
- pi.on("message_end", (event, ctx) => {
123
- const message = event.message;
124
- if (
125
- message.role !== "toolResult" ||
126
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")
127
- ) {
128
- return;
129
- }
130
- const values = intents(message.details);
131
- if (values === undefined || !isRecord(message.details)) {
132
- return;
133
- }
134
- const pending = uncharged(values, priorUsage(ctx));
135
- const { usage: _usage, ...rest } = message;
136
- // Finalized native tool messages are serialized even for concurrent tools.
137
- // Preparing or discarding a result cannot reserve a charge.
138
- return {
139
- message: {
140
- ...rest,
141
- details: { ...message.details, agentUsage: pending },
142
- ...(pending.length > 0 ? { usage: sumUsage(pending) } : {}),
143
- },
144
- };
145
- });
146
- }
147
17
  function billing(entry: Record<string, unknown>) {
148
18
  if (entry.checkpoint === true) {
149
19
  return;
package/src/agent/view.ts CHANGED
@@ -2,16 +2,16 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { readOutputPage } from "../shared/output-page.ts";
5
- import { actor, alive } from "./process.ts";
5
+ import { actor, alive, observeAlive } from "./process.ts";
6
6
  import {
7
7
  admissionSchema,
8
8
  commandSchema,
9
9
  exitSchema,
10
10
  successorSchema,
11
11
  type Launch,
12
- type RunsInput,
13
12
  type Result,
14
13
  } from "./contracts.ts";
14
+ import type { RunsInput } from "./tool-schemas.ts";
15
15
  import { conversation, read, runDirectory } from "./storage.ts";
16
16
  import { published } from "./notifications.ts";
17
17
  import { readCanonicalResult, readResult } from "./usage.ts";
@@ -56,16 +56,37 @@ interface RunView extends Omit<RunSummary, "result"> {
56
56
  result?: Result;
57
57
  output: ReturnType<typeof outputView>;
58
58
  }
59
- export function toolResult(value: unknown, text = JSON.stringify(value, null, 2)): ToolReply {
59
+ type Receipt =
60
+ | { readonly receipt: "launch"; readonly note: string }
61
+ | { readonly receipt: "continuation" | "existing-successor" }
62
+ | { readonly receipt: "guidance"; readonly command: string; readonly note: string }
63
+ | { readonly stopRequest?: string; readonly stopAcknowledged: false; readonly note: string };
64
+
65
+ function toolResult(value: unknown, text: string): ToolReply {
60
66
  return { content: [{ type: "text" as const, text }], details: value };
61
67
  }
62
- function processView(base: string, launch: Launch) {
68
+ function receiptResult(result: Result | undefined) {
69
+ return result === undefined
70
+ ? undefined
71
+ : {
72
+ id: result.id,
73
+ owner: result.owner,
74
+ status: result.status,
75
+ endedAt: result.endedAt,
76
+ terminalEntryId: result.terminalEntryId,
77
+ error: result.error,
78
+ accountingError: result.accountingError,
79
+ accountingPending: result.accountingPending,
80
+ };
81
+ }
82
+ /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
83
+ function processView(base: string, launch: Launch, observed?: ReadonlyMap<string, boolean>) {
63
84
  const identity = actor(base, launch);
64
85
  const exit = identity
65
86
  ? read(path.join(conversation(base, launch), `exit-${identity.token}.json`), exitSchema)
66
87
  : undefined;
67
88
  return {
68
- live: identity !== undefined && alive(identity),
89
+ live: identity !== undefined && (observed?.get(identity.token) ?? alive(identity)),
69
90
  exit: exit ?? null,
70
91
  exitKnown: exit !== undefined,
71
92
  };
@@ -120,10 +141,14 @@ function metadata(
120
141
  base: string,
121
142
  launch: Launch,
122
143
  result: Result | undefined,
123
- options: { ctx?: ExtensionContext; guidance: Guidance[] },
144
+ options: {
145
+ ctx?: ExtensionContext;
146
+ guidance: Guidance[];
147
+ observed?: ReadonlyMap<string, boolean>;
148
+ },
124
149
  ): Omit<RunView, "output"> {
125
150
  const directory = runDirectory(base, launch.id);
126
- const process = processView(base, launch);
151
+ const process = processView(base, launch, options.observed);
127
152
  const outputFile = path.join(directory, "output.txt");
128
153
  const admission = read(
129
154
  path.join(conversation(base, launch), "admissions", `${launch.id}.json`),
@@ -156,16 +181,22 @@ function metadata(
156
181
  guidance: options.guidance,
157
182
  };
158
183
  }
159
- export function summaries(
184
+ async function summaries(
160
185
  base: string,
161
186
  launches: readonly Launch[],
162
187
  ctx?: ExtensionContext,
163
- ): RunSummary[] {
188
+ ): Promise<RunSummary[]> {
164
189
  const guidance = guidanceView(base, launches);
190
+ const identities = [
191
+ ...new Map(launches.map((launch) => [launch.rootId, actor(base, launch)])).values(),
192
+ ].filter((identity) => identity !== undefined);
193
+ const verdicts = await observeAlive(identities);
194
+ const observed = new Map(identities.map((identity, index) => [identity.token, verdicts[index]]));
165
195
  return launches.map((launch) => {
166
196
  const view = metadata(base, launch, readCanonicalResult(base, launch), {
167
197
  ctx,
168
198
  guidance: guidance.get(launch.id) ?? [],
199
+ observed,
169
200
  });
170
201
  return {
171
202
  ...view,
@@ -180,7 +211,7 @@ export function summaries(
180
211
  };
181
212
  });
182
213
  }
183
- export function projection(
214
+ function projection(
184
215
  base: string,
185
216
  launch: Launch,
186
217
  ctx?: ExtensionContext,
@@ -195,6 +226,32 @@ export function projection(
195
226
  output: outputView(view.outputFile, input),
196
227
  };
197
228
  }
229
+ export function renderReceipt(
230
+ base: string,
231
+ launch: Launch,
232
+ ctx: ExtensionContext | undefined,
233
+ receipt: Receipt,
234
+ ): ToolReply {
235
+ const details = { ...projection(base, launch, ctx), ...receipt };
236
+ const model = { ...details, result: receiptResult(details.result) };
237
+ return toolResult(details, JSON.stringify(model, null, 2));
238
+ }
239
+ export async function renderInventory(
240
+ base: string,
241
+ launches: readonly Launch[],
242
+ ctx: ExtensionContext | undefined,
243
+ input: Pick<RunsInput, "offset">,
244
+ ): Promise<ToolReply> {
245
+ const offset = input.offset ?? 0;
246
+ const runs = await summaries(base, launches.slice(offset, offset + 50), ctx);
247
+ const details = {
248
+ runs,
249
+ offset,
250
+ nextOffset: offset + runs.length < launches.length ? offset + runs.length : undefined,
251
+ total: launches.length,
252
+ };
253
+ return toolResult(details, JSON.stringify(details, null, 2));
254
+ }
198
255
  function receiptText(details: RunView): string {
199
256
  return details.nativeReceipt !== null
200
257
  ? `Native completion receipt: published (${details.nativeReceipt}); publication does not prove model consumption.`
@@ -225,20 +282,13 @@ function resultText(details: RunView): string {
225
282
  `Native journal: ${details.sessionFile}`,
226
283
  ].join("\n");
227
284
  }
228
- export function render(
285
+ export function renderInspection(
229
286
  base: string,
230
287
  launch: Launch,
231
288
  ctx?: ExtensionContext,
232
- input: Pick<RunsInput, "offset" | "length"> & { receivedResult?: boolean } = {},
289
+ input: Pick<RunsInput, "offset" | "length"> = {},
233
290
  ): ToolReply {
234
- const view = projection(base, launch, ctx, input);
235
- const details = {
236
- ...view,
237
- receivedResult: input.receivedResult === true,
238
- ...(input.receivedResult === true && view.result?.contributions !== undefined
239
- ? { agentUsage: view.result.contributions }
240
- : {}),
241
- };
291
+ const details = projection(base, launch, ctx, input);
242
292
  return {
243
293
  ...toolResult(details, resultText(details)),
244
294
  isError: details.result?.status === "failed",
@@ -0,0 +1,107 @@
1
+ import * as fs from "node:fs";
2
+ import { journalStamp } from "../shared/native-journal.ts";
3
+ import { missing } from "./storage.ts";
4
+
5
+ /** Identity and change stamp of a path; "" when absent. */
6
+ export function pathStamp(file: string): string {
7
+ try {
8
+ const stat = fs.statSync(file, { bigint: true, throwIfNoEntry: false });
9
+ return stat === undefined ? "" : journalStamp(stat);
10
+ } catch {
11
+ return "unreadable";
12
+ }
13
+ }
14
+
15
+ /**
16
+ * `armed`: hints start now, so reconcile anything published earlier.
17
+ * `changed`: the directory's own entries changed since the previous check, which also
18
+ * covers hints the OS dropped.
19
+ */
20
+ export type WatchState = "unwatched" | "armed" | "changed" | "watching";
21
+
22
+ /** Native change hints for one directory identity; owners still reconcile canonical files. */
23
+ export class DirectoryWatch {
24
+ private watcher?: fs.FSWatcher;
25
+ private armed = "";
26
+ private checked = "";
27
+ private failed = false;
28
+ private readonly directory: string;
29
+ private readonly relevant: (file: string | null) => boolean;
30
+ private readonly wake: () => void;
31
+ constructor(directory: string, relevant: (file: string | null) => boolean, wake: () => void) {
32
+ this.directory = directory;
33
+ this.relevant = relevant;
34
+ this.wake = wake;
35
+ }
36
+ ensure(): WatchState {
37
+ const stat = fs.statSync(this.directory, { bigint: true, throwIfNoEntry: false });
38
+ const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
39
+ const checked = this.checked;
40
+ this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
41
+ if (this.watcher !== undefined && identity === this.armed) {
42
+ return checked === this.checked ? "watching" : "changed";
43
+ }
44
+ // A replaced directory leaves an inode watch silent on Linux.
45
+ this.close();
46
+ return identity === "" || this.failed ? "unwatched" : this.arm(identity);
47
+ }
48
+ private arm(identity: string): WatchState {
49
+ try {
50
+ const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
51
+ if (watcher === this.watcher && this.relevant(file)) {
52
+ this.wake();
53
+ }
54
+ });
55
+ watcher.on("error", () => {
56
+ if (watcher === this.watcher) {
57
+ this.failed = true;
58
+ this.close();
59
+ this.wake();
60
+ }
61
+ });
62
+ this.watcher = watcher;
63
+ this.armed = identity;
64
+ return "armed";
65
+ } catch (error) {
66
+ // Unsupported platforms and watch limits keep their owner's polling fallback.
67
+ this.failed = !missing(error);
68
+ return "unwatched";
69
+ }
70
+ }
71
+ close(): void {
72
+ this.watcher?.close();
73
+ this.watcher = undefined;
74
+ this.armed = "";
75
+ }
76
+ }
77
+
78
+ /** A coalesced wake for one owner loop; raised until that loop takes it. */
79
+ export class Wake {
80
+ private raised = true;
81
+ private resolve?: () => void;
82
+ readonly raise = (): void => {
83
+ this.raised = true;
84
+ this.resolve?.();
85
+ };
86
+ take(): boolean {
87
+ const raised = this.raised;
88
+ this.raised = false;
89
+ return raised;
90
+ }
91
+ /** Returns at once for a wake raised since the last take. */
92
+ async sleep(ms: number): Promise<void> {
93
+ if (this.raised) {
94
+ return;
95
+ }
96
+ const { promise, resolve } = Promise.withResolvers<"woken" | "elapsed">();
97
+ const timer = setTimeout(() => {
98
+ resolve("elapsed");
99
+ }, ms);
100
+ this.resolve = () => {
101
+ resolve("woken");
102
+ };
103
+ await promise;
104
+ clearTimeout(timer);
105
+ this.resolve = undefined;
106
+ }
107
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ IntercomClient as BrokerClient,
3
+ type IntercomClientOptions as BrokerOptions,
4
+ } from "./pi-intercom/broker/client.ts";
5
+ import {
6
+ isDeliveryGuard,
7
+ type DeliveryGuard,
8
+ type LastMessageQuery,
9
+ type LastMessageResult,
10
+ type ReceiptQuery,
11
+ type ReceiptResult,
12
+ type SendOptions,
13
+ type SendResult,
14
+ type SessionInfo,
15
+ } from "./pi-intercom/types.ts";
16
+ import { isRecord } from "./shared/unknown.ts";
17
+
18
+ export type {
19
+ ContextTarget,
20
+ DeliveryGuard,
21
+ LastMessageQuery,
22
+ LastMessageResult,
23
+ MessageDelivery,
24
+ NativeReceipt,
25
+ PublicationReplyHint,
26
+ ReceiptQuery,
27
+ ReceiptResult,
28
+ SendResult,
29
+ SessionInfo,
30
+ } from "./pi-intercom/types.ts";
31
+
32
+ export type GuardedSendOptions = Omit<
33
+ SendOptions,
34
+ "messageId" | "guard" | "expectsReply" | "replyTo" | "human" | "topic" | "passive" | "delivery"
35
+ > & {
36
+ readonly messageId: string;
37
+ readonly guard: DeliveryGuard;
38
+ readonly expectsReply: false;
39
+ readonly delivery: "queue" | "steer" | "passive";
40
+ };
41
+
42
+ export interface IntercomClientOptions extends BrokerOptions {
43
+ readonly spawn: false;
44
+ readonly session: Omit<
45
+ SessionInfo,
46
+ "id" | "guardedDelivery" | "applicationGuard" | "topics" | "subscriptions"
47
+ >;
48
+ readonly requestedId?: string;
49
+ readonly onError?: (error: Readonly<Error>) => void;
50
+ }
51
+
52
+ export interface IntercomClient {
53
+ readonly sessionId: string | null;
54
+ readonly isConnected: () => boolean;
55
+ readonly listSessions: () => Promise<readonly SessionInfo[]>;
56
+ readonly send: (to: string, options: GuardedSendOptions) => Promise<SendResult>;
57
+ readonly last: (to: string, query?: LastMessageQuery) => Promise<LastMessageResult>;
58
+ readonly receipt: (to: string, query: ReceiptQuery) => Promise<ReceiptResult>;
59
+ readonly disconnect: () => Promise<void>;
60
+ }
61
+
62
+ function requireNoSpawn(value: unknown): void {
63
+ if (value !== false) {
64
+ throw new Error("The supported Intercom client requires spawn:false.");
65
+ }
66
+ }
67
+
68
+ function guardedSend(to: string, input: unknown): boolean {
69
+ return (
70
+ isRecord(input) &&
71
+ isDeliveryGuard(input.guard) &&
72
+ to === input.guard.peerId &&
73
+ typeof input.messageId === "string" &&
74
+ input.expectsReply === false &&
75
+ input.delivery !== undefined
76
+ );
77
+ }
78
+
79
+ /** Connect to an existing native broker. No Pi runtime, broker spawn or automatic retry. */
80
+ export async function connectIntercom(options: IntercomClientOptions): Promise<IntercomClient> {
81
+ requireNoSpawn(options.spawn);
82
+ const client = new BrokerClient(options);
83
+ client.on("error", (error: Readonly<Error>) => options.onError?.(error));
84
+ try {
85
+ await client.connect(options.session, options.requestedId);
86
+ } catch (error) {
87
+ await client.disconnect();
88
+ throw error;
89
+ }
90
+ return Object.freeze({
91
+ get sessionId() {
92
+ return client.sessionId;
93
+ },
94
+ isConnected: () => client.isConnected(),
95
+ listSessions: () => client.listSessions(),
96
+ async send(to: string, input: GuardedSendOptions) {
97
+ if (!guardedSend(to, input)) {
98
+ throw new Error(
99
+ "An exact guarded nonblocking send with an original message ID is required.",
100
+ );
101
+ }
102
+ return await client.send(to, input);
103
+ },
104
+ last: (to: string, query?: LastMessageQuery) => client.last(to, query),
105
+ receipt: (to: string, query: ReceiptQuery) => client.receipt(to, query),
106
+ disconnect: () => client.disconnect(),
107
+ });
108
+ }
@@ -6,7 +6,7 @@ import type { Message, SendResult, SessionInfo } from "./types.ts";
6
6
  import type { Identity } from "./bridge-config.ts";
7
7
  import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
8
8
  import { Mailbox } from "./bridge-mailbox.ts";
9
- import type { UnknownRecord } from "../shared/unknown.ts";
9
+ import { hasErrorCode, type UnknownRecord } from "../shared/unknown.ts";
10
10
  import type { Operation } from "./bridge-routes.ts";
11
11
  import type { RequestContext } from "./bridge-http.ts";
12
12
  import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
@@ -272,7 +272,7 @@ export class Session {
272
272
  peers = await wait(this.client.listSessions(), signal);
273
273
  } catch (error) {
274
274
  cancelled(signal);
275
- if (error instanceof Error && error.message === "List sessions timeout") {
275
+ if (hasErrorCode(error, "list_timeout")) {
276
276
  throw new Fault(504, "list_timeout", "Broker session list deadline exceeded.");
277
277
  }
278
278
  throw new Fault(503, "broker_unavailable", "Broker session list failed.");