@bastani/atomic 0.8.25 → 0.8.26-alpha.1

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 (49) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +6 -0
  3. package/dist/builtin/intercom/index-heavy.ts +1754 -0
  4. package/dist/builtin/intercom/index.ts +374 -1746
  5. package/dist/builtin/intercom/package.json +1 -1
  6. package/dist/builtin/intercom/result-renderers.ts +77 -0
  7. package/dist/builtin/mcp/CHANGELOG.md +10 -0
  8. package/dist/builtin/mcp/index.ts +151 -57
  9. package/dist/builtin/mcp/package.json +1 -1
  10. package/dist/builtin/subagents/CHANGELOG.md +6 -0
  11. package/dist/builtin/subagents/package.json +1 -1
  12. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  13. package/dist/builtin/web-access/index-heavy.ts +2060 -0
  14. package/dist/builtin/web-access/index.ts +182 -2274
  15. package/dist/builtin/web-access/package.json +1 -1
  16. package/dist/builtin/web-access/result-renderers.ts +364 -0
  17. package/dist/builtin/workflows/CHANGELOG.md +9 -0
  18. package/dist/builtin/workflows/package.json +1 -1
  19. package/dist/builtin/workflows/src/extension/index.ts +13 -3
  20. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +53 -2
  21. package/dist/builtin/workflows/src/tui/inline-form-overlay.ts +12 -3
  22. package/dist/builtin/workflows/src/tui/inline-form-store.ts +17 -6
  23. package/dist/core/agent-session-services.d.ts.map +1 -1
  24. package/dist/core/agent-session-services.js +13 -0
  25. package/dist/core/agent-session-services.js.map +1 -1
  26. package/dist/core/extensions/loader.d.ts.map +1 -1
  27. package/dist/core/extensions/loader.js +7 -0
  28. package/dist/core/extensions/loader.js.map +1 -1
  29. package/dist/core/extensions/types.d.ts +13 -1
  30. package/dist/core/extensions/types.d.ts.map +1 -1
  31. package/dist/core/extensions/types.js.map +1 -1
  32. package/dist/core/resource-loader.d.ts.map +1 -1
  33. package/dist/core/resource-loader.js +17 -0
  34. package/dist/core/resource-loader.js.map +1 -1
  35. package/dist/core/timings.d.ts +9 -0
  36. package/dist/core/timings.d.ts.map +1 -1
  37. package/dist/core/timings.js +28 -1
  38. package/dist/core/timings.js.map +1 -1
  39. package/dist/main.d.ts.map +1 -1
  40. package/dist/main.js +4 -2
  41. package/dist/main.js.map +1 -1
  42. package/dist/modes/interactive/components/custom-message.d.ts +1 -0
  43. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  44. package/dist/modes/interactive/components/custom-message.js +36 -4
  45. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  46. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  47. package/dist/modes/interactive/interactive-mode.js +19 -7
  48. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,1313 +1,328 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@bastani/atomic";
2
- import { randomUUID } from "crypto";
3
- import { Type } from "typebox";
1
+ import type { ExtensionAPI, ExtensionContext, HandlerFn, MessageRenderer, RegisteredCommand, ToolDefinition } from "@bastani/atomic";
4
2
  import { Text } from "@mariozechner/pi-tui";
5
- import { APP_NAME, getEnvValue } from "@bastani/atomic";
6
- import { IntercomClient } from "./broker/client.ts";
7
- import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
8
- import { SessionListOverlay } from "./ui/session-list.ts";
9
- import { ComposeOverlay, type ComposeResult } from "./ui/compose.ts";
10
- import { InlineMessageComponent } from "./ui/inline-message.ts";
11
- import { loadConfig, type IntercomConfig } from "./config.ts";
12
- import type { SessionInfo, Message, Attachment } from "./types.ts";
13
- import { ReplyTracker } from "./reply-tracker.ts";
14
-
3
+ import { Type } from "typebox";
4
+ import { renderIntercomToolResult } from "./result-renderers.js";
5
+
6
+ type CapturedCommand = Omit<RegisteredCommand, "name" | "sourceInfo">;
7
+ type CapturedShortcut = Parameters<ExtensionAPI["registerShortcut"]>[1];
8
+ type EventHandler = Parameters<ExtensionAPI["events"]["on"]>[1];
9
+ type ToolRenderResultArgs = Parameters<NonNullable<ToolDefinition["renderResult"]>>;
10
+ type CapturedHeavy = {
11
+ tools: Map<string, ToolDefinition>;
12
+ commands: Map<string, CapturedCommand>;
13
+ handlers: Map<string, HandlerFn[]>;
14
+ shortcuts: Map<string, CapturedShortcut>;
15
+ eventHandlers: Map<string, EventHandler[]>;
16
+ };
17
+ type LifecycleSnapshot = {
18
+ event: unknown;
19
+ ctx: ExtensionContext;
20
+ };
21
+
22
+ type SessionSnapshot = LifecycleSnapshot & {
23
+ generation: number;
24
+ };
25
+
26
+ type ActiveLifecycleState = {
27
+ turnStart: LifecycleSnapshot | null;
28
+ agentStart: LifecycleSnapshot | null;
29
+ activeTools: Map<string, LifecycleSnapshot>;
30
+ modelSelect: LifecycleSnapshot | null;
31
+ };
32
+
33
+ type LazyLifecycleEvent =
34
+ | "session_start"
35
+ | "session_shutdown"
36
+ | "turn_start"
37
+ | "turn_end"
38
+ | "agent_start"
39
+ | "agent_end"
40
+ | "tool_execution_start"
41
+ | "tool_execution_end"
42
+ | "model_select";
43
+
44
+ const FORWARDED_EVENTS: readonly LazyLifecycleEvent[] = [
45
+ "session_start",
46
+ "session_shutdown",
47
+ "turn_start",
48
+ "turn_end",
49
+ "agent_start",
50
+ "agent_end",
51
+ "tool_execution_start",
52
+ "tool_execution_end",
53
+ "model_select",
54
+ ];
15
55
  const SUBAGENT_CONTROL_INTERCOM_EVENT = "subagent:control-intercom";
16
56
  const SUBAGENT_RESULT_INTERCOM_EVENT = "subagent:result-intercom";
17
- const SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT = "subagent:result-intercom-delivery";
18
- const INBOUND_FLUSH_DELAY_MS = 200;
19
- const INBOUND_IDLE_RETRY_MS = 500;
20
- const DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX = "subagent-chat";
21
- const ENV_PREFIX = APP_NAME.toUpperCase();
22
- const SUBAGENT_ORCHESTRATOR_TARGET_ENV = `${ENV_PREFIX}_SUBAGENT_ORCHESTRATOR_TARGET`;
23
- const SUBAGENT_RUN_ID_ENV = `${ENV_PREFIX}_SUBAGENT_RUN_ID`;
24
- const SUBAGENT_CHILD_AGENT_ENV = `${ENV_PREFIX}_SUBAGENT_CHILD_AGENT`;
25
- const SUBAGENT_CHILD_INDEX_ENV = `${ENV_PREFIX}_SUBAGENT_CHILD_INDEX`;
26
- const SUBAGENT_INTERCOM_SESSION_NAME_ENV = `${ENV_PREFIX}_SUBAGENT_INTERCOM_SESSION_NAME`;
27
57
 
28
- interface ChildOrchestratorMetadata {
29
- orchestratorTarget: string;
30
- runId: string;
31
- agent: string;
32
- index: string;
33
- sessionName?: string;
58
+ function hasSubagentIntercomEnv(): boolean {
59
+ return Object.keys(process.env).some((key) => key.endsWith("_SUBAGENT_ORCHESTRATOR_TARGET"));
34
60
  }
35
61
 
36
- interface InboundMessageEntry {
37
- from: SessionInfo;
38
- message: Message;
39
- replyCommand?: string;
40
- bodyText: string;
62
+ function getToolCallId(event: unknown): string | null {
63
+ if (typeof event !== "object" || event === null || !("toolCallId" in event)) return null;
64
+ const toolCallId = event.toolCallId;
65
+ return typeof toolCallId === "string" ? toolCallId : null;
41
66
  }
42
67
 
43
- type ContactSupervisorReason = "need_decision" | "progress_update" | "interview_request";
44
-
45
- interface SupervisorInterviewQuestion extends Record<string, unknown> {
46
- id: string;
47
- type: "single" | "multi" | "text" | "image" | "info";
48
- question: string;
49
- options?: unknown[];
68
+ function addHandler(captured: CapturedHeavy, event: string, handler: HandlerFn): void {
69
+ const handlers = captured.handlers.get(event) ?? [];
70
+ handlers.push(handler);
71
+ captured.handlers.set(event, handlers);
50
72
  }
51
73
 
52
- interface SupervisorInterviewRequest extends Record<string, unknown> {
53
- title?: string;
54
- description?: string;
55
- questions: SupervisorInterviewQuestion[];
74
+ function addEventHandler(captured: CapturedHeavy, event: string, handler: EventHandler): void {
75
+ const handlers = captured.eventHandlers.get(event) ?? [];
76
+ handlers.push(handler);
77
+ captured.eventHandlers.set(event, handlers);
56
78
  }
57
79
 
58
- interface SupervisorInterviewReply {
59
- responses: Array<{ id: string; value: unknown }>;
80
+ async function dispatchHandlers(captured: CapturedHeavy, eventName: string, event: unknown, ctx: ExtensionContext): Promise<void> {
81
+ for (const handler of captured.handlers.get(eventName) ?? []) {
82
+ await handler(event, ctx);
83
+ }
60
84
  }
61
85
 
62
- function getErrorMessage(error: unknown): string {
63
- return error instanceof Error ? error.message : String(error);
86
+ async function dispatchEventHandlers(captured: CapturedHeavy, eventName: string, payload: unknown): Promise<void> {
87
+ for (const handler of captured.eventHandlers.get(eventName) ?? []) {
88
+ await handler(payload);
89
+ }
64
90
  }
65
91
 
66
- function toError(error: unknown): Error {
67
- return error instanceof Error ? error : new Error(String(error));
92
+ function createHeavyProxy(pi: ExtensionAPI, captured: CapturedHeavy): ExtensionAPI {
93
+ return new Proxy(pi, {
94
+ get(target, prop, receiver) {
95
+ if (prop === "registerTool") {
96
+ return (tool: ToolDefinition) => captured.tools.set(tool.name, tool);
97
+ }
98
+ if (prop === "registerCommand") {
99
+ return (name: string, options: CapturedCommand) => captured.commands.set(name, options);
100
+ }
101
+ if (prop === "on") {
102
+ return (event: string, handler: HandlerFn) => {
103
+ addHandler(captured, event, handler);
104
+ };
105
+ }
106
+ if (prop === "registerShortcut") {
107
+ return (shortcut: string, options: CapturedShortcut) => {
108
+ captured.shortcuts.set(shortcut, options);
109
+ };
110
+ }
111
+ if (prop === "registerMessageRenderer") {
112
+ return (customType: string, renderer: MessageRenderer) => pi.registerMessageRenderer(customType, renderer);
113
+ }
114
+ if (prop === "events") {
115
+ return new Proxy(pi.events, {
116
+ get(eventTarget, eventProp, eventReceiver) {
117
+ if (eventProp === "on") {
118
+ return (event: string, handler: EventHandler) => {
119
+ addEventHandler(captured, event, handler);
120
+ return () => {
121
+ const handlers = captured.eventHandlers.get(event) ?? [];
122
+ captured.eventHandlers.set(event, handlers.filter((candidate) => candidate !== handler));
123
+ };
124
+ };
125
+ }
126
+ return Reflect.get(eventTarget, eventProp, eventReceiver);
127
+ },
128
+ });
129
+ }
130
+ return Reflect.get(target, prop, receiver);
131
+ },
132
+ }) as ExtensionAPI;
68
133
  }
69
134
 
70
- function formatAttachments(attachments: Attachment[]): string {
71
- let text = "";
72
- for (const att of attachments) {
73
- if (att.language) {
74
- text += `\n\n---\n📎 ${att.name}\n~~~${att.language}\n${att.content}\n~~~`;
75
- } else {
76
- text += `\n\n---\n📎 ${att.name}\n${att.content}`;
77
- }
78
- }
79
- return text;
80
- }
81
- function readChildOrchestratorMetadata(): ChildOrchestratorMetadata | null {
82
- const orchestratorTarget = getEnvValue(SUBAGENT_ORCHESTRATOR_TARGET_ENV)?.trim();
83
- const runId = getEnvValue(SUBAGENT_RUN_ID_ENV)?.trim();
84
- const agent = getEnvValue(SUBAGENT_CHILD_AGENT_ENV)?.trim();
85
- const index = getEnvValue(SUBAGENT_CHILD_INDEX_ENV)?.trim();
86
- if (!orchestratorTarget || !runId || !agent || !index) {
87
- return null;
88
- }
89
- const sessionName = getEnvValue(SUBAGENT_INTERCOM_SESSION_NAME_ENV)?.trim();
90
- return {
91
- orchestratorTarget,
92
- runId,
93
- agent,
94
- index,
95
- ...(sessionName ? { sessionName } : {}),
96
- };
97
- }
98
- function formatChildOrchestratorMessage(kind: "ask" | "update" | "interview", metadata: ChildOrchestratorMetadata, message: string): string {
99
- const heading = kind === "ask"
100
- ? "Subagent needs a supervisor decision."
101
- : kind === "interview"
102
- ? "Subagent requests a structured supervisor interview."
103
- : "Subagent progress update.";
104
- return [
105
- heading,
106
- `Run: ${metadata.runId}`,
107
- `Agent: ${metadata.agent}`,
108
- `Child index: ${metadata.index}`,
109
- metadata.sessionName ? `Child intercom target: ${metadata.sessionName}` : undefined,
110
- "",
111
- message,
112
- ].filter((line): line is string => line !== undefined).join("\n");
135
+ async function executeHeavyTool(
136
+ loadHeavy: (ctx?: ExtensionContext) => Promise<CapturedHeavy>,
137
+ name: string,
138
+ args: Parameters<NonNullable<ToolDefinition["execute"]>>,
139
+ ): Promise<ReturnType<NonNullable<ToolDefinition["execute"]>>> {
140
+ const ctx = args[4];
141
+ const heavy = await loadHeavy(ctx);
142
+ const tool = heavy.tools.get(name);
143
+ if (!tool?.execute) throw new Error(`Intercom tool implementation not found: ${name}`);
144
+ return tool.execute(...args) as ReturnType<NonNullable<ToolDefinition["execute"]>>;
113
145
  }
114
146
 
115
- function validateSupervisorInterviewRequest(input: unknown): { ok: true; interview: SupervisorInterviewRequest } | { ok: false; error: string } {
116
- if (!input || typeof input !== "object" || Array.isArray(input)) {
117
- return { ok: false, error: "interview must be an object with a questions array" };
118
- }
119
-
120
- const raw = input as Record<string, unknown>;
121
- if (raw.title !== undefined && typeof raw.title !== "string") {
122
- return { ok: false, error: "interview.title must be a string when provided" };
123
- }
124
- if (raw.description !== undefined && typeof raw.description !== "string") {
125
- return { ok: false, error: "interview.description must be a string when provided" };
126
- }
127
- if (!Array.isArray(raw.questions) || raw.questions.length === 0) {
128
- return { ok: false, error: "interview.questions must be a non-empty array" };
129
- }
130
-
131
- const validTypes = new Set(["single", "multi", "text", "image", "info"]);
132
- const ids = new Set<string>();
133
- const questions: SupervisorInterviewQuestion[] = [];
134
-
135
- for (let index = 0; index < raw.questions.length; index++) {
136
- const questionInput = raw.questions[index];
137
- if (!questionInput || typeof questionInput !== "object" || Array.isArray(questionInput)) {
138
- return { ok: false, error: `interview.questions[${index}] must be an object` };
139
- }
140
- const question = questionInput as Record<string, unknown>;
141
- if (typeof question.id !== "string" || question.id.trim() === "") {
142
- return { ok: false, error: `interview.questions[${index}].id must be a non-empty string` };
143
- }
144
- const id = question.id.trim();
145
- if (ids.has(id)) {
146
- return { ok: false, error: `interview question id must be unique: ${id}` };
147
- }
148
- ids.add(id);
149
-
150
- if (typeof question.type !== "string" || !validTypes.has(question.type)) {
151
- return { ok: false, error: `interview.questions[${index}].type must be one of: single, multi, text, image, info` };
152
- }
153
- if (typeof question.question !== "string" || question.question.trim() === "") {
154
- return { ok: false, error: `interview.questions[${index}].question must be a non-empty string` };
155
- }
156
- if (question.context !== undefined && typeof question.context !== "string") {
157
- return { ok: false, error: `interview.questions[${index}].context must be a string when provided` };
158
- }
159
- let options: unknown[] | undefined;
160
- if (question.options !== undefined) {
161
- if (!Array.isArray(question.options)) {
162
- return { ok: false, error: `interview.questions[${index}].options must be an array when provided` };
163
- }
164
- options = [];
165
- for (let optionIndex = 0; optionIndex < question.options.length; optionIndex++) {
166
- const option = question.options[optionIndex];
167
- if (typeof option === "string") {
168
- const label = option.trim();
169
- if (!label) {
170
- return { ok: false, error: `interview.questions[${index}].options[${optionIndex}] must not be empty` };
171
- }
172
- options.push(label);
173
- } else if (!option || typeof option !== "object" || Array.isArray(option) || typeof (option as { label?: unknown }).label !== "string" || (option as { label: string }).label.trim() === "") {
174
- return { ok: false, error: `interview.questions[${index}].options[${optionIndex}] must be a non-empty string or an object with a non-empty label` };
175
- } else {
176
- options.push({ ...option, label: (option as { label: string }).label.trim() });
177
- }
178
- }
179
- }
180
- if ((question.type === "single" || question.type === "multi") && (!options || options.length === 0)) {
181
- return { ok: false, error: `interview.questions[${index}].options must be a non-empty array for ${question.type} questions` };
182
- }
183
- if (question.type !== "single" && question.type !== "multi" && options) {
184
- return { ok: false, error: `interview.questions[${index}].options is only valid for single and multi questions` };
185
- }
186
-
187
- questions.push({
188
- ...question,
189
- id,
190
- type: question.type as SupervisorInterviewQuestion["type"],
191
- question: question.question.trim(),
192
- ...(options ? { options } : {}),
193
- });
194
- }
195
-
196
- return {
197
- ok: true,
198
- interview: {
199
- ...raw,
200
- ...(typeof raw.title === "string" ? { title: raw.title.trim() } : {}),
201
- ...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
202
- questions,
203
- },
204
- };
147
+ async function runHeavyCommand(loadHeavy: (ctx?: ExtensionContext) => Promise<CapturedHeavy>, args: string | undefined, ctx: ExtensionContext): Promise<void> {
148
+ const heavy = await loadHeavy(ctx);
149
+ const command = heavy.commands.get("intercom");
150
+ if (!command) throw new Error("Intercom command implementation not found");
151
+ await command.handler(args, ctx);
205
152
  }
206
153
 
207
- function interviewOptionLabel(option: unknown): string {
208
- return typeof option === "string" ? option : (option as { label: string }).label;
154
+ function renderHeavyToolResult(loadedHeavy: CapturedHeavy | null, name: string, args: ToolRenderResultArgs): ReturnType<NonNullable<ToolDefinition["renderResult"]>> {
155
+ const renderer = loadedHeavy?.tools.get(name)?.renderResult;
156
+ if (renderer) return renderer(...args);
157
+ return renderIntercomToolResult(name, args);
209
158
  }
210
159
 
211
- function interviewExampleValue(question: SupervisorInterviewQuestion): unknown {
212
- if (question.type === "multi") {
213
- return question.options?.slice(0, 2).map(interviewOptionLabel) ?? [];
214
- }
215
- if (question.type === "single") {
216
- return question.options?.[0] !== undefined ? interviewOptionLabel(question.options[0]) : "option label";
217
- }
218
- if (question.type === "image") {
219
- return "image/file reference or description";
220
- }
221
- return "answer text";
222
- }
223
-
224
- function formatSupervisorInterviewRequest(interview: SupervisorInterviewRequest, message?: string): string {
225
- const lines: string[] = [];
226
- const title = interview.title?.trim();
227
- if (title) lines.push(`Interview: ${title}`);
228
- const description = interview.description?.trim();
229
- if (description) lines.push(description);
230
- const note = message?.trim();
231
- if (note) lines.push(`Child note: ${note}`);
232
- if (lines.length > 0) lines.push("");
233
-
234
- lines.push("Questions:");
235
- interview.questions.forEach((question, index) => {
236
- lines.push(`${index + 1}. [${question.id}] (${question.type}) ${question.question}`);
237
- if (typeof question.context === "string" && question.context.trim()) {
238
- lines.push(` Context: ${question.context.trim()}`);
239
- }
240
- if (question.options?.length) {
241
- lines.push(" Options:");
242
- for (const option of question.options) {
243
- lines.push(` - ${interviewOptionLabel(option)}`);
244
- }
245
- }
246
- });
247
-
248
- const responseExample = {
249
- responses: interview.questions
250
- .filter((question) => question.type !== "info")
251
- .map((question) => ({
252
- id: question.id,
253
- value: interviewExampleValue(question),
254
- })),
255
- };
256
-
257
- lines.push(
258
- "",
259
- "Supervisor reply instructions:",
260
- "Reply with plain JSON or a fenced ```json block using this stable shape. Use the question ids exactly. Info questions are context-only and do not need responses. For single questions, value is one option label. For multi questions, value is an array of option labels. For text/image questions, value is a string unless the question asks otherwise.",
261
- "",
262
- "```json",
263
- JSON.stringify(responseExample, null, 2),
264
- "```",
265
- );
266
-
267
- return lines.join("\n");
268
- }
269
-
270
- function validateSupervisorInterviewReply(value: unknown, interview: SupervisorInterviewRequest): SupervisorInterviewReply {
271
- if (!value || typeof value !== "object" || Array.isArray(value)) {
272
- throw new Error("reply JSON must be an object with a responses array");
273
- }
274
-
275
- const responsesInput = (value as Record<string, unknown>).responses;
276
- if (!Array.isArray(responsesInput)) {
277
- throw new Error("reply JSON must include a responses array");
278
- }
279
-
280
- const questionById = new Map(interview.questions
281
- .filter((question) => question.type !== "info")
282
- .map((question) => [question.id, question]));
283
- const seenIds = new Set<string>();
284
- const responses: SupervisorInterviewReply["responses"] = [];
285
-
286
- for (let index = 0; index < responsesInput.length; index++) {
287
- const response = responsesInput[index];
288
- if (!response || typeof response !== "object" || Array.isArray(response)) {
289
- throw new Error(`responses[${index}] must be an object`);
290
- }
291
-
292
- const raw = response as Record<string, unknown>;
293
- if (typeof raw.id !== "string" || raw.id.trim() === "") {
294
- throw new Error(`responses[${index}].id must be a non-empty string`);
295
- }
296
- const id = raw.id.trim();
297
- const question = questionById.get(id);
298
- if (!question) {
299
- throw new Error(`responses[${index}].id must match a non-info interview question id`);
300
- }
301
- if (seenIds.has(id)) {
302
- throw new Error(`responses[${index}].id is duplicated: ${id}`);
303
- }
304
- seenIds.add(id);
305
- if (!Object.hasOwn(raw, "value")) {
306
- throw new Error(`responses[${index}].value is required`);
307
- }
308
-
309
- const value = raw.value;
310
- if (question.type === "single") {
311
- if (typeof value !== "string") throw new Error(`responses[${index}].value must be a string for single questions`);
312
- const optionLabels = new Set(question.options?.map(interviewOptionLabel));
313
- if (!optionLabels.has(value.trim())) throw new Error(`responses[${index}].value must match one of the question options`);
314
- responses.push({ id, value: value.trim() });
315
- continue;
316
- }
317
-
318
- if (question.type === "multi") {
319
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
320
- throw new Error(`responses[${index}].value must be an array of strings for multi questions`);
321
- }
322
- const optionLabels = new Set(question.options?.map(interviewOptionLabel));
323
- const selected = value.map((item) => item.trim());
324
- const invalid = selected.find((item) => !optionLabels.has(item));
325
- if (invalid) throw new Error(`responses[${index}].value contains an option that is not in the question options: ${invalid}`);
326
- responses.push({ id, value: selected });
327
- continue;
328
- }
329
-
330
- if (typeof value !== "string") {
331
- throw new Error(`responses[${index}].value must be a string for ${question.type} questions`);
332
- }
333
- responses.push({ id, value });
334
- }
335
-
336
- return { responses };
337
- }
338
-
339
- function parseStructuredSupervisorReply(text: string, interview: SupervisorInterviewRequest): { value?: SupervisorInterviewReply; error?: string } | undefined {
340
- const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
341
- const candidate = (fencedMatch?.[1] ?? text).trim();
342
- if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
343
- return undefined;
344
- }
345
- try {
346
- return { value: validateSupervisorInterviewReply(JSON.parse(candidate), interview) };
347
- } catch (error) {
348
- return { error: getErrorMessage(error) };
349
- }
350
- }
351
- function duplicateSessionNames(sessions: SessionInfo[]): Set<string> {
352
- return new Set(
353
- sessions
354
- .map(s => s.name?.toLowerCase())
355
- .filter((name): name is string => Boolean(name))
356
- .filter((name, index, names) => names.indexOf(name) !== index)
357
- );
358
- }
359
- function shortSessionId(sessionId: string): string {
360
- return sessionId.slice(0, 8);
361
- }
362
- function parseSubagentIntercomPayload(payload: unknown): { to: string; message: string; requestId?: string } | null {
363
- if (typeof payload !== "object" || payload === null) {
364
- return null;
365
- }
366
- const record = payload as Record<string, unknown>;
367
- if (typeof record.to !== "string" || typeof record.message !== "string") {
368
- return null;
369
- }
370
- const requestId = typeof record.requestId === "string" ? record.requestId : undefined;
371
- return { to: record.to, message: record.message, ...(requestId ? { requestId } : {}) };
372
- }
373
- function resolveIntercomPresenceName(sessionName: string | undefined, sessionId: string): string {
374
- const trimmedName = sessionName?.trim();
375
- if (trimmedName) {
376
- return trimmedName;
377
- }
378
- const normalizedSessionId = sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
379
- return `${DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
380
- }
381
- function buildPresenceIdentity(pi: ExtensionAPI, sessionId: string): { name: string } {
382
- return {
383
- name: resolveIntercomPresenceName(pi.getSessionName(), sessionId),
384
- };
385
- }
386
- function formatSessionLabel(session: SessionInfo, duplicates: Set<string>): string {
387
- if (!session.name) {
388
- return session.id;
389
- }
390
- return duplicates.has(session.name.toLowerCase())
391
- ? `${session.name} (${shortSessionId(session.id)})`
392
- : session.name;
393
- }
394
- function formatSessionListRow(session: SessionInfo, currentCwd: string, isSelf: boolean): string {
395
- const name = session.name || "Unnamed session";
396
- const tags = [isSelf ? "self" : session.cwd === currentCwd ? "same cwd" : undefined, session.status]
397
- .filter((tag): tag is string => Boolean(tag));
398
- const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
399
- return `• ${name} (${shortSessionId(session.id)}) — ${session.cwd} (${session.model})${suffix}`;
400
- }
401
- function previewText(value: unknown, maxLength = 72): string | undefined {
402
- if (typeof value !== "string") {
403
- return undefined;
404
- }
405
- const normalized = value.replace(/\s+/g, " ").trim();
406
- if (!normalized) {
407
- return undefined;
408
- }
409
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}…` : normalized;
410
- }
411
- function firstTextContent(result: { content?: Array<{ type: string; text?: string }> }): string {
412
- return result.content?.find((item) => item.type === "text" && typeof item.text === "string")?.text?.replace(/\*\*/g, "") ?? "";
413
- }
414
- export default function piIntercomExtension(pi: ExtensionAPI) {
415
- let client: IntercomClient | null = null;
416
- const config: IntercomConfig = loadConfig();
417
- let runtimeContext: ExtensionContext | null = null;
418
- let currentSessionId: string | null = null;
419
- let currentModel = "unknown";
420
- let sessionStartedAt: number | null = null;
421
- let reconnectTimer: NodeJS.Timeout | null = null;
422
- let reconnectPromise: Promise<IntercomClient> | null = null;
423
- let reconnectPromiseGeneration: number | null = null;
424
- let startupConnectTimer: NodeJS.Timeout | null = null;
425
- let reconnectAttempt = 0;
426
- let shuttingDown = false;
427
- let disposed = true;
428
- let runtimeStarted = false;
429
- let runtimeGeneration = 0;
430
- let agentRunning = false;
431
- const activeTools = new Map<string, string>();
432
- const replyTracker = new ReplyTracker();
433
- const pendingIdleMessages: InboundMessageEntry[] = [];
434
- let inboundFlushTimer: NodeJS.Timeout | null = null;
435
- let replyWaiter: {
436
- from: string;
437
- replyTo: string;
438
- resolve: (message: Message) => void;
439
- reject: (error: Error) => void;
440
- } | null = null;
441
- function waitForReply(from: string, replyTo: string, signal?: AbortSignal): Promise<Message> {
442
- if (replyWaiter) {
443
- return Promise.reject(new Error("Already waiting for a reply"));
444
- }
445
- if (signal?.aborted) {
446
- return Promise.reject(new Error("Cancelled"));
447
- }
448
- return new Promise((resolve, reject) => {
449
- const timeout = setTimeout(() => {
450
- rejectReplyWaiter(new Error(`No reply from "${from}" within 10 minutes`));
451
- }, 10 * 60 * 1000);
452
- const cleanup = () => {
453
- clearTimeout(timeout);
454
- signal?.removeEventListener("abort", onAbort);
455
- if (replyWaiter?.replyTo === replyTo) {
456
- replyWaiter = null;
457
- }
458
- };
459
- const onAbort = () => {
460
- cleanup();
461
- reject(new Error("Cancelled"));
462
- };
463
- signal?.addEventListener("abort", onAbort, { once: true });
464
- replyWaiter = {
465
- from,
466
- replyTo,
467
- resolve: (message) => {
468
- cleanup();
469
- resolve(message);
470
- },
471
- reject: (error) => {
472
- cleanup();
473
- reject(error);
474
- },
475
- };
476
- });
477
- }
478
- function rejectReplyWaiter(error: Error): void {
479
- replyWaiter?.reject(error);
480
- }
481
- function clearReconnectTimer(): void {
482
- if (!reconnectTimer) {
483
- return;
484
- }
485
- clearTimeout(reconnectTimer);
486
- reconnectTimer = null;
487
- }
488
- function clearStartupConnectTimer(): void {
489
- if (!startupConnectTimer) {
490
- return;
491
- }
492
- clearTimeout(startupConnectTimer);
493
- startupConnectTimer = null;
494
- }
495
- function clearInboundFlushTimer(): void {
496
- if (!inboundFlushTimer) {
497
- return;
498
- }
499
- clearTimeout(inboundFlushTimer);
500
- inboundFlushTimer = null;
501
- }
502
- function getLiveContext(ctx: ExtensionContext | null = runtimeContext, generation = runtimeGeneration): ExtensionContext | null {
503
- if (disposed || shuttingDown || generation !== runtimeGeneration || !ctx) {
504
- return null;
505
- }
506
- try {
507
- if (currentSessionId && ctx.sessionManager.getSessionId() !== currentSessionId) {
508
- return null;
509
- }
510
- void ctx.hasUI;
511
- return ctx;
512
- } catch {
513
- // A context that throws while reading session/UI state is no longer usable.
514
- return null;
515
- }
516
- }
517
- function notifyIfLive(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error", generation = runtimeGeneration): void {
518
- const liveContext = getLiveContext(ctx, generation);
519
- if (!liveContext?.hasUI) {
520
- return;
521
- }
522
- try {
523
- liveContext.ui.notify(message, level);
524
- } catch {
525
- // The UI can disappear during session shutdown/reload while async overlay work is settling.
526
- }
527
- }
528
- function getReconnectDelayMs(): number {
529
- const backoffMs = [1000, 2000, 5000, 10000, 30000];
530
- return backoffMs[Math.min(reconnectAttempt, backoffMs.length - 1)]!;
531
- }
532
- function currentStatus(): string {
533
- const activeToolName = activeTools.values().next().value;
534
- const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : agentRunning ? "thinking" : "idle";
535
- return config.status ? `${lifecycleStatus} · ${config.status}` : lifecycleStatus;
536
- }
537
- function buildRegistration(): Omit<SessionInfo, "id"> {
538
- const liveContext = getLiveContext();
539
- if (!liveContext || !currentSessionId || sessionStartedAt === null) {
540
- throw new Error("Intercom runtime not initialized");
541
- }
542
-
543
- const identity = buildPresenceIdentity(pi, currentSessionId);
544
- return {
545
- name: identity.name,
546
- cwd: liveContext.cwd ?? process.cwd(),
547
- model: currentModel,
548
- pid: process.pid,
549
- startedAt: sessionStartedAt,
550
- lastActivity: Date.now(),
551
- status: currentStatus(),
552
- };
553
- }
554
- function syncPresenceIdentity(sessionId: string): void {
555
- if (!client || !getLiveContext()) {
556
- return;
557
- }
558
- client.updatePresence({ ...buildPresenceIdentity(pi, sessionId), status: currentStatus() });
559
- }
560
- function syncPresenceStatus(): void {
561
- if (!client || !currentSessionId || !getLiveContext()) {
562
- return;
563
- }
564
- client.updatePresence({ status: currentStatus() });
565
- }
566
- function currentSessionTargetMatches(to: string, resolvedTo?: string | null, activeClient?: IntercomClient): boolean {
567
- const targets = new Set<string>();
568
- const addTarget = (target: string | undefined | null) => {
569
- const trimmed = target?.trim();
570
- if (trimmed) targets.add(trimmed.toLowerCase());
571
- };
572
- addTarget(currentSessionId);
573
- addTarget(activeClient?.sessionId);
574
- addTarget(pi.getSessionName());
575
- if (currentSessionId) addTarget(buildPresenceIdentity(pi, currentSessionId).name);
576
- return Boolean(resolvedTo && activeClient?.sessionId && resolvedTo === activeClient.sessionId)
577
- || targets.has(to.trim().toLowerCase());
578
- }
579
- function sendIncomingMessage(entry: InboundMessageEntry, delivery: "trigger" | "followUp", generation = runtimeGeneration): void {
580
- if (runtimeStarted && !getLiveContext(runtimeContext, generation)) {
581
- return;
582
- }
583
- if (delivery !== "followUp") {
584
- replyTracker.queueTurnContext({ from: entry.from, message: entry.message, receivedAt: Date.now() });
585
- }
586
- const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
587
- const replyInstruction = entry.replyCommand ? `\n\nTo reply, use the intercom tool: ${entry.replyCommand}` : "";
588
- pi.sendMessage(
589
- {
590
- customType: "intercom_message",
591
- content: `**📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`,
592
- display: true,
593
- details: entry,
594
- },
595
- delivery === "trigger"
596
- ? { triggerTurn: true }
597
- : { deliverAs: "followUp" }
598
- );
599
- }
600
- function scheduleInboundFlush(delayMs = INBOUND_FLUSH_DELAY_MS): void {
601
- if (!getLiveContext()) {
602
- return;
603
- }
604
- const scheduledGeneration = runtimeGeneration;
605
- clearInboundFlushTimer();
606
- inboundFlushTimer = setTimeout(() => {
607
- inboundFlushTimer = null;
608
- flushIdleMessages(scheduledGeneration);
609
- }, delayMs);
610
- }
611
- function flushIdleMessages(generation = runtimeGeneration): void {
612
- if (pendingIdleMessages.length === 0) {
613
- return;
614
- }
615
- const ctx = getLiveContext(runtimeContext, generation);
616
- if (!ctx) {
617
- return;
618
- }
619
-
620
- let isIdle: boolean;
621
- try {
622
- isIdle = ctx.isIdle();
623
- } catch {
624
- // Stale contexts are cleaned up by shutdown/reload; do not deliver queued messages through them.
625
- return;
626
- }
627
- if (!isIdle) {
628
- scheduleInboundFlush(INBOUND_IDLE_RETRY_MS);
629
- return;
630
- }
631
-
632
- const entries = pendingIdleMessages.splice(0, pendingIdleMessages.length);
633
- entries.forEach((entry, index) => {
634
- sendIncomingMessage(entry, index === 0 ? "trigger" : "followUp");
635
- });
636
- }
637
- function queueIdleMessage(entry: InboundMessageEntry): void {
638
- pendingIdleMessages.push(entry);
639
- scheduleInboundFlush();
640
- }
641
- function handleIncomingMessage(ctx: ExtensionContext, from: SessionInfo, message: Message): void {
642
- const messageGeneration = runtimeGeneration;
643
- const liveContext = getLiveContext(ctx, messageGeneration);
644
- if (!liveContext) {
645
- return;
646
- }
647
- if (replyWaiter) {
648
- const senderTarget = from.name || from.id;
649
- const fromMatches = senderTarget.toLowerCase() === replyWaiter.from.toLowerCase()
650
- || from.id === replyWaiter.from;
651
- const replyMatches = message.replyTo === replyWaiter.replyTo;
652
- if (fromMatches && replyMatches) {
653
- replyWaiter.resolve(message);
654
- return;
655
- }
656
- }
657
- const attachmentText = message.content.attachments?.length
658
- ? formatAttachments(message.content.attachments)
659
- : "";
660
- const bodyText = `${message.content.text}${attachmentText}`;
661
- const replyCommand = config.replyHint && message.expectsReply
662
- ? `intercom({ action: "reply", message: "..." })`
663
- : undefined;
664
- replyTracker.recordIncomingMessage(from, message);
665
- const entry = { from, message, replyCommand, bodyText };
666
- void (async () => {
667
- const activeContext = getLiveContext(liveContext, messageGeneration);
668
- if (!activeContext) {
669
- return;
670
- }
671
- if (!activeContext.isIdle()) {
672
- if (!activeContext.hasUI) {
673
- const activeClient = client;
674
- if (!message.replyTo && activeClient?.isConnected()) {
675
- try {
676
- const result = await activeClient.send(from.id, {
677
- 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.",
678
- replyTo: message.id,
679
- });
680
- if (result.delivered && getLiveContext(liveContext, messageGeneration)) {
681
- replyTracker.markReplied(message.id);
682
- }
683
- } catch {
684
- // Best-effort reply; keep the busy non-interactive session running either way.
685
- }
686
- }
687
- return;
688
- }
689
- queueIdleMessage(entry);
690
- return;
691
- }
692
- if (getLiveContext(liveContext, messageGeneration)) {
693
- sendIncomingMessage(entry, "trigger", messageGeneration);
694
- }
695
- })();
696
- }
697
- function attachClientHandlers(nextClient: IntercomClient): void {
698
- nextClient.on("message", (from, message) => {
699
- const liveContext = getLiveContext();
700
- if (client !== nextClient || !liveContext) {
701
- return;
702
- }
703
- handleIncomingMessage(liveContext, from, message);
704
- });
705
- nextClient.on("disconnected", (error: Error) => {
706
- if (client !== nextClient) {
707
- return;
708
- }
709
- rejectReplyWaiter(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
710
- client = null;
711
- if (!shuttingDown && !disposed) {
712
- clearReconnectTimer();
713
- scheduleReconnect();
714
- }
715
- });
716
- nextClient.on("error", () => {
717
- // Keep broker/socket noise out of the TUI. Reconnect logic runs from the disconnect path.
718
- });
719
- }
720
- function scheduleReconnect(): void {
721
- if (disposed || shuttingDown || reconnectTimer || reconnectPromise || !getLiveContext()) {
722
- return;
723
- }
724
- const scheduledGeneration = runtimeGeneration;
725
- reconnectTimer = setTimeout(() => {
726
- reconnectTimer = null;
727
- if (scheduledGeneration !== runtimeGeneration || !getLiveContext()) {
728
- return;
729
- }
730
- reconnectAttempt += 1;
731
- void ensureConnected("background").catch(() => {
732
- // ensureConnected("background") already queued the next retry.
733
- });
734
- }, getReconnectDelayMs());
735
- }
736
- async function ensureConnected(reason: "startup" | "background" | "tool" | "overlay"): Promise<IntercomClient> {
737
- if (!config.enabled) {
738
- throw new Error("Intercom disabled");
739
- }
740
- if (disposed || shuttingDown) {
741
- throw new Error("Intercom shutting down");
742
- }
743
- if (client && client.isConnected()) {
744
- return client;
745
- }
746
- const contextAtStart = getLiveContext();
747
- const generationAtStart = runtimeGeneration;
748
- if (!contextAtStart || !currentSessionId || sessionStartedAt === null) {
749
- throw new Error("Intercom runtime not initialized");
750
- }
751
- clearReconnectTimer();
752
- if (reconnectPromise && reconnectPromiseGeneration === generationAtStart) {
753
- return reconnectPromise;
754
- }
755
- const nextReconnectPromise = (async () => {
756
- const nextClient = new IntercomClient();
757
- client = nextClient;
758
- attachClientHandlers(nextClient);
759
- try {
760
- await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
761
- await nextClient.connect(buildRegistration());
762
- if (!getLiveContext(contextAtStart, generationAtStart)) {
763
- await nextClient.disconnect();
764
- throw new Error("Intercom runtime no longer active");
765
- }
766
- client = nextClient;
767
- reconnectAttempt = 0;
768
- return nextClient;
769
- } catch (error) {
770
- if (client === nextClient) {
771
- client = null;
772
- }
773
- if (reason === "background" && getLiveContext(contextAtStart, generationAtStart)) {
774
- scheduleReconnect();
775
- }
776
- throw toError(error);
777
- } finally {
778
- if (reconnectPromise === nextReconnectPromise) {
779
- reconnectPromise = null;
780
- reconnectPromiseGeneration = null;
781
- }
782
- }
783
- })();
784
- reconnectPromise = nextReconnectPromise;
785
- reconnectPromiseGeneration = generationAtStart;
786
- return nextReconnectPromise;
787
- }
788
- async function resolveSessionTarget(activeClient: IntercomClient, nameOrId: string): Promise<string | null> {
789
- const sessions = await activeClient.listSessions();
790
- const byId = sessions.find(s => s.id === nameOrId);
791
- if (byId) {
792
- return byId.id;
793
- }
794
- const lowerName = nameOrId.toLowerCase();
795
- const byName = sessions.filter(s => s.name?.toLowerCase() === lowerName);
796
- if (byName.length > 1) {
797
- throw new Error(`Multiple sessions named "${nameOrId}" are connected. Use the session ID instead.`);
798
- }
799
- return byName[0]?.id ?? null;
800
- }
801
- function deliverLocalSubagentRelayMessage(sender: "subagent-control" | "subagent-result", status: string, messageText: string): void {
802
- const now = Date.now();
803
- sendIncomingMessage({
804
- from: {
805
- id: sender,
806
- name: sender,
807
- cwd: runtimeContext?.cwd ?? process.cwd(),
808
- model: sender,
809
- pid: process.pid,
810
- startedAt: now,
811
- lastActivity: now,
812
- status,
813
- },
814
- message: {
815
- id: randomUUID(),
816
- timestamp: now,
817
- content: { text: messageText },
818
- },
819
- bodyText: messageText,
820
- }, "trigger");
821
- }
822
- function recordSubagentDeliveryError(entryType: string, to: string, message: string, error: unknown): void {
823
- pi.appendEntry(entryType, {
824
- to,
825
- message,
826
- error: getErrorMessage(error),
827
- timestamp: Date.now(),
828
- });
829
- }
830
- function emitResultDelivery(requestId: string | undefined, delivered: boolean, error?: unknown): void {
831
- if (!requestId) return;
832
- pi.events.emit(SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT, {
833
- requestId,
834
- delivered,
835
- ...(error ? { error: getErrorMessage(error) } : {}),
836
- });
837
- }
838
- function relaySubagentIntercomPayload(payload: unknown, options: {
839
- sender: "subagent-control" | "subagent-result";
840
- status: string;
841
- errorEntryType: string;
842
- acknowledge?: boolean;
843
- }): void {
844
- const parsed = parseSubagentIntercomPayload(payload);
845
- if (!parsed) return;
846
-
847
- const relayGeneration = runtimeGeneration;
848
- void (async () => {
849
- const relayStillLive = () => !runtimeStarted || Boolean(getLiveContext(runtimeContext, relayGeneration));
850
- if (!relayStillLive()) {
851
- return;
852
- }
853
- if (currentSessionTargetMatches(parsed.to)) {
854
- deliverLocalSubagentRelayMessage(options.sender, options.status, parsed.message);
855
- if (options.acknowledge) emitResultDelivery(parsed.requestId, true);
856
- return;
857
- }
858
-
859
- let activeClient: IntercomClient;
860
- let target: string;
861
- try {
862
- activeClient = await ensureConnected("background");
863
- target = await resolveSessionTarget(activeClient, parsed.to) ?? parsed.to;
864
- } catch (error) {
865
- if (!relayStillLive()) return;
866
- recordSubagentDeliveryError(options.errorEntryType, parsed.to, parsed.message, error);
867
- if (options.acknowledge) emitResultDelivery(parsed.requestId, false, error);
868
- return;
869
- }
870
-
871
- if (!relayStillLive()) {
872
- return;
873
- }
874
- if (currentSessionTargetMatches(parsed.to, target, activeClient)) {
875
- deliverLocalSubagentRelayMessage(options.sender, options.status, parsed.message);
876
- if (options.acknowledge) emitResultDelivery(parsed.requestId, true);
877
- return;
878
- }
879
-
880
- try {
881
- const result = await activeClient.send(target, { text: parsed.message });
882
- if (!relayStillLive()) return;
883
- if (!result.delivered) {
884
- const error = new Error(result.reason ?? "Session may not exist or has disconnected.");
885
- recordSubagentDeliveryError(options.errorEntryType, parsed.to, parsed.message, error);
886
- if (options.acknowledge) emitResultDelivery(parsed.requestId, false, error);
887
- return;
888
- }
889
- if (options.acknowledge) emitResultDelivery(parsed.requestId, true);
890
- } catch (error) {
891
- if (!relayStillLive()) return;
892
- recordSubagentDeliveryError(options.errorEntryType, parsed.to, parsed.message, error);
893
- if (options.acknowledge) emitResultDelivery(parsed.requestId, false, error);
894
- }
895
- })();
896
- }
897
- pi.events.on(SUBAGENT_CONTROL_INTERCOM_EVENT, (payload) => {
898
- relaySubagentIntercomPayload(payload, {
899
- sender: "subagent-control",
900
- status: "needs_attention",
901
- errorEntryType: "intercom_control_error",
902
- });
903
- });
904
- pi.events.on(SUBAGENT_RESULT_INTERCOM_EVENT, (payload) => {
905
- relaySubagentIntercomPayload(payload, {
906
- sender: "subagent-result",
907
- status: "result",
908
- errorEntryType: "intercom_result_error",
909
- acknowledge: true,
910
- });
911
- });
912
- pi.on("session_start", (_event, ctx) => {
913
- if (!config.enabled) {
914
- return;
915
- }
916
- shuttingDown = false;
917
- disposed = false;
918
- runtimeStarted = true;
919
- runtimeGeneration += 1;
920
- reconnectAttempt = 0;
921
- clearReconnectTimer();
922
- clearStartupConnectTimer();
923
- runtimeContext = ctx;
924
- currentSessionId = ctx.sessionManager.getSessionId();
925
- currentModel = ctx.model?.id ?? "unknown";
926
- sessionStartedAt = Date.now();
927
- agentRunning = false;
928
- activeTools.clear();
929
- const startupGeneration = runtimeGeneration;
930
- startupConnectTimer = setTimeout(() => {
931
- startupConnectTimer = null;
932
- if (!getLiveContext(ctx, startupGeneration)) {
933
- return;
934
- }
935
- void ensureConnected("startup").catch(() => {
936
- if (!getLiveContext(ctx, startupGeneration)) {
937
- return;
938
- }
939
- client = null;
940
- scheduleReconnect();
941
- });
942
- }, 0);
943
- });
944
-
945
- pi.on("session_shutdown", async () => {
946
- shuttingDown = true;
947
- disposed = true;
948
- runtimeGeneration += 1;
949
- clearStartupConnectTimer();
950
- clearReconnectTimer();
951
- rejectReplyWaiter(new Error("Session shutting down"));
952
- replyTracker.reset();
953
- pendingIdleMessages.length = 0;
954
- clearInboundFlushTimer();
955
- agentRunning = false;
956
- activeTools.clear();
957
- if (client) {
958
- await client.disconnect();
959
- client = null;
960
- }
961
- runtimeContext = null;
962
- currentSessionId = null;
963
- sessionStartedAt = null;
964
- });
965
- pi.on("turn_end", () => {
966
- if (!getLiveContext()) {
967
- return;
968
- }
969
- replyTracker.endTurn();
970
- scheduleInboundFlush(0);
971
- });
972
- pi.on("agent_start", () => {
973
- if (!getLiveContext()) {
974
- return;
975
- }
976
- agentRunning = true;
977
- activeTools.clear();
978
- syncPresenceStatus();
979
- });
980
- pi.on("tool_execution_start", (event) => {
981
- if (!getLiveContext()) {
982
- return;
983
- }
984
- activeTools.set(event.toolCallId, event.toolName);
985
- syncPresenceStatus();
986
- });
987
- pi.on("tool_execution_end", (event) => {
988
- if (!getLiveContext()) {
989
- return;
990
- }
991
- activeTools.delete(event.toolCallId);
992
- syncPresenceStatus();
993
- });
994
- pi.on("agent_end", () => {
995
- if (!getLiveContext()) {
996
- return;
997
- }
998
- agentRunning = false;
999
- activeTools.clear();
1000
- syncPresenceStatus();
1001
- scheduleInboundFlush(0);
1002
- });
1003
- pi.on("turn_start", (_event, ctx) => {
1004
- if (!getLiveContext(ctx)) {
1005
- return;
1006
- }
1007
- currentSessionId = ctx.sessionManager.getSessionId();
1008
- syncPresenceIdentity(ctx.sessionManager.getSessionId());
1009
- replyTracker.beginTurn();
1010
- });
1011
- pi.on("model_select", (event, ctx) => {
1012
- if (!getLiveContext(ctx)) {
1013
- return;
1014
- }
1015
- currentModel = event.model.id;
1016
- if (client) {
1017
- client.updatePresence({
1018
- ...buildPresenceIdentity(pi, ctx.sessionManager.getSessionId()),
1019
- model: event.model.id,
1020
- status: currentStatus(),
1021
- });
1022
- }
1023
- });
1024
-
1025
- pi.registerMessageRenderer("intercom_message", (message, _options, theme) => {
1026
- const details = message.details as { from: SessionInfo; message: Message; replyCommand?: string; bodyText?: string } | undefined;
1027
- if (!details) return undefined;
1028
- return new InlineMessageComponent(details.from, details.message, theme, details.replyCommand, details.bodyText);
1029
- });
1030
-
1031
- const childOrchestratorMetadata = readChildOrchestratorMetadata();
1032
- if (childOrchestratorMetadata) {
1033
- pi.registerTool({
1034
- name: "contact_supervisor",
1035
- label: "Contact Supervisor",
1036
- description: "Subagent-only tool for contacting the supervisor agent that delegated this task. Use need_decision when blocked, uncertain, needing approval, or facing a product/API/scope decision before continuing; this waits for the supervisor's reply. Use interview_request when multiple structured questions need supervisor answers; this also waits for a reply. Use progress_update only for meaningful progress or unexpected discoveries that change the plan; this does not wait for a reply. Do not use for routine completion handoffs.",
1037
- promptSnippet: "Subagent-only: contact the supervisor for decisions, structured interviews, or meaningful plan-changing updates. Do not use for routine completion handoffs.",
1038
- promptGuidelines: [
1039
- "Use contact_supervisor with reason='need_decision' when a subagent is blocked, uncertain, needs approval, or faces a product/API/scope decision before continuing.",
1040
- "Use contact_supervisor with reason='interview_request' when the child needs multiple structured answers from the supervisor in one blocking exchange.",
1041
- "Use contact_supervisor with reason='progress_update' only for meaningful progress or unexpected discoveries that change the plan.",
1042
- "Do not use contact_supervisor for routine completion handoffs; return the final subagent result normally.",
1043
- ],
1044
- parameters: Type.Object({
1045
- reason: Type.String({
1046
- enum: ["need_decision", "progress_update", "interview_request"],
1047
- description: "Contact reason: 'need_decision' waits for a reply; 'interview_request' sends structured questions and waits for a reply; 'progress_update' sends a non-blocking update",
1048
- }),
1049
- message: Type.Optional(Type.String({
1050
- description: "Decision request, optional interview note, or meaningful progress update for the supervisor",
1051
- })),
1052
- interview: Type.Optional(Type.Object({
1053
- title: Type.Optional(Type.String()),
1054
- description: Type.Optional(Type.String()),
1055
- questions: Type.Array(Type.Object({
1056
- id: Type.String(),
1057
- type: Type.String({ description: "Question type: single, multi, text, image, or info" }),
1058
- question: Type.String(),
1059
- options: Type.Optional(Type.Array(Type.Unknown())),
1060
- context: Type.Optional(Type.String()),
1061
- })),
1062
- }, { description: "Structured interview request for reason='interview_request'" })),
1063
- }),
1064
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1065
- const reason = params.reason as ContactSupervisorReason;
1066
- if (reason !== "need_decision" && reason !== "progress_update" && reason !== "interview_request") {
1067
- return {
1068
- content: [{ type: "text", text: "Invalid reason. Use 'need_decision', 'interview_request', or 'progress_update'." }],
1069
- isError: true,
1070
- details: { error: true },
1071
- };
1072
- }
1073
- if ((reason === "need_decision" || reason === "progress_update") && typeof params.message !== "string") {
1074
- return {
1075
- content: [{ type: "text", text: `Missing 'message' parameter for reason '${reason}'.` }],
1076
- isError: true,
1077
- details: { error: true },
1078
- };
1079
- }
1080
- const interviewValidation = reason === "interview_request"
1081
- ? validateSupervisorInterviewRequest(params.interview)
1082
- : undefined;
1083
- if (interviewValidation?.ok === false) {
1084
- return {
1085
- content: [{ type: "text", text: `Invalid interview request: ${interviewValidation.error}` }],
1086
- isError: true,
1087
- details: { error: true },
1088
- };
1089
- }
1090
- const supervisorInterview = interviewValidation?.ok === true ? interviewValidation.interview : undefined;
1091
-
1092
- let connectedClient: IntercomClient;
1093
- try {
1094
- connectedClient = await ensureConnected("tool");
1095
- } catch (error) {
1096
- return {
1097
- content: [{ type: "text", text: `Intercom not connected: ${getErrorMessage(error)}` }],
1098
- isError: true,
1099
- details: { error: true },
1100
- };
1101
- }
1102
-
1103
- syncPresenceIdentity(ctx.sessionManager.getSessionId());
1104
-
1105
- if (signal?.aborted) {
1106
- return {
1107
- content: [{ type: "text", text: "Cancelled" }],
1108
- isError: true,
1109
- details: { error: true },
1110
- };
1111
- }
1112
-
1113
- const metadata = childOrchestratorMetadata;
1114
- let sendTo: string;
1115
- try {
1116
- sendTo = await resolveSessionTarget(connectedClient, metadata.orchestratorTarget) ?? metadata.orchestratorTarget;
1117
- } catch (error) {
1118
- return {
1119
- content: [{ type: "text", text: `Failed to resolve supervisor target: ${getErrorMessage(error)}` }],
1120
- isError: true,
1121
- details: { error: true },
1122
- };
1123
- }
1124
- if (signal?.aborted) {
1125
- return {
1126
- content: [{ type: "text", text: "Cancelled" }],
1127
- isError: true,
1128
- details: { error: true },
1129
- };
1130
- }
1131
- if (sendTo === connectedClient.sessionId) {
1132
- return {
1133
- content: [{ type: "text", text: "Cannot message the current session" }],
1134
- isError: true,
1135
- details: { error: true },
1136
- };
1137
- }
1138
-
1139
- if (reason === "progress_update") {
1140
- const message = params.message as string;
1141
- try {
1142
- const result = await connectedClient.send(sendTo, {
1143
- text: formatChildOrchestratorMessage("update", metadata, message),
1144
- });
1145
- if (!result.delivered) {
1146
- const errorText = result.reason ?? "Session may not exist or has disconnected.";
1147
- return {
1148
- content: [{ type: "text", text: `Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}` }],
1149
- isError: true,
1150
- details: { messageId: result.id, delivered: false, reason: result.reason },
1151
- };
1152
- }
1153
- pi.appendEntry("intercom_sent", {
1154
- to: metadata.orchestratorTarget,
1155
- message: { text: message, reason },
1156
- messageId: result.id,
1157
- timestamp: Date.now(),
1158
- subagent: { runId: metadata.runId, agent: metadata.agent, index: metadata.index },
1159
- });
1160
- return {
1161
- content: [{ type: "text", text: `Progress update sent to supervisor ${metadata.orchestratorTarget}` }],
1162
- isError: false,
1163
- details: { messageId: result.id, delivered: true },
1164
- };
1165
- } catch (error) {
1166
- return {
1167
- content: [{ type: "text", text: `Failed to send progress update: ${getErrorMessage(error)}` }],
1168
- isError: true,
1169
- details: { error: true },
1170
- };
1171
- }
1172
- }
1173
-
1174
- if (replyWaiter) {
1175
- return {
1176
- content: [{ type: "text", text: "Already waiting for a reply" }],
1177
- isError: true,
1178
- details: { error: true },
1179
- };
1180
- }
1181
-
1182
- let replyPromise: Promise<Message> | null = null;
1183
- try {
1184
- const questionId = randomUUID();
1185
- replyPromise = waitForReply(sendTo, questionId, signal);
1186
- replyPromise.catch(() => undefined);
1187
- if (signal?.aborted) {
1188
- rejectReplyWaiter(new Error("Cancelled"));
1189
- try {
1190
- await replyPromise;
1191
- } catch {
1192
- // The waiter was intentionally rejected above; the tool result reports cancellation.
1193
- }
1194
- return {
1195
- content: [{ type: "text", text: "Cancelled" }],
1196
- isError: true,
1197
- details: { error: true },
1198
- };
1199
- }
1200
- const requestText = reason === "interview_request"
1201
- ? formatChildOrchestratorMessage("interview", metadata, formatSupervisorInterviewRequest(supervisorInterview!, typeof params.message === "string" ? params.message : undefined))
1202
- : formatChildOrchestratorMessage("ask", metadata, params.message as string);
1203
- const sendResult = await connectedClient.send(sendTo, {
1204
- messageId: questionId,
1205
- text: requestText,
1206
- expectsReply: true,
1207
- });
1208
- if (!sendResult.delivered) {
1209
- const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
1210
- rejectReplyWaiter(new Error(`Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}`));
1211
- if (replyPromise) {
1212
- try {
1213
- await replyPromise;
1214
- } catch {
1215
- // The waiter was already rejected above. Keep the delivery failure as the only error here.
1216
- }
1217
- }
1218
- return {
1219
- content: [{ type: "text", text: `Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}` }],
1220
- isError: true,
1221
- details: { error: true },
1222
- };
1223
- }
1224
- pi.appendEntry("intercom_sent", {
1225
- to: metadata.orchestratorTarget,
1226
- message: {
1227
- text: reason === "interview_request" ? requestText : params.message,
1228
- reason,
1229
- ...(reason === "interview_request" ? { interview: supervisorInterview } : {}),
1230
- },
1231
- messageId: sendResult.id,
1232
- timestamp: Date.now(),
1233
- subagent: { runId: metadata.runId, agent: metadata.agent, index: metadata.index },
1234
- });
1235
- const replyMessage = await replyPromise;
1236
- const replyText = replyMessage.content.text;
1237
- const replyAttachments = replyMessage.content.attachments?.length
1238
- ? formatAttachments(replyMessage.content.attachments)
1239
- : "";
1240
- const structuredReply = reason === "interview_request" ? parseStructuredSupervisorReply(replyText, supervisorInterview!) : undefined;
1241
- pi.appendEntry("intercom_received", {
1242
- from: metadata.orchestratorTarget,
1243
- message: { text: replyText, attachments: replyMessage.content.attachments },
1244
- messageId: replyMessage.id,
1245
- timestamp: replyMessage.timestamp,
1246
- subagent: { runId: metadata.runId, agent: metadata.agent, index: metadata.index },
1247
- });
1248
- return {
1249
- content: [{ type: "text", text: `**Reply from supervisor:**\n${replyText}${replyAttachments}` }],
1250
- isError: false,
1251
- ...(structuredReply
1252
- ? { details: structuredReply.value !== undefined ? { structuredReply: structuredReply.value } : { structuredReplyParseError: structuredReply.error } }
1253
- : {}),
1254
- };
1255
- } catch (error) {
1256
- rejectReplyWaiter(toError(error));
1257
- if (replyPromise) {
1258
- try {
1259
- await replyPromise;
1260
- } catch {
1261
- // The waiter is cleanup-only on this path. The real failure is the one from the outer catch.
1262
- }
1263
- }
1264
- return {
1265
- content: [{ type: "text", text: `Failed: ${getErrorMessage(error)}` }],
1266
- isError: true,
1267
- details: { error: true },
1268
- };
1269
- }
1270
- },
1271
- renderCall(args, theme) {
1272
- const reason = typeof args.reason === "string" ? args.reason : "contact";
1273
- const messagePreview = previewText(args.message, 96);
1274
- const interview = args.interview && typeof args.interview === "object" ? args.interview as { title?: unknown } : undefined;
1275
- let text = theme.fg("toolTitle", theme.bold("contact_supervisor "));
1276
- text += theme.fg(reason === "need_decision" ? "warning" : reason === "progress_update" ? "muted" : "accent", reason);
1277
- if (typeof interview?.title === "string" && interview.title.trim()) {
1278
- text += " " + theme.fg("accent", interview.title.trim());
1279
- }
1280
- if (messagePreview) {
1281
- text += "\n " + theme.fg("dim", messagePreview);
1282
- }
1283
- return new Text(text, 0, 0);
1284
- },
1285
- renderResult(result, { isPartial }, theme, context) {
1286
- if (isPartial) {
1287
- return new Text(theme.fg("warning", "Waiting for supervisor..."), 0, 0);
1288
- }
1289
- const details = result.details as { delivered?: boolean; error?: boolean; messageId?: string; reason?: string; structuredReplyParseError?: string } | undefined;
1290
- const textContent = firstTextContent(result);
1291
- const failed = Boolean(context.isError || details?.error === true || details?.delivered === false);
1292
- const parseWarning = typeof details?.structuredReplyParseError === "string";
1293
- let text = failed
1294
- ? theme.fg("error", "✗ ")
1295
- : parseWarning
1296
- ? theme.fg("warning", "⚠ ")
1297
- : theme.fg("success", "✓ ");
1298
- text += theme.fg(failed ? "error" : "text", textContent);
1299
- if (parseWarning) {
1300
- text += "\n" + theme.fg("warning", `Structured reply parse issue: ${details.structuredReplyParseError}`);
1301
- }
1302
- return new Text(text, 0, 0);
1303
- },
1304
- });
1305
- }
1306
-
1307
- pi.registerTool({
1308
- name: "intercom",
1309
- label: "Intercom",
1310
- description: `Send a message to another pi session running on this machine.
160
+ export default function intercom(pi: ExtensionAPI) {
161
+ let heavyPromise: Promise<CapturedHeavy> | null = null;
162
+ let loadedHeavy: CapturedHeavy | null = null;
163
+ let sessionSnapshot: SessionSnapshot | null = null;
164
+ let lifecycleGeneration = 0;
165
+ let replayedGeneration = 0;
166
+ const activeLifecycle: ActiveLifecycleState = {
167
+ turnStart: null,
168
+ agentStart: null,
169
+ activeTools: new Map(),
170
+ modelSelect: null,
171
+ };
172
+
173
+ async function replaySessionStart(heavy: CapturedHeavy): Promise<void> {
174
+ if (!sessionSnapshot || replayedGeneration === sessionSnapshot.generation) return;
175
+ replayedGeneration = sessionSnapshot.generation;
176
+ await dispatchHandlers(heavy, "session_start", sessionSnapshot.event, sessionSnapshot.ctx);
177
+ if (activeLifecycle.turnStart) {
178
+ await dispatchHandlers(heavy, "turn_start", activeLifecycle.turnStart.event, activeLifecycle.turnStart.ctx);
179
+ }
180
+ if (activeLifecycle.modelSelect) {
181
+ await dispatchHandlers(heavy, "model_select", activeLifecycle.modelSelect.event, activeLifecycle.modelSelect.ctx);
182
+ }
183
+ if (activeLifecycle.agentStart) {
184
+ await dispatchHandlers(heavy, "agent_start", activeLifecycle.agentStart.event, activeLifecycle.agentStart.ctx);
185
+ }
186
+ for (const activeTool of activeLifecycle.activeTools.values()) {
187
+ await dispatchHandlers(heavy, "tool_execution_start", activeTool.event, activeTool.ctx);
188
+ }
189
+ }
190
+
191
+ async function loadHeavy(ctx?: ExtensionContext): Promise<CapturedHeavy> {
192
+ if (!heavyPromise) {
193
+ heavyPromise = (async () => {
194
+ const captured: CapturedHeavy = {
195
+ tools: new Map(),
196
+ commands: new Map(),
197
+ handlers: new Map(),
198
+ shortcuts: new Map(),
199
+ eventHandlers: new Map(),
200
+ };
201
+ const mod = await import("./index-heavy.js");
202
+ await mod.default(createHeavyProxy(pi, captured));
203
+ loadedHeavy = captured;
204
+ if (!sessionSnapshot && ctx) {
205
+ sessionSnapshot = { event: {}, ctx, generation: ++lifecycleGeneration };
206
+ }
207
+ await replaySessionStart(captured);
208
+ return captured;
209
+ })();
210
+ }
211
+ const heavy = await heavyPromise;
212
+ if (!sessionSnapshot && ctx) {
213
+ sessionSnapshot = { event: {}, ctx, generation: ++lifecycleGeneration };
214
+ await replaySessionStart(heavy);
215
+ }
216
+ return heavy;
217
+ }
218
+
219
+ for (const eventName of FORWARDED_EVENTS) {
220
+ switch (eventName) {
221
+ case "session_start":
222
+ pi.on("session_start", async (event, ctx) => {
223
+ const generation = ++lifecycleGeneration;
224
+ sessionSnapshot = { event, ctx, generation };
225
+ if (loadedHeavy) {
226
+ replayedGeneration = generation;
227
+ await dispatchHandlers(loadedHeavy, "session_start", event, ctx);
228
+ }
229
+ });
230
+ break;
231
+ case "session_shutdown":
232
+ pi.on("session_shutdown", async (event, ctx) => {
233
+ ++lifecycleGeneration;
234
+ activeLifecycle.turnStart = null;
235
+ activeLifecycle.agentStart = null;
236
+ activeLifecycle.activeTools.clear();
237
+ activeLifecycle.modelSelect = null;
238
+ if (loadedHeavy) {
239
+ await dispatchHandlers(loadedHeavy, "session_shutdown", event, ctx);
240
+ }
241
+ sessionSnapshot = null;
242
+ replayedGeneration = lifecycleGeneration;
243
+ });
244
+ break;
245
+ case "turn_start":
246
+ pi.on("turn_start", async (event, ctx) => {
247
+ activeLifecycle.turnStart = { event, ctx };
248
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "turn_start", event, ctx);
249
+ });
250
+ break;
251
+ case "turn_end":
252
+ pi.on("turn_end", async (event, ctx) => {
253
+ activeLifecycle.turnStart = null;
254
+ activeLifecycle.agentStart = null;
255
+ activeLifecycle.activeTools.clear();
256
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "turn_end", event, ctx);
257
+ });
258
+ break;
259
+ case "agent_start":
260
+ pi.on("agent_start", async (event, ctx) => {
261
+ activeLifecycle.agentStart = { event, ctx };
262
+ activeLifecycle.activeTools.clear();
263
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "agent_start", event, ctx);
264
+ });
265
+ break;
266
+ case "agent_end":
267
+ pi.on("agent_end", async (event, ctx) => {
268
+ activeLifecycle.agentStart = null;
269
+ activeLifecycle.activeTools.clear();
270
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "agent_end", event, ctx);
271
+ });
272
+ break;
273
+ case "tool_execution_start":
274
+ pi.on("tool_execution_start", async (event, ctx) => {
275
+ const toolCallId = getToolCallId(event);
276
+ if (toolCallId) activeLifecycle.activeTools.set(toolCallId, { event, ctx });
277
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "tool_execution_start", event, ctx);
278
+ });
279
+ break;
280
+ case "tool_execution_end":
281
+ pi.on("tool_execution_end", async (event, ctx) => {
282
+ const toolCallId = getToolCallId(event);
283
+ if (toolCallId) activeLifecycle.activeTools.delete(toolCallId);
284
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "tool_execution_end", event, ctx);
285
+ });
286
+ break;
287
+ case "model_select":
288
+ pi.on("model_select", async (event, ctx) => {
289
+ activeLifecycle.modelSelect = { event, ctx };
290
+ if (loadedHeavy) await dispatchHandlers(loadedHeavy, "model_select", event, ctx);
291
+ });
292
+ break;
293
+ }
294
+ }
295
+
296
+ pi.registerShortcut("alt+m", {
297
+ description: "Open session intercom overlay",
298
+ handler: async (ctx) => {
299
+ const heavy = await loadHeavy(ctx);
300
+ const handler = heavy.shortcuts.get("alt+m")?.handler;
301
+ if (!handler) throw new Error("Intercom shortcut implementation not found: alt+m");
302
+ await handler(ctx);
303
+ },
304
+ });
305
+
306
+ for (const eventName of [SUBAGENT_CONTROL_INTERCOM_EVENT, SUBAGENT_RESULT_INTERCOM_EVENT] as const) {
307
+ pi.events.on(eventName, (payload) => {
308
+ void loadHeavy().then((heavy) => dispatchEventHandlers(heavy, eventName, payload)).catch((error) => {
309
+ console.error(`Intercom event relay failed (${eventName}):`, error);
310
+ });
311
+ });
312
+ }
313
+
314
+ if (hasSubagentIntercomEnv()) {
315
+ pi.on("session_start", (_event, ctx) => {
316
+ void loadHeavy(ctx).catch((error) => {
317
+ console.error("Intercom initialization failed:", error);
318
+ });
319
+ });
320
+ }
321
+
322
+ pi.registerTool({
323
+ name: "intercom",
324
+ label: "Intercom",
325
+ description: `Send a message to another pi session running on this machine.
1311
326
  Use this to communicate findings, request help, or coordinate work with other sessions.
1312
327
 
1313
328
  Usage:
@@ -1317,464 +332,77 @@ Usage:
1317
332
  intercom({ action: "reply", message: "..." }) → Reply to the active/single pending ask
1318
333
  intercom({ action: "pending" }) → List unresolved inbound asks
1319
334
  intercom({ action: "status" }) → Show connection status`,
1320
- promptSnippet:
1321
- "Use to coordinate with other local pi sessions: list peers, send updates, ask for help, or check intercom connectivity.",
1322
-
1323
- parameters: Type.Object({
1324
- action: Type.String({
1325
- description: "Action: 'list', 'send', 'ask', 'reply', 'pending', or 'status'",
1326
- }),
1327
- to: Type.Optional(Type.String({
1328
- description: "Target session name or ID (for 'send', 'ask', or disambiguating 'reply')",
1329
- })),
1330
- message: Type.Optional(Type.String({
1331
- description: "Message to send (for 'send', 'ask', or 'reply' action)",
1332
- })),
1333
- attachments: Type.Optional(Type.Array(Type.Object({
1334
- type: Type.Union([Type.Literal("file"), Type.Literal("snippet"), Type.Literal("context")]),
1335
- name: Type.String(),
1336
- content: Type.String(),
1337
- language: Type.Optional(Type.String()),
1338
- }))),
1339
- replyTo: Type.Optional(Type.String({
1340
- description: "Message ID to reply to (for threading or responding to an 'ask')",
1341
- })),
1342
- }),
1343
-
1344
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1345
- let connectedClient: IntercomClient;
1346
- try {
1347
- connectedClient = await ensureConnected("tool");
1348
- } catch (error) {
1349
- return {
1350
- content: [{ type: "text", text: `Intercom not connected: ${getErrorMessage(error)}` }],
1351
- isError: true,
1352
- details: { error: true },
1353
- };
1354
- }
1355
-
1356
- syncPresenceIdentity(ctx.sessionManager.getSessionId());
1357
-
1358
- const { action, to, message, attachments, replyTo } = params;
1359
-
1360
- switch (action) {
1361
- case "list": {
1362
- try {
1363
- const mySessionId = connectedClient.sessionId;
1364
- const sessions = await connectedClient.listSessions();
1365
- const currentSession = sessions.find(s => s.id === mySessionId);
1366
- const otherSessions = sessions.filter(s => s.id !== mySessionId);
1367
-
1368
- if (!currentSession) {
1369
- return {
1370
- content: [{ type: "text", text: "Current session is missing from intercom session list." }],
1371
- isError: true,
1372
- details: { error: true },
1373
- };
1374
- }
1375
-
1376
- const currentSection = `**Current session:**\n${formatSessionListRow(currentSession, currentSession.cwd, true)}`;
1377
- const otherSection = otherSessions.length === 0
1378
- ? "**Other sessions:**\nNo other sessions connected."
1379
- : `**Other sessions:**\n${otherSessions.map(s => formatSessionListRow(s, currentSession.cwd, false)).join("\n")}`;
1380
-
1381
- return {
1382
- content: [{ type: "text", text: `${currentSection}\n\n${otherSection}` }],
1383
- isError: false,
1384
- };
1385
- } catch (error) {
1386
- return {
1387
- content: [{ type: "text", text: `Failed to list sessions: ${getErrorMessage(error)}` }],
1388
- isError: true,
1389
- details: { error: true },
1390
- };
1391
- }
1392
- }
1393
-
1394
- case "send": {
1395
- if (!to || !message) {
1396
- return {
1397
- content: [{ type: "text", text: "Missing 'to' or 'message' parameter" }],
1398
- isError: true,
1399
- details: { error: true },
1400
- };
1401
- }
1402
- try {
1403
- const sendTo = await resolveSessionTarget(connectedClient, to) ?? to;
1404
- if (sendTo === connectedClient.sessionId) {
1405
- return {
1406
- content: [{ type: "text", text: "Cannot message the current session" }],
1407
- isError: true,
1408
- details: { error: true },
1409
- };
1410
- }
1411
- if (!replyTo && config.confirmSend && ctx.hasUI) {
1412
- const attachmentText = attachments?.length ? formatAttachments(attachments) : "";
1413
- const confirmed = await ctx.ui.confirm(
1414
- "Send Message",
1415
- `Send to "${to}":\n\n${message}${attachmentText}`,
1416
- );
1417
- if (!confirmed) {
1418
- return {
1419
- content: [{ type: "text", text: "Message cancelled by user" }],
1420
- isError: false,
1421
- };
1422
- }
1423
- }
1424
- const result = await connectedClient.send(sendTo, {
1425
- text: message,
1426
- attachments,
1427
- replyTo,
1428
- });
1429
- if (!result.delivered) {
1430
- const errorText = result.reason ?? "Session may not exist or has disconnected.";
1431
- return {
1432
- content: [{ type: "text", text: `Message to "${to}" was not delivered: ${errorText}` }],
1433
- isError: true,
1434
- details: { messageId: result.id, delivered: false, reason: result.reason },
1435
- };
1436
- }
1437
- pi.appendEntry("intercom_sent", {
1438
- to,
1439
- message: { text: message, attachments, replyTo },
1440
- messageId: result.id,
1441
- timestamp: Date.now(),
1442
- });
1443
- if (replyTo) {
1444
- replyTracker.markReplied(replyTo);
1445
- }
1446
- return {
1447
- content: [{ type: "text", text: `Message sent to ${to}` }],
1448
- isError: false,
1449
- details: { messageId: result.id, delivered: true },
1450
- };
1451
- } catch (error) {
1452
- return {
1453
- content: [{ type: "text", text: `Failed to send: ${getErrorMessage(error)}` }],
1454
- isError: true,
1455
- details: { error: true },
1456
- };
1457
- }
1458
- }
1459
-
1460
- case "ask": {
1461
- if (!to || !message) {
1462
- return {
1463
- content: [{ type: "text", text: "Missing 'to' or 'message' parameter" }],
1464
- isError: true,
1465
- details: { error: true },
1466
- };
1467
- }
1468
-
1469
- if (replyWaiter) {
1470
- return {
1471
- content: [{ type: "text", text: "Already waiting for a reply" }],
1472
- isError: true,
1473
- details: { error: true },
1474
- };
1475
- }
1476
-
1477
- if (_signal?.aborted) {
1478
- return {
1479
- content: [{ type: "text", text: "Cancelled" }],
1480
- isError: true,
1481
- details: { error: true },
1482
- };
1483
- }
1484
- let replyPromise: Promise<Message> | null = null;
1485
-
1486
- try {
1487
- const sendTo = await resolveSessionTarget(connectedClient, to) ?? to;
1488
- if (_signal?.aborted) {
1489
- return {
1490
- content: [{ type: "text", text: "Cancelled" }],
1491
- isError: true,
1492
- details: { error: true },
1493
- };
1494
- }
1495
- if (sendTo === connectedClient.sessionId) {
1496
- return {
1497
- content: [{ type: "text", text: "Cannot message the current session" }],
1498
- isError: true,
1499
- details: { error: true },
1500
- };
1501
- }
1502
- const questionId = randomUUID();
1503
- replyPromise = waitForReply(sendTo, questionId, _signal);
1504
- const sendResult = await connectedClient.send(sendTo, {
1505
- messageId: questionId,
1506
- text: message,
1507
- attachments,
1508
- replyTo,
1509
- expectsReply: true,
1510
- });
1511
-
1512
- if (!sendResult.delivered) {
1513
- const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
1514
- rejectReplyWaiter(new Error(`Message to "${to}" was not delivered: ${errorText}`));
1515
- if (replyPromise) {
1516
- try {
1517
- await replyPromise;
1518
- } catch {
1519
- // The waiter was already rejected above. Keep the delivery failure as the only error here.
1520
- }
1521
- }
1522
- return {
1523
- content: [{ type: "text", text: `Message to "${to}" was not delivered: ${errorText}` }],
1524
- isError: true,
1525
- details: { error: true },
1526
- };
1527
- }
1528
- pi.appendEntry("intercom_sent", {
1529
- to,
1530
- message: { text: message, attachments, replyTo },
1531
- messageId: sendResult.id,
1532
- timestamp: Date.now(),
1533
- });
1534
- const replyMessage = await replyPromise;
1535
- const replyText = replyMessage.content.text;
1536
- const replyAttachments = replyMessage.content.attachments?.length
1537
- ? formatAttachments(replyMessage.content.attachments)
1538
- : "";
1539
- pi.appendEntry("intercom_received", {
1540
- from: to,
1541
- message: { text: replyText, attachments: replyMessage.content.attachments },
1542
- messageId: replyMessage.id,
1543
- timestamp: replyMessage.timestamp,
1544
- });
1545
- return {
1546
- content: [{ type: "text", text: `**Reply from ${to}:**\n${replyText}${replyAttachments}` }],
1547
- isError: false,
1548
- };
1549
- } catch (error) {
1550
- rejectReplyWaiter(toError(error));
1551
- if (replyPromise) {
1552
- try {
1553
- await replyPromise;
1554
- } catch {
1555
- // The waiter is cleanup-only on this path. The real failure is the one from the outer catch.
1556
- }
1557
- }
1558
- return {
1559
- content: [{ type: "text", text: `Failed: ${getErrorMessage(error)}` }],
1560
- isError: true,
1561
- details: { error: true },
1562
- };
1563
- }
1564
- }
1565
-
1566
- case "reply": {
1567
- if (!message) {
1568
- return {
1569
- content: [{ type: "text", text: "Missing 'message' parameter" }],
1570
- isError: true,
1571
- details: { error: true },
1572
- };
1573
- }
1574
-
1575
- try {
1576
- const target = replyTracker.resolveReplyTarget({ to });
1577
- if (target.from.id === connectedClient.sessionId) {
1578
- return {
1579
- content: [{ type: "text", text: "Cannot message the current session" }],
1580
- isError: true,
1581
- details: { error: true },
1582
- };
1583
- }
1584
- const result = await connectedClient.send(target.from.id, {
1585
- text: message,
1586
- replyTo: target.message.id,
1587
- });
1588
- if (!result.delivered) {
1589
- const errorText = result.reason ?? "Session may not exist or has disconnected.";
1590
- return {
1591
- content: [{ type: "text", text: `Reply to "${target.from.name || target.from.id}" was not delivered: ${errorText}` }],
1592
- isError: true,
1593
- details: { messageId: result.id, delivered: false, reason: result.reason },
1594
- };
1595
- }
1596
- replyTracker.markReplied(target.message.id);
1597
- pi.appendEntry("intercom_sent", {
1598
- to: target.from.name || target.from.id,
1599
- message: { text: message, replyTo: target.message.id },
1600
- messageId: result.id,
1601
- timestamp: Date.now(),
1602
- });
1603
- return {
1604
- content: [{ type: "text", text: `Reply sent to ${target.from.name || target.from.id}` }],
1605
- isError: false,
1606
- details: { messageId: result.id, delivered: true, replyTo: target.message.id },
1607
- };
1608
- } catch (error) {
1609
- return {
1610
- content: [{ type: "text", text: `Failed to reply: ${getErrorMessage(error)}` }],
1611
- isError: true,
1612
- details: { error: true },
1613
- };
1614
- }
1615
- }
1616
-
1617
- case "pending": {
1618
- const pendingAsks = replyTracker.listPending();
1619
- if (pendingAsks.length === 0) {
1620
- return {
1621
- content: [{ type: "text", text: "No unresolved inbound asks." }],
1622
- isError: false,
1623
- };
1624
- }
1625
-
1626
- const now = Date.now();
1627
- const lines = pendingAsks.map(({ from, message, receivedAt }) => {
1628
- const preview = message.content.text.replace(/\s+/g, " ").slice(0, 80);
1629
- const elapsedSeconds = Math.max(0, Math.floor((now - receivedAt) / 1000));
1630
- return `- ${from.name || from.id} · ${message.id} · ${elapsedSeconds}s ago · ${preview}`;
1631
- });
1632
- return {
1633
- content: [{ type: "text", text: `**Pending asks:**\n${lines.join("\n")}` }],
1634
- isError: false,
1635
- };
1636
- }
1637
-
1638
- case "status": {
1639
- try {
1640
- const mySessionId = connectedClient.sessionId;
1641
- const sessions = await connectedClient.listSessions();
1642
- return {
1643
- content: [{
1644
- type: "text",
1645
- text: `**Intercom Status:**\nConnected: Yes\nSession ID: ${mySessionId}\nActive sessions: ${sessions.length}`,
1646
- }],
1647
- isError: false,
1648
- };
1649
- } catch (error) {
1650
- return {
1651
- content: [{ type: "text", text: `Failed to get status: ${getErrorMessage(error)}` }],
1652
- isError: true,
1653
- details: { error: true },
1654
- };
1655
- }
1656
- }
1657
-
1658
- default:
1659
- return {
1660
- content: [{ type: "text", text: `Unknown action: ${action}` }],
1661
- isError: true,
1662
- details: { error: true },
1663
- };
1664
- }
1665
- },
1666
- renderCall(args, theme) {
1667
- const action = typeof args.action === "string" ? args.action : "intercom";
1668
- const target = typeof args.to === "string" && args.to.trim() ? args.to.trim() : undefined;
1669
- const messagePreview = previewText(args.message, 96);
1670
- const attachmentCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
1671
- let text = theme.fg("toolTitle", theme.bold("intercom "));
1672
- text += theme.fg(action === "ask" ? "warning" : action === "reply" ? "success" : "accent", action);
1673
- if (target) {
1674
- text += " " + theme.fg("muted", "→") + " " + theme.fg("accent", target);
1675
- }
1676
- if (attachmentCount > 0) {
1677
- text += " " + theme.fg("dim", `(${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})`);
1678
- }
1679
- if (messagePreview) {
1680
- text += "\n " + theme.fg("dim", messagePreview);
1681
- }
1682
- return new Text(text, 0, 0);
1683
- },
1684
- renderResult(result, { isPartial }, theme, context) {
1685
- if (isPartial) {
1686
- return new Text(theme.fg("warning", "Intercom working..."), 0, 0);
1687
- }
1688
- const details = result.details as { delivered?: boolean; error?: boolean; messageId?: string; reason?: string } | undefined;
1689
- const failed = Boolean(context.isError || details?.error === true || details?.delivered === false);
1690
- let text = failed ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
1691
- text += theme.fg(failed ? "error" : "text", firstTextContent(result));
1692
- if (details?.messageId && !context.expanded) {
1693
- text += theme.fg("dim", ` (${details.messageId.slice(0, 8)})`);
1694
- }
1695
- if (details?.reason && context.expanded) {
1696
- text += "\n" + theme.fg("dim", `Reason: ${details.reason}`);
1697
- }
1698
- return new Text(text, 0, 0);
1699
- },
1700
- });
1701
-
1702
- async function openIntercomOverlay(ctx: ExtensionContext): Promise<void> {
1703
- const overlayGeneration = runtimeGeneration;
1704
- const liveContext = getLiveContext(ctx, overlayGeneration);
1705
- if (!liveContext?.hasUI) return;
1706
-
1707
- let overlayClient: IntercomClient;
1708
- try {
1709
- overlayClient = await ensureConnected("overlay");
1710
- } catch (error) {
1711
- notifyIfLive(ctx, `Intercom unavailable: ${getErrorMessage(error)}`, "error", overlayGeneration);
1712
- return;
1713
- }
1714
- if (!getLiveContext(ctx, overlayGeneration)) return;
1715
-
1716
- syncPresenceIdentity(ctx.sessionManager.getSessionId());
1717
-
1718
- let currentSession: SessionInfo;
1719
- let sessions: SessionInfo[];
1720
- let duplicates: Set<string>;
1721
- try {
1722
- const mySessionId = overlayClient.sessionId;
1723
- const allSessions = await overlayClient.listSessions();
1724
- if (!getLiveContext(ctx, overlayGeneration)) return;
1725
- const foundCurrentSession = allSessions.find(s => s.id === mySessionId);
1726
- if (!foundCurrentSession) {
1727
- notifyIfLive(ctx, "Current session is missing from intercom session list", "error", overlayGeneration);
1728
- return;
1729
- }
1730
- currentSession = foundCurrentSession;
1731
- duplicates = duplicateSessionNames(allSessions);
1732
- sessions = allSessions.filter(s => s.id !== mySessionId);
1733
- } catch (error) {
1734
- notifyIfLive(ctx, `Failed to list sessions: ${getErrorMessage(error)}`, "error", overlayGeneration);
1735
- return;
1736
- }
1737
-
1738
- const selectedSession = await ctx.ui.custom<SessionInfo | undefined>(
1739
- (_tui, theme, keybindings, done) => new SessionListOverlay(theme, keybindings, currentSession, sessions, done),
1740
- { overlay: true }
1741
- ).catch(() => undefined);
1742
-
1743
- if (!selectedSession || !getLiveContext(ctx, overlayGeneration)) return;
1744
-
1745
- try {
1746
- overlayClient = await ensureConnected("overlay");
1747
- } catch (error) {
1748
- notifyIfLive(ctx, `Intercom unavailable: ${getErrorMessage(error)}`, "error", overlayGeneration);
1749
- return;
1750
- }
1751
- if (!getLiveContext(ctx, overlayGeneration)) return;
1752
-
1753
- const targetLabel = formatSessionLabel(selectedSession, duplicates);
1754
-
1755
- const result = await ctx.ui.custom<ComposeResult>(
1756
- (tui, theme, keybindings, done) => new ComposeOverlay(tui, theme, keybindings, selectedSession, targetLabel, overlayClient, done),
1757
- { overlay: true }
1758
- ).catch(() => undefined);
1759
-
1760
- if (result?.sent && result.messageId && result.text && getLiveContext(ctx, overlayGeneration)) {
1761
- pi.appendEntry("intercom_sent", {
1762
- to: selectedSession.name || selectedSession.id,
1763
- message: { text: result.text },
1764
- messageId: result.messageId,
1765
- timestamp: Date.now(),
1766
- });
1767
- notifyIfLive(ctx, `Message sent to ${targetLabel}`, "info", overlayGeneration);
1768
- }
1769
- }
1770
-
1771
- pi.registerCommand("intercom", {
1772
- description: "Open session intercom overlay",
1773
- handler: async (_args, ctx) => openIntercomOverlay(ctx),
1774
- });
1775
-
1776
- pi.registerShortcut("alt+m", {
1777
- description: "Open session intercom",
1778
- handler: async (ctx) => openIntercomOverlay(ctx),
1779
- });
335
+ promptSnippet: "Use to coordinate with other local pi sessions: list peers, send updates, ask for help, or check intercom connectivity.",
336
+ parameters: Type.Object({
337
+ action: Type.String({ description: "Action: 'list', 'send', 'ask', 'reply', 'pending', or 'status'" }),
338
+ to: Type.Optional(Type.String({ description: "Target session name or ID (for 'send', 'ask', or disambiguating 'reply')" })),
339
+ message: Type.Optional(Type.String({ description: "Message to send (for 'send', 'ask', or 'reply' action)" })),
340
+ attachments: Type.Optional(Type.Array(Type.Object({
341
+ type: Type.Union([Type.Literal("file"), Type.Literal("snippet"), Type.Literal("context")]),
342
+ name: Type.String(),
343
+ content: Type.String(),
344
+ language: Type.Optional(Type.String()),
345
+ }))),
346
+ replyTo: Type.Optional(Type.String({ description: "Message ID to reply to (for threading or responding to an 'ask')" })),
347
+ }),
348
+ execute: (...args) => executeHeavyTool(loadHeavy, "intercom", args),
349
+ renderResult: (...args) => renderHeavyToolResult(loadedHeavy, "intercom", args),
350
+ renderCall(args, theme) {
351
+ const input = args as { action?: string; to?: string; message?: string };
352
+ const target = input.to ? ` ${input.to}` : "";
353
+ return new Text(theme.fg("toolTitle", theme.bold(`intercom ${input.action ?? ""}`)) + theme.fg("accent", target), 0, 0);
354
+ },
355
+ });
356
+
357
+ if (hasSubagentIntercomEnv()) {
358
+ pi.registerTool({
359
+ name: "contact_supervisor",
360
+ label: "Contact Supervisor",
361
+ description: "Subagent-only tool for contacting the supervisor agent that delegated this task. Use need_decision when blocked, uncertain, needing approval, or facing a product/API/scope decision before continuing; this waits for the supervisor's reply. Use interview_request when multiple structured questions need supervisor answers; this also waits for a reply. Use progress_update only for meaningful progress or unexpected discoveries that change the plan; this does not wait for a reply. Do not use for routine completion handoffs.",
362
+ promptSnippet: "Subagent-only: contact the supervisor for decisions, structured interviews, or meaningful plan-changing updates. Do not use for routine completion handoffs.",
363
+ promptGuidelines: [
364
+ "Use contact_supervisor with reason='need_decision' when a subagent is blocked, uncertain, needs approval, or faces a product/API/scope decision before continuing.",
365
+ "Use contact_supervisor with reason='interview_request' when the child needs multiple structured answers from the supervisor in one blocking exchange.",
366
+ "Use contact_supervisor with reason='progress_update' only for meaningful progress or unexpected discoveries that change the plan.",
367
+ "Do not use contact_supervisor for routine completion handoffs; return the final subagent result normally.",
368
+ ],
369
+ parameters: Type.Object({
370
+ reason: Type.String({
371
+ enum: ["need_decision", "progress_update", "interview_request"],
372
+ description: "Contact reason: 'need_decision' waits for a reply; 'interview_request' sends structured questions and waits for a reply; 'progress_update' sends a non-blocking update",
373
+ }),
374
+ message: Type.Optional(Type.String({
375
+ description: "Decision request, optional interview note, or meaningful progress update for the supervisor",
376
+ })),
377
+ interview: Type.Optional(Type.Object({
378
+ title: Type.Optional(Type.String()),
379
+ description: Type.Optional(Type.String()),
380
+ questions: Type.Array(Type.Object({
381
+ id: Type.String(),
382
+ type: Type.String({ description: "Question type: single, multi, text, image, or info" }),
383
+ question: Type.String(),
384
+ options: Type.Optional(Type.Array(Type.Unknown())),
385
+ context: Type.Optional(Type.String()),
386
+ })),
387
+ }, { description: "Structured interview request for reason='interview_request'" })),
388
+ }),
389
+ execute: (...args) => executeHeavyTool(loadHeavy, "contact_supervisor", args),
390
+ renderResult: (...args) => renderHeavyToolResult(loadedHeavy, "contact_supervisor", args),
391
+ renderCall(args, theme) {
392
+ const input = args as { reason?: string; message?: string; interview?: { title?: string } };
393
+ const reason = input.reason ?? "contact";
394
+ const title = input.interview?.title?.trim();
395
+ const preview = input.message?.trim();
396
+ let text = theme.fg("toolTitle", theme.bold("contact_supervisor ")) + theme.fg(reason === "need_decision" ? "warning" : reason === "progress_update" ? "muted" : "accent", reason);
397
+ if (title) text += " " + theme.fg("accent", title);
398
+ if (preview) text += "\n " + theme.fg("dim", preview.length > 96 ? `${preview.slice(0, 93)}...` : preview);
399
+ return new Text(text, 0, 0);
400
+ },
401
+ });
402
+ }
403
+
404
+ pi.registerCommand("intercom", {
405
+ description: "Open session intercom overlay",
406
+ handler: (args, ctx) => runHeavyCommand(loadHeavy, args, ctx),
407
+ });
1780
408
  }