@ctliz/agent-intercom-codex 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.
Files changed (52) hide show
  1. package/.codex-plugin/plugin.json +39 -0
  2. package/.mcp.json +10 -0
  3. package/COPYRIGHT +6 -0
  4. package/LICENSE +661 -0
  5. package/LICENSE_TRANSITION.md +25 -0
  6. package/README.md +572 -0
  7. package/THIRD_PARTY_NOTICES.md +17 -0
  8. package/assets/logo-generated.png +0 -0
  9. package/assets/logo.svg +22 -0
  10. package/boss-control-outbox.ts +195 -0
  11. package/broker/access-credential.ts +76 -0
  12. package/broker/access-registry.ts +464 -0
  13. package/broker/audit.ts +60 -0
  14. package/broker/authorization.ts +54 -0
  15. package/broker/boss-adapter.ts +531 -0
  16. package/broker/boss-control-ledger.ts +306 -0
  17. package/broker/broker.ts +2327 -0
  18. package/broker/client.ts +1111 -0
  19. package/broker/framing.ts +87 -0
  20. package/broker/ownership.ts +61 -0
  21. package/broker/paths.ts +161 -0
  22. package/broker/spawn.ts +453 -0
  23. package/codex/app-server-client.ts +562 -0
  24. package/codex/boss-client.ts +34 -0
  25. package/codex/bridge-config.ts +282 -0
  26. package/codex/bridge-daemon.ts +913 -0
  27. package/codex/clipboard.ts +142 -0
  28. package/codex/coi.ts +802 -0
  29. package/codex/contact.ts +54 -0
  30. package/codex/mcp-protocol.ts +248 -0
  31. package/codex/runtime.ts +488 -0
  32. package/codex/server.ts +60 -0
  33. package/codex/team.ts +249 -0
  34. package/codex/tui-input.ts +174 -0
  35. package/config.ts +195 -0
  36. package/dist/bridge-daemon.mjs +3781 -0
  37. package/dist/broker.mjs +3012 -0
  38. package/dist/build-info.json +12 -0
  39. package/dist/codex-server.mjs +2890 -0
  40. package/dist/coi.mjs +4686 -0
  41. package/durable-json.ts +25 -0
  42. package/licenses/MIT-pi-intercom.txt +21 -0
  43. package/outbound-outbox.ts +116 -0
  44. package/package.json +89 -0
  45. package/protocol-v4/contract.ts +27 -0
  46. package/provider/protected-service.ts +162 -0
  47. package/provider/provider.mjs +34 -0
  48. package/scripts/build-info.mjs +58 -0
  49. package/scripts/build.mjs +55 -0
  50. package/skills/codex-intercom/SKILL.md +104 -0
  51. package/tsconfig.json +16 -0
  52. package/types.ts +225 -0
package/codex/coi.ts ADDED
@@ -0,0 +1,802 @@
1
+ import { once } from "node:events";
2
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { types as nodeUtilTypes } from "node:util";
8
+ import { CodexBridgeDaemon } from "./bridge-daemon.ts";
9
+ import { intercomScopeIdFromEnv } from "../protocol-v4/contract.ts";
10
+ import {
11
+ assertHardenedBossAgentConfig,
12
+ parseHardenedBossClientKind,
13
+ type BridgeAgentConfig,
14
+ type BridgeConfig,
15
+ } from "./bridge-config.ts";
16
+ import { assertHardenedBossProviderAuthority, type HardenedBossClientKind } from "./boss-client.ts";
17
+ import { assertBossCanonicalData } from "../broker/boss-adapter.ts";
18
+ import { ensureIntercomRuntimeDir, getIntercomDirPath } from "../broker/paths.ts";
19
+ import { copyTextToClipboard, copyTextToTerminalClipboard } from "./clipboard.ts";
20
+ import { formatContactInstruction } from "./contact.ts";
21
+ import { TuiInputDecoder } from "./tui-input.ts";
22
+
23
+ export interface CoiOptions {
24
+ id?: string;
25
+ name?: string;
26
+ cwd: string;
27
+ instructions?: string;
28
+ socketPath?: string;
29
+ statePath?: string;
30
+ noTui: boolean;
31
+ copyShortcut: boolean;
32
+ codexCommand: string;
33
+ codexArgs: string[];
34
+ }
35
+
36
+ interface IdentityInput {
37
+ cwd: string;
38
+ pid: number;
39
+ gitRoot?: string | null;
40
+ branch?: string | null;
41
+ }
42
+
43
+ interface BridgeRuntimeConfig {
44
+ approvalPolicy?: string;
45
+ sandboxPolicy?: Record<string, unknown>;
46
+ }
47
+
48
+ const CODEX_OPTIONS_WITH_VALUE = new Set([
49
+ "-a",
50
+ "--ask-for-approval",
51
+ "--add-dir",
52
+ "--disable",
53
+ "--enable",
54
+ "-c",
55
+ "--cd",
56
+ "-C",
57
+ "--config",
58
+ "-i",
59
+ "--image",
60
+ "-m",
61
+ "--model",
62
+ "-p",
63
+ "--profile",
64
+ "--remote-auth-token-env",
65
+ "-s",
66
+ "--sandbox",
67
+ "--local-provider",
68
+ ]);
69
+ const COI_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
70
+ const MANAGED_MCP_ENV_KEYS = [
71
+ "AGENT_INTERCOM_WORKER_ID",
72
+ "AGENT_INTERCOM_WORKER_INCARNATION_ID",
73
+ "AGENT_INTERCOM_WORKER_GENERATION",
74
+ "AGENT_INTERCOM_RUN_ID",
75
+ "AGENT_INTERCOM_BOSS_RUN_ID",
76
+ "AGENT_INTERCOM_PARTICIPANT_ID",
77
+ "AGENT_INTERCOM_BINDING_EPOCH",
78
+ "AGENT_INTERCOM_MANAGER_TARGET",
79
+ "AGENT_INTERCOM_MANAGER_SESSION_ID",
80
+ "AGENT_INTERCOM_SYSTEMD_UNIT",
81
+ "AGENT_INTERCOM_OWNED",
82
+ "AGENT_INTERCOM_AGENT_DIR",
83
+ ] as const;
84
+
85
+ export function sanitizeSegment(value: string): string {
86
+ return value
87
+ .replace(/[^a-zA-Z0-9._:-]+/g, "-")
88
+ .replace(/^-+|-+$/g, "")
89
+ .toLowerCase() || "codex";
90
+ }
91
+
92
+ function shortHash(value: string): string {
93
+ return createHash("sha1").update(value).digest("hex").slice(0, 8);
94
+ }
95
+
96
+ function gitString(cwd: string, args: string[]): string | null {
97
+ const result = spawnSync("git", args, {
98
+ cwd,
99
+ encoding: "utf8",
100
+ stdio: ["ignore", "pipe", "ignore"],
101
+ });
102
+ if (result.status !== 0) return null;
103
+ const trimmed = result.stdout.trim();
104
+ return trimmed || null;
105
+ }
106
+
107
+ export function createDefaultIdentity(input: IdentityInput): { id: string; name: string } {
108
+ const root = input.gitRoot || input.cwd;
109
+ const repo = basename(root) || "codex";
110
+ const branch = input.branch || "worktree";
111
+ const readable = `${repo}:${branch}`;
112
+ const suffix = `${shortHash(input.cwd)}-${input.pid}`;
113
+ return {
114
+ id: sanitizeSegment(`codex-${repo}-${branch}-${suffix}`),
115
+ name: `codex:${readable}#${input.pid}`,
116
+ };
117
+ }
118
+
119
+ function detectIdentity(cwd: string): { id: string; name: string } {
120
+ return createDefaultIdentity({
121
+ cwd,
122
+ pid: process.pid,
123
+ gitRoot: gitString(cwd, ["rev-parse", "--show-toplevel"]),
124
+ branch: gitString(cwd, ["branch", "--show-current"]),
125
+ });
126
+ }
127
+
128
+ function readValue(args: string[], index: number, option: string): string {
129
+ const value = args[index + 1];
130
+ if (!value || value.startsWith("--")) {
131
+ throw new Error(`${option} requires a value`);
132
+ }
133
+ return value;
134
+ }
135
+
136
+ function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
137
+ if (value === undefined) return defaultValue;
138
+ const normalized = value.trim().toLowerCase();
139
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
140
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
141
+ return defaultValue;
142
+ }
143
+
144
+ function camelSandboxType(mode: string): string {
145
+ switch (mode) {
146
+ case "read-only":
147
+ return "readOnly";
148
+ case "workspace-write":
149
+ return "workspaceWrite";
150
+ case "danger-full-access":
151
+ return "dangerFullAccess";
152
+ default:
153
+ throw new Error(`Unsupported sandbox mode: ${mode}`);
154
+ }
155
+ }
156
+
157
+ function readCodexFlagValue(args: string[], index: number, option: string): { value: string; nextIndex: number } {
158
+ const arg = args[index];
159
+ const inline = arg.includes("=") ? arg.split(/=(.*)/s, 2)[1] : undefined;
160
+ if (inline !== undefined) return { value: inline, nextIndex: index };
161
+ return { value: readValue(args, index, option), nextIndex: index + 1 };
162
+ }
163
+
164
+ export function deriveBridgeAgentRuntimeConfig(args: string[], cwd: string): BridgeRuntimeConfig {
165
+ let approvalPolicy: string | undefined;
166
+ let sandboxMode: string | undefined;
167
+ const writableRoots = new Set<string>([resolve(cwd)]);
168
+
169
+ const { optionArgs } = splitCodexResumeArgs(args);
170
+ for (let index = 0; index < optionArgs.length; index += 1) {
171
+ const arg = optionArgs[index];
172
+ const optionName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
173
+
174
+ switch (optionName) {
175
+ case "--ask-for-approval":
176
+ case "-a": {
177
+ const parsed = readCodexFlagValue(optionArgs, index, optionName);
178
+ approvalPolicy = parsed.value;
179
+ index = parsed.nextIndex;
180
+ break;
181
+ }
182
+ case "--sandbox":
183
+ case "-s": {
184
+ const parsed = readCodexFlagValue(optionArgs, index, optionName);
185
+ sandboxMode = parsed.value;
186
+ index = parsed.nextIndex;
187
+ break;
188
+ }
189
+ case "--add-dir": {
190
+ const parsed = readCodexFlagValue(optionArgs, index, optionName);
191
+ writableRoots.add(resolve(cwd, parsed.value));
192
+ index = parsed.nextIndex;
193
+ break;
194
+ }
195
+ case "--dangerously-bypass-approvals-and-sandbox":
196
+ case "--yolo":
197
+ approvalPolicy = "never";
198
+ sandboxMode = "danger-full-access";
199
+ break;
200
+ default:
201
+ break;
202
+ }
203
+ }
204
+
205
+ const sandboxType = sandboxMode ? camelSandboxType(sandboxMode) : undefined;
206
+ const sandboxPolicy = sandboxType === "workspaceWrite"
207
+ ? { type: sandboxType, writableRoots: [...writableRoots], networkAccess: false }
208
+ : sandboxType
209
+ ? { type: sandboxType, ...(sandboxType === "readOnly" ? { networkAccess: false } : {}) }
210
+ : undefined;
211
+
212
+ return {
213
+ ...(approvalPolicy ? { approvalPolicy } : {}),
214
+ ...(sandboxPolicy ? { sandboxPolicy } : {}),
215
+ };
216
+ }
217
+
218
+ const HARDENED_BOSS_RAW_CONFIG_OPTIONS = new Set(["-c", "--config", "-p", "--profile", "--enable", "--disable"]);
219
+ const HARDENED_BOSS_BYPASS_OPTIONS = new Set([
220
+ "--dangerously-bypass-approvals-and-sandbox",
221
+ "--dangerously-bypass-hook-trust",
222
+ "--yolo",
223
+ ]);
224
+
225
+ function isHardenedBossRawConfigOption(optionName: string): boolean {
226
+ return HARDENED_BOSS_RAW_CONFIG_OPTIONS.has(optionName)
227
+ || (optionName.startsWith("-c") && optionName !== "-C")
228
+ || optionName.startsWith("-p");
229
+ }
230
+
231
+ function hardenedBossAttachedAuthorityOption(arg: string): "-s" | "-a" | "-C" | undefined {
232
+ if (arg.length <= 2) return undefined;
233
+ const option = arg.slice(0, 2);
234
+ return option === "-s" || option === "-a" || option === "-C" ? option : undefined;
235
+ }
236
+
237
+ /** Validate every user-controlled launch escape before an app-server exists. */
238
+ export function assertHardenedBossCoiLaunch(
239
+ args: string[],
240
+ cwd: string,
241
+ bossClient: HardenedBossClientKind | undefined,
242
+ ): BridgeRuntimeConfig {
243
+ if (bossClient !== undefined) {
244
+ assertBossCanonicalData(args, "$.argv");
245
+ if (!Array.isArray(args) || nodeUtilTypes.isProxy(args) || args.some((arg) => typeof arg !== "string")) {
246
+ throw new Error(`${bossClient} arguments must be a plain dense string array`);
247
+ }
248
+ }
249
+ if (bossClient === undefined) return deriveBridgeAgentRuntimeConfig(args, cwd);
250
+ let afterSeparator = false;
251
+ for (const arg of args) {
252
+ if (arg === "--") {
253
+ afterSeparator = true;
254
+ continue;
255
+ }
256
+ const attachedAuthorityOption = hardenedBossAttachedAuthorityOption(arg);
257
+ if (attachedAuthorityOption === "-C") {
258
+ throw new Error(`${bossClient} cannot expand or replace its writable root`);
259
+ }
260
+ if (attachedAuthorityOption !== undefined) {
261
+ throw new Error(`${bossClient} cannot use attached ${attachedAuthorityOption} policy overrides`);
262
+ }
263
+ const optionName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
264
+ if (isHardenedBossRawConfigOption(optionName)) {
265
+ throw new Error(`${bossClient} cannot use raw ${optionName} or profile configuration`);
266
+ }
267
+ if (HARDENED_BOSS_BYPASS_OPTIONS.has(optionName)) {
268
+ throw new Error(`${bossClient} cannot use approval or sandbox bypass aliases`);
269
+ }
270
+ if (optionName === "--add-dir" || optionName === "--cd" || optionName === "-C") {
271
+ throw new Error(`${bossClient} cannot expand or replace its writable root`);
272
+ }
273
+ if (afterSeparator && ["--sandbox", "-s", "--ask-for-approval", "-a"].includes(optionName)) {
274
+ throw new Error(`${bossClient} cannot place policy overrides after the argument separator`);
275
+ }
276
+ if (optionName.startsWith("-") && /(?:yolo|danger|bypass)/i.test(optionName)) {
277
+ throw new Error(`${bossClient} cannot use approval or sandbox bypass aliases`);
278
+ }
279
+ }
280
+ if (bossClient === "boss_participant") {
281
+ throw new Error("boss_participant requires unavailable broker-owned assigned workspace authority");
282
+ }
283
+ const runtime = deriveBridgeAgentRuntimeConfig(args, cwd);
284
+ const probe: BridgeAgentConfig = {
285
+ id: "launch-validation",
286
+ name: "launch-validation",
287
+ cwd: resolve(cwd),
288
+ bossClient,
289
+ ...runtime,
290
+ };
291
+ assertHardenedBossAgentConfig(probe);
292
+ return runtime;
293
+ }
294
+
295
+ export function parseCoiArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): CoiOptions {
296
+ const codexArgs: string[] = [];
297
+ const options: Partial<CoiOptions> = {};
298
+ let afterSeparator = false;
299
+
300
+ for (let index = 0; index < argv.length; index += 1) {
301
+ const arg = argv[index];
302
+ if (afterSeparator) {
303
+ codexArgs.push(arg);
304
+ continue;
305
+ }
306
+
307
+ if (arg === "--") {
308
+ afterSeparator = true;
309
+ codexArgs.push(arg);
310
+ continue;
311
+ }
312
+
313
+ const [key, inlineValue] = arg.includes("=") ? arg.split(/=(.*)/s, 2) : [arg, undefined];
314
+ const value = inlineValue ?? null;
315
+
316
+ switch (key) {
317
+ case "--name":
318
+ case "--intercom-name":
319
+ options.name = value ?? readValue(argv, index++, key);
320
+ break;
321
+ case "--id":
322
+ case "--intercom-id":
323
+ options.id = value ?? readValue(argv, index++, key);
324
+ break;
325
+ case "--cwd":
326
+ case "--intercom-cwd":
327
+ options.cwd = resolve(value ?? readValue(argv, index++, key));
328
+ break;
329
+ case "--instructions":
330
+ case "--intercom-instructions":
331
+ options.instructions = value ?? readValue(argv, index++, key);
332
+ break;
333
+ case "--socket":
334
+ case "--intercom-socket":
335
+ options.socketPath = resolve(value ?? readValue(argv, index++, key));
336
+ break;
337
+ case "--state":
338
+ case "--intercom-state":
339
+ options.statePath = resolve(value ?? readValue(argv, index++, key));
340
+ break;
341
+ case "--no-tui":
342
+ case "--intercom-no-tui":
343
+ options.noTui = true;
344
+ break;
345
+ case "--no-intercom-shortcut":
346
+ case "--intercom-no-shortcut":
347
+ options.copyShortcut = false;
348
+ break;
349
+ case "--intercom-shortcut":
350
+ options.copyShortcut = true;
351
+ break;
352
+ default:
353
+ codexArgs.push(arg);
354
+ break;
355
+ }
356
+ }
357
+
358
+ return {
359
+ cwd: resolve(options.cwd ?? env.CODEX_INTERCOM_CWD ?? process.cwd()),
360
+ id: options.id ?? env.CODEX_INTERCOM_SESSION_ID,
361
+ name: options.name ?? env.CODEX_INTERCOM_NAME,
362
+ instructions: options.instructions ?? env.CODEX_INTERCOM_INSTRUCTIONS,
363
+ socketPath: options.socketPath,
364
+ statePath: options.statePath,
365
+ noTui: options.noTui ?? false,
366
+ copyShortcut: options.copyShortcut ?? envFlagEnabled(env.CODEX_INTERCOM_SHORTCUT, true),
367
+ codexCommand: env.CODEX_INTERCOM_CODEX_COMMAND || "codex",
368
+ codexArgs,
369
+ };
370
+ }
371
+
372
+ export function hasCodexHelpOrVersion(args: string[]): boolean {
373
+ return splitCodexResumeArgs(args).optionArgs.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V");
374
+ }
375
+
376
+ export function splitCodexResumeArgs(args: string[]): { optionArgs: string[]; promptArgs: string[]; separatorPresent: boolean } {
377
+ const optionArgs: string[] = [];
378
+ const promptArgs: string[] = [];
379
+ let index = 0;
380
+ for (; index < args.length; index += 1) {
381
+ const arg = args[index];
382
+ if (arg === "--") {
383
+ promptArgs.push(...args.slice(index + 1));
384
+ return { optionArgs, promptArgs, separatorPresent: true };
385
+ }
386
+ if (!arg.startsWith("-") || arg === "-") break;
387
+ optionArgs.push(arg);
388
+ const optionName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
389
+ if (!arg.includes("=") && CODEX_OPTIONS_WITH_VALUE.has(optionName) && index + 1 < args.length) {
390
+ optionArgs.push(args[index + 1]);
391
+ index += 1;
392
+ }
393
+ }
394
+ promptArgs.push(...args.slice(index));
395
+ return { optionArgs, promptArgs, separatorPresent: false };
396
+ }
397
+
398
+ export function resolveCoiResumeRequest(args: string[]): {
399
+ optionArgs: string[];
400
+ promptArgs: string[];
401
+ threadId?: string;
402
+ } {
403
+ const { optionArgs, promptArgs, separatorPresent } = splitCodexResumeArgs(args);
404
+ if (separatorPresent || promptArgs[0] !== "resume" || !promptArgs[1]) return { optionArgs, promptArgs };
405
+ return { optionArgs, threadId: promptArgs[1], promptArgs: promptArgs.slice(2) };
406
+ }
407
+
408
+ export function buildCoiTuiArgs(
409
+ remote: string,
410
+ optionArgs: string[],
411
+ threadId: string,
412
+ promptArgs: string[],
413
+ explicitResume: boolean,
414
+ ): string[] {
415
+ const promptTail = promptArgs.length === 0 ? [] : ["--", ...promptArgs];
416
+ return explicitResume
417
+ ? ["resume", "--remote", remote, ...optionArgs, threadId, ...promptTail]
418
+ : ["--remote", remote, ...optionArgs, ...promptTail];
419
+ }
420
+
421
+ export function buildCodexAppServerArgs(
422
+ args: string[],
423
+ socketPath: string,
424
+ env: NodeJS.ProcessEnv = process.env,
425
+ ): string[] {
426
+ const { optionArgs } = splitCodexResumeArgs(args);
427
+ const appServerArgs: string[] = [];
428
+
429
+ for (let index = 0; index < optionArgs.length; index += 1) {
430
+ const arg = optionArgs[index];
431
+ const optionName = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
432
+ if (optionName === "--strict-config") {
433
+ appServerArgs.push(arg);
434
+ continue;
435
+ }
436
+ if (optionName !== "-c" && optionName !== "--config" && optionName !== "--enable" && optionName !== "--disable") {
437
+ continue;
438
+ }
439
+ appServerArgs.push(arg);
440
+ if (!arg.includes("=") && index + 1 < optionArgs.length) {
441
+ appServerArgs.push(optionArgs[index + 1]);
442
+ index += 1;
443
+ }
444
+ }
445
+
446
+ for (const key of MANAGED_MCP_ENV_KEYS) {
447
+ const value = env[key];
448
+ if (value) appServerArgs.push("-c", `mcp_servers.codex-intercom.env.${key}=${JSON.stringify(value)}`);
449
+ }
450
+
451
+ return ["app-server", ...appServerArgs, "--listen", `unix://${socketPath}`];
452
+ }
453
+
454
+ async function waitForSocket(socketPath: string, proc: ChildProcess, timeoutMs = 10000): Promise<void> {
455
+ const deadline = Date.now() + timeoutMs;
456
+ while (Date.now() < deadline) {
457
+ if (proc.exitCode !== null) {
458
+ throw new Error(`Codex app-server exited before creating ${socketPath}`);
459
+ }
460
+ if (existsSync(socketPath)) return;
461
+ await delay(50);
462
+ }
463
+ throw new Error(`Timed out waiting for Codex app-server socket: ${socketPath}`);
464
+ }
465
+
466
+ async function stopChild(proc: ChildProcess | null): Promise<void> {
467
+ if (!proc || proc.exitCode !== null || proc.killed) return;
468
+ await new Promise<void>((resolve) => {
469
+ const timeout = setTimeout(() => {
470
+ proc.kill("SIGKILL");
471
+ resolve();
472
+ }, 2000);
473
+ proc.once("exit", () => {
474
+ clearTimeout(timeout);
475
+ resolve();
476
+ });
477
+ proc.kill("SIGTERM");
478
+ });
479
+ }
480
+
481
+ function terminalNotification(message: string): void {
482
+ const safe = message.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 200);
483
+ if (process.stdout.isTTY) process.stdout.write(`\x1b]9;${safe}\x1b\\`);
484
+ else process.stderr.write(`${safe}\n`);
485
+ }
486
+
487
+ export async function runInteractiveTui(
488
+ command: string,
489
+ args: string[],
490
+ refreshArgs: string[],
491
+ cwd: string,
492
+ onAltI?: (controls: { insertText(text: string): void }) => void,
493
+ onAltM?: (controls: { insertText(text: string): void }) => void,
494
+ installRefresh?: (refresh: () => void) => () => void,
495
+ protectedBossClient?: HardenedBossClientKind,
496
+ launchEnv: NodeJS.ProcessEnv = process.env,
497
+ ): Promise<number> {
498
+ // This also runs on the recursive refresh path below, so a future caller
499
+ // cannot reintroduce an ambient-PATH spawn after the initial preflight.
500
+ assertHardenedBossProviderAuthority(protectedBossClient);
501
+ const runInherited = async (): Promise<number> => {
502
+ const tui = spawn(command, args, { cwd, env: launchEnv, stdio: "inherit" });
503
+ const [code, signal] = await once(tui, "exit") as [number | null, NodeJS.Signals | null];
504
+ if (typeof code === "number") return code;
505
+ return signal === "SIGINT" ? 130 : 1;
506
+ };
507
+
508
+ if ((!onAltI && !onAltM) || !process.stdin.isTTY || !process.stdout.isTTY) {
509
+ return runInherited();
510
+ }
511
+
512
+ let nodePty: typeof import("node-pty");
513
+ try {
514
+ nodePty = await import("node-pty");
515
+ } catch (error) {
516
+ process.stderr.write(`coi: Alt+I unavailable because optional node-pty could not load: ${error instanceof Error ? error.message : String(error)}\n`);
517
+ return runInherited();
518
+ }
519
+
520
+ const tui = nodePty.spawn(command, args, {
521
+ name: launchEnv.TERM || "xterm-256color",
522
+ cols: process.stdout.columns || 80,
523
+ rows: process.stdout.rows || 24,
524
+ cwd,
525
+ env: launchEnv,
526
+ });
527
+ const outputSubscription = tui.onData((data) => process.stdout.write(data));
528
+ let refreshRequested = false;
529
+ const removeRefresh = installRefresh?.(() => {
530
+ if (refreshRequested) return;
531
+ refreshRequested = true;
532
+ terminalNotification("Intercom turn completed; refreshing Codex TUI");
533
+ tui.kill();
534
+ });
535
+ const previousRawMode = Boolean(process.stdin.isRaw);
536
+ const inputDecoder = new TuiInputDecoder();
537
+ let pendingTimer: NodeJS.Timeout | null = null;
538
+
539
+ const flushPending = () => {
540
+ const pending = inputDecoder.flushPendingEscape();
541
+ if (!pending) return;
542
+ try {
543
+ tui.write(pending);
544
+ } catch {
545
+ // The PTY may have closed while a partial escape sequence was buffered.
546
+ }
547
+ pendingTimer = null;
548
+ };
549
+ const onInput = (chunk: Buffer | string) => {
550
+ if (pendingTimer) clearTimeout(pendingTimer);
551
+ const filtered = inputDecoder.write(chunk);
552
+ if (filtered.forwarded) tui.write(filtered.forwarded);
553
+ const controls = {
554
+ insertText(text: string) {
555
+ const safe = text.replace(/\x1b/g, "");
556
+ tui.write(`\x1b[200~${safe}\x1b[201~`);
557
+ },
558
+ };
559
+ for (let index = 0; index < filtered.altICount; index += 1) onAltI?.(controls);
560
+ for (let index = 0; index < filtered.altMCount; index += 1) onAltM?.(controls);
561
+ pendingTimer = inputDecoder.hasPendingEscape() ? setTimeout(flushPending, 25) : null;
562
+ };
563
+ const onResize = () => tui.resize(process.stdout.columns || 80, process.stdout.rows || 24);
564
+
565
+ process.stdin.setRawMode(true);
566
+ process.stdin.resume();
567
+ process.stdin.on("data", onInput);
568
+ process.stdout.on("resize", onResize);
569
+
570
+ let exitCode: number;
571
+ try {
572
+ exitCode = await new Promise<number>((resolve) => {
573
+ tui.onExit(({ exitCode, signal }) => resolve(exitCode ?? (signal === 2 ? 130 : 1)));
574
+ });
575
+ } finally {
576
+ removeRefresh?.();
577
+ if (pendingTimer) clearTimeout(pendingTimer);
578
+ flushPending();
579
+ const ended = inputDecoder.end();
580
+ if (ended.forwarded) {
581
+ try {
582
+ tui.write(ended.forwarded);
583
+ } catch {
584
+ // The PTY is already closed.
585
+ }
586
+ }
587
+ process.stdin.off("data", onInput);
588
+ process.stdout.off("resize", onResize);
589
+ process.stdin.setRawMode(previousRawMode);
590
+ outputSubscription.dispose();
591
+ }
592
+ if (refreshRequested) {
593
+ return runInteractiveTui(command, refreshArgs, refreshArgs, cwd, onAltI, onAltM, installRefresh, protectedBossClient, launchEnv);
594
+ }
595
+ return exitCode;
596
+ }
597
+
598
+ export function cleanupOldCoiStateFiles(intercomDir: string, now = Date.now(), maxAgeMs = COI_STATE_MAX_AGE_MS): void {
599
+ let entries: string[];
600
+ try {
601
+ entries = readdirSync(intercomDir);
602
+ } catch {
603
+ return;
604
+ }
605
+
606
+ for (const entry of entries) {
607
+ if (!/^coi-.+-state\.json$/.test(entry)) continue;
608
+ const path = join(intercomDir, entry);
609
+ try {
610
+ const stat = statSync(path);
611
+ if (now - stat.mtimeMs > maxAgeMs) rmSync(path, { force: true });
612
+ } catch {
613
+ // Best effort cleanup only.
614
+ }
615
+ }
616
+ }
617
+
618
+ export function resetCoiStateForFreshStart(statePath: string, fresh: boolean): void {
619
+ if (fresh) rmSync(statePath, { force: true });
620
+ }
621
+
622
+ export async function runCoi(options: CoiOptions, env: NodeJS.ProcessEnv = process.env): Promise<number> {
623
+ const scopeId = intercomScopeIdFromEnv(env);
624
+ const bossClient = parseHardenedBossClientKind(env.CODEX_INTERCOM_BOSS_CLIENT?.trim(), "CODEX_INTERCOM_BOSS_CLIENT");
625
+ // No provider executable/artifact authority is broker-owned yet. Fail
626
+ // before help, app-server, headless, TUI, refresh, or runtime-file setup.
627
+ assertHardenedBossProviderAuthority(bossClient);
628
+ if (bossClient !== undefined && options.codexCommand !== "codex") {
629
+ throw new Error(`${bossClient} cannot use a caller-provided Codex/app-server command`);
630
+ }
631
+ const runtimeConfig = assertHardenedBossCoiLaunch(options.codexArgs, options.cwd, bossClient);
632
+ if (hasCodexHelpOrVersion(options.codexArgs)) {
633
+ const help = spawn(options.codexCommand, options.codexArgs, {
634
+ cwd: options.cwd,
635
+ env,
636
+ stdio: "inherit",
637
+ });
638
+ const [code, signal] = await once(help, "exit") as [number | null, NodeJS.Signals | null];
639
+ if (typeof code === "number") return code;
640
+ return signal === "SIGINT" ? 130 : 1;
641
+ }
642
+
643
+ ensureIntercomRuntimeDir();
644
+ const identity = detectIdentity(options.cwd);
645
+ const id = sanitizeSegment(options.id ?? identity.id);
646
+ const name = options.name ?? identity.name;
647
+ const intercomDir = getIntercomDirPath();
648
+ cleanupOldCoiStateFiles(intercomDir);
649
+ const socketPath = options.socketPath ?? join(intercomDir, `coi-${process.pid}.sock`);
650
+ const statePath = options.statePath ?? join(intercomDir, `coi-${sanitizeSegment(id)}-state.json`);
651
+ const fresh = env.AGENT_INTERCOM_FRESH === "1";
652
+ resetCoiStateForFreshStart(statePath, fresh);
653
+ rmSync(socketPath, { force: true });
654
+
655
+ const resumeRequest = resolveCoiResumeRequest(options.codexArgs);
656
+ const agent: BridgeAgentConfig = {
657
+ id,
658
+ name,
659
+ cwd: options.cwd,
660
+ model: env.CODEX_INTERCOM_MODEL,
661
+ instructions: options.instructions,
662
+ threadId: fresh ? undefined : resumeRequest.threadId,
663
+ ...(bossClient === undefined ? {} : { bossClient }),
664
+ ...runtimeConfig,
665
+ };
666
+ assertHardenedBossAgentConfig(agent);
667
+
668
+ const appServer = spawn(options.codexCommand, buildCodexAppServerArgs(options.codexArgs, socketPath, env), {
669
+ cwd: options.cwd,
670
+ env,
671
+ stdio: ["ignore", "ignore", "pipe"],
672
+ });
673
+ appServer.stderr?.on("data", (chunk) => {
674
+ if (env.CODEX_INTERCOM_DEBUG) process.stderr.write(String(chunk));
675
+ });
676
+
677
+ const cleanup = async () => {
678
+ await daemon?.stop().catch(() => undefined);
679
+ await stopChild(appServer);
680
+ rmSync(socketPath, { force: true });
681
+ };
682
+
683
+ let daemon: CodexBridgeDaemon | null = null;
684
+ let cleaned = false;
685
+ const cleanupOnce = async () => {
686
+ if (cleaned) return;
687
+ cleaned = true;
688
+ await cleanup();
689
+ };
690
+
691
+ process.once("SIGINT", () => {
692
+ void cleanupOnce().finally(() => process.exit(130));
693
+ });
694
+ process.once("SIGTERM", () => {
695
+ void cleanupOnce().finally(() => process.exit(143));
696
+ });
697
+
698
+ await waitForSocket(socketPath, appServer);
699
+
700
+ const config: BridgeConfig = {
701
+ statePath,
702
+ appServer: {
703
+ transport: "unix-websocket",
704
+ socketPath,
705
+ },
706
+ agents: [agent],
707
+ };
708
+ let refreshVisibleTui: (() => void) | undefined;
709
+ daemon = new CodexBridgeDaemon(config, {
710
+ onExternalTurnComplete: ({ from, response }) => {
711
+ terminalNotification(`Intercom turn from ${from.name || from.id} completed: ${response}`);
712
+ setTimeout(() => refreshVisibleTui?.(), 1000).unref();
713
+ },
714
+ }, scopeId);
715
+ await daemon.start();
716
+ process.stderr.write(`coi intercom session: ${name} (${id})\n`);
717
+
718
+ if (options.noTui) {
719
+ await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM"), once(appServer, "exit")]);
720
+ await cleanupOnce();
721
+ return 0;
722
+ }
723
+
724
+ const remote = `unix://${socketPath}`;
725
+ const threadId = await daemon.ensureThreadForAgent(id);
726
+ const { optionArgs, promptArgs } = resumeRequest;
727
+ process.stderr.write(`coi sidecar thread: ${threadId}\n`);
728
+ const resolvedTuiArgs = buildCoiTuiArgs(
729
+ remote,
730
+ optionArgs,
731
+ threadId,
732
+ promptArgs,
733
+ Boolean(resumeRequest.threadId) && !fresh,
734
+ );
735
+ const refreshTuiArgs = ["resume", "--remote", remote, ...optionArgs, threadId];
736
+ let copying = false;
737
+ const copyCurrentContact = (controls: { insertText(text: string): void }) => {
738
+ if (copying) return;
739
+ copying = true;
740
+ void daemon!.getContactTargetForAgent(id)
741
+ .then(async (contact) => {
742
+ const instruction = formatContactInstruction(contact);
743
+ const preferTerminal = Boolean(env.SSH_TTY || env.SSH_CONNECTION);
744
+ let copied = preferTerminal
745
+ ? copyTextToTerminalClipboard(instruction, (sequence) => process.stdout.write(sequence))
746
+ : await copyTextToClipboard(instruction);
747
+ if (!copied.ok && process.stdout.isTTY) {
748
+ copied = copyTextToTerminalClipboard(instruction, (sequence) => process.stdout.write(sequence));
749
+ }
750
+
751
+ if (copied.ok) {
752
+ const fallback = contact.fallback ? " using the stable ID" : "";
753
+ terminalNotification(`Copied intercom contact${fallback}: ${contact.target}`);
754
+ return;
755
+ }
756
+
757
+ controls.insertText(instruction);
758
+ terminalNotification(`Clipboard unavailable; inserted intercom contact: ${contact.target}`);
759
+ })
760
+ .catch((error) => terminalNotification(`Failed to read intercom contact: ${error instanceof Error ? error.message : String(error)}`))
761
+ .finally(() => {
762
+ copying = false;
763
+ });
764
+ };
765
+ const openIntercom = (controls: { insertText(text: string): void }) => {
766
+ controls.insertText("Use intercom_list to show the active sessions, then ask me which session to message and what to send. Use intercom_send after I choose.");
767
+ terminalNotification("Inserted intercom session picker request");
768
+ };
769
+ try {
770
+ return await runInteractiveTui(
771
+ options.codexCommand,
772
+ resolvedTuiArgs,
773
+ refreshTuiArgs,
774
+ options.cwd,
775
+ options.copyShortcut ? copyCurrentContact : undefined,
776
+ options.copyShortcut ? openIntercom : undefined,
777
+ (refresh) => {
778
+ refreshVisibleTui = refresh;
779
+ return () => {
780
+ if (refreshVisibleTui === refresh) refreshVisibleTui = undefined;
781
+ };
782
+ },
783
+ bossClient,
784
+ env,
785
+ );
786
+ } finally {
787
+ await cleanupOnce();
788
+ }
789
+ }
790
+
791
+ async function main(): Promise<void> {
792
+ const options = parseCoiArgs(process.argv.slice(2));
793
+ const code = await runCoi(options);
794
+ process.exit(code);
795
+ }
796
+
797
+ if (process.argv[1] && (basename(process.argv[1]) === "coi.ts" || basename(process.argv[1]) === "coi.mjs")) {
798
+ void main().catch((error) => {
799
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
800
+ process.exit(1);
801
+ });
802
+ }