@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
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { IntercomConfig } from "./config.ts";
3
3
  import type { IntercomLifecycle } from "./lifecycle.ts";
4
4
  import type { InboundDeliveryOwner } from "./inbound-delivery.ts";
5
+ import type { InboundJournal } from "./inbound-journal.ts";
5
6
  import type { ReplyTracker } from "./reply-tracker.ts";
6
7
  import type { ReplyWait } from "./reply-wait.ts";
7
8
  import type { IntercomConnection } from "./connection.ts";
@@ -11,17 +12,18 @@ import { requestedDelivery } from "./inbound-record.ts";
11
12
  import { formatAttachments } from "./message-format.ts";
12
13
 
13
14
  type Lifecycle = Readonly<
14
- Pick<IntercomLifecycle, "generation" | "isIdle" | "live" | "markActivity">
15
+ Pick<IntercomLifecycle, "accepts" | "generation" | "isIdle" | "live" | "markActivity">
15
16
  >;
16
17
  type InboundDeliveryHandle = Readonly<
17
- Pick<InboundDeliveryOwner, "batching" | "detach" | "queue" | "restoring" | "send">
18
+ Pick<InboundDeliveryOwner, "batching" | "queue" | "restoring" | "send">
18
19
  >;
19
20
  type ReplyWaitHandle = Readonly<Pick<ReplyWait, "receive">>;
20
- type Connection = Readonly<Pick<IntercomConnection, "active" | "syncStatus">>;
21
- type Replies = Readonly<Pick<ReplyTracker, "markReplied" | "recordIncomingMessage">>;
21
+ type Connection = Readonly<Pick<IntercomConnection, "syncStatus">>;
22
+ type Replies = Readonly<Pick<ReplyTracker, "recordIncomingMessage">>;
22
23
 
23
24
  interface InboundOwners {
24
25
  readonly delivery: InboundDeliveryHandle;
26
+ readonly journal: Readonly<Pick<InboundJournal, "consume" | "owns">>;
25
27
  readonly replies: Replies;
26
28
  readonly wait: ReplyWaitHandle;
27
29
  readonly connection: Connection;
@@ -71,33 +73,6 @@ export class IntercomInbound {
71
73
  }
72
74
  return true;
73
75
  }
74
- private async busyNonInteractive(
75
- entry: InboundMessageEntry,
76
- ctx: ExtensionContext,
77
- generation: number,
78
- ): Promise<void> {
79
- if (entry.message.expectsReply !== true) {
80
- this.owners.delivery.send(entry, "steer", generation);
81
- return;
82
- }
83
- const active = this.owners.connection.active;
84
- if (entry.message.replyTo !== undefined || active?.isConnected() !== true) {
85
- return;
86
- }
87
- try {
88
- const result = await active.send(entry.from.id, {
89
- 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.",
90
- replyTo: entry.message.id,
91
- });
92
- if (result.delivered && this.lifecycle.live(ctx, generation)) {
93
- this.owners.replies.markReplied(entry.message.id);
94
- this.lifecycle.markActivity();
95
- this.owners.connection.syncStatus();
96
- }
97
- } catch {
98
- /* Best-effort busy reply; the non-interactive task must continue either way. */
99
- }
100
- }
101
76
  private async admit(
102
77
  entry: InboundMessageEntry,
103
78
  live: ExtensionContext,
@@ -108,7 +83,7 @@ export class IntercomInbound {
108
83
  return;
109
84
  }
110
85
  if (!this.lifecycle.isIdle(active)) {
111
- await this.busy(entry, active, generation);
86
+ this.busy(entry, generation);
112
87
  return;
113
88
  }
114
89
  if (this.lifecycle.live(live, generation)) {
@@ -119,32 +94,33 @@ export class IntercomInbound {
119
94
  );
120
95
  }
121
96
  }
122
- private async busy(
123
- entry: InboundMessageEntry,
124
- ctx: ExtensionContext,
125
- generation: number,
126
- ): Promise<void> {
97
+ private busy(entry: InboundMessageEntry, generation: number): void {
127
98
  const delivery = requestedDelivery(entry.message);
128
99
  if (delivery === "steer") {
129
100
  this.owners.delivery.send(entry, "steer", generation);
130
- await this.owners.delivery.detach(entry, entry.message.expectsReply !== true);
131
101
  return;
132
102
  }
133
- if (delivery === "queue" && entry.message.queueMode !== "replace") {
103
+ if (delivery === "queue") {
134
104
  this.owners.delivery.send(entry, "followUp", generation);
135
105
  return;
136
106
  }
137
- if (delivery === "passive") {
138
- this.owners.delivery.queue(entry, "passive");
139
- return;
140
- }
141
- if (!ctx.hasUI) {
142
- await this.busyNonInteractive(entry, ctx, generation);
143
- return;
144
- }
145
- this.owners.delivery.queue(entry, "auto");
107
+ this.owners.delivery.queue(entry, "passive");
146
108
  }
109
+ /** Counted synchronously so a parking fence observes frames received before its ack. */
110
+ receiving = 0;
147
111
  async receive(ctx: ExtensionContext, from: SessionInfo, input: Message): Promise<void> {
112
+ this.receiving++;
113
+ try {
114
+ await this.receiveCounted(ctx, from, input);
115
+ } finally {
116
+ this.receiving--;
117
+ }
118
+ }
119
+ private async receiveCounted(
120
+ ctx: ExtensionContext,
121
+ from: SessionInfo,
122
+ input: Message,
123
+ ): Promise<void> {
148
124
  const generation = this.lifecycle.generation;
149
125
  const restore = this.owners.delivery.restoring;
150
126
  if (restore) {
@@ -154,10 +130,18 @@ export class IntercomInbound {
154
130
  if (!live) {
155
131
  return;
156
132
  }
133
+ if (input.guard && !this.lifecycle.accepts(input.guard)) {
134
+ return;
135
+ }
157
136
  // Peer metadata never grants user authority.
158
137
  const message = input.human ? { ...input, human: undefined } : input;
138
+ const entry = this.entry(from, message);
139
+ if (!this.owners.journal.owns(entry)) {
140
+ return;
141
+ }
159
142
  if (
160
143
  this.owners.wait.receive(from, message, () => {
144
+ this.owners.journal.consume(message.id);
161
145
  this.lifecycle.markActivity();
162
146
  this.owners.connection.syncStatus();
163
147
  })
@@ -167,7 +151,6 @@ export class IntercomInbound {
167
151
  this.owners.replies.recordIncomingMessage(from, message);
168
152
  this.lifecycle.markActivity();
169
153
  this.owners.connection.syncStatus();
170
- const entry = this.entry(from, message);
171
154
  await this.admit(entry, live, generation);
172
155
  }
173
156
  }
@@ -13,6 +13,20 @@ import { IntercomInspection } from "./inspection.ts";
13
13
  import { IntercomTools } from "./tools.ts";
14
14
  import { IntercomSessionEvents } from "./session-events.ts";
15
15
  import { registerIntercomTools } from "./tool-registration.ts";
16
+ import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.ts";
17
+ import type { NativeHistory } from "./last-message.ts";
18
+
19
+ /** The agent runtime answers for its exited parked child from the captured native session. */
20
+ function parkedHistory(pi: ExtensionAPI, peer: string): Promise<NativeHistory | undefined> {
21
+ let history: Promise<NativeHistory | undefined> | undefined;
22
+ pi.events.emit(PARKED_HISTORY, {
23
+ peer,
24
+ reply: (found: Promise<NativeHistory | undefined>) => {
25
+ history = found;
26
+ },
27
+ });
28
+ return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
29
+ }
16
30
 
17
31
  export default function piIntercomExtension(pi: ExtensionAPI): void {
18
32
  const config = loadConfig();
@@ -23,6 +37,7 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
23
37
  const connection = new IntercomConnection(lifecycle, config, {
24
38
  restoring: () => delivery.restoring,
25
39
  pendingAsks: () => replies.listPending().length,
40
+ receipt: (query) => journal.receipt(query),
26
41
  incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
27
42
  peerLeft: (id) => {
28
43
  wait.peerDisconnected(id);
@@ -33,13 +48,19 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
33
48
  new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }),
34
49
  );
35
50
  },
51
+ parkedHistory: (peer) => parkedHistory(pi, peer.id),
52
+ wake: (peer) => {
53
+ pi.events.emit(WAKE, peer);
54
+ },
36
55
  });
37
56
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
38
57
  replies,
39
58
  syncStatus: () => connection.syncStatus(),
40
59
  });
60
+ lifecycle.registerContextEvents(() => connection.syncStatus());
41
61
  const inbound = new IntercomInbound(lifecycle, config, {
42
62
  delivery,
63
+ journal,
43
64
  replies,
44
65
  wait,
45
66
  connection,
@@ -56,4 +77,27 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
56
77
  wait,
57
78
  }).register();
58
79
  registerIntercomTools(pi, tools);
80
+ pi.events.on(PARK, (request) => {
81
+ if (isParkRequest(request)) {
82
+ request.reply(request.park ? connection.park() : connection.unpark());
83
+ }
84
+ });
85
+ // Native fork sleep readiness; it vetoes only this package's own parking, never other saves.
86
+ Reflect.apply(pi.on.bind(pi), undefined, [
87
+ "working_session_save",
88
+ () =>
89
+ !connection.parking
90
+ ? undefined
91
+ : {
92
+ blockers: [
93
+ inbound.receiving > 0 && "intercom is receiving a message",
94
+ journal.size > 0 && "intercom inbound messages await delivery",
95
+ replies.listPending().length > 0 && "intercom asks await replies",
96
+ wait.waiting && "intercom is waiting for a reply",
97
+ (delivery.restoring !== undefined || delivery.batching) &&
98
+ "intercom delivery is restoring",
99
+ connection.reconnected && "intercom reconnected while parking",
100
+ ].filter((blocker) => typeof blocker === "string"),
101
+ },
102
+ ]);
59
103
  }
@@ -1,26 +1,18 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { LastMessageQuery, LastMessageResult, SessionInfo } from "./types.ts";
2
+ import {
3
+ isLastMessageQuery,
4
+ LAST_MESSAGE_PAGE_LIMIT,
5
+ type LastMessageQuery,
6
+ type LastMessageResult,
7
+ type SessionInfo,
8
+ } from "./types.ts";
3
9
 
4
- type NativeHistory = ExtensionContext["sessionManager"];
5
- function validPage(offset: number, limit: number): boolean {
6
- return (
7
- Number.isSafeInteger(offset) &&
8
- offset >= 0 &&
9
- Number.isSafeInteger(limit) &&
10
- limit >= 1 &&
11
- limit <= 16384
12
- );
13
- }
10
+ export type NativeHistory = Pick<ExtensionContext["sessionManager"], "getBranch">;
14
11
  function completedResponse(
15
12
  manager: NativeHistory,
16
13
  entryId?: string,
17
14
  ): { readonly id: string; readonly text: string } | undefined {
18
- let id = manager.getLeafId();
19
- while (id !== null) {
20
- const entry = manager.getEntry(id);
21
- if (!entry) {
22
- throw new Error("Native session history is unavailable.");
23
- }
15
+ for (const entry of manager.getBranch().reverse()) {
24
16
  if (
25
17
  entry.type === "message" &&
26
18
  entry.message.role === "assistant" &&
@@ -35,7 +27,6 @@ function completedResponse(
35
27
  .join("\n"),
36
28
  };
37
29
  }
38
- id = entry.parentId;
39
30
  }
40
31
  return;
41
32
  }
@@ -46,8 +37,8 @@ export function readLastMessage(
46
37
  query: LastMessageQuery,
47
38
  ): LastMessageResult {
48
39
  const offset = query.offset ?? 0;
49
- const limit = query.limit ?? 16384;
50
- if (!validPage(offset, limit)) {
40
+ const limit = query.limit ?? LAST_MESSAGE_PAGE_LIMIT;
41
+ if (!isLastMessageQuery({ offset, limit })) {
51
42
  return {
52
43
  error: "Invalid last response page: offset must be non-negative and limit must be 1–16384.",
53
44
  };
@@ -1,8 +1,28 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
2
3
  import type { IntercomConfig } from "./config.ts";
3
4
  import { buildPresenceIdentity, type IntercomLineage } from "./identity.ts";
4
5
  import { resolveSessionProjectId } from "./session-targets.ts";
5
- import type { SessionInfo } from "./types.ts";
6
+ import {
7
+ isContextTarget,
8
+ type ContextTarget,
9
+ type DeliveryGuard,
10
+ type SessionInfo,
11
+ } from "./types.ts";
12
+ import { isRecord, safeLabel } from "./validation.ts";
13
+
14
+ export function isContextRequest(value: unknown): value is Readonly<Record<string, unknown>> & {
15
+ readonly respond: (response: unknown) => void;
16
+ } {
17
+ return isRecord(value) && typeof value.respond === "function";
18
+ }
19
+
20
+ type ContextSnapshot = Readonly<Record<keyof ContextTarget, string | null>> &
21
+ Pick<SessionInfo, "parentSessionId" | "applicationGuard"> & {
22
+ readonly version: 1;
23
+ readonly connected: boolean;
24
+ readonly guardedDelivery: 1 | null;
25
+ };
6
26
 
7
27
  /** Owns native context validity and occupancy, never journal or socket state. */
8
28
  export class IntercomLifecycle {
@@ -11,6 +31,10 @@ export class IntercomLifecycle {
11
31
  private model = "unknown";
12
32
  private disposed = true;
13
33
  private epoch = 0;
34
+ private incarnation: string | null = null;
35
+ private peerId: string | null = null;
36
+ private guardedDelivery = false;
37
+ private applicationGuard: string | undefined;
14
38
  private running = false;
15
39
  private activity = 0;
16
40
  private readonly tools = new Map<string, string>();
@@ -31,15 +55,97 @@ export class IntercomLifecycle {
31
55
  get toolCount(): number {
32
56
  return this.tools.size;
33
57
  }
58
+ registerContextEvents(sync: () => void): void {
59
+ this.pi.events.on("pi-intercom:context-query", (request) => this.contextRequest(request));
60
+ this.pi.events.on("pi-intercom:application-guard", (request) =>
61
+ this.contextRequest(request, sync),
62
+ );
63
+ }
64
+ private contextRequest(request: unknown, sync?: () => void): void {
65
+ if (!isContextRequest(request)) {
66
+ return;
67
+ }
68
+ if (request.version !== 1) {
69
+ request.respond({ error: "unsupported_version" });
70
+ return;
71
+ }
72
+ if (sync) {
73
+ if (
74
+ !isContextTarget(request.target) ||
75
+ !this.matches(request.target) ||
76
+ !(
77
+ request.revision === null ||
78
+ (typeof request.revision === "string" && safeLabel(request.revision))
79
+ )
80
+ ) {
81
+ request.respond({ error: "context_changed" });
82
+ return;
83
+ }
84
+ this.applicationGuard = request.revision === null ? undefined : request.revision;
85
+ sync();
86
+ this.publishContext();
87
+ }
88
+ request.respond(this.snapshot());
89
+ }
90
+ snapshot(): ContextSnapshot {
91
+ if (!this.live()) {
92
+ return {
93
+ version: 1,
94
+ rootSessionId: null,
95
+ peerId: null,
96
+ incarnation: null,
97
+ connected: false,
98
+ guardedDelivery: null,
99
+ } as const;
100
+ }
101
+ return {
102
+ version: 1,
103
+ rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
104
+ parentSessionId: this.lineage?.parentSessionId,
105
+ peerId: this.peerId,
106
+ incarnation: this.incarnation,
107
+ connected: this.peerId !== null,
108
+ guardedDelivery: this.guardedDelivery ? 1 : null,
109
+ applicationGuard: this.applicationGuard,
110
+ } as const;
111
+ }
112
+ private publishContext(): void {
113
+ this.pi.events.emit("pi-intercom:context-state", this.snapshot());
114
+ }
115
+ transport(peerId: string | null, guardedDelivery = false): void {
116
+ this.peerId = peerId;
117
+ this.guardedDelivery = guardedDelivery;
118
+ this.publishContext();
119
+ }
120
+ private matches(target: ContextTarget): boolean {
121
+ const current = this.snapshot();
122
+ return (
123
+ current.connected &&
124
+ current.rootSessionId === target.rootSessionId &&
125
+ current.peerId === target.peerId &&
126
+ current.incarnation === target.incarnation
127
+ );
128
+ }
129
+ accepts(guard: DeliveryGuard): boolean {
130
+ return (
131
+ this.guardedDelivery &&
132
+ this.lineage?.parentSessionId === undefined &&
133
+ this.matches(guard) &&
134
+ this.applicationGuard === guard.applicationGuard
135
+ );
136
+ }
34
137
  start(ctx: ExtensionContext): number {
35
138
  this.disposed = false;
36
139
  this.epoch += 1;
37
140
  this.context = ctx;
38
141
  this.sessionId = ctx.sessionManager.getSessionId();
142
+ this.incarnation = randomUUID();
143
+ this.applicationGuard = undefined;
39
144
  this.model = ctx.model?.id ?? "unknown";
40
145
  this.running = false;
41
146
  this.activity = 0;
42
147
  this.tools.clear();
148
+ this.transport(null);
43
149
  return this.epoch;
44
150
  }
45
151
  invalidate(): void {
@@ -47,6 +153,7 @@ export class IntercomLifecycle {
47
153
  this.epoch += 1;
48
154
  this.running = false;
49
155
  this.tools.clear();
156
+ this.publishContext();
50
157
  }
51
158
  clear(): void {
52
159
  this.context = null;
@@ -104,11 +211,7 @@ export class IntercomLifecycle {
104
211
  markActivity(): void {
105
212
  this.activity = Date.now();
106
213
  }
107
- identity(sessionId: string): {
108
- readonly name: string;
109
- readonly rootSessionId: string;
110
- readonly parentSessionId?: string;
111
- } {
214
+ identity(sessionId: string): Pick<SessionInfo, "name" | "rootSessionId" | "parentSessionId"> {
112
215
  return {
113
216
  ...buildPresenceIdentity(this.pi, sessionId),
114
217
  rootSessionId: this.lineage?.rootSessionId ?? sessionId,
@@ -125,11 +228,9 @@ export class IntercomLifecycle {
125
228
  ? `${status} · ${this.config.status}`
126
229
  : status;
127
230
  }
128
- health(pendingAsks: number): {
129
- readonly pendingAsks: number;
130
- readonly acceptsAsks: boolean;
131
- readonly lastIntercomActivity: number;
132
- } {
231
+ health(
232
+ pendingAsks: number,
233
+ ): Pick<SessionInfo, "pendingAsks" | "acceptsAsks" | "lastIntercomActivity"> {
133
234
  const live = this.live();
134
235
  return {
135
236
  pendingAsks,
@@ -142,12 +243,14 @@ export class IntercomLifecycle {
142
243
  if (!live || this.sessionId === null) {
143
244
  throw new Error("Intercom runtime not initialized");
144
245
  }
145
- const cwd = live.cwd;
146
246
  return {
147
247
  ...this.identity(this.sessionId),
148
- cwd,
248
+ incarnation: this.incarnation ?? undefined,
249
+ guardedDelivery: 1,
250
+ applicationGuard: this.applicationGuard,
251
+ cwd: live.cwd,
149
252
  model: this.model,
150
- projectId: await resolveSessionProjectId(cwd),
253
+ projectId: await resolveSessionProjectId(live.cwd),
151
254
  lastSeen: Date.now(),
152
255
  status: this.status(),
153
256
  ...this.health(pendingAsks),
@@ -22,7 +22,7 @@ import { formatAttachments } from "./message-format.ts";
22
22
  import {
23
23
  AskDeliveryError,
24
24
  failureDetails,
25
- replyFailureReason,
25
+ RecoveryError,
26
26
  type RecoveryAction,
27
27
  } from "./tool-results.ts";
28
28
  import { sendOptions, toolError, toolText } from "./tool-arguments.ts";
@@ -155,11 +155,10 @@ export class IntercomOutbound {
155
155
  }
156
156
  return this.sent(to, params, result);
157
157
  } catch (error) {
158
- const text = errorMessage(error);
159
158
  return toolError(
160
- `Failed to send: ${text}`,
159
+ `Failed to send: ${errorMessage(error)}`,
161
160
  failureDetails(
162
- text.startsWith("Target ") ? "ambiguous_target" : "send_failed",
161
+ error instanceof RecoveryError ? error.code : "send_failed",
163
162
  [
164
163
  { action: "list" },
165
164
  { action: "send", guidance: "Retry with an exact active recipient target." },
@@ -225,17 +224,10 @@ export class IntercomOutbound {
225
224
  const result = this.deliveryFailure(to, error.result);
226
225
  return { ...result, details: { ...result.details, error: true } };
227
226
  }
228
- const text = errorMessage(error);
229
- let reason = "ask_failed";
230
- if (text.startsWith("No reply from")) {
231
- reason = "reply_timeout";
232
- } else if (text.startsWith("Target ")) {
233
- reason = "ambiguous_target";
234
- }
235
227
  return toolError(
236
- `Failed: ${text}`,
228
+ `Failed: ${errorMessage(error)}`,
237
229
  failureDetails(
238
- reason,
230
+ error instanceof RecoveryError ? error.code : "ask_failed",
239
231
  [
240
232
  { action: "status" },
241
233
  { action: "list" },
@@ -409,8 +401,7 @@ export class IntercomOutbound {
409
401
  }
410
402
  }
411
403
  private replyFailure(error: unknown, replyTo: string | undefined): ToolResultLike {
412
- const text = errorMessage(error);
413
- const reason = replyFailureReason(text);
404
+ const reason = error instanceof RecoveryError ? error.code : "reply_failed";
414
405
  const actions: readonly RecoveryAction[] =
415
406
  reason === "no_pending_reply"
416
407
  ? [
@@ -423,7 +414,7 @@ export class IntercomOutbound {
423
414
  ]
424
415
  : [{ action: "pending" }, { action: "list" }];
425
416
  return toolError(
426
- `Failed to reply: ${text}`,
417
+ `Failed to reply: ${errorMessage(error)}`,
427
418
  failureDetails(reason, actions, {
428
419
  error: true,
429
420
  ...((replyTo ?? "") !== "" ? { replyTo } : {}),
@@ -1,5 +1,6 @@
1
1
  import type { Message, SessionInfo } from "./types.ts";
2
2
  import { formatSessionTarget, resolveSessionTarget, shortSessionId } from "./session-targets.ts";
3
+ import { RecoveryError } from "./tool-results.ts";
3
4
 
4
5
  export interface IntercomContext {
5
6
  readonly from: SessionInfo;
@@ -55,7 +56,7 @@ function matchingSender(
55
56
  ): readonly IntercomContext[] {
56
57
  const tooShort = tooShortSenderTargetMessage(contexts, to);
57
58
  if (tooShort !== null && tooShort !== "") {
58
- throw new Error(tooShort);
59
+ throw new RecoveryError("ambiguous_reply_target", tooShort);
59
60
  }
60
61
  return resolveBySenderTarget(contexts, to);
61
62
  }
@@ -66,7 +67,7 @@ function explicitReply(
66
67
  ): IntercomContext {
67
68
  const target = contexts.find((context) => context.message.id === replyTo);
68
69
  if (!target) {
69
- throw new Error(`No pending ask with replyTo "${replyTo}"`);
70
+ throw new RecoveryError("no_pending_reply", `No pending ask with replyTo "${replyTo}"`);
70
71
  }
71
72
  if (
72
73
  to !== "" &&
@@ -82,11 +83,12 @@ function replyBySender(contexts: readonly IntercomContext[], to: string): Interc
82
83
  return matches[0];
83
84
  }
84
85
  if (matches.length > 1) {
85
- throw new Error(
86
+ throw new RecoveryError(
87
+ "ambiguous_reply_target",
86
88
  `Multiple pending asks from "${to}" — use one of: ${pendingSenderOptions(matches, contexts)}.`,
87
89
  );
88
90
  }
89
- throw new Error(`No pending ask from "${to}"`);
91
+ throw new RecoveryError("no_pending_reply", `No pending ask from "${to}"`);
90
92
  }
91
93
  export class ReplyTracker {
92
94
  private readonly pendingAsks = new Map<string, IntercomContext>();
@@ -209,9 +211,9 @@ export class ReplyTracker {
209
211
  return pending[0];
210
212
  }
211
213
  if (pending.length === 0) {
212
- throw new Error("No active intercom context to reply to");
214
+ throw new RecoveryError("no_pending_reply", "No active intercom context to reply to");
213
215
  }
214
- throw new Error("Multiple pending asks — specify `to`");
216
+ throw new RecoveryError("ambiguous_reply_target", "Multiple pending asks — specify `to`");
215
217
  }
216
218
 
217
219
  markReplied(replyTo: string): void {
@@ -3,7 +3,7 @@ import type { IntercomClient, SendResult } from "./broker/client.ts";
3
3
  import type { IntercomTransport } from "./transport.ts";
4
4
  import type { IntercomLifecycle } from "./lifecycle.ts";
5
5
  import { asError } from "./validation.ts";
6
- import { AskDeliveryError } from "./tool-results.ts";
6
+ import { AskDeliveryError, RecoveryError } from "./tool-results.ts";
7
7
 
8
8
  type Lifecycle = Readonly<Pick<IntercomLifecycle, "markActivity">>;
9
9
  interface ReplyWaiter {
@@ -71,7 +71,8 @@ export class ReplyWait {
71
71
  const { from, replyTo, signal } = options;
72
72
  const timeout = setTimeout(() => {
73
73
  this.reject(
74
- new Error(
74
+ new RecoveryError(
75
+ "reply_timeout",
75
76
  `No reply from "${from}" within ${Math.max(1, Math.round(this.timeoutMs / 60000))} minute(s)`,
76
77
  ),
77
78
  );
@@ -17,7 +17,6 @@ export type InboundCheckpoint =
17
17
  readonly messageId: string;
18
18
  readonly stage: PendingInboundMessage["stage"] | "discarded" | "reply-retired";
19
19
  };
20
- export type RequestedDelivery = MessageDelivery | "auto";
21
20
  export type InboundDelivery = "trigger" | "followUp" | "steer" | "passive";
22
21
  export type IntercomSessionScope = "project" | "all";
23
22
  export interface ToolResultLike {
@@ -2,17 +2,20 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { IntercomTools } from "./tools.ts";
3
3
  import { throwIfToolError } from "./tool-results.ts";
4
4
  import { intercomSchema } from "./tool-schemas.ts";
5
+ import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.ts";
5
6
 
6
7
  export function registerIntercomTools(
7
8
  pi: ExtensionAPI,
8
9
  tools: Readonly<Pick<IntercomTools, "execute">>,
9
10
  ): void {
11
+ registerCompactMessages(pi, ["intercom_message", "subagent-human-message"]);
10
12
  pi.registerTool({
11
13
  name: "intercom",
12
14
  label: "Intercom",
13
15
  description:
14
- "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.",
16
+ "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.",
15
17
  parameters: intercomSchema,
18
+ renderResult: renderCompactResult,
16
19
  async execute(_id, params, signal, _update, ctx) {
17
20
  const result = throwIfToolError(await tools.execute(params, signal, ctx));
18
21
  return { content: [...result.content], details: result.details };
@@ -1,5 +1,10 @@
1
1
  import type { SendResult } from "./types.ts";
2
2
  import type { ToolResultLike } from "./runtime-types.ts";
3
+ type RecoveryCode =
4
+ | "ambiguous_target"
5
+ | "reply_timeout"
6
+ | "no_pending_reply"
7
+ | "ambiguous_reply_target";
3
8
  export interface RecoveryAction {
4
9
  readonly action: "list" | "pending" | "send" | "status";
5
10
  readonly guidance?: string;
@@ -11,16 +16,12 @@ export function failureDetails(
11
16
  ): Readonly<Record<string, unknown>> {
12
17
  return { ...details, reasonCode, nextActions };
13
18
  }
14
- export function replyFailureReason(
15
- message: string,
16
- ): "no_pending_reply" | "ambiguous_reply_target" | "reply_failed" {
17
- if (message.startsWith("No active intercom context") || message.startsWith("No pending ask")) {
18
- return "no_pending_reply";
19
- }
20
- if (message.startsWith("Multiple pending asks") || message.includes("too short")) {
21
- return "ambiguous_reply_target";
19
+ export class RecoveryError extends Error {
20
+ readonly code: RecoveryCode;
21
+ constructor(code: RecoveryCode, message: string) {
22
+ super(message);
23
+ this.code = code;
22
24
  }
23
- return "reply_failed";
24
25
  }
25
26
  export function firstTextContent(result: {
26
27
  readonly content?: readonly { readonly type: string; readonly text?: string }[];