@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.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +26 -0
  2. package/.mcp.json +10 -0
  3. package/CHANGELOG.md +62 -0
  4. package/COPYRIGHT +6 -0
  5. package/LICENSE +661 -0
  6. package/LICENSE_TRANSITION.md +25 -0
  7. package/README.md +506 -0
  8. package/THIRD_PARTY_NOTICES.md +25 -0
  9. package/broker/access-credential.ts +76 -0
  10. package/broker/access-registry.ts +460 -0
  11. package/broker/audit.ts +60 -0
  12. package/broker/authorization.ts +125 -0
  13. package/broker/boss-contracts.ts +267 -0
  14. package/broker/broker.ts +2028 -0
  15. package/broker/client.ts +1048 -0
  16. package/broker/framing.ts +87 -0
  17. package/broker/ownership.ts +61 -0
  18. package/broker/paths.ts +161 -0
  19. package/broker/spawn.ts +463 -0
  20. package/broker/validation.ts +132 -0
  21. package/claude/cci.ts +512 -0
  22. package/claude/ccim.ts +31 -0
  23. package/claude/cli-runner.ts +220 -0
  24. package/claude/contact.ts +33 -0
  25. package/claude/inbox-monitor.ts +49 -0
  26. package/claude/inbox.ts +77 -0
  27. package/claude/mcp-protocol.ts +253 -0
  28. package/claude/native-bridge.ts +219 -0
  29. package/claude/native-protocol.ts +346 -0
  30. package/claude/permission-policy.ts +287 -0
  31. package/claude/runtime.ts +510 -0
  32. package/claude/server.ts +69 -0
  33. package/claude/team.ts +348 -0
  34. package/claude/transport.ts +97 -0
  35. package/claude/worker-config.ts +198 -0
  36. package/claude/worker-daemon.ts +418 -0
  37. package/commands/intercom-id.md +9 -0
  38. package/commands/intercom.md +8 -0
  39. package/config.ts +195 -0
  40. package/dist/broker.mjs +2530 -0
  41. package/dist/cci.mjs +3607 -0
  42. package/dist/ccim.mjs +3621 -0
  43. package/dist/claude-server.mjs +2730 -0
  44. package/dist/inbox-monitor.mjs +68 -0
  45. package/dist/worker-daemon.mjs +2797 -0
  46. package/durable-json.ts +25 -0
  47. package/licenses/MIT-pi-claude-link.txt +21 -0
  48. package/licenses/MIT-pi-intercom.txt +21 -0
  49. package/monitors/monitors.json +8 -0
  50. package/outbound-outbox.ts +116 -0
  51. package/package.json +93 -0
  52. package/protocol-v4/contract.ts +45 -0
  53. package/provider/protected-service.ts +162 -0
  54. package/provider/provider.mjs +34 -0
  55. package/scripts/build.mjs +71 -0
  56. package/scripts/verify-core-provenance.mjs +64 -0
  57. package/scripts/verify-packed-runtime.mjs +140 -0
  58. package/skills/claude-intercom/SKILL.md +90 -0
  59. package/types.ts +212 -0
@@ -0,0 +1,220 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ assertNoPermissionOverrides,
4
+ bindHardenedClaudePaths,
5
+ resolveClaudePermissionPolicy,
6
+ type ClaudePermissionCeiling,
7
+ type ClaudePermissionMode,
8
+ } from "./permission-policy.ts";
9
+
10
+ export interface ClaudeTurnOptions {
11
+ prompt: string;
12
+ cwd: string;
13
+ sessionId?: string;
14
+ model?: string;
15
+ appendSystemPrompt?: string;
16
+ permissionMode?: ClaudePermissionMode;
17
+ dangerouslySkipPermissions?: boolean;
18
+ permissionCeiling?: ClaudePermissionCeiling;
19
+ addDirs?: string[];
20
+ mcpConfig?: string;
21
+ claudeCommand?: string;
22
+ extraArgs?: string[];
23
+ env?: NodeJS.ProcessEnv;
24
+ signal?: AbortSignal;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ export interface ClaudeTurnResult {
29
+ sessionId: string | null;
30
+ result: string;
31
+ isError: boolean;
32
+ raw: unknown;
33
+ }
34
+
35
+ interface PreparedClaudeLaunch {
36
+ args: string[];
37
+ cwd: string;
38
+ release(): void;
39
+ }
40
+
41
+ function prepareClaudeLaunch(options: ClaudeTurnOptions): PreparedClaudeLaunch {
42
+ const args: string[] = ["-p", "--output-format", "json"];
43
+ const permission = resolveClaudePermissionPolicy({
44
+ permissionMode: options.permissionMode,
45
+ dangerouslySkipPermissions: options.dangerouslySkipPermissions,
46
+ ceiling: options.permissionCeiling,
47
+ });
48
+ assertNoPermissionOverrides(options.extraArgs ?? [], "extraArgs", permission.ceiling);
49
+
50
+ let cwd = options.cwd;
51
+ let addDirs = options.addDirs ?? [];
52
+ let release = () => {};
53
+ if (permission.ceiling === "read-only") {
54
+ if (options.mcpConfig !== undefined) {
55
+ throw new Error("mcpConfig cannot add an arbitrary MCP capability to a hardened Claude role");
56
+ }
57
+ const binding = bindHardenedClaudePaths(options.cwd, addDirs);
58
+ cwd = binding.cwd;
59
+ addDirs = binding.addDirs;
60
+ release = binding.release;
61
+ // --bare prevents ambient project/user customizations and hooks from
62
+ // widening a hardened launch. Explicit broker-owned inputs are appended
63
+ // below after the permission boundary has been fixed.
64
+ args.push("--bare");
65
+ }
66
+
67
+ if (options.sessionId) {
68
+ args.push("--resume", options.sessionId);
69
+ }
70
+ if (options.model) {
71
+ args.push("--model", options.model);
72
+ }
73
+ if (options.appendSystemPrompt) {
74
+ args.push("--append-system-prompt", options.appendSystemPrompt);
75
+ }
76
+ if (permission.dangerouslySkipPermissions) {
77
+ args.push("--dangerously-skip-permissions");
78
+ } else if (permission.permissionMode && (
79
+ permission.ceiling === "read-only" || options.permissionMode !== undefined
80
+ )) {
81
+ args.push("--permission-mode", permission.permissionMode);
82
+ }
83
+ for (const dir of addDirs) {
84
+ args.push("--add-dir", dir);
85
+ }
86
+ if (options.mcpConfig) {
87
+ args.push("--mcp-config", options.mcpConfig);
88
+ }
89
+ if (options.extraArgs?.length) {
90
+ args.push(...options.extraArgs);
91
+ }
92
+
93
+ return { args, cwd, release };
94
+ }
95
+
96
+ export function buildClaudeArgs(options: ClaudeTurnOptions): string[] {
97
+ const launch = prepareClaudeLaunch(options);
98
+ try {
99
+ return launch.args;
100
+ } finally {
101
+ launch.release();
102
+ }
103
+ }
104
+
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ export function runClaudeTurn(options: ClaudeTurnOptions): Promise<ClaudeTurnResult> {
110
+ return new Promise((resolve, reject) => {
111
+ const command = options.claudeCommand ?? "claude";
112
+ const launch = prepareClaudeLaunch(options);
113
+ let child: ReturnType<typeof spawn>;
114
+ try {
115
+ child = spawn(command, launch.args, {
116
+ cwd: launch.cwd,
117
+ env: { ...process.env, ...options.env },
118
+ stdio: ["pipe", "pipe", "pipe"],
119
+ });
120
+ } finally {
121
+ // Directory descriptors remain open across the synchronous spawn setup,
122
+ // narrowing replacement races without leaking them for the turn.
123
+ launch.release();
124
+ }
125
+
126
+ let stdout = "";
127
+ let stderr = "";
128
+ let settled = false;
129
+ let timeoutHandle: NodeJS.Timeout | undefined;
130
+
131
+ const cleanup = () => {
132
+ if (timeoutHandle) clearTimeout(timeoutHandle);
133
+ options.signal?.removeEventListener("abort", onAbort);
134
+ };
135
+
136
+ const finishReject = (error: Error) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ cleanup();
140
+ reject(error);
141
+ };
142
+
143
+ const finishResolve = (result: ClaudeTurnResult) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ cleanup();
147
+ resolve(result);
148
+ };
149
+
150
+ const onAbort = () => {
151
+ child.kill("SIGTERM");
152
+ finishReject(new Error("Claude turn aborted"));
153
+ };
154
+
155
+ if (options.signal) {
156
+ if (options.signal.aborted) {
157
+ child.kill("SIGTERM");
158
+ finishReject(new Error("Claude turn aborted"));
159
+ return;
160
+ }
161
+ options.signal.addEventListener("abort", onAbort, { once: true });
162
+ }
163
+
164
+ if (options.timeoutMs) {
165
+ timeoutHandle = setTimeout(() => {
166
+ child.kill("SIGTERM");
167
+ finishReject(new Error(`Claude turn timed out after ${options.timeoutMs}ms`));
168
+ }, options.timeoutMs);
169
+ }
170
+
171
+ child.on("error", (error) => {
172
+ finishReject(new Error(`Failed to spawn "${command}": ${error.message}`, { cause: error }));
173
+ });
174
+
175
+ child.stdout?.on("data", (chunk: Buffer) => {
176
+ stdout += chunk.toString("utf8");
177
+ });
178
+ child.stderr?.on("data", (chunk: Buffer) => {
179
+ stderr += chunk.toString("utf8");
180
+ });
181
+
182
+ child.on("close", (exitCode) => {
183
+ if (settled) return;
184
+
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(stdout.trim());
188
+ } catch {
189
+ const fallback = stdout.trim() || stderr.trim();
190
+ finishResolve({
191
+ sessionId: options.sessionId ?? null,
192
+ result: fallback,
193
+ isError: exitCode !== 0,
194
+ raw: stdout,
195
+ });
196
+ return;
197
+ }
198
+
199
+ if (isRecord(parsed) && typeof parsed.result === "string") {
200
+ finishResolve({
201
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : null,
202
+ result: parsed.result,
203
+ isError: Boolean(parsed.is_error),
204
+ raw: parsed,
205
+ });
206
+ return;
207
+ }
208
+
209
+ finishResolve({
210
+ sessionId: options.sessionId ?? null,
211
+ result: stdout.trim(),
212
+ isError: exitCode !== 0,
213
+ raw: parsed,
214
+ });
215
+ });
216
+
217
+ child.stdin?.write(options.prompt);
218
+ child.stdin?.end();
219
+ });
220
+ }
@@ -0,0 +1,33 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import type { SessionInfo } from "../types.ts";
3
+
4
+ export function formatContactInstruction(current: SessionInfo, sessions: SessionInfo[]): string {
5
+ const name = current.name?.trim();
6
+ const duplicate = Boolean(name && sessions.some((session) => session.id !== current.id && session.name?.trim().toLowerCase() === name.toLowerCase()));
7
+ return name && !duplicate
8
+ ? `Intercom target: ${name}\nStable session ID: ${current.id}`
9
+ : `Intercom target: ${current.id}`;
10
+ }
11
+
12
+ export function copyTextToClipboard(text: string): boolean {
13
+ const candidates: Array<[string, string[]]> = process.platform === "darwin"
14
+ ? [["pbcopy", []]]
15
+ : process.platform === "win32"
16
+ ? [["clip.exe", []]]
17
+ : [
18
+ ...(process.env.WAYLAND_DISPLAY ? [["wl-copy", []] as [string, string[]]] : []),
19
+ ...(process.env.DISPLAY ? [["xclip", ["-selection", "clipboard"]] as [string, string[]], ["xsel", ["--clipboard", "--input"]] as [string, string[]]] : []),
20
+ ["clip.exe", []],
21
+ ];
22
+ for (const [command, args] of candidates) {
23
+ if (spawnSync("which", [command], { stdio: "ignore" }).status !== 0) continue;
24
+ if (command === "wl-copy") {
25
+ const child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] });
26
+ child.stdin.end(text);
27
+ child.unref();
28
+ return true;
29
+ }
30
+ if (spawnSync(command, args, { input: text, stdio: ["pipe", "ignore", "ignore"] }).status === 0) return true;
31
+ }
32
+ return false;
33
+ }
@@ -0,0 +1,49 @@
1
+ // Plugin monitor entry point (auto-armed by monitors/monitors.json in TUI mode).
2
+ // Tails the session's intercom inbox and prints one line per NEW inbound
3
+ // message. Claude Code's Monitor machinery injects each stdout line into the
4
+ // live session as an event the model acts on. Pre-existing backlog is skipped
5
+ // so only messages that arrive after the session starts wake it.
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { basename } from "node:path";
9
+ import { formatInboxLine, inboxBatchFrom } from "./inbox.ts";
10
+
11
+ const POLL_MS = 1000;
12
+
13
+ function readContent(path: string): string {
14
+ try {
15
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
16
+ } catch {
17
+ return "";
18
+ }
19
+ }
20
+
21
+ export async function runInboxMonitor(path: string, signal?: { aborted: boolean }): Promise<void> {
22
+ // Start after the current backlog so only new messages are surfaced.
23
+ let emitted = inboxBatchFrom(readContent(path), 0).total;
24
+ for (;;) {
25
+ if (signal?.aborted) return;
26
+ const { entries, total } = inboxBatchFrom(readContent(path), emitted);
27
+ for (const entry of entries) {
28
+ process.stdout.write(`${formatInboxLine(entry)}\n`);
29
+ }
30
+ emitted = total;
31
+ await delay(POLL_MS);
32
+ }
33
+ }
34
+
35
+ async function main(): Promise<void> {
36
+ const path = process.env.CLAUDE_INTERCOM_INBOX || process.argv[2];
37
+ if (!path) {
38
+ process.stderr.write("inbox-monitor: no inbox path (set CLAUDE_INTERCOM_INBOX or pass a path)\n");
39
+ process.exit(1);
40
+ }
41
+ await runInboxMonitor(path);
42
+ }
43
+
44
+ if (process.argv[1] && (basename(process.argv[1]) === "inbox-monitor.ts" || basename(process.argv[1]) === "inbox-monitor.mjs")) {
45
+ void main().catch((error) => {
46
+ process.stderr.write(`inbox-monitor: ${error instanceof Error ? error.message : String(error)}\n`);
47
+ process.exit(1);
48
+ });
49
+ }
@@ -0,0 +1,77 @@
1
+ // Durable per-session inbox used by the live TUI mode (`cci --tui`).
2
+ //
3
+ // In TUI mode a single live `claude` session owns the broker identity (via the
4
+ // intercom MCP server). That MCP server appends each inbound message to this
5
+ // JSONL inbox file, and a plugin monitor tails the file and injects new lines
6
+ // into the live session. This keeps one broker connection per identity while
7
+ // still delivering unprompted wakes into an interactive session.
8
+ import { appendFileSync, mkdirSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { getIntercomDirPath, INTERCOM_RUNTIME_FILE_MODE } from "../broker/paths.ts";
11
+ import type { Message, SessionInfo } from "../types.ts";
12
+
13
+ export interface InboxEntry {
14
+ ts: number;
15
+ fromId: string;
16
+ fromName?: string;
17
+ messageId: string;
18
+ replyTo?: string;
19
+ expectsReply: boolean;
20
+ text: string;
21
+ }
22
+
23
+ export function sanitizeInboxSegment(value: string): string {
24
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "session";
25
+ }
26
+
27
+ export function defaultInboxPath(sessionId: string, intercomDir: string = getIntercomDirPath()): string {
28
+ return join(intercomDir, `inbox-${sanitizeInboxSegment(sessionId)}.jsonl`);
29
+ }
30
+
31
+ export function toInboxEntry(from: SessionInfo, message: Message, now: number = Date.now()): InboxEntry {
32
+ return {
33
+ ts: now,
34
+ fromId: from.id,
35
+ ...(from.name ? { fromName: from.name } : {}),
36
+ messageId: message.id,
37
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
38
+ expectsReply: Boolean(message.expectsReply),
39
+ text: message.content.text,
40
+ };
41
+ }
42
+
43
+ export function appendInboxMessage(path: string, from: SessionInfo, message: Message): void {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ appendFileSync(path, `${JSON.stringify(toInboxEntry(from, message))}\n`, {
46
+ encoding: "utf-8",
47
+ mode: INTERCOM_RUNTIME_FILE_MODE,
48
+ });
49
+ }
50
+
51
+ /** Human/Claude-facing single line the monitor emits for a new inbound message. */
52
+ export function formatInboxLine(entry: InboxEntry): string {
53
+ const who = entry.fromName ? `${entry.fromName} (${entry.fromId.slice(0, 8)})` : entry.fromId;
54
+ const kind = entry.expectsReply ? " [asking — awaiting your reply]" : "";
55
+ const oneLine = entry.text.replace(/\s+/g, " ").trim();
56
+ return `Intercom message from ${who}${kind}: ${oneLine}`;
57
+ }
58
+
59
+ /**
60
+ * Pure monitor helper: given full inbox file contents and how many lines were
61
+ * already emitted, return the new entries plus the updated line count. Resets
62
+ * when the file shrank (truncation/rotation).
63
+ */
64
+ export function inboxBatchFrom(content: string, alreadyEmitted: number): { entries: InboxEntry[]; total: number } {
65
+ const lines = content.split("\n").map((line) => line.trim()).filter(Boolean);
66
+ const total = lines.length;
67
+ const start = alreadyEmitted <= total ? alreadyEmitted : 0;
68
+ const entries: InboxEntry[] = [];
69
+ for (let index = start; index < total; index += 1) {
70
+ try {
71
+ entries.push(JSON.parse(lines[index]) as InboxEntry);
72
+ } catch {
73
+ // Skip a torn/partial line; it will be complete on the next poll.
74
+ }
75
+ }
76
+ return { entries, total };
77
+ }
@@ -0,0 +1,253 @@
1
+ import type { ClaudeIntercomRuntime, ToolResult } from "./runtime.ts";
2
+ import { validateAskTimeoutMs } from "../config.ts";
3
+ import type { Attachment } from "../types.ts";
4
+ import { hasExactDataKeys, isDenseArrayOf } from "../broker/validation.ts";
5
+
6
+ interface JsonRpcRequest {
7
+ jsonrpc?: string;
8
+ id?: string | number | null;
9
+ method?: string;
10
+ params?: Record<string, unknown>;
11
+ }
12
+
13
+ type RequestId = string | number;
14
+ type ToolHandler = (args: Record<string, unknown>, signal?: AbortSignal) => Promise<ToolResult>;
15
+
16
+ interface ToolDefinition {
17
+ name: string;
18
+ description: string;
19
+ inputSchema: Record<string, unknown>;
20
+ handler: ToolHandler;
21
+ }
22
+
23
+ const inflightToolCalls = new Map<RequestId, AbortController>();
24
+
25
+ function asString(value: unknown, name: string): string {
26
+ if (typeof value !== "string" || value.trim() === "") {
27
+ throw new Error(`${name} must be a non-empty string`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function asBoolean(value: unknown, defaultValue: boolean): boolean {
33
+ return typeof value === "boolean" ? value : defaultValue;
34
+ }
35
+
36
+ function asOptionalPositiveInteger(value: unknown, name: string): number | undefined {
37
+ if (value === undefined) return undefined;
38
+ return validateAskTimeoutMs(value, name);
39
+ }
40
+
41
+ function asAttachmentArray(value: unknown): Attachment[] | undefined {
42
+ if (value === undefined) return undefined;
43
+ if (!isDenseArrayOf<unknown>(value, (_entry): _entry is unknown => true)) {
44
+ throw new Error("attachments must be an exact dense array");
45
+ }
46
+ return value.map((item, index) => {
47
+ if (!hasExactDataKeys(item, ["type", "name", "content"], ["language"])) {
48
+ throw new Error(`attachments[${index}] must be an exact object`);
49
+ }
50
+ const raw = item as Record<string, unknown>;
51
+ const type = raw.type;
52
+ if (type !== "file" && type !== "snippet" && type !== "context") throw new Error(`attachments[${index}].type must be file, snippet, or context`);
53
+ const name = asString(raw.name, `attachments[${index}].name`);
54
+ const content = asString(raw.content, `attachments[${index}].content`);
55
+ if (raw.language !== undefined && typeof raw.language !== "string") throw new Error(`attachments[${index}].language must be a string`);
56
+ return { type, name, content, ...(raw.language ? { language: raw.language } : {}) };
57
+ });
58
+ }
59
+
60
+ const attachmentsSchema = {
61
+ type: "array",
62
+ items: {
63
+ type: "object",
64
+ properties: {
65
+ type: { type: "string", enum: ["file", "snippet", "context"] },
66
+ name: { type: "string" },
67
+ content: { type: "string" },
68
+ language: { type: "string" },
69
+ },
70
+ required: ["type", "name", "content"],
71
+ additionalProperties: false,
72
+ },
73
+ };
74
+
75
+ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefinition[] {
76
+ const tools: ToolDefinition[] = [
77
+ {
78
+ name: "intercom_whoami",
79
+ description: "Return this Claude session's intercom identity for reliable targeting.",
80
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
81
+ handler: async () => runtime.whoami(),
82
+ },
83
+ {
84
+ name: "intercom_team",
85
+ description: "Show your current manager and the live coworkers owned by that manager. No arguments are required.",
86
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
87
+ handler: async () => runtime.team(),
88
+ },
89
+ {
90
+ name: "intercom_status",
91
+ description: "Show intercom connection status, active sessions, unread messages, and pending asks.",
92
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
93
+ handler: async () => runtime.status(),
94
+ },
95
+ {
96
+ name: "intercom_list",
97
+ description: "List intercom-connected Pi or Claude sessions on this machine.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ scope: { type: "string", enum: ["machine", "directory", "repo"], default: "machine" },
102
+ include_self: { type: "boolean", default: false },
103
+ },
104
+ additionalProperties: false,
105
+ },
106
+ handler: async (args) => runtime.list(
107
+ args.scope === "directory" || args.scope === "repo" ? args.scope : "machine",
108
+ asBoolean(args.include_self, false),
109
+ ),
110
+ },
111
+ {
112
+ name: "intercom_set_summary",
113
+ description: "Publish a short status summary so other sessions can discover what this Claude session is doing.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: { summary: { type: "string", maxLength: 400 } },
117
+ required: ["summary"],
118
+ additionalProperties: false,
119
+ },
120
+ handler: async (args) => runtime.setSummary(asString(args.summary, "summary")),
121
+ },
122
+ {
123
+ name: "intercom_send",
124
+ description: "Send a non-blocking direct message to another intercom session by name, full ID, or unique ID prefix.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ to: { type: "string" },
129
+ message: { type: "string" },
130
+ attachments: attachmentsSchema,
131
+ },
132
+ required: ["to", "message"],
133
+ additionalProperties: false,
134
+ },
135
+ handler: async (args) => runtime.send(asString(args.to, "to"), asString(args.message, "message"), asAttachmentArray(args.attachments)),
136
+ },
137
+ {
138
+ name: "intercom_ask",
139
+ description: "Ask another intercom session a question only when the next step depends on its reply. Use intercom_send for assignments, progress/status checkpoints, and notifications.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ to: { type: "string" },
144
+ message: { type: "string" },
145
+ attachments: attachmentsSchema,
146
+ timeout_ms: { type: "integer", minimum: 1, maximum: 120000, description: "Maximum time to wait for a reply before returning an error. Use intercom_send plus intercom_pending for longer work." },
147
+ },
148
+ required: ["to", "message"],
149
+ additionalProperties: false,
150
+ },
151
+ handler: async (args, signal) => runtime.ask(
152
+ asString(args.to, "to"),
153
+ asString(args.message, "message"),
154
+ asAttachmentArray(args.attachments),
155
+ asOptionalPositiveInteger(args.timeout_ms, "timeout_ms"),
156
+ signal,
157
+ ),
158
+ },
159
+ {
160
+ name: "intercom_pending",
161
+ description: "Read unread inbound messages and unresolved asks for this Claude session.",
162
+ inputSchema: {
163
+ type: "object",
164
+ properties: { mark_read: { type: "boolean", default: false } },
165
+ additionalProperties: false,
166
+ },
167
+ handler: async (args) => runtime.pending(asBoolean(args.mark_read, false)),
168
+ },
169
+ {
170
+ name: "intercom_reply",
171
+ description: "Reply to a pending inbound ask. Use to plus which=oldest/latest when one sender has multiple unresolved asks.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ message: { type: "string" },
176
+ to: { type: "string", description: "Optional sender/session selector; never a message or thread ID." },
177
+ which: { type: "string", enum: ["oldest", "latest"], description: "Select the oldest or latest ask from the chosen sender." },
178
+ },
179
+ required: ["message"],
180
+ additionalProperties: false,
181
+ },
182
+ handler: async (args) => runtime.reply(asString(args.message, "message"), typeof args.to === "string" ? args.to : undefined, args.which === "oldest" || args.which === "latest" ? args.which : undefined),
183
+ },
184
+ ];
185
+ return tools;
186
+ }
187
+
188
+ function ok(id: JsonRpcRequest["id"], result: unknown) {
189
+ return { jsonrpc: "2.0", id, result };
190
+ }
191
+
192
+ function error(id: JsonRpcRequest["id"], code: number, message: string) {
193
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
194
+ }
195
+
196
+ export async function handleMcpRequest(request: JsonRpcRequest, runtime: ClaudeIntercomRuntime): Promise<Record<string, unknown> | undefined> {
197
+ if (!request.method) {
198
+ return error(request.id, -32600, "Invalid request");
199
+ }
200
+
201
+ if (request.method === "notifications/cancelled") {
202
+ const requestId = request.params?.requestId;
203
+ if (typeof requestId === "string" || typeof requestId === "number") {
204
+ inflightToolCalls.get(requestId)?.abort();
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ if (request.id === undefined && request.method.startsWith("notifications/")) {
210
+ return undefined;
211
+ }
212
+
213
+ const tools = buildToolDefinitions(runtime);
214
+ switch (request.method) {
215
+ case "initialize":
216
+ return ok(request.id, {
217
+ protocolVersion: "2025-06-18",
218
+ capabilities: { tools: {} },
219
+ serverInfo: { name: "claude-intercom", version: "0.1.0" },
220
+ });
221
+ case "ping":
222
+ return ok(request.id, {});
223
+ case "tools/list":
224
+ return ok(request.id, {
225
+ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
226
+ });
227
+ case "tools/call": {
228
+ const name = request.params?.name;
229
+ const args = request.params?.arguments;
230
+ if (typeof name !== "string") return error(request.id, -32602, "tools/call requires params.name");
231
+ if (args !== undefined && (!args || typeof args !== "object" || Array.isArray(args))) {
232
+ return error(request.id, -32602, "tools/call params.arguments must be an object");
233
+ }
234
+ const tool = tools.find((candidate) => candidate.name === name);
235
+ if (!tool) return error(request.id, -32602, `Unknown tool: ${name}`);
236
+ const requestId = request.id;
237
+ const abortController = typeof requestId === "string" || typeof requestId === "number"
238
+ ? new AbortController()
239
+ : null;
240
+ if (abortController && requestId !== undefined && requestId !== null) inflightToolCalls.set(requestId, abortController);
241
+ try {
242
+ return ok(request.id, await tool.handler((args ?? {}) as Record<string, unknown>, abortController?.signal));
243
+ } catch (caught) {
244
+ const message = caught instanceof Error ? caught.message : String(caught);
245
+ return ok(request.id, { content: [{ type: "text", text: message }], isError: true });
246
+ } finally {
247
+ if (abortController && requestId !== undefined && requestId !== null) inflightToolCalls.delete(requestId);
248
+ }
249
+ }
250
+ default:
251
+ return error(request.id, -32601, `Method not found: ${request.method}`);
252
+ }
253
+ }