@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
@@ -4,21 +4,23 @@ 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";
7
+ import type { Message, SessionInfo, ReceiptQuery, ReceiptResult } from "./types.ts";
8
+ import type { NativeReadQuery } from "./broker/last.ts";
8
9
  import { readLastMessage } 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;
@@ -113,6 +115,7 @@ export class IntercomConnection {
113
115
  this.client.updatePresence({
114
116
  status: this.lifecycle.status(),
115
117
  ...this.lifecycle.health(this.hooks.pendingAsks()),
118
+ applicationGuard: this.lifecycle.snapshot().applicationGuard ?? null,
116
119
  });
117
120
  }
118
121
  }
@@ -124,7 +127,7 @@ export class IntercomConnection {
124
127
  this.client = next;
125
128
  next.on(
126
129
  "last_request",
127
- (request: { readonly requestId: string; readonly query: LastMessageQuery }) => {
130
+ (request: { readonly requestId: string; readonly query: NativeReadQuery }) => {
128
131
  const ctx = this.lifecycle.live();
129
132
  const id = next.sessionId;
130
133
  if (this.client !== next || !ctx || id === null) {
@@ -132,6 +135,10 @@ export class IntercomConnection {
132
135
  return;
133
136
  }
134
137
  try {
138
+ if ("messageId" in request.query) {
139
+ next.respondLast(request.requestId, this.hooks.receipt(request.query));
140
+ return;
141
+ }
135
142
  next.respondLast(
136
143
  request.requestId,
137
144
  readLastMessage(
@@ -172,6 +179,7 @@ export class IntercomConnection {
172
179
  }
173
180
  this.hooks.disconnected(error);
174
181
  this.client = null;
182
+ this.lifecycle.transport(null);
175
183
  this.clearReconnect();
176
184
  this.scheduleReconnect();
177
185
  });
@@ -197,6 +205,7 @@ export class IntercomConnection {
197
205
  throw new Error("Intercom runtime no longer active");
198
206
  }
199
207
  this.client = next;
208
+ this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
200
209
  this.syncIdentity(ctx.sessionManager.getSessionId());
201
210
  this.attempt = 0;
202
211
  return next;
@@ -279,12 +288,14 @@ export async function resolveConnectedTarget(
279
288
  return resolution.target.id;
280
289
  }
281
290
  if (resolution.status === "ambiguous") {
282
- throw new Error(
291
+ throw new RecoveryError(
292
+ "ambiguous_target",
283
293
  `Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`,
284
294
  );
285
295
  }
286
296
  if (resolution.status === "prefix_too_short") {
287
- throw new Error(
297
+ throw new RecoveryError(
298
+ "ambiguous_target",
288
299
  `Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`,
289
300
  );
290
301
  }
@@ -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
  }
@@ -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,30 +94,17 @@ 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
  }
147
109
  async receive(ctx: ExtensionContext, from: SessionInfo, input: Message): Promise<void> {
148
110
  const generation = this.lifecycle.generation;
@@ -154,10 +116,18 @@ export class IntercomInbound {
154
116
  if (!live) {
155
117
  return;
156
118
  }
119
+ if (input.guard && !this.lifecycle.accepts(input.guard)) {
120
+ return;
121
+ }
157
122
  // Peer metadata never grants user authority.
158
123
  const message = input.human ? { ...input, human: undefined } : input;
124
+ const entry = this.entry(from, message);
125
+ if (!this.owners.journal.owns(entry)) {
126
+ return;
127
+ }
159
128
  if (
160
129
  this.owners.wait.receive(from, message, () => {
130
+ this.owners.journal.consume(message.id);
161
131
  this.lifecycle.markActivity();
162
132
  this.owners.connection.syncStatus();
163
133
  })
@@ -167,7 +137,6 @@ export class IntercomInbound {
167
137
  this.owners.replies.recordIncomingMessage(from, message);
168
138
  this.lifecycle.markActivity();
169
139
  this.owners.connection.syncStatus();
170
- const entry = this.entry(from, message);
171
140
  await this.admit(entry, live, generation);
172
141
  }
173
142
  }
@@ -23,6 +23,7 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
23
23
  const connection = new IntercomConnection(lifecycle, config, {
24
24
  restoring: () => delivery.restoring,
25
25
  pendingAsks: () => replies.listPending().length,
26
+ receipt: (query) => journal.receipt(query),
26
27
  incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
27
28
  peerLeft: (id) => {
28
29
  wait.peerDisconnected(id);
@@ -38,8 +39,10 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
38
39
  replies,
39
40
  syncStatus: () => connection.syncStatus(),
40
41
  });
42
+ lifecycle.registerContextEvents(() => connection.syncStatus());
41
43
  const inbound = new IntercomInbound(lifecycle, config, {
42
44
  delivery,
45
+ journal,
43
46
  replies,
44
47
  wait,
45
48
  connection,