@gonzih/cc-discord 0.2.48 → 0.2.50

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.
@@ -18,6 +18,11 @@ import { join } from "path";
18
18
  import { homedir } from "os";
19
19
  import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, discordChatOutgoing, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
20
20
  const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
21
+ const CHAT_SOURCE = "claude";
22
+ function agentDriver() {
23
+ const raw = (process.env.CC_DISCORD_AGENT_DRIVER ?? process.env.AGENT_DRIVER ?? "claude").toLowerCase();
24
+ return raw === "codex" ? "codex" : "claude";
25
+ }
21
26
  /**
22
27
  * Returns the path to the workspace for the given namespace.
23
28
  * Creates the parent root directory if needed.
@@ -52,13 +57,18 @@ export async function ensureWorkspace(ns, repoUrl) {
52
57
  *
53
58
  * Template priority:
54
59
  * 1. CC_DISCORD_MCP_JSON env var (full JSON string) — operator-supplied override
55
- * 2. Built-in template: cc-agent MCP server with namespace-scoped env
60
+ * 2. Built-in template: gitkb MCP plus ATC shell environment
56
61
  *
57
62
  * Variables substituted in the template: {namespace}, {workspacePath}, {token},
58
63
  * {npmCache}, {trustedOwners}, {path}.
59
64
  */
60
65
  export function injectMcp(ns, wsPath, token) {
61
66
  const mcpPath = join(wsPath, ".mcp.json");
67
+ const codexDir = join(wsPath, ".codex");
68
+ const codexConfigPath = join(codexDir, "config.toml");
69
+ const agentsPath = join(wsPath, "AGENTS.md");
70
+ const atcDir = join(wsPath, ".atc");
71
+ const atcConfigPath = join(atcDir, "config.toml");
62
72
  if (process.env.CC_DISCORD_MCP_JSON) {
63
73
  const rendered = process.env.CC_DISCORD_MCP_JSON
64
74
  .replace(/\{namespace\}/g, ns)
@@ -66,34 +76,75 @@ export function injectMcp(ns, wsPath, token) {
66
76
  .replace(/\{token\}/g, token);
67
77
  writeFileSync(mcpPath, rendered, "utf8");
68
78
  console.log(`[meta-agent-manager] injected MCP config (from CC_DISCORD_MCP_JSON) for ${ns}`);
69
- return;
70
79
  }
71
- const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
72
- const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "gonzih,ecoclaw,simorgh-app";
73
- const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
74
- const config = {
75
- mcpServers: {
76
- "gitkb": {
77
- command: "/opt/homebrew/bin/git-kb",
78
- args: ["mcp"],
79
- },
80
- "cc-agent": {
81
- command: "/opt/homebrew/bin/npx",
82
- args: ["-y", "--prefer-online", "@gonzih/cc-agent"],
83
- env: {
84
- CC_AGENT_NAMESPACE: ns,
85
- CWD: wsPath,
86
- CLAUDE_CODE_OAUTH_TOKEN: token,
87
- CLAUDE_TOKENS: token,
88
- CC_AGENT_TRUSTED_OWNERS: trustedOwners,
89
- PATH: systemPath,
90
- npm_config_cache: npmCache,
80
+ else {
81
+ const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
82
+ const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
83
+ const config = {
84
+ mcpServers: {
85
+ "gitkb": {
86
+ command: "/opt/homebrew/bin/git-kb",
87
+ args: ["mcp"],
88
+ env: {
89
+ GITKB_ROOT: wsPath,
90
+ ATC_ROOT: process.env.ATC_ROOT ?? `${homedir()}/.local/share/atc`,
91
+ DISPATCH_KB_ROOT: wsPath,
92
+ DISPATCH_META_ROOT: process.env.DISPATCH_META_ROOT ?? wsPath,
93
+ PATH: systemPath,
94
+ npm_config_cache: npmCache,
95
+ },
91
96
  },
92
97
  },
93
- },
94
- };
95
- writeFileSync(mcpPath, JSON.stringify(config, null, 2), "utf8");
96
- console.log(`[meta-agent-manager] injected MCP config for namespace=${ns}`);
98
+ };
99
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2), "utf8");
100
+ console.log(`[meta-agent-manager] injected MCP config for namespace=${ns}`);
101
+ }
102
+ const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
103
+ const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
104
+ mkdirSync(codexDir, { recursive: true });
105
+ mkdirSync(atcDir, { recursive: true });
106
+ writeFileSync(codexConfigPath, [
107
+ "[mcp_servers.gitkb]",
108
+ 'command = "/opt/homebrew/bin/git-kb"',
109
+ 'args = ["mcp"]',
110
+ "",
111
+ "[mcp_servers.gitkb.env]",
112
+ `GITKB_ROOT = ${JSON.stringify(wsPath)}`,
113
+ `ATC_ROOT = ${JSON.stringify(process.env.ATC_ROOT ?? `${homedir()}/.local/share/atc`)}`,
114
+ `DISPATCH_KB_ROOT = ${JSON.stringify(wsPath)}`,
115
+ `DISPATCH_META_ROOT = ${JSON.stringify(process.env.DISPATCH_META_ROOT ?? wsPath)}`,
116
+ `PATH = ${JSON.stringify(systemPath)}`,
117
+ `npm_config_cache = ${JSON.stringify(npmCache)}`,
118
+ "",
119
+ ].join("\n"), "utf8");
120
+ writeFileSync(atcConfigPath, [
121
+ "[dispatch]",
122
+ "sandbox = false",
123
+ "project_env = true",
124
+ `max_turns = ${Number(process.env.ATC_MAX_TURNS ?? 10000)}`,
125
+ `max_budget_usd = ${Number(process.env.ATC_MAX_BUDGET_USD ?? 250)}`,
126
+ `max_retries = ${Number(process.env.ATC_MAX_RETRIES ?? 1)}`,
127
+ "",
128
+ ].join("\n"), "utf8");
129
+ if (!existsSync(agentsPath)) {
130
+ writeFileSync(agentsPath, [
131
+ `# ${ns} Agent Workspace`,
132
+ "",
133
+ "- This workspace is managed by cc-discord.",
134
+ "- Use gitkb for project memory.",
135
+ "- Use ATC for spawned or delegated agent work; ATC is a private local/cloud system and must not be vendored, published, or documented as public API.",
136
+ "- ATC command patterns:",
137
+ " - Implement a task: `GITKB_ROOT=$PWD atc run task <slug>`",
138
+ " - Research a task: `GITKB_ROOT=$PWD atc run research task <slug>`",
139
+ " - Review a PR: `GITKB_ROOT=$PWD atc run pr-review --param pr=<url>`",
140
+ " - Monitor work: `atc status --flat`, `atc logs <slug-or-id>`, `atc watch --id <dispatch-id>`",
141
+ "- Dry-run before dispatch: run `atc run <args> --dry-run`, inspect resolver/directive/worktree/repo, adjust the command, and dry-run again until the output matches intent.",
142
+ "- Never dispatch when the dry-run resolves as raw `prompt` unless raw-prompt dispatch is explicitly intended.",
143
+ "- Keep changes scoped to the registered repository for this Discord namespace.",
144
+ "",
145
+ ].join("\n"), "utf8");
146
+ }
147
+ console.log(`[meta-agent-manager] injected Codex config for namespace=${ns}`);
97
148
  }
98
149
  /**
99
150
  * Resolve claude binary — same logic as claude.ts resolveClaude.
@@ -121,6 +172,27 @@ function resolveClaude() {
121
172
  }
122
173
  return "claude";
123
174
  }
175
+ function resolveCodex() {
176
+ if (process.env.CODEX_BIN)
177
+ return process.env.CODEX_BIN;
178
+ const dirs = (process.env.PATH ?? "").split(":");
179
+ for (const dir of dirs) {
180
+ const c = `${dir}/codex`;
181
+ if (existsSync(c))
182
+ return c;
183
+ }
184
+ const fallbacks = [
185
+ `${homedir()}/.npm-global/bin/codex`,
186
+ "/opt/homebrew/bin/codex",
187
+ "/usr/local/bin/codex",
188
+ "/usr/bin/codex",
189
+ ];
190
+ for (const p of fallbacks) {
191
+ if (existsSync(p))
192
+ return p;
193
+ }
194
+ return "codex";
195
+ }
124
196
  /**
125
197
  * Redis keys for meta-agent stdout streaming.
126
198
  * cca:meta:{ns}:stream — pub/sub channel (live streaming)
@@ -134,6 +206,8 @@ export const metaLogKey = ccWireMetaLogKey;
134
206
  */
135
207
  function buildEnv(token) {
136
208
  const env = { ...process.env };
209
+ if (!token)
210
+ return env;
137
211
  if (token.startsWith("sk-ant-api")) {
138
212
  env.ANTHROPIC_API_KEY = token;
139
213
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
@@ -144,6 +218,276 @@ function buildEnv(token) {
144
218
  }
145
219
  return env;
146
220
  }
221
+ function codexArgs(message, sessionId) {
222
+ const extra = (process.env.CC_DISCORD_CODEX_ARGS ?? "--dangerously-bypass-approvals-and-sandbox")
223
+ .split(/\s+/)
224
+ .map((s) => s.trim())
225
+ .filter(Boolean);
226
+ if (sessionId) {
227
+ return ["exec", "resume", "--json", "--skip-git-repo-check", ...extra, sessionId, message];
228
+ }
229
+ return ["exec", "--json", "--skip-git-repo-check", ...extra, message];
230
+ }
231
+ function codexAppServerArgs() {
232
+ const extra = (process.env.CC_DISCORD_CODEX_SERVER_ARGS ?? "")
233
+ .split(/\s+/)
234
+ .map((s) => s.trim())
235
+ .filter(Boolean);
236
+ return ["app-server", "--stdio", ...extra];
237
+ }
238
+ function codexThreadParams(wsPath) {
239
+ return {
240
+ cwd: wsPath,
241
+ approvalPolicy: process.env.CC_DISCORD_CODEX_APPROVAL_POLICY ?? "never",
242
+ sandbox: process.env.CC_DISCORD_CODEX_SANDBOX ?? "danger-full-access",
243
+ };
244
+ }
245
+ function codexTurnParams(threadId, message, wsPath) {
246
+ return {
247
+ threadId,
248
+ cwd: wsPath,
249
+ approvalPolicy: process.env.CC_DISCORD_CODEX_APPROVAL_POLICY ?? "never",
250
+ sandboxPolicy: { type: "dangerFullAccess" },
251
+ input: [{ type: "text", text: message, text_elements: [] }],
252
+ };
253
+ }
254
+ function publishDiscordEvent(wire, ns, driver, event, content = "") {
255
+ wire._redis.publish(discordChatOutgoing(ns), JSON.stringify({
256
+ id: crypto.randomUUID(),
257
+ source: driver,
258
+ role: "assistant",
259
+ content,
260
+ event,
261
+ timestamp: new Date().toISOString(),
262
+ chatId: 0,
263
+ })).catch(() => { });
264
+ }
265
+ function publishAssistantText(wire, ns, driver, content, logLabel = "publishOutgoing") {
266
+ const msg = {
267
+ id: crypto.randomUUID(),
268
+ source: driver,
269
+ role: "assistant",
270
+ content,
271
+ timestamp: new Date().toISOString(),
272
+ chatId: 0,
273
+ };
274
+ if (driver === "codex") {
275
+ wire._redis.publish(discordChatOutgoing(ns), JSON.stringify(msg)).catch((err) => {
276
+ console.warn(`[meta-agent-manager] ${logLabel} failed (ns=${ns}):`, err.message);
277
+ });
278
+ return;
279
+ }
280
+ wire.discord.publishOutgoing(ns, { ...msg, source: CHAT_SOURCE }).catch((err) => {
281
+ console.warn(`[meta-agent-manager] ${logLabel} failed (ns=${ns}):`, err.message);
282
+ });
283
+ }
284
+ function extractCodexItemText(item) {
285
+ if (!item)
286
+ return "";
287
+ if (typeof item.text === "string")
288
+ return item.text;
289
+ const content = item.content;
290
+ if (!Array.isArray(content))
291
+ return "";
292
+ return content.map((part) => {
293
+ if (part && typeof part === "object" && "text" in part && typeof part.text === "string") {
294
+ return part.text;
295
+ }
296
+ return "";
297
+ }).join("");
298
+ }
299
+ const codexDeltaItemIds = new Set();
300
+ function forwardCodexAppServerMessage(ns, wire, msg) {
301
+ const eventJson = JSON.stringify(msg);
302
+ const rawRedis = wire._redis;
303
+ const streamCh = metaStreamChannel(ns);
304
+ const logKey = metaLogKey(ns);
305
+ rawRedis.publish(streamCh, eventJson).catch((err) => {
306
+ console.warn(`[meta-agent-manager] codex stream publish failed (ns=${ns}):`, err.message);
307
+ });
308
+ rawRedis.lpush(logKey, eventJson).then(() => {
309
+ rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
310
+ }).catch((err) => {
311
+ console.warn(`[meta-agent-manager] codex log lpush failed (ns=${ns}):`, err.message);
312
+ });
313
+ const method = typeof msg.method === "string" ? msg.method : "";
314
+ const params = (msg.params && typeof msg.params === "object") ? msg.params : {};
315
+ if (method === "item/agentMessage/delta" && typeof params.delta === "string" && params.delta) {
316
+ if (typeof params.itemId === "string")
317
+ codexDeltaItemIds.add(`${ns}:${params.itemId}`);
318
+ publishAssistantText(wire, ns, "codex", params.delta, "codex delta publishOutgoing");
319
+ return;
320
+ }
321
+ if (method === "item/started") {
322
+ const item = params.item;
323
+ const itemType = typeof item?.type === "string" ? item.type : "";
324
+ if (itemType === "commandExecution" || itemType === "mcpToolCall" || itemType === "dynamicToolCall") {
325
+ const label = typeof item?.command === "string"
326
+ ? item.command
327
+ : typeof item?.tool === "string"
328
+ ? item.tool
329
+ : itemType;
330
+ publishDiscordEvent(wire, ns, "codex", "tool_start", label);
331
+ }
332
+ return;
333
+ }
334
+ if (method === "item/completed") {
335
+ const item = params.item;
336
+ const itemType = typeof item?.type === "string" ? item.type : "";
337
+ if (itemType === "agentMessage") {
338
+ const itemId = typeof item?.id === "string" ? item.id : "";
339
+ if (itemId && codexDeltaItemIds.has(`${ns}:${itemId}`)) {
340
+ codexDeltaItemIds.delete(`${ns}:${itemId}`);
341
+ return;
342
+ }
343
+ const text = extractCodexItemText(item);
344
+ if (text) {
345
+ publishAssistantText(wire, ns, "codex", text, "codex item publishOutgoing");
346
+ }
347
+ }
348
+ else if (itemType === "commandExecution" || itemType === "mcpToolCall" || itemType === "dynamicToolCall") {
349
+ publishDiscordEvent(wire, ns, "codex", "tool_end");
350
+ }
351
+ return;
352
+ }
353
+ if (method === "turn/completed") {
354
+ publishDiscordEvent(wire, ns, "codex", "done");
355
+ return;
356
+ }
357
+ if (method === "error" || msg.error) {
358
+ const err = (msg.error && typeof msg.error === "object") ? msg.error : params;
359
+ const message = typeof err.message === "string" ? err.message : JSON.stringify(err);
360
+ publishAssistantText(wire, ns, "codex", `⚠️ Error: ${message}`, "codex error publishOutgoing");
361
+ }
362
+ }
363
+ function spawnCodexAppServerSession(ns, token, wire, onExit) {
364
+ const wsPath = workspacePath(ns);
365
+ const proc = spawn(resolveCodex(), codexAppServerArgs(), {
366
+ cwd: wsPath,
367
+ env: buildEnv(token),
368
+ stdio: ["pipe", "pipe", "pipe"],
369
+ });
370
+ proc.stdin.setDefaultEncoding("utf8");
371
+ const pending = new Map();
372
+ let nextId = 1;
373
+ let lineBuffer = "";
374
+ const session = {
375
+ proc,
376
+ ns,
377
+ wsPath,
378
+ running: false,
379
+ queue: [],
380
+ ready: Promise.resolve(),
381
+ sendRequest(method, params) {
382
+ const id = nextId++;
383
+ const message = { method, id, params };
384
+ return new Promise((resolve, reject) => {
385
+ pending.set(id, { resolve, reject });
386
+ proc.stdin.write(`${JSON.stringify(message)}\n`, (err) => {
387
+ if (err) {
388
+ pending.delete(id);
389
+ reject(err);
390
+ }
391
+ });
392
+ });
393
+ },
394
+ sendNotification(method, params) {
395
+ proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
396
+ },
397
+ stop() {
398
+ for (const [, waiter] of pending)
399
+ waiter.reject(new Error("Codex app-server stopped"));
400
+ pending.clear();
401
+ try {
402
+ proc.stdin.end();
403
+ proc.kill("SIGTERM");
404
+ }
405
+ catch {
406
+ // ignore shutdown errors
407
+ }
408
+ },
409
+ };
410
+ function handleLine(line) {
411
+ const trimmed = line.trim();
412
+ if (!trimmed)
413
+ return;
414
+ let msg;
415
+ try {
416
+ msg = JSON.parse(trimmed);
417
+ }
418
+ catch {
419
+ msg = { method: "text", params: { text: trimmed } };
420
+ }
421
+ if (typeof msg.id === "number") {
422
+ const waiter = pending.get(msg.id);
423
+ if (waiter) {
424
+ pending.delete(msg.id);
425
+ if (msg.error) {
426
+ const err = msg.error;
427
+ waiter.reject(new Error(err.message ?? JSON.stringify(msg.error)));
428
+ }
429
+ else {
430
+ waiter.resolve(msg);
431
+ }
432
+ }
433
+ }
434
+ const method = typeof msg.method === "string" ? msg.method : "";
435
+ if (method === "turn/started") {
436
+ const params = (msg.params && typeof msg.params === "object") ? msg.params : {};
437
+ const turn = (params.turn && typeof params.turn === "object") ? params.turn : {};
438
+ if (typeof turn.id === "string")
439
+ session.activeTurnId = turn.id;
440
+ }
441
+ else if (method === "turn/completed") {
442
+ session.activeTurnId = undefined;
443
+ }
444
+ forwardCodexAppServerMessage(ns, wire, msg);
445
+ }
446
+ proc.stdout.on("data", (chunk) => {
447
+ lineBuffer += chunk.toString();
448
+ const lines = lineBuffer.split("\n");
449
+ lineBuffer = lines.pop() ?? "";
450
+ for (const line of lines)
451
+ handleLine(line);
452
+ });
453
+ proc.stderr.on("data", (chunk) => {
454
+ const text = chunk.toString().trim();
455
+ if (text)
456
+ console.log(`[meta-agent-manager:${ns}:codex-stderr] ${text}`);
457
+ });
458
+ proc.on("exit", (code) => {
459
+ if (lineBuffer.trim())
460
+ handleLine(lineBuffer);
461
+ for (const [, waiter] of pending)
462
+ waiter.reject(new Error(`Codex app-server exited with code ${code}`));
463
+ pending.clear();
464
+ console.log(`[meta-agent-manager] codex app-server exited (ns=${ns}, code=${code})`);
465
+ onExit();
466
+ });
467
+ proc.on("error", (err) => {
468
+ for (const [, waiter] of pending)
469
+ waiter.reject(err);
470
+ pending.clear();
471
+ console.error(`[meta-agent-manager] codex app-server spawn error (ns=${ns}):`, err.message);
472
+ onExit();
473
+ });
474
+ session.ready = (async () => {
475
+ await session.sendRequest("initialize", {
476
+ clientInfo: { name: "cc-discord", title: "cc-discord", version: "0.1.0" },
477
+ capabilities: { experimentalApi: true, requestAttestation: false },
478
+ });
479
+ session.sendNotification("initialized", {});
480
+ const threadResponse = await session.sendRequest("thread/start", codexThreadParams(wsPath));
481
+ const result = (threadResponse.result && typeof threadResponse.result === "object")
482
+ ? threadResponse.result
483
+ : {};
484
+ const thread = (result.thread && typeof result.thread === "object") ? result.thread : {};
485
+ if (typeof thread.id !== "string")
486
+ throw new Error("Codex app-server did not return a thread id");
487
+ session.threadId = thread.id;
488
+ })();
489
+ return session;
490
+ }
147
491
  /**
148
492
  * Wire stdout from a claude subprocess into Redis.
149
493
  * Parses JSONL lines from stream-json format, publishes to:
@@ -151,12 +495,10 @@ function buildEnv(token) {
151
495
  * LPUSH cca:meta:{ns}:log (capped at 2000)
152
496
  * Also dispatches assistant/result text to wire.discord.publishOutgoing.
153
497
  */
154
- function wireStdoutToRedis(proc, ns, wire) {
498
+ function wireStdoutToRedis(proc, ns, wire, driver = "claude", onParsedEvent) {
155
499
  const rawRedis = wire._redis;
156
500
  const streamCh = metaStreamChannel(ns);
157
501
  const logKey = metaLogKey(ns);
158
- // Deduplicate rate_limit_event: Claude emits multiple per turn; only notify once per session.
159
- let rateLimitNotified = false;
160
502
  const forwardEventToRedis = (eventJson) => {
161
503
  rawRedis.publish(streamCh, eventJson).catch((err) => {
162
504
  console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
@@ -174,6 +516,7 @@ function wireStdoutToRedis(proc, ns, wire) {
174
516
  let structuredEvent;
175
517
  try {
176
518
  const parsed = JSON.parse(trimmed);
519
+ onParsedEvent?.(parsed);
177
520
  const type = parsed.type;
178
521
  if (type === "assistant") {
179
522
  const content = parsed.message;
@@ -181,17 +524,7 @@ function wireStdoutToRedis(proc, ns, wire) {
181
524
  const text = textBlock?.text ?? "";
182
525
  structuredEvent = { type: "assistant", text };
183
526
  if (text) {
184
- const msg = {
185
- id: crypto.randomUUID(),
186
- source: "claude",
187
- role: "assistant",
188
- content: text,
189
- timestamp: new Date().toISOString(),
190
- chatId: 0,
191
- };
192
- wire.discord.publishOutgoing(ns, msg).catch((err) => {
193
- console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
194
- });
527
+ publishAssistantText(wire, ns, driver, text);
195
528
  }
196
529
  }
197
530
  else if (type === "tool_use") {
@@ -203,7 +536,7 @@ function wireStdoutToRedis(proc, ns, wire) {
203
536
  };
204
537
  // Ephemeral signal: notifier shows tool activity overlay in the live message
205
538
  rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
206
- id: crypto.randomUUID(), source: "claude", role: "assistant",
539
+ id: crypto.randomUUID(), source: driver, role: "assistant",
207
540
  content: toolName, event: "tool_start", timestamp: new Date().toISOString(), chatId: 0,
208
541
  })).catch(() => { });
209
542
  }
@@ -214,7 +547,7 @@ function wireStdoutToRedis(proc, ns, wire) {
214
547
  };
215
548
  // Ephemeral signal: tool finished, notifier can restart finalize timer
216
549
  rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
217
- id: crypto.randomUUID(), source: "claude", role: "assistant",
550
+ id: crypto.randomUUID(), source: driver, role: "assistant",
218
551
  content: "", event: "tool_end", timestamp: new Date().toISOString(), chatId: 0,
219
552
  })).catch(() => { });
220
553
  }
@@ -227,55 +560,54 @@ function wireStdoutToRedis(proc, ns, wire) {
227
560
  };
228
561
  if (parsed.is_error && resultText) {
229
562
  // Error case: assistant event may not carry the error message — publish it directly
230
- const errMsg = {
231
- id: crypto.randomUUID(),
232
- source: "claude",
233
- role: "assistant",
234
- content: `⚠️ Error: ${resultText}`,
235
- timestamp: new Date().toISOString(),
236
- chatId: 0,
237
- };
238
- wire.discord.publishOutgoing(ns, errMsg).catch(() => { });
563
+ publishAssistantText(wire, ns, driver, `⚠️ Error: ${resultText}`);
239
564
  }
240
565
  // Signal turn completion — text was already published via the assistant event above.
241
566
  // Do NOT re-publish resultText here: that would double-deliver the same content.
242
567
  rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
243
- id: crypto.randomUUID(), source: "claude", role: "assistant",
568
+ id: crypto.randomUUID(), source: driver, role: "assistant",
244
569
  content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
245
570
  })).catch(() => { });
246
571
  }
247
- else if (type === "rate_limit_event") {
248
- const rl = parsed.rate_limit_info;
572
+ else if (driver === "codex" && type === "item.completed") {
573
+ const item = parsed.item;
574
+ const itemType = item?.type;
575
+ const text = typeof item?.text === "string" ? item.text : "";
249
576
  structuredEvent = parsed;
250
- // Throttled: five-hour window exhausted and no overage credits.
251
- // Claude emits multiple rate_limit_events per turn — only act on the first.
252
- if (rl?.overageStatus === "rejected" && !rateLimitNotified) {
253
- rateLimitNotified = true;
254
- const resetsAt = rl.resetsAt ? new Date(rl.resetsAt * 1000).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" }) : "soon";
255
- const alertContent = `⏳ Rate limited — five-hour window exhausted. Resets at **${resetsAt}**. Session paused; send any message after reset to resume.`;
256
- // 1. Close the current turn first — finalizes any real response text already in the live state.
257
- // If this is a pure rate-limit turn (no preceding text), done is a no-op in the notifier.
577
+ if (itemType === "agent_message" && text) {
578
+ publishAssistantText(wire, ns, "codex", text);
579
+ }
580
+ else if (itemType === "command_execution" || itemType === "mcp_tool_call") {
258
581
  rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
259
- id: crypto.randomUUID(), source: "claude", role: "assistant",
260
- content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
582
+ id: crypto.randomUUID(), source: driver, role: "assistant",
583
+ content: "", event: "tool_end", timestamp: new Date().toISOString(), chatId: 0,
261
584
  })).catch(() => { });
262
- // 2. Send alert tagged as "rate_limit" so the notifier delivers it as a standalone message,
263
- // not accumulated into whatever text the current live state already holds.
585
+ }
586
+ }
587
+ else if (driver === "codex" && type === "item.started") {
588
+ const item = parsed.item;
589
+ const itemType = item?.type;
590
+ structuredEvent = parsed;
591
+ if (itemType === "command_execution" || itemType === "mcp_tool_call") {
592
+ const label = typeof item?.command === "string" ? item.command : (itemType ?? "tool");
264
593
  rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
265
- id: crypto.randomUUID(), source: "claude", role: "assistant",
266
- content: alertContent, event: "rate_limit",
267
- timestamp: new Date().toISOString(), chatId: 0,
594
+ id: crypto.randomUUID(), source: driver, role: "assistant",
595
+ content: label, event: "tool_start", timestamp: new Date().toISOString(), chatId: 0,
268
596
  })).catch(() => { });
269
- // Kill the process — API calls will hang until reset anyway
270
- setTimeout(() => {
271
- try {
272
- proc.stdin.end();
273
- proc.kill("SIGTERM");
274
- }
275
- catch { /* ignore */ }
276
- }, 500);
277
597
  }
278
598
  }
599
+ else if (driver === "codex" && (type === "turn.completed" || type === "turn.failed")) {
600
+ structuredEvent = parsed;
601
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
602
+ id: crypto.randomUUID(), source: driver, role: "assistant",
603
+ content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
604
+ })).catch(() => { });
605
+ }
606
+ else if (driver === "codex" && type === "error") {
607
+ const message = typeof parsed.message === "string" ? parsed.message : JSON.stringify(parsed);
608
+ structuredEvent = parsed;
609
+ publishAssistantText(wire, ns, "codex", `⚠️ Error: ${message}`);
610
+ }
279
611
  else {
280
612
  structuredEvent = parsed;
281
613
  }
@@ -317,8 +649,26 @@ function wireStdoutToRedis(proc, ns, wire) {
317
649
  export function spawnSession(ns, message, token, wire) {
318
650
  return new Promise((resolve, reject) => {
319
651
  const wsPath = workspacePath(ns);
320
- const claudeBin = resolveClaude();
321
652
  const env = buildEnv(token);
653
+ const driver = agentDriver();
654
+ if (driver === "codex") {
655
+ const proc = spawn(resolveCodex(), codexArgs(message), {
656
+ cwd: wsPath,
657
+ env,
658
+ stdio: ["ignore", "pipe", "pipe"],
659
+ });
660
+ wireStdoutToRedis(proc, ns, wire, "codex");
661
+ proc.on("exit", (code) => {
662
+ console.log(`[meta-agent-manager] codex session exited (ns=${ns}, code=${code})`);
663
+ resolve();
664
+ });
665
+ proc.on("error", (err) => {
666
+ console.error(`[meta-agent-manager] codex spawn error (ns=${ns}):`, err.message);
667
+ reject(err);
668
+ });
669
+ return;
670
+ }
671
+ const claudeBin = resolveClaude();
322
672
  const proc = spawn(claudeBin, [
323
673
  "--continue",
324
674
  "-p", message,
@@ -397,6 +747,8 @@ export function createMetaAgentManager() {
397
747
  let watchdogInterval = null;
398
748
  /** One persistent ChildProcess per namespace. */
399
749
  const sessions = new Map();
750
+ /** One persistent Codex app-server process per namespace. */
751
+ const codexSessions = new Map();
400
752
  /** Namespaces currently being set up (workspace clone / first spawn). */
401
753
  const startingUp = new Set();
402
754
  /** Wire reference needed for watchdog notifications. Set in startPolling. */
@@ -488,6 +840,91 @@ export function createMetaAgentManager() {
488
840
  writeToStdin(ns, content);
489
841
  }
490
842
  }
843
+ async function popQueueMessage(ns, wire) {
844
+ const inputKey = discordMetaInputKey(ns);
845
+ let raw;
846
+ try {
847
+ raw = await wire._redis.lpop(inputKey);
848
+ }
849
+ catch {
850
+ return null;
851
+ }
852
+ if (!raw)
853
+ return null;
854
+ try {
855
+ return JSON.parse(raw).content ?? raw;
856
+ }
857
+ catch {
858
+ return raw;
859
+ }
860
+ }
861
+ async function ensureCodexSession(ns, repoUrl, token, wire) {
862
+ const existing = codexSessions.get(ns);
863
+ if (existing) {
864
+ await existing.ready;
865
+ return existing;
866
+ }
867
+ const wsPath = workspacePath(ns);
868
+ await ensureWorkspace(ns, repoUrl);
869
+ injectMcp(ns, wsPath, token);
870
+ const session = spawnCodexAppServerSession(ns, token, wire, () => {
871
+ codexSessions.delete(ns);
872
+ console.log(`[meta-agent-manager] codex session removed from map (ns=${ns})`);
873
+ });
874
+ codexSessions.set(ns, session);
875
+ await session.ready;
876
+ return session;
877
+ }
878
+ async function runCodexTurn(ns, repoUrl, token, wire, message) {
879
+ const session = await ensureCodexSession(ns, repoUrl, token, wire);
880
+ if (message.trim() === "/compact") {
881
+ await session.sendRequest("thread/compact/start", { threadId: session.threadId });
882
+ return;
883
+ }
884
+ if (!session.threadId)
885
+ throw new Error("Codex app-server session is missing thread id");
886
+ const response = await session.sendRequest("turn/start", codexTurnParams(session.threadId, message, session.wsPath));
887
+ const result = (response.result && typeof response.result === "object") ? response.result : {};
888
+ const turn = (result.turn && typeof result.turn === "object") ? result.turn : {};
889
+ if (typeof turn.id === "string")
890
+ session.activeTurnId = turn.id;
891
+ }
892
+ async function drainCodexQueue(ns, repoUrl, token, wire) {
893
+ const session = await ensureCodexSession(ns, repoUrl, token, wire);
894
+ if (session.running)
895
+ return;
896
+ session.running = true;
897
+ try {
898
+ for (;;) {
899
+ const message = await popQueueMessage(ns, wire);
900
+ if (message == null)
901
+ break;
902
+ if (session.activeTurnId && session.threadId) {
903
+ await session.sendRequest("turn/steer", {
904
+ threadId: session.threadId,
905
+ expectedTurnId: session.activeTurnId,
906
+ input: [{ type: "text", text: message, text_elements: [] }],
907
+ });
908
+ }
909
+ else {
910
+ await runCodexTurn(ns, repoUrl, token, wire, message);
911
+ }
912
+ }
913
+ await wire.discord.setStatus(ns, {
914
+ namespace: ns,
915
+ status: "running",
916
+ isTyping: false,
917
+ turnCount: 0,
918
+ updatedAt: new Date().toISOString(),
919
+ });
920
+ }
921
+ catch (err) {
922
+ console.error(`[meta-agent-manager] drainCodexQueue failed (ns=${ns}):`, err.message);
923
+ }
924
+ finally {
925
+ session.running = false;
926
+ }
927
+ }
491
928
  /**
492
929
  * Ensure a persistent session exists for ns.
493
930
  * If one already exists, returns immediately.
@@ -577,6 +1014,28 @@ export function createMetaAgentManager() {
577
1014
  }).catch(() => { });
578
1015
  }
579
1016
  // If session exists, drain any queued messages
1017
+ if (agentDriver() === "codex") {
1018
+ const inputKey = discordMetaInputKey(ns);
1019
+ wire._redis.llen(inputKey).then(async (queueLen) => {
1020
+ if (queueLen === 0)
1021
+ return;
1022
+ let token = "";
1023
+ try {
1024
+ token = await wire.token.getMaster();
1025
+ }
1026
+ catch {
1027
+ token = process.env.CLAUDE_CODE_OAUTH_TOKEN
1028
+ ?? process.env.CLAUDE_CODE_TOKEN
1029
+ ?? process.env.ANTHROPIC_API_KEY
1030
+ ?? "";
1031
+ }
1032
+ await drainCodexQueue(ns, repoUrl, token, wire);
1033
+ }).catch((err) => {
1034
+ console.warn(`[meta-agent-manager] codex llen error (ns=${ns}):`, err.message);
1035
+ });
1036
+ continue;
1037
+ }
1038
+ // If session exists, drain any queued messages
580
1039
  if (sessions.has(ns)) {
581
1040
  drainQueue(ns, wire).catch((err) => {
582
1041
  console.warn(`[meta-agent-manager] drainQueue error (ns=${ns}):`, err.message);
@@ -634,8 +1093,22 @@ export function createMetaAgentManager() {
634
1093
  sessions.delete(ns);
635
1094
  console.log(`[meta-agent-manager] killed session on stop (ns=${ns})`);
636
1095
  }
1096
+ for (const [ns, session] of codexSessions) {
1097
+ session.stop();
1098
+ codexSessions.delete(ns);
1099
+ console.log(`[meta-agent-manager] killed codex session on stop (ns=${ns})`);
1100
+ }
1101
+ codexSessions.clear();
637
1102
  },
638
1103
  killSession(ns) {
1104
+ if (agentDriver() === "codex") {
1105
+ const session = codexSessions.get(ns);
1106
+ if (session)
1107
+ session.stop();
1108
+ codexSessions.delete(ns);
1109
+ console.log(`[meta-agent-manager] cleared codex session state (ns=${ns})`);
1110
+ return;
1111
+ }
639
1112
  const session = sessions.get(ns);
640
1113
  if (!session)
641
1114
  return;
@@ -650,12 +1123,25 @@ export function createMetaAgentManager() {
650
1123
  console.log(`[meta-agent-manager] killed session (ns=${ns})`);
651
1124
  },
652
1125
  sendToSession(ns, line) {
1126
+ if (agentDriver() === "codex") {
1127
+ if (!wireRef)
1128
+ return;
1129
+ wireRef._redis.rpush(discordMetaInputKey(ns), JSON.stringify({
1130
+ id: crypto.randomUUID(),
1131
+ content: line,
1132
+ timestamp: new Date().toISOString(),
1133
+ source: "cc-discord",
1134
+ })).catch((err) => {
1135
+ console.warn(`[meta-agent-manager] codex sendToSession enqueue failed (ns=${ns}):`, err.message);
1136
+ });
1137
+ return;
1138
+ }
653
1139
  writeToStdin(ns, line);
654
1140
  },
655
1141
  };
656
1142
  }
657
1143
  /**
658
- * Migrate the old cc-agent meta input key to the new cc-discord key.
1144
+ * Migrate the old meta input key to the new cc-discord key.
659
1145
  * Old: cca:meta:{ns}:input → New: cca:discord:meta:{ns}:input
660
1146
  *
661
1147
  * Called once on startup.