@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,127 +1,12 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
3
  import { journalStamp } from "../shared/native-journal.js";
4
- import { isDeepStrictEqual } from "node:util";
5
4
  import { nativeProjection } from "../shared/journal-projections.js";
6
5
  import { scanJournal } from "../shared/journal-frames.js";
7
6
  import { isRecord } from "../shared/unknown.js";
8
7
  import { Assert } from "./typebox.js";
9
8
  import { contributionsSchema, resultSchema, } from "./contracts.js";
10
9
  import { read, runDirectory, write } from "./storage.js";
11
- export function sumUsage(values) {
12
- const total = {
13
- input: 0,
14
- output: 0,
15
- cacheRead: 0,
16
- cacheWrite: 0,
17
- totalTokens: 0,
18
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
19
- };
20
- for (const { usage } of values) {
21
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"]) {
22
- total[key] += usage[key];
23
- }
24
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"]) {
25
- total.cost[key] += usage.cost[key];
26
- }
27
- for (const key of ["reasoning", "cacheWrite1h"]) {
28
- if (usage[key] !== undefined) {
29
- total[key] = (total[key] ?? 0) + usage[key];
30
- }
31
- }
32
- }
33
- return total;
34
- }
35
- function intents(details) {
36
- if (!isRecord(details) || details.agentUsage === undefined) {
37
- return;
38
- }
39
- const values = details.agentUsage;
40
- Assert(contributionsSchema, values);
41
- return values;
42
- }
43
- function publishedContributions(entry) {
44
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) {
45
- return [];
46
- }
47
- const message = entry.message;
48
- if (message.role !== "toolResult" ||
49
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")) {
50
- return [];
51
- }
52
- const values = intents(message.details);
53
- if (values === undefined || !isDeepStrictEqual(sumUsage(values), message.usage)) {
54
- return [];
55
- }
56
- return values;
57
- }
58
- const receiptProjection = (keys) => {
59
- if (keys.length === 0 || keys[0] === "type") {
60
- return true;
61
- }
62
- if (keys[0] !== "message") {
63
- return false;
64
- }
65
- if (keys.length === 1 || ["role", "toolName", "usage"].includes(String(keys[1]))) {
66
- return true;
67
- }
68
- return keys[1] === "details" && (keys.length === 2 || keys[2] === "agentUsage");
69
- };
70
- function priorUsage(ctx) {
71
- const values = new Map();
72
- const file = ctx.sessionManager.getSessionFile();
73
- if (file !== undefined) {
74
- scanJournal(file, receiptProjection, ({ value: entry }) => {
75
- for (const value of publishedContributions(entry)) {
76
- values.set(value.id, value);
77
- }
78
- }, { policy: "live", requireNewline: true });
79
- }
80
- for (const entry of ctx.sessionManager.getEntries()) {
81
- for (const value of publishedContributions(entry)) {
82
- values.set(value.id, value);
83
- }
84
- }
85
- return values;
86
- }
87
- function uncharged(values, prior) {
88
- const pending = [];
89
- for (const value of values) {
90
- const earlier = prior.get(value.id);
91
- if (earlier && !isDeepStrictEqual(earlier, value)) {
92
- throw new Error(`Conflicting native usage contribution ${value.id}`);
93
- }
94
- if (!earlier) {
95
- pending.push(value);
96
- prior.set(value.id, value);
97
- }
98
- }
99
- return pending;
100
- }
101
- export function registerUsage(pi) {
102
- pi.on("message_end", (event, ctx) => {
103
- const message = event.message;
104
- if (message.role !== "toolResult" ||
105
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")) {
106
- return;
107
- }
108
- const values = intents(message.details);
109
- if (values === undefined || !isRecord(message.details)) {
110
- return;
111
- }
112
- const pending = uncharged(values, priorUsage(ctx));
113
- const { usage: _usage, ...rest } = message;
114
- // Finalized native tool messages are serialized even for concurrent tools.
115
- // Preparing or discarding a result cannot reserve a charge.
116
- return {
117
- message: {
118
- ...rest,
119
- details: { ...message.details, agentUsage: pending },
120
- ...(pending.length > 0 ? { usage: sumUsage(pending) } : {}),
121
- },
122
- };
123
- });
124
- }
125
10
  function billing(entry) {
126
11
  if (entry.checkpoint === true) {
127
12
  return;
@@ -1,23 +1,38 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { readOutputPage } from "../shared/output-page.js";
4
- import { actor, alive } from "./process.js";
4
+ import { actor, alive, observeAlive } from "./process.js";
5
5
  import { admissionSchema, commandSchema, exitSchema, successorSchema, } from "./contracts.js";
6
6
  import { conversation, read, runDirectory } from "./storage.js";
7
7
  import { published } from "./notifications.js";
8
8
  import { readCanonicalResult, readResult } from "./usage.js";
9
9
  import { Type } from "./typebox.js";
10
10
  const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
11
- export function toolResult(value, text = JSON.stringify(value, null, 2)) {
11
+ function toolResult(value, text) {
12
12
  return { content: [{ type: "text", text }], details: value };
13
13
  }
14
- function processView(base, launch) {
14
+ function receiptResult(result) {
15
+ return result === undefined
16
+ ? undefined
17
+ : {
18
+ id: result.id,
19
+ owner: result.owner,
20
+ status: result.status,
21
+ endedAt: result.endedAt,
22
+ terminalEntryId: result.terminalEntryId,
23
+ error: result.error,
24
+ accountingError: result.accountingError,
25
+ accountingPending: result.accountingPending,
26
+ };
27
+ }
28
+ /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
29
+ function processView(base, launch, observed) {
15
30
  const identity = actor(base, launch);
16
31
  const exit = identity
17
32
  ? read(path.join(conversation(base, launch), `exit-${identity.token}.json`), exitSchema)
18
33
  : undefined;
19
34
  return {
20
- live: identity !== undefined && alive(identity),
35
+ live: identity !== undefined && (observed?.get(identity.token) ?? alive(identity)),
21
36
  exit: exit ?? null,
22
37
  exitKnown: exit !== undefined,
23
38
  };
@@ -60,7 +75,7 @@ function guidanceView(base, launches) {
60
75
  }
61
76
  function metadata(base, launch, result, options) {
62
77
  const directory = runDirectory(base, launch.id);
63
- const process = processView(base, launch);
78
+ const process = processView(base, launch, options.observed);
64
79
  const outputFile = path.join(directory, "output.txt");
65
80
  const admission = read(path.join(conversation(base, launch), "admissions", `${launch.id}.json`), admissionSchema);
66
81
  const status = result?.status ??
@@ -89,12 +104,18 @@ function metadata(base, launch, result, options) {
89
104
  guidance: options.guidance,
90
105
  };
91
106
  }
92
- export function summaries(base, launches, ctx) {
107
+ async function summaries(base, launches, ctx) {
93
108
  const guidance = guidanceView(base, launches);
109
+ const identities = [
110
+ ...new Map(launches.map((launch) => [launch.rootId, actor(base, launch)])).values(),
111
+ ].filter((identity) => identity !== undefined);
112
+ const verdicts = await observeAlive(identities);
113
+ const observed = new Map(identities.map((identity, index) => [identity.token, verdicts[index]]));
94
114
  return launches.map((launch) => {
95
115
  const view = metadata(base, launch, readCanonicalResult(base, launch), {
96
116
  ctx,
97
117
  guidance: guidance.get(launch.id) ?? [],
118
+ observed,
98
119
  });
99
120
  return {
100
121
  ...view,
@@ -109,7 +130,7 @@ export function summaries(base, launches, ctx) {
109
130
  };
110
131
  });
111
132
  }
112
- export function projection(base, launch, ctx, input = {}) {
133
+ function projection(base, launch, ctx, input = {}) {
113
134
  const view = metadata(base, launch, readResult(base, launch), {
114
135
  ctx,
115
136
  guidance: guidanceView(base, [launch]).get(launch.id) ?? [],
@@ -119,6 +140,22 @@ export function projection(base, launch, ctx, input = {}) {
119
140
  output: outputView(view.outputFile, input),
120
141
  };
121
142
  }
143
+ export function renderReceipt(base, launch, ctx, receipt) {
144
+ const details = { ...projection(base, launch, ctx), ...receipt };
145
+ const model = { ...details, result: receiptResult(details.result) };
146
+ return toolResult(details, JSON.stringify(model, null, 2));
147
+ }
148
+ export async function renderInventory(base, launches, ctx, input) {
149
+ const offset = input.offset ?? 0;
150
+ const runs = await summaries(base, launches.slice(offset, offset + 50), ctx);
151
+ const details = {
152
+ runs,
153
+ offset,
154
+ nextOffset: offset + runs.length < launches.length ? offset + runs.length : undefined,
155
+ total: launches.length,
156
+ };
157
+ return toolResult(details, JSON.stringify(details, null, 2));
158
+ }
122
159
  function receiptText(details) {
123
160
  return details.nativeReceipt !== null
124
161
  ? `Native completion receipt: published (${details.nativeReceipt}); publication does not prove model consumption.`
@@ -148,15 +185,8 @@ function resultText(details) {
148
185
  `Native journal: ${details.sessionFile}`,
149
186
  ].join("\n");
150
187
  }
151
- export function render(base, launch, ctx, input = {}) {
152
- const view = projection(base, launch, ctx, input);
153
- const details = {
154
- ...view,
155
- receivedResult: input.receivedResult === true,
156
- ...(input.receivedResult === true && view.result?.contributions !== undefined
157
- ? { agentUsage: view.result.contributions }
158
- : {}),
159
- };
188
+ export function renderInspection(base, launch, ctx, input = {}) {
189
+ const details = projection(base, launch, ctx, input);
160
190
  return {
161
191
  ...toolResult(details, resultText(details)),
162
192
  isError: details.result?.status === "failed",
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import { journalStamp } from "../shared/native-journal.js";
3
+ import { missing } from "./storage.js";
4
+ /** Identity and change stamp of a path; "" when absent. */
5
+ export function pathStamp(file) {
6
+ try {
7
+ const stat = fs.statSync(file, { bigint: true, throwIfNoEntry: false });
8
+ return stat === undefined ? "" : journalStamp(stat);
9
+ }
10
+ catch {
11
+ return "unreadable";
12
+ }
13
+ }
14
+ /** Native change hints for one directory identity; owners still reconcile canonical files. */
15
+ export class DirectoryWatch {
16
+ watcher;
17
+ armed = "";
18
+ checked = "";
19
+ failed = false;
20
+ directory;
21
+ relevant;
22
+ wake;
23
+ constructor(directory, relevant, wake) {
24
+ this.directory = directory;
25
+ this.relevant = relevant;
26
+ this.wake = wake;
27
+ }
28
+ ensure() {
29
+ const stat = fs.statSync(this.directory, { bigint: true, throwIfNoEntry: false });
30
+ const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
31
+ const checked = this.checked;
32
+ this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
33
+ if (this.watcher !== undefined && identity === this.armed) {
34
+ return checked === this.checked ? "watching" : "changed";
35
+ }
36
+ // A replaced directory leaves an inode watch silent on Linux.
37
+ this.close();
38
+ return identity === "" || this.failed ? "unwatched" : this.arm(identity);
39
+ }
40
+ arm(identity) {
41
+ try {
42
+ const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
43
+ if (watcher === this.watcher && this.relevant(file)) {
44
+ this.wake();
45
+ }
46
+ });
47
+ watcher.on("error", () => {
48
+ if (watcher === this.watcher) {
49
+ this.failed = true;
50
+ this.close();
51
+ this.wake();
52
+ }
53
+ });
54
+ this.watcher = watcher;
55
+ this.armed = identity;
56
+ return "armed";
57
+ }
58
+ catch (error) {
59
+ // Unsupported platforms and watch limits keep their owner's polling fallback.
60
+ this.failed = !missing(error);
61
+ return "unwatched";
62
+ }
63
+ }
64
+ close() {
65
+ this.watcher?.close();
66
+ this.watcher = undefined;
67
+ this.armed = "";
68
+ }
69
+ }
70
+ /** A coalesced wake for one owner loop; raised until that loop takes it. */
71
+ export class Wake {
72
+ raised = true;
73
+ resolve;
74
+ raise = () => {
75
+ this.raised = true;
76
+ this.resolve?.();
77
+ };
78
+ take() {
79
+ const raised = this.raised;
80
+ this.raised = false;
81
+ return raised;
82
+ }
83
+ /** Returns at once for a wake raised since the last take. */
84
+ async sleep(ms) {
85
+ if (this.raised) {
86
+ return;
87
+ }
88
+ const { promise, resolve } = Promise.withResolvers();
89
+ const timer = setTimeout(() => {
90
+ resolve("elapsed");
91
+ }, ms);
92
+ this.resolve = () => {
93
+ resolve("woken");
94
+ };
95
+ await promise;
96
+ clearTimeout(timer);
97
+ this.resolve = undefined;
98
+ }
99
+ }
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.50.0","sha256":"030b516d81cb57e1faa44c99a09eb3eba9eca4a3b9d14e53836d7b54b900ced2"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.0","sha256":"a9faf29451051826907d9343b432c9232529ccaa68c75d681dbcb464702fc25f"});
@@ -0,0 +1,26 @@
1
+ import { type IntercomClientOptions as BrokerOptions } from "./pi-intercom/broker/client.ts";
2
+ import { type DeliveryGuard, type LastMessageQuery, type LastMessageResult, type ReceiptQuery, type ReceiptResult, type SendOptions, type SendResult, type SessionInfo } from "./pi-intercom/types.ts";
3
+ export type { ContextTarget, DeliveryGuard, LastMessageQuery, LastMessageResult, MessageDelivery, NativeReceipt, PublicationReplyHint, ReceiptQuery, ReceiptResult, SendResult, SessionInfo, } from "./pi-intercom/types.ts";
4
+ export type GuardedSendOptions = Omit<SendOptions, "messageId" | "guard" | "expectsReply" | "replyTo" | "human" | "topic" | "passive" | "delivery"> & {
5
+ readonly messageId: string;
6
+ readonly guard: DeliveryGuard;
7
+ readonly expectsReply: false;
8
+ readonly delivery: "queue" | "steer" | "passive";
9
+ };
10
+ export interface IntercomClientOptions extends BrokerOptions {
11
+ readonly spawn: false;
12
+ readonly session: Omit<SessionInfo, "id" | "guardedDelivery" | "applicationGuard" | "topics" | "subscriptions">;
13
+ readonly requestedId?: string;
14
+ readonly onError?: (error: Readonly<Error>) => void;
15
+ }
16
+ export interface IntercomClient {
17
+ readonly sessionId: string | null;
18
+ readonly isConnected: () => boolean;
19
+ readonly listSessions: () => Promise<readonly SessionInfo[]>;
20
+ readonly send: (to: string, options: GuardedSendOptions) => Promise<SendResult>;
21
+ readonly last: (to: string, query?: LastMessageQuery) => Promise<LastMessageResult>;
22
+ readonly receipt: (to: string, query: ReceiptQuery) => Promise<ReceiptResult>;
23
+ readonly disconnect: () => Promise<void>;
24
+ }
25
+ /** Connect to an existing native broker. No Pi runtime, broker spawn or automatic retry. */
26
+ export declare function connectIntercom(options: IntercomClientOptions): Promise<IntercomClient>;
@@ -0,0 +1,45 @@
1
+ import { IntercomClient as BrokerClient, } from "./pi-intercom/broker/client.js";
2
+ import { isDeliveryGuard, } from "./pi-intercom/types.js";
3
+ import { isRecord } from "./shared/unknown.js";
4
+ function requireNoSpawn(value) {
5
+ if (value !== false) {
6
+ throw new Error("The supported Intercom client requires spawn:false.");
7
+ }
8
+ }
9
+ function guardedSend(to, input) {
10
+ return (isRecord(input) &&
11
+ isDeliveryGuard(input.guard) &&
12
+ to === input.guard.peerId &&
13
+ typeof input.messageId === "string" &&
14
+ input.expectsReply === false &&
15
+ input.delivery !== undefined);
16
+ }
17
+ /** Connect to an existing native broker. No Pi runtime, broker spawn or automatic retry. */
18
+ export async function connectIntercom(options) {
19
+ requireNoSpawn(options.spawn);
20
+ const client = new BrokerClient(options);
21
+ client.on("error", (error) => options.onError?.(error));
22
+ try {
23
+ await client.connect(options.session, options.requestedId);
24
+ }
25
+ catch (error) {
26
+ await client.disconnect();
27
+ throw error;
28
+ }
29
+ return Object.freeze({
30
+ get sessionId() {
31
+ return client.sessionId;
32
+ },
33
+ isConnected: () => client.isConnected(),
34
+ listSessions: () => client.listSessions(),
35
+ async send(to, input) {
36
+ if (!guardedSend(to, input)) {
37
+ throw new Error("An exact guarded nonblocking send with an original message ID is required.");
38
+ }
39
+ return await client.send(to, input);
40
+ },
41
+ last: (to, query) => client.last(to, query),
42
+ receipt: (to, query) => client.receipt(to, query),
43
+ disconnect: () => client.disconnect(),
44
+ });
45
+ }
@@ -0,0 +1 @@
1
+ export declare function getPiAgentDir(): string;
@@ -4,6 +4,7 @@ import { IntercomClient } from "./broker/client.js";
4
4
  import { resolveSessionProjectId } from "./session-targets.js";
5
5
  import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
6
6
  import { Mailbox } from "./bridge-mailbox.js";
7
+ import { hasErrorCode } from "../shared/unknown.js";
7
8
  import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.js";
8
9
  const MAX_REQUESTS = 8;
9
10
  const IDLE_TIMEOUT = 5 * 60 * 1000;
@@ -222,7 +223,7 @@ export class Session {
222
223
  }
223
224
  catch (error) {
224
225
  cancelled(signal);
225
- if (error instanceof Error && error.message === "List sessions timeout") {
226
+ if (hasErrorCode(error, "list_timeout")) {
226
227
  throw new Fault(504, "list_timeout", "Broker session list deadline exceeded.");
227
228
  }
228
229
  throw new Fault(503, "broker_unavailable", "Broker session list failed.");
@@ -7,9 +7,11 @@ import { getBrokerRuntimeDir, prepareBrokerSocketPath } from "./paths.js";
7
7
  import { brokerPidRecord } from "./pid.js";
8
8
  import { assertBrokerCanStart } from "./spawn.js";
9
9
  import { LastMessageRelay } from "./last.js";
10
- import { assertMainParent, canSeeSession, findSessions } from "./targets.js";
11
- import { acceptBrokerConnection } from "./server-connection.js";
12
- import { compactTopicMessage, isSessionRegistration, normalizeMessage, normalizeSessionInfo, } from "../types.js";
10
+ import { canSeeSession, findSessions } from "./targets.js";
11
+ import { ParkedSessions, reachable } from "./parked.js";
12
+ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.js";
13
+ import { guardedDeliveryError } from "./guard.js";
14
+ import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
13
15
  import { errorMessage, isRecord } from "../../shared/unknown.js";
14
16
  import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
15
17
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
@@ -17,6 +19,7 @@ const REPLACE_DELIVERY_DELAY_MS = 1500;
17
19
  class IntercomBroker {
18
20
  sessions = new Map();
19
21
  lastMessages = new LastMessageRelay(this.sessions);
22
+ parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
20
23
  pendingReplaceDeliveries = new Map();
21
24
  server;
22
25
  shutdownTimer = null;
@@ -55,7 +58,7 @@ class IntercomBroker {
55
58
  }
56
59
  this.shutdownTimer = setTimeout(() => {
57
60
  this.shutdownTimer = null;
58
- if (this.sessions.size === 0) {
61
+ if (this.parking.exited) {
59
62
  console.log("No sessions connected, shutting down");
60
63
  this.shutdown();
61
64
  }
@@ -83,7 +86,7 @@ class IntercomBroker {
83
86
  }
84
87
  switch (msg.type) {
85
88
  case "unregister":
86
- this.removeSession(currentId);
89
+ this.removeSession(currentId, socket);
87
90
  setId(null);
88
91
  break;
89
92
  case "list":
@@ -93,7 +96,8 @@ class IntercomBroker {
93
96
  this.send(socket, msg, currentId);
94
97
  break;
95
98
  case "last_request":
96
- this.lastMessages.request(currentId, msg);
99
+ // Reads never spawn a parked child.
100
+ this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
97
101
  break;
98
102
  case "last_response":
99
103
  this.lastMessages.respond(currentId, msg);
@@ -101,28 +105,29 @@ class IntercomBroker {
101
105
  case "presence":
102
106
  this.presence(msg, currentId);
103
107
  break;
108
+ case "park":
109
+ case "unpark":
110
+ this.parking.park(socket, currentId, msg.type === "park");
111
+ break;
104
112
  default:
105
113
  throw new Error(`Unknown client message type: ${msg.type}`);
106
114
  }
107
115
  }
108
116
  register(socket, msg, setId) {
109
- if (!isSessionRegistration(msg.session)) {
110
- throw new Error("Invalid register message");
111
- }
112
- assertMainParent(msg.session, this.sessions);
113
- const requestedId = typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
114
- ? msg.requestedId
115
- : undefined;
116
- const id = requestedId !== undefined && !this.sessions.has(requestedId) ? requestedId : randomUUID();
117
- const info = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
117
+ const registered = registeredSessionInfo(msg, this.sessions);
118
+ const { id, resumed } = this.parking.claim(msg.requestedId, registered);
119
+ const info = { ...registered, id };
118
120
  snapshotFrames(randomUUID(), [info], true);
119
121
  const topicFrames = msg.topicFrames === true;
120
122
  this.sessions.set(id, {
121
123
  socket,
122
124
  info: messageSender(info),
123
- topics: new TopicSnapshot(info),
125
+ // A woken generation registers no topics; the parked identity's owner keeps them.
126
+ topics: resumed?.topics ?? new TopicSnapshot(info),
124
127
  topicFrames,
125
128
  lastMessages: msg.lastMessages === true,
129
+ parking: msg.parking === true,
130
+ receiptReads: msg.receiptReads === 1,
126
131
  });
127
132
  setId(id);
128
133
  if (this.shutdownTimer) {
@@ -133,18 +138,24 @@ class IntercomBroker {
133
138
  type: "registered",
134
139
  sessionId: id,
135
140
  lastMessages: true,
141
+ parking: msg.parking === true,
142
+ receiptReads: 1,
143
+ ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
136
144
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
137
145
  });
146
+ this.parking.registered(id, resumed);
138
147
  }
139
- removeSession(sessionId) {
148
+ removeSession(sessionId, socket) {
140
149
  const session = this.sessions.get(sessionId);
141
- if (!session) {
150
+ if (session?.socket !== socket) {
142
151
  return;
143
152
  }
144
- this.sessions.delete(sessionId);
145
153
  this.lastMessages.disconnected(sessionId);
146
- this.clearPendingReplaceDeliveries(sessionId);
147
- this.broadcast({ type: "session_left", sessionId }, session.info);
154
+ if (!this.parking.closed(session)) {
155
+ this.sessions.delete(sessionId);
156
+ this.clearPendingReplaceDeliveries(sessionId);
157
+ this.broadcast({ type: "session_left", sessionId }, session.info);
158
+ }
148
159
  this.scheduleShutdownCheck();
149
160
  }
150
161
  planPublications(fromId, from, publication) {
@@ -158,7 +169,7 @@ class IntercomBroker {
158
169
  continue;
159
170
  }
160
171
  const message = publicationMessage(publication, subscription.awaitRelease === true);
161
- const error = this.validateDeliveryPayload(from, message, target.topicFrames);
172
+ const error = this.validateDeliveryPayload(from, message, target);
162
173
  if (error !== null) {
163
174
  throw new Error(error);
164
175
  }
@@ -240,6 +251,15 @@ class IntercomBroker {
240
251
  const targets = findSessions(this.sessions, msg.to, currentId);
241
252
  const target = targets[0];
242
253
  if (targets.length === 1) {
254
+ const guardError = guardedDeliveryError(this.sessions, msg.to, message);
255
+ if (guardError !== null) {
256
+ writeMessage(socket, {
257
+ type: "delivery_failed",
258
+ messageId: message.id,
259
+ reason: guardError,
260
+ });
261
+ return;
262
+ }
243
263
  const fromSession = this.sessions.get(currentId);
244
264
  if (!fromSession) {
245
265
  writeMessage(socket, {
@@ -280,11 +300,15 @@ class IntercomBroker {
280
300
  "lastIntercomActivity",
281
301
  "subscriptions",
282
302
  "topics",
303
+ "applicationGuard",
283
304
  ]) {
284
305
  if (msg[key] !== undefined) {
285
306
  update[key] = msg[key];
286
307
  }
287
308
  }
309
+ if (msg.applicationGuard === null) {
310
+ delete update.applicationGuard;
311
+ }
288
312
  const nextInfo = normalizeSessionInfo(update);
289
313
  if (!nextInfo) {
290
314
  throw new Error("Invalid presence update");
@@ -297,22 +321,16 @@ class IntercomBroker {
297
321
  }
298
322
  sendToSession(fromId, toId, from, message) {
299
323
  const target = this.sessions.get(toId);
300
- if (!target) {
301
- return {
302
- id: message.id,
303
- accepted: false,
304
- delivered: false,
305
- reason: "Recipient disconnected before delivery",
306
- };
307
- }
308
- if (shouldReplaceDelivery(message, target.info)) {
324
+ if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
309
325
  return this.queueReplaceDelivery(fromId, toId, from, message);
310
326
  }
311
327
  const reason = this.deliverMessage(toId, from, message);
328
+ const queued = reason === null && target?.parked !== undefined;
312
329
  return {
313
330
  id: message.id,
314
331
  accepted: reason === null,
315
- delivered: reason === null,
332
+ delivered: reason === null && !queued,
333
+ ...(queued ? { queued } : {}),
316
334
  ...(reason !== null ? { reason } : {}),
317
335
  };
318
336
  }
@@ -333,7 +351,7 @@ class IntercomBroker {
333
351
  return `${fromId}\0${toId}\0${threadId}`;
334
352
  }
335
353
  queueReplaceDelivery(fromId, toId, from, message) {
336
- const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId)?.topicFrames === true);
354
+ const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
337
355
  if (validationError !== null) {
338
356
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
339
357
  }
@@ -365,24 +383,25 @@ class IntercomBroker {
365
383
  }
366
384
  }
367
385
  }
368
- validateDeliveryPayload(from, message, topicFrames) {
386
+ /** One admission rule; retained input is checked as the uncompacted envelope any later
387
+ * transport generation accepts. */
388
+ validateDeliveryPayload(from, message, target) {
389
+ const compact = target?.parked === undefined && target?.topicFrames === true;
369
390
  return (validateIntercomMessageSize({
370
391
  type: "message",
371
392
  from: messageSender(from),
372
- message: topicFrames ? compactTopicMessage(message) : message,
393
+ message: compact ? compactTopicMessage(message) : message,
373
394
  })?.message ?? null);
374
395
  }
375
396
  deliverMessage(toId, from, message) {
376
397
  const target = this.sessions.get(toId);
377
- if (!target ||
378
- target.socket.destroyed ||
379
- target.socket.writableEnded ||
380
- !target.socket.writable) {
381
- return "Recipient disconnected before delivery";
398
+ const refused = guardedDeliveryError(this.sessions, toId, message) ??
399
+ this.validateDeliveryPayload(from, message, target);
400
+ if (refused !== null || this.parking.retain(target, from, message)) {
401
+ return refused;
382
402
  }
383
- const validationError = this.validateDeliveryPayload(from, message, target.topicFrames);
384
- if (validationError !== null) {
385
- return validationError;
403
+ if (!reachable(target)) {
404
+ return "Recipient disconnected before delivery";
386
405
  }
387
406
  this.touchActivity(toId, true);
388
407
  try {