@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
@@ -4,28 +4,40 @@ import type { IntercomTransport } from "./transport.ts";
4
4
  import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
5
5
  import type { IntercomConfig } from "./config.ts";
6
6
  import type { IntercomLifecycle } from "./lifecycle.ts";
7
- import type { LastMessageQuery, Message, SessionInfo } from "./types.ts";
8
- import { readLastMessage } from "./last-message.ts";
7
+ import type { Message, SessionInfo, ReceiptQuery, ReceiptResult } from "./types.ts";
8
+ import type { NativeReadQuery } from "./broker/last.ts";
9
+ import { readLastMessage, type NativeHistory } from "./last-message.ts";
9
10
  import { asError } from "./validation.ts";
11
+ import { RecoveryError } from "./tool-results.ts";
10
12
  import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
11
13
 
12
- type Lifecycle = Readonly<
13
- Pick<
14
- IntercomLifecycle,
15
- "currentSessionId" | "generation" | "health" | "identity" | "live" | "registration" | "status"
16
- >
17
- >;
14
+ type Lifecycle = Pick<
15
+ Readonly<IntercomLifecycle>,
16
+ "currentSessionId" | "generation" | "health" | "identity" | "live" | "registration" | "status"
17
+ > &
18
+ Pick<Readonly<IntercomLifecycle>, "snapshot" | "transport">;
18
19
 
19
20
  interface ConnectionHooks {
20
21
  readonly restoring: () => Promise<void> | undefined;
21
22
  readonly pendingAsks: () => number;
23
+ readonly receipt: (query: ReceiptQuery) => ReceiptResult;
22
24
  readonly incoming: (ctx: ExtensionContext, from: SessionInfo, message: Message) => Promise<void>;
23
25
  readonly peerLeft: (id: string) => void;
24
26
  readonly disconnected: (error: Readonly<Error>) => void;
27
+ /** A main answers for and wakes its exited parked children. */
28
+ readonly parkedHistory: (peer: SessionInfo) => Promise<NativeHistory | undefined>;
29
+ readonly wake: (peer: string) => void;
30
+ }
31
+ interface LastRequest {
32
+ readonly requestId: string;
33
+ readonly query: NativeReadQuery;
34
+ readonly parked?: SessionInfo;
25
35
  }
26
36
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
27
37
  export class IntercomConnection {
28
38
  private client: IntercomClient | null = null;
39
+ /** The transport whose broker retains this actor's input while it parks. */
40
+ private parked: IntercomClient | null = null;
29
41
  private reconnectTimer: NodeJS.Timeout | null = null;
30
42
  private startupTimer: NodeJS.Timeout | null = null;
31
43
  private connecting: Promise<IntercomTransport> | null = null;
@@ -42,6 +54,22 @@ export class IntercomConnection {
42
54
  get active(): IntercomTransport | null {
43
55
  return this.client;
44
56
  }
57
+ async park(): Promise<boolean> {
58
+ const client = this.client;
59
+ this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
60
+ return this.parked !== null && this.parked === this.client;
61
+ }
62
+ get parking(): boolean {
63
+ return this.parked !== null;
64
+ }
65
+ get reconnected(): boolean {
66
+ return this.client !== null && this.client !== this.parked;
67
+ }
68
+ unpark(): Promise<boolean> {
69
+ const client = this.parked === this.client ? this.client : null;
70
+ this.parked = null;
71
+ return client?.park(false).catch(() => false) ?? Promise.resolve(false);
72
+ }
45
73
  start(ctx: ExtensionContext, generation: number): void {
46
74
  this.attempt = 0;
47
75
  this.clearTimers();
@@ -113,44 +141,71 @@ export class IntercomConnection {
113
141
  this.client.updatePresence({
114
142
  status: this.lifecycle.status(),
115
143
  ...this.lifecycle.health(this.hooks.pendingAsks()),
144
+ applicationGuard: this.lifecycle.snapshot().applicationGuard ?? null,
116
145
  });
117
146
  }
118
147
  }
148
+ private answerLast(next: IntercomClient, request: LastRequest): void {
149
+ const ctx = this.lifecycle.live();
150
+ const id = next.sessionId;
151
+ if (this.client !== next || !ctx || id === null) {
152
+ next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
153
+ return;
154
+ }
155
+ const { parked, query } = request;
156
+ if ("messageId" in query) {
157
+ // The broker refuses receipt reads of an exited parked child before relaying them.
158
+ next.respondLast(
159
+ request.requestId,
160
+ parked ? { error: "publication_unavailable" } : this.hooks.receipt(query),
161
+ );
162
+ return;
163
+ }
164
+ if (parked) {
165
+ this.hooks
166
+ .parkedHistory(parked)
167
+ .then((history) =>
168
+ history
169
+ ? readLastMessage(history, parked, query)
170
+ : { error: "Parked session history is unavailable." },
171
+ )
172
+ .catch((error: unknown) => ({ error: asError(error).message }))
173
+ .then((result) => {
174
+ next.respondLast(request.requestId, result);
175
+ })
176
+ .catch(() => {
177
+ /* The requester's broker timeout reports a lost transport. */
178
+ });
179
+ return;
180
+ }
181
+ try {
182
+ next.respondLast(
183
+ request.requestId,
184
+ readLastMessage(
185
+ ctx.sessionManager,
186
+ {
187
+ id,
188
+ ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
189
+ cwd: ctx.cwd,
190
+ model: ctx.model?.id ?? "unknown",
191
+ status: this.lifecycle.status(),
192
+ },
193
+ query,
194
+ ),
195
+ );
196
+ } catch (error) {
197
+ next.respondLast(request.requestId, { error: asError(error).message });
198
+ }
199
+ }
119
200
  private createClient(): IntercomClient {
120
201
  const next = new IntercomClient({
121
202
  sendTimeoutMs: this.config.sendTimeoutMs,
122
203
  listTimeoutMs: this.config.listTimeoutMs,
123
204
  });
124
205
  this.client = next;
125
- next.on(
126
- "last_request",
127
- (request: { readonly requestId: string; readonly query: LastMessageQuery }) => {
128
- const ctx = this.lifecycle.live();
129
- const id = next.sessionId;
130
- if (this.client !== next || !ctx || id === null) {
131
- next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
132
- return;
133
- }
134
- try {
135
- next.respondLast(
136
- request.requestId,
137
- readLastMessage(
138
- ctx.sessionManager,
139
- {
140
- id,
141
- ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
142
- cwd: ctx.cwd,
143
- model: ctx.model?.id ?? "unknown",
144
- status: this.lifecycle.status(),
145
- },
146
- request.query,
147
- ),
148
- );
149
- } catch (error) {
150
- next.respondLast(request.requestId, { error: asError(error).message });
151
- }
152
- },
153
- );
206
+ next.on("last_request", (request: LastRequest) => {
207
+ this.answerLast(next, request);
208
+ });
154
209
  next.on("message", (from: SessionInfo, message: Message) => {
155
210
  const ctx = this.lifecycle.live();
156
211
  if (this.client !== next || !ctx) {
@@ -160,6 +215,11 @@ export class IntercomConnection {
160
215
  console.error("Intercom inbound delivery failed:", error);
161
216
  });
162
217
  });
218
+ next.on("wake", (peer: string) => {
219
+ if (this.client === next) {
220
+ this.hooks.wake(peer);
221
+ }
222
+ });
163
223
  next.on("session_left", (id: string) => {
164
224
  if (this.client !== next) {
165
225
  return;
@@ -172,6 +232,7 @@ export class IntercomConnection {
172
232
  }
173
233
  this.hooks.disconnected(error);
174
234
  this.client = null;
235
+ this.lifecycle.transport(null);
175
236
  this.clearReconnect();
176
237
  this.scheduleReconnect();
177
238
  });
@@ -197,6 +258,7 @@ export class IntercomConnection {
197
258
  throw new Error("Intercom runtime no longer active");
198
259
  }
199
260
  this.client = next;
261
+ this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
200
262
  this.syncIdentity(ctx.sessionManager.getSessionId());
201
263
  this.attempt = 0;
202
264
  return next;
@@ -279,12 +341,14 @@ export async function resolveConnectedTarget(
279
341
  return resolution.target.id;
280
342
  }
281
343
  if (resolution.status === "ambiguous") {
282
- throw new Error(
344
+ throw new RecoveryError(
345
+ "ambiguous_target",
283
346
  `Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`,
284
347
  );
285
348
  }
286
349
  if (resolution.status === "prefix_too_short") {
287
- throw new Error(
350
+ throw new RecoveryError(
351
+ "ambiguous_target",
288
352
  `Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`,
289
353
  );
290
354
  }
@@ -4,20 +4,23 @@ export interface IntercomLineage {
4
4
  readonly rootSessionId: string;
5
5
  readonly parentSessionId: string;
6
6
  }
7
+ // Launches set both variables to the parent's exact native session ID. Absence means a main;
8
+ // anything else is refused, never normalized into a different identity.
7
9
  export function readIntercomLineage(): IntercomLineage | undefined {
8
- const rootSessionId = process.env.PI_INTERCOM_ROOT_SESSION_ID?.trim();
9
- const parentSessionId = process.env.PI_INTERCOM_PARENT_SESSION_ID?.trim();
10
+ const rootSessionId = process.env.PI_INTERCOM_ROOT_SESSION_ID;
11
+ const parentSessionId = process.env.PI_INTERCOM_PARENT_SESSION_ID;
10
12
  if (rootSessionId === undefined && parentSessionId === undefined) {
11
13
  return;
12
14
  }
13
15
  if (
14
16
  rootSessionId === undefined ||
15
- parentSessionId === undefined ||
16
17
  rootSessionId === "" ||
17
- parentSessionId === "" ||
18
+ rootSessionId !== rootSessionId.trim() ||
18
19
  rootSessionId !== parentSessionId
19
20
  ) {
20
- throw new Error("Intercom child launch requires matching root and parent native session IDs.");
21
+ throw new Error(
22
+ "Child launch requires identical, unpadded root and parent native session IDs.",
23
+ );
21
24
  }
22
25
  return { rootSessionId, parentSessionId };
23
26
  }
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import type { IntercomLifecycle } from "./lifecycle.ts";
4
3
  import type { InboundJournal } from "./inbound-journal.ts";
@@ -9,10 +8,9 @@ import type {
9
8
  PendingInboundMessage,
10
9
  } from "./runtime-types.ts";
11
10
  import { requestedDelivery } from "./inbound-record.ts";
12
- import { isRecord } from "./validation.ts";
13
11
  import { runCooperatively } from "../shared/cooperative.ts";
14
12
 
15
- type Lifecycle = Readonly<Pick<IntercomLifecycle, "generation" | "isIdle" | "live" | "toolCount">>;
13
+ type Lifecycle = Pick<Readonly<IntercomLifecycle>, "generation" | "isIdle" | "live" | "toolCount">;
16
14
  type Journal = Readonly<
17
15
  Pick<
18
16
  InboundJournal,
@@ -121,6 +119,9 @@ export class InboundDeliveryOwner {
121
119
  flushDelivery: PendingInboundMessage["flushDelivery"] = "auto",
122
120
  delayMs = 200,
123
121
  ): void {
122
+ if (!this.journal.owns(entry)) {
123
+ return;
124
+ }
124
125
  this.journal.remember(entry, "queued", flushDelivery);
125
126
  this.hooks.syncStatus();
126
127
  this.schedule(delayMs);
@@ -153,10 +154,7 @@ export class InboundDeliveryOwner {
153
154
  private triggerIndex(entries: readonly PendingInboundMessage[]): number {
154
155
  const canTrigger = (entry: PendingInboundMessage) =>
155
156
  entry.flushDelivery !== "passive" && requestedDelivery(entry.message) !== "passive";
156
- const ask = entries.findIndex(
157
- (entry) => canTrigger(entry) && entry.message.expectsReply === true,
158
- );
159
- return ask === -1 ? entries.findIndex(canTrigger) : ask;
157
+ return entries.findLastIndex(canTrigger);
160
158
  }
161
159
  private *sendTriggerLast(
162
160
  entries: readonly PendingInboundMessage[],
@@ -170,14 +168,19 @@ export class InboundDeliveryOwner {
170
168
  }
171
169
  const index = this.triggerIndex(rest);
172
170
  const trigger = index === -1 ? undefined : rest.splice(index, 1)[0];
173
- if (trigger && this.journal.get(trigger.message.id) === trigger) {
174
- this.hooks.replies.queueTurnContext({ ...trigger, receivedAt: Date.now() });
171
+ const replyTarget = entries.find((entry) => entry.message.expectsReply === true) ?? trigger;
172
+ if (replyTarget && this.journal.get(replyTarget.message.id) === replyTarget) {
173
+ this.hooks.replies.queueTurnContext({ ...replyTarget, receivedAt: Date.now() });
175
174
  }
176
175
  for (const entry of rest) {
177
176
  if (!this.live(generation)) {
178
177
  return;
179
178
  }
180
- this.send(entry, entry.flushDelivery === "steer" ? "steer" : "followUp", generation);
179
+ this.send(
180
+ entry,
181
+ requestedDelivery(entry.message) === "queue" ? "followUp" : "steer",
182
+ generation,
183
+ );
181
184
  yield;
182
185
  }
183
186
  if (trigger) {
@@ -202,7 +205,6 @@ export class InboundDeliveryOwner {
202
205
  private *sendSteers(
203
206
  entries: readonly PendingInboundMessage[],
204
207
  generation: number,
205
- onDetach: (task: Promise<boolean>) => void,
206
208
  ): Generator<void, void> {
207
209
  for (const entry of entries) {
208
210
  if (!this.live(generation)) {
@@ -212,9 +214,6 @@ export class InboundDeliveryOwner {
212
214
  continue;
213
215
  }
214
216
  this.send(entry, "steer", generation);
215
- if (requestedDelivery(entry.message) === "steer") {
216
- onDetach(this.detach(entry, entry.message.expectsReply !== true));
217
- }
218
217
  yield;
219
218
  }
220
219
  }
@@ -243,15 +242,7 @@ export class InboundDeliveryOwner {
243
242
  this.schedule(500);
244
243
  return;
245
244
  }
246
- return this.runBatch(async () => {
247
- const detachments: Promise<boolean>[] = [];
248
- await runCooperatively(
249
- this.sendSteers(steers, generation, (task) => {
250
- detachments.push(task);
251
- }),
252
- );
253
- await Promise.all(detachments);
254
- }, generation);
245
+ return this.runBatch(() => runCooperatively(this.sendSteers(steers, generation)), generation);
255
246
  }
256
247
  private messageOptions(delivery: InboundDelivery): {
257
248
  readonly triggerTurn?: boolean;
@@ -297,57 +288,20 @@ export class InboundDeliveryOwner {
297
288
  }
298
289
  }
299
290
  private nativeMessage(entry: InboundMessageEntry): Parameters<ExtensionAPI["sendMessage"]>[0] {
300
- const sender =
301
- entry.from.name === undefined || entry.from.name === ""
302
- ? entry.from.id.slice(0, 8)
303
- : entry.from.name;
291
+ const sender = entry.from.name ?? entry.from.id.slice(0, 8);
304
292
  const command = entry.replyCommand ?? "";
305
293
  const reply = command === "" ? "" : `\n\nTo reply, use the intercom tool: ${command}`;
294
+ const hint = entry.message.replyHint;
295
+ const publication = hint
296
+ ? `\n\nTo publish a correlated reply, use ${hint.tool} with commandId ${hint.commandId}. This is not an intercom ask.`
297
+ : "";
306
298
  return {
307
299
  customType: "intercom_message",
308
- content: `**📨 From ${sender}** (Native session cwd: ${entry.from.cwd})${reply}\n\n${entry.bodyText}`,
300
+ content: `**📨 From ${sender}** (Native session cwd: ${entry.from.cwd})${reply}${publication}\n\n${entry.bodyText}`,
309
301
  display: true,
310
302
  details: { ...entry, bodyText: entry.bodyText },
311
303
  };
312
304
  }
313
- async detach(entry: InboundMessageEntry, attention = false): Promise<boolean> {
314
- if (!attention && entry.message.expectsReply !== true) {
315
- return false;
316
- }
317
- const requestId = randomUUID();
318
- return await new Promise((resolve) => {
319
- let settled = false;
320
-
321
- const finish = (accepted: boolean) => {
322
- if (settled) {
323
- return;
324
- }
325
- settled = true;
326
- clearTimeout(timer);
327
- unsubscribe();
328
- resolve(accepted);
329
- };
330
- const timer = setTimeout(() => finish(false), 500);
331
- timer.unref();
332
- const unsubscribe = this.pi.events.on("pi-intercom:detach-response", (payload: unknown) => {
333
- if (isRecord(payload) && payload.requestId === requestId) {
334
- finish(payload.accepted === true);
335
- }
336
- });
337
- try {
338
- this.pi.events.emit("pi-intercom:detach-request", {
339
- requestId,
340
- ...(attention ? { reason: "attention" } : {}),
341
- });
342
- // Owned waits answer ordinary attention synchronously; an unanswered probe completes now.
343
- if (attention) {
344
- finish(false);
345
- }
346
- } catch {
347
- finish(false);
348
- }
349
- });
350
- }
351
305
  async redeliver(ctx: ExtensionContext, generation: number): Promise<void> {
352
306
  if (this.batchTask) {
353
307
  await this.batchTask;
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
- import type { IntercomLifecycle } from "./lifecycle.ts";
2
+ import { createHash } from "node:crypto";
3
+ import { isContextRequest, type IntercomLifecycle } from "./lifecycle.ts";
3
4
  import type { ReplyTracker } from "./reply-tracker.ts";
4
5
  import {
5
6
  INBOUND_CHECKPOINT_TYPE,
@@ -7,7 +8,13 @@ import {
7
8
  type InboundMessageEntry,
8
9
  type PendingInboundMessage,
9
10
  } from "./runtime-types.ts";
10
- import { isInboundCheckpoint } from "./inbound-record.ts";
11
+ import { isInboundCheckpoint, publicationMetadata, requestedDelivery } from "./inbound-record.ts";
12
+ import {
13
+ isReceiptQuery,
14
+ type NativeReceipt,
15
+ type ReceiptQuery,
16
+ type ReceiptResult,
17
+ } from "./types.ts";
11
18
  import { inboundIdFromCustomMessage } from "./message-format.ts";
12
19
  import { isRecord } from "./validation.ts";
13
20
 
@@ -29,6 +36,85 @@ export class InboundJournal {
29
36
  this.pi = pi;
30
37
  this.lifecycle = lifecycle;
31
38
  this.replies = replies;
39
+ pi.events.on("pi-intercom:provenance-query", (request) => {
40
+ if (!isContextRequest(request)) {
41
+ return;
42
+ }
43
+ request.respond(
44
+ request.version === 1 && isReceiptQuery(request)
45
+ ? this.receipt(request)
46
+ : { error: "publication_unavailable" },
47
+ );
48
+ });
49
+ }
50
+ receipt(query: ReceiptQuery): ReceiptResult {
51
+ const ctx = this.lifecycle.live();
52
+ if (!ctx || ctx.sessionManager.getSessionId() !== query.rootSessionId) {
53
+ return { error: "publication_unavailable" };
54
+ }
55
+ let receipt: NativeReceipt | undefined;
56
+ for (const item of ctx.sessionManager.getEntries()) {
57
+ receipt = this.receiptEntry(receipt, query, item);
58
+ }
59
+ return receipt ? { receipt } : { error: "receipt_not_found" };
60
+ }
61
+ private receiptEntry(
62
+ receipt: NativeReceipt | undefined,
63
+ query: ReceiptQuery,
64
+ item: SessionEntry,
65
+ ): NativeReceipt | undefined {
66
+ if (
67
+ item.type === "custom" &&
68
+ item.customType === INBOUND_CHECKPOINT_TYPE &&
69
+ isInboundCheckpoint(item.data) &&
70
+ item.data.sessionId === query.rootSessionId
71
+ ) {
72
+ return this.receiptCheckpoint(receipt, query, item.id, item.data);
73
+ }
74
+ return receipt && inboundIdFromCustomMessage(item) === query.messageId
75
+ ? { ...receipt, placementEntryId: item.id }
76
+ : receipt;
77
+ }
78
+ private receiptCheckpoint(
79
+ receipt: NativeReceipt | undefined,
80
+ query: ReceiptQuery,
81
+ entryId: string,
82
+ data: InboundCheckpoint,
83
+ ): NativeReceipt | undefined {
84
+ if ("entry" in data) {
85
+ if (receipt || data.entry.message.id !== query.messageId) {
86
+ return receipt;
87
+ }
88
+ const { from, message, stage, receivedAt } = data.entry;
89
+ return {
90
+ version: 1,
91
+ rootSessionId: query.rootSessionId,
92
+ messageId: query.messageId,
93
+ sender: {
94
+ peerId: from.id,
95
+ rootSessionId: from.rootSessionId,
96
+ incarnation: from.incarnation,
97
+ },
98
+ textSha256: createHash("sha256").update(message.content.text, "utf8").digest("hex"),
99
+ delivery: requestedDelivery(message),
100
+ ...publicationMetadata(message),
101
+ checkpointEntryId: entryId,
102
+ receivedAt,
103
+ queued: stage === "queued",
104
+ nativeHandoff: stage === "native",
105
+ placementEntryId: null,
106
+ discarded: false,
107
+ };
108
+ }
109
+ if (!receipt || data.messageId !== query.messageId) {
110
+ return receipt;
111
+ }
112
+ return {
113
+ ...receipt,
114
+ queued: receipt.queued || data.stage === "queued",
115
+ nativeHandoff: receipt.nativeHandoff || data.stage === "native",
116
+ discarded: receipt.discarded || data.stage === "discarded",
117
+ };
32
118
  }
33
119
  peerLeft(id: string): void {
34
120
  for (const pending of this.replies.listPending()) {
@@ -39,7 +125,7 @@ export class InboundJournal {
39
125
  this.replies.expireSender(id);
40
126
  for (const pending of this.queued()) {
41
127
  if (pending.from.id === id && pending.message.expectsReply === true) {
42
- this.pending.delete(pending.message.id);
128
+ this.consume(pending.message.id);
43
129
  this.checkpoint({ messageId: pending.message.id, stage: "discarded" });
44
130
  }
45
131
  }
@@ -57,7 +143,10 @@ export class InboundJournal {
57
143
  return this.pending.get(id);
58
144
  }
59
145
  owns(entry: InboundMessageEntry): boolean {
60
- return !("stage" in entry) || this.pending.get(entry.message.id) === entry;
146
+ const id = entry.message.id;
147
+ return "stage" in entry
148
+ ? this.pending.get(id) === entry
149
+ : !this.pending.has(id) && !this.consumed.has(id);
61
150
  }
62
151
  checkpoint(update: InboundCheckpoint): void {
63
152
  const sessionId = this.lifecycle.currentSessionId;
@@ -96,7 +185,7 @@ export class InboundJournal {
96
185
  pending.from.id === entry.from.id &&
97
186
  pending.message.threadId === entry.message.threadId
98
187
  ) {
99
- this.pending.delete(pending.message.id);
188
+ this.consume(pending.message.id);
100
189
  this.replies.markReplied(pending.message.id);
101
190
  }
102
191
  }
@@ -164,7 +253,7 @@ export class InboundJournal {
164
253
  return;
165
254
  }
166
255
  if (data.stage === "discarded") {
167
- this.pending.delete(data.messageId);
256
+ this.consume(data.messageId);
168
257
  this.replies.markReplied(data.messageId);
169
258
  return;
170
259
  }
@@ -192,6 +281,13 @@ export class InboundJournal {
192
281
  if (item.customType === "intercom_sent") {
193
282
  this.restoreSent(item.data);
194
283
  }
284
+ if (
285
+ item.customType === "intercom_received" &&
286
+ isRecord(item.data) &&
287
+ typeof item.data.messageId === "string"
288
+ ) {
289
+ this.consume(item.data.messageId);
290
+ }
195
291
  }
196
292
  private restoreSent(data: unknown): void {
197
293
  if (
@@ -226,7 +322,7 @@ export class InboundJournal {
226
322
  (entry.type === "custom_message" &&
227
323
  ["intercom_message", "subagent-human-message"].includes(entry.customType)) ||
228
324
  (entry.type === "custom" &&
229
- [INBOUND_CHECKPOINT_TYPE, "intercom_sent"].includes(entry.customType))
325
+ [INBOUND_CHECKPOINT_TYPE, "intercom_sent", "intercom_received"].includes(entry.customType))
230
326
  );
231
327
  }
232
328
  private recentEntries(ctx: ExtensionContext, fullScan: boolean): readonly SessionEntry[] {
@@ -1,16 +1,31 @@
1
- import { isMessage, isSessionRegistration, type Message } from "./types.ts";
1
+ import { isMessage, isSessionRegistration, type Message, type MessageDelivery } from "./types.ts";
2
2
  import type {
3
3
  InboundCheckpoint,
4
4
  InboundMessageEntry,
5
5
  PendingInboundMessage,
6
- RequestedDelivery,
7
6
  } from "./runtime-types.ts";
8
7
  import { finiteNumber, isRecord } from "./validation.ts";
9
- export function requestedDelivery(message: Message): RequestedDelivery {
8
+
9
+ export function publicationMetadata(message: Message): Pick<Message, "guard" | "replyHint"> {
10
+ return {
11
+ guard: message.guard && {
12
+ rootSessionId: message.guard.rootSessionId,
13
+ peerId: message.guard.peerId,
14
+ incarnation: message.guard.incarnation,
15
+ applicationGuard: message.guard.applicationGuard,
16
+ },
17
+ replyHint: message.replyHint && {
18
+ tool: message.replyHint.tool,
19
+ commandId: message.replyHint.commandId,
20
+ commandFingerprint: message.replyHint.commandFingerprint,
21
+ },
22
+ };
23
+ }
24
+ export function requestedDelivery(message: Message): MessageDelivery {
10
25
  if (message.passive === true || message.delivery === "passive") {
11
26
  return "passive";
12
27
  }
13
- return message.delivery ?? (message.expectsReply === true ? "auto" : "steer");
28
+ return message.delivery ?? "steer";
14
29
  }
15
30
  function isLocalJournalMessage(value: unknown): value is Message {
16
31
  if (!isRecord(value) || !isRecord(value.content)) {
@@ -32,6 +47,8 @@ function isLocalJournalMessage(value: unknown): value is Message {
32
47
  "queueMode",
33
48
  "threadId",
34
49
  "passive",
50
+ "guard",
51
+ "replyHint",
35
52
  ].every((key) => value[key] === undefined)
36
53
  );
37
54
  }