@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,24 +1,42 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isRecord } from "../../shared/unknown.js";
3
- import { normalizeSessionInfo, } from "../types.js";
3
+ import { isDeliveryGuard, isPublicationReplyHint, isLastMessageQuery, isReceiptQuery, LAST_MESSAGE_PAGE_LIMIT, normalizeSessionInfo, } from "../types.js";
4
+ import { finiteNumber, nonNegativeInteger, optionalLabel, safeLabel } from "../validation.js";
4
5
  import { validateIntercomMessageSize, writeMessage } from "./framing.js";
5
6
  import { messageSender } from "./snapshots.js";
6
7
  import { findSessions } from "./targets.js";
7
- export const LAST_MESSAGE_PAGE_LIMIT = 16384;
8
- function nonNegativeInteger(value) {
9
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
8
+ export function isNativeReadQuery(value) {
9
+ return isReceiptQuery(value) || isLastMessageQuery(value);
10
10
  }
11
- function optionalEntryId(value) {
12
- return value === undefined || (typeof value === "string" && value.length > 0);
11
+ function isNativeReceipt(value) {
12
+ if (!isReceiptQuery(value) || !isRecord(value) || !isRecord(value.sender)) {
13
+ return false;
14
+ }
15
+ return (value.version === 1 &&
16
+ safeLabel(value.sender.peerId) &&
17
+ [value.sender.rootSessionId, value.sender.incarnation].every(optionalLabel) &&
18
+ typeof value.textSha256 === "string" &&
19
+ /^[a-f0-9]{64}$/.test(value.textSha256) &&
20
+ receiptPublication(value) &&
21
+ receiptFacts(value));
13
22
  }
14
- export function isLastMessageQuery(value) {
15
- return (isRecord(value) &&
16
- optionalEntryId(value.entryId) &&
17
- (value.offset === undefined || nonNegativeInteger(value.offset)) &&
18
- (value.limit === undefined ||
19
- (nonNegativeInteger(value.limit) &&
20
- value.limit > 0 &&
21
- value.limit <= LAST_MESSAGE_PAGE_LIMIT)));
23
+ function receiptPublication(value) {
24
+ return (typeof value.delivery === "string" &&
25
+ ["queue", "steer", "passive"].includes(value.delivery) &&
26
+ (value.guard === undefined || isDeliveryGuard(value.guard)) &&
27
+ (value.replyHint === undefined || isPublicationReplyHint(value.replyHint)));
28
+ }
29
+ function receiptFacts(value) {
30
+ return (safeLabel(value.checkpointEntryId) &&
31
+ finiteNumber(value.receivedAt) &&
32
+ [value.queued, value.nativeHandoff, value.discarded].every((field) => typeof field === "boolean") &&
33
+ (value.placementEntryId === null || safeLabel(value.placementEntryId)));
34
+ }
35
+ export function isReceiptResult(value) {
36
+ return isRecord(value) && (typeof value.error === "string" || isNativeReceipt(value.receipt));
37
+ }
38
+ export function isNativeReadResult(value) {
39
+ return isReceiptResult(value) || isLastMessageResult(value);
22
40
  }
23
41
  function pageIdentity(value) {
24
42
  return (normalizeSessionInfo(value.session) !== null &&
@@ -45,16 +63,35 @@ export function isLastMessageResult(value) {
45
63
  return (isRecord(value) &&
46
64
  (typeof value.error === "string" || (pageIdentity(value) && pageOffsets(value))));
47
65
  }
48
- function unavailable(targets, name, pending) {
66
+ /** An exited parked child answers nothing itself; only its main holds the captured history. */
67
+ function exitedRefusal(target, query, via) {
68
+ if (target.parked?.closed !== true) {
69
+ return undefined;
70
+ }
71
+ // No loaded journal owner answers receipts, with or without its main.
72
+ if ("messageId" in query) {
73
+ return "publication_unavailable";
74
+ }
75
+ return via === undefined ? "Parked session history is unavailable." : undefined;
76
+ }
77
+ function unavailable(targets, name, pending, { query, via }) {
49
78
  if (targets.length === 0) {
50
79
  return "Session not found";
51
80
  }
52
81
  if (targets.length > 1) {
53
82
  return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
54
83
  }
84
+ const exited = exitedRefusal(targets[0], query, via);
85
+ if (exited !== undefined) {
86
+ return exited;
87
+ }
55
88
  if (!targets[0].lastMessages) {
56
89
  return "Recipient does not support last-message reads; reload its extension.";
57
90
  }
91
+ if ("messageId" in query &&
92
+ (!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)) {
93
+ return "publication_unavailable";
94
+ }
58
95
  if (pending >= 128) {
59
96
  return "Too many pending last-message reads; retry after they settle.";
60
97
  }
@@ -64,10 +101,28 @@ function matchesPage(result, pending, fromId) {
64
101
  if ("error" in result) {
65
102
  return true;
66
103
  }
104
+ if ("messageId" in pending.query) {
105
+ return ("receipt" in result &&
106
+ result.receipt.messageId === pending.query.messageId &&
107
+ result.receipt.rootSessionId === pending.query.rootSessionId);
108
+ }
109
+ if ("receipt" in result) {
110
+ return false;
111
+ }
112
+ return matchesLastPage(result, pending.query, fromId);
113
+ }
114
+ function matchesLastPage(result, query, fromId) {
67
115
  return (result.session.id === fromId &&
68
- result.offset === (pending.query.offset ?? 0) &&
69
- (pending.query.entryId === undefined || result.entryId === pending.query.entryId) &&
70
- result.text.length <= (pending.query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
116
+ result.offset === (query.offset ?? 0) &&
117
+ (query.entryId === undefined || result.entryId === query.entryId) &&
118
+ result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
119
+ }
120
+ function lastRequest(message) {
121
+ const { requestId, to, query } = message;
122
+ if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
123
+ throw new Error("Invalid last message request");
124
+ }
125
+ return { requestId, to, query };
71
126
  }
72
127
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
73
128
  export class LastMessageRelay {
@@ -76,40 +131,31 @@ export class LastMessageRelay {
76
131
  constructor(sessions) {
77
132
  this.sessions = sessions;
78
133
  }
79
- request(fromId, message) {
80
- if (typeof message.requestId !== "string" ||
81
- typeof message.to !== "string" ||
82
- !isLastMessageQuery(message.query)) {
83
- throw new Error("Invalid last message request");
84
- }
134
+ /** `reader` names another peer that answers for an exited target from its captured history. */
135
+ request(fromId, input, reader) {
136
+ const message = lastRequest(input);
85
137
  const targets = findSessions(this.sessions, message.to, fromId);
86
138
  const target = targets[0];
87
139
  const from = this.sessions.get(fromId);
88
140
  if (!from) {
89
141
  return;
90
142
  }
91
- const error = unavailable(targets, message.to, this.pending.size);
92
- if (error !== undefined) {
93
- writeMessage(from.socket, {
94
- type: "last_response",
95
- requestId: message.requestId,
96
- result: { error },
97
- });
98
- return;
99
- }
100
143
  const token = randomUUID();
144
+ const via = targets.length === 1 ? reader(target.info.id) : undefined;
101
145
  const frame = {
102
146
  type: "last_request",
103
147
  requestId: token,
104
148
  from: messageSender(from.info),
105
149
  query: message.query,
150
+ parked: via && messageSender(target.info),
106
151
  };
107
- const invalid = validateIntercomMessageSize(frame);
108
- if (invalid) {
152
+ const error = unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
153
+ validateIntercomMessageSize(frame)?.message;
154
+ if (error !== undefined) {
109
155
  writeMessage(from.socket, {
110
156
  type: "last_response",
111
157
  requestId: message.requestId,
112
- result: { error: invalid.message },
158
+ result: { error },
113
159
  });
114
160
  return;
115
161
  }
@@ -118,12 +164,13 @@ export class LastMessageRelay {
118
164
  this.pending.set(token, {
119
165
  fromId,
120
166
  toId: target.info.id,
167
+ via: via?.info.id,
121
168
  requestId: message.requestId,
122
169
  query: message.query,
123
170
  timer,
124
171
  });
125
172
  try {
126
- writeMessage(target.socket, frame);
173
+ writeMessage((via ?? target).socket, frame);
127
174
  }
128
175
  catch {
129
176
  this.finish(token, { error: "Recipient disconnected before last-message read" });
@@ -134,17 +181,17 @@ export class LastMessageRelay {
134
181
  throw new Error("Invalid last message response");
135
182
  }
136
183
  const pending = this.pending.get(message.requestId);
137
- if (!pending || pending.toId !== fromId) {
184
+ if (!pending || (pending.via ?? pending.toId) !== fromId) {
138
185
  return;
139
186
  }
140
187
  const result = message.result;
141
- this.finish(message.requestId, isLastMessageResult(result) && matchesPage(result, pending, fromId)
188
+ this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
142
189
  ? result
143
190
  : { error: "Recipient returned an invalid last-message page" });
144
191
  }
145
192
  disconnected(sessionId) {
146
193
  for (const [token, pending] of this.pending) {
147
- if (pending.fromId === sessionId || pending.toId === sessionId) {
194
+ if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
148
195
  this.finish(token, { error: "Peer disconnected during last-message read" });
149
196
  }
150
197
  }
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from "crypto";
2
+ import { nativePeerId } from "../session-targets.js";
3
+ import { writeMessage } from "./framing.js";
4
+ export function reachable(target) {
5
+ const socket = target?.socket;
6
+ return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
7
+ }
8
+ function requested(id) {
9
+ return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
10
+ }
11
+ /** A fenced child keeps its identity and queued input; it never departs while parked. */
12
+ export class ParkedSessions {
13
+ sessions;
14
+ deliver;
15
+ constructor(sessions, deliver) {
16
+ this.sessions = sessions;
17
+ this.deliver = deliver;
18
+ }
19
+ park(socket, id, on) {
20
+ const session = this.sessions.get(id);
21
+ if (on && session?.parking === true) {
22
+ session.parked ??= { retained: [], closed: false };
23
+ // Frames already written to this socket precede the acknowledgement.
24
+ writeMessage(socket, { type: "parked" });
25
+ }
26
+ else if (!on && session?.parked?.closed === false) {
27
+ const { retained } = session.parked;
28
+ session.parked = undefined;
29
+ this.flush(id, retained);
30
+ }
31
+ }
32
+ /**
33
+ * Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
34
+ * next generation; delivery refusals on a live transport follow resident delivery.
35
+ */
36
+ flush(id, retained) {
37
+ for (const [index, { from, message }] of retained.entries()) {
38
+ const session = this.sessions.get(id);
39
+ if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
40
+ session.parked = { retained: retained.slice(index), closed: false };
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ /** A free requested identity, or a parked one resumed by the same child lineage. */
46
+ claim(requestedId, next) {
47
+ const id = requested(requestedId);
48
+ const previous = id === undefined ? undefined : this.sessions.get(id);
49
+ const resumes = previous?.parked !== undefined &&
50
+ previous.info.rootSessionId === next.rootSessionId &&
51
+ previous.info.parentSessionId === next.parentSessionId;
52
+ if (id === undefined || (previous && !resumes)) {
53
+ return { id: randomUUID() };
54
+ }
55
+ previous?.socket.destroy();
56
+ return { id, resumed: previous };
57
+ }
58
+ /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
59
+ registered(id, previous) {
60
+ this.flush(id, previous?.parked?.retained ?? []);
61
+ for (const child of this.sessions.values()) {
62
+ if (nativePeerId(child.info.rootSessionId ?? "") === id) {
63
+ this.wake(child);
64
+ }
65
+ }
66
+ }
67
+ /** A transport handoff, not a departure: no session_left and queued input stays. */
68
+ closed(session) {
69
+ const { parked } = session;
70
+ if (parked) {
71
+ parked.closed = true;
72
+ this.wake(session);
73
+ }
74
+ return parked !== undefined;
75
+ }
76
+ /**
77
+ * Exited parked children keep identities while some live session remains. Input already
78
+ * admitted for one keeps the broker until a returning main wakes it.
79
+ */
80
+ get exited() {
81
+ return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
82
+ }
83
+ retain(target, from, message) {
84
+ target?.parked?.retained.push({ from, message });
85
+ if (target) {
86
+ this.wake(target);
87
+ }
88
+ return target?.parked !== undefined;
89
+ }
90
+ /** Reads of an exited parked child go to its main, which answers from captured history. */
91
+ reader(id) {
92
+ const child = this.sessions.get(id);
93
+ return child?.parked?.closed === true ? this.main(child) : undefined;
94
+ }
95
+ // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
96
+ wake(child) {
97
+ const main = this.main(child);
98
+ const wakes = child.parked?.retained.some(({ message }) => message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
99
+ if (main && child.parked?.closed === true && wakes === true) {
100
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
101
+ }
102
+ }
103
+ main(child) {
104
+ const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
105
+ return main?.parking === true && main.parked === undefined ? main : undefined;
106
+ }
107
+ }
@@ -0,0 +1,13 @@
1
+ /** Old agent-directory namespaces are inspected for migration, never used for routing. */
2
+ export declare function getPreviousBrokerSocketPaths(): readonly string[];
3
+ export declare function isOwnedBrokerSocket(socketPath: string): boolean;
4
+ /** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
5
+ export declare function getBrokerRuntimeDir(): string;
6
+ /** Read-only child observation endpoints share the runtime policy, not the broker namespace.
7
+ * The endpoint name is a short token digest because the private runtime directory also counts
8
+ * toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
9
+ export declare function agentObservationSocketPath(token: string): string;
10
+ export declare function getBrokerSocketPath(): string;
11
+ /** Create or verify an owned, non-symlink, private socket directory. */
12
+ export declare function preparePrivateSocketPath(socketPath: string): string;
13
+ export declare function prepareBrokerSocketPath(): string;
@@ -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.js";
6
6
  const MAX_UNIX_SOCKET_PATH_BYTES = 100;
7
7
  function agentDigest(agentDir, uid) {
@@ -32,33 +32,56 @@ export function isOwnedBrokerSocket(socketPath) {
32
32
  return false;
33
33
  }
34
34
  }
35
- /** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
36
- export function getBrokerRuntimeDir() {
35
+ function defaultRuntimeRoot() {
36
+ if (process.platform !== "android") {
37
+ return "/tmp";
38
+ }
39
+ return process.env.PREFIX === undefined ? tmpdir() : join(process.env.PREFIX, "tmp");
40
+ }
41
+ function runtimeDirectory(socketName, sibling = "") {
37
42
  const configured = process.env.PI_INTERCOM_RUNTIME_DIR;
38
43
  const uid = process.getuid?.();
39
44
  if (uid === undefined) {
40
45
  throw new Error("Intercom requires Unix user IDs and Unix domain sockets.");
41
46
  }
42
- const directory = configured ?? join("/tmp", `pi-intercom-${uid}`);
47
+ const base = configured ?? join(defaultRuntimeRoot(), `pi-intercom-${uid}`);
48
+ const directory = isAbsolute(base) && sibling !== "" ? `${resolve(base)}${sibling}` : base;
43
49
  if (!isAbsolute(directory) ||
44
50
  directory.includes("\0") ||
45
- Buffer.byteLength(join(directory, "broker.sock")) > MAX_UNIX_SOCKET_PATH_BYTES) {
51
+ Buffer.byteLength(join(directory, socketName)) > MAX_UNIX_SOCKET_PATH_BYTES) {
46
52
  throw new Error("PI_INTERCOM_RUNTIME_DIR must be an absolute short Unix socket directory.");
47
53
  }
48
54
  return directory;
49
55
  }
56
+ /** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
57
+ export function getBrokerRuntimeDir() {
58
+ return runtimeDirectory("broker.sock");
59
+ }
60
+ /** Read-only child observation endpoints share the runtime policy, not the broker namespace.
61
+ * The endpoint name is a short token digest because the private runtime directory also counts
62
+ * toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
63
+ export function agentObservationSocketPath(token) {
64
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(token)) {
65
+ throw new Error("Invalid actor token.");
66
+ }
67
+ const name = `obs-${createHash("sha256").update(token).digest("hex").slice(0, 16)}.sock`;
68
+ return join(runtimeDirectory(name, "-agents"), name);
69
+ }
50
70
  export function getBrokerSocketPath() {
51
71
  return join(getBrokerRuntimeDir(), "broker.sock");
52
72
  }
53
- export function prepareBrokerSocketPath() {
54
- const socketPath = getBrokerSocketPath();
55
- const brokerDir = dirname(socketPath);
56
- fs.mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
57
- const stat = fs.lstatSync(brokerDir);
73
+ /** Create or verify an owned, non-symlink, private socket directory. */
74
+ export function preparePrivateSocketPath(socketPath) {
75
+ const directory = dirname(socketPath);
76
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
77
+ const stat = fs.lstatSync(directory);
58
78
  const uid = process.getuid?.();
59
79
  if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
60
- throw new Error(`Unsafe intercom socket directory: ${brokerDir}`);
80
+ throw new Error(`Unsafe intercom socket directory: ${directory}`);
61
81
  }
62
- fs.chmodSync(brokerDir, 0o700);
82
+ fs.chmodSync(directory, 0o700);
63
83
  return socketPath;
64
84
  }
85
+ export function prepareBrokerSocketPath() {
86
+ return preparePrivateSocketPath(getBrokerSocketPath());
87
+ }
@@ -73,6 +73,10 @@ export function brokerPidRecord() {
73
73
  return `${process.pid}\n${identity !== undefined ? `${identity}\n` : ""}`;
74
74
  }
75
75
  export function isBrokerPidReused(pid, record) {
76
+ return isLinuxProcessReplaced(pid, record.slice(record.indexOf("\n") + 1));
77
+ }
78
+ /** True only when a live Linux PID provably names a different process than the identity. */
79
+ export function isLinuxProcessReplaced(pid, identity) {
76
80
  const current = readLinuxProcess(pid);
77
81
  if (!current) {
78
82
  return false;
@@ -80,7 +84,7 @@ export function isBrokerPidReused(pid, record) {
80
84
  if (current.tgid !== pid) {
81
85
  return true;
82
86
  }
83
- const saved = IDENTITY.exec(record.slice(record.indexOf("\n") + 1).trim());
87
+ const saved = IDENTITY.exec(identity.trim());
84
88
  const actual = IDENTITY.exec(current.identity ?? "");
85
89
  if (!saved || !actual) {
86
90
  return false;
@@ -1,4 +1,23 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { createMessageReader } from "./framing.js";
3
+ import { assertMainParent } from "./targets.js";
4
+ import { isSessionRegistration } from "../types.js";
5
+ export function registeredSessionInfo(msg, sessions) {
6
+ if (!isSessionRegistration(msg.session)) {
7
+ throw new Error("Invalid register message");
8
+ }
9
+ assertMainParent(msg.session, sessions);
10
+ const requestedId = typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
11
+ ? msg.requestedId
12
+ : undefined;
13
+ const id = requestedId !== undefined && !sessions.has(requestedId) ? requestedId : randomUUID();
14
+ return {
15
+ ...msg.session,
16
+ id,
17
+ guardedDelivery: msg.guardedDelivery === 1 ? msg.session.guardedDelivery : undefined,
18
+ lastSeen: msg.session.lastSeen ?? Date.now(),
19
+ };
20
+ }
2
21
  /** A server connection owns its registration identity until the socket closes. */
3
22
  export function acceptBrokerConnection(socket, handleMessage, disconnected) {
4
23
  let sessionId = null;
@@ -9,7 +28,7 @@ export function acceptBrokerConnection(socket, handleMessage, disconnected) {
9
28
  }));
10
29
  socket.on("close", () => {
11
30
  if (sessionId !== null) {
12
- disconnected(sessionId);
31
+ disconnected(sessionId, socket);
13
32
  }
14
33
  });
15
34
  socket.on("error", (error) => {
@@ -0,0 +1,9 @@
1
+ import { type SessionSnapshot } from "../types.ts";
2
+ /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
3
+ export declare class SnapshotReader {
4
+ private readonly sessions;
5
+ private readonly receipts;
6
+ private addSession;
7
+ append(sessions: readonly unknown[], receipts: unknown): void;
8
+ finish(): SessionSnapshot;
9
+ }
@@ -0,0 +1,29 @@
1
+ import { type SessionInfo, type TopicUpdate, type Message, type BrokerMessage, type TopicSubscription, type SendResult } from "../types.ts";
2
+ /** Direct messages and old clients receive identity, not potentially large topic state. */
3
+ export declare function messageSender(info: SessionInfo): SessionInfo;
4
+ export declare function snapshotFrames(requestId: string, infos: readonly SessionInfo[], stream: boolean): BrokerMessage[];
5
+ interface PreparedTopicChange {
6
+ readonly topic?: TopicUpdate;
7
+ readonly subscription?: TopicSubscription;
8
+ readonly unsubscribe?: string;
9
+ readonly publication?: TopicUpdate;
10
+ readonly restore: boolean;
11
+ readonly changed: boolean;
12
+ readonly bytes: number;
13
+ }
14
+ /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
15
+ export declare class TopicSnapshot {
16
+ private readonly topics;
17
+ private readonly subscriptions;
18
+ constructor(info: SessionInfo);
19
+ subscription(topic: string): TopicSubscription | undefined;
20
+ prepare(change: unknown, supported: boolean): PreparedTopicChange;
21
+ private preparePublication;
22
+ snapshot(info: SessionInfo, prepared?: PreparedTopicChange): SessionInfo;
23
+ validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void;
24
+ commit(prepared: PreparedTopicChange): void;
25
+ }
26
+ export declare function deliveryAcknowledgement(receipt: SendResult): BrokerMessage;
27
+ export declare function shouldReplaceDelivery(message: Message, info: SessionInfo): boolean;
28
+ export declare function publicationMessage(publication: TopicUpdate, awaitRelease: boolean): Message;
29
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { SessionInfo } from "../types.ts";
2
+ export declare function canSeeSession(from: SessionInfo, target: SessionInfo): boolean;
3
+ export declare function assertMainParent(session: Omit<SessionInfo, "id">, sessions: ReadonlyMap<string, {
4
+ readonly info: SessionInfo;
5
+ }>): void;
6
+ /** Preserve exact-ID precedence and case-insensitive duplicate-name ambiguity. */
7
+ export declare function findSessions<T extends {
8
+ readonly info: SessionInfo;
9
+ }>(sessions: ReadonlyMap<string, T>, nameOrId: string, fromId: string): T[];
@@ -2,10 +2,13 @@ import { IntercomClient } from "./broker/client.js";
2
2
  import { spawnBrokerIfNeeded } from "./broker/spawn.js";
3
3
  import { readLastMessage } from "./last-message.js";
4
4
  import { asError } from "./validation.js";
5
+ import { RecoveryError } from "./tool-results.js";
5
6
  import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
6
7
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
7
8
  export class IntercomConnection {
8
9
  client = null;
10
+ /** The transport whose broker retains this actor's input while it parks. */
11
+ parked = null;
9
12
  reconnectTimer = null;
10
13
  startupTimer = null;
11
14
  connecting = null;
@@ -22,6 +25,22 @@ export class IntercomConnection {
22
25
  get active() {
23
26
  return this.client;
24
27
  }
28
+ async park() {
29
+ const client = this.client;
30
+ this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
31
+ return this.parked !== null && this.parked === this.client;
32
+ }
33
+ get parking() {
34
+ return this.parked !== null;
35
+ }
36
+ get reconnected() {
37
+ return this.client !== null && this.client !== this.parked;
38
+ }
39
+ unpark() {
40
+ const client = this.parked === this.client ? this.client : null;
41
+ this.parked = null;
42
+ return client?.park(false).catch(() => false) ?? Promise.resolve(false);
43
+ }
25
44
  start(ctx, generation) {
26
45
  this.attempt = 0;
27
46
  this.clearTimers();
@@ -93,9 +112,51 @@ export class IntercomConnection {
93
112
  this.client.updatePresence({
94
113
  status: this.lifecycle.status(),
95
114
  ...this.lifecycle.health(this.hooks.pendingAsks()),
115
+ applicationGuard: this.lifecycle.snapshot().applicationGuard ?? null,
96
116
  });
97
117
  }
98
118
  }
119
+ answerLast(next, request) {
120
+ const ctx = this.lifecycle.live();
121
+ const id = next.sessionId;
122
+ if (this.client !== next || !ctx || id === null) {
123
+ next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
124
+ return;
125
+ }
126
+ const { parked, query } = request;
127
+ if ("messageId" in query) {
128
+ // The broker refuses receipt reads of an exited parked child before relaying them.
129
+ next.respondLast(request.requestId, parked ? { error: "publication_unavailable" } : this.hooks.receipt(query));
130
+ return;
131
+ }
132
+ if (parked) {
133
+ this.hooks
134
+ .parkedHistory(parked)
135
+ .then((history) => history
136
+ ? readLastMessage(history, parked, query)
137
+ : { error: "Parked session history is unavailable." })
138
+ .catch((error) => ({ error: asError(error).message }))
139
+ .then((result) => {
140
+ next.respondLast(request.requestId, result);
141
+ })
142
+ .catch(() => {
143
+ /* The requester's broker timeout reports a lost transport. */
144
+ });
145
+ return;
146
+ }
147
+ try {
148
+ next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
149
+ id,
150
+ ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
151
+ cwd: ctx.cwd,
152
+ model: ctx.model?.id ?? "unknown",
153
+ status: this.lifecycle.status(),
154
+ }, query));
155
+ }
156
+ catch (error) {
157
+ next.respondLast(request.requestId, { error: asError(error).message });
158
+ }
159
+ }
99
160
  createClient() {
100
161
  const next = new IntercomClient({
101
162
  sendTimeoutMs: this.config.sendTimeoutMs,
@@ -103,24 +164,7 @@ export class IntercomConnection {
103
164
  });
104
165
  this.client = next;
105
166
  next.on("last_request", (request) => {
106
- const ctx = this.lifecycle.live();
107
- const id = next.sessionId;
108
- if (this.client !== next || !ctx || id === null) {
109
- next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
110
- return;
111
- }
112
- try {
113
- next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
114
- id,
115
- ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
116
- cwd: ctx.cwd,
117
- model: ctx.model?.id ?? "unknown",
118
- status: this.lifecycle.status(),
119
- }, request.query));
120
- }
121
- catch (error) {
122
- next.respondLast(request.requestId, { error: asError(error).message });
123
- }
167
+ this.answerLast(next, request);
124
168
  });
125
169
  next.on("message", (from, message) => {
126
170
  const ctx = this.lifecycle.live();
@@ -131,6 +175,11 @@ export class IntercomConnection {
131
175
  console.error("Intercom inbound delivery failed:", error);
132
176
  });
133
177
  });
178
+ next.on("wake", (peer) => {
179
+ if (this.client === next) {
180
+ this.hooks.wake(peer);
181
+ }
182
+ });
134
183
  next.on("session_left", (id) => {
135
184
  if (this.client !== next) {
136
185
  return;
@@ -143,6 +192,7 @@ export class IntercomConnection {
143
192
  }
144
193
  this.hooks.disconnected(error);
145
194
  this.client = null;
195
+ this.lifecycle.transport(null);
146
196
  this.clearReconnect();
147
197
  this.scheduleReconnect();
148
198
  });
@@ -161,6 +211,7 @@ export class IntercomConnection {
161
211
  throw new Error("Intercom runtime no longer active");
162
212
  }
163
213
  this.client = next;
214
+ this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
164
215
  this.syncIdentity(ctx.sessionManager.getSessionId());
165
216
  this.attempt = 0;
166
217
  return next;
@@ -239,10 +290,10 @@ export async function resolveConnectedTarget(active, name) {
239
290
  return resolution.target.id;
240
291
  }
241
292
  if (resolution.status === "ambiguous") {
242
- throw new Error(`Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`);
293
+ throw new RecoveryError("ambiguous_target", `Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`);
243
294
  }
244
295
  if (resolution.status === "prefix_too_short") {
245
- throw new Error(`Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`);
296
+ throw new RecoveryError("ambiguous_target", `Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`);
246
297
  }
247
298
  return null;
248
299
  }