@fitchmultz/pi-subagents 0.50.0 → 0.51.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 (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
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.");
@@ -7,11 +7,11 @@ import { getBrokerRuntimeDir, prepareBrokerSocketPath } from "./paths.ts";
7
7
  import { brokerPidRecord } from "./pid.ts";
8
8
  import { assertBrokerCanStart } from "./spawn.ts";
9
9
  import { LastMessageRelay } from "./last.ts";
10
- import { assertMainParent, canSeeSession, findSessions } from "./targets.ts";
11
- import { acceptBrokerConnection } from "./server-connection.ts";
10
+ import { canSeeSession, findSessions } from "./targets.ts";
11
+ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.ts";
12
+ import { guardedDeliveryError } from "./guard.ts";
12
13
  import {
13
14
  compactTopicMessage,
14
- isSessionRegistration,
15
15
  normalizeMessage,
16
16
  normalizeSessionInfo,
17
17
  type SessionInfo,
@@ -41,6 +41,7 @@ interface ConnectedSession {
41
41
  topics: TopicSnapshot;
42
42
  readonly topicFrames: boolean;
43
43
  readonly lastMessages: boolean;
44
+ readonly receiptReads: boolean;
44
45
  }
45
46
 
46
47
  interface PendingReplaceDelivery {
@@ -153,17 +154,8 @@ class IntercomBroker {
153
154
  }
154
155
 
155
156
  private register(socket: net.Socket, msg: UnknownRecord, setId: (id: string) => void): void {
156
- if (!isSessionRegistration(msg.session)) {
157
- throw new Error("Invalid register message");
158
- }
159
- assertMainParent(msg.session, this.sessions);
160
- const requestedId =
161
- typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
162
- ? msg.requestedId
163
- : undefined;
164
- const id =
165
- requestedId !== undefined && !this.sessions.has(requestedId) ? requestedId : randomUUID();
166
- const info: SessionInfo = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
157
+ const info = registeredSessionInfo(msg, this.sessions);
158
+ const id = info.id;
167
159
  snapshotFrames(randomUUID(), [info], true);
168
160
  const topicFrames = msg.topicFrames === true;
169
161
  this.sessions.set(id, {
@@ -172,6 +164,7 @@ class IntercomBroker {
172
164
  topics: new TopicSnapshot(info),
173
165
  topicFrames,
174
166
  lastMessages: msg.lastMessages === true,
167
+ receiptReads: msg.receiptReads === 1,
175
168
  });
176
169
  setId(id);
177
170
  if (this.shutdownTimer) {
@@ -182,6 +175,8 @@ class IntercomBroker {
182
175
  type: "registered",
183
176
  sessionId: id,
184
177
  lastMessages: true,
178
+ receiptReads: 1,
179
+ ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
185
180
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
186
181
  });
187
182
  }
@@ -296,6 +291,15 @@ class IntercomBroker {
296
291
  const targets = findSessions(this.sessions, msg.to, currentId);
297
292
  const target = targets[0];
298
293
  if (targets.length === 1) {
294
+ const guardError = guardedDeliveryError(this.sessions, msg.to, message);
295
+ if (guardError !== null) {
296
+ writeMessage(socket, {
297
+ type: "delivery_failed",
298
+ messageId: message.id,
299
+ reason: guardError,
300
+ });
301
+ return;
302
+ }
299
303
  const fromSession = this.sessions.get(currentId);
300
304
  if (!fromSession) {
301
305
  writeMessage(socket, {
@@ -343,11 +347,15 @@ class IntercomBroker {
343
347
  "lastIntercomActivity",
344
348
  "subscriptions",
345
349
  "topics",
350
+ "applicationGuard",
346
351
  ]) {
347
352
  if (msg[key] !== undefined) {
348
353
  update[key] = msg[key];
349
354
  }
350
355
  }
356
+ if (msg.applicationGuard === null) {
357
+ delete update.applicationGuard;
358
+ }
351
359
  const nextInfo = normalizeSessionInfo(update);
352
360
  if (!nextInfo) {
353
361
  throw new Error("Invalid presence update");
@@ -366,15 +374,7 @@ class IntercomBroker {
366
374
  message: Message,
367
375
  ): SendResult {
368
376
  const target = this.sessions.get(toId);
369
- if (!target) {
370
- return {
371
- id: message.id,
372
- accepted: false,
373
- delivered: false,
374
- reason: "Recipient disconnected before delivery",
375
- };
376
- }
377
- if (shouldReplaceDelivery(message, target.info)) {
377
+ if (target && shouldReplaceDelivery(message, target.info)) {
378
378
  return this.queueReplaceDelivery(fromId, toId, from, message);
379
379
  }
380
380
  const reason = this.deliverMessage(toId, from, message);
@@ -465,6 +465,10 @@ class IntercomBroker {
465
465
  }
466
466
 
467
467
  private deliverMessage(toId: string, from: SessionInfo, message: Message): string | null {
468
+ const guardError = guardedDeliveryError(this.sessions, toId, message);
469
+ if (guardError !== null) {
470
+ return guardError;
471
+ }
468
472
  const target = this.sessions.get(toId);
469
473
  if (
470
474
  !target ||
@@ -1,15 +1,25 @@
1
1
  import type net from "node:net";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { errorMessage, type UnknownRecord } from "../../shared/unknown.ts";
4
- import type { LastMessageResult } from "../types.ts";
5
- import { isLastMessageQuery, isLastMessageResult } from "./last.ts";
4
+ import {
5
+ isNativeReadQuery,
6
+ isNativeReadResult,
7
+ isLastMessageResult,
8
+ isReceiptResult,
9
+ type NativeReadResult,
10
+ } from "./last.ts";
11
+ import type { LastMessageQuery, LastMessageResult, ReceiptQuery, ReceiptResult } from "../types.ts";
6
12
  import { writeMessage } from "./framing.ts";
7
13
 
8
14
  export class LastMessageRequests {
15
+ private readonly timeoutMs: number;
16
+ constructor(timeoutMs: number) {
17
+ this.timeoutMs = timeoutMs;
18
+ }
9
19
  private readonly pending = new Map<
10
20
  string,
11
21
  {
12
- readonly resolve: (result: LastMessageResult) => void;
22
+ readonly resolve: (result: NativeReadResult) => void;
13
23
  readonly reject: (error: Readonly<Error>) => void;
14
24
  readonly timer: NodeJS.Timeout;
15
25
  }
@@ -24,7 +34,7 @@ export class LastMessageRequests {
24
34
  }
25
35
 
26
36
  receive(message: UnknownRecord): void {
27
- if (typeof message.requestId !== "string" || !isLastMessageResult(message.result)) {
37
+ if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
28
38
  throw new Error("Invalid last-message response");
29
39
  }
30
40
  const pending = this.pending.get(message.requestId);
@@ -36,13 +46,16 @@ export class LastMessageRequests {
36
46
  pending.resolve(message.result);
37
47
  }
38
48
 
39
- request(
40
- socket: net.Socket,
41
- to: string,
42
- query: unknown,
43
- timeoutMs: number,
44
- ): Promise<LastMessageResult> {
45
- if (!isLastMessageQuery(query)) {
49
+ async last(socket: net.Socket, to: string, query: LastMessageQuery): Promise<LastMessageResult> {
50
+ const result = await this.request(socket, to, query);
51
+ return isLastMessageResult(result) ? result : { error: "Invalid last-message response" };
52
+ }
53
+ async receipt(socket: net.Socket, to: string, query: ReceiptQuery): Promise<ReceiptResult> {
54
+ const result = await this.request(socket, to, query);
55
+ return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
56
+ }
57
+ private request(socket: net.Socket, to: string, query: unknown): Promise<NativeReadResult> {
58
+ if (!isNativeReadQuery(query)) {
46
59
  return Promise.reject(new Error("Invalid last-message query"));
47
60
  }
48
61
  return new Promise((resolve, reject) => {
@@ -50,7 +63,7 @@ export class LastMessageRequests {
50
63
  const timer = setTimeout(() => {
51
64
  this.pending.delete(requestId);
52
65
  reject(new Error("Last-message read timeout"));
53
- }, timeoutMs);
66
+ }, this.timeoutMs);
54
67
  timer.unref();
55
68
  this.pending.set(requestId, { resolve, reject, timer });
56
69
  try {