claude2codex 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -3,9 +3,12 @@
3
3
  // src/server.ts
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { stat } from "fs/promises";
7
+ import { resolve } from "path";
6
8
  import { z } from "zod";
7
9
 
8
10
  // src/contracts.ts
11
+ import { statSync } from "fs";
9
12
  import { readFile } from "fs/promises";
10
13
  import { join } from "path";
11
14
  var PROTOCOL_INSTRUCTIONS = `## Protocol Instructions
@@ -47,6 +50,19 @@ async function optionalAgentsInstructions(cwd) {
47
50
  throw error;
48
51
  }
49
52
  }
53
+ function renderContextFiles(contextFiles2) {
54
+ if (!contextFiles2?.length) return [];
55
+ return [
56
+ "### Context Files",
57
+ "Read these files before implementing. They are reference material, not necessarily files to modify.",
58
+ ...contextFiles2.map((contextFile) => {
59
+ const directory = statSync(contextFile.path).isDirectory() ? " (directory)" : "";
60
+ const note = contextFile.note ? ` \u2014 ${contextFile.note}` : "";
61
+ return `- ${contextFile.path}${directory}${note}`;
62
+ }),
63
+ ""
64
+ ];
65
+ }
50
66
  async function renderGoalContract(c, cwd = process.cwd()) {
51
67
  const agents = await optionalAgentsInstructions(cwd);
52
68
  const lines = [
@@ -58,6 +74,7 @@ async function renderGoalContract(c, cwd = process.cwd()) {
58
74
  "### Goal",
59
75
  c.goal.trim(),
60
76
  "",
77
+ ...renderContextFiles(c.context_files),
61
78
  "### Constraints",
62
79
  ...c.constraints.map((x) => `- ${x}`),
63
80
  "",
@@ -75,6 +92,7 @@ function renderDeltaContract(d) {
75
92
  "### Findings",
76
93
  ...d.findings.map((x) => `- ${x}`),
77
94
  "",
95
+ ...renderContextFiles(d.context_files),
78
96
  "### Failed Success Conditions",
79
97
  ...d.failed_conditions.map((x) => `- [ ] ${x}`),
80
98
  "",
@@ -126,6 +144,7 @@ var CodexAppServer = class {
126
144
  pending = /* @__PURE__ */ new Map();
127
145
  opts;
128
146
  stderrTail = [];
147
+ exitError;
129
148
  exited = false;
130
149
  constructor(opts) {
131
150
  this.opts = opts;
@@ -142,24 +161,26 @@ var CodexAppServer = class {
142
161
  if (this.stderrTail.length > 50) this.stderrTail.shift();
143
162
  opts.onLog?.(line);
144
163
  });
145
- this.proc.on("exit", (code) => {
146
- this.exited = true;
147
- for (const [, p] of this.pending) {
148
- clearTimeout(p.timer);
149
- p.reject(new Error(`codex app-server exited (code ${code}). stderr: ${this.stderrTail.slice(-10).join(" | ")}`));
150
- }
151
- this.pending.clear();
152
- opts.onExit?.(code);
153
- });
164
+ this.proc.on("spawn", () => opts.onSpawn?.(this.proc.pid));
165
+ this.proc.on("exit", (code) => this.handleExit(code));
154
166
  this.proc.on("error", (err) => {
155
- this.exited = true;
156
- for (const [, p] of this.pending) {
157
- clearTimeout(p.timer);
158
- p.reject(new Error(`failed to spawn ${opts.bin}: ${err.message}`));
159
- }
160
- this.pending.clear();
167
+ this.handleExit(null, new Error(`failed to spawn ${opts.bin}: ${err.message}`));
161
168
  });
162
169
  }
170
+ handleExit(code, error) {
171
+ if (this.exited) return;
172
+ this.exited = true;
173
+ const stderr = this.stderrTail.slice(-10).join(" | ");
174
+ this.exitError = error ?? new Error(
175
+ `codex app-server exited (code ${code})${stderr ? `. stderr: ${stderr}` : ""}`
176
+ );
177
+ for (const [, p] of this.pending) {
178
+ clearTimeout(p.timer);
179
+ p.reject(this.exitError);
180
+ }
181
+ this.pending.clear();
182
+ this.opts.onExit?.(code, this.exitError);
183
+ }
163
184
  handleLine(line) {
164
185
  const trimmed = line.trim();
165
186
  if (!trimmed) return;
@@ -192,13 +213,16 @@ var CodexAppServer = class {
192
213
  this.proc.stdin.write(JSON.stringify(obj) + "\n");
193
214
  }
194
215
  request(method, params, timeoutMs = 6e4) {
216
+ if (this.exited) {
217
+ return Promise.reject(this.exitError ?? new Error("codex app-server is not running"));
218
+ }
195
219
  const id = this.nextId++;
196
- return new Promise((resolve, reject) => {
220
+ return new Promise((resolve2, reject) => {
197
221
  const timer = setTimeout(() => {
198
222
  this.pending.delete(id);
199
223
  reject(new Error(`request ${method} timed out after ${timeoutMs}ms`));
200
224
  }, timeoutMs);
201
- this.pending.set(id, { resolve, reject, timer });
225
+ this.pending.set(id, { resolve: resolve2, reject, timer });
202
226
  this.send({ id, method, ...params !== void 0 ? { params } : {} });
203
227
  });
204
228
  }
@@ -207,7 +231,7 @@ var CodexAppServer = class {
207
231
  }
208
232
  async initialize() {
209
233
  const result = await this.request("initialize", {
210
- clientInfo: { name: "claude2codex", title: "Claude\u2194Codex Contract Bridge", version: "0.1.0" },
234
+ clientInfo: { name: "claude2codex", title: "Claude\u2194Codex Contract Bridge", version: "0.3.0" },
211
235
  capabilities: { experimentalApi: true }
212
236
  });
213
237
  this.notify("initialized", {});
@@ -218,25 +242,178 @@ var CodexAppServer = class {
218
242
  }
219
243
  };
220
244
 
245
+ // src/log.ts
246
+ var priorities = { error: 1, info: 2, debug: 3 };
247
+ function configuredLevel() {
248
+ const value = process.env.C2C_LOG_LEVEL;
249
+ return value === "silent" || value === "error" || value === "info" || value === "debug" ? value : "info";
250
+ }
251
+ function writeLog(level, event, fields = {}) {
252
+ const configured = configuredLevel();
253
+ if (configured === "silent" || priorities[level] > priorities[configured]) return;
254
+ process.stderr.write(`${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, event, ...fields })}
255
+ `);
256
+ }
257
+
258
+ // src/store.ts
259
+ import { accessSync, constants, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
260
+ import { homedir } from "os";
261
+ import { join as join2 } from "path";
262
+ var MAX_JOBS = 50;
263
+ var warned = false;
264
+ function warnOnce(error) {
265
+ if (warned) return;
266
+ warned = true;
267
+ writeLog("error", "persistence_degraded", {
268
+ error: error instanceof Error ? error.message : String(error)
269
+ });
270
+ }
271
+ function defaultStateDir() {
272
+ return process.env.C2C_STATE_DIR ?? join2(homedir(), ".claude2codex", "jobs");
273
+ }
274
+ function createJobStore(dir = defaultStateDir()) {
275
+ const records = /* @__PURE__ */ new Map();
276
+ let enabled = true;
277
+ try {
278
+ mkdirSync(dir, { recursive: true });
279
+ accessSync(dir, constants.R_OK | constants.W_OK);
280
+ const interrupted = [];
281
+ for (const name of readdirSync(dir)) {
282
+ if (!name.endsWith(".json")) continue;
283
+ try {
284
+ const job = JSON.parse(readFileSync(join2(dir, name), "utf8"));
285
+ if (!job?.id || !job?.startedAt || !Array.isArray(job.transcript)) continue;
286
+ if (job.state === "blocked" || job.state === "queued" || job.state === "starting" || job.state === "running") {
287
+ job.state = "error";
288
+ job.error = "interrupted by server restart";
289
+ job.endedAt = (/* @__PURE__ */ new Date()).toISOString();
290
+ interrupted.push(job);
291
+ }
292
+ records.set(job.id, job);
293
+ } catch {
294
+ }
295
+ }
296
+ for (const job of interrupted) atomicWrite(dir, job);
297
+ prune(dir, records);
298
+ } catch (error) {
299
+ enabled = false;
300
+ warnOnce(error);
301
+ }
302
+ return {
303
+ getJob: (id) => records.get(id),
304
+ listJobs: () => [...records.values()],
305
+ save(job) {
306
+ const record = { ...job, transcript: job.transcript.slice(-200) };
307
+ records.set(job.id, record);
308
+ if (!enabled) return;
309
+ try {
310
+ atomicWrite(dir, record);
311
+ prune(dir, records);
312
+ } catch (error) {
313
+ enabled = false;
314
+ warnOnce(error);
315
+ }
316
+ }
317
+ };
318
+ }
319
+ function atomicWrite(dir, job) {
320
+ const target = join2(dir, `${job.id}.json`);
321
+ const temp = join2(dir, `.${job.id}.${process.pid}.${Date.now()}.tmp`);
322
+ writeFileSync(temp, JSON.stringify(job), { encoding: "utf8", mode: 384 });
323
+ renameSync(temp, target);
324
+ }
325
+ function prune(dir, records) {
326
+ const entries = [...records.values()].sort(
327
+ (a, b) => Date.parse(b.endedAt ?? b.startedAt) - Date.parse(a.endedAt ?? a.startedAt)
328
+ );
329
+ for (const job of entries.slice(MAX_JOBS)) {
330
+ records.delete(job.id);
331
+ try {
332
+ unlinkSync(join2(dir, `${job.id}.json`));
333
+ } catch (error) {
334
+ if (error?.code !== "ENOENT") throw error;
335
+ }
336
+ }
337
+ }
338
+
221
339
  // src/jobs.ts
222
- var jobs = /* @__PURE__ */ new Map();
340
+ var store = createJobStore();
341
+ var jobs = new Map(store.listJobs().map((job) => [job.id, job]));
223
342
  function getJob(id) {
224
- return jobs.get(id);
343
+ return jobs.get(id) ?? store.getJob(id);
344
+ }
345
+ function listJobs() {
346
+ return [...jobs.values()];
347
+ }
348
+ var queue = [];
349
+ var blocked = /* @__PURE__ */ new Map();
350
+ function getQueuePosition(id) {
351
+ const index = queue.findIndex((entry) => entry.job.id === id);
352
+ return index === -1 ? void 0 : index + 1;
353
+ }
354
+ function activeJobCount() {
355
+ return [...jobs.values()].filter((job) => job.state === "starting" || job.state === "running").length;
356
+ }
357
+ function launch(job, opts) {
358
+ void runJob(job, opts).catch((err) => {
359
+ finish(job, "error", void 0, String(err?.message ?? err));
360
+ });
361
+ }
362
+ function drainQueue() {
363
+ while (queue.length > 0 && activeJobCount() < queue[0].opts.config.maxConcurrent) {
364
+ const { job, opts } = queue.shift();
365
+ job.state = "starting";
366
+ log(job, "queue", "dequeued; starting job");
367
+ store.save(job);
368
+ writeLog("info", "job_dequeued", { job_id: job.id, kind: job.kind });
369
+ opts.onProgress?.({ jobId: job.id, event: "dequeued", message: "job dequeued; starting" });
370
+ launch(job, opts);
371
+ }
225
372
  }
226
373
  function startJob(opts) {
374
+ const dependency = opts.dependsOn ? getJob(opts.dependsOn) : void 0;
375
+ if (opts.dependsOn && !dependency) throw new Error(`unknown depends_on job_id ${opts.dependsOn}`);
376
+ if (dependency?.state === "error" || dependency?.state === "timeout") {
377
+ throw new Error(dependencyFailureMessage(dependency));
378
+ }
379
+ const isBlocked = dependency !== void 0 && dependency.state !== "done";
380
+ const queued = !isBlocked && (queue.length > 0 || activeJobCount() >= opts.config.maxConcurrent);
381
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
227
382
  const job = {
228
383
  id: randomUUID().slice(0, 8),
229
384
  kind: opts.kind,
230
- state: "starting",
385
+ state: isBlocked ? "blocked" : queued ? "queued" : "starting",
231
386
  goalSet: false,
387
+ usage: {},
388
+ attempts: 1,
232
389
  turns: 0,
233
390
  transcript: [],
234
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
391
+ ...opts.contextFiles ? { contextFiles: opts.contextFiles } : {},
392
+ ...opts.dependsOn ? { dependsOn: opts.dependsOn } : {},
393
+ startedAt,
394
+ lastActivityAt: startedAt
235
395
  };
236
396
  jobs.set(job.id, job);
237
- void runJob(job, opts).catch((err) => {
238
- finish(job, "error", void 0, String(err?.message ?? err));
239
- });
397
+ if (isBlocked) {
398
+ const dependents = blocked.get(opts.dependsOn) ?? [];
399
+ dependents.push({ job, opts });
400
+ blocked.set(opts.dependsOn, dependents);
401
+ log(job, "blocked", `blocked on dependency ${opts.dependsOn}`);
402
+ } else if (queued) {
403
+ queue.push({ job, opts });
404
+ log(job, "queue", `enqueued at position ${queue.length}`);
405
+ }
406
+ store.save(job);
407
+ writeLog("info", "job_created", { job_id: job.id, kind: job.kind, state: job.state });
408
+ if (isBlocked) {
409
+ writeLog("info", "job_blocked", { job_id: job.id, kind: job.kind, depends_on: opts.dependsOn });
410
+ opts.onProgress?.({ jobId: job.id, event: "blocked", message: `blocked on dependency ${opts.dependsOn}` });
411
+ } else if (queued) {
412
+ writeLog("info", "job_enqueued", { job_id: job.id, kind: job.kind, queue_position: queue.length });
413
+ opts.onProgress?.({ jobId: job.id, event: "queued", message: `job queued at position ${queue.length}` });
414
+ } else {
415
+ launch(job, opts);
416
+ }
240
417
  return job;
241
418
  }
242
419
  function log(job, kind, detail) {
@@ -245,11 +422,73 @@ function log(job, kind, detail) {
245
422
  }
246
423
  function finish(job, state, client, error) {
247
424
  if (job.state === "done" || job.state === "error" || job.state === "timeout") return;
425
+ const previousState = job.state;
248
426
  job.state = state;
249
427
  job.error = error;
250
428
  job.endedAt = (/* @__PURE__ */ new Date()).toISOString();
251
429
  if (job.finalMessage) job.handoff = parseHandoff(job.finalMessage);
430
+ store.save(job);
431
+ writeLog(state === "error" || state === "timeout" ? "error" : "info", "job_state_changed", {
432
+ job_id: job.id,
433
+ from: previousState,
434
+ to: state,
435
+ ...error ? { error } : {}
436
+ });
437
+ writeLog("info", "job_usage", {
438
+ job_id: job.id,
439
+ state,
440
+ usage: job.usage ?? {}
441
+ });
252
442
  client?.kill();
443
+ resolveDependents(job);
444
+ drainQueue();
445
+ }
446
+ function dependencyFailureMessage(dependency) {
447
+ return `dependency ${dependency.id} failed: ${dependency.error ?? `state ${dependency.state}`}`;
448
+ }
449
+ function resolveDependents(dependency) {
450
+ const dependents = blocked.get(dependency.id);
451
+ if (!dependents?.length) return;
452
+ blocked.delete(dependency.id);
453
+ for (const { job, opts } of dependents) {
454
+ if (dependency.state === "done") {
455
+ log(job, "unblocked", `dependency ${dependency.id} completed; unblocked`);
456
+ store.save(job);
457
+ writeLog("info", "job_unblocked", { job_id: job.id, depends_on: dependency.id });
458
+ opts.onProgress?.({
459
+ jobId: job.id,
460
+ event: "unblocked",
461
+ message: `dependency ${dependency.id} completed; job unblocked`
462
+ });
463
+ enqueueOrLaunch(job, opts);
464
+ } else {
465
+ const error = dependencyFailureMessage(dependency);
466
+ log(job, "dependency_failed", error);
467
+ store.save(job);
468
+ writeLog("info", "job_dependency_failed", {
469
+ job_id: job.id,
470
+ depends_on: dependency.id,
471
+ error
472
+ });
473
+ opts.onProgress?.({ jobId: job.id, event: "dependency_failed", message: error });
474
+ finish(job, "error", void 0, error);
475
+ }
476
+ }
477
+ }
478
+ function enqueueOrLaunch(job, opts) {
479
+ if (queue.length > 0 || activeJobCount() >= opts.config.maxConcurrent) {
480
+ job.state = "queued";
481
+ queue.push({ job, opts });
482
+ log(job, "queue", `enqueued at position ${queue.length} after dependency completed`);
483
+ store.save(job);
484
+ writeLog("info", "job_enqueued", { job_id: job.id, kind: job.kind, queue_position: queue.length });
485
+ opts.onProgress?.({ jobId: job.id, event: "queued", message: `job queued at position ${queue.length}` });
486
+ drainQueue();
487
+ return;
488
+ }
489
+ job.state = "starting";
490
+ store.save(job);
491
+ launch(job, opts);
253
492
  }
254
493
  function extractText(item) {
255
494
  if (typeof item?.text === "string") return item.text;
@@ -270,23 +509,96 @@ function extractGoal(params) {
270
509
  timeUsedSeconds: g.timeUsedSeconds ?? g.time_used_seconds
271
510
  };
272
511
  }
512
+ var usageAliases = {
513
+ input_tokens: "inputTokens",
514
+ input_token_count: "inputTokens",
515
+ output_tokens: "outputTokens",
516
+ output_token_count: "outputTokens",
517
+ total_tokens: "totalTokens",
518
+ total_token_count: "totalTokens",
519
+ tokensUsed: "totalTokens",
520
+ tokens_used: "totalTokens"
521
+ };
522
+ function extractUsage(params) {
523
+ const source = params?.turn?.usage ?? params?.usage ?? params?.turn?.tokenUsage ?? params?.tokenUsage;
524
+ if (!source || typeof source !== "object") return void 0;
525
+ const usage = {};
526
+ for (const [key, value] of Object.entries(source)) {
527
+ if (typeof value === "number" && Number.isFinite(value)) usage[usageAliases[key] ?? key] = value;
528
+ }
529
+ return Object.keys(usage).length ? usage : void 0;
530
+ }
531
+ function mergeUsage(job, usage, cumulative = false) {
532
+ if (!usage) return;
533
+ job.usage ??= {};
534
+ for (const [key, value] of Object.entries(usage)) {
535
+ job.usage[key] = cumulative ? value : (job.usage[key] ?? 0) + value;
536
+ }
537
+ }
538
+ function updateGoal(job, params) {
539
+ job.goal = extractGoal(params);
540
+ if (typeof job.goal.tokensUsed === "number") {
541
+ mergeUsage(job, { totalTokens: job.goal.tokensUsed }, true);
542
+ }
543
+ store.save(job);
544
+ }
273
545
  var TERMINAL_GOAL_STATUSES = /* @__PURE__ */ new Set(["complete", "budget_limited", "budgetLimited"]);
274
546
  async function runJob(job, opts) {
547
+ const deadline = Date.now() + opts.config.jobTimeoutMs;
548
+ const maxAttempts = (opts.config.retries ?? 1) + 1;
549
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
550
+ job.attempts = attempt;
551
+ log(job, "attempt", `attempt ${attempt} started`);
552
+ store.save(job);
553
+ writeLog("info", "job_attempt_started", { job_id: job.id, attempt });
554
+ const result = await runAttempt(job, opts, Math.max(0, deadline - Date.now()));
555
+ if (!result.error || job.state === "done" || job.state === "error" || job.state === "timeout") return;
556
+ const retryable = result.processFailure && job.state === "starting" && job.turns === 0;
557
+ if (!retryable || attempt === maxAttempts) {
558
+ finish(job, "error", void 0, result.error.message);
559
+ return;
560
+ }
561
+ log(job, "retry", `attempt ${attempt} failed before the first turn; retrying in 500ms`);
562
+ store.save(job);
563
+ writeLog("info", "job_retry_scheduled", {
564
+ job_id: job.id,
565
+ attempt,
566
+ next_attempt: attempt + 1,
567
+ delay_ms: 500,
568
+ error: result.error.message
569
+ });
570
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
571
+ }
572
+ }
573
+ async function runAttempt(job, opts, timeoutMs) {
275
574
  const cfg = opts.config;
276
575
  let activeTurn = false;
576
+ let stalled = false;
577
+ let processError;
277
578
  let quietTimer;
579
+ let stallTimer;
278
580
  let settle;
279
581
  const doneSignal = new Promise((res) => settle = res);
280
582
  const progress = (event, message) => opts.onProgress?.({ jobId: job.id, event, message });
583
+ if (!opts.resumeThreadId) job.goalSet = false;
281
584
  const client = new CodexAppServer({
282
585
  bin: cfg.bin,
283
586
  args: cfg.args,
284
587
  cwd: cfg.cwd,
285
588
  onLog: (line) => log(job, "log", line),
286
- onExit: (code) => {
287
- if (job.state === "starting" || job.state === "running") {
288
- finish(job, "error", void 0, `codex app-server exited early (code ${code})`);
289
- }
589
+ onSpawn: (pid) => writeLog("info", "codex_process_spawn", {
590
+ job_id: job.id,
591
+ attempt: job.attempts ?? 1,
592
+ ...pid === void 0 ? {} : { pid }
593
+ }),
594
+ onExit: (code, error) => {
595
+ writeLog(job.state === "starting" || job.state === "running" ? "error" : "info", "codex_process_exit", {
596
+ job_id: job.id,
597
+ attempt: job.attempts ?? 1,
598
+ code,
599
+ error: error.message
600
+ });
601
+ if (job.state === "starting" || job.state === "running") processError = error;
290
602
  settle();
291
603
  },
292
604
  onNotification: (method, params) => handleNotification(method, params)
@@ -295,18 +607,73 @@ async function runJob(job, opts) {
295
607
  clearTimeout(quietTimer);
296
608
  quietTimer = setTimeout(() => {
297
609
  log(job, "quiet", `no goal continuation within ${cfg.quietMs}ms; treating turn as final`);
298
- finish(job, "done", client);
610
+ finalize("done");
299
611
  settle();
300
612
  }, cfg.quietMs);
301
613
  }
614
+ function clearStallTimer() {
615
+ clearTimeout(stallTimer);
616
+ stallTimer = void 0;
617
+ }
618
+ function armStallTimer() {
619
+ clearStallTimer();
620
+ if (cfg.stallWarnMs === 0 || !activeTurn || stalled || !job.lastActivityAt) return;
621
+ const stalledForMs = Math.max(0, Date.now() - Date.parse(job.lastActivityAt));
622
+ stallTimer = setTimeout(checkForStall, Math.max(0, cfg.stallWarnMs - stalledForMs));
623
+ }
624
+ function checkForStall() {
625
+ stallTimer = void 0;
626
+ if (!activeTurn || stalled || !job.lastActivityAt) return;
627
+ const stalledForMs = Math.max(0, Date.now() - Date.parse(job.lastActivityAt));
628
+ if (stalledForMs < cfg.stallWarnMs) {
629
+ armStallTimer();
630
+ return;
631
+ }
632
+ stalled = true;
633
+ log(job, "stalled", `no Codex activity for ${stalledForMs}ms`);
634
+ store.save(job);
635
+ writeLog("info", "job_stalled", { job_id: job.id, stalled_for_ms: stalledForMs });
636
+ progress("stalled", `job stalled for ${stalledForMs}ms`);
637
+ }
638
+ function recordActivity() {
639
+ const now = /* @__PURE__ */ new Date();
640
+ if (stalled && job.lastActivityAt) {
641
+ const stalledForMs = Math.max(0, now.getTime() - Date.parse(job.lastActivityAt));
642
+ stalled = false;
643
+ log(job, "resumed", `Codex activity resumed after ${stalledForMs}ms`);
644
+ writeLog("info", "job_resumed", { job_id: job.id, stalled_for_ms: stalledForMs });
645
+ progress("resumed", `job resumed after ${stalledForMs}ms`);
646
+ }
647
+ job.lastActivityAt = now.toISOString();
648
+ store.save(job);
649
+ if (activeTurn) armStallTimer();
650
+ }
651
+ function finalize(state, error) {
652
+ clearStallTimer();
653
+ finish(job, state, client, error);
654
+ }
302
655
  function handleNotification(method, params) {
656
+ writeLog("debug", "codex_notification", {
657
+ job_id: job.id,
658
+ attempt: job.attempts ?? 1,
659
+ method,
660
+ params: params ?? null
661
+ });
662
+ recordActivity();
303
663
  const p = params ?? {};
304
664
  switch (method) {
305
665
  case "turn/started": {
306
666
  activeTurn = true;
667
+ armStallTimer();
307
668
  job.turns += 1;
308
669
  clearTimeout(quietTimer);
670
+ if (job.state === "starting") {
671
+ job.state = "running";
672
+ store.save(job);
673
+ writeLog("info", "job_state_changed", { job_id: job.id, from: "starting", to: "running" });
674
+ }
309
675
  log(job, "turn", `turn ${job.turns} started`);
676
+ writeLog("info", "turn_started", { job_id: job.id, turn: job.turns });
310
677
  progress("turn_started", `turn ${job.turns} started`);
311
678
  break;
312
679
  }
@@ -328,11 +695,16 @@ async function runJob(job, opts) {
328
695
  break;
329
696
  }
330
697
  case "thread/goal/updated": {
331
- job.goal = extractGoal(p);
332
- log(job, "goal", `status=${job.goal.status} tokensUsed=${job.goal.tokensUsed ?? "?"}`);
333
- progress("goal_updated", `goal status=${job.goal.status ?? "unknown"}`);
334
- if (job.goal.status && TERMINAL_GOAL_STATUSES.has(job.goal.status) && !activeTurn) {
335
- finish(job, "done", client);
698
+ updateGoal(job, p);
699
+ log(job, "goal", `status=${job.goal?.status} tokensUsed=${job.goal?.tokensUsed ?? "?"}`);
700
+ writeLog("info", "goal_updated", {
701
+ job_id: job.id,
702
+ status: job.goal?.status ?? null,
703
+ tokens_used: job.goal?.tokensUsed ?? null
704
+ });
705
+ progress("goal_updated", `goal status=${job.goal?.status ?? "unknown"}`);
706
+ if (job.goal?.status && TERMINAL_GOAL_STATUSES.has(job.goal.status) && !activeTurn) {
707
+ finalize("done");
336
708
  settle();
337
709
  }
338
710
  break;
@@ -340,8 +712,12 @@ async function runJob(job, opts) {
340
712
  case "turn/completed":
341
713
  case "turn/failed": {
342
714
  activeTurn = false;
715
+ clearStallTimer();
716
+ mergeUsage(job, extractUsage(p));
717
+ store.save(job);
343
718
  const status = p.turn?.status ?? (method === "turn/failed" ? "failed" : "completed");
344
719
  log(job, "turn", `turn ended (${status})`);
720
+ writeLog("info", "turn_ended", { job_id: job.id, turn: job.turns, status });
345
721
  progress("turn_ended", `turn ended (${status})`);
346
722
  void onTurnEnded();
347
723
  break;
@@ -352,21 +728,21 @@ async function runJob(job, opts) {
352
728
  }
353
729
  async function onTurnEnded() {
354
730
  if (!job.goalSet) {
355
- finish(job, "done", client);
731
+ finalize("done");
356
732
  settle();
357
733
  return;
358
734
  }
359
735
  if (job.goal?.status && TERMINAL_GOAL_STATUSES.has(job.goal.status)) {
360
- finish(job, "done", client);
736
+ finalize("done");
361
737
  settle();
362
738
  return;
363
739
  }
364
740
  try {
365
741
  const res = await client.request("thread/goal/get", { threadId: job.threadId }, 15e3);
366
742
  if (res?.goal) {
367
- job.goal = extractGoal(res);
368
- if (job.goal.status && TERMINAL_GOAL_STATUSES.has(job.goal.status)) {
369
- finish(job, "done", client);
743
+ updateGoal(job, res);
744
+ if (job.goal?.status && TERMINAL_GOAL_STATUSES.has(job.goal.status)) {
745
+ finalize("done");
370
746
  settle();
371
747
  return;
372
748
  }
@@ -377,9 +753,9 @@ async function runJob(job, opts) {
377
753
  if (!activeTurn) armQuietTimer();
378
754
  }
379
755
  const overallTimer = setTimeout(() => {
380
- finish(job, "timeout", client, `job exceeded ${cfg.jobTimeoutMs}ms`);
756
+ finalize("timeout", `job exceeded ${cfg.jobTimeoutMs}ms`);
381
757
  settle();
382
- }, cfg.jobTimeoutMs);
758
+ }, timeoutMs);
383
759
  try {
384
760
  await client.initialize();
385
761
  const threadParams = {
@@ -399,6 +775,7 @@ async function runJob(job, opts) {
399
775
  job.threadId = threadRes?.thread?.id ?? threadRes?.id;
400
776
  }
401
777
  if (!job.threadId) throw new Error(`could not determine thread id from ${JSON.stringify(threadRes).slice(0, 300)}`);
778
+ store.save(job);
402
779
  log(job, "thread", `thread ${job.threadId} ${opts.resumeThreadId ? "resumed" : "started"}`);
403
780
  if (opts.objective) {
404
781
  try {
@@ -408,24 +785,29 @@ async function runJob(job, opts) {
408
785
  ...opts.tokenBudget ? { tokenBudget: opts.tokenBudget } : {}
409
786
  });
410
787
  job.goalSet = true;
411
- job.goal = extractGoal(goalRes);
412
- log(job, "goal", `goal set (status=${job.goal.status ?? "active"})`);
413
- progress("goal_updated", `goal status=${job.goal.status ?? "active"}`);
788
+ updateGoal(job, goalRes);
789
+ log(job, "goal", `goal set (status=${job.goal?.status ?? "active"})`);
790
+ progress("goal_updated", `goal status=${job.goal?.status ?? "active"}`);
414
791
  } catch (err) {
792
+ if (client.exited) throw err;
415
793
  log(job, "goal", `thread/goal/set failed (${err?.message ?? err}); continuing without goal loop`);
416
794
  job.goalSet = false;
417
795
  }
418
796
  }
419
- job.state = "running";
420
797
  await client.request("turn/start", {
421
798
  threadId: job.threadId,
422
799
  input: [{ type: "text", text: opts.prompt }],
423
800
  ...opts.reasoningEffort ? { effort: opts.reasoningEffort } : {}
424
801
  });
425
802
  await doneSignal;
803
+ return processError ? { error: processError, processFailure: true } : { processFailure: false };
804
+ } catch (error) {
805
+ const failure = processError ?? (error instanceof Error ? error : new Error(String(error)));
806
+ return { error: failure, processFailure: client.exited };
426
807
  } finally {
427
808
  clearTimeout(overallTimer);
428
809
  clearTimeout(quietTimer);
810
+ clearStallTimer();
429
811
  client.kill();
430
812
  }
431
813
  }
@@ -463,7 +845,10 @@ function config(cwd) {
463
845
  approvalPolicy: process.env.CODEX_APPROVAL_POLICY ?? "never",
464
846
  permissions: process.env.CODEX_PERMISSIONS || void 0,
465
847
  jobTimeoutMs: envInt("CODEX_JOB_TIMEOUT_MS", 18e5),
466
- quietMs: envInt("CODEX_QUIET_MS", 3e4)
848
+ quietMs: envInt("CODEX_QUIET_MS", 3e4),
849
+ stallWarnMs: Math.max(0, envInt("C2C_STALL_WARN_MS", 12e4)),
850
+ retries: Math.max(0, envInt("C2C_RETRIES", 1)),
851
+ maxConcurrent: Math.max(1, envInt("C2C_MAX_CONCURRENT", 2))
467
852
  };
468
853
  }
469
854
  var OBJECTIVE_MAX = envInt("GOAL_OBJECTIVE_MAX", 2e3);
@@ -474,10 +859,17 @@ function jobSummary(job, transcriptTail = 15) {
474
859
  state: job.state,
475
860
  thread_id: job.threadId ?? null,
476
861
  goal: job.goal ?? null,
862
+ usage: job.usage ?? {},
863
+ attempts: job.attempts ?? 1,
477
864
  turns: job.turns,
478
865
  started_at: job.startedAt,
479
866
  ended_at: job.endedAt ?? null,
480
867
  error: job.error ?? null,
868
+ ...!["done", "error", "timeout"].includes(job.state) ? { last_activity_at: job.lastActivityAt ?? null } : {},
869
+ ...job.state === "running" && job.lastActivityAt ? { seconds_since_activity: Math.max(0, (Date.now() - Date.parse(job.lastActivityAt)) / 1e3) } : {},
870
+ ...job.contextFiles ? { context_files: job.contextFiles } : {},
871
+ ...job.dependsOn ? { depends_on: job.dependsOn } : {},
872
+ ...job.state === "queued" ? { queue_position: getQueuePosition(job.id) ?? null } : {},
481
873
  transcript_tail: job.transcript.slice(-transcriptTail)
482
874
  };
483
875
  }
@@ -487,8 +879,53 @@ function textResult(obj, isError = false) {
487
879
  isError
488
880
  };
489
881
  }
490
- var server = new McpServer({ name: "c2c-codex", version: "0.1.0" });
882
+ var server = new McpServer({ name: "c2c-codex", version: "0.3.0" });
491
883
  var reasoningEffort = z.enum(["low", "medium", "high", "xhigh", "max", "ultra"]);
884
+ var contextFiles = z.array(z.object({
885
+ path: z.string().min(1),
886
+ note: z.string().optional()
887
+ }));
888
+ async function resolveContextFiles(files, cwd) {
889
+ if (!files?.length) return { files: void 0, missing: [] };
890
+ const resolved = files.map((file) => ({ ...file, path: resolve(cwd, file.path) }));
891
+ const exists = await Promise.all(resolved.map(async (file) => {
892
+ try {
893
+ await stat(file.path);
894
+ return true;
895
+ } catch {
896
+ return false;
897
+ }
898
+ }));
899
+ return {
900
+ files: resolved,
901
+ missing: resolved.filter((_, index) => !exists[index]).map((file) => file.path)
902
+ };
903
+ }
904
+ function missingContextFiles(paths) {
905
+ return textResult({ error: `context_files not found:
906
+ ${paths.map((path) => `- ${path}`).join("\n")}` }, true);
907
+ }
908
+ function implementHistory() {
909
+ const totals = listJobs().filter((job) => job.kind === "implement" && job.state === "done").map((job) => job.usage?.totalTokens).filter((total) => typeof total === "number" && Number.isFinite(total)).sort((a, b) => a - b);
910
+ if (!totals.length) return null;
911
+ const middle = Math.floor(totals.length / 2);
912
+ const median = totals.length % 2 ? totals[middle] : (totals[middle - 1] + totals[middle]) / 2;
913
+ return {
914
+ samples: totals.length,
915
+ median_total_tokens: Math.round(median),
916
+ p90_total_tokens: Math.round(totals[Math.ceil(totals.length * 0.9) - 1]),
917
+ mean_total_tokens: Math.round(totals.reduce((sum, total) => sum + total, 0) / totals.length)
918
+ };
919
+ }
920
+ function dependencyValidationError(dependsOn) {
921
+ if (!dependsOn) return void 0;
922
+ const dependency = getJob(dependsOn);
923
+ if (!dependency) return `unknown depends_on job_id ${dependsOn}`;
924
+ if (dependency.state === "error" || dependency.state === "timeout") {
925
+ return `dependency ${dependency.id} failed: ${dependency.error ?? `state ${dependency.state}`}`;
926
+ }
927
+ return void 0;
928
+ }
492
929
  function progressReporter() {
493
930
  let sequence = 0;
494
931
  return (event) => {
@@ -514,21 +951,31 @@ server.registerTool(
514
951
  success_conditions: z.array(z.string()).min(1).describe("Checkable criteria proving the goal is met. At least one must be a runnable command/test."),
515
952
  token_budget: z.number().int().positive().optional().describe("Optional token budget for the Codex goal loop (thread/goal/set tokenBudget)."),
516
953
  cwd: z.string().optional().describe("Working directory for Codex. Defaults to the project directory."),
954
+ context_files: contextFiles.optional().describe("Files or directories Codex should read before implementing."),
955
+ depends_on: z.string().min(1).optional().describe("Job id of a prior job that must succeed before this job starts."),
517
956
  reasoning_effort: reasoningEffort.optional().describe("Optional Codex reasoning effort; omitted uses the Codex default.")
518
957
  }
519
958
  },
520
959
  async (input) => {
960
+ const jobConfig = config(input.cwd);
961
+ const resolvedContext = await resolveContextFiles(input.context_files, jobConfig.cwd);
962
+ if (resolvedContext.missing.length) return missingContextFiles(resolvedContext.missing);
521
963
  const contract = {
522
964
  goal: input.goal,
523
965
  constraints: input.constraints,
524
- success_conditions: input.success_conditions
966
+ success_conditions: input.success_conditions,
967
+ context_files: resolvedContext.files
525
968
  };
526
- const jobConfig = config(input.cwd);
969
+ const prompt = await renderGoalContract(contract, jobConfig.cwd);
970
+ const dependencyError = dependencyValidationError(input.depends_on);
971
+ if (dependencyError) return textResult({ error: dependencyError }, true);
527
972
  const job = startJob({
528
973
  kind: "implement",
529
- prompt: await renderGoalContract(contract, jobConfig.cwd),
974
+ prompt,
530
975
  objective: renderObjective(contract, OBJECTIVE_MAX),
531
976
  tokenBudget: input.token_budget,
977
+ contextFiles: resolvedContext.files?.map((file) => file.path),
978
+ dependsOn: input.depends_on,
532
979
  config: jobConfig,
533
980
  reasoningEffort: input.reasoning_effort,
534
981
  onProgress: progressReporter()
@@ -536,7 +983,41 @@ server.registerTool(
536
983
  return textResult({
537
984
  job_id: job.id,
538
985
  state: job.state,
539
- note: "Job started. Poll codex_status until state is 'done', then call codex_result."
986
+ note: job.state === "blocked" ? `Job blocked on dependency ${job.dependsOn}. Poll codex_status for its state.` : job.state === "queued" ? "Job queued. Poll codex_status for its queue position and state." : "Job started. Poll codex_status until state is 'done', then call codex_result."
987
+ });
988
+ }
989
+ );
990
+ server.registerTool(
991
+ "codex_estimate",
992
+ {
993
+ title: "Estimate a Goal Contract's token cost",
994
+ description: "Read-only estimate made before starting a job. Measures the fully rendered Goal Contract prompt and returns approx_prompt_tokens using an approximate characters/4 heuristic, plus local completed-job history.",
995
+ inputSchema: {
996
+ goal: z.string().min(1).describe("What the code must do when this task is done."),
997
+ constraints: z.array(z.string()).describe("Technical boundaries: files to touch, patterns to follow, 'Do not modify' lists."),
998
+ success_conditions: z.array(z.string()).min(1).describe("Checkable criteria proving the goal is met. At least one must be a runnable command/test."),
999
+ cwd: z.string().optional().describe("Working directory used to render the prompt. Defaults to the project directory."),
1000
+ context_files: contextFiles.optional().describe("Files or directories included in the rendered prompt.")
1001
+ },
1002
+ annotations: { readOnlyHint: true }
1003
+ },
1004
+ async (input) => {
1005
+ const jobConfig = config(input.cwd);
1006
+ const resolvedContext = await resolveContextFiles(input.context_files, jobConfig.cwd);
1007
+ if (resolvedContext.missing.length) return missingContextFiles(resolvedContext.missing);
1008
+ const prompt = await renderGoalContract({
1009
+ goal: input.goal,
1010
+ constraints: input.constraints,
1011
+ success_conditions: input.success_conditions,
1012
+ context_files: resolvedContext.files
1013
+ }, jobConfig.cwd);
1014
+ const history = implementHistory();
1015
+ return textResult({
1016
+ prompt_chars: prompt.length,
1017
+ approx_prompt_tokens: Math.ceil(prompt.length / 4),
1018
+ history,
1019
+ estimated_total_tokens: history?.median_total_tokens ?? null,
1020
+ note: history ? "Estimated total tokens are based on local history and vary with task complexity." : "No completed implement job history is available; estimates based on local history vary with task complexity."
540
1021
  });
541
1022
  }
542
1023
  );
@@ -552,10 +1033,14 @@ server.registerTool(
552
1033
  failed_conditions: z.array(z.string()).describe("The specific Success Conditions from the original contract that did not pass."),
553
1034
  constraints: z.array(z.string()).optional().describe("Additional constraints beyond the originals."),
554
1035
  cwd: z.string().optional().describe("Working directory for Codex. Defaults to the project directory."),
1036
+ context_files: contextFiles.optional().describe("Files or directories Codex should read before implementing."),
555
1037
  reasoning_effort: reasoningEffort.optional().describe("Optional Codex reasoning effort; omitted uses the Codex default.")
556
1038
  }
557
1039
  },
558
1040
  async (input) => {
1041
+ const jobConfig = config(input.cwd);
1042
+ const resolvedContext = await resolveContextFiles(input.context_files, jobConfig.cwd);
1043
+ if (resolvedContext.missing.length) return missingContextFiles(resolvedContext.missing);
559
1044
  let threadId = input.thread_id;
560
1045
  if (!threadId && input.job_id) {
561
1046
  const prev = getJob(input.job_id);
@@ -570,10 +1055,12 @@ server.registerTool(
570
1055
  prompt: renderDeltaContract({
571
1056
  findings: input.findings,
572
1057
  failed_conditions: input.failed_conditions,
573
- constraints: input.constraints
1058
+ constraints: input.constraints,
1059
+ context_files: resolvedContext.files
574
1060
  }),
575
1061
  resumeThreadId: threadId,
576
- config: config(input.cwd),
1062
+ contextFiles: resolvedContext.files?.map((file) => file.path),
1063
+ config: jobConfig,
577
1064
  reasoningEffort: input.reasoning_effort,
578
1065
  onProgress: progressReporter()
579
1066
  });
@@ -581,7 +1068,7 @@ server.registerTool(
581
1068
  job_id: job.id,
582
1069
  resumed_thread_id: threadId,
583
1070
  state: job.state,
584
- note: "Rework job started. Poll codex_status until state is 'done', then call codex_result."
1071
+ note: job.state === "queued" ? "Rework job queued. Poll codex_status for its queue position and state." : "Rework job started. Poll codex_status until state is 'done', then call codex_result."
585
1072
  });
586
1073
  }
587
1074
  );
@@ -601,7 +1088,7 @@ server.registerTool(
601
1088
  "codex_status",
602
1089
  {
603
1090
  title: "Check a Codex job",
604
- description: "Status of a running or finished Codex job: state, thread id, goal state (status/tokens), turn count, and a tail of the activity transcript.",
1091
+ description: "Status of a blocked, queued, running, or finished Codex job: dependency, state, queue position, thread id, goal state (status/tokens), turn count, and a tail of the activity transcript.",
605
1092
  inputSchema: {
606
1093
  job_id: z.string().describe("Job id returned by codex_implement or codex_rework.")
607
1094
  }
@@ -616,7 +1103,7 @@ server.registerTool(
616
1103
  "codex_result",
617
1104
  {
618
1105
  title: "Fetch a finished Codex job's handoff",
619
- description: "Final result of a finished job: Codex's final message, the parsed structured handoff (Changed Files / Validation / Success Conditions / Risks & Deviations \u2014 a missing section is itself a review failure), and final goal state. Errors if the job is still running.",
1106
+ description: "Final result of a finished job: Codex's final message, the parsed structured handoff (Changed Files / Validation / Success Conditions / Risks & Deviations \u2014 a missing section is itself a review failure), and final goal state. Errors if the job is not finished.",
620
1107
  inputSchema: {
621
1108
  job_id: z.string().describe("Job id returned by codex_implement or codex_rework.")
622
1109
  }
@@ -624,7 +1111,7 @@ server.registerTool(
624
1111
  async (input) => {
625
1112
  const job = getJob(input.job_id);
626
1113
  if (!job) return textResult({ error: `unknown job_id ${input.job_id}` }, true);
627
- if (job.state === "starting" || job.state === "running") {
1114
+ if (job.state === "blocked" || job.state === "queued" || job.state === "starting" || job.state === "running") {
628
1115
  return textResult({ error: `job ${job.id} is still ${job.state}; poll codex_status` }, true);
629
1116
  }
630
1117
  return textResult({