@ctliz/agent-intercom-claude 0.13.1 → 0.14.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +37 -7
- package/claude/cci.ts +13 -2
- package/claude/inbox-monitor.ts +34 -10
- package/claude/mcp-protocol.ts +1 -1
- package/claude/native-bridge.ts +8 -1
- package/claude/runtime.ts +72 -22
- package/claude/server.ts +48 -12
- package/claude/session-hook.ts +14 -0
- package/claude/session-lifecycle.ts +165 -0
- package/dist/cci.mjs +36 -1
- package/dist/ccim.mjs +36 -1
- package/dist/claude-server.mjs +257 -42
- package/dist/inbox-monitor.mjs +275 -14
- package/dist/session-hook.mjs +202 -0
- package/hooks/hooks.json +26 -0
- package/package.json +2 -1
- package/scripts/build.mjs +7 -0
- package/scripts/verify-packed-runtime.mjs +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.14.1 - 2026-09-30
|
|
6
|
+
|
|
7
|
+
- Expose the existing MCP owner's `runtimeReady` promise to companion Grok/AGY launchers so native identity and title updates use `syncSession` without opening a competing broker connection.
|
|
8
|
+
|
|
9
|
+
## 0.14.0 - 2026-09-30
|
|
10
|
+
|
|
11
|
+
- Register ordinary MCP launches before the first prompt/tool call; packaged SessionStart/SessionEnd hooks synchronize native conversation identity without creating a competing broker connection.
|
|
12
|
+
- Forward `cci --tui --name` to Claude's native title and synchronize `/rename` into broker presence in MCP and verified native modes. Preserve launcher IDs, resume targets, and the existing native compatibility gate.
|
|
13
|
+
- Retain messages received during Monitor startup and follow conversation changes after `/clear`.
|
|
14
|
+
- Pause automatic reconnect on `SESSION_ID_IN_USE`, expose the conflict in `intercom_status`, and cancel pending reply waiters when a session ends.
|
|
15
|
+
|
|
5
16
|
## 0.13.1 - 2026-09-06
|
|
6
17
|
|
|
7
18
|
- Stable release of named teams without tmux. `intercom_join`, `/intercom-create`, and `/intercom-join` work outside TmuxDeck.
|
package/README.md
CHANGED
|
@@ -185,18 +185,16 @@ With `--transport mcp`, `cci` does this automatically for each normal headless w
|
|
|
185
185
|
|
|
186
186
|
The `claude-intercom-mcp` executable is a standard stdio MCP server, but Grok Build and AGY should normally use the dedicated `@ctliz/agent-intercom-grok` and `@ctliz/agent-intercom-agy` packages. Those packages install `agent-intercom-grok-mcp` and `agent-intercom-agy-mcp`, depend on this package, and load its runtime internally. A separate global `claude-intercom-mcp` installation is not required. For a source-only checkout, configure `node` with the absolute path to `dist/claude-server.mjs`. Node 22.19+ is required.
|
|
187
187
|
|
|
188
|
-
Install the matching host package globally, then install its GitHub plugin at the matching release tag.
|
|
188
|
+
Install the matching host package globally, then install its GitHub plugin at the matching release tag. Companion launchers may await the exported `runtimeReady` promise from `dist/claude-server.mjs` and call `syncSession({ ...runtime.getIdentity(), name })` on that same runtime. This entry starts MCP stdio; do not create a second broker client to synchronize a host title.
|
|
189
189
|
|
|
190
190
|
Use `AGENT_INTERCOM_SESSION_ID` and `AGENT_INTERCOM_SESSION_NAME` for a host-neutral identity; `CLAUDE_INTERCOM_SESSION_ID` and `CLAUDE_INTERCOM_NAME` remain higher-priority compatibility aliases. Do not share a fixed session ID between concurrent host sessions.
|
|
191
191
|
|
|
192
192
|
A plain MCP host is connected only while its MCP process is alive. Incoming Intercom messages are durable and appear through `intercom_pending`, but this server cannot itself inject a new prompt into Grok or AGY. Use `intercom_send` plus `intercom_pending` for manual-polling workflows; a host-specific relay is required for wake-on-message.
|
|
193
193
|
|
|
194
|
-
For Grok Build, register the
|
|
194
|
+
For Grok Build, register the dedicated launcher without a fixed identity. It uses Grok's native `GROK_SESSION_ID` when available and synchronizes the exact session's persisted title:
|
|
195
195
|
|
|
196
196
|
```bash
|
|
197
197
|
grok mcp add agent-intercom \
|
|
198
|
-
-e AGENT_INTERCOM_SESSION_ID=grok-probe-01 \
|
|
199
|
-
-e AGENT_INTERCOM_SESSION_NAME=grok-probe \
|
|
200
198
|
-e CLAUDE_INTERCOM_MODEL=grok-build \
|
|
201
199
|
-- agent-intercom-grok-mcp
|
|
202
200
|
```
|
|
@@ -209,8 +207,6 @@ For AGY, add the equivalent entry to `~/.gemini/config/mcp_config.json`:
|
|
|
209
207
|
"agent-intercom": {
|
|
210
208
|
"command": "agent-intercom-agy-mcp",
|
|
211
209
|
"env": {
|
|
212
|
-
"AGENT_INTERCOM_SESSION_ID": "agy-probe-01",
|
|
213
|
-
"AGENT_INTERCOM_SESSION_NAME": "agy-probe",
|
|
214
210
|
"CLAUDE_INTERCOM_MODEL": "agy"
|
|
215
211
|
}
|
|
216
212
|
}
|
|
@@ -218,7 +214,7 @@ For AGY, add the equivalent entry to `~/.gemini/config/mcp_config.json`:
|
|
|
218
214
|
}
|
|
219
215
|
```
|
|
220
216
|
|
|
221
|
-
Both hosts must use the same `AGENT_INTERCOM_SCOPE_ID` as their intended peers, or leave it unset for the default local scope.
|
|
217
|
+
Both hosts must use the same `AGENT_INTERCOM_SCOPE_ID` as their intended peers, or leave it unset for the default local scope. Their persistent MCP server registers eagerly, without a first prompt or tool call. AGY's optional native metadata publisher is described in its package README. Use `intercom_whoami`, `intercom_list`, `intercom_send`, and `intercom_pending` to verify identity and delivery.
|
|
222
218
|
|
|
223
219
|
Optional identity variables can be attached at registration time:
|
|
224
220
|
|
|
@@ -375,6 +371,40 @@ Choose explicitly with `--transport native` or `--transport mcp`, or set `CLAUDE
|
|
|
375
371
|
|
|
376
372
|
`--minimal` is ignored in live TUI mode. The native path requires an interactive Claude process that publishes its local messaging socket. The MCP path additionally needs a built checkout (`npm run build`) and an available Monitor feature; Monitor is unavailable when `DISABLE_TELEMETRY` or `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is set, or on Bedrock/Vertex/Foundry. Both paths remain local and work behind a custom `ANTHROPIC_BASE_URL`/proxy. See [docs/wake-mechanisms.md](docs/wake-mechanisms.md).
|
|
377
373
|
|
|
374
|
+
### Startup registration and native session names
|
|
375
|
+
|
|
376
|
+
The MCP server now registers as soon as it starts, without waiting for a prompt
|
|
377
|
+
or an Intercom tool call. Bare MCP configuration provides discovery and queued
|
|
378
|
+
messages; load the packaged plugin for hooks and automatic Monitor delivery:
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
claude --plugin-dir /path/to/agent-intercom-claude --name reviewer
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
On macOS/Linux, the plugin's `SessionStart`/`SessionEnd` hooks publish private
|
|
385
|
+
metadata only. The persistent MCP server is the **only** broker connection.
|
|
386
|
+
Without a launcher-provided ID, Intercom uses `claude-<native-session-id>`, so
|
|
387
|
+
resuming a conversation preserves its target and `/clear` releases the old
|
|
388
|
+
conversation before registering the new one. Explicit Claude/generic launcher
|
|
389
|
+
IDs retain their existing precedence.
|
|
390
|
+
|
|
391
|
+
`cci --tui --name reviewer` also passes Claude's native `--name` flag. `/rename`
|
|
392
|
+
updates broker presence without changing the stable ID. MCP mode incrementally
|
|
393
|
+
tails only native `custom-title` transcript records; it never replaces your
|
|
394
|
+
statusline configuration. Native mode follows the verified socket registry's
|
|
395
|
+
name. The native transport version gate remains **2.1.220–2.1.226**; Claude
|
|
396
|
+
2.1.285 was smoke-tested through MCP, not added to that gate.
|
|
397
|
+
|
|
398
|
+
The Monitor skips historical inbox entries but retains messages that arrived
|
|
399
|
+
after `SessionStart`, even if they preceded Monitor startup. Unsupported host
|
|
400
|
+
process discovery falls back to eager MCP registration and explicit launcher
|
|
401
|
+
identity/inbox settings, as used by `cci`.
|
|
402
|
+
|
|
403
|
+
Enable Intercom once per Claude process: loading the plugin and a second manual
|
|
404
|
+
MCP entry for the same identity creates competing runtimes. `SESSION_ID_IN_USE`
|
|
405
|
+
leaves the incumbent authoritative, pauses automatic retries, and is exposed by
|
|
406
|
+
`intercom_status`. Do not share a fixed launcher ID between concurrent sessions.
|
|
407
|
+
|
|
378
408
|
## Normal And Minimal Workers
|
|
379
409
|
|
|
380
410
|
Like Codex's `coi` (normal) and `coim` (minimal), `cci` has a minimal mode. Codex
|
package/claude/cci.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type ResolvedClaudeIntercomTransport,
|
|
22
22
|
} from "./transport.ts";
|
|
23
23
|
import { NativeClaudeBrokerBridge } from "./native-bridge.ts";
|
|
24
|
-
import { waitForNativeClaudePeer } from "./native-protocol.ts";
|
|
24
|
+
import { nativePeerNameBySocket, waitForNativeClaudePeer } from "./native-protocol.ts";
|
|
25
25
|
import { INTERCOM_SCOPE_ENV, intercomScopeIdFromEnvForRegistration } from "../protocol-v4/contract.ts";
|
|
26
26
|
|
|
27
27
|
export interface CciOptions {
|
|
@@ -325,7 +325,7 @@ async function runCciTui(options: CciOptions, id: string, name: string, scopeId:
|
|
|
325
325
|
rmSync(inboxPath, { force: true }); // fresh session: only surface messages that arrive from now on
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
const args: string[] = ["--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
|
|
328
|
+
const args: string[] = ["--name", name, "--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
|
|
329
329
|
if (resolution.selected === "mcp") args.unshift("--plugin-dir", root);
|
|
330
330
|
const permission = resolveClaudePermissionPolicy(options);
|
|
331
331
|
if (options.model) args.push("--model", options.model);
|
|
@@ -354,12 +354,21 @@ async function runCciTui(options: CciOptions, id: string, name: string, scopeId:
|
|
|
354
354
|
},
|
|
355
355
|
});
|
|
356
356
|
let bridge: NativeClaudeBrokerBridge | undefined;
|
|
357
|
+
let nativeNameTimer: NodeJS.Timeout | undefined;
|
|
358
|
+
let nativeNameSync = Promise.resolve();
|
|
357
359
|
if (resolution.selected === "native") {
|
|
358
360
|
try {
|
|
359
361
|
if (!child.pid) throw new Error("Claude started without a process id; native transport cannot attach");
|
|
360
362
|
const peer = await waitForNativeClaudePeer(child.pid);
|
|
361
363
|
bridge = new NativeClaudeBrokerBridge({ id, name, cwd: options.cwd, model: options.model }, { scopeId });
|
|
362
364
|
await bridge.start(peer.socketPath);
|
|
365
|
+
nativeNameTimer = setInterval(() => {
|
|
366
|
+
const name = nativePeerNameBySocket(peer.socketPath);
|
|
367
|
+
if (name) nativeNameSync = nativeNameSync.then(() => bridge!.syncName(name)).catch((error) => {
|
|
368
|
+
process.stderr.write(`cci: native name sync failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
369
|
+
});
|
|
370
|
+
}, 1000);
|
|
371
|
+
nativeNameTimer.unref();
|
|
363
372
|
} catch (error) {
|
|
364
373
|
child.kill("SIGTERM");
|
|
365
374
|
await waitForChildExit(child).catch(() => undefined);
|
|
@@ -370,6 +379,8 @@ async function runCciTui(options: CciOptions, id: string, name: string, scopeId:
|
|
|
370
379
|
}
|
|
371
380
|
}
|
|
372
381
|
const [code, signal] = await waitForChildExit(child);
|
|
382
|
+
clearInterval(nativeNameTimer);
|
|
383
|
+
await nativeNameSync;
|
|
373
384
|
await bridge?.stop();
|
|
374
385
|
if (resolution.selected === "mcp") rmSync(inboxPath, { force: true });
|
|
375
386
|
if (typeof code === "number") return code;
|
package/claude/inbox-monitor.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
8
|
import { basename } from "node:path";
|
|
9
|
-
import { formatInboxLine, inboxBatchFrom } from "./inbox.ts";
|
|
9
|
+
import { defaultInboxPath, formatInboxLine, inboxBatchFrom } from "./inbox.ts";
|
|
10
|
+
import {
|
|
11
|
+
ClaudeSessionReader, claudeIdentityForSession, claudeSessionMetadataPath,
|
|
12
|
+
findClaudeHost, waitForClaudeSession,
|
|
13
|
+
} from "./session-lifecycle.ts";
|
|
10
14
|
|
|
11
15
|
const POLL_MS = 1000;
|
|
12
16
|
|
|
@@ -18,12 +22,24 @@ function readContent(path: string): string {
|
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
type InboxSource = string | (() => { path: string; startedAt?: number });
|
|
26
|
+
|
|
27
|
+
export async function runInboxMonitor(source: InboxSource, signal?: { aborted: boolean }): Promise<void> {
|
|
28
|
+
let path = "";
|
|
29
|
+
let emitted = 0;
|
|
24
30
|
for (;;) {
|
|
25
31
|
if (signal?.aborted) return;
|
|
26
|
-
const {
|
|
32
|
+
const next = typeof source === "string" ? { path: source } : source();
|
|
33
|
+
const content = readContent(next.path);
|
|
34
|
+
if (path !== next.path) {
|
|
35
|
+
path = next.path;
|
|
36
|
+
const backlog = inboxBatchFrom(content, 0);
|
|
37
|
+
// Skip old history, not messages received after SessionStart but before
|
|
38
|
+
// Monitor startup. A resumed/cleared conversation gets its own cutoff.
|
|
39
|
+
emitted = next.startedAt === undefined ? backlog.total
|
|
40
|
+
: backlog.entries.filter((entry) => entry.ts < next.startedAt!).length;
|
|
41
|
+
}
|
|
42
|
+
const { entries, total } = inboxBatchFrom(content, emitted);
|
|
27
43
|
for (const entry of entries) {
|
|
28
44
|
process.stdout.write(`${formatInboxLine(entry)}\n`);
|
|
29
45
|
}
|
|
@@ -33,12 +49,20 @@ export async function runInboxMonitor(path: string, signal?: { aborted: boolean
|
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
async function main(): Promise<void> {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
const explicitPath = process.env.CLAUDE_INTERCOM_INBOX || process.argv[2];
|
|
53
|
+
const host = findClaudeHost();
|
|
54
|
+
if (!host) {
|
|
55
|
+
if (!explicitPath) throw new Error("Cannot determine this Claude session's inbox");
|
|
56
|
+
await runInboxMonitor(explicitPath);
|
|
57
|
+
return;
|
|
40
58
|
}
|
|
41
|
-
|
|
59
|
+
const reader = new ClaudeSessionReader(claudeSessionMetadataPath(host), host);
|
|
60
|
+
await waitForClaudeSession(reader);
|
|
61
|
+
await runInboxMonitor(() => {
|
|
62
|
+
const metadata = reader.read();
|
|
63
|
+
const identity = claudeIdentityForSession(metadata, process.env, host.pid);
|
|
64
|
+
return { path: explicitPath || defaultInboxPath(identity.sessionId), startedAt: metadata?.startedAt };
|
|
65
|
+
});
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
if (process.argv[1] && (basename(process.argv[1]) === "inbox-monitor.ts" || basename(process.argv[1]) === "inbox-monitor.mjs")) {
|
package/claude/mcp-protocol.ts
CHANGED
|
@@ -248,7 +248,7 @@ export async function handleMcpRequest(request: JsonRpcRequest, runtime: ClaudeI
|
|
|
248
248
|
return ok(request.id, {
|
|
249
249
|
protocolVersion: "2025-06-18",
|
|
250
250
|
capabilities: { tools: {} },
|
|
251
|
-
serverInfo: { name: "claude-intercom", version: "0.
|
|
251
|
+
serverInfo: { name: "claude-intercom", version: "0.14.0" },
|
|
252
252
|
});
|
|
253
253
|
case "ping":
|
|
254
254
|
return ok(request.id, {});
|
package/claude/native-bridge.ts
CHANGED
|
@@ -36,7 +36,7 @@ interface NativeBridgeClient {
|
|
|
36
36
|
disconnect(): Promise<void>;
|
|
37
37
|
acknowledgeMessage(deliveryId: string): void;
|
|
38
38
|
send(to: string, message: { text: string; replyTo?: string }): Promise<{ delivered: boolean; reason?: string }>;
|
|
39
|
-
updatePresence(update: { status?: string }): void;
|
|
39
|
+
updatePresence(update: { name?: string; status?: string }): void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface NativeClaudeBridgeIdentity {
|
|
@@ -153,6 +153,13 @@ export class NativeClaudeBrokerBridge {
|
|
|
153
153
|
this.started = true;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
async syncName(name: string): Promise<void> {
|
|
157
|
+
if (!this.started || !name.trim() || name === this.identity.name) return;
|
|
158
|
+
this.identity.name = name;
|
|
159
|
+
this.client.updatePresence({ name });
|
|
160
|
+
await updateNativeClaudePeer(this.pid, { name: `${name} bridge` }, this.options.registryDir);
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
private async handleBrokerMessage(from: SessionInfo, message: Message, deliveryId: string): Promise<void> {
|
|
157
164
|
try {
|
|
158
165
|
this.client.updatePresence({ status: "active" });
|
package/claude/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { IntercomClient } from "../broker/client.ts";
|
|
|
6
6
|
import { intercomScopeIdFromEnvForRegistration } from "../protocol-v4/contract.ts";
|
|
7
7
|
import { spawnBrokerIfNeeded } from "../broker/spawn.ts";
|
|
8
8
|
import { getAskTimeoutMs, loadConfig } from "../config.ts";
|
|
9
|
-
import { appendInboxMessage } from "./inbox.ts";
|
|
9
|
+
import { appendInboxMessage, defaultInboxPath } from "./inbox.ts";
|
|
10
10
|
import type { Attachment, Message, SessionInfo } from "../types.ts";
|
|
11
11
|
import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
|
|
12
12
|
import {
|
|
@@ -219,6 +219,7 @@ export class ClaudeIntercomRuntime {
|
|
|
219
219
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
220
220
|
private reconnectAttempt = 0;
|
|
221
221
|
private reconnectEnabled = true;
|
|
222
|
+
private registrationConflict: Error | null = null;
|
|
222
223
|
private identity: ClaudeRuntimeIdentity;
|
|
223
224
|
private unread: PendingInboundMessage[] = [];
|
|
224
225
|
private unresolvedAsks = new Map<string, PendingInboundMessage>();
|
|
@@ -251,7 +252,37 @@ export class ClaudeIntercomRuntime {
|
|
|
251
252
|
return this.identity;
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
async syncSession(identity: ClaudeRuntimeIdentity): Promise<void> {
|
|
256
|
+
const changed = identity.sessionId !== this.identity.sessionId || identity.cwd !== this.identity.cwd;
|
|
257
|
+
if (changed) {
|
|
258
|
+
await this.disconnect();
|
|
259
|
+
this.unread = [];
|
|
260
|
+
this.unresolvedAsks.clear();
|
|
261
|
+
this.registrationConflict = null;
|
|
262
|
+
}
|
|
263
|
+
const renamed = identity.name !== this.identity.name;
|
|
264
|
+
const modelChanged = identity.model !== this.identity.model;
|
|
265
|
+
this.identity = identity;
|
|
266
|
+
if (changed) await this.connect();
|
|
267
|
+
else if (renamed || modelChanged) this.client?.updatePresence({ name: identity.name, model: identity.model });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private pauseOnRegistrationConflict(error: unknown): boolean {
|
|
271
|
+
// Framing/client wrappers preserve the broker error as an Error.cause.
|
|
272
|
+
let cause = error;
|
|
273
|
+
for (let depth = 0; cause instanceof Error && depth < 8; depth++, cause = cause.cause) {
|
|
274
|
+
if ((cause as Error & { code?: string }).code === "SESSION_ID_IN_USE") {
|
|
275
|
+
this.registrationConflict = cause;
|
|
276
|
+
this.reconnectEnabled = false;
|
|
277
|
+
this.clearReconnectTimer();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
254
284
|
async connect(): Promise<IntercomClient> {
|
|
285
|
+
if (this.registrationConflict) throw this.registrationConflict;
|
|
255
286
|
this.reconnectEnabled = true;
|
|
256
287
|
this.clearReconnectTimer();
|
|
257
288
|
if (this.client?.isConnected()) return this.client;
|
|
@@ -259,6 +290,9 @@ export class ClaudeIntercomRuntime {
|
|
|
259
290
|
this.connectPromise = this.connectOnce();
|
|
260
291
|
try {
|
|
261
292
|
return await this.connectPromise;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
this.pauseOnRegistrationConflict(error);
|
|
295
|
+
throw error;
|
|
262
296
|
} finally {
|
|
263
297
|
this.connectPromise = null;
|
|
264
298
|
}
|
|
@@ -271,26 +305,29 @@ export class ClaudeIntercomRuntime {
|
|
|
271
305
|
this.handleIncomingMessage(from, message);
|
|
272
306
|
client.acknowledgeMessage(deliveryId);
|
|
273
307
|
});
|
|
308
|
+
client.on("error", (error: Error) => {
|
|
309
|
+
this.pauseOnRegistrationConflict(error);
|
|
310
|
+
process.stderr.write(`claude-intercom: ${error.message}\n`);
|
|
311
|
+
});
|
|
274
312
|
client.on("disconnected", (error: Error) => {
|
|
275
|
-
|
|
276
|
-
clearTimeout(waiter.timeout);
|
|
277
|
-
waiter.cleanup?.();
|
|
278
|
-
waiter.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
279
|
-
}
|
|
280
|
-
this.replyWaiters.clear();
|
|
313
|
+
this.rejectReplyWaiters(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
281
314
|
if (this.client === client) this.client = null;
|
|
282
315
|
this.scheduleReconnect();
|
|
283
316
|
});
|
|
317
|
+
const registeredIdentity = this.identity;
|
|
284
318
|
await client.connect({
|
|
285
|
-
name:
|
|
286
|
-
cwd:
|
|
287
|
-
model:
|
|
319
|
+
name: registeredIdentity.name,
|
|
320
|
+
cwd: registeredIdentity.cwd,
|
|
321
|
+
model: registeredIdentity.model,
|
|
288
322
|
pid: process.pid,
|
|
289
|
-
startedAt:
|
|
323
|
+
startedAt: registeredIdentity.startedAt,
|
|
290
324
|
lastActivity: Date.now(),
|
|
291
325
|
status: "idle",
|
|
292
|
-
},
|
|
326
|
+
}, registeredIdentity.sessionId);
|
|
293
327
|
this.client = client;
|
|
328
|
+
if (registeredIdentity.name !== this.identity.name || registeredIdentity.model !== this.identity.model) {
|
|
329
|
+
client.updatePresence({ name: this.identity.name, model: this.identity.model });
|
|
330
|
+
}
|
|
294
331
|
this.reconnectAttempt = 0;
|
|
295
332
|
return client;
|
|
296
333
|
}
|
|
@@ -319,8 +356,18 @@ export class ClaudeIntercomRuntime {
|
|
|
319
356
|
this.reconnectTimer = null;
|
|
320
357
|
}
|
|
321
358
|
|
|
359
|
+
private rejectReplyWaiters(error: Error): void {
|
|
360
|
+
for (const waiter of this.replyWaiters.values()) {
|
|
361
|
+
clearTimeout(waiter.timeout);
|
|
362
|
+
waiter.cleanup?.();
|
|
363
|
+
waiter.reject(error);
|
|
364
|
+
}
|
|
365
|
+
this.replyWaiters.clear();
|
|
366
|
+
}
|
|
367
|
+
|
|
322
368
|
async disconnect(): Promise<void> {
|
|
323
369
|
this.reconnectEnabled = false;
|
|
370
|
+
this.rejectReplyWaiters(new Error("Claude session disconnected while waiting for reply"));
|
|
324
371
|
this.clearReconnectTimer();
|
|
325
372
|
if (this.connectPromise) {
|
|
326
373
|
try {
|
|
@@ -398,16 +445,12 @@ export class ClaudeIntercomRuntime {
|
|
|
398
445
|
this.unresolvedAsks.set(message.id, entry);
|
|
399
446
|
}
|
|
400
447
|
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
appendInboxMessage(inboxPath, from, message);
|
|
408
|
-
} catch {
|
|
409
|
-
// Best-effort delivery mirror only.
|
|
410
|
-
}
|
|
448
|
+
// Ordinary plugin launches and cci share the same inbox/Monitor delivery.
|
|
449
|
+
// Keep the persistent MCP connection as the sole owner of this identity.
|
|
450
|
+
try {
|
|
451
|
+
appendInboxMessage(process.env.CLAUDE_INTERCOM_INBOX || defaultInboxPath(this.identity.sessionId), from, message);
|
|
452
|
+
} catch {
|
|
453
|
+
// Best-effort delivery mirror only; never break broker ACK handling.
|
|
411
454
|
}
|
|
412
455
|
}
|
|
413
456
|
|
|
@@ -462,6 +505,13 @@ export class ClaudeIntercomRuntime {
|
|
|
462
505
|
}
|
|
463
506
|
|
|
464
507
|
async status(): Promise<ToolResult> {
|
|
508
|
+
if (this.registrationConflict) {
|
|
509
|
+
return textResult(`Connected: No\nSession ID: ${this.identity.sessionId}\nRegistration conflict: ${this.registrationConflict.message}\nAutomatic reconnect paused; the incumbent session was not replaced.`, {
|
|
510
|
+
connected: false, session_id: this.identity.sessionId,
|
|
511
|
+
registration_conflict: { code: "SESSION_ID_IN_USE", message: this.registrationConflict.message },
|
|
512
|
+
reconnect_paused: true,
|
|
513
|
+
}, true);
|
|
514
|
+
}
|
|
465
515
|
const client = await this.connect();
|
|
466
516
|
const sessions = await client.listSessions();
|
|
467
517
|
return textResult(
|
package/claude/server.ts
CHANGED
|
@@ -2,24 +2,56 @@ import readline from "node:readline";
|
|
|
2
2
|
import { stdin, stdout } from "node:process";
|
|
3
3
|
import { ClaudeIntercomRuntime } from "./runtime.ts";
|
|
4
4
|
import { handleMcpRequest } from "./mcp-protocol.ts";
|
|
5
|
+
import {
|
|
6
|
+
ClaudeSessionReader, claudeIdentityForSession, claudeSessionMetadataPath,
|
|
7
|
+
findClaudeHost, waitForClaudeSession,
|
|
8
|
+
} from "./session-lifecycle.ts";
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
let stopPolling = () => {};
|
|
12
|
+
let pendingSync = Promise.resolve();
|
|
13
|
+
const report = (error: unknown) => {
|
|
14
|
+
process.stderr.write(`claude-intercom: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
15
|
+
};
|
|
7
16
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
// Companion MCP launchers await this same owner to synchronize native titles.
|
|
18
|
+
export const runtimeReady = (async () => {
|
|
19
|
+
const host = findClaudeHost();
|
|
20
|
+
const reader = host ? new ClaudeSessionReader(claudeSessionMetadataPath(host), host) : undefined;
|
|
21
|
+
const metadata = reader ? await waitForClaudeSession(reader) : undefined;
|
|
22
|
+
const runtime = new ClaudeIntercomRuntime(claudeIdentityForSession(metadata, process.env, host?.pid));
|
|
23
|
+
// Always register, including ordinary MCP launches. Hook/Monitor processes
|
|
24
|
+
// never connect to the broker, so there is still exactly one owner.
|
|
25
|
+
if (!shuttingDown && !metadata?.ended) void runtime.connect().catch(report);
|
|
26
|
+
if (reader && !shuttingDown) {
|
|
27
|
+
let previous = JSON.stringify(metadata);
|
|
28
|
+
let syncing = false;
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
if (syncing || shuttingDown) return;
|
|
31
|
+
const next = reader.read();
|
|
32
|
+
const signature = JSON.stringify(next);
|
|
33
|
+
if (!next || signature === previous) return;
|
|
34
|
+
previous = signature;
|
|
35
|
+
syncing = true;
|
|
36
|
+
pendingSync = (async () => {
|
|
37
|
+
if (next.ended) await runtime.disconnect();
|
|
38
|
+
else {
|
|
39
|
+
await runtime.syncSession(claudeIdentityForSession(next, process.env, host!.pid));
|
|
40
|
+
await runtime.connect();
|
|
41
|
+
}
|
|
42
|
+
})().catch(report).finally(() => { syncing = false; });
|
|
43
|
+
}, 250);
|
|
44
|
+
timer.unref();
|
|
45
|
+
stopPolling = () => clearInterval(timer);
|
|
46
|
+
}
|
|
47
|
+
return runtime;
|
|
48
|
+
})();
|
|
16
49
|
|
|
17
50
|
const rl = readline.createInterface({
|
|
18
51
|
input: stdin,
|
|
19
52
|
crlfDelay: Infinity,
|
|
20
53
|
});
|
|
21
54
|
|
|
22
|
-
let shuttingDown = false;
|
|
23
55
|
let pendingRequests = 0;
|
|
24
56
|
|
|
25
57
|
function writeResponse(response: Record<string, unknown> | undefined): void {
|
|
@@ -29,7 +61,11 @@ function writeResponse(response: Record<string, unknown> | undefined): void {
|
|
|
29
61
|
|
|
30
62
|
function maybeShutdown(): void {
|
|
31
63
|
if (!shuttingDown || pendingRequests > 0) return;
|
|
32
|
-
|
|
64
|
+
stopPolling();
|
|
65
|
+
void runtimeReady.then(async (runtime) => {
|
|
66
|
+
await pendingSync;
|
|
67
|
+
await runtime.disconnect();
|
|
68
|
+
}).finally(() => process.exit(0));
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
rl.on("line", (line) => {
|
|
@@ -39,7 +75,7 @@ rl.on("line", (line) => {
|
|
|
39
75
|
void (async () => {
|
|
40
76
|
try {
|
|
41
77
|
const request = JSON.parse(trimmed);
|
|
42
|
-
writeResponse(await handleMcpRequest(request,
|
|
78
|
+
writeResponse(await handleMcpRequest(request, await runtimeReady));
|
|
43
79
|
} catch (error) {
|
|
44
80
|
writeResponse({
|
|
45
81
|
jsonrpc: "2.0",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { findClaudeHost, publishClaudeSessionMetadata } from "./session-lifecycle.ts";
|
|
3
|
+
|
|
4
|
+
// Do not connect to the broker here: short-lived hooks must never compete with
|
|
5
|
+
// the session's persistent MCP runtime for the same stable ID.
|
|
6
|
+
try {
|
|
7
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
8
|
+
const host = findClaudeHost();
|
|
9
|
+
if (host && input && typeof input === "object" && !Array.isArray(input)) {
|
|
10
|
+
publishClaudeSessionMetadata(input, host);
|
|
11
|
+
}
|
|
12
|
+
} catch (error) {
|
|
13
|
+
process.stderr.write(`claude-intercom hook: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
14
|
+
}
|