@bastani/atomic 0.8.25-alpha.1 → 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.
- package/CHANGELOG.md +17 -0
- package/dist/builtin/intercom/CHANGELOG.md +12 -0
- package/dist/builtin/intercom/index-heavy.ts +1754 -0
- package/dist/builtin/intercom/index.ts +374 -1746
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/intercom/result-renderers.ts +77 -0
- package/dist/builtin/mcp/CHANGELOG.md +16 -0
- package/dist/builtin/mcp/index.ts +151 -57
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +12 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/CHANGELOG.md +12 -0
- package/dist/builtin/web-access/index-heavy.ts +2060 -0
- package/dist/builtin/web-access/index.ts +182 -2274
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/web-access/result-renderers.ts +364 -0
- package/dist/builtin/workflows/CHANGELOG.md +15 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/index.ts +13 -3
- package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +53 -2
- package/dist/builtin/workflows/src/tui/inline-form-overlay.ts +12 -3
- package/dist/builtin/workflows/src/tui/inline-form-store.ts +17 -6
- package/dist/core/agent-session-services.d.ts.map +1 -1
- package/dist/core/agent-session-services.js +13 -0
- package/dist/core/agent-session-services.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +7 -0
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +13 -1
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +17 -0
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/timings.d.ts +9 -0
- package/dist/core/timings.d.ts.map +1 -1
- package/dist/core/timings.js +28 -1
- package/dist/core/timings.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +4 -2
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts +1 -0
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js +36 -4
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +19 -7
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
208
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
}
|