@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,510 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "crypto";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { basename } from "path";
|
|
4
|
+
import { cwd as processCwd } from "process";
|
|
5
|
+
import { IntercomClient } from "../broker/client.ts";
|
|
6
|
+
import { intercomScopeIdFromEnvForRegistration } from "../protocol-v4/contract.ts";
|
|
7
|
+
import { spawnBrokerIfNeeded } from "../broker/spawn.ts";
|
|
8
|
+
import { getAskTimeoutMs, loadConfig } from "../config.ts";
|
|
9
|
+
import { appendInboxMessage } from "./inbox.ts";
|
|
10
|
+
import type { Attachment, Message, SessionInfo } from "../types.ts";
|
|
11
|
+
import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
|
|
12
|
+
|
|
13
|
+
export interface ClaudeRuntimeIdentity {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
model: string;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PendingInboundMessage {
|
|
22
|
+
from: SessionInfo;
|
|
23
|
+
message: Message;
|
|
24
|
+
receivedAt: number;
|
|
25
|
+
read: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReplyWhich = "oldest" | "latest";
|
|
29
|
+
|
|
30
|
+
function matchesPendingSender(entry: PendingInboundMessage, to: string): boolean {
|
|
31
|
+
return entry.from.id === to
|
|
32
|
+
|| entry.from.name?.toLowerCase() === to.toLowerCase()
|
|
33
|
+
|| entry.from.id.startsWith(to);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function selectPendingAsk(entries: PendingInboundMessage[], to?: string, which?: ReplyWhich): PendingInboundMessage {
|
|
37
|
+
const sorted = [...entries].sort((a, b) => a.receivedAt - b.receivedAt);
|
|
38
|
+
if (sorted.length === 0) throw new Error("No matching pending ask. Call intercom_pending to inspect unresolved asks.");
|
|
39
|
+
const matches = to ? sorted.filter((entry) => matchesPendingSender(entry, to)) : sorted;
|
|
40
|
+
if (matches.length === 0) throw new Error(`No pending ask from "${to}".`);
|
|
41
|
+
if (matches.length === 1) return matches[0]!;
|
|
42
|
+
if (!to && new Set(matches.map((entry) => entry.from.id)).size > 1) {
|
|
43
|
+
throw new Error("Multiple pending asks — specify `to` using a sender from intercom_pending.");
|
|
44
|
+
}
|
|
45
|
+
if (!which) {
|
|
46
|
+
const sender = to ? ` from "${to}"` : "";
|
|
47
|
+
throw new Error(`Multiple pending asks${sender} — specify \`which\` as \`oldest\` or \`latest\`.`);
|
|
48
|
+
}
|
|
49
|
+
return which === "oldest" ? matches[0]! : matches[matches.length - 1]!;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pendingSelector(entries: PendingInboundMessage[], entry: PendingInboundMessage): "oldest" | "latest" | "queued" | undefined {
|
|
53
|
+
const sameSender = entries.filter((candidate) => candidate.from.id === entry.from.id);
|
|
54
|
+
if (sameSender.length <= 1) return undefined;
|
|
55
|
+
const index = sameSender.findIndex((candidate) => candidate.message.id === entry.message.id);
|
|
56
|
+
if (index === 0) return "oldest";
|
|
57
|
+
if (index === sameSender.length - 1) return "latest";
|
|
58
|
+
return "queued";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function publicPendingEntry(entry: PendingInboundMessage, selector?: string): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
from: {
|
|
64
|
+
id: entry.from.id,
|
|
65
|
+
name: entry.from.name,
|
|
66
|
+
origin: entry.from.origin ?? "local",
|
|
67
|
+
...(entry.from.remoteHostId ? { remote_host_id: entry.from.remoteHostId } : {}),
|
|
68
|
+
...(entry.from.parentSessionId ? { parent_session_id: entry.from.parentSessionId } : {}),
|
|
69
|
+
...(entry.from.generation ? { generation: entry.from.generation } : {}),
|
|
70
|
+
},
|
|
71
|
+
received_at: entry.receivedAt,
|
|
72
|
+
read: entry.read,
|
|
73
|
+
text: entry.message.content.text,
|
|
74
|
+
attachments: entry.message.content.attachments,
|
|
75
|
+
expects_reply: entry.message.expectsReply,
|
|
76
|
+
...(selector ? { selector } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ToolResult {
|
|
81
|
+
content: Array<{ type: "text"; text: string }>;
|
|
82
|
+
structuredContent?: Record<string, unknown>;
|
|
83
|
+
isError?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ReplyWaiter {
|
|
87
|
+
from: string;
|
|
88
|
+
replyTo: string;
|
|
89
|
+
resolve: (message: Message) => void;
|
|
90
|
+
reject: (error: Error) => void;
|
|
91
|
+
timeout: NodeJS.Timeout;
|
|
92
|
+
cleanup?: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function shortHash(value: string): string {
|
|
96
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildClaudeRuntimeIdentity(env: NodeJS.ProcessEnv = process.env, cwd = env.PWD || processCwd(), pid = process.pid): ClaudeRuntimeIdentity {
|
|
100
|
+
const sessionId = env.CLAUDE_INTERCOM_SESSION_ID?.trim()
|
|
101
|
+
|| env.CLAUDE_PEER_ID?.trim()
|
|
102
|
+
|| `claude-${pid}-${shortHash(cwd)}`;
|
|
103
|
+
const cwdName = basename(cwd) || "workspace";
|
|
104
|
+
const name = env.CLAUDE_INTERCOM_NAME?.trim()
|
|
105
|
+
|| env.CLAUDE_PEER_NAME?.trim()
|
|
106
|
+
|| `claude-${cwdName}-${pid}`;
|
|
107
|
+
return {
|
|
108
|
+
sessionId,
|
|
109
|
+
name,
|
|
110
|
+
cwd,
|
|
111
|
+
model: env.CLAUDE_INTERCOM_MODEL?.trim() || env.CLAUDE_MODEL?.trim() || "claude",
|
|
112
|
+
startedAt: Date.now(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatAttachments(attachments: Attachment[] | undefined): string {
|
|
117
|
+
if (!attachments?.length) return "";
|
|
118
|
+
return attachments.map((attachment) => {
|
|
119
|
+
if (attachment.language) {
|
|
120
|
+
return `\n\n---\nAttachment: ${attachment.name}\n~~~${attachment.language}\n${attachment.content}\n~~~`;
|
|
121
|
+
}
|
|
122
|
+
return `\n\n---\nAttachment: ${attachment.name}\n${attachment.content}`;
|
|
123
|
+
}).join("");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveSessionTarget(sessions: SessionInfo[], nameOrId: string): string | null {
|
|
127
|
+
const byId = sessions.find((session) => session.id === nameOrId);
|
|
128
|
+
if (byId) return byId.id;
|
|
129
|
+
|
|
130
|
+
const lowerName = nameOrId.toLowerCase();
|
|
131
|
+
const byName = sessions.filter((session) => session.name?.toLowerCase() === lowerName);
|
|
132
|
+
if (byName.length > 1) {
|
|
133
|
+
throw new Error(`Multiple sessions named "${nameOrId}" are connected. Use the session ID instead.`);
|
|
134
|
+
}
|
|
135
|
+
if (byName[0]) return byName[0].id;
|
|
136
|
+
|
|
137
|
+
if (nameOrId.length >= 4) {
|
|
138
|
+
const byPrefix = sessions.filter((session) => session.id.startsWith(nameOrId));
|
|
139
|
+
if (byPrefix.length > 1) {
|
|
140
|
+
throw new Error(`Multiple sessions match the ID prefix "${nameOrId}". Use the full session ID or a unique name.`);
|
|
141
|
+
}
|
|
142
|
+
if (byPrefix[0]) return byPrefix[0].id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatSessionDisplay(session: SessionInfo): string {
|
|
149
|
+
const name = session.name || session.id;
|
|
150
|
+
return session.origin === "remote" ? `${name} [remote:${session.remoteHostId || "unknown-host"}]` : name;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatSessionList(sessions: SessionInfo[], currentSessionId: string | null, currentCwd: string): string {
|
|
154
|
+
if (!sessions.length) return "No intercom sessions connected.";
|
|
155
|
+
return sessions.map((session) => {
|
|
156
|
+
const tags = [
|
|
157
|
+
session.id === currentSessionId ? "self" : undefined,
|
|
158
|
+
session.cwd === currentCwd ? "same cwd" : undefined,
|
|
159
|
+
session.status,
|
|
160
|
+
].filter((tag): tag is string => Boolean(tag));
|
|
161
|
+
const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
162
|
+
return `- ${formatSessionDisplay(session)} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`;
|
|
163
|
+
}).join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function detectGitRoot(cwd: string): string | null {
|
|
167
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
168
|
+
cwd,
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
171
|
+
});
|
|
172
|
+
if (result.status !== 0) return null;
|
|
173
|
+
return result.stdout.trim() || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function textResult(text: string, structuredContent?: Record<string, unknown>, isError = false): ToolResult {
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: "text", text }],
|
|
179
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
180
|
+
...(isError ? { isError: true } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ClaudeIntercomRuntimeOptions {
|
|
185
|
+
clientFactory?: () => IntercomClient;
|
|
186
|
+
prepareConnection?: () => Promise<void>;
|
|
187
|
+
reconnectDelays?: number[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class ClaudeIntercomRuntime {
|
|
191
|
+
private client: IntercomClient | null = null;
|
|
192
|
+
private connectPromise: Promise<IntercomClient> | null = null;
|
|
193
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
194
|
+
private reconnectAttempt = 0;
|
|
195
|
+
private reconnectEnabled = true;
|
|
196
|
+
private identity: ClaudeRuntimeIdentity;
|
|
197
|
+
private unread: PendingInboundMessage[] = [];
|
|
198
|
+
private unresolvedAsks = new Map<string, PendingInboundMessage>();
|
|
199
|
+
private replyWaiters = new Map<string, ReplyWaiter>();
|
|
200
|
+
|
|
201
|
+
private readonly clientFactory: () => IntercomClient;
|
|
202
|
+
private readonly capturedScopeId: string | undefined;
|
|
203
|
+
private readonly prepareConnection: () => Promise<void>;
|
|
204
|
+
private readonly reconnectDelays: number[];
|
|
205
|
+
|
|
206
|
+
constructor(identity: ClaudeRuntimeIdentity = buildClaudeRuntimeIdentity(), options: ClaudeIntercomRuntimeOptions = {}) {
|
|
207
|
+
this.identity = identity;
|
|
208
|
+
// Capture AGENT_INTERCOM_SCOPE_ID exactly once at runtime construction so that
|
|
209
|
+
// reconnects reuse the same private scope even if process.env is mutated later.
|
|
210
|
+
this.capturedScopeId = intercomScopeIdFromEnvForRegistration(process.env);
|
|
211
|
+
// Freeze the exact env value the client should see for scope resolution.
|
|
212
|
+
// When capturedScopeId is undefined (unscoped), pass an empty env so the client
|
|
213
|
+
// does not silently re-read a later-mutated AGENT_INTERCOM_SCOPE_ID.
|
|
214
|
+
const scopeSnapshot: NodeJS.ProcessEnv = this.capturedScopeId
|
|
215
|
+
? { AGENT_INTERCOM_SCOPE_ID: this.capturedScopeId }
|
|
216
|
+
: {};
|
|
217
|
+
this.clientFactory = options.clientFactory ?? (() => new IntercomClient({ env: scopeSnapshot }));
|
|
218
|
+
this.prepareConnection = options.prepareConnection ?? (async () => {
|
|
219
|
+
const config = loadConfig();
|
|
220
|
+
if (!config.enabled) throw new Error("Intercom disabled");
|
|
221
|
+
await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
|
|
222
|
+
});
|
|
223
|
+
this.reconnectDelays = options.reconnectDelays?.length ? options.reconnectDelays : [250, 500, 1000, 2000, 5000];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
getIdentity(): ClaudeRuntimeIdentity {
|
|
227
|
+
return this.identity;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async connect(): Promise<IntercomClient> {
|
|
231
|
+
this.reconnectEnabled = true;
|
|
232
|
+
this.clearReconnectTimer();
|
|
233
|
+
if (this.client?.isConnected()) return this.client;
|
|
234
|
+
if (this.connectPromise) return this.connectPromise;
|
|
235
|
+
this.connectPromise = this.connectOnce();
|
|
236
|
+
try {
|
|
237
|
+
return await this.connectPromise;
|
|
238
|
+
} finally {
|
|
239
|
+
this.connectPromise = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async connectOnce(): Promise<IntercomClient> {
|
|
244
|
+
await this.prepareConnection();
|
|
245
|
+
const client = this.clientFactory();
|
|
246
|
+
client.on("message", (from: SessionInfo, message: Message, deliveryId: string) => {
|
|
247
|
+
this.handleIncomingMessage(from, message);
|
|
248
|
+
client.acknowledgeMessage(deliveryId);
|
|
249
|
+
});
|
|
250
|
+
client.on("disconnected", (error: Error) => {
|
|
251
|
+
for (const waiter of this.replyWaiters.values()) {
|
|
252
|
+
clearTimeout(waiter.timeout);
|
|
253
|
+
waiter.cleanup?.();
|
|
254
|
+
waiter.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
255
|
+
}
|
|
256
|
+
this.replyWaiters.clear();
|
|
257
|
+
if (this.client === client) this.client = null;
|
|
258
|
+
this.scheduleReconnect();
|
|
259
|
+
});
|
|
260
|
+
await client.connect({
|
|
261
|
+
name: this.identity.name,
|
|
262
|
+
cwd: this.identity.cwd,
|
|
263
|
+
model: this.identity.model,
|
|
264
|
+
pid: process.pid,
|
|
265
|
+
startedAt: this.identity.startedAt,
|
|
266
|
+
lastActivity: Date.now(),
|
|
267
|
+
status: "idle",
|
|
268
|
+
}, this.identity.sessionId);
|
|
269
|
+
this.client = client;
|
|
270
|
+
this.reconnectAttempt = 0;
|
|
271
|
+
return client;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private scheduleReconnect(): void {
|
|
275
|
+
if (!this.reconnectEnabled || this.reconnectTimer) return;
|
|
276
|
+
const delay = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)]!;
|
|
277
|
+
this.reconnectTimer = setTimeout(() => {
|
|
278
|
+
this.reconnectTimer = null;
|
|
279
|
+
void this.connect().then((client) => {
|
|
280
|
+
if (!client.isConnected()) {
|
|
281
|
+
this.reconnectAttempt += 1;
|
|
282
|
+
this.scheduleReconnect();
|
|
283
|
+
}
|
|
284
|
+
}).catch(() => {
|
|
285
|
+
this.reconnectAttempt += 1;
|
|
286
|
+
this.scheduleReconnect();
|
|
287
|
+
});
|
|
288
|
+
}, delay);
|
|
289
|
+
this.reconnectTimer.unref?.();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private clearReconnectTimer(): void {
|
|
293
|
+
if (!this.reconnectTimer) return;
|
|
294
|
+
clearTimeout(this.reconnectTimer);
|
|
295
|
+
this.reconnectTimer = null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async disconnect(): Promise<void> {
|
|
299
|
+
this.reconnectEnabled = false;
|
|
300
|
+
this.clearReconnectTimer();
|
|
301
|
+
if (this.connectPromise) {
|
|
302
|
+
try {
|
|
303
|
+
await this.connectPromise;
|
|
304
|
+
} catch {
|
|
305
|
+
// A failed in-progress connection is already closed.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const client = this.client;
|
|
309
|
+
this.client = null;
|
|
310
|
+
if (client) await client.disconnect();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private handleIncomingMessage(from: SessionInfo, message: Message): void {
|
|
314
|
+
const waiter = this.replyWaiters.get(message.replyTo ?? "");
|
|
315
|
+
if (waiter) {
|
|
316
|
+
const senderTarget = from.name || from.id;
|
|
317
|
+
const fromMatches = senderTarget.toLowerCase() === waiter.from.toLowerCase() || from.id === waiter.from;
|
|
318
|
+
if (fromMatches) {
|
|
319
|
+
this.replyWaiters.delete(waiter.replyTo);
|
|
320
|
+
clearTimeout(waiter.timeout);
|
|
321
|
+
waiter.cleanup?.();
|
|
322
|
+
waiter.resolve(message);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const entry = { from, message, receivedAt: Date.now(), read: false };
|
|
328
|
+
this.unread.push(entry);
|
|
329
|
+
if (message.expectsReply) {
|
|
330
|
+
this.unresolvedAsks.set(message.id, entry);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// TUI mode: mirror the message to the session inbox so the plugin monitor
|
|
334
|
+
// can inject it into the live interactive session. Never let inbox I/O
|
|
335
|
+
// break normal message handling.
|
|
336
|
+
const inboxPath = process.env.CLAUDE_INTERCOM_INBOX;
|
|
337
|
+
if (inboxPath) {
|
|
338
|
+
try {
|
|
339
|
+
appendInboxMessage(inboxPath, from, message);
|
|
340
|
+
} catch {
|
|
341
|
+
// Best-effort delivery mirror only.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private waitForReply(from: string, replyTo: string, timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<Message> {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
if (signal?.aborted) {
|
|
349
|
+
reject(new Error("intercom_ask cancelled"));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
let timeout: NodeJS.Timeout;
|
|
353
|
+
const cleanup = () => {
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
signal?.removeEventListener("abort", onAbort);
|
|
356
|
+
};
|
|
357
|
+
const onAbort = () => {
|
|
358
|
+
this.replyWaiters.delete(replyTo);
|
|
359
|
+
cleanup();
|
|
360
|
+
void this.client?.cancelAsk(replyTo);
|
|
361
|
+
reject(new Error("intercom_ask cancelled"));
|
|
362
|
+
};
|
|
363
|
+
timeout = setTimeout(() => {
|
|
364
|
+
this.replyWaiters.delete(replyTo);
|
|
365
|
+
void this.client?.deferAsk(replyTo);
|
|
366
|
+
signal?.removeEventListener("abort", onAbort);
|
|
367
|
+
reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1000)} seconds`));
|
|
368
|
+
}, timeoutMs);
|
|
369
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
370
|
+
this.replyWaiters.set(replyTo, { from, replyTo, resolve, reject, timeout, cleanup });
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async resolveTarget(to: string): Promise<string> {
|
|
375
|
+
const client = await this.connect();
|
|
376
|
+
const sessions = await client.listSessions();
|
|
377
|
+
return resolveSessionTarget(sessions, to) ?? to;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async whoami(): Promise<ToolResult> {
|
|
381
|
+
const client = await this.connect();
|
|
382
|
+
const sessionId = client.sessionId ?? this.identity.sessionId;
|
|
383
|
+
return textResult(
|
|
384
|
+
`session_id: ${sessionId}\nname: ${this.identity.name}\ncwd: ${this.identity.cwd}`,
|
|
385
|
+
{ session_id: sessionId, name: this.identity.name, cwd: this.identity.cwd, model: this.identity.model },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async team(): Promise<ToolResult> {
|
|
390
|
+
const client = await this.connect();
|
|
391
|
+
const sessions = await client.listSessions();
|
|
392
|
+
const team = await resolveIntercomTeam({ selfId: client.sessionId ?? this.identity.sessionId, sessions });
|
|
393
|
+
return textResult(formatIntercomTeam(team), team as unknown as Record<string, unknown>);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async status(): Promise<ToolResult> {
|
|
397
|
+
const client = await this.connect();
|
|
398
|
+
const sessions = await client.listSessions();
|
|
399
|
+
return textResult(
|
|
400
|
+
`Connected: ${client.isConnected() ? "Yes" : "No"}\nSession ID: ${client.sessionId ?? "unknown"}\nActive sessions: ${sessions.length}\nUnread messages: ${this.unread.filter((entry) => !entry.read).length}\nPending asks: ${this.unresolvedAsks.size}`,
|
|
401
|
+
{
|
|
402
|
+
connected: client.isConnected(),
|
|
403
|
+
session_id: client.sessionId,
|
|
404
|
+
active_sessions: sessions.length,
|
|
405
|
+
unread_messages: this.unread.filter((entry) => !entry.read).length,
|
|
406
|
+
pending_asks: this.unresolvedAsks.size,
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async list(scope: "machine" | "directory" | "repo" = "machine", includeSelf = false): Promise<ToolResult> {
|
|
412
|
+
const client = await this.connect();
|
|
413
|
+
let sessions = await client.listSessions();
|
|
414
|
+
if (scope === "directory") {
|
|
415
|
+
sessions = sessions.filter((session) => session.cwd === this.identity.cwd);
|
|
416
|
+
} else if (scope === "repo") {
|
|
417
|
+
const currentRoot = detectGitRoot(this.identity.cwd);
|
|
418
|
+
sessions = currentRoot
|
|
419
|
+
? sessions.filter((session) => detectGitRoot(session.cwd) === currentRoot)
|
|
420
|
+
: [];
|
|
421
|
+
}
|
|
422
|
+
if (!includeSelf) {
|
|
423
|
+
sessions = sessions.filter((session) => session.id !== client.sessionId);
|
|
424
|
+
}
|
|
425
|
+
return textResult(formatSessionList(sessions, client.sessionId, this.identity.cwd), { sessions });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async setSummary(summary: string): Promise<ToolResult> {
|
|
429
|
+
const client = await this.connect();
|
|
430
|
+
client.updatePresence({ status: summary.trim() || "idle" });
|
|
431
|
+
return textResult("Summary updated.", { ok: true, summary });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async send(to: string, message: string, attachments?: Attachment[], replyTo?: string): Promise<ToolResult> {
|
|
435
|
+
const client = await this.connect();
|
|
436
|
+
const sendTo = await this.resolveTarget(to);
|
|
437
|
+
const result = await client.send(sendTo, { text: message, attachments, replyTo });
|
|
438
|
+
if (!result.delivered) {
|
|
439
|
+
return textResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
|
|
440
|
+
}
|
|
441
|
+
if (replyTo) this.unresolvedAsks.delete(replyTo);
|
|
442
|
+
return textResult(`Message sent to ${to}.`, { ok: true, message_id: result.id, to });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async ask(to: string, message: string, attachments?: Attachment[], timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<ToolResult> {
|
|
446
|
+
const client = await this.connect();
|
|
447
|
+
const sendTo = await this.resolveTarget(to);
|
|
448
|
+
const questionId = randomUUID();
|
|
449
|
+
const replyPromise = this.waitForReply(sendTo, questionId, timeoutMs, signal);
|
|
450
|
+
void replyPromise.catch(() => undefined);
|
|
451
|
+
try {
|
|
452
|
+
const result = await client.send(sendTo, {
|
|
453
|
+
messageId: questionId,
|
|
454
|
+
text: message,
|
|
455
|
+
attachments,
|
|
456
|
+
expectsReply: true,
|
|
457
|
+
});
|
|
458
|
+
if (!result.delivered) {
|
|
459
|
+
this.replyWaiters.get(questionId)?.reject(new Error(result.reason ?? "Session may not exist or has disconnected."));
|
|
460
|
+
this.replyWaiters.delete(questionId);
|
|
461
|
+
void client.cancelAsk(questionId);
|
|
462
|
+
return textResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
|
|
463
|
+
}
|
|
464
|
+
const reply = await replyPromise;
|
|
465
|
+
const replyText = `${reply.content.text}${formatAttachments(reply.content.attachments)}`;
|
|
466
|
+
return textResult(`Reply from ${to}:\n${replyText}`, { ok: true, message_id: result.id, reply });
|
|
467
|
+
} catch (error) {
|
|
468
|
+
void client.cancelAsk(questionId);
|
|
469
|
+
return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async pending(markRead = false): Promise<ToolResult> {
|
|
474
|
+
const unreadMessages = this.unread.filter((entry) => !entry.read);
|
|
475
|
+
if (markRead) {
|
|
476
|
+
for (const entry of unreadMessages) entry.read = true;
|
|
477
|
+
}
|
|
478
|
+
const pendingAsks = Array.from(this.unresolvedAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
|
|
479
|
+
const lines = [
|
|
480
|
+
unreadMessages.length
|
|
481
|
+
? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n")
|
|
482
|
+
: "No unread messages.",
|
|
483
|
+
pendingAsks.length
|
|
484
|
+
? `\nPending asks:\n${pendingAsks.map((entry) => {
|
|
485
|
+
const selector = pendingSelector(pendingAsks, entry);
|
|
486
|
+
return `- ${formatSessionDisplay(entry.from)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`;
|
|
487
|
+
}).join("\n")}`
|
|
488
|
+
: "",
|
|
489
|
+
].filter(Boolean);
|
|
490
|
+
return textResult(lines.join("\n"), {
|
|
491
|
+
unread_messages: unreadMessages.map((entry) => publicPendingEntry(entry)),
|
|
492
|
+
pending_asks: pendingAsks.map((entry) => publicPendingEntry(entry, pendingSelector(pendingAsks, entry))),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async reply(message: string, to?: string, which?: ReplyWhich): Promise<ToolResult> {
|
|
497
|
+
let target: PendingInboundMessage;
|
|
498
|
+
try {
|
|
499
|
+
target = selectPendingAsk(Array.from(this.unresolvedAsks.values()), to, which);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const result = await this.send(target.from.id, message, undefined, target.message.id);
|
|
505
|
+
if (!result.isError) {
|
|
506
|
+
this.unresolvedAsks.delete(target.message.id);
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
}
|
package/claude/server.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { ClaudeIntercomRuntime } from "./runtime.ts";
|
|
4
|
+
import { handleMcpRequest } from "./mcp-protocol.ts";
|
|
5
|
+
|
|
6
|
+
const runtime = new ClaudeIntercomRuntime();
|
|
7
|
+
|
|
8
|
+
// TUI/live mode (`cci --tui` sets CLAUDE_INTERCOM_INBOX): register on the broker
|
|
9
|
+
// immediately so peers can reach this session and inbound messages flow into the
|
|
10
|
+
// inbox before the first tool call. Otherwise registration is lazy (first tool).
|
|
11
|
+
if (process.env.CLAUDE_INTERCOM_INBOX) {
|
|
12
|
+
void runtime.connect().catch((error) => {
|
|
13
|
+
process.stderr.write(`claude-intercom: eager connect failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rl = readline.createInterface({
|
|
18
|
+
input: stdin,
|
|
19
|
+
crlfDelay: Infinity,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let shuttingDown = false;
|
|
23
|
+
let pendingRequests = 0;
|
|
24
|
+
|
|
25
|
+
function writeResponse(response: Record<string, unknown> | undefined): void {
|
|
26
|
+
if (!response) return;
|
|
27
|
+
stdout.write(`${JSON.stringify(response)}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function maybeShutdown(): void {
|
|
31
|
+
if (!shuttingDown || pendingRequests > 0) return;
|
|
32
|
+
void runtime.disconnect().finally(() => process.exit(0));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
rl.on("line", (line) => {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed) return;
|
|
38
|
+
pendingRequests += 1;
|
|
39
|
+
void (async () => {
|
|
40
|
+
try {
|
|
41
|
+
const request = JSON.parse(trimmed);
|
|
42
|
+
writeResponse(await handleMcpRequest(request, runtime));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
writeResponse({
|
|
45
|
+
jsonrpc: "2.0",
|
|
46
|
+
id: null,
|
|
47
|
+
error: {
|
|
48
|
+
code: -32700,
|
|
49
|
+
message: error instanceof Error ? error.message : String(error),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
})().finally(() => {
|
|
54
|
+
pendingRequests -= 1;
|
|
55
|
+
maybeShutdown();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const shutdown = () => {
|
|
60
|
+
if (shuttingDown) return;
|
|
61
|
+
shuttingDown = true;
|
|
62
|
+
rl.close();
|
|
63
|
+
maybeShutdown();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
rl.on("close", shutdown);
|
|
67
|
+
process.on("SIGINT", shutdown);
|
|
68
|
+
process.on("SIGTERM", shutdown);
|
|
69
|
+
process.on("disconnect", shutdown);
|