unfold-deploy 0.1.0 → 0.2.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.
@@ -50,10 +50,25 @@ export async function observe(opts) {
50
50
  session.subscribe((_e, state) => panel.update(state));
51
51
  panel.start();
52
52
  }
53
+ const show = (chunk) => {
54
+ process.stdout.write(chunk);
55
+ panel?.afterChildOutput(chunk);
56
+ };
57
+ // Structured CLIs (JSON events) are shown as readable lines, not raw output.
58
+ const formatted = !!opts.provider.createPresenter;
59
+ if (formatted) {
60
+ let shown = 0;
61
+ session.subscribe((e) => {
62
+ if (e.type !== 'log' || e.line.seq <= shown)
63
+ return;
64
+ shown = e.line.seq;
65
+ show(e.line.text.replace(/\r?\n/g, '\r\n') + '\r\n');
66
+ });
67
+ }
53
68
  const term = {
54
69
  write(chunk) {
55
- process.stdout.write(chunk);
56
- panel?.afterChildOutput(chunk);
70
+ if (!formatted)
71
+ show(chunk);
57
72
  },
58
73
  rows: () => panel?.childRows ?? (process.stdout.rows || 30),
59
74
  onRowsChange: (fn) => rowsListeners.add(fn),
@@ -28,9 +28,24 @@ export async function runReplay(file, flags, speed) {
28
28
  save: false,
29
29
  waitForBrowser: true,
30
30
  run: async (session, term) => {
31
- // Re-run the original lines through today's parser so parser fixes apply to old runs.
32
31
  const t0 = started.at;
33
32
  const start = Date.now();
33
+ const wait = async (at) => {
34
+ const ms = (at - t0) / speed - (Date.now() - start);
35
+ if (ms > 0)
36
+ await new Promise((r) => setTimeout(r, Math.min(ms, 2000)));
37
+ };
38
+ if (provider.createPresenter) {
39
+ // Structured providers: raw events aren't saved, so replay the recorded events.
40
+ for (const e of events) {
41
+ if (e.type === 'log')
42
+ await wait(e.line.at);
43
+ session.inject(e);
44
+ }
45
+ session.endReplay(ended?.exitCode ?? null, ended?.signal ?? null, ended?.interrupted ?? false);
46
+ return { exitCode: ended?.exitCode ?? null, signal: ended?.signal ?? null, interrupted: ended?.interrupted ?? false };
47
+ }
48
+ // Re-run the original lines through today's parser so parser fixes apply to old runs.
34
49
  for (const e of events) {
35
50
  if (e.type !== 'log')
36
51
  continue;
@@ -6,7 +6,7 @@ export async function runLive(provider, userArgs, flags) {
6
6
  const spec = provider.launch(userArgs);
7
7
  const bin = which(spec.bin);
8
8
  if (!bin) {
9
- say(`Could not find "${spec.bin}" on your PATH. Install it first (for Vercel: npm i -g vercel), then run vercel login.`);
9
+ say(`Could not find "${spec.bin}" on your PATH.${provider.install ? ` ${provider.install}` : ''}`);
10
10
  return 127;
11
11
  }
12
12
  for (const msg of provider.preflight?.(cwd) ?? [])
@@ -0,0 +1,3 @@
1
+ /** A mistake in how Unfold was invoked: printed without a stack trace. */
2
+ export class UsageError extends Error {
3
+ }
package/dist/core/flow.js CHANGED
@@ -30,6 +30,7 @@ export class FlowReducer {
30
30
  exitCode: null,
31
31
  exitSignal: null,
32
32
  stages: event.stages.map((d) => ({ ...d, status: 'pending', evidence: [] })),
33
+ notes: event.notes,
33
34
  facts: [],
34
35
  errors: [],
35
36
  lineCount: 0,
@@ -94,6 +95,13 @@ export class FlowReducer {
94
95
  st.note = sig.note;
95
96
  }
96
97
  }
98
+ else if (sig.status === 'unverified') {
99
+ // A parser saying "this happened but didn't succeed" (e.g. checks ran and failed).
100
+ if (st.status === 'pending' || st.status === 'active') {
101
+ st.status = 'unverified';
102
+ st.note = sig.note;
103
+ }
104
+ }
97
105
  else if (st.status !== 'confirmed') {
98
106
  st.status = 'failed';
99
107
  st.endedAt = e.at;
@@ -126,9 +134,12 @@ export class FlowReducer {
126
134
  if (sig.severity === 'fatal') {
127
135
  const st = this.stage(s, sig.stage) ?? this.currentStage(s);
128
136
  if (st && st.status !== 'confirmed') {
137
+ // Keep a note the parser already gave the failure (e.g. "Creating Revision failed.");
138
+ // the full message is shown as the error.
139
+ if (st.status !== 'failed' || !st.note)
140
+ st.note = sig.message;
129
141
  st.status = 'failed';
130
142
  st.endedAt = e.at;
131
- st.note = sig.message;
132
143
  s.failedStage = st.id;
133
144
  info.stage = st.id;
134
145
  this.addEvidence(st, e.seq, e.at, 'Error reported here');
@@ -12,6 +12,7 @@ export class Session {
12
12
  reducer = new FlowReducer();
13
13
  runId;
14
14
  parser;
15
+ present;
15
16
  decoder;
16
17
  redactor;
17
18
  listeners = new Set();
@@ -23,9 +24,10 @@ export class Session {
23
24
  this.opts = opts;
24
25
  this.now = opts.now ?? Date.now;
25
26
  this.runId = new Date(this.now()).toISOString().replace(/[:.]/g, '-') + '-' + randomBytes(3).toString('hex');
26
- this.parser = opts.provider.createParser();
27
+ this.parser = opts.provider.createParser({ cwd: opts.cwd });
27
28
  this.redactor = opts.redactor ?? new Redactor();
28
- this.decoder = new TerminalDecoder((l) => this.onLine(l));
29
+ this.present = opts.provider.createPresenter?.({ cwd: opts.cwd });
30
+ this.decoder = new TerminalDecoder((l) => this.onLine(l), opts.provider.maxLineLength);
29
31
  }
30
32
  get state() {
31
33
  return this.reducer.state;
@@ -52,6 +54,21 @@ export class Session {
52
54
  notes: p.notes,
53
55
  });
54
56
  }
57
+ /**
58
+ * Replays an already-processed event (log, signal, fix) from a saved run. Used for
59
+ * providers whose raw output isn't saved, such as JSON event streams.
60
+ */
61
+ inject(event) {
62
+ if (event.type === 'log' || event.type === 'signal' || event.type === 'fix.ready')
63
+ this.emit(event);
64
+ }
65
+ /** Ends a replayed run without re-running the parser. */
66
+ endReplay(exitCode, signal, interrupted) {
67
+ if (this.ended)
68
+ return;
69
+ this.ended = true;
70
+ this.emit({ type: 'run.ended', at: this.now(), exitCode, signal, interrupted });
71
+ }
55
72
  /** Feed raw terminal output (may contain partial lines and escape codes). */
56
73
  write(chunk) {
57
74
  if (!this.ended)
@@ -62,6 +79,10 @@ export class Session {
62
79
  return;
63
80
  this.decoder.end();
64
81
  this.ended = true;
82
+ const at = this.now();
83
+ const seq = this.last?.line.seq ?? 0;
84
+ for (const signal of this.parser.finish?.() ?? [])
85
+ this.emit({ type: 'signal', at, seq, signal });
65
86
  this.emit({ type: 'run.ended', at: this.now(), exitCode, signal, interrupted });
66
87
  const prompt = buildFixPrompt(this.state, this.logs, this.opts.provider.fixHints?.(this.state) ?? []);
67
88
  if (prompt) {
@@ -76,9 +97,19 @@ export class Session {
76
97
  }
77
98
  }
78
99
  onLine({ text: rawText, transient }) {
79
- const text = this.redactor.line(rawText);
80
- const key = redrawKey(text);
100
+ const raw = this.redactor.line(rawText);
81
101
  const at = this.now();
102
+ const shown = this.present ? this.present(raw) : raw;
103
+ if (shown === null) {
104
+ // Hidden line (e.g. a structured event with nothing to show): its signals attach
105
+ // to the last visible line.
106
+ const seq = this.last?.line.seq ?? 0;
107
+ for (const signal of this.parser.feed({ seq, at, text: raw }))
108
+ this.emit({ type: 'signal', at, seq, signal });
109
+ return;
110
+ }
111
+ const text = shown;
112
+ const key = redrawKey(text);
82
113
  let line;
83
114
  // A redrawn spinner/progress line replaces the previous one instead of piling up.
84
115
  if (this.last?.transient && this.last.key === key) {
@@ -93,7 +124,7 @@ export class Session {
93
124
  }
94
125
  this.last = { line, transient, key };
95
126
  this.emit({ type: 'log', line });
96
- for (const signal of this.parser.feed(line))
127
+ for (const signal of this.parser.feed({ ...line, text: raw }))
97
128
  this.emit({ type: 'signal', at, seq: line.seq, signal });
98
129
  }
99
130
  emit(event) {
@@ -9,12 +9,14 @@ export function stripSpinner(text) {
9
9
  }
10
10
  export class TerminalDecoder {
11
11
  emit;
12
+ maxLine;
12
13
  buf = '';
13
14
  esc = 'none';
14
15
  csiParams = '';
15
16
  pendingCR = false;
16
- constructor(emit) {
17
+ constructor(emit, maxLine = MAX_LINE) {
17
18
  this.emit = emit;
19
+ this.maxLine = maxLine;
18
20
  }
19
21
  write(chunk) {
20
22
  for (const ch of chunk)
@@ -82,7 +84,7 @@ export class TerminalDecoder {
82
84
  this.buf += ' ';
83
85
  }
84
86
  else if (ch >= ' ' || ch > '\x7f') {
85
- if (this.buf.length < MAX_LINE)
87
+ if (this.buf.length < this.maxLine)
86
88
  this.buf += ch;
87
89
  }
88
90
  }
@@ -0,0 +1,205 @@
1
+ import { UsageError } from '../../core/errors.js';
2
+ import { agentFixHints, AGENT_NOTES, agentStages, CheckTracker, classifyCommand, oneLine, relPath } from './shared.js';
3
+ const parse = (text) => {
4
+ if (!text.startsWith('{'))
5
+ return undefined;
6
+ try {
7
+ return JSON.parse(text);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ };
13
+ const EXPLORE_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'NotebookRead', 'Task', 'Agent', 'ToolSearch']);
14
+ const CHANGE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
15
+ function activity(name, input) {
16
+ if (name === 'Bash')
17
+ return classifyCommand(String(input.command ?? ''));
18
+ if (CHANGE_TOOLS.has(name))
19
+ return 'change';
20
+ if (EXPLORE_TOOLS.has(name) || name.startsWith('mcp__'))
21
+ return 'explore';
22
+ return undefined; // TodoWrite and other bookkeeping
23
+ }
24
+ function describe(name, input, cwd) {
25
+ const path = typeof input.file_path === 'string' ? relPath(input.file_path, cwd) : typeof input.notebook_path === 'string' ? relPath(input.notebook_path, cwd) : '';
26
+ switch (name) {
27
+ case 'Bash':
28
+ return `$ ${oneLine(String(input.command ?? ''), 140)}`;
29
+ case 'Read':
30
+ return `Read ${path}`;
31
+ case 'Edit':
32
+ case 'MultiEdit':
33
+ return `Edit ${path}`;
34
+ case 'Write':
35
+ return `Write ${path}`;
36
+ case 'NotebookEdit':
37
+ return `Edit ${path}`;
38
+ case 'Glob':
39
+ return `Find files ${String(input.pattern ?? '')}`;
40
+ case 'Grep':
41
+ return `Search for ${oneLine(String(input.pattern ?? ''), 60)}`;
42
+ case 'WebFetch':
43
+ return `Fetch ${String(input.url ?? '')}`;
44
+ case 'WebSearch':
45
+ return `Web search: ${oneLine(String(input.query ?? ''), 60)}`;
46
+ case 'Task':
47
+ case 'Agent':
48
+ return `Subagent: ${oneLine(String(input.description ?? input.prompt ?? ''), 60)}`;
49
+ default:
50
+ return name.startsWith('mcp__') ? `Tool ${name.slice(5).replace('__', '.')}` : name;
51
+ }
52
+ }
53
+ const resultText = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.map((c) => (typeof c === 'object' && c && 'text' in c ? String(c.text) : '')).join(' ') : '';
54
+ export class ClaudeParser {
55
+ cwd;
56
+ pending = new Map();
57
+ checks = new CheckTracker();
58
+ feed(line) {
59
+ const e = parse(line.text.trim());
60
+ if (!e) {
61
+ // Plain text: Claude Code's own errors (bad flags, not logged in) land here.
62
+ const t = line.text.trim();
63
+ return /error|invalid|not logged in|unauthorized/i.test(t) ? [{ kind: 'error', severity: 'cause', stage: 'start', message: t }] : [];
64
+ }
65
+ const out = [];
66
+ if (e.type === 'system' && e.subtype === 'init') {
67
+ this.cwd = e.cwd;
68
+ if (e.claude_code_version)
69
+ out.push({ kind: 'fact', key: 'cliVersion', label: 'Claude Code', value: e.claude_code_version, stage: 'start' });
70
+ if (e.model)
71
+ out.push({ kind: 'fact', key: 'model', label: 'Model', value: e.model, stage: 'start' });
72
+ if (e.permissionMode)
73
+ out.push({ kind: 'fact', key: 'permissionMode', label: 'Permission mode', value: e.permissionMode, stage: 'start' });
74
+ out.push({ kind: 'stage', stage: 'start', status: 'confirmed', note: `Session started${e.model ? ` with ${e.model}` : ''}.` });
75
+ return out;
76
+ }
77
+ if (e.type === 'assistant') {
78
+ for (const b of e.message?.content ?? []) {
79
+ if (b.type !== 'tool_use' || !b.id || !b.name)
80
+ continue;
81
+ const input = b.input ?? {};
82
+ const kind = activity(b.name, input);
83
+ this.pending.set(b.id, { name: b.name, input, kind });
84
+ if (kind)
85
+ out.push({ kind: 'stage', stage: kind, status: 'active', note: describe(b.name, input, this.cwd) });
86
+ }
87
+ return out;
88
+ }
89
+ if (e.type === 'user') {
90
+ for (const b of e.message?.content ?? []) {
91
+ if (b.type !== 'tool_result' || !b.tool_use_id)
92
+ continue;
93
+ const call = this.pending.get(b.tool_use_id);
94
+ if (!call?.kind)
95
+ continue;
96
+ this.pending.delete(b.tool_use_id);
97
+ const what = describe(call.name, call.input, this.cwd);
98
+ if (call.kind === 'verify')
99
+ this.checks.record(String(call.input.command ?? call.name), !b.is_error);
100
+ if (b.is_error) {
101
+ const why = oneLine(resultText(b.content), 120);
102
+ out.push({ kind: 'evidence', stage: call.kind, note: `Failed: ${what}${why ? ` (${why})` : ''}` });
103
+ if (call.kind === 'verify')
104
+ out.push({ kind: 'error', severity: 'cause', stage: 'verify', message: `${what}: ${why}`, priority: 1 });
105
+ }
106
+ else {
107
+ if (call.kind === 'change' && typeof call.input.file_path === 'string')
108
+ this.checks.changed.add(relPath(call.input.file_path, this.cwd));
109
+ out.push({ kind: 'stage', stage: call.kind, status: 'confirmed', note: call.kind === 'verify' ? `Check passed: ${what.replace(/^\$ /, '')}` : what });
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ if (e.type === 'result') {
115
+ const secs = e.duration_ms !== undefined ? `${(e.duration_ms / 1000).toFixed(1)}s` : undefined;
116
+ if (e.total_cost_usd !== undefined)
117
+ out.push({ kind: 'fact', key: 'cost', label: 'Cost', value: `$${e.total_cost_usd.toFixed(2)}`, stage: 'done' });
118
+ if (e.num_turns !== undefined)
119
+ out.push({ kind: 'fact', key: 'turns', label: 'Turns', value: String(e.num_turns), stage: 'done' });
120
+ if (secs)
121
+ out.push({ kind: 'fact', key: 'duration', label: 'Duration', value: secs, stage: 'done' });
122
+ const denied = [...new Set((e.permission_denials ?? []).map((d) => d.tool_name).filter(Boolean))];
123
+ if (denied.length)
124
+ out.push({ kind: 'fact', key: 'denied', label: 'Permission denied', value: denied.join(', '), stage: 'done' });
125
+ if (e.subtype === 'success' && !e.is_error) {
126
+ if (e.result)
127
+ out.push({ kind: 'fact', key: 'answer', label: 'Answer', value: oneLine(e.result, 300), stage: 'done' });
128
+ out.push({ kind: 'stage', stage: 'done', status: 'confirmed', note: `Finished${secs ? ` in ${secs}` : ''}${e.num_turns ? ` after ${e.num_turns} turns` : ''}.` });
129
+ }
130
+ else {
131
+ const reason = e.subtype === 'error_max_turns' ? 'It hit the maximum number of turns.' : oneLine(e.result || String(e.api_error_status ?? '') || e.subtype || 'The session ended with an error.', 200);
132
+ out.push({ kind: 'error', severity: 'fatal', stage: 'done', message: reason });
133
+ }
134
+ if (denied.length)
135
+ out.push({ kind: 'error', severity: 'cause', stage: 'done', message: `Permission denied for: ${denied.join(', ')}`, priority: 2 });
136
+ }
137
+ return out;
138
+ }
139
+ finish() {
140
+ return this.checks.finish();
141
+ }
142
+ }
143
+ /** Turns stream-json events into readable terminal lines. */
144
+ export function claudePresenter() {
145
+ let cwd;
146
+ const calls = new Map();
147
+ return (raw) => {
148
+ const e = parse(raw.trim());
149
+ if (!e)
150
+ return raw.trim() ? raw : null;
151
+ if (e.type === 'system' && e.subtype === 'init') {
152
+ cwd = e.cwd;
153
+ return `● Claude Code ${e.claude_code_version ?? ''} · ${e.model ?? 'model'} · permission mode: ${e.permissionMode ?? 'default'}`;
154
+ }
155
+ if (e.type === 'assistant') {
156
+ const lines = [];
157
+ for (const b of e.message?.content ?? []) {
158
+ if (b.type === 'tool_use' && b.name) {
159
+ const d = describe(b.name, b.input ?? {}, cwd);
160
+ if (b.id)
161
+ calls.set(b.id, d);
162
+ if (b.name !== 'TodoWrite')
163
+ lines.push(` ${d.startsWith('$') ? d : `→ ${d}`}`);
164
+ }
165
+ else if (b.type === 'text' && b.text?.trim()) {
166
+ lines.push(`› ${oneLine(b.text, 300)}`);
167
+ }
168
+ }
169
+ return lines.length ? lines.join('\n') : null;
170
+ }
171
+ if (e.type === 'user') {
172
+ const failed = (e.message?.content ?? []).filter((b) => b.type === 'tool_result' && b.is_error);
173
+ return failed.length ? failed.map((b) => ` ✗ ${oneLine(resultText(b.content), 140)}`).join('\n') : null;
174
+ }
175
+ if (e.type === 'result') {
176
+ const ok = e.subtype === 'success' && !e.is_error;
177
+ const bits = [e.duration_ms !== undefined ? `${(e.duration_ms / 1000).toFixed(1)}s` : '', e.num_turns ? `${e.num_turns} turns` : '', e.total_cost_usd !== undefined ? `$${e.total_cost_usd.toFixed(2)}` : ''].filter(Boolean);
178
+ return `■ ${ok ? 'Done' : `Failed (${e.subtype})`}${bits.length ? ` · ${bits.join(' · ')}` : ''}`;
179
+ }
180
+ return null; // hooks, rate limits, partial messages
181
+ };
182
+ }
183
+ export const claude = {
184
+ id: 'claude',
185
+ title: 'Claude Code task',
186
+ stages: agentStages('Claude Code', 'https://docs.claude.com/en/docs/claude-code/sdk/sdk-headless', 'Allow tools with `--allowedTools` (for example `--allowedTools Read Edit Bash`) or `--permission-mode acceptEdits`.'),
187
+ notes: { ...AGENT_NOTES, docsLabel: 'Claude Code docs' },
188
+ install: 'Install Claude Code (https://docs.claude.com/en/docs/claude-code), then run `claude` once to log in.',
189
+ maxLineLength: 2_000_000,
190
+ launch(userArgs) {
191
+ const args = userArgs.filter((a) => a !== '-p' && a !== '--print');
192
+ if (!args.some((a) => !a.startsWith('-'))) {
193
+ throw new UsageError('give Claude Code a task, for example: unfold claude "fix the failing test" --allowedTools Read Edit Bash');
194
+ }
195
+ const out = ['-p', ...args];
196
+ if (!args.some((a) => a.startsWith('--output-format')))
197
+ out.push('--output-format', 'stream-json');
198
+ if (!args.includes('--verbose'))
199
+ out.push('--verbose');
200
+ return { bin: process.env.UNFOLD_CLAUDE_BIN || 'claude', args: out };
201
+ },
202
+ createParser: () => new ClaudeParser(),
203
+ createPresenter: claudePresenter,
204
+ fixHints: (state) => agentFixHints(state, 'Allow it with `--allowedTools <tool>` or `--permission-mode acceptEdits`, or run the task interactively.'),
205
+ };
@@ -0,0 +1,152 @@
1
+ import { UsageError } from '../../core/errors.js';
2
+ import { agentFixHints, AGENT_NOTES, agentStages, CheckTracker, classifyCommand, oneLine, relPath, unwrapShell } from './shared.js';
3
+ const parse = (text) => {
4
+ if (!text.startsWith('{'))
5
+ return undefined;
6
+ try {
7
+ return JSON.parse(text);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ };
13
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
14
+ function describeChanges(changes, cwd) {
15
+ return changes.map((c) => `${c.kind === 'add' ? 'add' : c.kind === 'delete' ? 'delete' : 'edit'} ${relPath(c.path, cwd)}`).join(', ');
16
+ }
17
+ export class CodexParser {
18
+ cwd;
19
+ checks = new CheckTracker();
20
+ constructor(cwd = process.cwd()) {
21
+ this.cwd = cwd;
22
+ }
23
+ feed(line) {
24
+ const e = parse(line.text.trim());
25
+ if (!e) {
26
+ const t = line.text.trim();
27
+ // Codex prints its own warnings and errors as plain text; MCP transport noise is not a task error.
28
+ return /^(error|fatal)\b|not logged in|unauthorized/i.test(t) && !/rmcp::|mcp/i.test(t) ? [{ kind: 'error', severity: 'cause', stage: 'start', message: t }] : [];
29
+ }
30
+ const it = e.item ?? {};
31
+ switch (e.type) {
32
+ case 'thread.started':
33
+ return [{ kind: 'stage', stage: 'start', status: 'confirmed', note: 'Session started.' }];
34
+ case 'item.started':
35
+ if (it.type === 'command_execution' && it.command) {
36
+ const cmd = unwrapShell(it.command);
37
+ return [{ kind: 'stage', stage: classifyCommand(cmd), status: 'active', note: `$ ${oneLine(cmd, 140)}` }];
38
+ }
39
+ if (it.type === 'file_change')
40
+ return [{ kind: 'stage', stage: 'change', status: 'active', note: `Editing ${describeChanges(it.changes ?? [], this.cwd)}` }];
41
+ return [];
42
+ case 'item.completed':
43
+ return this.completed(it);
44
+ case 'turn.completed': {
45
+ const u = e.usage ?? {};
46
+ const out = [];
47
+ if (u.input_tokens !== undefined)
48
+ out.push({ kind: 'fact', key: 'tokens', label: 'Tokens', value: `${k(u.input_tokens)} in (${k(u.cached_input_tokens ?? 0)} cached), ${k(u.output_tokens ?? 0)} out`, stage: 'done' });
49
+ out.push({ kind: 'stage', stage: 'done', status: 'confirmed', note: 'Finished the turn.' });
50
+ return out;
51
+ }
52
+ case 'turn.failed':
53
+ return [{ kind: 'error', severity: 'fatal', stage: 'done', message: e.error?.message ?? 'The turn failed.' }];
54
+ case 'error':
55
+ return [{ kind: 'error', severity: 'fatal', stage: 'done', message: e.message ?? e.error?.message ?? 'Codex reported an error.' }];
56
+ default:
57
+ return [];
58
+ }
59
+ }
60
+ completed(it) {
61
+ switch (it.type) {
62
+ case 'command_execution': {
63
+ const cmd = unwrapShell(it.command ?? '');
64
+ const kind = classifyCommand(cmd);
65
+ const ok = it.exit_code === 0 && it.status !== 'failed';
66
+ if (kind === 'verify')
67
+ this.checks.record(cmd, ok);
68
+ if (ok)
69
+ return [{ kind: 'stage', stage: kind, status: 'confirmed', note: kind === 'verify' ? `Check passed: ${oneLine(cmd, 100)}` : `$ ${oneLine(cmd, 140)}` }];
70
+ const out = [{ kind: 'evidence', stage: kind, note: `Failed (exit ${it.exit_code ?? '?'}): $ ${oneLine(cmd, 120)}` }];
71
+ if (kind === 'verify') {
72
+ const tail = oneLine((it.aggregated_output ?? '').trim().split('\n').slice(-3).join(' '), 160);
73
+ out.push({ kind: 'error', severity: 'cause', stage: 'verify', message: `${oneLine(cmd, 80)} exited with ${it.exit_code ?? '?'}${tail ? `: ${tail}` : ''}`, priority: 2 });
74
+ }
75
+ return out;
76
+ }
77
+ case 'file_change': {
78
+ const changes = it.changes ?? [];
79
+ if (it.status === 'failed')
80
+ return [{ kind: 'evidence', stage: 'change', note: `Failed to change ${describeChanges(changes, this.cwd)}` }];
81
+ for (const c of changes)
82
+ this.checks.changed.add(relPath(c.path, this.cwd));
83
+ return [{ kind: 'stage', stage: 'change', status: 'confirmed', note: `Changed: ${describeChanges(changes, this.cwd)}` }];
84
+ }
85
+ case 'mcp_tool_call':
86
+ return [{ kind: 'stage', stage: 'explore', status: it.status === 'failed' ? 'active' : 'confirmed', note: `Tool ${it.server ?? ''}.${it.tool ?? ''}` }];
87
+ case 'web_search':
88
+ return [{ kind: 'stage', stage: 'explore', status: 'confirmed', note: `Web search: ${oneLine(it.query ?? '', 60)}` }];
89
+ case 'agent_message':
90
+ return it.text ? [{ kind: 'fact', key: 'answer', label: 'Answer', value: oneLine(it.text, 300), stage: 'done' }] : [];
91
+ case 'error':
92
+ return [{ kind: 'error', severity: 'cause', stage: 'done', message: it.message ?? 'Codex reported an error.' }];
93
+ default:
94
+ return [];
95
+ }
96
+ }
97
+ finish() {
98
+ return this.checks.finish();
99
+ }
100
+ }
101
+ export function codexPresenter(cwd = process.cwd()) {
102
+ return (raw) => {
103
+ const e = parse(raw.trim());
104
+ if (!e)
105
+ return raw.trim() && !/rmcp::/.test(raw) ? raw : null;
106
+ const it = e.item ?? {};
107
+ if (e.type === 'thread.started')
108
+ return '● Codex session started';
109
+ if (e.type === 'item.started' && it.type === 'command_execution')
110
+ return ` $ ${oneLine(unwrapShell(it.command ?? ''), 140)}`;
111
+ if (e.type === 'item.completed') {
112
+ if (it.type === 'command_execution')
113
+ return it.exit_code === 0 ? null : ` ✗ exit ${it.exit_code ?? '?'}${it.aggregated_output?.trim() ? `: ${oneLine(it.aggregated_output.trim().split('\n').at(-1), 120)}` : ''}`;
114
+ if (it.type === 'file_change')
115
+ return ` ✎ ${describeChanges(it.changes ?? [], cwd)}${it.status === 'failed' ? ' (failed)' : ''}`;
116
+ if (it.type === 'agent_message' && it.text)
117
+ return `› ${oneLine(it.text, 300)}`;
118
+ if (it.type === 'mcp_tool_call')
119
+ return ` → Tool ${it.server ?? ''}.${it.tool ?? ''}`;
120
+ if (it.type === 'web_search')
121
+ return ` → Web search: ${oneLine(it.query ?? '', 60)}`;
122
+ if (it.type === 'error')
123
+ return ` ✗ ${it.message ?? 'error'}`;
124
+ return null;
125
+ }
126
+ if (e.type === 'turn.completed') {
127
+ const u = e.usage ?? {};
128
+ return `■ Done${u.input_tokens !== undefined ? ` · ${k(u.input_tokens)} tokens in, ${k(u.output_tokens ?? 0)} out` : ''}`;
129
+ }
130
+ if (e.type === 'turn.failed' || e.type === 'error')
131
+ return `■ Failed: ${e.error?.message ?? e.message ?? ''}`;
132
+ return null;
133
+ };
134
+ }
135
+ export const codex = {
136
+ id: 'codex',
137
+ title: 'Codex task',
138
+ stages: agentStages('Codex', 'https://developers.openai.com/codex/cli', 'Codex runs in a sandbox; use `--sandbox workspace-write` to let it edit files.'),
139
+ notes: { ...AGENT_NOTES, docsLabel: 'Codex docs' },
140
+ install: 'Install the Codex CLI (`npm i -g @openai/codex`), then run `codex login`.',
141
+ maxLineLength: 2_000_000,
142
+ launch(userArgs) {
143
+ const args = userArgs[0] === 'exec' ? userArgs.slice(1) : [...userArgs];
144
+ if (!args.some((a) => !a.startsWith('-'))) {
145
+ throw new UsageError('give Codex a task, for example: unfold codex "fix the failing test" --sandbox workspace-write');
146
+ }
147
+ return { bin: process.env.UNFOLD_CODEX_BIN || 'codex', args: ['exec', ...(args.includes('--json') ? [] : ['--json']), ...args] };
148
+ },
149
+ createParser: (ctx) => new CodexParser(ctx.cwd),
150
+ createPresenter: (ctx) => codexPresenter(ctx.cwd),
151
+ fixHints: (state) => agentFixHints(state, 'Give it more room with `--sandbox workspace-write`.'),
152
+ };