@fitchmultz/pi-subagents 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
@@ -1,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
10
  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
- }
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 }[];
@@ -1,26 +1,14 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "../agent/typebox.ts";
3
3
  export const intercomSchema = Type.Object({
4
- action: StringEnum(["list", "send", "ask", "reply", "pending", "status", "last"] as const, {
5
- description:
6
- "List, send, ask, reply, pending asks, connection status, or last completed assistant response.",
7
- }),
4
+ action: StringEnum(["list", "send", "ask", "reply", "pending", "status", "last"] as const),
8
5
  scope: Type.Optional(
9
6
  StringEnum(["project", "all"] as const, {
10
- description:
11
- "For list/status: 'all' (default) includes every reachable session; 'project' further filters this repository and its worktrees.",
12
- }),
13
- ),
14
- to: Type.Optional(
15
- Type.String({
16
- description: "Target session name or ID (for 'send', 'ask', or disambiguating 'reply')",
17
- }),
18
- ),
19
- message: Type.Optional(
20
- Type.String({
21
- description: "Message for send/ask/reply",
7
+ description: "all (default) or project (repo and worktrees)",
22
8
  }),
23
9
  ),
10
+ to: Type.Optional(Type.String({ description: "Session name or ID" })),
11
+ message: Type.Optional(Type.String()),
24
12
  attachments: Type.Optional(
25
13
  Type.Array(
26
14
  Type.Object({
@@ -31,41 +19,21 @@ export const intercomSchema = Type.Object({
31
19
  }),
32
20
  ),
33
21
  ),
34
- replyTo: Type.Optional(
35
- Type.String({
36
- description: "Message ID to reply to (for threading or responding to an 'ask')",
37
- }),
38
- ),
22
+ replyTo: Type.Optional(Type.String({ description: "Message ID to answer or thread" })),
39
23
  delivery: Type.Optional(
40
24
  StringEnum(["queue", "steer", "passive"] as const, {
41
25
  description:
42
- "Delivery mode. Omitted send delivery defaults to 'steer', which injects after the current tool call. Use 'queue' only to intentionally wait behind active work; 'passive' does not wake the recipient model.",
26
+ "steer (default) at next tool boundary; queue after active work; passive never wakes the model",
43
27
  }),
44
28
  ),
45
29
  queueMode: Type.Optional(
46
30
  StringEnum(["stack", "replace"] as const, {
47
- description:
48
- "For delivery='queue': 'stack' keeps all messages; 'replace' keeps only the latest undelivered message for the same threadId.",
49
- }),
50
- ),
51
- threadId: Type.Optional(
52
- Type.String({ description: "Stable topic key for queueMode='replace'." }),
53
- ),
54
- entryId: Type.Optional(Type.String({ description: "Last response entry ID for stable paging." })),
55
- offset: Type.Optional(
56
- Type.Integer({ minimum: 0, description: "Last response character offset (UTF-16)." }),
57
- ),
58
- limit: Type.Optional(
59
- Type.Integer({
60
- minimum: 1,
61
- maximum: 16384,
62
- description: "Last response page size; default/max 16384 characters.",
63
- }),
64
- ),
65
- passive: Type.Optional(
66
- Type.Boolean({
67
- description:
68
- "For action='send' only: legacy alias for delivery='passive'. Discouraged for agent-to-agent messages.",
31
+ description: "stack keeps all; replace keeps only the latest per threadId",
69
32
  }),
70
33
  ),
34
+ threadId: Type.Optional(Type.String()),
35
+ entryId: Type.Optional(Type.String()),
36
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "UTF-16 characters" })),
37
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16384 })),
38
+ passive: Type.Optional(Type.Boolean({ description: "Legacy alias for delivery=passive" })),
71
39
  });