@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.
@@ -0,0 +1,165 @@
1
+ // Hooks publish metadata only. The MCP server remains the sole broker owner.
2
+ import { execFileSync } from "node:child_process";
3
+ import { closeSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { ensureIntercomRuntimeDir, getIntercomDirPath } from "../broker/paths.ts";
8
+ import { buildClaudeRuntimeIdentity, type ClaudeRuntimeIdentity } from "./runtime.ts";
9
+
10
+ export interface ClaudeHost {
11
+ pid: number;
12
+ startedAt: string;
13
+ }
14
+
15
+ export interface ClaudeSessionMetadata {
16
+ hostStartedAt: string;
17
+ sessionId: string;
18
+ cwd: string;
19
+ name?: string;
20
+ model?: string;
21
+ transcriptPath: string;
22
+ startedAt: number;
23
+ ended: boolean;
24
+ }
25
+
26
+ // Hooks may have a shell parent, while MCP/Monitor processes are direct children.
27
+ // Match the nearest actual Claude executable, never another session in the cwd.
28
+ export function findClaudeHost(pid = process.ppid): ClaudeHost | undefined {
29
+ for (let depth = 0; pid > 1 && depth < 16; depth++) {
30
+ let parts: string[];
31
+ try {
32
+ parts = execFileSync("ps", ["-p", String(pid), "-o", "ppid=", "-o", "lstart=", "-o", "comm="],
33
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\s+/);
34
+ } catch { return undefined; }
35
+ const command = basename(parts.slice(6).join(" "));
36
+ if (/^claude(?:\.exe)?$/i.test(command)) {
37
+ return { pid, startedAt: parts.slice(1, 6).join(" ") };
38
+ }
39
+ // Do not attach a different harness launched by a Claude Bash tool to
40
+ // that outer Claude's identity. Only traverse hook-launcher shells.
41
+ if (!/^(sh|bash|zsh|dash|fish)$/.test(command)) return undefined;
42
+ pid = Number(parts[0]);
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ export function claudeSessionMetadataPath(host: ClaudeHost): string {
48
+ return join(getIntercomDirPath(), `claude-host-${host.pid}.json`);
49
+ }
50
+
51
+ export function readClaudeSessionMetadata(path: string, host: ClaudeHost): ClaudeSessionMetadata | undefined {
52
+ try {
53
+ const data = JSON.parse(readFileSync(path, "utf8"));
54
+ if (data.hostStartedAt !== host.startedAt || typeof data.sessionId !== "string"
55
+ || !/^[A-Za-z0-9_-]{1,128}$/.test(data.sessionId) || typeof data.cwd !== "string"
56
+ || typeof data.transcriptPath !== "string" || typeof data.startedAt !== "number"
57
+ || typeof data.ended !== "boolean"
58
+ || (data.name !== undefined && typeof data.name !== "string")
59
+ || (data.model !== undefined && typeof data.model !== "string")) return undefined;
60
+ return data;
61
+ } catch { return undefined; }
62
+ }
63
+
64
+ export function publishClaudeSessionMetadata(input: Record<string, unknown>, host: ClaudeHost,
65
+ env: NodeJS.ProcessEnv = process.env, path = claudeSessionMetadataPath(host)): void {
66
+ if (typeof input.session_id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input.session_id)) return;
67
+ const previous = readClaudeSessionMetadata(path, host);
68
+ const ended = input.hook_event_name === "SessionEnd";
69
+ // A delayed end for an old conversation must not disconnect its replacement.
70
+ if (ended && previous?.sessionId !== input.session_id) return;
71
+ if (!ended && (typeof input.cwd !== "string" || typeof input.transcript_path !== "string")) return;
72
+ const name = typeof input.session_title === "string" && input.session_title.trim()
73
+ ? input.session_title : buildClaudeRuntimeIdentity(env, input.cwd as string, host.pid).name;
74
+ const data: ClaudeSessionMetadata = ended ? { ...previous!, ended: true } : {
75
+ hostStartedAt: host.startedAt, sessionId: input.session_id,
76
+ cwd: input.cwd as string, transcriptPath: input.transcript_path as string,
77
+ name, ...(typeof input.model === "string" ? { model: input.model } : {}),
78
+ startedAt: Date.now(), ended: false,
79
+ };
80
+ ensureIntercomRuntimeDir(dirname(path));
81
+ const temp = `${path}.${process.pid}.tmp`;
82
+ writeFileSync(temp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
83
+ renameSync(temp, path);
84
+ }
85
+
86
+ export function claudeIdentityForSession(metadata: ClaudeSessionMetadata | undefined,
87
+ env: NodeJS.ProcessEnv = process.env, pid = process.pid): ClaudeRuntimeIdentity {
88
+ const identity = buildClaudeRuntimeIdentity(env, metadata?.cwd ?? env.PWD ?? process.cwd(), pid);
89
+ if (!metadata) return identity;
90
+ if (!env.CLAUDE_INTERCOM_SESSION_ID?.trim() && !env.CLAUDE_PEER_ID?.trim() && !env.AGENT_INTERCOM_SESSION_ID?.trim()) {
91
+ identity.sessionId = `claude-${metadata.sessionId}`;
92
+ }
93
+ identity.name = metadata.name?.trim() || identity.name;
94
+ if (!env.CLAUDE_INTERCOM_MODEL?.trim()) identity.model = metadata.model?.trim() || identity.model;
95
+ identity.startedAt = metadata.startedAt;
96
+ return identity;
97
+ }
98
+
99
+ // Incremental transcript tail: /rename writes a custom-title record, even without
100
+ // a model turn. Never rescan conversation bodies on every presence poll.
101
+ export class ClaudeSessionReader {
102
+ private transcriptPath = "";
103
+ private sessionId = "";
104
+ private offset = 0;
105
+ private partial = "";
106
+ private decoder = new StringDecoder("utf8");
107
+ private name?: string;
108
+ private startedAt = 0;
109
+
110
+ constructor(private readonly path: string, private readonly host: ClaudeHost) {}
111
+
112
+ read(): ClaudeSessionMetadata | undefined {
113
+ const metadata = readClaudeSessionMetadata(this.path, this.host);
114
+ if (!metadata || metadata.ended) return metadata;
115
+ if (metadata.sessionId !== this.sessionId || metadata.transcriptPath !== this.transcriptPath
116
+ || metadata.startedAt !== this.startedAt) {
117
+ this.sessionId = metadata.sessionId;
118
+ this.transcriptPath = metadata.transcriptPath;
119
+ this.startedAt = metadata.startedAt;
120
+ this.name = undefined;
121
+ this.partial = "";
122
+ this.decoder = new StringDecoder("utf8");
123
+ // A bounded tail also catches renames between the hook and MCP startup.
124
+ try { this.offset = Math.max(0, statSync(this.transcriptPath).size - 64 * 1024); } catch { this.offset = 0; }
125
+ }
126
+ let fd: number | undefined;
127
+ try {
128
+ fd = openSync(this.transcriptPath, "r");
129
+ const size = statSync(this.transcriptPath).size;
130
+ if (size < this.offset) {
131
+ this.offset = 0;
132
+ this.partial = "";
133
+ this.decoder = new StringDecoder("utf8");
134
+ }
135
+ // Bound reads and memory even during large tool-output writes.
136
+ const buffer = Buffer.alloc(Math.min(size - this.offset, 64 * 1024));
137
+ const count = readSync(fd, buffer, 0, buffer.length, this.offset);
138
+ this.offset += count;
139
+ const lines = (this.partial + this.decoder.write(buffer.subarray(0, count))).split("\n");
140
+ this.partial = lines.pop() ?? "";
141
+ if (this.partial.length > 64 * 1024) this.partial = "";
142
+ for (const line of lines) {
143
+ if (!line.includes('"custom-title"')) continue;
144
+ try {
145
+ const record = JSON.parse(line);
146
+ if (record.type === "custom-title" && record.sessionId === metadata.sessionId
147
+ && typeof record.customTitle === "string" && record.customTitle.trim()) this.name = record.customTitle;
148
+ } catch { /* Torn or unrelated transcript records are not presence updates. */ }
149
+ }
150
+ } catch { /* Transcripts are created asynchronously and can be rotated. */ }
151
+ finally { if (fd !== undefined) closeSync(fd); }
152
+ return { ...metadata, name: this.name ?? metadata.name };
153
+ }
154
+ }
155
+
156
+ export async function waitForClaudeSession(reader: ClaudeSessionReader): Promise<ClaudeSessionMetadata | undefined> {
157
+ // SessionStart and MCP/Monitor startup can race. Wait briefly for the hook,
158
+ // but standalone MCP configurations without the plugin must still connect.
159
+ for (let attempt = 0; attempt < 20; attempt++) {
160
+ const metadata = reader.read();
161
+ if (metadata) return metadata;
162
+ await delay(50);
163
+ }
164
+ return reader.read();
165
+ }
package/dist/cci.mjs CHANGED
@@ -3062,6 +3062,23 @@ async function deregisterNativeClaudePeer(pid, socketPath, registryDir = DEFAULT
3062
3062
  await unlink(join8(registryDir, `${pid}.json`)).catch(() => void 0);
3063
3063
  if (socketPath) await unlink(socketPath).catch(() => void 0);
3064
3064
  }
3065
+ function nativePeerNameBySocket(socketPath, registryDir = DEFAULT_CLAUDE_SESSION_REGISTRY) {
3066
+ if (!socketPath) return void 0;
3067
+ try {
3068
+ for (const file of readdirSync(registryDir)) {
3069
+ if (!/^\d+\.json$/.test(file)) continue;
3070
+ let entry;
3071
+ try {
3072
+ entry = JSON.parse(readFileSync7(join8(registryDir, file), "utf8"));
3073
+ } catch {
3074
+ continue;
3075
+ }
3076
+ if (entry.messagingSocketPath === socketPath && typeof entry.name === "string") return entry.name;
3077
+ }
3078
+ } catch {
3079
+ }
3080
+ return void 0;
3081
+ }
3065
3082
 
3066
3083
  // claude/native-bridge.ts
3067
3084
  function closeServer(server) {
@@ -3141,6 +3158,12 @@ var NativeClaudeBrokerBridge = class {
3141
3158
  }, this.identity.id);
3142
3159
  this.started = true;
3143
3160
  }
3161
+ async syncName(name) {
3162
+ if (!this.started || !name.trim() || name === this.identity.name) return;
3163
+ this.identity.name = name;
3164
+ this.client.updatePresence({ name });
3165
+ await updateNativeClaudePeer(this.pid, { name: `${name} bridge` }, this.options.registryDir);
3166
+ }
3144
3167
  async handleBrokerMessage(from, message, deliveryId) {
3145
3168
  try {
3146
3169
  this.client.updatePresence({ status: "active" });
@@ -3436,7 +3459,7 @@ async function runCciTui(options, id, name, scopeId) {
3436
3459
  if (resolution.selected === "mcp") {
3437
3460
  rmSync(inboxPath, { force: true });
3438
3461
  }
3439
- const args = ["--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
3462
+ const args = ["--name", name, "--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
3440
3463
  if (resolution.selected === "mcp") args.unshift("--plugin-dir", root);
3441
3464
  const permission = resolveClaudePermissionPolicy(options);
3442
3465
  if (options.model) args.push("--model", options.model);
@@ -3462,12 +3485,22 @@ async function runCciTui(options, id, name, scopeId) {
3462
3485
  }
3463
3486
  });
3464
3487
  let bridge;
3488
+ let nativeNameTimer;
3489
+ let nativeNameSync = Promise.resolve();
3465
3490
  if (resolution.selected === "native") {
3466
3491
  try {
3467
3492
  if (!child.pid) throw new Error("Claude started without a process id; native transport cannot attach");
3468
3493
  const peer = await waitForNativeClaudePeer(child.pid);
3469
3494
  bridge = new NativeClaudeBrokerBridge({ id, name, cwd: options.cwd, model: options.model }, { scopeId });
3470
3495
  await bridge.start(peer.socketPath);
3496
+ nativeNameTimer = setInterval(() => {
3497
+ const name2 = nativePeerNameBySocket(peer.socketPath);
3498
+ if (name2) nativeNameSync = nativeNameSync.then(() => bridge.syncName(name2)).catch((error) => {
3499
+ process.stderr.write(`cci: native name sync failed: ${error instanceof Error ? error.message : String(error)}
3500
+ `);
3501
+ });
3502
+ }, 1e3);
3503
+ nativeNameTimer.unref();
3471
3504
  } catch (error) {
3472
3505
  child.kill("SIGTERM");
3473
3506
  await waitForChildExit(child).catch(() => void 0);
@@ -3479,6 +3512,8 @@ async function runCciTui(options, id, name, scopeId) {
3479
3512
  }
3480
3513
  }
3481
3514
  const [code, signal] = await waitForChildExit(child);
3515
+ clearInterval(nativeNameTimer);
3516
+ await nativeNameSync;
3482
3517
  await bridge?.stop();
3483
3518
  if (resolution.selected === "mcp") rmSync(inboxPath, { force: true });
3484
3519
  if (typeof code === "number") return code;
package/dist/ccim.mjs CHANGED
@@ -3066,6 +3066,23 @@ async function deregisterNativeClaudePeer(pid, socketPath, registryDir = DEFAULT
3066
3066
  await unlink(join8(registryDir, `${pid}.json`)).catch(() => void 0);
3067
3067
  if (socketPath) await unlink(socketPath).catch(() => void 0);
3068
3068
  }
3069
+ function nativePeerNameBySocket(socketPath, registryDir = DEFAULT_CLAUDE_SESSION_REGISTRY) {
3070
+ if (!socketPath) return void 0;
3071
+ try {
3072
+ for (const file of readdirSync(registryDir)) {
3073
+ if (!/^\d+\.json$/.test(file)) continue;
3074
+ let entry;
3075
+ try {
3076
+ entry = JSON.parse(readFileSync7(join8(registryDir, file), "utf8"));
3077
+ } catch {
3078
+ continue;
3079
+ }
3080
+ if (entry.messagingSocketPath === socketPath && typeof entry.name === "string") return entry.name;
3081
+ }
3082
+ } catch {
3083
+ }
3084
+ return void 0;
3085
+ }
3069
3086
 
3070
3087
  // claude/native-bridge.ts
3071
3088
  function closeServer(server) {
@@ -3145,6 +3162,12 @@ var NativeClaudeBrokerBridge = class {
3145
3162
  }, this.identity.id);
3146
3163
  this.started = true;
3147
3164
  }
3165
+ async syncName(name) {
3166
+ if (!this.started || !name.trim() || name === this.identity.name) return;
3167
+ this.identity.name = name;
3168
+ this.client.updatePresence({ name });
3169
+ await updateNativeClaudePeer(this.pid, { name: `${name} bridge` }, this.options.registryDir);
3170
+ }
3148
3171
  async handleBrokerMessage(from, message, deliveryId) {
3149
3172
  try {
3150
3173
  this.client.updatePresence({ status: "active" });
@@ -3440,7 +3463,7 @@ async function runCciTui(options, id, name, scopeId) {
3440
3463
  if (resolution.selected === "mcp") {
3441
3464
  rmSync(inboxPath, { force: true });
3442
3465
  }
3443
- const args = ["--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
3466
+ const args = ["--name", name, "--append-system-prompt", buildTuiAppendSystemPrompt(name, id, resolution.selected)];
3444
3467
  if (resolution.selected === "mcp") args.unshift("--plugin-dir", root);
3445
3468
  const permission = resolveClaudePermissionPolicy(options);
3446
3469
  if (options.model) args.push("--model", options.model);
@@ -3466,12 +3489,22 @@ async function runCciTui(options, id, name, scopeId) {
3466
3489
  }
3467
3490
  });
3468
3491
  let bridge;
3492
+ let nativeNameTimer;
3493
+ let nativeNameSync = Promise.resolve();
3469
3494
  if (resolution.selected === "native") {
3470
3495
  try {
3471
3496
  if (!child.pid) throw new Error("Claude started without a process id; native transport cannot attach");
3472
3497
  const peer = await waitForNativeClaudePeer(child.pid);
3473
3498
  bridge = new NativeClaudeBrokerBridge({ id, name, cwd: options.cwd, model: options.model }, { scopeId });
3474
3499
  await bridge.start(peer.socketPath);
3500
+ nativeNameTimer = setInterval(() => {
3501
+ const name2 = nativePeerNameBySocket(peer.socketPath);
3502
+ if (name2) nativeNameSync = nativeNameSync.then(() => bridge.syncName(name2)).catch((error) => {
3503
+ process.stderr.write(`cci: native name sync failed: ${error instanceof Error ? error.message : String(error)}
3504
+ `);
3505
+ });
3506
+ }, 1e3);
3507
+ nativeNameTimer.unref();
3475
3508
  } catch (error) {
3476
3509
  child.kill("SIGTERM");
3477
3510
  await waitForChildExit(child).catch(() => void 0);
@@ -3483,6 +3516,8 @@ async function runCciTui(options, id, name, scopeId) {
3483
3516
  }
3484
3517
  }
3485
3518
  const [code, signal] = await waitForChildExit(child);
3519
+ clearInterval(nativeNameTimer);
3520
+ await nativeNameSync;
3486
3521
  await bridge?.stop();
3487
3522
  if (resolution.selected === "mcp") rmSync(inboxPath, { force: true });
3488
3523
  if (typeof code === "number") return code;