@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,5 +1,8 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isContextRequest } from "./lifecycle.js";
1
3
  import { INBOUND_CHECKPOINT_TYPE, } from "./runtime-types.js";
2
- import { isInboundCheckpoint } from "./inbound-record.js";
4
+ import { isInboundCheckpoint, publicationMetadata, requestedDelivery } from "./inbound-record.js";
5
+ import { isReceiptQuery, } from "./types.js";
3
6
  import { inboundIdFromCustomMessage } from "./message-format.js";
4
7
  import { isRecord } from "./validation.js";
5
8
  export class InboundJournal {
@@ -14,6 +17,72 @@ export class InboundJournal {
14
17
  this.pi = pi;
15
18
  this.lifecycle = lifecycle;
16
19
  this.replies = replies;
20
+ pi.events.on("pi-intercom:provenance-query", (request) => {
21
+ if (!isContextRequest(request)) {
22
+ return;
23
+ }
24
+ request.respond(request.version === 1 && isReceiptQuery(request)
25
+ ? this.receipt(request)
26
+ : { error: "publication_unavailable" });
27
+ });
28
+ }
29
+ receipt(query) {
30
+ const ctx = this.lifecycle.live();
31
+ if (!ctx || ctx.sessionManager.getSessionId() !== query.rootSessionId) {
32
+ return { error: "publication_unavailable" };
33
+ }
34
+ let receipt;
35
+ for (const item of ctx.sessionManager.getEntries()) {
36
+ receipt = this.receiptEntry(receipt, query, item);
37
+ }
38
+ return receipt ? { receipt } : { error: "receipt_not_found" };
39
+ }
40
+ receiptEntry(receipt, query, item) {
41
+ if (item.type === "custom" &&
42
+ item.customType === INBOUND_CHECKPOINT_TYPE &&
43
+ isInboundCheckpoint(item.data) &&
44
+ item.data.sessionId === query.rootSessionId) {
45
+ return this.receiptCheckpoint(receipt, query, item.id, item.data);
46
+ }
47
+ return receipt && inboundIdFromCustomMessage(item) === query.messageId
48
+ ? { ...receipt, placementEntryId: item.id }
49
+ : receipt;
50
+ }
51
+ receiptCheckpoint(receipt, query, entryId, data) {
52
+ if ("entry" in data) {
53
+ if (receipt || data.entry.message.id !== query.messageId) {
54
+ return receipt;
55
+ }
56
+ const { from, message, stage, receivedAt } = data.entry;
57
+ return {
58
+ version: 1,
59
+ rootSessionId: query.rootSessionId,
60
+ messageId: query.messageId,
61
+ sender: {
62
+ peerId: from.id,
63
+ rootSessionId: from.rootSessionId,
64
+ incarnation: from.incarnation,
65
+ },
66
+ textSha256: createHash("sha256").update(message.content.text, "utf8").digest("hex"),
67
+ delivery: requestedDelivery(message),
68
+ ...publicationMetadata(message),
69
+ checkpointEntryId: entryId,
70
+ receivedAt,
71
+ queued: stage === "queued",
72
+ nativeHandoff: stage === "native",
73
+ placementEntryId: null,
74
+ discarded: false,
75
+ };
76
+ }
77
+ if (!receipt || data.messageId !== query.messageId) {
78
+ return receipt;
79
+ }
80
+ return {
81
+ ...receipt,
82
+ queued: receipt.queued || data.stage === "queued",
83
+ nativeHandoff: receipt.nativeHandoff || data.stage === "native",
84
+ discarded: receipt.discarded || data.stage === "discarded",
85
+ };
17
86
  }
18
87
  peerLeft(id) {
19
88
  for (const pending of this.replies.listPending()) {
@@ -24,7 +93,7 @@ export class InboundJournal {
24
93
  this.replies.expireSender(id);
25
94
  for (const pending of this.queued()) {
26
95
  if (pending.from.id === id && pending.message.expectsReply === true) {
27
- this.pending.delete(pending.message.id);
96
+ this.consume(pending.message.id);
28
97
  this.checkpoint({ messageId: pending.message.id, stage: "discarded" });
29
98
  }
30
99
  }
@@ -42,7 +111,10 @@ export class InboundJournal {
42
111
  return this.pending.get(id);
43
112
  }
44
113
  owns(entry) {
45
- return !("stage" in entry) || this.pending.get(entry.message.id) === entry;
114
+ const id = entry.message.id;
115
+ return "stage" in entry
116
+ ? this.pending.get(id) === entry
117
+ : !this.pending.has(id) && !this.consumed.has(id);
46
118
  }
47
119
  checkpoint(update) {
48
120
  const sessionId = this.lifecycle.currentSessionId;
@@ -77,7 +149,7 @@ export class InboundJournal {
77
149
  if (pending.stage === "queued" &&
78
150
  pending.from.id === entry.from.id &&
79
151
  pending.message.threadId === entry.message.threadId) {
80
- this.pending.delete(pending.message.id);
152
+ this.consume(pending.message.id);
81
153
  this.replies.markReplied(pending.message.id);
82
154
  }
83
155
  }
@@ -135,7 +207,7 @@ export class InboundJournal {
135
207
  return;
136
208
  }
137
209
  if (data.stage === "discarded") {
138
- this.pending.delete(data.messageId);
210
+ this.consume(data.messageId);
139
211
  this.replies.markReplied(data.messageId);
140
212
  return;
141
213
  }
@@ -163,6 +235,11 @@ export class InboundJournal {
163
235
  if (item.customType === "intercom_sent") {
164
236
  this.restoreSent(item.data);
165
237
  }
238
+ if (item.customType === "intercom_received" &&
239
+ isRecord(item.data) &&
240
+ typeof item.data.messageId === "string") {
241
+ this.consume(item.data.messageId);
242
+ }
166
243
  }
167
244
  restoreSent(data) {
168
245
  if (isRecord(data) &&
@@ -194,7 +271,7 @@ export class InboundJournal {
194
271
  return ((entry.type === "custom_message" &&
195
272
  ["intercom_message", "subagent-human-message"].includes(entry.customType)) ||
196
273
  (entry.type === "custom" &&
197
- [INBOUND_CHECKPOINT_TYPE, "intercom_sent"].includes(entry.customType)));
274
+ [INBOUND_CHECKPOINT_TYPE, "intercom_sent", "intercom_received"].includes(entry.customType)));
198
275
  }
199
276
  recentEntries(ctx, fullScan) {
200
277
  if (fullScan) {
@@ -1,10 +1,25 @@
1
1
  import { isMessage, isSessionRegistration } from "./types.js";
2
2
  import { finiteNumber, isRecord } from "./validation.js";
3
+ export function publicationMetadata(message) {
4
+ return {
5
+ guard: message.guard && {
6
+ rootSessionId: message.guard.rootSessionId,
7
+ peerId: message.guard.peerId,
8
+ incarnation: message.guard.incarnation,
9
+ applicationGuard: message.guard.applicationGuard,
10
+ },
11
+ replyHint: message.replyHint && {
12
+ tool: message.replyHint.tool,
13
+ commandId: message.replyHint.commandId,
14
+ commandFingerprint: message.replyHint.commandFingerprint,
15
+ },
16
+ };
17
+ }
3
18
  export function requestedDelivery(message) {
4
19
  if (message.passive === true || message.delivery === "passive") {
5
20
  return "passive";
6
21
  }
7
- return message.delivery ?? (message.expectsReply === true ? "auto" : "steer");
22
+ return message.delivery ?? "steer";
8
23
  }
9
24
  function isLocalJournalMessage(value) {
10
25
  if (!isRecord(value) || !isRecord(value.content)) {
@@ -25,6 +40,8 @@ function isLocalJournalMessage(value) {
25
40
  "queueMode",
26
41
  "threadId",
27
42
  "passive",
43
+ "guard",
44
+ "replyHint",
28
45
  ].every((key) => value[key] === undefined));
29
46
  }
30
47
  function isJournalMessage(value, sender) {
@@ -45,63 +45,30 @@ export class IntercomInbound {
45
45
  }
46
46
  return true;
47
47
  }
48
- async busyNonInteractive(entry, ctx, generation) {
49
- if (entry.message.expectsReply !== true) {
50
- this.owners.delivery.send(entry, "steer", generation);
51
- return;
52
- }
53
- const active = this.owners.connection.active;
54
- if (entry.message.replyTo !== undefined || active?.isConnected() !== true) {
55
- return;
56
- }
57
- try {
58
- const result = await active.send(entry.from.id, {
59
- text: "This agent is running in non-interactive mode and cannot respond to intercom messages while it is working. It will continue its current task and exit when done.",
60
- replyTo: entry.message.id,
61
- });
62
- if (result.delivered && this.lifecycle.live(ctx, generation)) {
63
- this.owners.replies.markReplied(entry.message.id);
64
- this.lifecycle.markActivity();
65
- this.owners.connection.syncStatus();
66
- }
67
- }
68
- catch {
69
- /* Best-effort busy reply; the non-interactive task must continue either way. */
70
- }
71
- }
72
48
  async admit(entry, live, generation) {
73
49
  const active = this.lifecycle.live(live, generation);
74
50
  if (!active || this.queueAdmission(entry, active)) {
75
51
  return;
76
52
  }
77
53
  if (!this.lifecycle.isIdle(active)) {
78
- await this.busy(entry, active, generation);
54
+ this.busy(entry, generation);
79
55
  return;
80
56
  }
81
57
  if (this.lifecycle.live(live, generation)) {
82
58
  this.owners.delivery.send(entry, requestedDelivery(entry.message) === "passive" ? "passive" : "trigger", generation);
83
59
  }
84
60
  }
85
- async busy(entry, ctx, generation) {
61
+ busy(entry, generation) {
86
62
  const delivery = requestedDelivery(entry.message);
87
63
  if (delivery === "steer") {
88
64
  this.owners.delivery.send(entry, "steer", generation);
89
- await this.owners.delivery.detach(entry, entry.message.expectsReply !== true);
90
65
  return;
91
66
  }
92
- if (delivery === "queue" && entry.message.queueMode !== "replace") {
67
+ if (delivery === "queue") {
93
68
  this.owners.delivery.send(entry, "followUp", generation);
94
69
  return;
95
70
  }
96
- if (delivery === "passive") {
97
- this.owners.delivery.queue(entry, "passive");
98
- return;
99
- }
100
- if (!ctx.hasUI) {
101
- await this.busyNonInteractive(entry, ctx, generation);
102
- return;
103
- }
104
- this.owners.delivery.queue(entry, "auto");
71
+ this.owners.delivery.queue(entry, "passive");
105
72
  }
106
73
  async receive(ctx, from, input) {
107
74
  const generation = this.lifecycle.generation;
@@ -113,9 +80,17 @@ export class IntercomInbound {
113
80
  if (!live) {
114
81
  return;
115
82
  }
83
+ if (input.guard && !this.lifecycle.accepts(input.guard)) {
84
+ return;
85
+ }
116
86
  // Peer metadata never grants user authority.
117
87
  const message = input.human ? { ...input, human: undefined } : input;
88
+ const entry = this.entry(from, message);
89
+ if (!this.owners.journal.owns(entry)) {
90
+ return;
91
+ }
118
92
  if (this.owners.wait.receive(from, message, () => {
93
+ this.owners.journal.consume(message.id);
119
94
  this.lifecycle.markActivity();
120
95
  this.owners.connection.syncStatus();
121
96
  })) {
@@ -124,7 +99,6 @@ export class IntercomInbound {
124
99
  this.owners.replies.recordIncomingMessage(from, message);
125
100
  this.lifecycle.markActivity();
126
101
  this.owners.connection.syncStatus();
127
- const entry = this.entry(from, message);
128
102
  await this.admit(entry, live, generation);
129
103
  }
130
104
  }
@@ -21,6 +21,7 @@ export default function piIntercomExtension(pi) {
21
21
  const connection = new IntercomConnection(lifecycle, config, {
22
22
  restoring: () => delivery.restoring,
23
23
  pendingAsks: () => replies.listPending().length,
24
+ receipt: (query) => journal.receipt(query),
24
25
  incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
25
26
  peerLeft: (id) => {
26
27
  wait.peerDisconnected(id);
@@ -34,8 +35,10 @@ export default function piIntercomExtension(pi) {
34
35
  replies,
35
36
  syncStatus: () => connection.syncStatus(),
36
37
  });
38
+ lifecycle.registerContextEvents(() => connection.syncStatus());
37
39
  const inbound = new IntercomInbound(lifecycle, config, {
38
40
  delivery,
41
+ journal,
39
42
  replies,
40
43
  wait,
41
44
  connection,
@@ -1,17 +1,6 @@
1
- function validPage(offset, limit) {
2
- return (Number.isSafeInteger(offset) &&
3
- offset >= 0 &&
4
- Number.isSafeInteger(limit) &&
5
- limit >= 1 &&
6
- limit <= 16384);
7
- }
1
+ import { isLastMessageQuery, LAST_MESSAGE_PAGE_LIMIT, } from "./types.js";
8
2
  function completedResponse(manager, entryId) {
9
- let id = manager.getLeafId();
10
- while (id !== null) {
11
- const entry = manager.getEntry(id);
12
- if (!entry) {
13
- throw new Error("Native session history is unavailable.");
14
- }
3
+ for (const entry of manager.getBranch().reverse()) {
15
4
  if (entry.type === "message" &&
16
5
  entry.message.role === "assistant" &&
17
6
  (entry.message.stopReason === "stop" || entry.message.stopReason === "length") &&
@@ -24,15 +13,14 @@ function completedResponse(manager, entryId) {
24
13
  .join("\n"),
25
14
  };
26
15
  }
27
- id = entry.parentId;
28
16
  }
29
17
  return;
30
18
  }
31
19
  /** Reads finalized native history only; no model invocation or synthesized response. */
32
20
  export function readLastMessage(manager, session, query) {
33
21
  const offset = query.offset ?? 0;
34
- const limit = query.limit ?? 16384;
35
- if (!validPage(offset, limit)) {
22
+ const limit = query.limit ?? LAST_MESSAGE_PAGE_LIMIT;
23
+ if (!isLastMessageQuery({ offset, limit })) {
36
24
  return {
37
25
  error: "Invalid last response page: offset must be non-negative and limit must be 1–16384.",
38
26
  };
@@ -1,5 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { buildPresenceIdentity } from "./identity.js";
2
3
  import { resolveSessionProjectId } from "./session-targets.js";
4
+ import { isContextTarget, } from "./types.js";
5
+ import { isRecord, safeLabel } from "./validation.js";
6
+ export function isContextRequest(value) {
7
+ return isRecord(value) && typeof value.respond === "function";
8
+ }
3
9
  /** Owns native context validity and occupancy, never journal or socket state. */
4
10
  export class IntercomLifecycle {
5
11
  context = null;
@@ -7,6 +13,10 @@ export class IntercomLifecycle {
7
13
  model = "unknown";
8
14
  disposed = true;
9
15
  epoch = 0;
16
+ incarnation = null;
17
+ peerId = null;
18
+ guardedDelivery = false;
19
+ applicationGuard;
10
20
  running = false;
11
21
  activity = 0;
12
22
  tools = new Map();
@@ -27,15 +37,87 @@ export class IntercomLifecycle {
27
37
  get toolCount() {
28
38
  return this.tools.size;
29
39
  }
40
+ registerContextEvents(sync) {
41
+ this.pi.events.on("pi-intercom:context-query", (request) => this.contextRequest(request));
42
+ this.pi.events.on("pi-intercom:application-guard", (request) => this.contextRequest(request, sync));
43
+ }
44
+ contextRequest(request, sync) {
45
+ if (!isContextRequest(request)) {
46
+ return;
47
+ }
48
+ if (request.version !== 1) {
49
+ request.respond({ error: "unsupported_version" });
50
+ return;
51
+ }
52
+ if (sync) {
53
+ if (!isContextTarget(request.target) ||
54
+ !this.matches(request.target) ||
55
+ !(request.revision === null ||
56
+ (typeof request.revision === "string" && safeLabel(request.revision)))) {
57
+ request.respond({ error: "context_changed" });
58
+ return;
59
+ }
60
+ this.applicationGuard = request.revision === null ? undefined : request.revision;
61
+ sync();
62
+ this.publishContext();
63
+ }
64
+ request.respond(this.snapshot());
65
+ }
66
+ snapshot() {
67
+ if (!this.live()) {
68
+ return {
69
+ version: 1,
70
+ rootSessionId: null,
71
+ peerId: null,
72
+ incarnation: null,
73
+ connected: false,
74
+ guardedDelivery: null,
75
+ };
76
+ }
77
+ return {
78
+ version: 1,
79
+ rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
80
+ parentSessionId: this.lineage?.parentSessionId,
81
+ peerId: this.peerId,
82
+ incarnation: this.incarnation,
83
+ connected: this.peerId !== null,
84
+ guardedDelivery: this.guardedDelivery ? 1 : null,
85
+ applicationGuard: this.applicationGuard,
86
+ };
87
+ }
88
+ publishContext() {
89
+ this.pi.events.emit("pi-intercom:context-state", this.snapshot());
90
+ }
91
+ transport(peerId, guardedDelivery = false) {
92
+ this.peerId = peerId;
93
+ this.guardedDelivery = guardedDelivery;
94
+ this.publishContext();
95
+ }
96
+ matches(target) {
97
+ const current = this.snapshot();
98
+ return (current.connected &&
99
+ current.rootSessionId === target.rootSessionId &&
100
+ current.peerId === target.peerId &&
101
+ current.incarnation === target.incarnation);
102
+ }
103
+ accepts(guard) {
104
+ return (this.guardedDelivery &&
105
+ this.lineage?.parentSessionId === undefined &&
106
+ this.matches(guard) &&
107
+ this.applicationGuard === guard.applicationGuard);
108
+ }
30
109
  start(ctx) {
31
110
  this.disposed = false;
32
111
  this.epoch += 1;
33
112
  this.context = ctx;
34
113
  this.sessionId = ctx.sessionManager.getSessionId();
114
+ this.incarnation = randomUUID();
115
+ this.applicationGuard = undefined;
35
116
  this.model = ctx.model?.id ?? "unknown";
36
117
  this.running = false;
37
118
  this.activity = 0;
38
119
  this.tools.clear();
120
+ this.transport(null);
39
121
  return this.epoch;
40
122
  }
41
123
  invalidate() {
@@ -43,6 +125,7 @@ export class IntercomLifecycle {
43
125
  this.epoch += 1;
44
126
  this.running = false;
45
127
  this.tools.clear();
128
+ this.publishContext();
46
129
  }
47
130
  clear() {
48
131
  this.context = null;
@@ -129,12 +212,14 @@ export class IntercomLifecycle {
129
212
  if (!live || this.sessionId === null) {
130
213
  throw new Error("Intercom runtime not initialized");
131
214
  }
132
- const cwd = live.cwd;
133
215
  return {
134
216
  ...this.identity(this.sessionId),
135
- cwd,
217
+ incarnation: this.incarnation ?? undefined,
218
+ guardedDelivery: 1,
219
+ applicationGuard: this.applicationGuard,
220
+ cwd: live.cwd,
136
221
  model: this.model,
137
- projectId: await resolveSessionProjectId(cwd),
222
+ projectId: await resolveSessionProjectId(live.cwd),
138
223
  lastSeen: Date.now(),
139
224
  status: this.status(),
140
225
  ...this.health(pendingAsks),
@@ -3,7 +3,7 @@ import { resolveConnectedTarget, resolvePeerHealth, } from "./connection.js";
3
3
  import { RECIPIENT_TURN_FAILED_ATTACHMENT, } from "./runtime-types.js";
4
4
  import { errorMessage } from "./validation.js";
5
5
  import { formatAttachments } from "./message-format.js";
6
- import { AskDeliveryError, failureDetails, replyFailureReason, } from "./tool-results.js";
6
+ import { AskDeliveryError, failureDetails, RecoveryError, } from "./tool-results.js";
7
7
  import { sendOptions, toolError, toolText } from "./tool-arguments.js";
8
8
  export class IntercomOutbound {
9
9
  owners;
@@ -101,8 +101,7 @@ export class IntercomOutbound {
101
101
  return this.sent(to, params, result);
102
102
  }
103
103
  catch (error) {
104
- const text = errorMessage(error);
105
- return toolError(`Failed to send: ${text}`, failureDetails(text.startsWith("Target ") ? "ambiguous_target" : "send_failed", [
104
+ return toolError(`Failed to send: ${errorMessage(error)}`, failureDetails(error instanceof RecoveryError ? error.code : "send_failed", [
106
105
  { action: "list" },
107
106
  { action: "send", guidance: "Retry with an exact active recipient target." },
108
107
  ], { error: true }));
@@ -147,15 +146,7 @@ export class IntercomOutbound {
147
146
  const result = this.deliveryFailure(to, error.result);
148
147
  return { ...result, details: { ...result.details, error: true } };
149
148
  }
150
- const text = errorMessage(error);
151
- let reason = "ask_failed";
152
- if (text.startsWith("No reply from")) {
153
- reason = "reply_timeout";
154
- }
155
- else if (text.startsWith("Target ")) {
156
- reason = "ambiguous_target";
157
- }
158
- return toolError(`Failed: ${text}`, failureDetails(reason, [
149
+ return toolError(`Failed: ${errorMessage(error)}`, failureDetails(error instanceof RecoveryError ? error.code : "ask_failed", [
159
150
  { action: "status" },
160
151
  { action: "list" },
161
152
  {
@@ -293,8 +284,7 @@ export class IntercomOutbound {
293
284
  }
294
285
  }
295
286
  replyFailure(error, replyTo) {
296
- const text = errorMessage(error);
297
- const reason = replyFailureReason(text);
287
+ const reason = error instanceof RecoveryError ? error.code : "reply_failed";
298
288
  const actions = reason === "no_pending_reply"
299
289
  ? [
300
290
  { action: "pending" },
@@ -304,7 +294,7 @@ export class IntercomOutbound {
304
294
  },
305
295
  ]
306
296
  : [{ action: "pending" }, { action: "list" }];
307
- return toolError(`Failed to reply: ${text}`, failureDetails(reason, actions, {
297
+ return toolError(`Failed to reply: ${errorMessage(error)}`, failureDetails(reason, actions, {
308
298
  error: true,
309
299
  ...((replyTo ?? "") !== "" ? { replyTo } : {}),
310
300
  }));
@@ -1,4 +1,5 @@
1
1
  import { formatSessionTarget, resolveSessionTarget, shortSessionId } from "./session-targets.js";
2
+ import { RecoveryError } from "./tool-results.js";
2
3
  function resolveBySenderTarget(contexts, to) {
3
4
  const sessions = contexts.map((context) => context.from);
4
5
  const resolution = resolveSessionTarget(sessions, to);
@@ -26,14 +27,14 @@ function pendingSenderOptions(contexts, allContexts = contexts) {
26
27
  function matchingSender(contexts, to) {
27
28
  const tooShort = tooShortSenderTargetMessage(contexts, to);
28
29
  if (tooShort !== null && tooShort !== "") {
29
- throw new Error(tooShort);
30
+ throw new RecoveryError("ambiguous_reply_target", tooShort);
30
31
  }
31
32
  return resolveBySenderTarget(contexts, to);
32
33
  }
33
34
  function explicitReply(contexts, replyTo, to) {
34
35
  const target = contexts.find((context) => context.message.id === replyTo);
35
36
  if (!target) {
36
- throw new Error(`No pending ask with replyTo "${replyTo}"`);
37
+ throw new RecoveryError("no_pending_reply", `No pending ask with replyTo "${replyTo}"`);
37
38
  }
38
39
  if (to !== "" &&
39
40
  !matchingSender(contexts, to).some((context) => context.message.id === target.message.id)) {
@@ -47,9 +48,9 @@ function replyBySender(contexts, to) {
47
48
  return matches[0];
48
49
  }
49
50
  if (matches.length > 1) {
50
- throw new Error(`Multiple pending asks from "${to}" — use one of: ${pendingSenderOptions(matches, contexts)}.`);
51
+ throw new RecoveryError("ambiguous_reply_target", `Multiple pending asks from "${to}" — use one of: ${pendingSenderOptions(matches, contexts)}.`);
51
52
  }
52
- throw new Error(`No pending ask from "${to}"`);
53
+ throw new RecoveryError("no_pending_reply", `No pending ask from "${to}"`);
53
54
  }
54
55
  export class ReplyTracker {
55
56
  pendingAsks = new Map();
@@ -151,9 +152,9 @@ export class ReplyTracker {
151
152
  return pending[0];
152
153
  }
153
154
  if (pending.length === 0) {
154
- throw new Error("No active intercom context to reply to");
155
+ throw new RecoveryError("no_pending_reply", "No active intercom context to reply to");
155
156
  }
156
- throw new Error("Multiple pending asks — specify `to`");
157
+ throw new RecoveryError("ambiguous_reply_target", "Multiple pending asks — specify `to`");
157
158
  }
158
159
  markReplied(replyTo) {
159
160
  this.removeContext(replyTo);
@@ -1,5 +1,5 @@
1
1
  import { asError } from "./validation.js";
2
- import { AskDeliveryError } from "./tool-results.js";
2
+ import { AskDeliveryError, RecoveryError } from "./tool-results.js";
3
3
  /** One correlated blocking wait; admitted before send so a fast reply cannot race it. */
4
4
  export class ReplyWait {
5
5
  waiter = null;
@@ -42,7 +42,7 @@ export class ReplyWait {
42
42
  return new Promise((resolve, reject) => {
43
43
  const { from, replyTo, signal } = options;
44
44
  const timeout = setTimeout(() => {
45
- this.reject(new Error(`No reply from "${from}" within ${Math.max(1, Math.round(this.timeoutMs / 60000))} minute(s)`));
45
+ this.reject(new RecoveryError("reply_timeout", `No reply from "${from}" within ${Math.max(1, Math.round(this.timeoutMs / 60000))} minute(s)`));
46
46
  }, this.timeoutMs);
47
47
  timeout.unref();
48
48
  const cleanup = () => {
@@ -0,0 +1,24 @@
1
+ export interface TargetIdentity {
2
+ readonly id: string;
3
+ readonly name?: string;
4
+ }
5
+ export interface ProjectSessionIdentity extends TargetIdentity {
6
+ readonly cwd: string;
7
+ readonly projectId?: string;
8
+ }
9
+ export declare const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
10
+ export declare function nativePeerId(sessionId: string): string;
11
+ export interface TargetResolution<T extends TargetIdentity> {
12
+ readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
13
+ readonly target?: T;
14
+ readonly matches: readonly T[];
15
+ }
16
+ export declare function shortSessionId(sessionId: string): string;
17
+ export declare function resolveSessionProjectId(cwd: string): Promise<string>;
18
+ export declare function formatSessionTarget(session: TargetIdentity, allSessions?: readonly TargetIdentity[]): string;
19
+ export declare function formatTargetOptions(sessions: readonly TargetIdentity[], allSessions?: readonly TargetIdentity[]): string;
20
+ export declare function targetDisplayName(session: TargetIdentity, allSessions?: readonly TargetIdentity[]): string;
21
+ export declare const PEER_AWARENESS_HINT = "Other Pi sessions may be connected to this project. Use load_intercom({}) if needed, then intercom({ action: \"list\" }) before changing shared state or coordinating known overlapping work. Routine standalone read-only tasks do not need a peer check. Coordinate only when work overlaps; use subagent controls for managed child runs.";
22
+ export declare function filterProjectSessions<T extends ProjectSessionIdentity>(sessions: readonly T[], currentSessionId: string): T[];
23
+ export declare function formatPeerAwarenessHint(sessions: readonly ProjectSessionIdentity[], currentSessionId: string): string | undefined;
24
+ export declare function resolveSessionTarget<T extends TargetIdentity>(sessions: readonly T[], rawTarget: string): TargetResolution<T>;
@@ -1,11 +1,14 @@
1
1
  import { throwIfToolError } from "./tool-results.js";
2
2
  import { intercomSchema } from "./tool-schemas.js";
3
+ import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.js";
3
4
  export function registerIntercomTools(pi, tools) {
5
+ registerCompactMessages(pi, ["intercom_message", "subagent-human-message"]);
4
6
  pi.registerTool({
5
7
  name: "intercom",
6
8
  label: "Intercom",
7
- description: "Discover and message connected Pi sessions in your collaboration group; main sessions can also reach other mains, across projects. Use list for unambiguous targets. Send defaults to steer at the next tool boundary; queue defers; passive never wakes the model. Ask waits for a correlated reply; reply answers the active or single pending ask (specify replyTo when ambiguous). Pending lists asks; status shows connection and inbound queue state. Last reads a peer's original completed assistant response without waking it; page using entryId and nextOffset. Broker acceptance is not recipient persistence, consumption, or task completion.",
9
+ description: "Message connected Pi sessions in your group; mains also reach other mains across projects. List shows target IDs. Ask waits for a correlated reply; reply answers the active or sole pending ask. Pending lists asks; status shows connection and inbound queue. Last pages a peer's last completed response without waking it. Broker acceptance is not recipient persistence, consumption or completion.",
8
10
  parameters: intercomSchema,
11
+ renderResult: renderCompactResult,
9
12
  async execute(_id, params, signal, _update, ctx) {
10
13
  const result = throwIfToolError(await tools.execute(params, signal, ctx));
11
14
  return { content: [...result.content], details: result.details };
@@ -1,14 +1,12 @@
1
1
  export function failureDetails(reasonCode, nextActions, details = {}) {
2
2
  return { ...details, reasonCode, nextActions };
3
3
  }
4
- export function replyFailureReason(message) {
5
- if (message.startsWith("No active intercom context") || message.startsWith("No pending ask")) {
6
- return "no_pending_reply";
7
- }
8
- if (message.startsWith("Multiple pending asks") || message.includes("too short")) {
9
- return "ambiguous_reply_target";
4
+ export class RecoveryError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
10
9
  }
11
- return "reply_failed";
12
10
  }
13
11
  export function firstTextContent(result) {
14
12
  return (result.content