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