@jhlee0619/codexloop 0.1.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.
@@ -0,0 +1,849 @@
1
+ #!/usr/bin/env node
2
+ // CodexLoop companion script.
3
+ //
4
+ // Single entry point dispatched by all slash commands (and, for background
5
+ // loops, re-entered as the detached worker via the internal `loop-worker`
6
+ // verb). Verbs:
7
+ //
8
+ // start — initialize a new loop and run it (foreground or background)
9
+ // iterate — run exactly one iteration synchronously
10
+ // status — show current state (read-only)
11
+ // stop — signal a running worker (SIGTERM, or SIGKILL with --force)
12
+ // result — dump full iteration history + final diff hints
13
+ // loop-worker — internal: the detached worker invoked by /cloop:start --background
14
+ //
15
+ // All verbs honor `-C, --cwd <dir>` to override the target repository root.
16
+ //
17
+ // This file mirrors the structure of codex-plugin-cc's scripts/codex-companion.mjs
18
+ // verb dispatch pattern but owns all code directly — no cross-plugin imports.
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import process from "node:process";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import {
26
+ normalizeArgv,
27
+ parseArgs,
28
+ parseDuration,
29
+ parseInteger
30
+ } from "./lib/args.mjs";
31
+ import {
32
+ gitHeadSha,
33
+ requireGitRepository
34
+ } from "./lib/workspace.mjs";
35
+ import {
36
+ acquireLock,
37
+ computeGoalHash,
38
+ DEFAULT_MODEL,
39
+ DEFAULT_REASONING_EFFORT,
40
+ defaultState,
41
+ ensureGitignore,
42
+ ensureLoopDir,
43
+ generateLoopId,
44
+ getLoopPaths,
45
+ loadState,
46
+ LoopLockError,
47
+ MODEL_ALIASES,
48
+ normalizeModelName,
49
+ normalizeReasoningEffort,
50
+ readIterationFile,
51
+ readLock,
52
+ readPidFile,
53
+ releaseLock,
54
+ removePidFile,
55
+ saveState,
56
+ VALID_REASONING_EFFORTS,
57
+ writePidFile
58
+ } from "./lib/state.mjs";
59
+ import { runIteration } from "./lib/iteration.mjs";
60
+ import { checkCodexAvailable } from "./lib/codex-exec.mjs";
61
+ import { checkStopping } from "./lib/convergence.mjs";
62
+ import {
63
+ isProcessAlive,
64
+ runCommand,
65
+ sleep,
66
+ spawnDetached,
67
+ terminateProcess
68
+ } from "./lib/process.mjs";
69
+ import {
70
+ renderIterationReport,
71
+ renderResultReport,
72
+ renderStatusReport
73
+ } from "./lib/render.mjs";
74
+
75
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
76
+
77
+ const USAGE = [
78
+ "Usage:",
79
+ " cloop start [--wait|--background] [--goal <text>] [--task-file <path>]",
80
+ " [--max-iter N] [--max-time <dur>] [--max-calls N]",
81
+ " [--dry-run] [--model <m>] [--effort <e>] [--nproposals N]",
82
+ " [--test-cmd <cmd>] [--lint-cmd <cmd>] [--type-cmd <cmd>]",
83
+ " [-C <dir>]",
84
+ " cloop iterate [--dry-run] [--skip-apply] [--model <m>] [--effort <e>] [-C <dir>]",
85
+ " cloop status [--json] [-C <dir>]",
86
+ " cloop stop [--force] [-C <dir>]",
87
+ " cloop result [--iteration N] [--json] [--diff] [-C <dir>]",
88
+ " cloop model [<model>] [--effort <e>] [--list] [--clear] [--json] [-C <dir>]",
89
+ "",
90
+ " (internal) cloop loop-worker --loop-id <id> [-C <dir>]",
91
+ "",
92
+ `Defaults: model=${DEFAULT_MODEL}, reasoning effort=${DEFAULT_REASONING_EFFORT}.`,
93
+ `Valid reasoning efforts: ${VALID_REASONING_EFFORTS.join(", ")}.`,
94
+ "",
95
+ "Inside Claude Code you can also invoke these as /cloop:start, /cloop:iterate,",
96
+ "/cloop:status, /cloop:stop, /cloop:result, /cloop:model."
97
+ ].join("\n");
98
+
99
+ const VALUE_OPTIONS = [
100
+ "goal", "task-file", "max-iter", "max-time", "max-calls",
101
+ "model", "effort", "nproposals", "test-cmd", "lint-cmd", "type-cmd",
102
+ "cwd", "loop-id", "iteration"
103
+ ];
104
+ const BOOLEAN_OPTIONS = [
105
+ "wait", "background", "dry-run", "skip-apply",
106
+ "force", "json", "diff", "list", "clear",
107
+ // `yes` is a UX hint the slash command body uses to skip the approval
108
+ // dialog. The runtime itself ignores it — we just list it here so
109
+ // parseArgs accepts it without turning it into a positional.
110
+ "yes"
111
+ ];
112
+ const ALIAS_MAP = { C: "cwd" };
113
+
114
+ const VERBS = {
115
+ start: handleStart,
116
+ iterate: handleIterate,
117
+ status: handleStatus,
118
+ stop: handleStop,
119
+ result: handleResult,
120
+ model: handleModel,
121
+ "loop-worker": handleWorker
122
+ };
123
+
124
+ function parseCommonArgs(argv) {
125
+ return parseArgs(argv, {
126
+ valueOptions: VALUE_OPTIONS,
127
+ booleanOptions: BOOLEAN_OPTIONS,
128
+ aliasMap: ALIAS_MAP
129
+ });
130
+ }
131
+
132
+ function resolveCwd(options) {
133
+ return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd();
134
+ }
135
+
136
+ function nowIso() {
137
+ return new Date().toISOString();
138
+ }
139
+
140
+ function printError(msg) {
141
+ process.stderr.write(`${msg}\n`);
142
+ }
143
+
144
+ async function main() {
145
+ const [verb, ...rawArgv] = process.argv.slice(2);
146
+
147
+ if (!verb || verb === "--help" || verb === "-h") {
148
+ process.stdout.write(`${USAGE}\n`);
149
+ return verb ? 0 : 1;
150
+ }
151
+
152
+ const handler = VERBS[verb];
153
+ if (!handler) {
154
+ printError(`Unknown verb: ${verb}`);
155
+ printError(USAGE);
156
+ return 1;
157
+ }
158
+
159
+ try {
160
+ const argv = normalizeArgv(rawArgv);
161
+ const code = await handler(argv);
162
+ return typeof code === "number" ? code : 0;
163
+ } catch (err) {
164
+ if (err instanceof LoopLockError) {
165
+ printError(err.message);
166
+ return 2;
167
+ }
168
+ printError(`Error: ${err.message ?? err}`);
169
+ if (process.env.CODEXLOOP_DEBUG && err.stack) {
170
+ printError(err.stack);
171
+ }
172
+ return 1;
173
+ }
174
+ }
175
+
176
+ main().then(
177
+ (code) => process.exit(code),
178
+ (err) => {
179
+ printError(`Fatal: ${err.message ?? err}`);
180
+ if (err.stack) printError(err.stack);
181
+ process.exit(1);
182
+ }
183
+ );
184
+
185
+ // ── start ──────────────────────────────────────────────────────────
186
+ async function handleStart(argv) {
187
+ const { options, positionals } = parseCommonArgs(argv);
188
+ const repoRoot = requireGitRepository(resolveCwd(options));
189
+
190
+ const availability = checkCodexAvailable({ cwd: repoRoot });
191
+ if (!availability.available) {
192
+ printError(`Codex CLI is not ready: ${availability.detail}`);
193
+ return 1;
194
+ }
195
+
196
+ const existingLock = readLock(repoRoot);
197
+ if (existingLock && isProcessAlive(existingLock.pid)) {
198
+ printError(
199
+ `A CodexLoop is already running (pid=${existingLock.pid}, loopId=${existingLock.loopId}). ` +
200
+ "Use /cloop:stop or /cloop:status before starting a new loop."
201
+ );
202
+ return 2;
203
+ }
204
+ if (existingLock) {
205
+ releaseLock(repoRoot);
206
+ }
207
+
208
+ const status = runCommand("git", ["status", "--porcelain"], { cwd: repoRoot });
209
+ if ((status.stdout || "").trim()) {
210
+ printError("Working tree is not clean. Commit or stash your changes before /cloop:start.");
211
+ printError(
212
+ status.stdout.trim().split("\n").slice(0, 10).join("\n")
213
+ );
214
+ return 1;
215
+ }
216
+
217
+ // Goal sources, in priority order:
218
+ // 1. --goal "<text>" (explicit flag)
219
+ // 2. --task-file <path> (read goal from a file)
220
+ // 3. positional tokens (e.g. `cloop start "fix the bug"` or `cloop start fix the bug`)
221
+ // 4. error — runtime requires an explicit goal. (Claude Code's slash
222
+ // command body runs an interview BEFORE hitting the runtime, so
223
+ // users interacting via /cloop:start never see this error; it only
224
+ // fires when somebody calls the CLI directly without a goal.)
225
+ let goalText = options.goal ? String(options.goal).trim() : "";
226
+ if (!goalText && options["task-file"]) {
227
+ const taskPath = path.resolve(repoRoot, options["task-file"]);
228
+ if (!fs.existsSync(taskPath)) {
229
+ printError(`Task file not found: ${taskPath}`);
230
+ return 1;
231
+ }
232
+ goalText = fs.readFileSync(taskPath, "utf8").trim();
233
+ }
234
+ if (!goalText && Array.isArray(positionals) && positionals.length > 0) {
235
+ // Join any free-form positional tokens. Works for both
236
+ // `cloop start "fix the bug"` (shell handed us 1 quoted token which
237
+ // splitRawArgumentString re-tokenizes on whitespace) and the
238
+ // multi-token `cloop start fix the bug` form. Either way, joining
239
+ // with space reproduces the original text.
240
+ goalText = positionals.join(" ").trim();
241
+ }
242
+ if (!goalText) {
243
+ printError(
244
+ 'A goal is required. Pass --goal "<text>", --task-file <path>, or a free-form goal as the first positional argument (e.g. `cloop start "fix the failing test"`).'
245
+ );
246
+ return 1;
247
+ }
248
+
249
+ const state = defaultState();
250
+ state.loopId = generateLoopId();
251
+ state.mode = options["dry-run"] ? "dry-run" : options.background ? "background" : "interactive";
252
+ // model + reasoningEffort default to DEFAULT_MODEL / DEFAULT_REASONING_EFFORT
253
+ // via defaultState(); user-supplied --model / --effort override them here.
254
+ if (options.model !== undefined) {
255
+ state.model = normalizeModelName(options.model);
256
+ }
257
+ if (options.effort !== undefined) {
258
+ try {
259
+ state.reasoningEffort = normalizeReasoningEffort(options.effort);
260
+ } catch (err) {
261
+ printError(err.message);
262
+ return 1;
263
+ }
264
+ }
265
+ state.startedAt = nowIso();
266
+ state.goal.text = goalText;
267
+ state.goal.seedCommit = gitHeadSha(repoRoot);
268
+ state.goal.testCmd = options["test-cmd"] ?? null;
269
+ state.goal.lintCmd = options["lint-cmd"] ?? null;
270
+ state.goal.typeCmd = options["type-cmd"] ?? null;
271
+ state.goal.goalHash = computeGoalHash(state.goal);
272
+
273
+ if (options["max-iter"]) {
274
+ state.budget.maxIterations = parseInteger(options["max-iter"], {
275
+ name: "--max-iter",
276
+ min: 1,
277
+ max: 1000
278
+ });
279
+ }
280
+ if (options["max-time"]) {
281
+ state.budget.maxElapsedMs = parseDuration(options["max-time"]);
282
+ }
283
+ if (options["max-calls"]) {
284
+ state.budget.maxCodexCalls = parseInteger(options["max-calls"], {
285
+ name: "--max-calls",
286
+ min: 1
287
+ });
288
+ }
289
+
290
+ state.budget.consumed.startedAtMs = Date.now();
291
+ state.status = "queued";
292
+ state.pid = null;
293
+
294
+ ensureLoopDir(repoRoot);
295
+ const gi = ensureGitignore(repoRoot);
296
+ if (gi.added) {
297
+ process.stdout.write(`Added .loop/ to ${gi.path}\n`);
298
+ }
299
+ saveState(repoRoot, state);
300
+
301
+ const foreground = options.wait || !options.background;
302
+
303
+ if (!foreground) {
304
+ const loopPaths = getLoopPaths(repoRoot);
305
+ const pid = spawnDetached(
306
+ SCRIPT_PATH,
307
+ ["loop-worker", "--loop-id", state.loopId, "-C", repoRoot],
308
+ { cwd: repoRoot, logFile: loopPaths.log }
309
+ );
310
+ if (!pid) {
311
+ printError("Failed to spawn background worker.");
312
+ return 1;
313
+ }
314
+ process.stdout.write(
315
+ [
316
+ "CodexLoop started in background.",
317
+ ` loopId: ${state.loopId}`,
318
+ ` pid: ${pid}`,
319
+ ` log: ${loopPaths.log}`,
320
+ "Check status with /cloop:status.",
321
+ ""
322
+ ].join("\n")
323
+ );
324
+ return 0;
325
+ }
326
+
327
+ // Foreground mode: we ARE the worker.
328
+ try {
329
+ acquireLock(repoRoot, { pid: process.pid, loopId: state.loopId });
330
+ } catch (err) {
331
+ if (err instanceof LoopLockError) {
332
+ printError(err.message);
333
+ return 2;
334
+ }
335
+ throw err;
336
+ }
337
+ writePidFile(repoRoot, process.pid);
338
+
339
+ try {
340
+ const s2 = loadState(repoRoot);
341
+ s2.status = "running";
342
+ s2.pid = process.pid;
343
+ saveState(repoRoot, s2);
344
+
345
+ const runOpts = buildRunOptions(options);
346
+ await runLoop({ repoRoot, runOpts });
347
+
348
+ const final = loadState(repoRoot);
349
+ process.stdout.write(`${renderStatusReport(final)}\n`);
350
+ if (final.status === "completed") return 0;
351
+ if (final.status === "paused") return 0;
352
+ return 1;
353
+ } finally {
354
+ removePidFile(repoRoot);
355
+ releaseLock(repoRoot);
356
+ }
357
+ }
358
+
359
+ function buildRunOptions(options) {
360
+ const runOpts = {
361
+ dryRun: !!options["dry-run"],
362
+ skipApply: !!options["skip-apply"]
363
+ };
364
+ // Only set model / reasoningEffort when the user explicitly passed them,
365
+ // so the runtime falls back to the stored state.model / state.reasoningEffort.
366
+ if (options.model !== undefined) {
367
+ runOpts.model = normalizeModelName(options.model);
368
+ }
369
+ if (options.effort !== undefined) {
370
+ runOpts.reasoningEffort = normalizeReasoningEffort(options.effort);
371
+ }
372
+ if (options.nproposals) {
373
+ runOpts.nProposals = parseInteger(options.nproposals, {
374
+ name: "--nproposals",
375
+ min: 2,
376
+ max: 5
377
+ });
378
+ }
379
+ return runOpts;
380
+ }
381
+
382
+ // ── main run loop (shared by foreground start + background worker) ─
383
+ async function runLoop({ repoRoot, runOpts }) {
384
+ let stopRequested = false;
385
+ const markStop = (signal) => {
386
+ stopRequested = true;
387
+ process.stdout.write(`\nCodexLoop: received ${signal}; stopping at next iteration boundary...\n`);
388
+ };
389
+ const onTerm = () => markStop("SIGTERM");
390
+ const onInt = () => markStop("SIGINT");
391
+ process.on("SIGTERM", onTerm);
392
+ process.on("SIGINT", onInt);
393
+
394
+ try {
395
+ while (!stopRequested) {
396
+ const state = loadState(repoRoot);
397
+ if (["completed", "paused", "failed", "cancelled"].includes(state.status)) {
398
+ break;
399
+ }
400
+
401
+ const stop = checkStopping(state);
402
+ if (stop.shouldStop) {
403
+ const finalState = loadState(repoRoot);
404
+ finalState.status = stop.reason === "goal-met" ? "completed" : "paused";
405
+ finalState.stopReason = stop.reason;
406
+ finalState.completedAt = nowIso();
407
+ saveState(repoRoot, finalState);
408
+ break;
409
+ }
410
+
411
+ try {
412
+ const { iteration } = await runIteration({ repoRoot, options: runOpts });
413
+ process.stdout.write(`${renderIterationReport(iteration)}\n\n`);
414
+ if (iteration.stopReason) {
415
+ break;
416
+ }
417
+ } catch (err) {
418
+ printError(`Iteration failed: ${err.message ?? err}`);
419
+ const s = loadState(repoRoot);
420
+ s.status = "failed";
421
+ s.error = {
422
+ kind: err.kind ?? err.name ?? "Error",
423
+ message: err.message ?? String(err)
424
+ };
425
+ s.completedAt = nowIso();
426
+ saveState(repoRoot, s);
427
+ return;
428
+ }
429
+ }
430
+
431
+ if (stopRequested) {
432
+ const s = loadState(repoRoot);
433
+ if (s.status === "running") {
434
+ s.status = "paused";
435
+ s.stopReason = s.stopReason ?? "user-stop";
436
+ s.completedAt = nowIso();
437
+ saveState(repoRoot, s);
438
+ }
439
+ }
440
+ } finally {
441
+ process.off("SIGTERM", onTerm);
442
+ process.off("SIGINT", onInt);
443
+ }
444
+ }
445
+
446
+ // ── iterate (foreground single-shot) ───────────────────────────────
447
+ async function handleIterate(argv) {
448
+ const { options } = parseCommonArgs(argv);
449
+ const repoRoot = requireGitRepository(resolveCwd(options));
450
+
451
+ const state = loadState(repoRoot);
452
+ if (!state.loopId) {
453
+ printError("No active loop. Run /cloop:start first.");
454
+ return 1;
455
+ }
456
+ if (["completed", "failed", "cancelled"].includes(state.status)) {
457
+ printError(
458
+ `Loop is in terminal state: ${state.status}. Start a new loop with /cloop:start.`
459
+ );
460
+ return 1;
461
+ }
462
+
463
+ const lock = readLock(repoRoot);
464
+ if (lock && lock.pid !== process.pid && isProcessAlive(lock.pid)) {
465
+ printError(
466
+ `Another process (pid=${lock.pid}) is running the loop. Use /cloop:stop first to yield.`
467
+ );
468
+ return 2;
469
+ }
470
+
471
+ try {
472
+ acquireLock(repoRoot, { pid: process.pid, loopId: state.loopId });
473
+ } catch (err) {
474
+ if (err instanceof LoopLockError) {
475
+ printError(err.message);
476
+ return 2;
477
+ }
478
+ throw err;
479
+ }
480
+ writePidFile(repoRoot, process.pid);
481
+
482
+ try {
483
+ const runOpts = buildRunOptions(options);
484
+ const { iteration } = await runIteration({ repoRoot, options: runOpts });
485
+ process.stdout.write(`${renderIterationReport(iteration)}\n`);
486
+ return 0;
487
+ } catch (err) {
488
+ printError(`Iteration failed: ${err.message ?? err}`);
489
+ return 1;
490
+ } finally {
491
+ removePidFile(repoRoot);
492
+ releaseLock(repoRoot);
493
+ }
494
+ }
495
+
496
+ // ── status (read-only) ─────────────────────────────────────────────
497
+ async function handleStatus(argv) {
498
+ const { options } = parseCommonArgs(argv);
499
+ const repoRoot = requireGitRepository(resolveCwd(options));
500
+ let state = loadState(repoRoot);
501
+
502
+ // Detect crashed worker: status says running but pid is dead.
503
+ if (state.status === "running" && state.pid && !isProcessAlive(state.pid)) {
504
+ state.status = "crashed";
505
+ state.error = state.error ?? { kind: "crash", message: `worker pid ${state.pid} is not alive` };
506
+ saveState(repoRoot, state);
507
+ releaseLock(repoRoot);
508
+ removePidFile(repoRoot);
509
+ }
510
+
511
+ if (options.json) {
512
+ process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
513
+ return 0;
514
+ }
515
+ process.stdout.write(`${renderStatusReport(state)}\n`);
516
+ return 0;
517
+ }
518
+
519
+ // ── stop (signal running worker) ───────────────────────────────────
520
+ async function handleStop(argv) {
521
+ const { options } = parseCommonArgs(argv);
522
+ const repoRoot = requireGitRepository(resolveCwd(options));
523
+
524
+ const state = loadState(repoRoot);
525
+ const pid = state.pid ?? readPidFile(repoRoot);
526
+ if (!pid) {
527
+ process.stdout.write("No running CodexLoop found.\n");
528
+ return 0;
529
+ }
530
+
531
+ if (!isProcessAlive(pid)) {
532
+ const s = loadState(repoRoot);
533
+ if (s.status === "running") {
534
+ s.status = "crashed";
535
+ s.error = s.error ?? { kind: "crash", message: `stale pid ${pid}` };
536
+ saveState(repoRoot, s);
537
+ }
538
+ removePidFile(repoRoot);
539
+ releaseLock(repoRoot);
540
+ process.stdout.write(`No live worker (stale pid ${pid}). Cleaned up.\n`);
541
+ return 0;
542
+ }
543
+
544
+ if (options.force) {
545
+ terminateProcess(pid, "SIGKILL");
546
+ const s = loadState(repoRoot);
547
+ s.status = "cancelled";
548
+ s.stopReason = "user-stop-force";
549
+ s.completedAt = nowIso();
550
+ saveState(repoRoot, s);
551
+ removePidFile(repoRoot);
552
+ releaseLock(repoRoot);
553
+ process.stdout.write(`Sent SIGKILL to pid ${pid}. Loop marked cancelled.\n`);
554
+ return 0;
555
+ }
556
+
557
+ terminateProcess(pid, "SIGTERM");
558
+ process.stdout.write(`Sent SIGTERM to pid ${pid}. Waiting up to 60 s for graceful shutdown...\n`);
559
+
560
+ const deadline = Date.now() + 60_000;
561
+ while (Date.now() < deadline) {
562
+ if (!isProcessAlive(pid)) break;
563
+ await sleep(500);
564
+ }
565
+
566
+ if (isProcessAlive(pid)) {
567
+ printError(
568
+ `Worker pid ${pid} did not exit within 60 s. Re-run '/cloop:stop --force' to SIGKILL.`
569
+ );
570
+ return 1;
571
+ }
572
+
573
+ const finalState = loadState(repoRoot);
574
+ if (finalState.status === "running") {
575
+ finalState.status = "paused";
576
+ finalState.stopReason = finalState.stopReason ?? "user-stop";
577
+ finalState.completedAt = nowIso();
578
+ saveState(repoRoot, finalState);
579
+ }
580
+ removePidFile(repoRoot);
581
+ releaseLock(repoRoot);
582
+ process.stdout.write(`Worker pid ${pid} stopped. Status: ${finalState.status}.\n`);
583
+ return 0;
584
+ }
585
+
586
+ // ── result (read-only) ─────────────────────────────────────────────
587
+ async function handleResult(argv) {
588
+ const { options } = parseCommonArgs(argv);
589
+ const repoRoot = requireGitRepository(resolveCwd(options));
590
+ const state = loadState(repoRoot);
591
+
592
+ const fullIterations = (state.iterations ?? []).map((summary) => {
593
+ const full = readIterationFile(repoRoot, summary.index, { dryRun: summary.dryRun });
594
+ return full ?? summary;
595
+ });
596
+
597
+ const iterationIndex = options.iteration
598
+ ? parseInteger(options.iteration, { name: "--iteration", min: 1 })
599
+ : null;
600
+
601
+ if (options.json) {
602
+ process.stdout.write(
603
+ `${JSON.stringify({ state, iterations: fullIterations }, null, 2)}\n`
604
+ );
605
+ return 0;
606
+ }
607
+
608
+ process.stdout.write(
609
+ `${renderResultReport(state, fullIterations, {
610
+ iterationIndex,
611
+ withDiff: !!options.diff
612
+ })}\n`
613
+ );
614
+ return 0;
615
+ }
616
+
617
+ // ── model (show / set / clear / list) ─────────────────────────────
618
+ //
619
+ // Also manages reasoning effort — pass --effort <e> alongside a model name
620
+ // to set both at once, or use --effort alone to change just the effort.
621
+ async function handleModel(argv) {
622
+ const { options, positionals } = parseCommonArgs(argv);
623
+ const repoRoot = requireGitRepository(resolveCwd(options));
624
+
625
+ // --list is repo-independent — show supported aliases + valid efforts and exit.
626
+ if (options.list) {
627
+ const aliasLines = Object.entries(MODEL_ALIASES)
628
+ .map(([alias, full]) => `- \`${alias}\` \u2192 \`${full}\``)
629
+ .join("\n");
630
+ const effortLine = VALID_REASONING_EFFORTS.map((e) => `\`${e}\``).join(", ");
631
+ const body = [
632
+ "# CodexLoop model & reasoning aliases",
633
+ "",
634
+ `**Defaults:** model = \`${DEFAULT_MODEL}\`, reasoning effort = \`${DEFAULT_REASONING_EFFORT}\``,
635
+ "",
636
+ "## Model aliases",
637
+ "",
638
+ aliasLines || "- (none)",
639
+ "",
640
+ "Pass any model name to `cloop model <name>` (aliases are expanded before saving).",
641
+ "The actual set of usable models depends on your Codex subscription;",
642
+ "`cloop` does not hard-code a list beyond these aliases.",
643
+ "",
644
+ "## Valid reasoning efforts",
645
+ "",
646
+ effortLine
647
+ ].join("\n");
648
+ if (options.json) {
649
+ process.stdout.write(
650
+ `${JSON.stringify(
651
+ {
652
+ defaults: { model: DEFAULT_MODEL, reasoningEffort: DEFAULT_REASONING_EFFORT },
653
+ aliases: MODEL_ALIASES,
654
+ validReasoningEfforts: VALID_REASONING_EFFORTS
655
+ },
656
+ null,
657
+ 2
658
+ )}\n`
659
+ );
660
+ } else {
661
+ process.stdout.write(`${body}\n`);
662
+ }
663
+ return 0;
664
+ }
665
+
666
+ const state = loadState(repoRoot);
667
+ if (!state.loopId) {
668
+ printError(
669
+ "No active loop. Run /cloop:start first (pass --model / --effort there), or use `cloop model --list` to see defaults."
670
+ );
671
+ return 1;
672
+ }
673
+
674
+ const hasNewModel = positionals.length > 0;
675
+ const hasNewEffort = options.effort !== undefined;
676
+ const isClear = !!options.clear;
677
+ const isShow = !hasNewModel && !hasNewEffort && !isClear;
678
+
679
+ // Parse inputs up-front so we fail before touching state if --effort is bogus.
680
+ let normalizedModel = null;
681
+ let normalizedEffort = null;
682
+ if (hasNewModel) {
683
+ if (positionals.length > 1) {
684
+ printError(
685
+ `cloop model: expected at most one model name, got ${positionals.length}: ${positionals.join(" ")}`
686
+ );
687
+ return 1;
688
+ }
689
+ normalizedModel = normalizeModelName(positionals[0]);
690
+ if (!normalizedModel) {
691
+ printError("cloop model: model name is empty.");
692
+ return 1;
693
+ }
694
+ }
695
+ if (hasNewEffort) {
696
+ try {
697
+ normalizedEffort = normalizeReasoningEffort(options.effort);
698
+ } catch (err) {
699
+ printError(err.message);
700
+ return 1;
701
+ }
702
+ }
703
+
704
+ // Show current config.
705
+ if (isShow) {
706
+ const body = [
707
+ "# CodexLoop model",
708
+ "",
709
+ "| field | value |",
710
+ "|----------|-------|",
711
+ `| loopId | \`${state.loopId}\` |`,
712
+ `| status | ${state.status} |`,
713
+ `| model | ${state.model ? `\`${state.model}\`` : "(codex default)"} |`,
714
+ `| effort | ${state.reasoningEffort ? `\`${state.reasoningEffort}\`` : "(codex default)"} |`,
715
+ "",
716
+ `**Defaults** (used when state is cleared): model=\`${DEFAULT_MODEL}\`, effort=\`${DEFAULT_REASONING_EFFORT}\``,
717
+ "",
718
+ "Run `/cloop:model <name>` to change the model.",
719
+ "Run `/cloop:model --effort <level>` to change reasoning effort.",
720
+ "Run `/cloop:model <name> --effort <level>` to change both.",
721
+ "Run `/cloop:model --clear` to reset both to the codex CLI default.",
722
+ "Run `/cloop:model --list` to see known aliases and valid effort levels."
723
+ ].join("\n");
724
+
725
+ if (options.json) {
726
+ process.stdout.write(
727
+ `${JSON.stringify(
728
+ {
729
+ loopId: state.loopId,
730
+ status: state.status,
731
+ model: state.model,
732
+ reasoningEffort: state.reasoningEffort,
733
+ defaults: { model: DEFAULT_MODEL, reasoningEffort: DEFAULT_REASONING_EFFORT }
734
+ },
735
+ null,
736
+ 2
737
+ )}\n`
738
+ );
739
+ } else {
740
+ process.stdout.write(`${body}\n`);
741
+ }
742
+ return 0;
743
+ }
744
+
745
+ if (["completed", "failed", "cancelled"].includes(state.status)) {
746
+ printError(
747
+ `Loop is in terminal state: ${state.status}. Start a new loop with /cloop:start --model <m> --effort <e>.`
748
+ );
749
+ return 1;
750
+ }
751
+
752
+ const s = loadState(repoRoot);
753
+ const previousModel = s.model ?? "(codex default)";
754
+ const previousEffort = s.reasoningEffort ?? "(codex default)";
755
+
756
+ if (isClear) {
757
+ s.model = null;
758
+ s.reasoningEffort = null;
759
+ } else {
760
+ if (hasNewModel) s.model = normalizedModel;
761
+ if (hasNewEffort) s.reasoningEffort = normalizedEffort;
762
+ }
763
+ saveState(repoRoot, s);
764
+
765
+ const aliasUsed =
766
+ hasNewModel &&
767
+ String(positionals[0]).trim().toLowerCase() !== normalizedModel &&
768
+ Object.prototype.hasOwnProperty.call(
769
+ MODEL_ALIASES,
770
+ String(positionals[0]).trim().toLowerCase()
771
+ );
772
+
773
+ if (options.json) {
774
+ process.stdout.write(
775
+ `${JSON.stringify(
776
+ {
777
+ loopId: s.loopId,
778
+ previous: { model: previousModel, reasoningEffort: previousEffort },
779
+ current: { model: s.model, reasoningEffort: s.reasoningEffort },
780
+ aliasExpanded: aliasUsed ? positionals[0] : null,
781
+ cleared: isClear
782
+ },
783
+ null,
784
+ 2
785
+ )}\n`
786
+ );
787
+ } else {
788
+ const headerLine = isClear
789
+ ? "CodexLoop model + effort cleared."
790
+ : "CodexLoop model/effort updated.";
791
+ const lines = [
792
+ headerLine,
793
+ ` previous model: ${previousModel}`,
794
+ ` current model: ${s.model ?? "(codex default)"}`,
795
+ ` previous effort: ${previousEffort}`,
796
+ ` current effort: ${s.reasoningEffort ?? "(codex default)"}`,
797
+ ` loopId: ${s.loopId}`
798
+ ];
799
+ if (aliasUsed) {
800
+ lines.push(` (expanded alias '${positionals[0]}' -> '${normalizedModel}')`);
801
+ }
802
+ lines.push("", "The next iteration will use these settings.");
803
+ process.stdout.write(lines.join("\n") + "\n");
804
+ }
805
+ return 0;
806
+ }
807
+
808
+ // ── loop-worker (internal) ─────────────────────────────────────────
809
+ async function handleWorker(argv) {
810
+ const { options } = parseCommonArgs(argv);
811
+ const repoRoot = resolveCwd(options);
812
+ const loopId = options["loop-id"] ?? null;
813
+
814
+ try {
815
+ acquireLock(repoRoot, { pid: process.pid, loopId });
816
+ } catch (err) {
817
+ const s = loadState(repoRoot);
818
+ s.status = "failed";
819
+ s.error = {
820
+ kind: err instanceof LoopLockError ? "lock" : err.name ?? "Error",
821
+ message: err.message ?? String(err)
822
+ };
823
+ s.completedAt = nowIso();
824
+ saveState(repoRoot, s);
825
+ printError(`Worker failed to acquire lock: ${err.message}`);
826
+ return 2;
827
+ }
828
+ writePidFile(repoRoot, process.pid);
829
+
830
+ try {
831
+ const s = loadState(repoRoot);
832
+ s.status = "running";
833
+ s.pid = process.pid;
834
+ saveState(repoRoot, s);
835
+
836
+ await runLoop({ repoRoot, runOpts: {} });
837
+ } catch (err) {
838
+ const sf = loadState(repoRoot);
839
+ sf.status = "failed";
840
+ sf.error = { kind: err.kind ?? err.name ?? "Error", message: err.message };
841
+ sf.completedAt = nowIso();
842
+ saveState(repoRoot, sf);
843
+ printError(`Worker failed: ${err.message}`);
844
+ } finally {
845
+ removePidFile(repoRoot);
846
+ releaseLock(repoRoot);
847
+ }
848
+ return 0;
849
+ }