@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-intercom",
3
- "version": "0.1.0",
3
+ "version": "0.14.0",
4
4
  "description": "Claude Code MCP plugin and wakeable worker for local Pi/Codex/Claude intercom messaging.",
5
5
  "author": {
6
6
  "name": "ctliz",
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.0-connect.9
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.0-connect.9 https://github.com/ctliz/agent-intercom-claude.git
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.0-connect.9
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;
@@ -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
- 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;
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 { entries, total } = inboxBatchFrom(readContent(path), emitted);
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 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);
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
- await runInboxMonitor(path);
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")) {
@@ -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.1.0" },
251
+ serverInfo: { name: "claude-intercom", version: "0.14.0" },
252
252
  });
253
253
  case "ping":
254
254
  return ok(request.id, {});
@@ -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
- for (const waiter of this.replyWaiters.values()) {
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: this.identity.name,
286
- cwd: this.identity.cwd,
287
- model: this.identity.model,
319
+ name: registeredIdentity.name,
320
+ cwd: registeredIdentity.cwd,
321
+ model: registeredIdentity.model,
288
322
  pid: process.pid,
289
- startedAt: this.identity.startedAt,
323
+ startedAt: registeredIdentity.startedAt,
290
324
  lastActivity: Date.now(),
291
325
  status: "idle",
292
- }, this.identity.sessionId);
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
- // TUI mode: mirror the message to the session inbox so the plugin monitor
402
- // can inject it into the live interactive session. Never let inbox I/O
403
- // break normal message handling.
404
- const inboxPath = process.env.CLAUDE_INTERCOM_INBOX;
405
- if (inboxPath) {
406
- try {
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
- const runtime = new ClaudeIntercomRuntime();
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
- // 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
- }
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
- void runtime.disconnect().finally(() => process.exit(0));
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, runtime));
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
+ }