@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
@@ -19,15 +19,19 @@ export function validateIntercomMessageSize(msg: unknown): Error | null {
19
19
  * Format: 4-byte big-endian length + JSON payload
20
20
  */
21
21
  export function writeMessage(socket: Socket, msg: unknown): void {
22
- const json = JSON.stringify(msg);
22
+ writeFrame(socket, JSON.stringify(msg));
23
+ }
24
+
25
+ /** Write serialized JSON; false means Node accepted it but the caller should wait for drain. */
26
+ export function writeFrame(socket: Socket, json: string, limit = MAX_FRAME_SIZE_BYTES): boolean {
23
27
  const size = Buffer.byteLength(json, "utf-8");
24
- if (size > MAX_FRAME_SIZE_BYTES) {
25
- throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
28
+ if (size > limit) {
29
+ throw new Error(`Intercom message too large (${size} bytes; max ${limit})`);
26
30
  }
27
31
  const payload = Buffer.from(json, "utf-8");
28
32
  const header = Buffer.alloc(4);
29
33
  header.writeUInt32BE(payload.length, 0);
30
- socket.write(Buffer.concat([header, payload]));
34
+ return socket.write(Buffer.concat([header, payload]));
31
35
  }
32
36
 
33
37
  /** Retain fragments until a header or payload is complete; copy each fragmented value once. */
@@ -88,6 +92,7 @@ class MessageBuffer {
88
92
  export function createMessageReader(
89
93
  onMessage: (msg: unknown) => void,
90
94
  onError: (error: Readonly<Error>) => void,
95
+ limit = MAX_FRAME_SIZE_BYTES,
91
96
  ): (data: Buffer) => void {
92
97
  const buffer = new MessageBuffer();
93
98
  let length: number | undefined;
@@ -98,10 +103,8 @@ export function createMessageReader(
98
103
  while (buffer.length >= (length ?? 4)) {
99
104
  if (length === undefined) {
100
105
  length = buffer.read(4).readUInt32BE(0);
101
- if (length > MAX_FRAME_SIZE_BYTES) {
102
- onError(
103
- new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
104
- );
106
+ if (length > limit) {
107
+ onError(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
105
108
  buffer.clear();
106
109
  length = undefined;
107
110
  return;
@@ -0,0 +1,33 @@
1
+ import type { Message, SessionInfo } from "../types.ts";
2
+
3
+ /** Guarded commands use an exact main, never convenience names or a replacement context. */
4
+ export function guardedDeliveryError(
5
+ sessions: ReadonlyMap<string, { readonly info: SessionInfo }>,
6
+ to: string,
7
+ message: Message,
8
+ ): string | null {
9
+ const guard = message.guard;
10
+ if (!guard) {
11
+ return null;
12
+ }
13
+ const registered = sessions.get(to);
14
+ if (!registered || to !== guard.peerId) {
15
+ return "Guarded target not found";
16
+ }
17
+ const target = registered.info;
18
+ if (target.guardedDelivery !== 1) {
19
+ return "Recipient does not support guarded delivery";
20
+ }
21
+ if (
22
+ target.parentSessionId !== undefined ||
23
+ target.rootSessionId !== guard.rootSessionId ||
24
+ target.incarnation !== guard.incarnation ||
25
+ target.applicationGuard !== guard.applicationGuard
26
+ ) {
27
+ return "Guarded target changed";
28
+ }
29
+ const mains = [...sessions.values()].filter(
30
+ ({ info }) => info.rootSessionId === guard.rootSessionId && info.parentSessionId === undefined,
31
+ );
32
+ return mains.length === 1 ? null : "Guarded root is ambiguous";
33
+ }
@@ -2,34 +2,68 @@ import type net from "node:net";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { isRecord, type UnknownRecord } from "../../shared/unknown.ts";
4
4
  import {
5
+ isDeliveryGuard,
6
+ isPublicationReplyHint,
7
+ isLastMessageQuery,
8
+ isReceiptQuery,
9
+ LAST_MESSAGE_PAGE_LIMIT,
5
10
  normalizeSessionInfo,
6
11
  type LastMessageQuery,
7
12
  type LastMessageResult,
8
13
  type SessionInfo,
14
+ type NativeReceipt,
15
+ type ReceiptQuery,
16
+ type ReceiptResult,
9
17
  } from "../types.ts";
18
+ import { finiteNumber, nonNegativeInteger, optionalLabel, safeLabel } from "../validation.ts";
10
19
  import { validateIntercomMessageSize, writeMessage } from "./framing.ts";
11
20
  import { messageSender } from "./snapshots.ts";
12
21
  import { findSessions } from "./targets.ts";
13
22
 
14
- export const LAST_MESSAGE_PAGE_LIMIT = 16384;
23
+ export type NativeReadQuery = LastMessageQuery | ReceiptQuery;
24
+ export type NativeReadResult = LastMessageResult | ReceiptResult;
15
25
 
16
- function nonNegativeInteger(value: unknown): value is number {
17
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
26
+ export function isNativeReadQuery(value: unknown): value is NativeReadQuery {
27
+ return isReceiptQuery(value) || isLastMessageQuery(value);
18
28
  }
19
- function optionalEntryId(value: unknown): boolean {
20
- return value === undefined || (typeof value === "string" && value.length > 0);
29
+ function isNativeReceipt(value: unknown): value is NativeReceipt {
30
+ if (!isReceiptQuery(value) || !isRecord(value) || !isRecord(value.sender)) {
31
+ return false;
32
+ }
33
+ return (
34
+ value.version === 1 &&
35
+ safeLabel(value.sender.peerId) &&
36
+ [value.sender.rootSessionId, value.sender.incarnation].every(optionalLabel) &&
37
+ typeof value.textSha256 === "string" &&
38
+ /^[a-f0-9]{64}$/.test(value.textSha256) &&
39
+ receiptPublication(value) &&
40
+ receiptFacts(value)
41
+ );
21
42
  }
22
- export function isLastMessageQuery(value: unknown): value is LastMessageQuery {
43
+ function receiptPublication(value: UnknownRecord): boolean {
23
44
  return (
24
- isRecord(value) &&
25
- optionalEntryId(value.entryId) &&
26
- (value.offset === undefined || nonNegativeInteger(value.offset)) &&
27
- (value.limit === undefined ||
28
- (nonNegativeInteger(value.limit) &&
29
- value.limit > 0 &&
30
- value.limit <= LAST_MESSAGE_PAGE_LIMIT))
45
+ typeof value.delivery === "string" &&
46
+ ["queue", "steer", "passive"].includes(value.delivery) &&
47
+ (value.guard === undefined || isDeliveryGuard(value.guard)) &&
48
+ (value.replyHint === undefined || isPublicationReplyHint(value.replyHint))
31
49
  );
32
50
  }
51
+ function receiptFacts(value: UnknownRecord): boolean {
52
+ return (
53
+ safeLabel(value.checkpointEntryId) &&
54
+ finiteNumber(value.receivedAt) &&
55
+ [value.queued, value.nativeHandoff, value.discarded].every(
56
+ (field) => typeof field === "boolean",
57
+ ) &&
58
+ (value.placementEntryId === null || safeLabel(value.placementEntryId))
59
+ );
60
+ }
61
+ export function isReceiptResult(value: unknown): value is ReceiptResult {
62
+ return isRecord(value) && (typeof value.error === "string" || isNativeReceipt(value.receipt));
63
+ }
64
+ export function isNativeReadResult(value: unknown): value is NativeReadResult {
65
+ return isReceiptResult(value) || isLastMessageResult(value);
66
+ }
33
67
  function pageIdentity(value: UnknownRecord): boolean {
34
68
  return (
35
69
  normalizeSessionInfo(value.session) !== null &&
@@ -65,18 +99,38 @@ interface LastPeer {
65
99
  readonly socket: net.Socket;
66
100
  readonly info: SessionInfo;
67
101
  readonly lastMessages: boolean;
102
+ readonly receiptReads: boolean;
103
+ readonly parked?: { readonly closed: boolean };
68
104
  }
69
105
  interface PendingLast {
70
106
  readonly fromId: string;
71
107
  readonly toId: string;
108
+ /** A parked child's main answers from the child's captured native history. */
109
+ readonly via?: string;
72
110
  readonly requestId: string;
73
- readonly query: LastMessageQuery;
111
+ readonly query: NativeReadQuery;
74
112
  readonly timer: NodeJS.Timeout;
75
113
  }
114
+ /** An exited parked child answers nothing itself; only its main holds the captured history. */
115
+ function exitedRefusal(
116
+ target: LastPeer,
117
+ query: NativeReadQuery,
118
+ via?: LastPeer,
119
+ ): string | undefined {
120
+ if (target.parked?.closed !== true) {
121
+ return undefined;
122
+ }
123
+ // No loaded journal owner answers receipts, with or without its main.
124
+ if ("messageId" in query) {
125
+ return "publication_unavailable";
126
+ }
127
+ return via === undefined ? "Parked session history is unavailable." : undefined;
128
+ }
76
129
  function unavailable(
77
130
  targets: readonly LastPeer[],
78
131
  name: string,
79
132
  pending: number,
133
+ { query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer },
80
134
  ): string | undefined {
81
135
  if (targets.length === 0) {
82
136
  return "Session not found";
@@ -84,26 +138,65 @@ function unavailable(
84
138
  if (targets.length > 1) {
85
139
  return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
86
140
  }
141
+ const exited = exitedRefusal(targets[0], query, via);
142
+ if (exited !== undefined) {
143
+ return exited;
144
+ }
87
145
  if (!targets[0].lastMessages) {
88
146
  return "Recipient does not support last-message reads; reload its extension.";
89
147
  }
148
+ if (
149
+ "messageId" in query &&
150
+ (!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)
151
+ ) {
152
+ return "publication_unavailable";
153
+ }
90
154
  if (pending >= 128) {
91
155
  return "Too many pending last-message reads; retry after they settle.";
92
156
  }
93
157
  return undefined;
94
158
  }
95
- function matchesPage(result: LastMessageResult, pending: PendingLast, fromId: string): boolean {
159
+ function matchesPage(result: NativeReadResult, pending: PendingLast, fromId: string): boolean {
96
160
  if ("error" in result) {
97
161
  return true;
98
162
  }
163
+ if ("messageId" in pending.query) {
164
+ return (
165
+ "receipt" in result &&
166
+ result.receipt.messageId === pending.query.messageId &&
167
+ result.receipt.rootSessionId === pending.query.rootSessionId
168
+ );
169
+ }
170
+ if ("receipt" in result) {
171
+ return false;
172
+ }
173
+ return matchesLastPage(result, pending.query, fromId);
174
+ }
175
+
176
+ function matchesLastPage(
177
+ result: Exclude<LastMessageResult, { readonly error: string }>,
178
+ query: LastMessageQuery,
179
+ fromId: string,
180
+ ): boolean {
99
181
  return (
100
182
  result.session.id === fromId &&
101
- result.offset === (pending.query.offset ?? 0) &&
102
- (pending.query.entryId === undefined || result.entryId === pending.query.entryId) &&
103
- result.text.length <= (pending.query.limit ?? LAST_MESSAGE_PAGE_LIMIT)
183
+ result.offset === (query.offset ?? 0) &&
184
+ (query.entryId === undefined || result.entryId === query.entryId) &&
185
+ result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT)
104
186
  );
105
187
  }
106
188
 
189
+ function lastRequest(message: UnknownRecord): {
190
+ readonly requestId: string;
191
+ readonly to: string;
192
+ readonly query: NativeReadQuery;
193
+ } {
194
+ const { requestId, to, query } = message;
195
+ if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
196
+ throw new Error("Invalid last message request");
197
+ }
198
+ return { requestId, to, query };
199
+ }
107
200
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
108
201
  export class LastMessageRelay {
109
202
  private readonly pending = new Map<string, PendingLast>();
@@ -112,42 +205,36 @@ export class LastMessageRelay {
112
205
  this.sessions = sessions;
113
206
  }
114
207
 
115
- request(fromId: string, message: UnknownRecord): void {
116
- if (
117
- typeof message.requestId !== "string" ||
118
- typeof message.to !== "string" ||
119
- !isLastMessageQuery(message.query)
120
- ) {
121
- throw new Error("Invalid last message request");
122
- }
208
+ /** `reader` names another peer that answers for an exited target from its captured history. */
209
+ request(
210
+ fromId: string,
211
+ input: UnknownRecord,
212
+ reader: (id: string) => LastPeer | undefined,
213
+ ): void {
214
+ const message = lastRequest(input);
123
215
  const targets = findSessions(this.sessions, message.to, fromId);
124
216
  const target = targets[0];
125
217
  const from = this.sessions.get(fromId);
126
218
  if (!from) {
127
219
  return;
128
220
  }
129
- const error = unavailable(targets, message.to, this.pending.size);
130
- if (error !== undefined) {
131
- writeMessage(from.socket, {
132
- type: "last_response",
133
- requestId: message.requestId,
134
- result: { error },
135
- });
136
- return;
137
- }
138
221
  const token = randomUUID();
222
+ const via = targets.length === 1 ? reader(target.info.id) : undefined;
139
223
  const frame = {
140
224
  type: "last_request" as const,
141
225
  requestId: token,
142
226
  from: messageSender(from.info),
143
227
  query: message.query,
228
+ parked: via && messageSender(target.info),
144
229
  };
145
- const invalid = validateIntercomMessageSize(frame);
146
- if (invalid) {
230
+ const error =
231
+ unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
232
+ validateIntercomMessageSize(frame)?.message;
233
+ if (error !== undefined) {
147
234
  writeMessage(from.socket, {
148
235
  type: "last_response",
149
236
  requestId: message.requestId,
150
- result: { error: invalid.message },
237
+ result: { error },
151
238
  });
152
239
  return;
153
240
  }
@@ -159,12 +246,13 @@ export class LastMessageRelay {
159
246
  this.pending.set(token, {
160
247
  fromId,
161
248
  toId: target.info.id,
249
+ via: via?.info.id,
162
250
  requestId: message.requestId,
163
251
  query: message.query,
164
252
  timer,
165
253
  });
166
254
  try {
167
- writeMessage(target.socket, frame);
255
+ writeMessage((via ?? target).socket, frame);
168
256
  } catch {
169
257
  this.finish(token, { error: "Recipient disconnected before last-message read" });
170
258
  }
@@ -175,13 +263,13 @@ export class LastMessageRelay {
175
263
  throw new Error("Invalid last message response");
176
264
  }
177
265
  const pending = this.pending.get(message.requestId);
178
- if (!pending || pending.toId !== fromId) {
266
+ if (!pending || (pending.via ?? pending.toId) !== fromId) {
179
267
  return;
180
268
  }
181
269
  const result = message.result;
182
270
  this.finish(
183
271
  message.requestId,
184
- isLastMessageResult(result) && matchesPage(result, pending, fromId)
272
+ isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
185
273
  ? result
186
274
  : { error: "Recipient returned an invalid last-message page" },
187
275
  );
@@ -189,13 +277,13 @@ export class LastMessageRelay {
189
277
 
190
278
  disconnected(sessionId: string): void {
191
279
  for (const [token, pending] of this.pending) {
192
- if (pending.fromId === sessionId || pending.toId === sessionId) {
280
+ if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
193
281
  this.finish(token, { error: "Peer disconnected during last-message read" });
194
282
  }
195
283
  }
196
284
  }
197
285
 
198
- private finish(token: string, result: LastMessageResult): void {
286
+ private finish(token: string, result: NativeReadResult): void {
199
287
  const pending = this.pending.get(token);
200
288
  if (!pending) {
201
289
  return;
@@ -0,0 +1,130 @@
1
+ import type net from "net";
2
+ import { randomUUID } from "crypto";
3
+ import type { Message, SessionInfo } from "../types.ts";
4
+ import { nativePeerId } from "../session-targets.ts";
5
+ import { writeMessage } from "./framing.ts";
6
+
7
+ type Retained = Array<{ readonly from: SessionInfo; readonly message: Message }>;
8
+ interface Parked {
9
+ readonly retained: Retained;
10
+ /** The fenced actor's socket closed: input waits for its restored replacement. */
11
+ closed: boolean;
12
+ }
13
+ export interface ParkablePeer {
14
+ readonly socket: net.Socket;
15
+ readonly info: SessionInfo;
16
+ /** This client can park itself, or wake and answer for its parked children. */
17
+ readonly parking: boolean;
18
+ parked?: Parked;
19
+ }
20
+ export function reachable<T extends ParkablePeer>(target?: T): target is T {
21
+ const socket = target?.socket;
22
+ return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
23
+ }
24
+ type Deliver = (id: string, from: SessionInfo, message: Message) => string | null;
25
+ function requested(id: unknown): string | undefined {
26
+ return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
27
+ }
28
+
29
+ /** A fenced child keeps its identity and queued input; it never departs while parked. */
30
+ export class ParkedSessions<T extends ParkablePeer> {
31
+ private readonly sessions: ReadonlyMap<string, T>;
32
+ private readonly deliver: Deliver;
33
+ constructor(sessions: ReadonlyMap<string, T>, deliver: Deliver) {
34
+ this.sessions = sessions;
35
+ this.deliver = deliver;
36
+ }
37
+ park(socket: net.Socket, id: string, on: boolean): void {
38
+ const session = this.sessions.get(id);
39
+ if (on && session?.parking === true) {
40
+ session.parked ??= { retained: [], closed: false };
41
+ // Frames already written to this socket precede the acknowledgement.
42
+ writeMessage(socket, { type: "parked" });
43
+ } else if (!on && session?.parked?.closed === false) {
44
+ const { retained } = session.parked;
45
+ session.parked = undefined;
46
+ this.flush(id, retained);
47
+ }
48
+ }
49
+ /**
50
+ * Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
51
+ * next generation; delivery refusals on a live transport follow resident delivery.
52
+ */
53
+ private flush(id: string, retained: Retained): void {
54
+ for (const [index, { from, message }] of retained.entries()) {
55
+ const session = this.sessions.get(id);
56
+ if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
57
+ session.parked = { retained: retained.slice(index), closed: false };
58
+ return;
59
+ }
60
+ }
61
+ }
62
+ /** A free requested identity, or a parked one resumed by the same child lineage. */
63
+ claim(requestedId: unknown, next: Omit<SessionInfo, "id">): { id: string; resumed?: T } {
64
+ const id = requested(requestedId);
65
+ const previous = id === undefined ? undefined : this.sessions.get(id);
66
+ const resumes =
67
+ previous?.parked !== undefined &&
68
+ previous.info.rootSessionId === next.rootSessionId &&
69
+ previous.info.parentSessionId === next.parentSessionId;
70
+ if (id === undefined || (previous && !resumes)) {
71
+ return { id: randomUUID() };
72
+ }
73
+ previous?.socket.destroy();
74
+ return { id, resumed: previous };
75
+ }
76
+ /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
77
+ registered(id: string, previous?: T): void {
78
+ this.flush(id, previous?.parked?.retained ?? []);
79
+ for (const child of this.sessions.values()) {
80
+ if (nativePeerId(child.info.rootSessionId ?? "") === id) {
81
+ this.wake(child);
82
+ }
83
+ }
84
+ }
85
+ /** A transport handoff, not a departure: no session_left and queued input stays. */
86
+ closed(session: T): boolean {
87
+ const { parked } = session;
88
+ if (parked) {
89
+ parked.closed = true;
90
+ this.wake(session);
91
+ }
92
+ return parked !== undefined;
93
+ }
94
+ /**
95
+ * Exited parked children keep identities while some live session remains. Input already
96
+ * admitted for one keeps the broker until a returning main wakes it.
97
+ */
98
+ get exited(): boolean {
99
+ return [...this.sessions.values()].every(
100
+ ({ parked }) => parked?.closed === true && parked.retained.length === 0,
101
+ );
102
+ }
103
+ retain(target: T | undefined, from: SessionInfo, message: Message): boolean {
104
+ target?.parked?.retained.push({ from, message });
105
+ if (target) {
106
+ this.wake(target);
107
+ }
108
+ return target?.parked !== undefined;
109
+ }
110
+ /** Reads of an exited parked child go to its main, which answers from captured history. */
111
+ reader(id: string): T | undefined {
112
+ const child = this.sessions.get(id);
113
+ return child?.parked?.closed === true ? this.main(child) : undefined;
114
+ }
115
+ // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
116
+ private wake(child: T): void {
117
+ const main = this.main(child);
118
+ const wakes = child.parked?.retained.some(
119
+ ({ message }) =>
120
+ message.passive !== true && message.delivery !== "passive" && message.topic === undefined,
121
+ );
122
+ if (main && child.parked?.closed === true && wakes === true) {
123
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
124
+ }
125
+ }
126
+ private main(child: T): T | undefined {
127
+ const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
128
+ return main?.parking === true && main.parked === undefined ? main : undefined;
129
+ }
130
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, isAbsolute, join } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { getPiAgentDir } from "../agent-dir.ts";
6
6
 
7
7
  const MAX_UNIX_SOCKET_PATH_BYTES = 100;
@@ -36,37 +36,64 @@ export function isOwnedBrokerSocket(socketPath: string): boolean {
36
36
  }
37
37
  }
38
38
 
39
- /** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
40
- export function getBrokerRuntimeDir(): string {
39
+ function defaultRuntimeRoot(): string {
40
+ if (process.platform !== "android") {
41
+ return "/tmp";
42
+ }
43
+ return process.env.PREFIX === undefined ? tmpdir() : join(process.env.PREFIX, "tmp");
44
+ }
45
+
46
+ function runtimeDirectory(socketName: string, sibling = ""): string {
41
47
  const configured = process.env.PI_INTERCOM_RUNTIME_DIR;
42
48
  const uid = process.getuid?.();
43
49
  if (uid === undefined) {
44
50
  throw new Error("Intercom requires Unix user IDs and Unix domain sockets.");
45
51
  }
46
- const directory = configured ?? join("/tmp", `pi-intercom-${uid}`);
52
+ const base = configured ?? join(defaultRuntimeRoot(), `pi-intercom-${uid}`);
53
+ const directory = isAbsolute(base) && sibling !== "" ? `${resolve(base)}${sibling}` : base;
47
54
  if (
48
55
  !isAbsolute(directory) ||
49
56
  directory.includes("\0") ||
50
- Buffer.byteLength(join(directory, "broker.sock")) > MAX_UNIX_SOCKET_PATH_BYTES
57
+ Buffer.byteLength(join(directory, socketName)) > MAX_UNIX_SOCKET_PATH_BYTES
51
58
  ) {
52
59
  throw new Error("PI_INTERCOM_RUNTIME_DIR must be an absolute short Unix socket directory.");
53
60
  }
54
61
  return directory;
55
62
  }
56
63
 
64
+ /** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
65
+ export function getBrokerRuntimeDir(): string {
66
+ return runtimeDirectory("broker.sock");
67
+ }
68
+
69
+ /** Read-only child observation endpoints share the runtime policy, not the broker namespace.
70
+ * The endpoint name is a short token digest because the private runtime directory also counts
71
+ * toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
72
+ export function agentObservationSocketPath(token: string): string {
73
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(token)) {
74
+ throw new Error("Invalid actor token.");
75
+ }
76
+ const name = `obs-${createHash("sha256").update(token).digest("hex").slice(0, 16)}.sock`;
77
+ return join(runtimeDirectory(name, "-agents"), name);
78
+ }
79
+
57
80
  export function getBrokerSocketPath(): string {
58
81
  return join(getBrokerRuntimeDir(), "broker.sock");
59
82
  }
60
83
 
61
- export function prepareBrokerSocketPath(): string {
62
- const socketPath = getBrokerSocketPath();
63
- const brokerDir = dirname(socketPath);
64
- fs.mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
65
- const stat = fs.lstatSync(brokerDir);
84
+ /** Create or verify an owned, non-symlink, private socket directory. */
85
+ export function preparePrivateSocketPath(socketPath: string): string {
86
+ const directory = dirname(socketPath);
87
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
88
+ const stat = fs.lstatSync(directory);
66
89
  const uid = process.getuid?.();
67
90
  if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
68
- throw new Error(`Unsafe intercom socket directory: ${brokerDir}`);
91
+ throw new Error(`Unsafe intercom socket directory: ${directory}`);
69
92
  }
70
- fs.chmodSync(brokerDir, 0o700);
93
+ fs.chmodSync(directory, 0o700);
71
94
  return socketPath;
72
95
  }
96
+
97
+ export function prepareBrokerSocketPath(): string {
98
+ return preparePrivateSocketPath(getBrokerSocketPath());
99
+ }
@@ -80,6 +80,11 @@ export function brokerPidRecord(): string {
80
80
  }
81
81
 
82
82
  export function isBrokerPidReused(pid: number, record: string): boolean {
83
+ return isLinuxProcessReplaced(pid, record.slice(record.indexOf("\n") + 1));
84
+ }
85
+
86
+ /** True only when a live Linux PID provably names a different process than the identity. */
87
+ export function isLinuxProcessReplaced(pid: number, identity: string): boolean {
83
88
  const current = readLinuxProcess(pid);
84
89
  if (!current) {
85
90
  return false;
@@ -87,7 +92,7 @@ export function isBrokerPidReused(pid: number, record: string): boolean {
87
92
  if (current.tgid !== pid) {
88
93
  return true;
89
94
  }
90
- const saved = IDENTITY.exec(record.slice(record.indexOf("\n") + 1).trim());
95
+ const saved = IDENTITY.exec(identity.trim());
91
96
  const actual = IDENTITY.exec(current.identity ?? "");
92
97
  if (!saved || !actual) {
93
98
  return false;
@@ -1,5 +1,30 @@
1
1
  import type net from "node:net";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { createMessageReader } from "./framing.ts";
4
+ import { assertMainParent } from "./targets.ts";
5
+ import { isSessionRegistration, type SessionInfo } from "../types.ts";
6
+ import type { UnknownRecord } from "../../shared/unknown.ts";
7
+
8
+ export function registeredSessionInfo(
9
+ msg: UnknownRecord,
10
+ sessions: ReadonlyMap<string, { readonly info: SessionInfo }>,
11
+ ): SessionInfo {
12
+ if (!isSessionRegistration(msg.session)) {
13
+ throw new Error("Invalid register message");
14
+ }
15
+ assertMainParent(msg.session, sessions);
16
+ const requestedId =
17
+ typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
18
+ ? msg.requestedId
19
+ : undefined;
20
+ const id = requestedId !== undefined && !sessions.has(requestedId) ? requestedId : randomUUID();
21
+ return {
22
+ ...msg.session,
23
+ id,
24
+ guardedDelivery: msg.guardedDelivery === 1 ? msg.session.guardedDelivery : undefined,
25
+ lastSeen: msg.session.lastSeen ?? Date.now(),
26
+ };
27
+ }
3
28
 
4
29
  type ClientMessageHandler = (
5
30
  socket: net.Socket,
@@ -12,7 +37,7 @@ type ClientMessageHandler = (
12
37
  export function acceptBrokerConnection(
13
38
  socket: net.Socket,
14
39
  handleMessage: ClientMessageHandler,
15
- disconnected: (sessionId: string) => void,
40
+ disconnected: (sessionId: string, socket: net.Socket) => void,
16
41
  ): void {
17
42
  let sessionId: string | null = null;
18
43
  socket.on(
@@ -29,7 +54,7 @@ export function acceptBrokerConnection(
29
54
  );
30
55
  socket.on("close", () => {
31
56
  if (sessionId !== null) {
32
- disconnected(sessionId);
57
+ disconnected(sessionId, socket);
33
58
  }
34
59
  });
35
60
  socket.on("error", (error: Error) => {