@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
|
@@ -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;
|