@ctliz/agent-intercom-claude 0.12.0-connect.3
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/.claude-plugin/plugin.json +26 -0
- package/.mcp.json +10 -0
- package/CHANGELOG.md +62 -0
- package/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +25 -0
- package/README.md +506 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +125 -0
- package/broker/boss-contracts.ts +267 -0
- package/broker/broker.ts +2028 -0
- package/broker/client.ts +1048 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/spawn.ts +463 -0
- package/broker/validation.ts +132 -0
- package/claude/cci.ts +512 -0
- package/claude/ccim.ts +31 -0
- package/claude/cli-runner.ts +220 -0
- package/claude/contact.ts +33 -0
- package/claude/inbox-monitor.ts +49 -0
- package/claude/inbox.ts +77 -0
- package/claude/mcp-protocol.ts +253 -0
- package/claude/native-bridge.ts +219 -0
- package/claude/native-protocol.ts +346 -0
- package/claude/permission-policy.ts +287 -0
- package/claude/runtime.ts +510 -0
- package/claude/server.ts +69 -0
- package/claude/team.ts +348 -0
- package/claude/transport.ts +97 -0
- package/claude/worker-config.ts +198 -0
- package/claude/worker-daemon.ts +418 -0
- package/commands/intercom-id.md +9 -0
- package/commands/intercom.md +8 -0
- package/config.ts +195 -0
- package/dist/broker.mjs +2530 -0
- package/dist/cci.mjs +3607 -0
- package/dist/ccim.mjs +3621 -0
- package/dist/claude-server.mjs +2730 -0
- package/dist/inbox-monitor.mjs +68 -0
- package/dist/worker-daemon.mjs +2797 -0
- package/durable-json.ts +25 -0
- package/licenses/MIT-pi-claude-link.txt +21 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/monitors/monitors.json +8 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +93 -0
- package/protocol-v4/contract.ts +45 -0
- package/provider/protected-service.ts +162 -0
- package/provider/provider.mjs +34 -0
- package/scripts/build.mjs +71 -0
- package/scripts/verify-core-provenance.mjs +64 -0
- package/scripts/verify-packed-runtime.mjs +140 -0
- package/skills/claude-intercom/SKILL.md +90 -0
- package/types.ts +212 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { runClaudeTurn } from "./cli-runner.ts";
|
|
5
|
+
import {
|
|
6
|
+
loadWorkerConfig,
|
|
7
|
+
loadWorkerState,
|
|
8
|
+
saveWorkerState,
|
|
9
|
+
type WorkerAgentConfig,
|
|
10
|
+
type WorkerConfig,
|
|
11
|
+
type WorkerState,
|
|
12
|
+
} from "./worker-config.ts";
|
|
13
|
+
import { IntercomClient } from "../broker/client.ts";
|
|
14
|
+
import { spawnBrokerIfNeeded } from "../broker/spawn.ts";
|
|
15
|
+
import { INTERCOM_SCOPE_ENV, intercomScopeIdFromEnvForRegistration, parseIntercomScopeIdForRegistration } from "../protocol-v4/contract.ts";
|
|
16
|
+
import { loadConfig } from "../config.ts";
|
|
17
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
18
|
+
import { formatAttachments, formatSessionDisplay } from "./runtime.ts";
|
|
19
|
+
import { copyTextToClipboard, formatContactInstruction } from "./contact.ts";
|
|
20
|
+
import { resolveClaudeIntercomTransport } from "./transport.ts";
|
|
21
|
+
|
|
22
|
+
const MAX_WAKES_PER_MINUTE = 20;
|
|
23
|
+
|
|
24
|
+
export type WorkerActivity =
|
|
25
|
+
| { type: "started"; agent: WorkerAgentConfig; from: SessionInfo; message: Message }
|
|
26
|
+
| { type: "completed"; agent: WorkerAgentConfig; from: SessionInfo; message: Message; result: string; sessionId: string | null }
|
|
27
|
+
| { type: "error"; agent: WorkerAgentConfig; from: SessionInfo; message: Message; error: string };
|
|
28
|
+
|
|
29
|
+
export type WorkerActivityReporter = (activity: WorkerActivity) => void;
|
|
30
|
+
|
|
31
|
+
export interface VirtualClaudeAgentOptions {
|
|
32
|
+
client?: IntercomClient;
|
|
33
|
+
prepareConnection?: () => Promise<void>;
|
|
34
|
+
reconnectDelays?: number[];
|
|
35
|
+
scopeId?: string;
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ClaudeWorkerDaemonOptions {
|
|
40
|
+
scopeId?: string;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatWorkerActivity(activity: WorkerActivity): string {
|
|
45
|
+
const sender = formatSessionDisplay(activity.from);
|
|
46
|
+
if (activity.type === "started") {
|
|
47
|
+
return `\n[intercom] Wake from ${sender}: ${activity.message.content.text}\n[intercom] Claude is working…\n`;
|
|
48
|
+
}
|
|
49
|
+
if (activity.type === "completed") {
|
|
50
|
+
const result = activity.result || "(Claude finished without a final message)";
|
|
51
|
+
return `[intercom] Completed wake from ${sender} (session ${activity.sessionId ?? "none"})\n${result}\n`;
|
|
52
|
+
}
|
|
53
|
+
return `[intercom] Wake from ${sender} failed: ${activity.error}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reportToTerminal(activity: WorkerActivity): void {
|
|
57
|
+
process.stderr.write(formatWorkerActivity(activity));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatMessage(from: SessionInfo, message: Message, agent: WorkerAgentConfig): string {
|
|
61
|
+
const replyInstruction = message.expectsReply
|
|
62
|
+
? [
|
|
63
|
+
"",
|
|
64
|
+
"",
|
|
65
|
+
"The sender is blocking on your reply.",
|
|
66
|
+
"Your FINAL assistant message will be sent back to them automatically as the reply.",
|
|
67
|
+
"Do not try to use intercom tools to answer; just put your answer in your final message.",
|
|
68
|
+
].join("\n")
|
|
69
|
+
: "";
|
|
70
|
+
const attachments = formatAttachments(message.content.attachments);
|
|
71
|
+
const custom = agent.instructions ? `\n\nAgent instructions:\n${agent.instructions}` : "";
|
|
72
|
+
return [
|
|
73
|
+
`Intercom message for ${agent.name}.`,
|
|
74
|
+
`From: ${formatSessionDisplay(from)} (${from.id})`,
|
|
75
|
+
`Message id: ${message.id}`,
|
|
76
|
+
"",
|
|
77
|
+
message.content.text,
|
|
78
|
+
attachments,
|
|
79
|
+
custom,
|
|
80
|
+
replyInstruction,
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class VirtualClaudeAgent {
|
|
85
|
+
private client: IntercomClient;
|
|
86
|
+
private sessionId: string | null;
|
|
87
|
+
private messageQueue: Promise<void> = Promise.resolve();
|
|
88
|
+
private wakeTimestamps: number[] = [];
|
|
89
|
+
private connectPromise: Promise<void> | null = null;
|
|
90
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
91
|
+
private reconnectAttempt = 0;
|
|
92
|
+
private reconnectEnabled = true;
|
|
93
|
+
private readonly intercomStartedAt = Date.now();
|
|
94
|
+
private readonly prepareConnection: () => Promise<void>;
|
|
95
|
+
private readonly reconnectDelays: number[];
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly agent: WorkerAgentConfig,
|
|
99
|
+
private readonly state: WorkerState,
|
|
100
|
+
private readonly statePath: string,
|
|
101
|
+
private readonly claudeCommand: string,
|
|
102
|
+
private readonly reportActivity: WorkerActivityReporter = reportToTerminal,
|
|
103
|
+
private readonly runTurn: typeof runClaudeTurn = runClaudeTurn,
|
|
104
|
+
options: VirtualClaudeAgentOptions = {},
|
|
105
|
+
) {
|
|
106
|
+
const scopeId = options.scopeId === undefined
|
|
107
|
+
? intercomScopeIdFromEnvForRegistration(options.env ?? process.env)
|
|
108
|
+
: parseIntercomScopeIdForRegistration(options.scopeId);
|
|
109
|
+
const scopeEnv = scopeId === undefined ? {} : { [INTERCOM_SCOPE_ENV]: scopeId };
|
|
110
|
+
this.client = options.client ?? new IntercomClient({ env: scopeEnv });
|
|
111
|
+
this.sessionId = agent.sessionId ?? state.agents[agent.id]?.sessionId ?? null;
|
|
112
|
+
this.prepareConnection = options.prepareConnection ?? (async () => {
|
|
113
|
+
const config = loadConfig();
|
|
114
|
+
await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
|
|
115
|
+
});
|
|
116
|
+
this.reconnectDelays = options.reconnectDelays?.length ? options.reconnectDelays : [250, 500, 1000, 2000, 5000];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get id(): string {
|
|
120
|
+
return this.agent.id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async copyContact(): Promise<string> {
|
|
124
|
+
const sessions = await this.client.listSessions();
|
|
125
|
+
const current = sessions.find((session) => session.id === this.client.sessionId);
|
|
126
|
+
const text = current ? formatContactInstruction(current, sessions) : `Intercom target: ${this.client.sessionId ?? this.agent.id}`;
|
|
127
|
+
return copyTextToClipboard(text) ? `Copied ${text.replace(/\n/g, "; ")}` : `Clipboard unavailable. ${text.replace(/\n/g, "; ")}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async listPeers(): Promise<SessionInfo[]> {
|
|
131
|
+
const sessions = await this.client.listSessions();
|
|
132
|
+
return sessions.filter((session) => session.id !== this.client.sessionId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async sendMessage(to: string, text: string): Promise<string> {
|
|
136
|
+
const sessions = await this.client.listSessions();
|
|
137
|
+
const normalized = to.toLowerCase();
|
|
138
|
+
const matches = sessions.filter((session) =>
|
|
139
|
+
session.id === to
|
|
140
|
+
|| session.id.startsWith(to)
|
|
141
|
+
|| session.name?.toLowerCase() === normalized
|
|
142
|
+
);
|
|
143
|
+
if (matches.length > 1) {
|
|
144
|
+
throw new Error(`Multiple intercom sessions match "${to}"; choose a full ID.`);
|
|
145
|
+
}
|
|
146
|
+
const target = matches[0]?.id ?? to;
|
|
147
|
+
const result = await this.client.send(target, { text });
|
|
148
|
+
if (!result.delivered) {
|
|
149
|
+
throw new Error(result.reason ?? `Intercom session "${to}" is unavailable.`);
|
|
150
|
+
}
|
|
151
|
+
return `Message sent to ${matches[0]?.name || to}.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async start(): Promise<void> {
|
|
155
|
+
this.reconnectEnabled = true;
|
|
156
|
+
this.client.on("message", (from: SessionInfo, message: Message, deliveryId: string) => {
|
|
157
|
+
this.routeMessage(from, message);
|
|
158
|
+
this.client.acknowledgeMessage(deliveryId);
|
|
159
|
+
});
|
|
160
|
+
this.client.on("error", (error: Error) => {
|
|
161
|
+
process.stderr.write(`worker ${this.agent.id}: ${error.message}\n`);
|
|
162
|
+
});
|
|
163
|
+
this.client.on("disconnected", () => {
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
});
|
|
166
|
+
await this.connectIntercom();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async connectIntercom(): Promise<void> {
|
|
170
|
+
this.clearReconnectTimer();
|
|
171
|
+
if (this.client.isConnected()) return;
|
|
172
|
+
if (this.connectPromise) return this.connectPromise;
|
|
173
|
+
this.connectPromise = (async () => {
|
|
174
|
+
await this.prepareConnection();
|
|
175
|
+
await this.client.connect({
|
|
176
|
+
name: this.agent.name,
|
|
177
|
+
cwd: this.agent.cwd,
|
|
178
|
+
model: this.agent.model ?? "claude",
|
|
179
|
+
pid: process.pid,
|
|
180
|
+
startedAt: this.intercomStartedAt,
|
|
181
|
+
lastActivity: Date.now(),
|
|
182
|
+
status: "idle",
|
|
183
|
+
}, this.agent.id);
|
|
184
|
+
this.reconnectAttempt = 0;
|
|
185
|
+
})();
|
|
186
|
+
try {
|
|
187
|
+
await this.connectPromise;
|
|
188
|
+
} finally {
|
|
189
|
+
this.connectPromise = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private scheduleReconnect(): void {
|
|
194
|
+
if (!this.reconnectEnabled || this.reconnectTimer) return;
|
|
195
|
+
const delay = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)]!;
|
|
196
|
+
this.reconnectTimer = setTimeout(() => {
|
|
197
|
+
this.reconnectTimer = null;
|
|
198
|
+
void this.connectIntercom().then(() => {
|
|
199
|
+
if (!this.client.isConnected()) {
|
|
200
|
+
this.reconnectAttempt += 1;
|
|
201
|
+
this.scheduleReconnect();
|
|
202
|
+
}
|
|
203
|
+
}).catch((error) => {
|
|
204
|
+
this.reconnectAttempt += 1;
|
|
205
|
+
process.stderr.write(`worker ${this.agent.id}: reconnect failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
206
|
+
this.scheduleReconnect();
|
|
207
|
+
});
|
|
208
|
+
}, delay);
|
|
209
|
+
this.reconnectTimer.unref?.();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private clearReconnectTimer(): void {
|
|
213
|
+
if (!this.reconnectTimer) return;
|
|
214
|
+
clearTimeout(this.reconnectTimer);
|
|
215
|
+
this.reconnectTimer = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async stop(): Promise<void> {
|
|
219
|
+
this.reconnectEnabled = false;
|
|
220
|
+
this.clearReconnectTimer();
|
|
221
|
+
if (this.connectPromise) {
|
|
222
|
+
try {
|
|
223
|
+
await this.connectPromise;
|
|
224
|
+
} catch {
|
|
225
|
+
// A failed in-progress connection is already closed.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await this.client.disconnect();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private routeMessage(from: SessionInfo, message: Message): void {
|
|
232
|
+
if (this.isRateLimited()) {
|
|
233
|
+
process.stderr.write(`worker ${this.agent.id}: rate limited, dropping message from ${from.id}\n`);
|
|
234
|
+
if (message.expectsReply) {
|
|
235
|
+
void this.client.send(from.id, {
|
|
236
|
+
text: "This worker is rate limited (too many wakes this minute). Please try again shortly.",
|
|
237
|
+
replyTo: message.id,
|
|
238
|
+
}).catch((error) => {
|
|
239
|
+
process.stderr.write(`worker ${this.agent.id}: rate-limit reply failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.messageQueue = this.messageQueue
|
|
246
|
+
.catch(() => undefined)
|
|
247
|
+
.then(() => this.handleMessage(from, message));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private isRateLimited(): boolean {
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
this.wakeTimestamps = this.wakeTimestamps.filter((timestamp) => now - timestamp < 60000);
|
|
253
|
+
if (this.wakeTimestamps.length >= MAX_WAKES_PER_MINUTE) return true;
|
|
254
|
+
this.wakeTimestamps.push(now);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async handleMessage(from: SessionInfo, message: Message): Promise<void> {
|
|
259
|
+
try {
|
|
260
|
+
const prompt = formatMessage(from, message, this.agent);
|
|
261
|
+
this.client.updatePresence({ status: "active" });
|
|
262
|
+
this.reportActivity({ type: "started", agent: this.agent, from, message });
|
|
263
|
+
|
|
264
|
+
const result = await this.runTurn({
|
|
265
|
+
prompt,
|
|
266
|
+
cwd: this.agent.cwd,
|
|
267
|
+
sessionId: this.sessionId ?? undefined,
|
|
268
|
+
model: this.agent.model,
|
|
269
|
+
appendSystemPrompt: this.agent.instructions,
|
|
270
|
+
// Historical worker configs omitted both permission fields. Preserve
|
|
271
|
+
// their noninteractive behavior; hardened roles are normalized to an
|
|
272
|
+
// explicit plan mode before a VirtualClaudeAgent can be constructed.
|
|
273
|
+
permissionMode: this.agent.permissionMode ?? "bypassPermissions",
|
|
274
|
+
dangerouslySkipPermissions: this.agent.dangerouslySkipPermissions,
|
|
275
|
+
permissionCeiling: this.agent.permissionCeiling,
|
|
276
|
+
addDirs: this.agent.addDirs,
|
|
277
|
+
mcpConfig: this.agent.mcpConfig,
|
|
278
|
+
claudeCommand: this.claudeCommand,
|
|
279
|
+
extraArgs: this.agent.claudeArgs,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (result.sessionId && result.sessionId !== this.sessionId) {
|
|
283
|
+
this.sessionId = result.sessionId;
|
|
284
|
+
this.state.agents[this.agent.id] = { sessionId: result.sessionId, updatedAt: Date.now() };
|
|
285
|
+
saveWorkerState(this.statePath, this.state);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (result.isError) {
|
|
289
|
+
const text = result.result || "Claude exited with an error and no message";
|
|
290
|
+
this.client.updatePresence({ status: `error: ${text}` });
|
|
291
|
+
this.reportActivity({ type: "error", agent: this.agent, from, message, error: text });
|
|
292
|
+
} else {
|
|
293
|
+
this.client.updatePresence({ status: "idle" });
|
|
294
|
+
this.reportActivity({
|
|
295
|
+
type: "completed",
|
|
296
|
+
agent: this.agent,
|
|
297
|
+
from,
|
|
298
|
+
message,
|
|
299
|
+
result: result.result,
|
|
300
|
+
sessionId: this.sessionId,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (message.expectsReply) {
|
|
305
|
+
await this.client.send(from.id, {
|
|
306
|
+
text: result.result || "(the worker finished without a final message)",
|
|
307
|
+
replyTo: message.id,
|
|
308
|
+
});
|
|
309
|
+
process.stderr.write(`worker ${this.agent.id}: replied to ${from.name || from.id} (session ${this.sessionId ?? "none"})\n`);
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
313
|
+
this.client.updatePresence({ status: `error: ${text}` });
|
|
314
|
+
this.reportActivity({ type: "error", agent: this.agent, from, message, error: text });
|
|
315
|
+
if (message.expectsReply) {
|
|
316
|
+
await this.client.send(from.id, {
|
|
317
|
+
text: `Worker error: ${text}`,
|
|
318
|
+
replyTo: message.id,
|
|
319
|
+
}).catch((sendError) => {
|
|
320
|
+
process.stderr.write(`worker ${this.agent.id}: error reply failed: ${sendError instanceof Error ? sendError.message : String(sendError)}\n`);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export class ClaudeWorkerDaemon {
|
|
328
|
+
private agents: VirtualClaudeAgent[] = [];
|
|
329
|
+
private readonly scopeId: string | undefined;
|
|
330
|
+
|
|
331
|
+
constructor(
|
|
332
|
+
private readonly config: WorkerConfig,
|
|
333
|
+
private readonly reportActivity: WorkerActivityReporter = reportToTerminal,
|
|
334
|
+
options: ClaudeWorkerDaemonOptions = {},
|
|
335
|
+
) {
|
|
336
|
+
this.scopeId = options.scopeId === undefined
|
|
337
|
+
? intercomScopeIdFromEnvForRegistration(options.env ?? process.env)
|
|
338
|
+
: parseIntercomScopeIdForRegistration(options.scopeId);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async start(): Promise<void> {
|
|
342
|
+
const intercomConfig = loadConfig();
|
|
343
|
+
await spawnBrokerIfNeeded(intercomConfig.brokerCommand, intercomConfig.brokerArgs);
|
|
344
|
+
const state = loadWorkerState(this.config.statePath);
|
|
345
|
+
const claudeCommand = this.config.claudeCommand ?? "claude";
|
|
346
|
+
this.agents = this.config.agents.map((agent) => {
|
|
347
|
+
const transport = resolveClaudeIntercomTransport({
|
|
348
|
+
requested: agent.transport,
|
|
349
|
+
claudeCommand,
|
|
350
|
+
});
|
|
351
|
+
process.stderr.write(`worker ${agent.id}: ${transport.selected} transport (${transport.reason})\n`);
|
|
352
|
+
return new VirtualClaudeAgent(
|
|
353
|
+
{ ...agent, transport: transport.selected },
|
|
354
|
+
state,
|
|
355
|
+
this.config.statePath,
|
|
356
|
+
claudeCommand,
|
|
357
|
+
this.reportActivity,
|
|
358
|
+
runClaudeTurn,
|
|
359
|
+
{ scopeId: this.scopeId },
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
for (const agent of this.agents) await agent.start();
|
|
363
|
+
process.stderr.write(`claude-intercom worker running ${this.agents.length} agent(s)\n`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async stop(): Promise<void> {
|
|
367
|
+
for (const agent of this.agents) await agent.stop().catch(() => undefined);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async copyPrimaryContact(): Promise<string> {
|
|
371
|
+
if (!this.agents[0]) throw new Error("No Claude intercom agent is running");
|
|
372
|
+
return this.agents[0].copyContact();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async listPrimaryPeers(): Promise<SessionInfo[]> {
|
|
376
|
+
if (!this.agents[0]) throw new Error("No Claude intercom agent is running");
|
|
377
|
+
return this.agents[0].listPeers();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async sendFromPrimary(to: string, text: string): Promise<string> {
|
|
381
|
+
if (!this.agents[0]) throw new Error("No Claude intercom agent is running");
|
|
382
|
+
return this.agents[0].sendMessage(to, text);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function main(): Promise<void> {
|
|
387
|
+
intercomScopeIdFromEnvForRegistration(process.env);
|
|
388
|
+
const configPath = process.argv.includes("--config")
|
|
389
|
+
? process.argv[process.argv.indexOf("--config") + 1]
|
|
390
|
+
: undefined;
|
|
391
|
+
const config = loadWorkerConfig(configPath);
|
|
392
|
+
if (!config.agents.length) throw new Error("Worker config must include at least one agent");
|
|
393
|
+
const daemon = new ClaudeWorkerDaemon(config);
|
|
394
|
+
const stop = () => {
|
|
395
|
+
void daemon.stop().finally(() => process.exit(0));
|
|
396
|
+
};
|
|
397
|
+
process.on("SIGINT", stop);
|
|
398
|
+
process.on("SIGTERM", stop);
|
|
399
|
+
await daemon.start();
|
|
400
|
+
await once(process, "SIGTERM");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// See cci.ts: realpath the invoked path so the `claude-intercom-worker` npm-bin
|
|
404
|
+
// symlink resolves to the real bundle file (worker-daemon.mjs) and triggers main.
|
|
405
|
+
function invokedFileBasename(): string {
|
|
406
|
+
try {
|
|
407
|
+
return process.argv[1] ? basename(realpathSync(process.argv[1])) : "";
|
|
408
|
+
} catch {
|
|
409
|
+
return process.argv[1] ? basename(process.argv[1]) : "";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (invokedFileBasename() === "worker-daemon.ts" || invokedFileBasename() === "worker-daemon.mjs") {
|
|
414
|
+
void main().catch((error) => {
|
|
415
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show this Claude session's stable intercom contact target
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Call `intercom_whoami`. Present the session ID in a short, copyable instruction using this exact shape:
|
|
6
|
+
|
|
7
|
+
`Intercom send ID: <session-id>`
|
|
8
|
+
|
|
9
|
+
Do not add unrelated explanation.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: List intercom peers and send a message to another coding-agent session
|
|
3
|
+
argument-hint: [target and message]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use the claude-intercom MCP tools to help the user send a message.
|
|
7
|
+
|
|
8
|
+
First call `intercom_list` with `include_self: false`. If `$ARGUMENTS` clearly contains both a target and a message, resolve the target from that list and call `intercom_send`. Otherwise, show the connected sessions and ask the user which session to contact and what to send. Never guess a target or message. Use non-blocking `intercom_send` unless the user explicitly asks to wait for an answer, in which case use `intercom_ask`.
|
package/config.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 45 * 1000;
|
|
6
|
+
export const MAX_ASK_TIMEOUT_MS = 120 * 1000;
|
|
7
|
+
|
|
8
|
+
export function validateAskTimeoutMs(value: unknown, name = "timeout_ms"): number {
|
|
9
|
+
if (!Number.isSafeInteger(value) || typeof value !== "number" || value <= 0) {
|
|
10
|
+
throw new Error(`${name} must be a positive integer number of milliseconds`);
|
|
11
|
+
}
|
|
12
|
+
if (value > MAX_ASK_TIMEOUT_MS) {
|
|
13
|
+
throw new Error(`${name} must be ${MAX_ASK_TIMEOUT_MS} ms or less; use intercom_send plus intercom_pending for longer-running work`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getAskTimeoutMs(): number {
|
|
19
|
+
const raw = process.env.PI_INTERCOM_ASK_TIMEOUT_MS;
|
|
20
|
+
if (raw === undefined || raw.trim() === "") {
|
|
21
|
+
return DEFAULT_ASK_TIMEOUT_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const value = Number(raw);
|
|
25
|
+
return validateAskTimeoutMs(value, "PI_INTERCOM_ASK_TIMEOUT_MS");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InboundForkHandlersConfig {
|
|
29
|
+
/** Route inbound intercom messages to background fork handlers (default: true) */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
|
|
32
|
+
/** When to fork: automatically while parent is busy/queued, only while busy, or for all inbound messages */
|
|
33
|
+
when: "auto" | "busy" | "always";
|
|
34
|
+
|
|
35
|
+
/** Parent notification policy for launched handlers */
|
|
36
|
+
notify: "ack-and-summary" | "summary" | "none";
|
|
37
|
+
|
|
38
|
+
/** Optional Pi executable override for handler launch */
|
|
39
|
+
piCommand?: string;
|
|
40
|
+
|
|
41
|
+
/** Trigger a parent turn when the handler summary arrives (default: auto; ignored when notify is "none") */
|
|
42
|
+
triggerParentOnSummary: boolean | "auto";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IntercomConfig {
|
|
46
|
+
/** Broker command used to spawn the broker process (e.g. "npx" or "bun") */
|
|
47
|
+
brokerCommand: string;
|
|
48
|
+
|
|
49
|
+
/** Arguments passed to the broker command before the broker script path */
|
|
50
|
+
brokerArgs: string[];
|
|
51
|
+
|
|
52
|
+
/** Require confirmation before non-reply sends from interactive sessions */
|
|
53
|
+
confirmSend: boolean;
|
|
54
|
+
|
|
55
|
+
/** Optional custom status suffix shown after automatic lifecycle status */
|
|
56
|
+
status?: string;
|
|
57
|
+
|
|
58
|
+
/** Enable/disable intercom (default: true) */
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
|
|
61
|
+
/** Show reply hint in incoming messages (default: true) */
|
|
62
|
+
replyHint: boolean;
|
|
63
|
+
|
|
64
|
+
/** Optional inbound background fork-handler routing */
|
|
65
|
+
inboundForkHandlers: InboundForkHandlersConfig;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getConfigPath(): string {
|
|
69
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR
|
|
70
|
+
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
71
|
+
: join(homedir(), ".pi", "agent");
|
|
72
|
+
return join(agentDir, "intercom", "config.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const defaults: IntercomConfig = {
|
|
76
|
+
brokerCommand: "npx",
|
|
77
|
+
brokerArgs: ["--no-install", "tsx"],
|
|
78
|
+
confirmSend: false,
|
|
79
|
+
enabled: true,
|
|
80
|
+
replyHint: true,
|
|
81
|
+
inboundForkHandlers: {
|
|
82
|
+
enabled: true,
|
|
83
|
+
when: "auto",
|
|
84
|
+
notify: "summary",
|
|
85
|
+
triggerParentOnSummary: "auto",
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function loadConfig(): IntercomConfig {
|
|
90
|
+
const configPath = getConfigPath();
|
|
91
|
+
if (!existsSync(configPath)) {
|
|
92
|
+
return { ...defaults };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
97
|
+
const parsed: unknown = JSON.parse(raw);
|
|
98
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
99
|
+
throw new Error("Config must be a JSON object");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parsedConfig = parsed as Record<string, unknown>;
|
|
103
|
+
const config: IntercomConfig = { ...defaults };
|
|
104
|
+
|
|
105
|
+
if (Object.hasOwn(parsedConfig, "brokerCommand")) {
|
|
106
|
+
if (typeof parsedConfig.brokerCommand !== "string") {
|
|
107
|
+
throw new Error(`"brokerCommand" must be a string`);
|
|
108
|
+
}
|
|
109
|
+
const brokerCommand = parsedConfig.brokerCommand.trim();
|
|
110
|
+
if (!brokerCommand) {
|
|
111
|
+
throw new Error(`"brokerCommand" must not be empty`);
|
|
112
|
+
}
|
|
113
|
+
config.brokerCommand = brokerCommand;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Object.hasOwn(parsedConfig, "brokerArgs")) {
|
|
117
|
+
if (!Array.isArray(parsedConfig.brokerArgs)) {
|
|
118
|
+
throw new Error(`"brokerArgs" must be an array`);
|
|
119
|
+
}
|
|
120
|
+
const brokerArgs: string[] = [];
|
|
121
|
+
for (const arg of parsedConfig.brokerArgs) {
|
|
122
|
+
if (typeof arg !== "string") {
|
|
123
|
+
throw new Error(`"brokerArgs" items must be strings`);
|
|
124
|
+
}
|
|
125
|
+
brokerArgs.push(arg);
|
|
126
|
+
}
|
|
127
|
+
config.brokerArgs = brokerArgs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Object.hasOwn(parsedConfig, "confirmSend")) {
|
|
131
|
+
if (typeof parsedConfig.confirmSend !== "boolean") {
|
|
132
|
+
throw new Error(`"confirmSend" must be a boolean`);
|
|
133
|
+
}
|
|
134
|
+
config.confirmSend = parsedConfig.confirmSend;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Object.hasOwn(parsedConfig, "enabled")) {
|
|
138
|
+
if (typeof parsedConfig.enabled !== "boolean") {
|
|
139
|
+
throw new Error(`"enabled" must be a boolean`);
|
|
140
|
+
}
|
|
141
|
+
config.enabled = parsedConfig.enabled;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (Object.hasOwn(parsedConfig, "replyHint")) {
|
|
145
|
+
if (typeof parsedConfig.replyHint !== "boolean") {
|
|
146
|
+
throw new Error(`"replyHint" must be a boolean`);
|
|
147
|
+
}
|
|
148
|
+
config.replyHint = parsedConfig.replyHint;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (Object.hasOwn(parsedConfig, "status")) {
|
|
152
|
+
if (typeof parsedConfig.status !== "string") {
|
|
153
|
+
throw new Error(`"status" must be a string`);
|
|
154
|
+
}
|
|
155
|
+
config.status = parsedConfig.status;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (Object.hasOwn(parsedConfig, "inboundForkHandlers")) {
|
|
159
|
+
if (typeof parsedConfig.inboundForkHandlers !== "object" || parsedConfig.inboundForkHandlers === null || Array.isArray(parsedConfig.inboundForkHandlers)) {
|
|
160
|
+
throw new Error(`"inboundForkHandlers" must be an object`);
|
|
161
|
+
}
|
|
162
|
+
const forkConfig = parsedConfig.inboundForkHandlers as Record<string, unknown>;
|
|
163
|
+
config.inboundForkHandlers = { ...defaults.inboundForkHandlers };
|
|
164
|
+
if (Object.hasOwn(forkConfig, "enabled")) {
|
|
165
|
+
if (typeof forkConfig.enabled !== "boolean") throw new Error(`"inboundForkHandlers.enabled" must be a boolean`);
|
|
166
|
+
config.inboundForkHandlers.enabled = forkConfig.enabled;
|
|
167
|
+
}
|
|
168
|
+
if (Object.hasOwn(forkConfig, "when")) {
|
|
169
|
+
if (forkConfig.when !== "auto" && forkConfig.when !== "busy" && forkConfig.when !== "always") throw new Error(`"inboundForkHandlers.when" must be "auto", "busy", or "always"`);
|
|
170
|
+
config.inboundForkHandlers.when = forkConfig.when;
|
|
171
|
+
}
|
|
172
|
+
if (Object.hasOwn(forkConfig, "notify")) {
|
|
173
|
+
if (forkConfig.notify !== "ack-and-summary" && forkConfig.notify !== "summary" && forkConfig.notify !== "none") throw new Error(`"inboundForkHandlers.notify" must be "ack-and-summary", "summary", or "none"`);
|
|
174
|
+
config.inboundForkHandlers.notify = forkConfig.notify;
|
|
175
|
+
}
|
|
176
|
+
if (Object.hasOwn(forkConfig, "piCommand")) {
|
|
177
|
+
if (typeof forkConfig.piCommand !== "string") throw new Error(`"inboundForkHandlers.piCommand" must be a string`);
|
|
178
|
+
const piCommand = forkConfig.piCommand.trim();
|
|
179
|
+
if (piCommand) config.inboundForkHandlers.piCommand = piCommand;
|
|
180
|
+
}
|
|
181
|
+
if (Object.hasOwn(forkConfig, "triggerParentOnSummary")) {
|
|
182
|
+
const triggerParentOnSummary = forkConfig.triggerParentOnSummary;
|
|
183
|
+
if (typeof triggerParentOnSummary !== "boolean" && triggerParentOnSummary !== "auto") {
|
|
184
|
+
throw new Error(`"inboundForkHandlers.triggerParentOnSummary" must be a boolean or "auto"`);
|
|
185
|
+
}
|
|
186
|
+
config.inboundForkHandlers.triggerParentOnSummary = triggerParentOnSummary;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return config;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error(`Failed to load intercom config at ${configPath}:`, error);
|
|
193
|
+
return { ...defaults };
|
|
194
|
+
}
|
|
195
|
+
}
|