@ctliz/agent-intercom-pi 0.11.0-connect.2
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/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +26 -0
- package/README.md +693 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/banner.png +0 -0
- package/boss-team-scope.ts +241 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +140 -0
- package/broker/boss-adapter.ts +383 -0
- package/broker/broker-impl.ts +2019 -0
- package/broker/broker.ts +7 -0
- package/broker/client.ts +932 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/protected-service.ts +158 -0
- package/broker/registration.ts +173 -0
- package/broker/spawn.ts +448 -0
- package/config.ts +183 -0
- package/control.ts +150 -0
- package/durable-json.ts +25 -0
- package/inbound-inbox.ts +296 -0
- package/index.ts +2826 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +72 -0
- package/protocol-v4/contract.ts +39 -0
- package/provider/provider.mjs +34 -0
- package/reply-tracker.ts +231 -0
- package/skills/pi-intercom/SKILL.md +537 -0
- package/team.ts +192 -0
- package/types.ts +183 -0
- package/ui/compose.ts +229 -0
- package/ui/inline-message.ts +126 -0
- package/ui/session-identity.ts +55 -0
- package/ui/session-list.ts +205 -0
- package/ui/timestamps.ts +50 -0
package/broker/spawn.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import net from "net";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { POLICY_SEMANTICS_VERSION } from "@ctliz/agent-intercom-core/policy";
|
|
9
|
+
import { POLICY_SEMANTICS_HASH } from "@ctliz/agent-intercom-core/vectors";
|
|
10
|
+
import { createMessageReader, writeMessage } from "./framing.ts";
|
|
11
|
+
import {
|
|
12
|
+
ensureIntercomRuntimeDir,
|
|
13
|
+
getAgentDirPath,
|
|
14
|
+
getBrokerConnectTarget,
|
|
15
|
+
getIntercomDirPath,
|
|
16
|
+
INTERCOM_PROTOCOL_NAME,
|
|
17
|
+
INTERCOM_PROTOCOL_VERSION,
|
|
18
|
+
INTERCOM_RUNTIME_FILE_MODE,
|
|
19
|
+
restrictIntercomRuntimeFile,
|
|
20
|
+
type BrokerConnectTarget,
|
|
21
|
+
} from "./paths.ts";
|
|
22
|
+
|
|
23
|
+
const INTERCOM_DIR = getIntercomDirPath();
|
|
24
|
+
const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
const BROKER_PID = join(INTERCOM_DIR, "broker.pid");
|
|
26
|
+
const BROKER_SPAWN_LOCK = join(INTERCOM_DIR, "broker.spawn.lock");
|
|
27
|
+
|
|
28
|
+
type BrokerLaunchSpec =
|
|
29
|
+
| {
|
|
30
|
+
kind: "direct";
|
|
31
|
+
command: string;
|
|
32
|
+
args: string[];
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
kind: "windows-launcher";
|
|
36
|
+
command: string;
|
|
37
|
+
args: string[];
|
|
38
|
+
launcherPath: string;
|
|
39
|
+
launcherCommandLine: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function sleep(ms: number): Promise<void> {
|
|
43
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getTsxCliPath(extensionDir: string = EXTENSION_DIR): string {
|
|
47
|
+
// Resolve tsx via Node's module resolution so it works regardless of whether
|
|
48
|
+
// tsx is bundled under extensionDir/node_modules or hoisted to a workspace
|
|
49
|
+
// root by npm. We resolve the tsx package main entry (its "exports" field
|
|
50
|
+
// does not expose ./dist/cli.mjs as a subpath) and then locate cli.mjs next
|
|
51
|
+
// to it. Falls back to the legacy relative path if resolution fails.
|
|
52
|
+
try {
|
|
53
|
+
const requireFromExtension = createRequire(import.meta.url);
|
|
54
|
+
const tsxMain = requireFromExtension.resolve("tsx");
|
|
55
|
+
return join(dirname(tsxMain), "cli.mjs");
|
|
56
|
+
} catch {
|
|
57
|
+
return join(extensionDir, "node_modules", "tsx", "dist", "cli.mjs");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function quoteWindowsArg(value: string): string {
|
|
62
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getWindowsHiddenLauncherPath(intercomDir: string = INTERCOM_DIR): string {
|
|
66
|
+
return join(intercomDir, "broker-launch.vbs");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function usesDefaultBrokerCommand(brokerCommand: string, brokerArgs: string[]): boolean {
|
|
70
|
+
return brokerCommand === "npx"
|
|
71
|
+
&& brokerArgs.length === 2
|
|
72
|
+
&& brokerArgs[0] === "--no-install"
|
|
73
|
+
&& brokerArgs[1] === "tsx";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getWindowsBrokerCommandLine(
|
|
77
|
+
brokerPath: string,
|
|
78
|
+
extensionDir: string = EXTENSION_DIR,
|
|
79
|
+
nodePath: string = process.execPath,
|
|
80
|
+
brokerCommand = "npx",
|
|
81
|
+
brokerArgs: string[] = ["--no-install", "tsx"],
|
|
82
|
+
): string {
|
|
83
|
+
if (usesDefaultBrokerCommand(brokerCommand, brokerArgs)) {
|
|
84
|
+
return [quoteWindowsArg(nodePath), quoteWindowsArg(getTsxCliPath(extensionDir)), quoteWindowsArg(brokerPath)].join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [quoteWindowsArg(brokerCommand), ...brokerArgs.map(quoteWindowsArg), quoteWindowsArg(brokerPath)].join(" ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getWindowsHiddenLauncherScript(commandLine: string): string {
|
|
91
|
+
return [
|
|
92
|
+
'Set WshShell = CreateObject("WScript.Shell")',
|
|
93
|
+
`WshShell.Run "${commandLine.replace(/"/g, '""')}", 0, False`,
|
|
94
|
+
'Set WshShell = Nothing',
|
|
95
|
+
'',
|
|
96
|
+
].join("\r\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isBrokerHealthOkMessage(message: unknown, requestId: string): boolean {
|
|
100
|
+
if (typeof message !== "object" || message === null || !("type" in message)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const response = message as Record<string, unknown>;
|
|
104
|
+
if (
|
|
105
|
+
response.type !== "health_ok"
|
|
106
|
+
|| response.requestId !== requestId
|
|
107
|
+
|| response.protocol !== INTERCOM_PROTOCOL_NAME
|
|
108
|
+
|| response.version !== INTERCOM_PROTOCOL_VERSION
|
|
109
|
+
|| response.endpoint !== "local"
|
|
110
|
+
) return false;
|
|
111
|
+
const remoteAccess = response.remoteAccess;
|
|
112
|
+
if (typeof remoteAccess !== "object" || remoteAccess === null || Array.isArray(remoteAccess)) return false;
|
|
113
|
+
const contract = remoteAccess as Record<string, unknown>;
|
|
114
|
+
return contract.feature === "remote-access-v1"
|
|
115
|
+
&& contract.policySemanticsVersion === POLICY_SEMANTICS_VERSION
|
|
116
|
+
&& contract.policySemanticsHash === POLICY_SEMANTICS_HASH;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeWindowsHiddenLauncher(
|
|
120
|
+
commandLine: string,
|
|
121
|
+
launcherPath: string = getWindowsHiddenLauncherPath(),
|
|
122
|
+
): string {
|
|
123
|
+
ensureIntercomRuntimeDir(dirname(launcherPath));
|
|
124
|
+
writeFileSync(launcherPath, getWindowsHiddenLauncherScript(commandLine), {
|
|
125
|
+
encoding: "utf-8",
|
|
126
|
+
mode: INTERCOM_RUNTIME_FILE_MODE,
|
|
127
|
+
});
|
|
128
|
+
restrictIntercomRuntimeFile(launcherPath);
|
|
129
|
+
return launcherPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getBrokerLaunchSpec(
|
|
133
|
+
brokerPath: string,
|
|
134
|
+
brokerCommand: string,
|
|
135
|
+
brokerArgs: string[],
|
|
136
|
+
extensionDir: string = EXTENSION_DIR,
|
|
137
|
+
platform: NodeJS.Platform = process.platform,
|
|
138
|
+
intercomDir: string = INTERCOM_DIR,
|
|
139
|
+
nodePath: string = process.execPath,
|
|
140
|
+
): BrokerLaunchSpec {
|
|
141
|
+
if (platform === "win32") {
|
|
142
|
+
const launcherPath = getWindowsHiddenLauncherPath(intercomDir);
|
|
143
|
+
return {
|
|
144
|
+
kind: "windows-launcher",
|
|
145
|
+
command: "wscript.exe",
|
|
146
|
+
args: [launcherPath],
|
|
147
|
+
launcherPath,
|
|
148
|
+
launcherCommandLine: getWindowsBrokerCommandLine(brokerPath, extensionDir, nodePath, brokerCommand, brokerArgs),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (usesDefaultBrokerCommand(brokerCommand, brokerArgs)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "direct",
|
|
155
|
+
command: nodePath,
|
|
156
|
+
args: [getTsxCliPath(extensionDir), brokerPath],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
kind: "direct",
|
|
162
|
+
command: brokerCommand,
|
|
163
|
+
args: [...brokerArgs, brokerPath],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getBrokerSpawnOptions(
|
|
168
|
+
extensionDir: string = EXTENSION_DIR,
|
|
169
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
170
|
+
): {
|
|
171
|
+
detached: true;
|
|
172
|
+
stdio: "ignore";
|
|
173
|
+
cwd: string;
|
|
174
|
+
env: NodeJS.ProcessEnv;
|
|
175
|
+
windowsHide: true;
|
|
176
|
+
} {
|
|
177
|
+
return {
|
|
178
|
+
detached: true,
|
|
179
|
+
stdio: "ignore",
|
|
180
|
+
cwd: extensionDir,
|
|
181
|
+
env: { ...env, PI_CODING_AGENT_DIR: getAgentDirPath(env), NODE_NO_WARNINGS: "1" },
|
|
182
|
+
windowsHide: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function toError(error: unknown): Error {
|
|
187
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function spawnBrokerIfNeeded(brokerCommand: string, brokerArgs: string[]): Promise<void> {
|
|
191
|
+
ensureIntercomRuntimeDir(INTERCOM_DIR);
|
|
192
|
+
|
|
193
|
+
if (await isBrokerRunning()) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ownsLock = acquireSpawnLock();
|
|
198
|
+
if (!ownsLock) {
|
|
199
|
+
await waitForBroker();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
if (await isBrokerRunning()) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (await checkBrokerHealth() === "incompatible") {
|
|
208
|
+
throw new Error(`Incompatible live intercom broker; expected ${INTERCOM_PROTOCOL_NAME} v${INTERCOM_PROTOCOL_VERSION}. Stop it explicitly during a coordinated migration.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "broker.ts");
|
|
212
|
+
const launch = getBrokerLaunchSpec(brokerPath, brokerCommand, brokerArgs);
|
|
213
|
+
if (launch.kind === "windows-launcher") {
|
|
214
|
+
writeWindowsHiddenLauncher(launch.launcherCommandLine, launch.launcherPath);
|
|
215
|
+
}
|
|
216
|
+
const child = spawn(launch.command, launch.args, getBrokerSpawnOptions());
|
|
217
|
+
child.unref();
|
|
218
|
+
|
|
219
|
+
await new Promise<void>((resolve, reject) => {
|
|
220
|
+
const cleanup = () => {
|
|
221
|
+
child.off("error", onError);
|
|
222
|
+
child.off("exit", onExit);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const onError = (error: Error) => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error(`Failed to spawn intercom broker: ${error.message}`, { cause: error }));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
231
|
+
if (launch.kind === "windows-launcher" && code === 0 && signal === null) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
cleanup();
|
|
235
|
+
if (signal) {
|
|
236
|
+
reject(new Error(`Intercom broker exited before startup with signal ${signal}`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
reject(new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}`));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
child.once("error", onError);
|
|
243
|
+
child.once("exit", onExit);
|
|
244
|
+
waitForBroker().then(() => {
|
|
245
|
+
cleanup();
|
|
246
|
+
resolve();
|
|
247
|
+
}, (error) => {
|
|
248
|
+
cleanup();
|
|
249
|
+
reject(toError(error));
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
} finally {
|
|
253
|
+
releaseSpawnLock();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function stopBrokerProcess(pidFile = BROKER_PID, timeoutMs = 3000): Promise<void> {
|
|
258
|
+
if (!existsSync(pidFile)) return;
|
|
259
|
+
let pid: number;
|
|
260
|
+
try {
|
|
261
|
+
pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
262
|
+
} catch {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return;
|
|
266
|
+
try {
|
|
267
|
+
process.kill(pid, "SIGTERM");
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const startedAt = Date.now();
|
|
272
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
273
|
+
try {
|
|
274
|
+
process.kill(pid, 0);
|
|
275
|
+
await sleep(50);
|
|
276
|
+
} catch {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`Incompatible intercom broker ${pid} did not stop within ${timeoutMs}ms`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function isBrokerRunning(): Promise<boolean> {
|
|
284
|
+
if (await checkSocketConnectable()) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!existsSync(BROKER_PID)) return false;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const pid = parseInt(readFileSync(BROKER_PID, "utf-8").trim(), 10);
|
|
292
|
+
if (!Number.isFinite(pid)) return false;
|
|
293
|
+
process.kill(pid, 0);
|
|
294
|
+
return checkSocketConnectable();
|
|
295
|
+
} catch {
|
|
296
|
+
// Missing or unreadable PID state means there is no live broker to reuse.
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function connectToBrokerTarget(target: BrokerConnectTarget): net.Socket {
|
|
302
|
+
return typeof target === "string"
|
|
303
|
+
? net.connect(target)
|
|
304
|
+
: net.connect({ host: target.host, port: target.port });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type BrokerHealth = "compatible" | "incompatible" | "unreachable";
|
|
308
|
+
|
|
309
|
+
async function checkSocketConnectable(): Promise<boolean> {
|
|
310
|
+
return await checkBrokerHealth() === "compatible";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function checkBrokerHealth(): Promise<BrokerHealth> {
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
let target: BrokerConnectTarget;
|
|
316
|
+
try {
|
|
317
|
+
target = getBrokerConnectTarget();
|
|
318
|
+
} catch {
|
|
319
|
+
resolve("unreachable");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const socket = connectToBrokerTarget(target);
|
|
324
|
+
const requestId = randomUUID();
|
|
325
|
+
const expectedStateId = typeof target === "string" ? undefined : target.stateId;
|
|
326
|
+
let settled = false;
|
|
327
|
+
const finish = (health: BrokerHealth) => {
|
|
328
|
+
if (settled) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
settled = true;
|
|
332
|
+
clearTimeout(timeout);
|
|
333
|
+
socket.off("connect", onConnect);
|
|
334
|
+
socket.off("error", onError);
|
|
335
|
+
socket.off("data", reader);
|
|
336
|
+
socket.destroy();
|
|
337
|
+
resolve(health);
|
|
338
|
+
};
|
|
339
|
+
const onConnect = () => {
|
|
340
|
+
try {
|
|
341
|
+
writeMessage(socket, {
|
|
342
|
+
type: "health",
|
|
343
|
+
requestId,
|
|
344
|
+
...(expectedStateId ? { stateId: expectedStateId } : {}),
|
|
345
|
+
});
|
|
346
|
+
} catch {
|
|
347
|
+
finish("unreachable");
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const onError = () => finish("unreachable");
|
|
351
|
+
const reader = createMessageReader((message) => {
|
|
352
|
+
if (isBrokerHealthOkMessage(message, requestId)) {
|
|
353
|
+
finish("compatible");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
typeof message === "object"
|
|
358
|
+
&& message !== null
|
|
359
|
+
&& "type" in message
|
|
360
|
+
&& message.type === "health_ok"
|
|
361
|
+
&& "requestId" in message
|
|
362
|
+
&& message.requestId === requestId
|
|
363
|
+
) {
|
|
364
|
+
finish("incompatible");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
finish("unreachable");
|
|
368
|
+
}, () => finish("unreachable"));
|
|
369
|
+
socket.on("connect", onConnect);
|
|
370
|
+
socket.on("error", onError);
|
|
371
|
+
socket.on("data", reader);
|
|
372
|
+
const timeout = setTimeout(() => finish("unreachable"), 1000);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function acquireSpawnLock(): boolean {
|
|
377
|
+
const maxRetries = 5;
|
|
378
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
379
|
+
try {
|
|
380
|
+
writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, {
|
|
381
|
+
flag: "wx",
|
|
382
|
+
mode: INTERCOM_RUNTIME_FILE_MODE,
|
|
383
|
+
});
|
|
384
|
+
restrictIntercomRuntimeFile(BROKER_SPAWN_LOCK);
|
|
385
|
+
return true;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
if (isSpawnLockStale()) {
|
|
391
|
+
try {
|
|
392
|
+
unlinkSync(BROKER_SPAWN_LOCK);
|
|
393
|
+
} catch {
|
|
394
|
+
// If we can't delete the stale lock, retry a few times before giving up
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function isSpawnLockStale(): boolean {
|
|
405
|
+
if (!existsSync(BROKER_SPAWN_LOCK)) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8").trim().split("\n");
|
|
411
|
+
const pid = Number.parseInt(pidLine, 10);
|
|
412
|
+
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
413
|
+
const ageMs = Date.now() - createdAt;
|
|
414
|
+
|
|
415
|
+
if (Number.isFinite(pid)) {
|
|
416
|
+
try {
|
|
417
|
+
process.kill(pid, 0);
|
|
418
|
+
} catch {
|
|
419
|
+
// The process that created the lock is gone.
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return !Number.isFinite(createdAt) || ageMs > 10_000;
|
|
425
|
+
} catch {
|
|
426
|
+
// Unreadable lock contents are treated as stale so a new broker can start.
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function releaseSpawnLock(): void {
|
|
432
|
+
try {
|
|
433
|
+
unlinkSync(BROKER_SPAWN_LOCK);
|
|
434
|
+
} catch {
|
|
435
|
+
// Another cleanup path may already have removed the lock.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function waitForBroker(timeoutMs = 5000): Promise<void> {
|
|
440
|
+
const start = Date.now();
|
|
441
|
+
while (Date.now() - start < timeoutMs) {
|
|
442
|
+
if (await checkSocketConnectable()) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await sleep(100);
|
|
446
|
+
}
|
|
447
|
+
throw new Error("Broker failed to start within timeout");
|
|
448
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getIntercomDirPath } from "./broker/paths.ts";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
|
+
export const DEFAULT_ASK_WAIT_MS = 30 * 1000;
|
|
7
|
+
|
|
8
|
+
export function getAskWaitMs(): number {
|
|
9
|
+
const raw = process.env.PI_INTERCOM_ASK_WAIT_MS;
|
|
10
|
+
if (raw === undefined || raw.trim() === "") {
|
|
11
|
+
return DEFAULT_ASK_WAIT_MS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const value = Number(raw);
|
|
15
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
16
|
+
throw new Error("PI_INTERCOM_ASK_WAIT_MS must be a positive integer number of milliseconds");
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getAskTimeoutMs(): number {
|
|
22
|
+
const raw = process.env.PI_INTERCOM_ASK_TIMEOUT_MS;
|
|
23
|
+
if (raw === undefined || raw.trim() === "") {
|
|
24
|
+
return DEFAULT_ASK_TIMEOUT_MS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const value = Number(raw);
|
|
28
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
29
|
+
throw new Error("PI_INTERCOM_ASK_TIMEOUT_MS must be a positive integer number of milliseconds");
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type InboundTriggerPolicy = "always" | "replies" | "never";
|
|
35
|
+
|
|
36
|
+
export interface IntercomConfig {
|
|
37
|
+
/** Broker command used to spawn the broker process (e.g. "npx" or "bun") */
|
|
38
|
+
brokerCommand: string;
|
|
39
|
+
|
|
40
|
+
/** Arguments passed to the broker command before the broker script path */
|
|
41
|
+
brokerArgs: string[];
|
|
42
|
+
|
|
43
|
+
/** Require confirmation before non-reply sends from interactive sessions */
|
|
44
|
+
confirmSend: boolean;
|
|
45
|
+
|
|
46
|
+
/** Controls whether inbound broker messages may automatically trigger a model turn */
|
|
47
|
+
inboundTrigger: InboundTriggerPolicy;
|
|
48
|
+
|
|
49
|
+
/** Optional custom status suffix shown after automatic lifecycle status */
|
|
50
|
+
status?: string;
|
|
51
|
+
|
|
52
|
+
/** Enable/disable intercom (default: true) */
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
|
|
55
|
+
/** Show reply hint in incoming messages (default: true) */
|
|
56
|
+
replyHint: boolean;
|
|
57
|
+
|
|
58
|
+
/** Expose the deprecated monolithic `intercom` tool to models (default: false) */
|
|
59
|
+
legacyTool: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getConfigPath(intercomDir: string = getIntercomDirPath()): string {
|
|
63
|
+
return join(intercomDir, "config.json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defaults: IntercomConfig = {
|
|
67
|
+
brokerCommand: "npx",
|
|
68
|
+
brokerArgs: ["--no-install", "tsx"],
|
|
69
|
+
confirmSend: false,
|
|
70
|
+
inboundTrigger: "always",
|
|
71
|
+
enabled: true,
|
|
72
|
+
replyHint: true,
|
|
73
|
+
legacyTool: false,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function applyEnvironment(config: IntercomConfig): IntercomConfig {
|
|
77
|
+
const legacyToolEnv = process.env.PI_INTERCOM_LEGACY_TOOL?.trim().toLowerCase();
|
|
78
|
+
if (!legacyToolEnv) return config;
|
|
79
|
+
if (!["1", "true", "yes", "0", "false", "no"].includes(legacyToolEnv)) {
|
|
80
|
+
throw new Error("PI_INTERCOM_LEGACY_TOOL must be one of: 1, true, yes, 0, false, no");
|
|
81
|
+
}
|
|
82
|
+
config.legacyTool = ["1", "true", "yes"].includes(legacyToolEnv);
|
|
83
|
+
return config;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function loadConfig(): IntercomConfig {
|
|
87
|
+
const configPath = getConfigPath();
|
|
88
|
+
if (!existsSync(configPath)) {
|
|
89
|
+
try {
|
|
90
|
+
return applyEnvironment({ ...defaults });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(`Failed to load intercom config at ${configPath}:`, error);
|
|
93
|
+
return { ...defaults, inboundTrigger: "never" };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
99
|
+
const parsed: unknown = JSON.parse(raw);
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
101
|
+
throw new Error("Config must be a JSON object");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parsedConfig = parsed as Record<string, unknown>;
|
|
105
|
+
const config: IntercomConfig = { ...defaults };
|
|
106
|
+
|
|
107
|
+
if (Object.hasOwn(parsedConfig, "brokerCommand")) {
|
|
108
|
+
if (typeof parsedConfig.brokerCommand !== "string") {
|
|
109
|
+
throw new Error(`"brokerCommand" must be a string`);
|
|
110
|
+
}
|
|
111
|
+
const brokerCommand = parsedConfig.brokerCommand.trim();
|
|
112
|
+
if (!brokerCommand) {
|
|
113
|
+
throw new Error(`"brokerCommand" must not be empty`);
|
|
114
|
+
}
|
|
115
|
+
config.brokerCommand = brokerCommand;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (Object.hasOwn(parsedConfig, "brokerArgs")) {
|
|
119
|
+
if (!Array.isArray(parsedConfig.brokerArgs)) {
|
|
120
|
+
throw new Error(`"brokerArgs" must be an array`);
|
|
121
|
+
}
|
|
122
|
+
const brokerArgs: string[] = [];
|
|
123
|
+
for (const arg of parsedConfig.brokerArgs) {
|
|
124
|
+
if (typeof arg !== "string") {
|
|
125
|
+
throw new Error(`"brokerArgs" items must be strings`);
|
|
126
|
+
}
|
|
127
|
+
brokerArgs.push(arg);
|
|
128
|
+
}
|
|
129
|
+
config.brokerArgs = brokerArgs;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Object.hasOwn(parsedConfig, "confirmSend")) {
|
|
133
|
+
if (typeof parsedConfig.confirmSend !== "boolean") {
|
|
134
|
+
throw new Error(`"confirmSend" must be a boolean`);
|
|
135
|
+
}
|
|
136
|
+
config.confirmSend = parsedConfig.confirmSend;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (Object.hasOwn(parsedConfig, "enabled")) {
|
|
140
|
+
if (typeof parsedConfig.enabled !== "boolean") {
|
|
141
|
+
throw new Error(`"enabled" must be a boolean`);
|
|
142
|
+
}
|
|
143
|
+
config.enabled = parsedConfig.enabled;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Object.hasOwn(parsedConfig, "inboundTrigger")) {
|
|
147
|
+
if (
|
|
148
|
+
parsedConfig.inboundTrigger !== "always"
|
|
149
|
+
&& parsedConfig.inboundTrigger !== "replies"
|
|
150
|
+
&& parsedConfig.inboundTrigger !== "never"
|
|
151
|
+
) {
|
|
152
|
+
throw new Error(`"inboundTrigger" must be "always", "replies", or "never"`);
|
|
153
|
+
}
|
|
154
|
+
config.inboundTrigger = parsedConfig.inboundTrigger;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (Object.hasOwn(parsedConfig, "replyHint")) {
|
|
158
|
+
if (typeof parsedConfig.replyHint !== "boolean") {
|
|
159
|
+
throw new Error(`"replyHint" must be a boolean`);
|
|
160
|
+
}
|
|
161
|
+
config.replyHint = parsedConfig.replyHint;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (Object.hasOwn(parsedConfig, "legacyTool")) {
|
|
165
|
+
if (typeof parsedConfig.legacyTool !== "boolean") {
|
|
166
|
+
throw new Error(`"legacyTool" must be a boolean`);
|
|
167
|
+
}
|
|
168
|
+
config.legacyTool = parsedConfig.legacyTool;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (Object.hasOwn(parsedConfig, "status")) {
|
|
172
|
+
if (typeof parsedConfig.status !== "string") {
|
|
173
|
+
throw new Error(`"status" must be a string`);
|
|
174
|
+
}
|
|
175
|
+
config.status = parsedConfig.status;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return applyEnvironment(config);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(`Failed to load intercom config at ${configPath}:`, error);
|
|
181
|
+
return { ...defaults, inboundTrigger: "never" };
|
|
182
|
+
}
|
|
183
|
+
}
|