unfold-deploy 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -2
  4. package/bin/unfold.js +14 -0
  5. package/demos/claude-success.json +72 -0
  6. package/demos/codex-checks-failing.json +62 -0
  7. package/demos/gcloud-deploy-failure.json +616 -0
  8. package/demos/gcloud-success.json +608 -0
  9. package/demos/vercel-build-failure.json +331 -0
  10. package/demos/vercel-interrupted.json +271 -0
  11. package/demos/vercel-success.json +343 -0
  12. package/dist/cli.js +121 -0
  13. package/dist/commands/demo.js +75 -0
  14. package/dist/commands/observe.js +137 -0
  15. package/dist/commands/replay.js +76 -0
  16. package/dist/commands/run.js +24 -0
  17. package/dist/commands/show.js +44 -0
  18. package/dist/core/errors.js +3 -0
  19. package/dist/core/fix-prompt.js +85 -0
  20. package/dist/core/flow.js +211 -0
  21. package/dist/core/launch.js +196 -0
  22. package/dist/core/model.js +3 -0
  23. package/dist/core/redact.js +79 -0
  24. package/dist/core/server.js +109 -0
  25. package/dist/core/session.js +136 -0
  26. package/dist/core/store.js +52 -0
  27. package/dist/core/terminal.js +109 -0
  28. package/dist/providers/agents/claude.js +205 -0
  29. package/dist/providers/agents/codex.js +152 -0
  30. package/dist/providers/agents/shared.js +119 -0
  31. package/dist/providers/gcloud/index.js +39 -0
  32. package/dist/providers/gcloud/parser.js +149 -0
  33. package/dist/providers/gcloud/stages.js +55 -0
  34. package/dist/providers/index.js +5 -0
  35. package/dist/providers/provider.js +1 -0
  36. package/dist/providers/vercel/index.js +56 -0
  37. package/dist/providers/vercel/parser.js +239 -0
  38. package/dist/providers/vercel/stages.js +54 -0
  39. package/dist/tui/ansi.js +71 -0
  40. package/dist/tui/explore.js +220 -0
  41. package/dist/tui/panel.js +118 -0
  42. package/dist/tui/render.js +140 -0
  43. package/dist/tui/restore.js +29 -0
  44. package/dist/ui/app.js +332 -0
  45. package/dist/ui/index.html +60 -0
  46. package/dist/ui/styles.css +220 -0
  47. package/package.json +64 -4
  48. package/scripts/postinstall.mjs +13 -0
@@ -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
+ };
@@ -0,0 +1,119 @@
1
+ export function agentStages(agent, docsUrl, permissions) {
2
+ return [
3
+ {
4
+ id: 'start',
5
+ title: 'Start',
6
+ summary: `${agent} starts a session with its model, settings and the tools it may use.`,
7
+ explanation: [
8
+ `Unfold runs ${agent} in its non-interactive mode, which reports every step as a structured event instead of drawing a full-screen interface.`,
9
+ `In this mode ${agent} cannot stop to ask you for permission, so it can only do what your settings and flags allow. ${permissions}`,
10
+ ],
11
+ confirmedBy: 'The session start event.',
12
+ docsUrl,
13
+ },
14
+ {
15
+ id: 'explore',
16
+ title: 'Explore',
17
+ summary: 'It reads files and searches the code to understand the task.',
18
+ explanation: [
19
+ 'Agents usually start by looking around: listing folders, reading files, searching for symbols and running read-only commands such as `git status`.',
20
+ ],
21
+ confirmedBy: 'A successful read, search or read-only command.',
22
+ docsUrl,
23
+ },
24
+ {
25
+ id: 'change',
26
+ title: 'Change',
27
+ summary: 'It edits or creates files to carry out the task.',
28
+ explanation: [
29
+ 'Edits are written straight to your working tree. Review them with `git diff` before committing.',
30
+ 'If it is not allowed to edit, its attempts are denied and the task cannot be completed.',
31
+ ],
32
+ confirmedBy: 'A successful file edit or write.',
33
+ docsUrl,
34
+ },
35
+ {
36
+ id: 'verify',
37
+ title: 'Verify',
38
+ summary: 'It runs checks such as tests, a type check, a linter or a build.',
39
+ explanation: [
40
+ 'Unfold counts a command as a check when it looks like one, for example `npm test`, `pytest`, `tsc`, `eslint`, `cargo build` or `make`.',
41
+ 'If the agent never runs a check, this stays unverified: the change may still be right, but nothing in the run showed it working.',
42
+ ],
43
+ confirmedBy: 'A check command that exits successfully.',
44
+ docsUrl,
45
+ },
46
+ {
47
+ id: 'done',
48
+ title: 'Done',
49
+ summary: 'It reports its final answer and the session ends.',
50
+ explanation: ['The final event says whether the session succeeded and, depending on the agent, how long it took, how many turns it used and what it cost.'],
51
+ confirmedBy: 'The final result event, with no error.',
52
+ docsUrl,
53
+ },
54
+ ];
55
+ }
56
+ export const AGENT_NOTES = {
57
+ success: 'Done',
58
+ successHeadline: 'The agent finished',
59
+ docsLabel: 'Docs',
60
+ highlights: ['answer', 'filesChanged', 'denied', 'cost', 'tokens'],
61
+ interrupted: 'You pressed Ctrl+C while this was in progress. Changes the agent already wrote stay on disk; review them with `git diff`.',
62
+ unverified: "This run's output never showed this step.",
63
+ };
64
+ const VERIFY = /(^|[\s;&|(])((npm|pnpm|yarn|bun)( run)? (test|build|lint|typecheck|check)\b|npx (tsc|vitest|jest|eslint|playwright)|\b(tsc|vitest|jest|mocha|pytest|tox|ruff|mypy|eslint|phpunit|rspec|playwright)\b|go (test|build|vet)|cargo (test|build|check|clippy)|\bmake\b|gradle|mvn|dotnet (test|build)|swift (test|build))/;
65
+ const CHANGE = /(^|[\s;&|(])(sed -i|perl -pi|tee |mv |rm |cp |mkdir |touch |chmod |apply_patch|git (commit|apply|checkout|restore|reset|stash|add)|(npm|pnpm|yarn|bun) (i|install|add|remove|uninstall)\b|pip install)|[^2&]>{1,2}\s*(?!\/dev\/null)[\w./]/;
66
+ /** Rough classification of a shell command by what it does. */
67
+ export function classifyCommand(cmd) {
68
+ if (VERIFY.test(cmd))
69
+ return 'verify';
70
+ if (CHANGE.test(cmd))
71
+ return 'change';
72
+ return 'explore';
73
+ }
74
+ /** `/bin/zsh -lc "npm test"` → `npm test` (Codex wraps commands in a login shell). */
75
+ export function unwrapShell(cmd) {
76
+ const m = /^\S*\/(?:ba|z)?sh -l?c (['"])([\s\S]*)\1$/.exec(cmd.trim()) ?? /^\S*\/(?:ba|z)?sh -l?c (\S+)$/.exec(cmd.trim());
77
+ if (!m)
78
+ return cmd;
79
+ const inner = m.length === 3 ? m[2] : m[1];
80
+ return m[1] === "'" ? inner.replace(/'\\''/g, "'").replace(/'"'"'/g, "'") : inner.replace(/\\"/g, '"');
81
+ }
82
+ /** Shows paths relative to the session's folder when possible. */
83
+ export function relPath(path, cwd) {
84
+ if (cwd && path.startsWith(cwd.replace(/\/$/, '') + '/'))
85
+ return path.slice(cwd.replace(/\/$/, '').length + 1);
86
+ return path;
87
+ }
88
+ export const oneLine = (s, max = 160) => {
89
+ const t = s.replace(/\s+/g, ' ').trim();
90
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
91
+ };
92
+ /** Tracks checks across a run so the end of the run can say how they went. */
93
+ export class CheckTracker {
94
+ last;
95
+ changed = new Set();
96
+ record(cmd, ok) {
97
+ this.last = { cmd, ok };
98
+ }
99
+ finish() {
100
+ const out = [];
101
+ if (this.changed.size) {
102
+ const files = [...this.changed];
103
+ out.push({ kind: 'fact', key: 'filesChanged', label: 'Files changed', value: `${files.slice(0, 8).join(', ')}${files.length > 8 ? ` and ${files.length - 8} more` : ''}`, stage: 'change' });
104
+ }
105
+ if (this.last && !this.last.ok) {
106
+ out.push({ kind: 'stage', stage: 'verify', status: 'unverified', note: `Checks ran, but the last one failed: ${oneLine(this.last.cmd, 80)}` });
107
+ }
108
+ return out;
109
+ }
110
+ }
111
+ /** Hints for the fix prompt when an agent run fails. */
112
+ export function agentFixHints(state, allowHint) {
113
+ const hints = [];
114
+ const denied = state.facts.find((f) => f.key === 'denied');
115
+ if (denied)
116
+ hints.push(`The agent was denied permission for: ${denied.value}. ${allowHint}`);
117
+ hints.push('Review what the agent already changed with `git diff` before running it again.');
118
+ return hints;
119
+ }
@@ -0,0 +1,39 @@
1
+ import { GcloudParser } from './parser.js';
2
+ import { GCLOUD_NOTES, GCLOUD_STAGES } from './stages.js';
3
+ export const gcloud = {
4
+ id: 'gcloud',
5
+ title: 'Cloud Run deploy',
6
+ stages: GCLOUD_STAGES,
7
+ notes: GCLOUD_NOTES,
8
+ install: 'Install the Google Cloud CLI (https://cloud.google.com/sdk/docs/install), then run `gcloud auth login`.',
9
+ launch(userArgs) {
10
+ const args = [...userArgs];
11
+ // `unfold gcloud …` means `gcloud run deploy …`; accept the full form too.
12
+ if (args[0] === 'run' && args[1] === 'deploy')
13
+ args.splice(0, 2);
14
+ return { bin: process.env.UNFOLD_GCLOUD_BIN || 'gcloud', args: ['run', 'deploy', ...args] };
15
+ },
16
+ createParser: () => new GcloudParser(),
17
+ fixHints(state) {
18
+ const fact = (k) => state.facts.find((f) => f.key === k)?.value;
19
+ switch (state.failedStage) {
20
+ case 'prepare':
21
+ return [
22
+ 'This failed before anything was built. Account, project, billing and disabled-API problems are fixed with gcloud commands, not code: `gcloud auth login`, `gcloud config set project <id>`, and `gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com`. If that is the cause, say so and stop.',
23
+ ];
24
+ case 'build':
25
+ return [
26
+ `The container was built by Cloud Build, not locally.${fact('logsUrl') ? ` The full build log is at ${fact('logsUrl')}.` : ''}`,
27
+ `It was built with ${fact('buildType') ?? 'a Dockerfile or buildpacks'}. Reproduce it locally with \`docker build .\` (Dockerfile) or \`pack build\` (buildpacks) before and after the fix.`,
28
+ 'For buildpacks, check that the project declares how to start (for example a `start` script in package.json, or a Procfile).',
29
+ ];
30
+ case 'deploy':
31
+ return [
32
+ 'The image built, but the new revision did not become healthy. The most common cause: the container must listen on 0.0.0.0 at the port in the PORT environment variable (8080 by default).',
33
+ `Check start-up errors with \`gcloud run services logs read ${fact('service') ?? '<service>'}${fact('region') ? ` --region ${fact('region')}` : ''}\`. Crashes on start, missing environment variables and memory limits show up there.`,
34
+ ];
35
+ default:
36
+ return [];
37
+ }
38
+ },
39
+ };