@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
@@ -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,7 +63,7 @@ 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
+ function unavailable(targets, name, pending, query) {
49
67
  if (targets.length === 0) {
50
68
  return "Session not found";
51
69
  }
@@ -55,6 +73,10 @@ function unavailable(targets, name, pending) {
55
73
  if (!targets[0].lastMessages) {
56
74
  return "Recipient does not support last-message reads; reload its extension.";
57
75
  }
76
+ if ("messageId" in query &&
77
+ (!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)) {
78
+ return "publication_unavailable";
79
+ }
58
80
  if (pending >= 128) {
59
81
  return "Too many pending last-message reads; retry after they settle.";
60
82
  }
@@ -64,10 +86,21 @@ function matchesPage(result, pending, fromId) {
64
86
  if ("error" in result) {
65
87
  return true;
66
88
  }
89
+ if ("messageId" in pending.query) {
90
+ return ("receipt" in result &&
91
+ result.receipt.messageId === pending.query.messageId &&
92
+ result.receipt.rootSessionId === pending.query.rootSessionId);
93
+ }
94
+ if ("receipt" in result) {
95
+ return false;
96
+ }
97
+ return matchesLastPage(result, pending.query, fromId);
98
+ }
99
+ function matchesLastPage(result, query, fromId) {
67
100
  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));
101
+ result.offset === (query.offset ?? 0) &&
102
+ (query.entryId === undefined || result.entryId === query.entryId) &&
103
+ result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
71
104
  }
72
105
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
73
106
  export class LastMessageRelay {
@@ -79,7 +112,7 @@ export class LastMessageRelay {
79
112
  request(fromId, message) {
80
113
  if (typeof message.requestId !== "string" ||
81
114
  typeof message.to !== "string" ||
82
- !isLastMessageQuery(message.query)) {
115
+ !isNativeReadQuery(message.query)) {
83
116
  throw new Error("Invalid last message request");
84
117
  }
85
118
  const targets = findSessions(this.sessions, message.to, fromId);
@@ -88,15 +121,6 @@ export class LastMessageRelay {
88
121
  if (!from) {
89
122
  return;
90
123
  }
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
124
  const token = randomUUID();
101
125
  const frame = {
102
126
  type: "last_request",
@@ -104,12 +128,13 @@ export class LastMessageRelay {
104
128
  from: messageSender(from.info),
105
129
  query: message.query,
106
130
  };
107
- const invalid = validateIntercomMessageSize(frame);
108
- if (invalid) {
131
+ const error = unavailable(targets, message.to, this.pending.size, message.query) ??
132
+ validateIntercomMessageSize(frame)?.message;
133
+ if (error !== undefined) {
109
134
  writeMessage(from.socket, {
110
135
  type: "last_response",
111
136
  requestId: message.requestId,
112
- result: { error: invalid.message },
137
+ result: { error },
113
138
  });
114
139
  return;
115
140
  }
@@ -138,7 +163,7 @@ export class LastMessageRelay {
138
163
  return;
139
164
  }
140
165
  const result = message.result;
141
- this.finish(message.requestId, isLastMessageResult(result) && matchesPage(result, pending, fromId)
166
+ this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, fromId)
142
167
  ? result
143
168
  : { error: "Recipient returned an invalid last-message page" });
144
169
  }
@@ -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;
@@ -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,6 +2,7 @@ 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 {
@@ -93,6 +94,7 @@ export class IntercomConnection {
93
94
  this.client.updatePresence({
94
95
  status: this.lifecycle.status(),
95
96
  ...this.lifecycle.health(this.hooks.pendingAsks()),
97
+ applicationGuard: this.lifecycle.snapshot().applicationGuard ?? null,
96
98
  });
97
99
  }
98
100
  }
@@ -110,6 +112,10 @@ export class IntercomConnection {
110
112
  return;
111
113
  }
112
114
  try {
115
+ if ("messageId" in request.query) {
116
+ next.respondLast(request.requestId, this.hooks.receipt(request.query));
117
+ return;
118
+ }
113
119
  next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
114
120
  id,
115
121
  ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
@@ -143,6 +149,7 @@ export class IntercomConnection {
143
149
  }
144
150
  this.hooks.disconnected(error);
145
151
  this.client = null;
152
+ this.lifecycle.transport(null);
146
153
  this.clearReconnect();
147
154
  this.scheduleReconnect();
148
155
  });
@@ -161,6 +168,7 @@ export class IntercomConnection {
161
168
  throw new Error("Intercom runtime no longer active");
162
169
  }
163
170
  this.client = next;
171
+ this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
164
172
  this.syncIdentity(ctx.sessionManager.getSessionId());
165
173
  this.attempt = 0;
166
174
  return next;
@@ -239,10 +247,10 @@ export async function resolveConnectedTarget(active, name) {
239
247
  return resolution.target.id;
240
248
  }
241
249
  if (resolution.status === "ambiguous") {
242
- throw new Error(`Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`);
250
+ throw new RecoveryError("ambiguous_target", `Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`);
243
251
  }
244
252
  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)}.`);
253
+ throw new RecoveryError("ambiguous_target", `Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`);
246
254
  }
247
255
  return null;
248
256
  }
@@ -1,15 +1,16 @@
1
+ // Launches set both variables to the parent's exact native session ID. Absence means a main;
2
+ // anything else is refused, never normalized into a different identity.
1
3
  export function readIntercomLineage() {
2
- const rootSessionId = process.env.PI_INTERCOM_ROOT_SESSION_ID?.trim();
3
- const parentSessionId = process.env.PI_INTERCOM_PARENT_SESSION_ID?.trim();
4
+ const rootSessionId = process.env.PI_INTERCOM_ROOT_SESSION_ID;
5
+ const parentSessionId = process.env.PI_INTERCOM_PARENT_SESSION_ID;
4
6
  if (rootSessionId === undefined && parentSessionId === undefined) {
5
7
  return;
6
8
  }
7
9
  if (rootSessionId === undefined ||
8
- parentSessionId === undefined ||
9
10
  rootSessionId === "" ||
10
- parentSessionId === "" ||
11
+ rootSessionId !== rootSessionId.trim() ||
11
12
  rootSessionId !== parentSessionId) {
12
- throw new Error("Intercom child launch requires matching root and parent native session IDs.");
13
+ throw new Error("Child launch requires identical, unpadded root and parent native session IDs.");
13
14
  }
14
15
  return { rootSessionId, parentSessionId };
15
16
  }
@@ -1,6 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { requestedDelivery } from "./inbound-record.js";
3
- import { isRecord } from "./validation.js";
4
2
  import { runCooperatively } from "../shared/cooperative.js";
5
3
  /** Owns cooperative inbound batches, restoration and the flush timer. */
6
4
  export class InboundDeliveryOwner {
@@ -83,6 +81,9 @@ export class InboundDeliveryOwner {
83
81
  this.timer.unref();
84
82
  }
85
83
  queue(entry, flushDelivery = "auto", delayMs = 200) {
84
+ if (!this.journal.owns(entry)) {
85
+ return;
86
+ }
86
87
  this.journal.remember(entry, "queued", flushDelivery);
87
88
  this.hooks.syncStatus();
88
89
  this.schedule(delayMs);
@@ -112,8 +113,7 @@ export class InboundDeliveryOwner {
112
113
  }
113
114
  triggerIndex(entries) {
114
115
  const canTrigger = (entry) => entry.flushDelivery !== "passive" && requestedDelivery(entry.message) !== "passive";
115
- const ask = entries.findIndex((entry) => canTrigger(entry) && entry.message.expectsReply === true);
116
- return ask === -1 ? entries.findIndex(canTrigger) : ask;
116
+ return entries.findLastIndex(canTrigger);
117
117
  }
118
118
  *sendTriggerLast(entries, generation) {
119
119
  const passives = entries.filter((entry) => entry.flushDelivery === "passive");
@@ -124,14 +124,15 @@ export class InboundDeliveryOwner {
124
124
  }
125
125
  const index = this.triggerIndex(rest);
126
126
  const trigger = index === -1 ? undefined : rest.splice(index, 1)[0];
127
- if (trigger && this.journal.get(trigger.message.id) === trigger) {
128
- this.hooks.replies.queueTurnContext({ ...trigger, receivedAt: Date.now() });
127
+ const replyTarget = entries.find((entry) => entry.message.expectsReply === true) ?? trigger;
128
+ if (replyTarget && this.journal.get(replyTarget.message.id) === replyTarget) {
129
+ this.hooks.replies.queueTurnContext({ ...replyTarget, receivedAt: Date.now() });
129
130
  }
130
131
  for (const entry of rest) {
131
132
  if (!this.live(generation)) {
132
133
  return;
133
134
  }
134
- this.send(entry, entry.flushDelivery === "steer" ? "steer" : "followUp", generation);
135
+ this.send(entry, requestedDelivery(entry.message) === "queue" ? "followUp" : "steer", generation);
135
136
  yield;
136
137
  }
137
138
  if (trigger) {
@@ -150,7 +151,7 @@ export class InboundDeliveryOwner {
150
151
  live(generation) {
151
152
  return this.lifecycle.generation === generation && this.lifecycle.live() !== null;
152
153
  }
153
- *sendSteers(entries, generation, onDetach) {
154
+ *sendSteers(entries, generation) {
154
155
  for (const entry of entries) {
155
156
  if (!this.live(generation)) {
156
157
  return;
@@ -159,9 +160,6 @@ export class InboundDeliveryOwner {
159
160
  continue;
160
161
  }
161
162
  this.send(entry, "steer", generation);
162
- if (requestedDelivery(entry.message) === "steer") {
163
- onDetach(this.detach(entry, entry.message.expectsReply !== true));
164
- }
165
163
  yield;
166
164
  }
167
165
  }
@@ -184,13 +182,7 @@ export class InboundDeliveryOwner {
184
182
  this.schedule(500);
185
183
  return;
186
184
  }
187
- return this.runBatch(async () => {
188
- const detachments = [];
189
- await runCooperatively(this.sendSteers(steers, generation, (task) => {
190
- detachments.push(task);
191
- }));
192
- await Promise.all(detachments);
193
- }, generation);
185
+ return this.runBatch(() => runCooperatively(this.sendSteers(steers, generation)), generation);
194
186
  }
195
187
  messageOptions(delivery) {
196
188
  if (delivery === "trigger") {
@@ -229,56 +221,20 @@ export class InboundDeliveryOwner {
229
221
  }
230
222
  }
231
223
  nativeMessage(entry) {
232
- const sender = entry.from.name === undefined || entry.from.name === ""
233
- ? entry.from.id.slice(0, 8)
234
- : entry.from.name;
224
+ const sender = entry.from.name ?? entry.from.id.slice(0, 8);
235
225
  const command = entry.replyCommand ?? "";
236
226
  const reply = command === "" ? "" : `\n\nTo reply, use the intercom tool: ${command}`;
227
+ const hint = entry.message.replyHint;
228
+ const publication = hint
229
+ ? `\n\nTo publish a correlated reply, use ${hint.tool} with commandId ${hint.commandId}. This is not an intercom ask.`
230
+ : "";
237
231
  return {
238
232
  customType: "intercom_message",
239
- content: `**📨 From ${sender}** (Native session cwd: ${entry.from.cwd})${reply}\n\n${entry.bodyText}`,
233
+ content: `**📨 From ${sender}** (Native session cwd: ${entry.from.cwd})${reply}${publication}\n\n${entry.bodyText}`,
240
234
  display: true,
241
235
  details: { ...entry, bodyText: entry.bodyText },
242
236
  };
243
237
  }
244
- async detach(entry, attention = false) {
245
- if (!attention && entry.message.expectsReply !== true) {
246
- return false;
247
- }
248
- const requestId = randomUUID();
249
- return await new Promise((resolve) => {
250
- let settled = false;
251
- const finish = (accepted) => {
252
- if (settled) {
253
- return;
254
- }
255
- settled = true;
256
- clearTimeout(timer);
257
- unsubscribe();
258
- resolve(accepted);
259
- };
260
- const timer = setTimeout(() => finish(false), 500);
261
- timer.unref();
262
- const unsubscribe = this.pi.events.on("pi-intercom:detach-response", (payload) => {
263
- if (isRecord(payload) && payload.requestId === requestId) {
264
- finish(payload.accepted === true);
265
- }
266
- });
267
- try {
268
- this.pi.events.emit("pi-intercom:detach-request", {
269
- requestId,
270
- ...(attention ? { reason: "attention" } : {}),
271
- });
272
- // Owned waits answer ordinary attention synchronously; an unanswered probe completes now.
273
- if (attention) {
274
- finish(false);
275
- }
276
- }
277
- catch {
278
- finish(false);
279
- }
280
- });
281
- }
282
238
  async redeliver(ctx, generation) {
283
239
  if (this.batchTask) {
284
240
  await this.batchTask;