@ctliz/agent-intercom-claude 0.13.0-connect.9 → 0.14.0
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 -3
- 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 +47 -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 +254 -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.0 - 2026-09-30
|
|
6
|
+
|
|
7
|
+
- Register ordinary MCP launches before the first prompt/tool call; packaged SessionStart/SessionEnd hooks synchronize native conversation identity without creating a competing broker connection.
|
|
8
|
+
- 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.
|
|
9
|
+
- Retain messages received during Monitor startup and follow conversation changes after `/clear`.
|
|
10
|
+
- Pause automatic reconnect on `SESSION_ID_IN_USE`, expose the conflict in `intercom_status`, and cancel pending reply waiters when a session ends.
|
|
11
|
+
|
|
12
|
+
## 0.13.1 - 2026-09-06
|
|
13
|
+
|
|
14
|
+
- Stable release of named teams without tmux. `intercom_join`, `/intercom-create`, and `/intercom-join` work outside TmuxDeck.
|
|
15
|
+
|
|
5
16
|
## 0.13.0-connect.9 - 2026-09-06
|
|
6
17
|
|
|
7
18
|
- Add `intercom_join` so Claude sessions can create or join a named team without tmux. `/intercom-create` and `/intercom-join` call the same tool. Named teams share `~/.pi/agent/intercom/named-teams.json` with Pi, Codex, and OpenCode.
|
package/README.md
CHANGED
|
@@ -129,13 +129,13 @@ Install via npm using the `connect` dist-tag:
|
|
|
129
129
|
```bash
|
|
130
130
|
npm install -g @ctliz/agent-intercom-claude@connect
|
|
131
131
|
# or by exact prerelease version
|
|
132
|
-
npm install -g @ctliz/agent-intercom-claude@0.13.
|
|
132
|
+
npm install -g @ctliz/agent-intercom-claude@0.13.1
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
Or install from GitHub source at the exact tag so the command-line entry points are on `PATH`:
|
|
136
136
|
|
|
137
137
|
```bash
|
|
138
|
-
git clone --depth 1 --branch v0.13.
|
|
138
|
+
git clone --depth 1 --branch v0.13.1 https://github.com/ctliz/agent-intercom-claude.git
|
|
139
139
|
cd agent-intercom-claude && npm ci && npm link
|
|
140
140
|
```
|
|
141
141
|
|
|
@@ -149,7 +149,7 @@ This provides:
|
|
|
149
149
|
To let a Pi manager create Claude workers with owned systemd cgroups, leases, model/effort selection, logs, and verified cleanup, install the companion Pi packages:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
pi install git:github.com/ctliz/agent-intercom-pi@v0.12.
|
|
152
|
+
pi install git:github.com/ctliz/agent-intercom-pi@v0.12.2
|
|
153
153
|
pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.12.0-connect.5
|
|
154
154
|
```
|
|
155
155
|
|
|
@@ -375,6 +375,40 @@ Choose explicitly with `--transport native` or `--transport mcp`, or set `CLAUDE
|
|
|
375
375
|
|
|
376
376
|
`--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
377
|
|
|
378
|
+
### Startup registration and native session names
|
|
379
|
+
|
|
380
|
+
The MCP server now registers as soon as it starts, without waiting for a prompt
|
|
381
|
+
or an Intercom tool call. Bare MCP configuration provides discovery and queued
|
|
382
|
+
messages; load the packaged plugin for hooks and automatic Monitor delivery:
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
claude --plugin-dir /path/to/agent-intercom-claude --name reviewer
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
On macOS/Linux, the plugin's `SessionStart`/`SessionEnd` hooks publish private
|
|
389
|
+
metadata only. The persistent MCP server is the **only** broker connection.
|
|
390
|
+
Without a launcher-provided ID, Intercom uses `claude-<native-session-id>`, so
|
|
391
|
+
resuming a conversation preserves its target and `/clear` releases the old
|
|
392
|
+
conversation before registering the new one. Explicit Claude/generic launcher
|
|
393
|
+
IDs retain their existing precedence.
|
|
394
|
+
|
|
395
|
+
`cci --tui --name reviewer` also passes Claude's native `--name` flag. `/rename`
|
|
396
|
+
updates broker presence without changing the stable ID. MCP mode incrementally
|
|
397
|
+
tails only native `custom-title` transcript records; it never replaces your
|
|
398
|
+
statusline configuration. Native mode follows the verified socket registry's
|
|
399
|
+
name. The native transport version gate remains **2.1.220–2.1.226**; Claude
|
|
400
|
+
2.1.285 was smoke-tested through MCP, not added to that gate.
|
|
401
|
+
|
|
402
|
+
The Monitor skips historical inbox entries but retains messages that arrived
|
|
403
|
+
after `SessionStart`, even if they preceded Monitor startup. Unsupported host
|
|
404
|
+
process discovery falls back to eager MCP registration and explicit launcher
|
|
405
|
+
identity/inbox settings, as used by `cci`.
|
|
406
|
+
|
|
407
|
+
Enable Intercom once per Claude process: loading the plugin and a second manual
|
|
408
|
+
MCP entry for the same identity creates competing runtimes. `SESSION_ID_IN_USE`
|
|
409
|
+
leaves the incumbent authoritative, pauses automatic retries, and is exposed by
|
|
410
|
+
`intercom_status`. Do not share a fixed launcher ID between concurrent sessions.
|
|
411
|
+
|
|
378
412
|
## Normal And Minimal Workers
|
|
379
413
|
|
|
380
414
|
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,55 @@ 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
|
+
const runtimeReady = (async () => {
|
|
18
|
+
const host = findClaudeHost();
|
|
19
|
+
const reader = host ? new ClaudeSessionReader(claudeSessionMetadataPath(host), host) : undefined;
|
|
20
|
+
const metadata = reader ? await waitForClaudeSession(reader) : undefined;
|
|
21
|
+
const runtime = new ClaudeIntercomRuntime(claudeIdentityForSession(metadata, process.env, host?.pid));
|
|
22
|
+
// Always register, including ordinary MCP launches. Hook/Monitor processes
|
|
23
|
+
// never connect to the broker, so there is still exactly one owner.
|
|
24
|
+
if (!shuttingDown && !metadata?.ended) void runtime.connect().catch(report);
|
|
25
|
+
if (reader && !shuttingDown) {
|
|
26
|
+
let previous = JSON.stringify(metadata);
|
|
27
|
+
let syncing = false;
|
|
28
|
+
const timer = setInterval(() => {
|
|
29
|
+
if (syncing || shuttingDown) return;
|
|
30
|
+
const next = reader.read();
|
|
31
|
+
const signature = JSON.stringify(next);
|
|
32
|
+
if (!next || signature === previous) return;
|
|
33
|
+
previous = signature;
|
|
34
|
+
syncing = true;
|
|
35
|
+
pendingSync = (async () => {
|
|
36
|
+
if (next.ended) await runtime.disconnect();
|
|
37
|
+
else {
|
|
38
|
+
await runtime.syncSession(claudeIdentityForSession(next, process.env, host!.pid));
|
|
39
|
+
await runtime.connect();
|
|
40
|
+
}
|
|
41
|
+
})().catch(report).finally(() => { syncing = false; });
|
|
42
|
+
}, 250);
|
|
43
|
+
timer.unref();
|
|
44
|
+
stopPolling = () => clearInterval(timer);
|
|
45
|
+
}
|
|
46
|
+
return runtime;
|
|
47
|
+
})();
|
|
16
48
|
|
|
17
49
|
const rl = readline.createInterface({
|
|
18
50
|
input: stdin,
|
|
19
51
|
crlfDelay: Infinity,
|
|
20
52
|
});
|
|
21
53
|
|
|
22
|
-
let shuttingDown = false;
|
|
23
54
|
let pendingRequests = 0;
|
|
24
55
|
|
|
25
56
|
function writeResponse(response: Record<string, unknown> | undefined): void {
|
|
@@ -29,7 +60,11 @@ function writeResponse(response: Record<string, unknown> | undefined): void {
|
|
|
29
60
|
|
|
30
61
|
function maybeShutdown(): void {
|
|
31
62
|
if (!shuttingDown || pendingRequests > 0) return;
|
|
32
|
-
|
|
63
|
+
stopPolling();
|
|
64
|
+
void runtimeReady.then(async (runtime) => {
|
|
65
|
+
await pendingSync;
|
|
66
|
+
await runtime.disconnect();
|
|
67
|
+
}).finally(() => process.exit(0));
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
rl.on("line", (line) => {
|
|
@@ -39,7 +74,7 @@ rl.on("line", (line) => {
|
|
|
39
74
|
void (async () => {
|
|
40
75
|
try {
|
|
41
76
|
const request = JSON.parse(trimmed);
|
|
42
|
-
writeResponse(await handleMcpRequest(request,
|
|
77
|
+
writeResponse(await handleMcpRequest(request, await runtimeReady));
|
|
43
78
|
} catch (error) {
|
|
44
79
|
writeResponse({
|
|
45
80
|
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
|
+
}
|