claude2codex 0.1.0 → 0.3.1

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