@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
@@ -0,0 +1,913 @@
1
+ import { once } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ import { basename } from "node:path";
4
+ import { CodexAppServerClient, defaultServerRequestResponse, type JsonRpcMessage } from "./app-server-client.ts";
5
+ import {
6
+ bridgeAgentApprovalPolicy,
7
+ bridgeAgentDefaultSandbox,
8
+ assertHardenedBossAgentConfig,
9
+ assertHardenedBossBridgeConfig,
10
+ loadBridgeConfig,
11
+ loadBridgeState,
12
+ saveBridgeState,
13
+ type BridgeAgentConfig,
14
+ type BridgeConfig,
15
+ type BridgeState,
16
+ } from "./bridge-config.ts";
17
+ import { IntercomClient } from "../broker/client.ts";
18
+ import { assertBossCanonicalData } from "../broker/boss-adapter.ts";
19
+ import { spawnBrokerIfNeeded } from "../broker/spawn.ts";
20
+ import { DEFAULT_ASK_TIMEOUT_MS, loadConfig, validateAskTimeoutMs } from "../config.ts";
21
+ import { assertHardenedBossProviderAuthority, type HardenedBossClientKind } from "./boss-client.ts";
22
+ import type { Message, SessionInfo } from "../types.ts";
23
+ import { resolveContactTarget, type IntercomContact } from "./contact.ts";
24
+ import { formatAttachments, formatSessionDisplay, formatSessionList, resolveSessionTarget, type ToolResult } from "./runtime.ts";
25
+ import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
26
+
27
+ interface TurnWaiter {
28
+ from: SessionInfo;
29
+ message: Message;
30
+ }
31
+
32
+ interface ToolReplyWaiter {
33
+ from: string;
34
+ resolve: (message: Message) => void;
35
+ reject: (error: Error) => void;
36
+ timeout: NodeJS.Timeout;
37
+ cleanup?: () => void;
38
+ }
39
+
40
+ export interface ExternalTurnEvent {
41
+ agentId: string;
42
+ threadId: string;
43
+ from: SessionInfo;
44
+ message: Message;
45
+ response: string;
46
+ }
47
+
48
+ export interface CodexBridgeHooks {
49
+ onExternalTurnComplete?: (event: ExternalTurnEvent) => void;
50
+ }
51
+
52
+ export interface VirtualCodexAgentOptions {
53
+ client?: IntercomClient;
54
+ prepareConnection?: () => Promise<void>;
55
+ reconnectDelays?: number[];
56
+ }
57
+
58
+ const APPROVED_INTERCOM_TOOLS = new Set([
59
+ "intercom_whoami",
60
+ "intercom_team",
61
+ "intercom_status",
62
+ "intercom_list",
63
+ "intercom_set_summary",
64
+ "intercom_send",
65
+ "intercom_ask",
66
+ "intercom_pending",
67
+ "intercom_reply",
68
+ ]);
69
+ const MAX_TOOL_MESSAGES_PER_TURN = 8;
70
+ const MAX_TOOL_MESSAGES_PER_MINUTE = 30;
71
+
72
+ function formatMessage(from: SessionInfo, message: Message, agent: BridgeAgentConfig): string {
73
+ const replyInstruction = message.expectsReply
74
+ ? [
75
+ "",
76
+ "",
77
+ "The sender is waiting for a blocking intercom reply.",
78
+ "The coi sidecar will automatically send your final assistant message as the reply to this ask.",
79
+ "Do not use intercom_reply or intercom_send to answer this ask; normal Codex MCP intercom tools run under a separate session identity and will not unblock the sender.",
80
+ "If you need to acknowledge first, put the acknowledgement at the start of your final assistant message.",
81
+ ].join("\n")
82
+ : "";
83
+ const attachments = message.content.attachments?.map((attachment) => {
84
+ const language = attachment.language ? ` (${attachment.language})` : "";
85
+ return `\n\nAttachment: ${attachment.name}${language}\n${attachment.content}`;
86
+ }).join("") ?? "";
87
+ const custom = agent.instructions ? `\n\nAgent instructions:\n${agent.instructions}` : "";
88
+ return [
89
+ `Intercom message for ${agent.name}.`,
90
+ `From: ${formatSessionDisplay(from)} (${from.id})`,
91
+ `Message id: ${message.id}`,
92
+ "",
93
+ message.content.text,
94
+ attachments,
95
+ custom,
96
+ replyInstruction,
97
+ ].join("\n");
98
+ }
99
+
100
+ function textInput(text: string) {
101
+ return { type: "text", text, text_elements: [] };
102
+ }
103
+
104
+ function statusText(status: unknown): string {
105
+ if (!status || typeof status !== "object" || !("type" in status)) return "unknown";
106
+ const type = (status as { type?: unknown }).type;
107
+ return typeof type === "string" ? type : "unknown";
108
+ }
109
+
110
+ function getThreadId(result: unknown): string {
111
+ const thread = result && typeof result === "object" ? (result as Record<string, unknown>).thread : undefined;
112
+ if (!thread || typeof thread !== "object" || typeof (thread as Record<string, unknown>).id !== "string") {
113
+ throw new Error("Codex app-server response did not include thread.id");
114
+ }
115
+ return (thread as Record<string, string>).id;
116
+ }
117
+
118
+ export function threadSandboxMode(sandboxPolicy: unknown): string {
119
+ if (!sandboxPolicy || typeof sandboxPolicy !== "object" || Array.isArray(sandboxPolicy)) return "read-only";
120
+ const type = (sandboxPolicy as Record<string, unknown>).type;
121
+ switch (type) {
122
+ case "readOnly":
123
+ case "read-only":
124
+ return "read-only";
125
+ case "workspaceWrite":
126
+ case "workspace-write":
127
+ return "workspace-write";
128
+ case "dangerFullAccess":
129
+ case "danger-full-access":
130
+ return "danger-full-access";
131
+ default:
132
+ return "read-only";
133
+ }
134
+ }
135
+
136
+ function protectedBossClientForBridge(config: BridgeConfig): HardenedBossClientKind | undefined {
137
+ // Reject accessors/proxies before reading a deny-only launch marker.
138
+ assertBossCanonicalData(config, "$.bridgeConfig");
139
+ if (!Array.isArray(config.agents)) return undefined;
140
+ for (const agent of config.agents) {
141
+ if (typeof agent !== "object" || agent === null || Array.isArray(agent)) continue;
142
+ if (agent.bossClient === "boss_participant" || agent.bossClient === "boss_reviewer") return agent.bossClient;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ function bridgeAgentSandboxMode(agent: BridgeAgentConfig): string {
148
+ return agent.sandboxPolicy === undefined
149
+ ? bridgeAgentDefaultSandbox(agent) ?? "read-only"
150
+ : threadSandboxMode(agent.sandboxPolicy);
151
+ }
152
+
153
+ function bridgeAgentTurnSandboxPolicy(agent: BridgeAgentConfig): unknown {
154
+ if (agent.sandboxPolicy !== undefined) return agent.sandboxPolicy;
155
+ if (bridgeAgentSandboxMode(agent) === "workspace-write") {
156
+ throw new Error("workspace-write requires unavailable broker-owned assigned workspace authority");
157
+ }
158
+ return { type: "readOnly", networkAccess: false };
159
+ }
160
+
161
+ function getTurnId(result: unknown): string {
162
+ const turn = result && typeof result === "object" ? (result as Record<string, unknown>).turn : undefined;
163
+ if (!turn || typeof turn !== "object" || typeof (turn as Record<string, unknown>).id !== "string") {
164
+ throw new Error("Codex app-server response did not include turn.id");
165
+ }
166
+ return (turn as Record<string, string>).id;
167
+ }
168
+
169
+ function getNotificationThreadId(params: unknown): string | null {
170
+ if (!params || typeof params !== "object") return null;
171
+ const value = (params as Record<string, unknown>).threadId;
172
+ return typeof value === "string" ? value : null;
173
+ }
174
+
175
+ function getNotificationTurnId(params: unknown): string | null {
176
+ if (!params || typeof params !== "object") return null;
177
+ const direct = (params as Record<string, unknown>).turnId;
178
+ if (typeof direct === "string") return direct;
179
+ const turn = (params as Record<string, unknown>).turn;
180
+ if (turn && typeof turn === "object" && typeof (turn as Record<string, unknown>).id === "string") {
181
+ return (turn as Record<string, string>).id;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function getCompletedAgentText(params: unknown): string | null {
187
+ if (!params || typeof params !== "object") return null;
188
+ const item = (params as Record<string, unknown>).item;
189
+ if (!item || typeof item !== "object") return null;
190
+ const raw = item as Record<string, unknown>;
191
+ return raw.type === "agentMessage" && typeof raw.text === "string" ? raw.text : null;
192
+ }
193
+
194
+ function intercomSendFromArgs(rawArgs: unknown): { to: string; message: string } | null {
195
+ let args: Record<string, unknown>;
196
+ try {
197
+ args = parseToolArguments(rawArgs);
198
+ } catch {
199
+ return null;
200
+ }
201
+ return typeof args.to === "string" && typeof args.message === "string"
202
+ ? { to: args.to, message: args.message }
203
+ : null;
204
+ }
205
+
206
+ export function getCompletedIntercomSend(params: unknown): { to: string; message: string } | null {
207
+ if (!params || typeof params !== "object") return null;
208
+ const item = (params as Record<string, unknown>).item;
209
+ if (!isRecord(item)) return null;
210
+ const rawName = item.name ?? item.toolName ?? item.tool_name;
211
+ if (typeof rawName !== "string" || normalizeToolName(rawName) !== "intercom_send") return null;
212
+ return intercomSendFromArgs(item.arguments ?? item.args ?? item.input);
213
+ }
214
+
215
+ export function getApprovedIntercomSend(params: unknown): { to: string; message: string } | null {
216
+ if (getApprovedIntercomToolFromApproval(params) !== "intercom_send") return null;
217
+ if (!isRecord(params)) return null;
218
+ const meta = isRecord(params._meta) ? params._meta : {};
219
+ return intercomSendFromArgs(meta.tool_params ?? meta.toolParams ?? meta.tool_params_json);
220
+ }
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
224
+ }
225
+
226
+ function asString(value: unknown, name: string): string {
227
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be a non-empty string`);
228
+ return value;
229
+ }
230
+
231
+ function asOptionalPositiveInteger(value: unknown, name: string): number | undefined {
232
+ if (value === undefined) return undefined;
233
+ return validateAskTimeoutMs(value, name);
234
+ }
235
+
236
+ function normalizeToolName(name: string): string {
237
+ const mcpMatch = name.match(/(?:^|__|\.)intercom_(whoami|status|list|set_summary|send|ask|pending|reply)$/);
238
+ if (mcpMatch) return `intercom_${mcpMatch[1]}`;
239
+ return name;
240
+ }
241
+
242
+ function parseToolArguments(value: unknown): Record<string, unknown> {
243
+ if (value === undefined || value === null) return {};
244
+ if (typeof value === "string") {
245
+ const parsed: unknown = value.trim() ? JSON.parse(value) : {};
246
+ if (!isRecord(parsed)) throw new Error("tool arguments must be an object");
247
+ return parsed;
248
+ }
249
+ if (!isRecord(value)) throw new Error("tool arguments must be an object");
250
+ return value;
251
+ }
252
+
253
+ function extractToolCall(message: JsonRpcMessage): { threadId: string | null; turnId: string | null; name: string; args: Record<string, unknown> } {
254
+ const params = isRecord(message.params) ? message.params : {};
255
+ const nested = ["toolCall", "tool", "call", "item"]
256
+ .map((key) => params[key])
257
+ .find(isRecord) ?? {};
258
+ const rawName = params.name ?? params.toolName ?? params.tool_name ?? nested.name ?? nested.toolName ?? nested.tool_name;
259
+ if (typeof rawName !== "string") throw new Error("item/tool/call did not include a tool name");
260
+ const rawArgs = params.arguments ?? params.args ?? params.input ?? nested.arguments ?? nested.args ?? nested.input;
261
+ const threadId = typeof params.threadId === "string" ? params.threadId : null;
262
+ const turnId = typeof params.turnId === "string" ? params.turnId : null;
263
+ return { threadId, turnId, name: normalizeToolName(rawName), args: parseToolArguments(rawArgs) };
264
+ }
265
+
266
+ function textToolResult(text: string, structuredContent?: Record<string, unknown>, isError = false): ToolResult {
267
+ return {
268
+ content: [{ type: "text", text }],
269
+ ...(structuredContent ? { structuredContent } : {}),
270
+ ...(isError ? { isError: true } : {}),
271
+ };
272
+ }
273
+
274
+ function appServerToolResponse(result: ToolResult): unknown {
275
+ return {
276
+ success: !result.isError,
277
+ contentItems: result.content,
278
+ ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}),
279
+ };
280
+ }
281
+
282
+ export class VirtualCodexAgent {
283
+ private client: IntercomClient;
284
+ private threadId: string | null;
285
+ private activeTurnId: string | null = null;
286
+ private waiters = new Map<string, TurnWaiter[]>();
287
+ private finalMessages = new Map<string, string>();
288
+ private toolReplyWaiters = new Map<string, ToolReplyWaiter>();
289
+ private messageQueue: Promise<void> = Promise.resolve();
290
+ private idleWaiters: Array<() => void> = [];
291
+ private turnCompletionWaiters = new Map<string, Array<() => void>>();
292
+ private toolMessageCountsByTurn = new Map<string, number>();
293
+ private toolMessageTimestamps: number[] = [];
294
+ private externalTurns = new Map<string, { from: SessionInfo; message: Message }>();
295
+ private connectPromise: Promise<void> | null = null;
296
+ private reconnectTimer: NodeJS.Timeout | null = null;
297
+ private reconnectAttempt = 0;
298
+ private reconnectEnabled = true;
299
+ private readonly intercomStartedAt = Date.now();
300
+ private readonly prepareConnection: () => Promise<void>;
301
+ private readonly reconnectDelays: number[];
302
+ private readonly scopeId: string | undefined;
303
+
304
+ constructor(
305
+ private readonly agent: BridgeAgentConfig,
306
+ private readonly app: CodexAppServerClient,
307
+ private readonly state: BridgeState,
308
+ private readonly statePath: string,
309
+ private readonly hooks: CodexBridgeHooks = {},
310
+ options: VirtualCodexAgentOptions = {},
311
+ scopeId?: string,
312
+ ) {
313
+ this.threadId = agent.threadId ?? state.agents[agent.id]?.threadId ?? null;
314
+ this.scopeId = scopeId;
315
+ this.client = options.client ?? new IntercomClient(scopeId ? { scopeId } : {});
316
+ this.prepareConnection = options.prepareConnection ?? (async () => {
317
+ const config = loadConfig();
318
+ await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
319
+ });
320
+ this.reconnectDelays = options.reconnectDelays?.length ? options.reconnectDelays : [250, 500, 1000, 2000, 5000];
321
+ }
322
+
323
+ async start(): Promise<void> {
324
+ this.reconnectEnabled = true;
325
+ this.client.on("message", (from: SessionInfo, message: Message, deliveryId: string) => {
326
+ const routed = this.routeMessage(from, message);
327
+ this.client.acknowledgeMessage(deliveryId);
328
+ void routed.catch((error) => {
329
+ this.client.updatePresence({ status: `error: ${error instanceof Error ? error.message : String(error)}` });
330
+ });
331
+ });
332
+ this.client.on("error", (error) => {
333
+ process.stderr.write(`intercom ${this.agent.id}: ${error.message}\n`);
334
+ });
335
+ this.client.on("disconnected", (error: Error) => {
336
+ this.rejectAllToolReplies(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
337
+ this.scheduleReconnect();
338
+ });
339
+ await this.connectIntercom();
340
+ }
341
+
342
+ private async connectIntercom(): Promise<void> {
343
+ this.clearReconnectTimer();
344
+ if (this.client.isConnected()) return;
345
+ if (this.connectPromise) return this.connectPromise;
346
+ this.connectPromise = (async () => {
347
+ await this.prepareConnection();
348
+ await this.client.connect({
349
+ name: this.agent.name,
350
+ cwd: this.agent.cwd,
351
+ model: this.agent.model ?? "codex-app-server",
352
+ pid: process.pid,
353
+ startedAt: this.intercomStartedAt,
354
+ lastActivity: Date.now(),
355
+ status: this.threadId ? "idle" : "idle:no-thread",
356
+ }, this.agent.id);
357
+ this.reconnectAttempt = 0;
358
+ })();
359
+ try {
360
+ await this.connectPromise;
361
+ } finally {
362
+ this.connectPromise = null;
363
+ }
364
+ }
365
+
366
+ private scheduleReconnect(): void {
367
+ if (!this.reconnectEnabled || this.reconnectTimer) return;
368
+ const delay = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)]!;
369
+ this.reconnectTimer = setTimeout(() => {
370
+ this.reconnectTimer = null;
371
+ void this.connectIntercom().then(() => {
372
+ if (!this.client.isConnected()) {
373
+ this.reconnectAttempt += 1;
374
+ this.scheduleReconnect();
375
+ }
376
+ }).catch((error) => {
377
+ this.reconnectAttempt += 1;
378
+ process.stderr.write(`intercom ${this.agent.id}: reconnect failed: ${error instanceof Error ? error.message : String(error)}\n`);
379
+ this.scheduleReconnect();
380
+ });
381
+ }, delay);
382
+ this.reconnectTimer.unref?.();
383
+ }
384
+
385
+ private clearReconnectTimer(): void {
386
+ if (!this.reconnectTimer) return;
387
+ clearTimeout(this.reconnectTimer);
388
+ this.reconnectTimer = null;
389
+ }
390
+
391
+ private rejectAllToolReplies(error: Error): void {
392
+ for (const [replyTo, waiter] of Array.from(this.toolReplyWaiters.entries())) {
393
+ clearTimeout(waiter.timeout);
394
+ waiter.cleanup?.();
395
+ this.toolReplyWaiters.delete(replyTo);
396
+ waiter.reject(error);
397
+ }
398
+ }
399
+
400
+ async stop(): Promise<void> {
401
+ this.reconnectEnabled = false;
402
+ this.clearReconnectTimer();
403
+ if (this.connectPromise) {
404
+ try {
405
+ await this.connectPromise;
406
+ } catch {
407
+ // A failed in-progress connection is already closed.
408
+ }
409
+ }
410
+ await this.client.disconnect();
411
+ }
412
+
413
+ get id(): string {
414
+ return this.agent.id;
415
+ }
416
+
417
+ async getContactTarget(): Promise<IntercomContact> {
418
+ return resolveContactTarget(this.agent.id, this.agent.name, () => this.client.listSessions());
419
+ }
420
+
421
+ ownsThread(threadId: string): boolean {
422
+ return this.threadId === threadId;
423
+ }
424
+
425
+ onNotification(message: JsonRpcMessage): void {
426
+ const threadId = getNotificationThreadId(message.params);
427
+ if (!threadId || threadId !== this.threadId) return;
428
+
429
+ if (message.method === "error") {
430
+ const params = isRecord(message.params) ? message.params : {};
431
+ const detail = isRecord(params.error) && typeof params.error.message === "string"
432
+ ? params.error.message
433
+ : "Codex turn error";
434
+ const willRetry = params.willRetry === true;
435
+ this.client.updatePresence({ status: willRetry ? `reconnecting: ${detail}` : `error: ${detail}` });
436
+ process.stderr.write(`codex ${this.agent.id}: ${detail}${willRetry ? "; app-server is retrying" : ""}\n`);
437
+ return;
438
+ }
439
+
440
+ if (message.method === "turn/started") {
441
+ this.activeTurnId = getNotificationTurnId(message.params);
442
+ this.client.updatePresence({ status: "active" });
443
+ return;
444
+ }
445
+
446
+ if (message.method === "thread/status/changed" && message.params && typeof message.params === "object") {
447
+ const status = statusText((message.params as Record<string, unknown>).status);
448
+ this.client.updatePresence({ status });
449
+ return;
450
+ }
451
+
452
+ if (message.method === "item/completed") {
453
+ const turnId = getNotificationTurnId(message.params);
454
+ const text = getCompletedAgentText(message.params);
455
+ if (turnId && text) this.finalMessages.set(turnId, text);
456
+ const intercomSend = getCompletedIntercomSend(message.params);
457
+ if (turnId && intercomSend) {
458
+ void this.replyToWaitersFromIntercomSend(turnId, intercomSend).catch((error) => {
459
+ process.stderr.write(`reply failed for ${this.agent.id} after intercom_send: ${error instanceof Error ? error.message : String(error)}\n`);
460
+ });
461
+ }
462
+ return;
463
+ }
464
+
465
+ if (message.method === "turn/completed") {
466
+ const turnId = getNotificationTurnId(message.params);
467
+ if (!turnId) return;
468
+ if (this.activeTurnId === turnId) this.activeTurnId = null;
469
+ this.client.updatePresence({ status: "idle" });
470
+ const idleWaiters = this.idleWaiters.splice(0);
471
+ for (const resolve of idleWaiters) resolve();
472
+ void this.finishTurn(turnId);
473
+ }
474
+ }
475
+
476
+ async ensureThread(): Promise<string> {
477
+ if (this.threadId) {
478
+ try {
479
+ const sandbox = bridgeAgentSandboxMode(this.agent);
480
+ await this.app.request("thread/resume", {
481
+ threadId: this.threadId,
482
+ cwd: this.agent.cwd,
483
+ model: this.agent.model ?? null,
484
+ approvalPolicy: bridgeAgentApprovalPolicy(this.agent),
485
+ sandbox,
486
+ });
487
+ return this.threadId;
488
+ } catch {
489
+ this.threadId = null;
490
+ }
491
+ }
492
+
493
+ const sandbox = bridgeAgentSandboxMode(this.agent);
494
+ const result = await this.app.request("thread/start", {
495
+ cwd: this.agent.cwd,
496
+ model: this.agent.model ?? null,
497
+ approvalPolicy: bridgeAgentApprovalPolicy(this.agent),
498
+ sandbox,
499
+ serviceName: "codex-intercom",
500
+ developerInstructions: this.agent.instructions ?? null,
501
+ threadSource: "cli",
502
+ });
503
+ this.threadId = getThreadId(result);
504
+ this.state.agents[this.agent.id] = { threadId: this.threadId, updatedAt: Date.now() };
505
+ saveBridgeState(this.statePath, this.state);
506
+ await this.app.request("thread/name/set", { threadId: this.threadId, name: this.agent.name }).catch(() => undefined);
507
+ this.client.updatePresence({ status: "idle" });
508
+ return this.threadId;
509
+ }
510
+
511
+ private routeMessage(from: SessionInfo, message: Message): Promise<void> {
512
+ const toolWaiter = this.toolReplyWaiters.get(message.replyTo ?? "");
513
+ if (toolWaiter) {
514
+ if (from.id === toolWaiter.from) {
515
+ this.toolReplyWaiters.delete(message.replyTo ?? "");
516
+ clearTimeout(toolWaiter.timeout);
517
+ toolWaiter.cleanup?.();
518
+ toolWaiter.resolve(message);
519
+ return Promise.resolve();
520
+ }
521
+ }
522
+
523
+ const run = this.messageQueue
524
+ .catch(() => undefined)
525
+ .then(() => this.handleMessage(from, message));
526
+ this.messageQueue = run.catch((error) => {
527
+ this.client.updatePresence({ status: `error: ${error instanceof Error ? error.message : String(error)}` });
528
+ });
529
+ return run;
530
+ }
531
+
532
+ private async handleMessage(from: SessionInfo, message: Message): Promise<void> {
533
+ const threadId = await this.ensureThread();
534
+ await this.waitUntilIdle();
535
+ const input = [textInput(formatMessage(from, message, this.agent))];
536
+ const result = await this.startTurn(threadId, input);
537
+ const turnId = getTurnId(result);
538
+ this.externalTurns.set(turnId, { from, message });
539
+ const completed = this.waitForTurnCompletion(turnId);
540
+
541
+ if (message.expectsReply) {
542
+ const waiters = this.waiters.get(turnId) ?? [];
543
+ waiters.push({ from, message });
544
+ this.waiters.set(turnId, waiters);
545
+ }
546
+ await completed;
547
+ }
548
+
549
+ private startTurn(threadId: string, input: Array<ReturnType<typeof textInput>>): Promise<unknown> {
550
+ this.client.updatePresence({ status: "active" });
551
+ return this.app.request("turn/start", {
552
+ threadId,
553
+ input,
554
+ cwd: this.agent.cwd,
555
+ approvalPolicy: bridgeAgentApprovalPolicy(this.agent),
556
+ sandboxPolicy: bridgeAgentTurnSandboxPolicy(this.agent),
557
+ model: this.agent.model ?? null,
558
+ });
559
+ }
560
+
561
+ private async replyToWaiters(turnId: string): Promise<void> {
562
+ const waiters = this.waiters.get(turnId);
563
+ if (!waiters?.length) return;
564
+ this.waiters.delete(turnId);
565
+ const reply = this.finalMessages.get(turnId)?.trim() || "Codex turn completed without a final message.";
566
+ for (const waiter of waiters) {
567
+ await this.client.send(waiter.from.id, { text: reply, replyTo: waiter.message.id }).catch((error) => {
568
+ process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}\n`);
569
+ });
570
+ }
571
+ }
572
+
573
+ async replyToWaitersFromIntercomSend(turnId: string, send: { to: string; message: string }): Promise<void> {
574
+ const waiters = this.waiters.get(turnId);
575
+ if (!waiters?.length) return;
576
+ const lowerTo = send.to.toLowerCase();
577
+ const remaining: TurnWaiter[] = [];
578
+ for (const waiter of waiters) {
579
+ const matchesSender = send.to === waiter.from.id
580
+ || waiter.from.id.startsWith(send.to)
581
+ || waiter.from.name?.toLowerCase() === lowerTo;
582
+ if (!matchesSender) {
583
+ remaining.push(waiter);
584
+ continue;
585
+ }
586
+ await this.client.send(waiter.from.id, { text: send.message, replyTo: waiter.message.id }).catch((error) => {
587
+ remaining.push(waiter);
588
+ process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}\n`);
589
+ });
590
+ }
591
+ if (remaining.length) {
592
+ this.waiters.set(turnId, remaining);
593
+ } else {
594
+ this.waiters.delete(turnId);
595
+ }
596
+ }
597
+
598
+ private waitUntilIdle(): Promise<void> {
599
+ if (!this.activeTurnId) return Promise.resolve();
600
+ return new Promise((resolve) => {
601
+ this.idleWaiters.push(resolve);
602
+ });
603
+ }
604
+
605
+ private waitForTurnCompletion(turnId: string): Promise<void> {
606
+ return new Promise((resolve) => {
607
+ const waiters = this.turnCompletionWaiters.get(turnId) ?? [];
608
+ waiters.push(resolve);
609
+ this.turnCompletionWaiters.set(turnId, waiters);
610
+ });
611
+ }
612
+
613
+ private async finishTurn(turnId: string): Promise<void> {
614
+ try {
615
+ await this.replyToWaiters(turnId);
616
+ const external = this.externalTurns.get(turnId);
617
+ if (external && this.threadId) {
618
+ this.hooks.onExternalTurnComplete?.({
619
+ agentId: this.agent.id,
620
+ threadId: this.threadId,
621
+ from: external.from,
622
+ message: external.message,
623
+ response: this.finalMessages.get(turnId)?.trim() || "Codex turn completed without a final message.",
624
+ });
625
+ }
626
+ } finally {
627
+ this.externalTurns.delete(turnId);
628
+ this.finalMessages.delete(turnId);
629
+ this.waiters.delete(turnId);
630
+ this.toolMessageCountsByTurn.delete(turnId);
631
+ const waiters = this.turnCompletionWaiters.get(turnId) ?? [];
632
+ this.turnCompletionWaiters.delete(turnId);
633
+ for (const resolve of waiters) resolve();
634
+ }
635
+ }
636
+
637
+ async handleToolCall(name: string, args: Record<string, unknown>, turnId: string | null, signal?: AbortSignal): Promise<unknown> {
638
+ try {
639
+ const result = await this.callIntercomTool(name, args, turnId, signal);
640
+ return appServerToolResponse(result);
641
+ } catch (error) {
642
+ return appServerToolResponse(textToolResult(error instanceof Error ? error.message : String(error), { ok: false }, true));
643
+ }
644
+ }
645
+
646
+ private async callIntercomTool(name: string, args: Record<string, unknown>, turnId: string | null, signal?: AbortSignal): Promise<ToolResult> {
647
+ switch (name) {
648
+ case "intercom_whoami":
649
+ return textToolResult(
650
+ `session_id: ${this.agent.id}\nname: ${this.agent.name}\ncwd: ${this.agent.cwd}`,
651
+ { session_id: this.agent.id, name: this.agent.name, cwd: this.agent.cwd, model: this.agent.model ?? "codex-app-server" },
652
+ );
653
+ case "intercom_team": {
654
+ const sessions = await this.client.listSessions();
655
+ const team = await resolveIntercomTeam({ selfId: this.agent.id, sessions });
656
+ return textToolResult(formatIntercomTeam(team), team as unknown as Record<string, unknown>);
657
+ }
658
+ case "intercom_status": {
659
+ const sessions = await this.client.listSessions();
660
+ return textToolResult(
661
+ `Connected: Yes\nSession ID: ${this.agent.id}\nActive sessions: ${sessions.length}`,
662
+ { connected: true, session_id: this.agent.id, active_sessions: sessions.length },
663
+ );
664
+ }
665
+ case "intercom_list": {
666
+ const includeSelf = typeof args.include_self === "boolean" ? args.include_self : false;
667
+ const sessions = (await this.client.listSessions()).filter((session) => includeSelf || session.id !== this.agent.id);
668
+ return textToolResult(formatSessionList(sessions, this.agent.id, this.agent.cwd), { sessions });
669
+ }
670
+ case "intercom_set_summary": {
671
+ const summary = asString(args.summary, "summary");
672
+ this.client.updatePresence({ status: summary.trim() || "idle" });
673
+ return textToolResult("Summary updated.", { ok: true, summary });
674
+ }
675
+ case "intercom_send": {
676
+ const limit = this.reserveToolMessage(turnId);
677
+ if (limit) return limit;
678
+ const to = asString(args.to, "to");
679
+ const message = asString(args.message, "message");
680
+ const sendTo = await this.resolveTarget(to);
681
+ const result = await this.client.send(sendTo, { text: message });
682
+ if (!result.delivered) {
683
+ return textToolResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
684
+ }
685
+ return textToolResult(`Message sent to ${to}.`, { ok: true, message_id: result.id, to });
686
+ }
687
+ case "intercom_ask": {
688
+ const limit = this.reserveToolMessage(turnId);
689
+ if (limit) return limit;
690
+ const to = asString(args.to, "to");
691
+ const message = asString(args.message, "message");
692
+ const timeoutMs = asOptionalPositiveInteger(args.timeout_ms, "timeout_ms") ?? DEFAULT_ASK_TIMEOUT_MS;
693
+ const sendTo = await this.resolveTarget(to);
694
+ const questionId = randomUUID();
695
+ const replyPromise = this.waitForToolReply(sendTo, questionId, timeoutMs, signal);
696
+ void replyPromise.catch(() => undefined);
697
+ const result = await this.client.send(sendTo, { messageId: questionId, text: message, expectsReply: true });
698
+ if (!result.delivered) {
699
+ this.rejectToolReply(questionId, new Error(result.reason ?? "Session may not exist or has disconnected."));
700
+ return textToolResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
701
+ }
702
+ const reply = await replyPromise;
703
+ return textToolResult(`Reply from ${to}:\n${reply.content.text}${formatAttachments(reply.content.attachments)}`, { ok: true, message_id: result.id, reply });
704
+ }
705
+ case "intercom_pending":
706
+ return textToolResult("No unread messages.", { unread_messages: [], pending_asks: [] });
707
+ case "intercom_reply":
708
+ return textToolResult("No matching pending ask. App-server sidecar asks are answered automatically by final assistant messages.", { ok: false }, true);
709
+ default:
710
+ return textToolResult(`Unknown tool: ${name}`, { ok: false }, true);
711
+ }
712
+ }
713
+
714
+ private reserveToolMessage(turnId: string | null): ToolResult | null {
715
+ const now = Date.now();
716
+ this.toolMessageTimestamps = this.toolMessageTimestamps.filter((timestamp) => now - timestamp < 60000);
717
+ if (this.toolMessageTimestamps.length >= MAX_TOOL_MESSAGES_PER_MINUTE) {
718
+ return textToolResult(`Intercom message limit reached: max ${MAX_TOOL_MESSAGES_PER_MINUTE} sidecar-originated sends per minute.`, { ok: false, limit: "per_minute" }, true);
719
+ }
720
+
721
+ const key = turnId ?? "unknown-turn";
722
+ const count = this.toolMessageCountsByTurn.get(key) ?? 0;
723
+ if (count >= MAX_TOOL_MESSAGES_PER_TURN) {
724
+ return textToolResult(`Intercom message limit reached: max ${MAX_TOOL_MESSAGES_PER_TURN} sidecar-originated sends per turn.`, { ok: false, limit: "per_turn" }, true);
725
+ }
726
+
727
+ this.toolMessageCountsByTurn.set(key, count + 1);
728
+ this.toolMessageTimestamps.push(now);
729
+ return null;
730
+ }
731
+
732
+ private async resolveTarget(to: string): Promise<string> {
733
+ const sessions = await this.client.listSessions();
734
+ return resolveSessionTarget(sessions, to) ?? to;
735
+ }
736
+
737
+ private waitForToolReply(from: string, replyTo: string, timeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal?: AbortSignal): Promise<Message> {
738
+ return new Promise((resolve, reject) => {
739
+ if (signal?.aborted) {
740
+ reject(new Error("intercom_ask cancelled"));
741
+ return;
742
+ }
743
+ let timeout: NodeJS.Timeout;
744
+ const cleanup = () => {
745
+ clearTimeout(timeout);
746
+ signal?.removeEventListener("abort", onAbort);
747
+ };
748
+ const onAbort = () => {
749
+ this.toolReplyWaiters.delete(replyTo);
750
+ cleanup();
751
+ void this.client.cancelAsk(replyTo);
752
+ reject(new Error("intercom_ask cancelled"));
753
+ };
754
+ timeout = setTimeout(() => {
755
+ this.toolReplyWaiters.delete(replyTo);
756
+ void this.client.deferAsk(replyTo);
757
+ signal?.removeEventListener("abort", onAbort);
758
+ reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1000)} seconds`));
759
+ }, timeoutMs);
760
+ signal?.addEventListener("abort", onAbort, { once: true });
761
+ this.toolReplyWaiters.set(replyTo, { from, resolve, reject, timeout, cleanup });
762
+ });
763
+ }
764
+
765
+ private rejectToolReply(replyTo: string, error: Error): void {
766
+ const waiter = this.toolReplyWaiters.get(replyTo);
767
+ if (!waiter) return;
768
+ clearTimeout(waiter.timeout);
769
+ waiter.cleanup?.();
770
+ this.toolReplyWaiters.delete(replyTo);
771
+ waiter.reject(error);
772
+ void this.client.cancelAsk(replyTo);
773
+ }
774
+ }
775
+
776
+ export class CodexBridgeDaemon {
777
+ private app: CodexAppServerClient;
778
+ private agents: VirtualCodexAgent[] = [];
779
+ private inflightToolCalls = new Map<string | number, AbortController>();
780
+
781
+ constructor(private readonly config: BridgeConfig, private readonly hooks: CodexBridgeHooks = {}, private readonly scopeId?: string) {
782
+ // A marker is deny-only here: no config field can provide executable or
783
+ // artifact authority.
784
+ const protectedBossClient = protectedBossClientForBridge(config);
785
+ assertHardenedBossProviderAuthority(protectedBossClient);
786
+ assertHardenedBossBridgeConfig(config);
787
+ for (const agent of config.agents) assertHardenedBossAgentConfig(agent);
788
+ this.app = new CodexAppServerClient(config.appServer, protectedBossClient);
789
+ this.app.setServerRequestHandler((message) => this.handleServerRequest(message));
790
+ }
791
+
792
+ async start(): Promise<void> {
793
+ // Revalidate immediately before CodexAppServerClient reaches either its
794
+ // daemon spawnSync or stdio spawn boundary.
795
+ assertHardenedBossProviderAuthority(protectedBossClientForBridge(this.config));
796
+ assertHardenedBossBridgeConfig(this.config);
797
+ for (const agent of this.config.agents) assertHardenedBossAgentConfig(agent);
798
+ const intercomConfig = loadConfig();
799
+ await spawnBrokerIfNeeded(intercomConfig.brokerCommand, intercomConfig.brokerArgs);
800
+ await this.app.connect();
801
+ const state = loadBridgeState(this.config.statePath);
802
+ this.app.on("notification", (message: JsonRpcMessage) => {
803
+ if (message.method === "notifications/cancelled" && message.params && typeof message.params === "object") {
804
+ const requestId = (message.params as Record<string, unknown>).requestId;
805
+ if (typeof requestId === "string" || typeof requestId === "number") {
806
+ this.inflightToolCalls.get(requestId)?.abort();
807
+ }
808
+ }
809
+ for (const agent of this.agents) agent.onNotification(message);
810
+ });
811
+ this.agents = this.config.agents.map((agent) => new VirtualCodexAgent(agent, this.app, state, this.config.statePath, this.hooks, {}, this.scopeId));
812
+ for (const agent of this.agents) await agent.start();
813
+ process.stderr.write(`codex-intercom bridge running ${this.agents.length} virtual agent(s)\n`);
814
+ }
815
+
816
+ async stop(): Promise<void> {
817
+ for (const agent of this.agents) await agent.stop().catch(() => undefined);
818
+ await this.app.disconnect();
819
+ }
820
+
821
+ async ensureThreadForAgent(agentId: string): Promise<string> {
822
+ const agent = this.agents.find((candidate) => candidate.id === agentId);
823
+ if (!agent) throw new Error(`No bridge agent registered with id: ${agentId}`);
824
+ return agent.ensureThread();
825
+ }
826
+
827
+ async getContactTargetForAgent(agentId: string): Promise<IntercomContact> {
828
+ const agent = this.agents.find((candidate) => candidate.id === agentId);
829
+ if (!agent) throw new Error(`No bridge agent registered with id: ${agentId}`);
830
+ return agent.getContactTarget();
831
+ }
832
+
833
+ private async handleServerRequest(message: JsonRpcMessage): Promise<unknown> {
834
+ if (message.method === "mcpServer/elicitation/request" && isIntercomToolApprovalRequest(message.params)) {
835
+ const threadId = getNotificationThreadId(message.params);
836
+ const turnId = getNotificationTurnId(message.params);
837
+ const intercomSend = getApprovedIntercomSend(message.params);
838
+ if (threadId && turnId && intercomSend) {
839
+ const agent = this.agents.find((candidate) => candidate.ownsThread(threadId));
840
+ if (agent) await agent.replyToWaitersFromIntercomSend(turnId, intercomSend);
841
+ }
842
+ return { action: "accept", content: {}, _meta: null };
843
+ }
844
+
845
+ if (message.method !== "item/tool/call") {
846
+ if (!message.method) throw new Error("Unsupported app-server request");
847
+ return defaultServerRequestResponse(message.method);
848
+ }
849
+
850
+ const call = extractToolCall(message);
851
+ const agent = call.threadId
852
+ ? this.agents.find((candidate) => candidate.ownsThread(call.threadId!))
853
+ : this.agents[0];
854
+ if (!agent) return appServerToolResponse(textToolResult("No bridge agent owns this tool call.", { ok: false }, true));
855
+ const requestId = message.id;
856
+ const abortController = typeof requestId === "string" || typeof requestId === "number"
857
+ ? new AbortController()
858
+ : null;
859
+ if (abortController && requestId !== undefined) this.inflightToolCalls.set(requestId, abortController);
860
+ try {
861
+ return await agent.handleToolCall(call.name, call.args, call.turnId, abortController?.signal);
862
+ } finally {
863
+ if (abortController && requestId !== undefined) this.inflightToolCalls.delete(requestId);
864
+ }
865
+ }
866
+ }
867
+
868
+ export function isIntercomToolApprovalRequest(params: unknown): boolean {
869
+ if (!isRecord(params)) return false;
870
+ const meta = isRecord(params._meta) ? params._meta : {};
871
+ return params.serverName === "codex-intercom"
872
+ && meta.codex_approval_kind === "mcp_tool_call"
873
+ && Boolean(getApprovedIntercomToolFromApproval(params));
874
+ }
875
+
876
+ export function getApprovedIntercomToolFromApproval(params: unknown): string | null {
877
+ if (!isRecord(params)) return null;
878
+ const meta = isRecord(params._meta) ? params._meta : {};
879
+ const candidates = [
880
+ meta.tool,
881
+ meta.toolName,
882
+ meta.tool_name,
883
+ meta.name,
884
+ typeof params.message === "string" ? params.message.match(/tool "([^"]+)"/)?.[1] : undefined,
885
+ ];
886
+ for (const candidate of candidates) {
887
+ if (typeof candidate === "string" && APPROVED_INTERCOM_TOOLS.has(candidate)) return candidate;
888
+ }
889
+ return null;
890
+ }
891
+
892
+ async function main(): Promise<void> {
893
+ const configPath = process.argv.includes("--config")
894
+ ? process.argv[process.argv.indexOf("--config") + 1]
895
+ : undefined;
896
+ const config = loadBridgeConfig(configPath);
897
+ if (!config.agents.length) throw new Error("Bridge config must include at least one agent");
898
+ const daemon = new CodexBridgeDaemon(config);
899
+ const stop = () => {
900
+ void daemon.stop().finally(() => process.exit(0));
901
+ };
902
+ process.on("SIGINT", stop);
903
+ process.on("SIGTERM", stop);
904
+ await daemon.start();
905
+ await once(process, "SIGTERM");
906
+ }
907
+
908
+ if (process.argv[1] && (basename(process.argv[1]) === "bridge-daemon.ts" || basename(process.argv[1]) === "bridge-daemon.mjs")) {
909
+ void main().catch((error) => {
910
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
911
+ process.exit(1);
912
+ });
913
+ }