@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
@@ -5,7 +5,7 @@ import { writeMessage } from "./framing.ts";
5
5
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
6
  import { SnapshotReader } from "./snapshot-reader.ts";
7
7
  import { LastMessageRequests } from "./client-last.ts";
8
- import { isLastMessageQuery, isLastMessageResult } from "./last.ts";
8
+ import { isNativeReadQuery, isNativeReadResult, type NativeReadQuery } from "./last.ts";
9
9
  import {
10
10
  errorMessage,
11
11
  isRecord,
@@ -18,16 +18,16 @@ import {
18
18
  normalizeSessionInfo,
19
19
  type SessionInfo,
20
20
  type Message,
21
- type Attachment,
22
21
  type MessageDelivery,
23
- type QueueMode,
24
- type HumanMessageOrigin,
25
22
  type TopicUpdate,
26
23
  type TopicChange,
27
24
  type SessionSnapshot,
28
25
  type SendResult,
29
26
  type LastMessageQuery,
30
27
  type LastMessageResult,
28
+ type SendOptions,
29
+ type ReceiptQuery,
30
+ type ReceiptResult,
31
31
  } from "../types.ts";
32
32
  export type { SendResult } from "../types.ts";
33
33
 
@@ -43,20 +43,6 @@ export interface IntercomClientOptions {
43
43
  readonly listTimeoutMs?: number;
44
44
  }
45
45
 
46
- interface SendOptions {
47
- readonly text: string;
48
- readonly attachments?: readonly Attachment[];
49
- readonly replyTo?: string;
50
- readonly expectsReply?: boolean;
51
- readonly delivery?: MessageDelivery;
52
- readonly queueMode?: QueueMode;
53
- readonly threadId?: string;
54
- readonly passive?: boolean;
55
- readonly messageId?: string;
56
- readonly human?: HumanMessageOrigin;
57
- readonly topic?: TopicUpdate;
58
- }
59
-
60
46
  function toError(error: unknown): Error {
61
47
  return error instanceof Error ? error : new Error(errorMessage(error));
62
48
  }
@@ -79,7 +65,9 @@ export class IntercomClient extends EventEmitter {
79
65
  private _sessionId: string | null = null;
80
66
  private _topicsSupported = false;
81
67
  private lastMessagesSupported = false;
82
- private readonly lastRequests = new LastMessageRequests();
68
+ private receiptReadsSupported = false;
69
+ private guardedDeliverySupported = false;
70
+ private readonly lastRequests: LastMessageRequests;
83
71
  private readonly pendingSends = new Map<
84
72
  string,
85
73
  { readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
@@ -101,6 +89,7 @@ export class IntercomClient extends EventEmitter {
101
89
  super();
102
90
  this.sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
103
91
  this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
92
+ this.lastRequests = new LastMessageRequests(this.listTimeoutMs);
104
93
  }
105
94
 
106
95
  private failPending(error: Readonly<Error>): void {
@@ -122,6 +111,9 @@ export class IntercomClient extends EventEmitter {
122
111
  get supportsTopics(): boolean {
123
112
  return this.isConnected() && this._topicsSupported;
124
113
  }
114
+ get supportsGuardedDelivery(): boolean {
115
+ return this.isConnected() && this.guardedDeliverySupported;
116
+ }
125
117
  isConnected(): boolean {
126
118
  return (
127
119
  (this._sessionId ?? "").length > 0 &&
@@ -165,6 +157,8 @@ export class IntercomClient extends EventEmitter {
165
157
  this._sessionId = null;
166
158
  this._topicsSupported = false;
167
159
  this.lastMessagesSupported = false;
160
+ this.receiptReadsSupported = false;
161
+ this.guardedDeliverySupported = false;
168
162
  this.disconnecting = false;
169
163
  this.failPending(error);
170
164
  if (established && !expected) {
@@ -181,6 +175,8 @@ export class IntercomClient extends EventEmitter {
181
175
  requestedId,
182
176
  topicFrames: true,
183
177
  lastMessages: true,
178
+ receiptReads: 1,
179
+ guardedDelivery: 1,
184
180
  });
185
181
  } catch (error) {
186
182
  if (this.connection === connection) {
@@ -266,13 +262,15 @@ export class IntercomClient extends EventEmitter {
266
262
  this._sessionId = msg.sessionId;
267
263
  this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
268
264
  this.lastMessagesSupported = msg.lastMessages === true;
265
+ this.receiptReadsSupported = msg.receiptReads === 1;
266
+ this.guardedDeliverySupported = msg.guardedDelivery === 1;
269
267
  this.connection?.confirmRegistration();
270
268
  this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
271
269
  }
272
270
 
273
271
  private receiveLastRequest(msg: UnknownRecord): void {
274
272
  const from = normalizeSessionInfo(msg.from);
275
- if (typeof msg.requestId !== "string" || !from || !isLastMessageQuery(msg.query)) {
273
+ if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
276
274
  throw new Error("Invalid last-message request");
277
275
  }
278
276
  if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
@@ -351,20 +349,25 @@ export class IntercomClient extends EventEmitter {
351
349
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
352
350
  }
353
351
 
354
- last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
355
- try {
356
- const socket = this.requireActiveSocket();
357
- if (!this.lastMessagesSupported) {
358
- throw new Error("This broker does not support last-message reads; reload after it exits.");
359
- }
360
- return this.lastRequests.request(socket, to, query, this.listTimeoutMs);
361
- } catch (error) {
362
- return Promise.reject(toError(error));
352
+ async last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
353
+ return await this.lastRequests.last(this.readSocket(query), to, query);
354
+ }
355
+ async receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult> {
356
+ return await this.lastRequests.receipt(this.readSocket(query), to, query);
357
+ }
358
+ private readSocket(query: NativeReadQuery): net.Socket {
359
+ const socket = this.requireActiveSocket();
360
+ if (!this.lastMessagesSupported) {
361
+ throw new Error("This broker does not support last-message reads; reload after it exits.");
362
+ }
363
+ if ("messageId" in query && !this.receiptReadsSupported) {
364
+ throw new Error("publication_unavailable");
363
365
  }
366
+ return socket;
364
367
  }
365
368
 
366
369
  respondLast(requestId: string, result: unknown): void {
367
- if (!isLastMessageResult(result)) {
370
+ if (!isNativeReadResult(result)) {
368
371
  throw new Error("Invalid last-message result");
369
372
  }
370
373
  writeMessage(this.requireActiveSocket(), { type: "last_response", requestId, result });
@@ -410,7 +413,9 @@ export class IntercomClient extends EventEmitter {
410
413
  const timeout = setTimeout(() => {
411
414
  if (this.pendingLists.has(requestId)) {
412
415
  this.pendingLists.delete(requestId);
413
- wrappedReject(new Error("List sessions timeout"));
416
+ wrappedReject(
417
+ Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
418
+ );
414
419
  }
415
420
  }, this.listTimeoutMs);
416
421
  timeout.unref();
@@ -434,20 +439,33 @@ export class IntercomClient extends EventEmitter {
434
439
  });
435
440
  }
436
441
 
442
+ private requireSendSocket(options: SendOptions): net.Socket {
443
+ const socket = this.requireActiveSocket();
444
+ if (options.guard && !this.guardedDeliverySupported) {
445
+ throw new Error("This broker does not support guarded delivery; no message sent.");
446
+ }
447
+ return socket;
448
+ }
449
+
437
450
  send(to: string, options: SendOptions): Promise<SendResult> {
438
451
  let socket: net.Socket;
439
452
  try {
440
- socket = this.requireActiveSocket();
453
+ socket = this.requireSendSocket(options);
441
454
  } catch (error) {
442
455
  return Promise.reject(toError(error));
443
456
  }
444
457
 
445
- const messageId = options.messageId ?? randomUUID();
458
+ const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
459
+ if (this.pendingSends.has(messageId)) {
460
+ return Promise.reject(new Error("Message ID already has a pending send"));
461
+ }
446
462
  const message: Message = {
447
463
  id: messageId,
448
464
  timestamp: Date.now(),
449
465
  replyTo: options.replyTo,
450
466
  expectsReply: options.expectsReply,
467
+ guard: options.guard,
468
+ replyHint: options.replyHint,
451
469
  ...(options.human ? { human: options.human } : {}),
452
470
  ...(options.topic ? { topic: options.topic } : {}),
453
471
  delivery: messageDelivery(options),
@@ -460,6 +478,9 @@ export class IntercomClient extends EventEmitter {
460
478
  },
461
479
  };
462
480
 
481
+ if ((options.guard || options.replyHint) && normalizeMessage(message) === null) {
482
+ return Promise.reject(new Error("Invalid message format"));
483
+ }
463
484
  return new Promise((resolve, reject) => {
464
485
  const wrappedResolve = (result: SendResult) => {
465
486
  clearTimeout(timeout);
@@ -493,6 +514,7 @@ export class IntercomClient extends EventEmitter {
493
514
  }
494
515
 
495
516
  updatePresence(updates: {
517
+ readonly applicationGuard?: string | null;
496
518
  readonly name?: string;
497
519
  readonly status?: string;
498
520
  readonly model?: string;
@@ -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))
49
+ );
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))
31
59
  );
32
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,20 @@ interface LastPeer {
65
99
  readonly socket: net.Socket;
66
100
  readonly info: SessionInfo;
67
101
  readonly lastMessages: boolean;
102
+ readonly receiptReads: boolean;
68
103
  }
69
104
  interface PendingLast {
70
105
  readonly fromId: string;
71
106
  readonly toId: string;
72
107
  readonly requestId: string;
73
- readonly query: LastMessageQuery;
108
+ readonly query: NativeReadQuery;
74
109
  readonly timer: NodeJS.Timeout;
75
110
  }
76
111
  function unavailable(
77
112
  targets: readonly LastPeer[],
78
113
  name: string,
79
114
  pending: number,
115
+ query: NativeReadQuery,
80
116
  ): string | undefined {
81
117
  if (targets.length === 0) {
82
118
  return "Session not found";
@@ -87,20 +123,44 @@ function unavailable(
87
123
  if (!targets[0].lastMessages) {
88
124
  return "Recipient does not support last-message reads; reload its extension.";
89
125
  }
126
+ if (
127
+ "messageId" in query &&
128
+ (!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)
129
+ ) {
130
+ return "publication_unavailable";
131
+ }
90
132
  if (pending >= 128) {
91
133
  return "Too many pending last-message reads; retry after they settle.";
92
134
  }
93
135
  return undefined;
94
136
  }
95
- function matchesPage(result: LastMessageResult, pending: PendingLast, fromId: string): boolean {
137
+ function matchesPage(result: NativeReadResult, pending: PendingLast, fromId: string): boolean {
96
138
  if ("error" in result) {
97
139
  return true;
98
140
  }
141
+ if ("messageId" in pending.query) {
142
+ return (
143
+ "receipt" in result &&
144
+ result.receipt.messageId === pending.query.messageId &&
145
+ result.receipt.rootSessionId === pending.query.rootSessionId
146
+ );
147
+ }
148
+ if ("receipt" in result) {
149
+ return false;
150
+ }
151
+ return matchesLastPage(result, pending.query, fromId);
152
+ }
153
+
154
+ function matchesLastPage(
155
+ result: Exclude<LastMessageResult, { readonly error: string }>,
156
+ query: LastMessageQuery,
157
+ fromId: string,
158
+ ): boolean {
99
159
  return (
100
160
  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)
161
+ result.offset === (query.offset ?? 0) &&
162
+ (query.entryId === undefined || result.entryId === query.entryId) &&
163
+ result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT)
104
164
  );
105
165
  }
106
166
 
@@ -116,7 +176,7 @@ export class LastMessageRelay {
116
176
  if (
117
177
  typeof message.requestId !== "string" ||
118
178
  typeof message.to !== "string" ||
119
- !isLastMessageQuery(message.query)
179
+ !isNativeReadQuery(message.query)
120
180
  ) {
121
181
  throw new Error("Invalid last message request");
122
182
  }
@@ -126,15 +186,6 @@ export class LastMessageRelay {
126
186
  if (!from) {
127
187
  return;
128
188
  }
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
189
  const token = randomUUID();
139
190
  const frame = {
140
191
  type: "last_request" as const,
@@ -142,12 +193,14 @@ export class LastMessageRelay {
142
193
  from: messageSender(from.info),
143
194
  query: message.query,
144
195
  };
145
- const invalid = validateIntercomMessageSize(frame);
146
- if (invalid) {
196
+ const error =
197
+ unavailable(targets, message.to, this.pending.size, message.query) ??
198
+ validateIntercomMessageSize(frame)?.message;
199
+ if (error !== undefined) {
147
200
  writeMessage(from.socket, {
148
201
  type: "last_response",
149
202
  requestId: message.requestId,
150
- result: { error: invalid.message },
203
+ result: { error },
151
204
  });
152
205
  return;
153
206
  }
@@ -181,7 +234,7 @@ export class LastMessageRelay {
181
234
  const result = message.result;
182
235
  this.finish(
183
236
  message.requestId,
184
- isLastMessageResult(result) && matchesPage(result, pending, fromId)
237
+ isNativeReadResult(result) && matchesPage(result, pending, fromId)
185
238
  ? result
186
239
  : { error: "Recipient returned an invalid last-message page" },
187
240
  );
@@ -195,7 +248,7 @@ export class LastMessageRelay {
195
248
  }
196
249
  }
197
250
 
198
- private finish(token: string, result: LastMessageResult): void {
251
+ private finish(token: string, result: NativeReadResult): void {
199
252
  const pending = this.pending.get(token);
200
253
  if (!pending) {
201
254
  return;
@@ -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,