@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,488 @@
1
+ import { randomUUID, createHash } from "crypto";
2
+ import { spawnSync } from "child_process";
3
+ import { basename } from "path";
4
+ import { cwd as processCwd } from "process";
5
+ import { IntercomClient } from "../broker/client.ts";
6
+ import { intercomScopeIdFromEnv } from "../protocol-v4/contract.ts";
7
+ import { spawnBrokerIfNeeded } from "../broker/spawn.ts";
8
+ import { getAskTimeoutMs, loadConfig } from "../config.ts";
9
+ import type { Attachment, Message, SessionInfo } from "../types.ts";
10
+ import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
11
+
12
+ export interface CodexRuntimeIdentity {
13
+ sessionId: string;
14
+ name: string;
15
+ cwd: string;
16
+ model: string;
17
+ startedAt: number;
18
+ }
19
+
20
+ export interface PendingInboundMessage {
21
+ from: SessionInfo;
22
+ message: Message;
23
+ receivedAt: number;
24
+ read: boolean;
25
+ }
26
+
27
+ export type ReplyWhich = "oldest" | "latest";
28
+
29
+ function matchesPendingSender(entry: PendingInboundMessage, to: string): boolean {
30
+ return entry.from.id === to
31
+ || entry.from.name?.toLowerCase() === to.toLowerCase()
32
+ || entry.from.id.startsWith(to);
33
+ }
34
+
35
+ export function selectPendingAsk(entries: PendingInboundMessage[], to?: string, which?: ReplyWhich): PendingInboundMessage {
36
+ const sorted = [...entries].sort((a, b) => a.receivedAt - b.receivedAt);
37
+ if (sorted.length === 0) throw new Error("No matching pending ask. Call intercom_pending to inspect unresolved asks.");
38
+ const matches = to ? sorted.filter((entry) => matchesPendingSender(entry, to)) : sorted;
39
+ if (matches.length === 0) throw new Error(`No pending ask from "${to}".`);
40
+ if (matches.length === 1) return matches[0]!;
41
+ if (!to && new Set(matches.map((entry) => entry.from.id)).size > 1) {
42
+ throw new Error("Multiple pending asks — specify `to` using a sender from intercom_pending.");
43
+ }
44
+ if (!which) {
45
+ const sender = to ? ` from "${to}"` : "";
46
+ throw new Error(`Multiple pending asks${sender} — specify \`which\` as \`oldest\` or \`latest\`.`);
47
+ }
48
+ return which === "oldest" ? matches[0]! : matches[matches.length - 1]!;
49
+ }
50
+
51
+ function pendingSelector(entries: PendingInboundMessage[], entry: PendingInboundMessage): "oldest" | "latest" | "queued" | undefined {
52
+ const sameSender = entries.filter((candidate) => candidate.from.id === entry.from.id);
53
+ if (sameSender.length <= 1) return undefined;
54
+ const index = sameSender.findIndex((candidate) => candidate.message.id === entry.message.id);
55
+ if (index === 0) return "oldest";
56
+ if (index === sameSender.length - 1) return "latest";
57
+ return "queued";
58
+ }
59
+
60
+ function publicPendingEntry(entry: PendingInboundMessage, selector?: string): Record<string, unknown> {
61
+ return {
62
+ from: {
63
+ id: entry.from.id,
64
+ name: entry.from.name,
65
+ origin: entry.from.origin ?? "local",
66
+ ...(entry.from.remoteHostId ? { remote_host_id: entry.from.remoteHostId } : {}),
67
+ ...(entry.from.parentSessionId ? { parent_session_id: entry.from.parentSessionId } : {}),
68
+ ...(entry.from.generation ? { generation: entry.from.generation } : {}),
69
+ },
70
+ received_at: entry.receivedAt,
71
+ read: entry.read,
72
+ text: entry.message.content.text,
73
+ attachments: entry.message.content.attachments,
74
+ expects_reply: entry.message.expectsReply,
75
+ ...(selector ? { selector } : {}),
76
+ };
77
+ }
78
+
79
+ export interface ToolResult {
80
+ content: Array<{ type: "text"; text: string }>;
81
+ structuredContent?: Record<string, unknown>;
82
+ isError?: boolean;
83
+ }
84
+
85
+ interface ReplyWaiter {
86
+ from: string;
87
+ replyTo: string;
88
+ resolve: (message: Message) => void;
89
+ reject: (error: Error) => void;
90
+ timeout: NodeJS.Timeout;
91
+ cleanup?: () => void;
92
+ }
93
+
94
+ function shortHash(value: string): string {
95
+ return createHash("sha256").update(value).digest("hex").slice(0, 8);
96
+ }
97
+
98
+ export function buildCodexRuntimeIdentity(env: NodeJS.ProcessEnv = process.env, cwd = env.PWD || processCwd(), pid = process.pid): CodexRuntimeIdentity {
99
+ const sessionId = env.CODEX_INTERCOM_SESSION_ID?.trim()
100
+ || env.CODEX_PEER_ID?.trim()
101
+ || `codex-${pid}-${shortHash(cwd)}`;
102
+ const cwdName = basename(cwd) || "workspace";
103
+ const name = env.CODEX_INTERCOM_NAME?.trim()
104
+ || env.CODEX_PEER_NAME?.trim()
105
+ || `codex-${cwdName}-${pid}`;
106
+ return {
107
+ sessionId,
108
+ name,
109
+ cwd,
110
+ model: env.CODEX_INTERCOM_MODEL?.trim() || env.CODEX_MODEL?.trim() || "codex",
111
+ startedAt: Date.now(),
112
+ };
113
+ }
114
+
115
+ export function formatAttachments(attachments: Attachment[] | undefined): string {
116
+ if (!attachments?.length) return "";
117
+ return attachments.map((attachment) => {
118
+ if (attachment.language) {
119
+ return `\n\n---\nAttachment: ${attachment.name}\n~~~${attachment.language}\n${attachment.content}\n~~~`;
120
+ }
121
+ return `\n\n---\nAttachment: ${attachment.name}\n${attachment.content}`;
122
+ }).join("");
123
+ }
124
+
125
+ export function resolveSessionTarget(sessions: SessionInfo[], nameOrId: string): string | null {
126
+ const byId = sessions.find((session) => session.id === nameOrId);
127
+ if (byId) return byId.id;
128
+
129
+ const lowerName = nameOrId.toLowerCase();
130
+ const byName = sessions.filter((session) => session.name?.toLowerCase() === lowerName);
131
+ if (byName.length > 1) {
132
+ throw new Error(`Multiple sessions named "${nameOrId}" are connected. Use the session ID instead.`);
133
+ }
134
+ if (byName[0]) return byName[0].id;
135
+
136
+ if (nameOrId.length >= 4) {
137
+ const byPrefix = sessions.filter((session) => session.id.startsWith(nameOrId));
138
+ if (byPrefix.length > 1) {
139
+ throw new Error(`Multiple sessions match the ID prefix "${nameOrId}". Use the full session ID or a unique name.`);
140
+ }
141
+ if (byPrefix[0]) return byPrefix[0].id;
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ export function formatSessionDisplay(session: SessionInfo): string {
148
+ const name = session.name || session.id;
149
+ return session.origin === "remote" ? `${name} [remote:${session.remoteHostId || "unknown-host"}]` : name;
150
+ }
151
+
152
+ export function formatSessionList(sessions: SessionInfo[], currentSessionId: string | null, currentCwd: string): string {
153
+ if (!sessions.length) return "No intercom sessions connected.";
154
+ return sessions.map((session) => {
155
+ const tags = [
156
+ session.id === currentSessionId ? "self" : undefined,
157
+ session.cwd === currentCwd ? "same cwd" : undefined,
158
+ session.status,
159
+ ].filter((tag): tag is string => Boolean(tag));
160
+ const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
161
+ return `- ${formatSessionDisplay(session)} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`;
162
+ }).join("\n");
163
+ }
164
+
165
+ export function detectGitRoot(cwd: string): string | null {
166
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
167
+ cwd,
168
+ encoding: "utf8",
169
+ stdio: ["ignore", "pipe", "ignore"],
170
+ });
171
+ if (result.status !== 0) return null;
172
+ return result.stdout.trim() || null;
173
+ }
174
+
175
+ function textResult(text: string, structuredContent?: Record<string, unknown>, isError = false): ToolResult {
176
+ return {
177
+ content: [{ type: "text", text }],
178
+ ...(structuredContent ? { structuredContent } : {}),
179
+ ...(isError ? { isError: true } : {}),
180
+ };
181
+ }
182
+
183
+ export interface CodexIntercomRuntimeOptions {
184
+ clientFactory?: () => IntercomClient;
185
+ prepareConnection?: () => Promise<void>;
186
+ reconnectDelays?: number[];
187
+ }
188
+
189
+ export class CodexIntercomRuntime {
190
+ private client: IntercomClient | null = null;
191
+ private connectPromise: Promise<IntercomClient> | null = null;
192
+ private reconnectTimer: NodeJS.Timeout | null = null;
193
+ private reconnectAttempt = 0;
194
+ private reconnectEnabled = true;
195
+ private identity: CodexRuntimeIdentity;
196
+ private unread: PendingInboundMessage[] = [];
197
+ private unresolvedAsks = new Map<string, PendingInboundMessage>();
198
+ private replyWaiters = new Map<string, ReplyWaiter>();
199
+
200
+ private readonly clientFactory: () => IntercomClient;
201
+ private readonly prepareConnection: () => Promise<void>;
202
+ private readonly reconnectDelays: number[];
203
+
204
+ constructor(identity: CodexRuntimeIdentity = buildCodexRuntimeIdentity(), options: CodexIntercomRuntimeOptions = {}) {
205
+ this.identity = identity;
206
+ const initialScopeId = intercomScopeIdFromEnv();
207
+ this.clientFactory = options.clientFactory ?? (() => new IntercomClient(initialScopeId ? { scopeId: initialScopeId } : {}));
208
+ this.prepareConnection = options.prepareConnection ?? (async () => {
209
+ const config = loadConfig();
210
+ if (!config.enabled) throw new Error("Intercom disabled");
211
+ await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
212
+ });
213
+ this.reconnectDelays = options.reconnectDelays?.length ? options.reconnectDelays : [250, 500, 1000, 2000, 5000];
214
+ }
215
+
216
+ getIdentity(): CodexRuntimeIdentity {
217
+ return this.identity;
218
+ }
219
+
220
+ async connect(): Promise<IntercomClient> {
221
+ this.reconnectEnabled = true;
222
+ this.clearReconnectTimer();
223
+ if (this.client?.isConnected()) return this.client;
224
+ if (this.connectPromise) return this.connectPromise;
225
+ this.connectPromise = this.connectOnce();
226
+ try {
227
+ return await this.connectPromise;
228
+ } finally {
229
+ this.connectPromise = null;
230
+ }
231
+ }
232
+
233
+ private async connectOnce(): Promise<IntercomClient> {
234
+ await this.prepareConnection();
235
+ const client = this.clientFactory();
236
+ client.on("message", (from: SessionInfo, message: Message, deliveryId: string) => {
237
+ this.handleIncomingMessage(from, message);
238
+ client.acknowledgeMessage(deliveryId);
239
+ });
240
+ client.on("disconnected", (error: Error) => {
241
+ for (const waiter of this.replyWaiters.values()) {
242
+ clearTimeout(waiter.timeout);
243
+ waiter.cleanup?.();
244
+ waiter.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
245
+ }
246
+ this.replyWaiters.clear();
247
+ if (this.client === client) this.client = null;
248
+ this.scheduleReconnect();
249
+ });
250
+ await client.connect({
251
+ name: this.identity.name,
252
+ cwd: this.identity.cwd,
253
+ model: this.identity.model,
254
+ pid: process.pid,
255
+ startedAt: this.identity.startedAt,
256
+ lastActivity: Date.now(),
257
+ status: "idle",
258
+ }, this.identity.sessionId);
259
+ this.client = client;
260
+ this.reconnectAttempt = 0;
261
+ return client;
262
+ }
263
+
264
+ private scheduleReconnect(): void {
265
+ if (!this.reconnectEnabled || this.reconnectTimer) return;
266
+ const delay = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)]!;
267
+ this.reconnectTimer = setTimeout(() => {
268
+ this.reconnectTimer = null;
269
+ void this.connect().then((client) => {
270
+ if (!client.isConnected()) {
271
+ this.reconnectAttempt += 1;
272
+ this.scheduleReconnect();
273
+ }
274
+ }).catch(() => {
275
+ this.reconnectAttempt += 1;
276
+ this.scheduleReconnect();
277
+ });
278
+ }, delay);
279
+ this.reconnectTimer.unref?.();
280
+ }
281
+
282
+ private clearReconnectTimer(): void {
283
+ if (!this.reconnectTimer) return;
284
+ clearTimeout(this.reconnectTimer);
285
+ this.reconnectTimer = null;
286
+ }
287
+
288
+ async disconnect(): Promise<void> {
289
+ this.reconnectEnabled = false;
290
+ this.clearReconnectTimer();
291
+ if (this.connectPromise) {
292
+ try {
293
+ await this.connectPromise;
294
+ } catch {
295
+ // A failed in-progress connection is already closed.
296
+ }
297
+ }
298
+ const client = this.client;
299
+ this.client = null;
300
+ if (client) await client.disconnect();
301
+ }
302
+
303
+ private handleIncomingMessage(from: SessionInfo, message: Message): void {
304
+ const waiter = this.replyWaiters.get(message.replyTo ?? "");
305
+ if (waiter) {
306
+ const senderTarget = from.name || from.id;
307
+ const fromMatches = senderTarget.toLowerCase() === waiter.from.toLowerCase() || from.id === waiter.from;
308
+ if (fromMatches) {
309
+ this.replyWaiters.delete(waiter.replyTo);
310
+ clearTimeout(waiter.timeout);
311
+ waiter.cleanup?.();
312
+ waiter.resolve(message);
313
+ return;
314
+ }
315
+ }
316
+
317
+ const entry = { from, message, receivedAt: Date.now(), read: false };
318
+ this.unread.push(entry);
319
+ if (message.expectsReply) {
320
+ this.unresolvedAsks.set(message.id, entry);
321
+ }
322
+ }
323
+
324
+ private waitForReply(from: string, replyTo: string, timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<Message> {
325
+ return new Promise((resolve, reject) => {
326
+ if (signal?.aborted) {
327
+ reject(new Error("intercom_ask cancelled"));
328
+ return;
329
+ }
330
+ let timeout: NodeJS.Timeout;
331
+ const cleanup = () => {
332
+ clearTimeout(timeout);
333
+ signal?.removeEventListener("abort", onAbort);
334
+ };
335
+ const onAbort = () => {
336
+ this.replyWaiters.delete(replyTo);
337
+ cleanup();
338
+ void this.client?.cancelAsk(replyTo);
339
+ reject(new Error("intercom_ask cancelled"));
340
+ };
341
+ timeout = setTimeout(() => {
342
+ this.replyWaiters.delete(replyTo);
343
+ void this.client?.deferAsk(replyTo);
344
+ signal?.removeEventListener("abort", onAbort);
345
+ reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1000)} seconds`));
346
+ }, timeoutMs);
347
+ signal?.addEventListener("abort", onAbort, { once: true });
348
+ this.replyWaiters.set(replyTo, { from, replyTo, resolve, reject, timeout, cleanup });
349
+ });
350
+ }
351
+
352
+ private async resolveTarget(to: string): Promise<string> {
353
+ const client = await this.connect();
354
+ const sessions = await client.listSessions();
355
+ return resolveSessionTarget(sessions, to) ?? to;
356
+ }
357
+
358
+ async whoami(): Promise<ToolResult> {
359
+ const client = await this.connect();
360
+ const sessionId = client.sessionId ?? this.identity.sessionId;
361
+ return textResult(
362
+ `session_id: ${sessionId}\nname: ${this.identity.name}\ncwd: ${this.identity.cwd}`,
363
+ { session_id: sessionId, name: this.identity.name, cwd: this.identity.cwd, model: this.identity.model },
364
+ );
365
+ }
366
+
367
+ async team(): Promise<ToolResult> {
368
+ const client = await this.connect();
369
+ const sessions = await client.listSessions();
370
+ const team = await resolveIntercomTeam({ selfId: client.sessionId ?? this.identity.sessionId, sessions });
371
+ return textResult(formatIntercomTeam(team), team as unknown as Record<string, unknown>);
372
+ }
373
+
374
+ async status(): Promise<ToolResult> {
375
+ const client = await this.connect();
376
+ const sessions = await client.listSessions();
377
+ return textResult(
378
+ `Connected: ${client.isConnected() ? "Yes" : "No"}\nSession ID: ${client.sessionId ?? "unknown"}\nActive sessions: ${sessions.length}\nUnread messages: ${this.unread.filter((entry) => !entry.read).length}\nPending asks: ${this.unresolvedAsks.size}`,
379
+ {
380
+ connected: client.isConnected(),
381
+ session_id: client.sessionId,
382
+ active_sessions: sessions.length,
383
+ unread_messages: this.unread.filter((entry) => !entry.read).length,
384
+ pending_asks: this.unresolvedAsks.size,
385
+ },
386
+ );
387
+ }
388
+
389
+ async list(scope: "machine" | "directory" | "repo" = "machine", includeSelf = false): Promise<ToolResult> {
390
+ const client = await this.connect();
391
+ let sessions = await client.listSessions();
392
+ if (scope === "directory") {
393
+ sessions = sessions.filter((session) => session.cwd === this.identity.cwd);
394
+ } else if (scope === "repo") {
395
+ const currentRoot = detectGitRoot(this.identity.cwd);
396
+ sessions = currentRoot
397
+ ? sessions.filter((session) => detectGitRoot(session.cwd) === currentRoot)
398
+ : [];
399
+ }
400
+ if (!includeSelf) {
401
+ sessions = sessions.filter((session) => session.id !== client.sessionId);
402
+ }
403
+ return textResult(formatSessionList(sessions, client.sessionId, this.identity.cwd), { sessions });
404
+ }
405
+
406
+ async setSummary(summary: string): Promise<ToolResult> {
407
+ const client = await this.connect();
408
+ client.updatePresence({ status: summary.trim() || "idle" });
409
+ return textResult("Summary updated.", { ok: true, summary });
410
+ }
411
+
412
+ async send(to: string, message: string, attachments?: Attachment[], replyTo?: string): Promise<ToolResult> {
413
+ const client = await this.connect();
414
+ const sendTo = await this.resolveTarget(to);
415
+ const result = await client.send(sendTo, { text: message, attachments, replyTo });
416
+ if (!result.delivered) {
417
+ return textResult(`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);
418
+ }
419
+ if (replyTo) this.unresolvedAsks.delete(replyTo);
420
+ return textResult(`Message sent to ${to}.`, { ok: true, message_id: result.id, to });
421
+ }
422
+
423
+ async ask(to: string, message: string, attachments?: Attachment[], timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<ToolResult> {
424
+ const client = await this.connect();
425
+ const sendTo = await this.resolveTarget(to);
426
+ const questionId = randomUUID();
427
+ const replyPromise = this.waitForReply(sendTo, questionId, timeoutMs, signal);
428
+ void replyPromise.catch(() => undefined);
429
+ try {
430
+ const result = await client.send(sendTo, {
431
+ messageId: questionId,
432
+ text: message,
433
+ attachments,
434
+ expectsReply: true,
435
+ });
436
+ if (!result.delivered) {
437
+ this.replyWaiters.get(questionId)?.reject(new Error(result.reason ?? "Session may not exist or has disconnected."));
438
+ this.replyWaiters.delete(questionId);
439
+ client.cancelAsk(questionId);
440
+ return textResult(`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);
441
+ }
442
+ const reply = await replyPromise;
443
+ const replyText = `${reply.content.text}${formatAttachments(reply.content.attachments)}`;
444
+ return textResult(`Reply from ${to}:\n${replyText}`, { ok: true, message_id: result.id, reply });
445
+ } catch (error) {
446
+ client.cancelAsk(questionId);
447
+ return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
448
+ }
449
+ }
450
+
451
+ async pending(markRead = false): Promise<ToolResult> {
452
+ const unreadMessages = this.unread.filter((entry) => !entry.read);
453
+ if (markRead) {
454
+ for (const entry of unreadMessages) entry.read = true;
455
+ }
456
+ const pendingAsks = Array.from(this.unresolvedAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
457
+ const lines = [
458
+ unreadMessages.length
459
+ ? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n")
460
+ : "No unread messages.",
461
+ pendingAsks.length
462
+ ? `\nPending asks:\n${pendingAsks.map((entry) => {
463
+ const selector = pendingSelector(pendingAsks, entry);
464
+ return `- ${formatSessionDisplay(entry.from)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`;
465
+ }).join("\n")}`
466
+ : "",
467
+ ].filter(Boolean);
468
+ return textResult(lines.join("\n"), {
469
+ unread_messages: unreadMessages.map((entry) => publicPendingEntry(entry)),
470
+ pending_asks: pendingAsks.map((entry) => publicPendingEntry(entry, pendingSelector(pendingAsks, entry))),
471
+ });
472
+ }
473
+
474
+ async reply(message: string, to?: string, which?: ReplyWhich): Promise<ToolResult> {
475
+ let target: PendingInboundMessage;
476
+ try {
477
+ target = selectPendingAsk(Array.from(this.unresolvedAsks.values()), to, which);
478
+ } catch (error) {
479
+ return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
480
+ }
481
+
482
+ const result = await this.send(target.from.id, message, undefined, target.message.id);
483
+ if (!result.isError) {
484
+ this.unresolvedAsks.delete(target.message.id);
485
+ }
486
+ return result;
487
+ }
488
+ }
@@ -0,0 +1,60 @@
1
+ import readline from "node:readline";
2
+ import { stdin, stdout } from "node:process";
3
+ import { CodexIntercomRuntime } from "./runtime.ts";
4
+ import { handleMcpRequest } from "./mcp-protocol.ts";
5
+
6
+ const runtime = new CodexIntercomRuntime();
7
+
8
+ const rl = readline.createInterface({
9
+ input: stdin,
10
+ crlfDelay: Infinity,
11
+ });
12
+
13
+ let shuttingDown = false;
14
+ let pendingRequests = 0;
15
+
16
+ function writeResponse(response: Record<string, unknown> | undefined): void {
17
+ if (!response) return;
18
+ stdout.write(`${JSON.stringify(response)}\n`);
19
+ }
20
+
21
+ function maybeShutdown(): void {
22
+ if (!shuttingDown || pendingRequests > 0) return;
23
+ void runtime.disconnect().finally(() => process.exit(0));
24
+ }
25
+
26
+ rl.on("line", (line) => {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) return;
29
+ pendingRequests += 1;
30
+ void (async () => {
31
+ try {
32
+ const request = JSON.parse(trimmed);
33
+ writeResponse(await handleMcpRequest(request, runtime));
34
+ } catch (error) {
35
+ writeResponse({
36
+ jsonrpc: "2.0",
37
+ id: null,
38
+ error: {
39
+ code: -32700,
40
+ message: error instanceof Error ? error.message : String(error),
41
+ },
42
+ });
43
+ }
44
+ })().finally(() => {
45
+ pendingRequests -= 1;
46
+ maybeShutdown();
47
+ });
48
+ });
49
+
50
+ const shutdown = () => {
51
+ if (shuttingDown) return;
52
+ shuttingDown = true;
53
+ rl.close();
54
+ maybeShutdown();
55
+ };
56
+
57
+ rl.on("close", shutdown);
58
+ process.on("SIGINT", shutdown);
59
+ process.on("SIGTERM", shutdown);
60
+ process.on("disconnect", shutdown);