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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +150 -2
- package/bin/unfold.js +14 -0
- package/demos/claude-success.json +72 -0
- package/demos/codex-checks-failing.json +62 -0
- package/demos/gcloud-deploy-failure.json +616 -0
- package/demos/gcloud-success.json +608 -0
- package/demos/vercel-build-failure.json +331 -0
- package/demos/vercel-interrupted.json +271 -0
- package/demos/vercel-success.json +343 -0
- package/dist/cli.js +121 -0
- package/dist/commands/demo.js +75 -0
- package/dist/commands/observe.js +137 -0
- package/dist/commands/replay.js +76 -0
- package/dist/commands/run.js +24 -0
- package/dist/commands/show.js +44 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/fix-prompt.js +85 -0
- package/dist/core/flow.js +211 -0
- package/dist/core/launch.js +196 -0
- package/dist/core/model.js +3 -0
- package/dist/core/redact.js +79 -0
- package/dist/core/server.js +109 -0
- package/dist/core/session.js +136 -0
- package/dist/core/store.js +52 -0
- package/dist/core/terminal.js +109 -0
- package/dist/providers/agents/claude.js +205 -0
- package/dist/providers/agents/codex.js +152 -0
- package/dist/providers/agents/shared.js +119 -0
- package/dist/providers/gcloud/index.js +39 -0
- package/dist/providers/gcloud/parser.js +149 -0
- package/dist/providers/gcloud/stages.js +55 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/vercel/index.js +56 -0
- package/dist/providers/vercel/parser.js +239 -0
- package/dist/providers/vercel/stages.js +54 -0
- package/dist/tui/ansi.js +71 -0
- package/dist/tui/explore.js +220 -0
- package/dist/tui/panel.js +118 -0
- package/dist/tui/render.js +140 -0
- package/dist/tui/restore.js +29 -0
- package/dist/ui/app.js +332 -0
- package/dist/ui/index.html +60 -0
- package/dist/ui/styles.css +220 -0
- package/package.json +64 -4
- package/scripts/postinstall.mjs +13 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Redacts secrets from captured output before it is displayed or saved.
|
|
2
|
+
// Pattern based, plus exact values of secret-looking environment variables.
|
|
3
|
+
// Env values are only held in memory for matching; they are never stored.
|
|
4
|
+
const R = '[REDACTED]';
|
|
5
|
+
const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL|AUTH|COOKIE|SESSION|DSN|DATABASE_URL|CONNECTION_STRING|WEBHOOK|SIGNING)/i;
|
|
6
|
+
const TOKEN_PATTERNS = [
|
|
7
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, // GitHub
|
|
8
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
9
|
+
/\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
|
|
10
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key id
|
|
11
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g, // Slack
|
|
12
|
+
/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, // Stripe
|
|
13
|
+
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/g, // OpenAI / Anthropic style
|
|
14
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g, // Google API key
|
|
15
|
+
/\bnpm_[A-Za-z0-9]{36}\b/g, // npm
|
|
16
|
+
/\b(?:vcp|vck|vci|vca|vcr)_[A-Za-z0-9]{20,}\b/g, // Vercel-style prefixed tokens
|
|
17
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
|
|
18
|
+
];
|
|
19
|
+
const KEY_VALUE = new RegExp(String.raw `\b([A-Za-z0-9_.-]*` + SECRET_NAME.source.replace(/^\(/, '(?:') + String.raw `[A-Za-z0-9_.-]*)(\s*[=:]\s*)(["']?)([^\s"']{3,})\3`, 'gi');
|
|
20
|
+
export class Redactor {
|
|
21
|
+
envSecrets = [];
|
|
22
|
+
inPrivateKey = false;
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
for (const [name, value] of Object.entries(opts.env ?? {})) {
|
|
25
|
+
if (value && value.length >= 8 && SECRET_NAME.test(name))
|
|
26
|
+
this.envSecrets.push({ name, value });
|
|
27
|
+
}
|
|
28
|
+
// Longest first so overlapping values are fully replaced.
|
|
29
|
+
this.envSecrets.sort((a, b) => b.value.length - a.value.length);
|
|
30
|
+
}
|
|
31
|
+
line(input) {
|
|
32
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(input)) {
|
|
33
|
+
this.inPrivateKey = !/-----END [A-Z ]*PRIVATE KEY-----/.test(input);
|
|
34
|
+
return '-----BEGIN PRIVATE KEY----- ' + R;
|
|
35
|
+
}
|
|
36
|
+
if (this.inPrivateKey) {
|
|
37
|
+
if (/-----END [A-Z ]*PRIVATE KEY-----/.test(input))
|
|
38
|
+
this.inPrivateKey = false;
|
|
39
|
+
return R;
|
|
40
|
+
}
|
|
41
|
+
let out = input;
|
|
42
|
+
for (const { name, value } of this.envSecrets) {
|
|
43
|
+
if (out.includes(value))
|
|
44
|
+
out = out.split(value).join(`[REDACTED:${name}]`);
|
|
45
|
+
}
|
|
46
|
+
// user:password@host in URLs
|
|
47
|
+
out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/]+):([^\s@/]+)@/gi, `$1$2:${R}@`);
|
|
48
|
+
// Authorization headers / bearer tokens
|
|
49
|
+
out = out.replace(/\b(Bearer|Basic|token)\s+([A-Za-z0-9._~+/=-]{12,})/g, `$1 ${R}`);
|
|
50
|
+
// CLI flags carrying tokens
|
|
51
|
+
out = out.replace(/(--token[=\s]+|\s-t\s+)(\S+)/g, `$1${R}`);
|
|
52
|
+
for (const re of TOKEN_PATTERNS)
|
|
53
|
+
out = out.replace(re, R);
|
|
54
|
+
out = out.replace(KEY_VALUE, (m, key, sep, q, val) => val.startsWith('[REDACTED') || /^(Bearer|Basic|token)$/i.test(val) ? m : `${key}${sep}${q}${R}${q}`);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Redacts command-line args before they are shown (token flags, env values). */
|
|
59
|
+
export function redactArgs(args) {
|
|
60
|
+
const out = [];
|
|
61
|
+
let hideNext = 'none';
|
|
62
|
+
for (const arg of args) {
|
|
63
|
+
if (hideNext !== 'none') {
|
|
64
|
+
out.push(hideNext === 'value' && arg.includes('=') ? `${arg.split('=')[0]}=${R}` : R);
|
|
65
|
+
hideNext = 'none';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg === '--token' || arg === '-t')
|
|
69
|
+
hideNext = 'all';
|
|
70
|
+
else if (['--env', '-e', '--build-env', '-b'].includes(arg))
|
|
71
|
+
hideNext = 'value';
|
|
72
|
+
const m = /^(--token|--env|--build-env)=(.*)$/.exec(arg);
|
|
73
|
+
if (m)
|
|
74
|
+
out.push(m[1] === '--token' ? `--token=${R}` : `${m[1]}=${(m[2] ?? '').split('=')[0]}=${R}`);
|
|
75
|
+
else
|
|
76
|
+
out.push(arg);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
6
|
+
const STATIC = {
|
|
7
|
+
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
8
|
+
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
9
|
+
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
|
|
10
|
+
};
|
|
11
|
+
/** Serves the UI and streams state over SSE. Binds to loopback only. */
|
|
12
|
+
export async function startServer(session, port = Number(process.env.UNFOLD_PORT) || 4317) {
|
|
13
|
+
const clients = new Set();
|
|
14
|
+
let clientWaiters = [];
|
|
15
|
+
let pendingLogs = new Map();
|
|
16
|
+
let flushTimer;
|
|
17
|
+
let actualPort = port;
|
|
18
|
+
const send = (res, type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
19
|
+
const broadcast = (type, data) => clients.forEach((c) => send(c, type, data));
|
|
20
|
+
const flush = () => {
|
|
21
|
+
flushTimer = undefined;
|
|
22
|
+
if (pendingLogs.size)
|
|
23
|
+
broadcast('logs', [...pendingLogs.values()]);
|
|
24
|
+
pendingLogs = new Map();
|
|
25
|
+
broadcast('state', session.state);
|
|
26
|
+
};
|
|
27
|
+
session.subscribe((event) => {
|
|
28
|
+
if (event.type === 'log')
|
|
29
|
+
pendingLogs.set(event.line.seq, event.line);
|
|
30
|
+
if (event.type === 'run.started' || event.type === 'run.ended' || event.type === 'fix.ready') {
|
|
31
|
+
if (flushTimer)
|
|
32
|
+
clearTimeout(flushTimer);
|
|
33
|
+
if (event.type === 'run.started')
|
|
34
|
+
broadcast('reset', null);
|
|
35
|
+
flush();
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
flushTimer ??= setTimeout(flush, 60);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const handler = async (req, res) => {
|
|
42
|
+
// Reject requests addressed to other hostnames (DNS-rebinding protection).
|
|
43
|
+
const host = (req.headers.host ?? '').replace(/:\d+$/, '');
|
|
44
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
45
|
+
res.writeHead(403).end('Forbidden');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
49
|
+
if (path === '/api/events') {
|
|
50
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
51
|
+
clients.add(res);
|
|
52
|
+
send(res, 'snapshot', { state: session.state, logs: session.logs });
|
|
53
|
+
clientWaiters.forEach((w) => w());
|
|
54
|
+
clientWaiters = [];
|
|
55
|
+
req.on('close', () => clients.delete(res));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (path === '/api/state') {
|
|
59
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ state: session.state, logs: session.logs }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const file = STATIC[path];
|
|
63
|
+
if (req.method === 'GET' && file) {
|
|
64
|
+
try {
|
|
65
|
+
const body = await readFile(join(UI_DIR, file[0]));
|
|
66
|
+
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' }).end(body);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
res.writeHead(500).end('UI assets missing. Run `npm run build`.');
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(404).end('Not found');
|
|
74
|
+
};
|
|
75
|
+
const server = createServer((req, res) => void handler(req, res));
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
const tryListen = (p, attempts) => {
|
|
78
|
+
server.once('error', (err) => {
|
|
79
|
+
if (err.code === 'EADDRINUSE' && attempts > 0)
|
|
80
|
+
tryListen(p === 0 ? 0 : p + 1, attempts - 1);
|
|
81
|
+
else
|
|
82
|
+
reject(err);
|
|
83
|
+
});
|
|
84
|
+
server.listen(p, '127.0.0.1', () => {
|
|
85
|
+
actualPort = server.address().port;
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
tryListen(port, 20);
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
url: `http://127.0.0.1:${actualPort}/`,
|
|
93
|
+
waitForClient(ms) {
|
|
94
|
+
if (clients.size)
|
|
95
|
+
return Promise.resolve(true);
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
98
|
+
clientWaiters.push(() => {
|
|
99
|
+
clearTimeout(t);
|
|
100
|
+
resolve(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
close() {
|
|
105
|
+
clients.forEach((c) => c.end());
|
|
106
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { FlowReducer } from './flow.js';
|
|
3
|
+
import { agentCommands, buildFixPrompt } from './fix-prompt.js';
|
|
4
|
+
import { Redactor } from './redact.js';
|
|
5
|
+
import { redrawKey, TerminalDecoder } from './terminal.js';
|
|
6
|
+
/**
|
|
7
|
+
* One observed run: raw terminal output in, redacted lines + provider signals +
|
|
8
|
+
* flow state out. Everything emitted from here is already redacted.
|
|
9
|
+
*/
|
|
10
|
+
export class Session {
|
|
11
|
+
opts;
|
|
12
|
+
reducer = new FlowReducer();
|
|
13
|
+
runId;
|
|
14
|
+
parser;
|
|
15
|
+
present;
|
|
16
|
+
decoder;
|
|
17
|
+
redactor;
|
|
18
|
+
listeners = new Set();
|
|
19
|
+
now;
|
|
20
|
+
seq = 0;
|
|
21
|
+
last;
|
|
22
|
+
ended = false;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
this.now = opts.now ?? Date.now;
|
|
26
|
+
this.runId = new Date(this.now()).toISOString().replace(/[:.]/g, '-') + '-' + randomBytes(3).toString('hex');
|
|
27
|
+
this.parser = opts.provider.createParser({ cwd: opts.cwd });
|
|
28
|
+
this.redactor = opts.redactor ?? new Redactor();
|
|
29
|
+
this.present = opts.provider.createPresenter?.({ cwd: opts.cwd });
|
|
30
|
+
this.decoder = new TerminalDecoder((l) => this.onLine(l), opts.provider.maxLineLength);
|
|
31
|
+
}
|
|
32
|
+
get state() {
|
|
33
|
+
return this.reducer.state;
|
|
34
|
+
}
|
|
35
|
+
get logs() {
|
|
36
|
+
return [...this.reducer.logs.values()];
|
|
37
|
+
}
|
|
38
|
+
subscribe(fn) {
|
|
39
|
+
this.listeners.add(fn);
|
|
40
|
+
return () => this.listeners.delete(fn);
|
|
41
|
+
}
|
|
42
|
+
start() {
|
|
43
|
+
const p = this.opts.provider;
|
|
44
|
+
this.emit({
|
|
45
|
+
type: 'run.started',
|
|
46
|
+
at: this.now(),
|
|
47
|
+
runId: this.runId,
|
|
48
|
+
provider: p.id,
|
|
49
|
+
title: p.title,
|
|
50
|
+
command: this.opts.command,
|
|
51
|
+
cwd: this.opts.cwd,
|
|
52
|
+
mode: this.opts.mode,
|
|
53
|
+
stages: p.stages,
|
|
54
|
+
notes: p.notes,
|
|
55
|
+
});
|
|
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
|
+
}
|
|
72
|
+
/** Feed raw terminal output (may contain partial lines and escape codes). */
|
|
73
|
+
write(chunk) {
|
|
74
|
+
if (!this.ended)
|
|
75
|
+
this.decoder.write(chunk);
|
|
76
|
+
}
|
|
77
|
+
end(exitCode, signal, interrupted) {
|
|
78
|
+
if (this.ended)
|
|
79
|
+
return;
|
|
80
|
+
this.decoder.end();
|
|
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 });
|
|
86
|
+
this.emit({ type: 'run.ended', at: this.now(), exitCode, signal, interrupted });
|
|
87
|
+
const prompt = buildFixPrompt(this.state, this.logs, this.opts.provider.fixHints?.(this.state) ?? []);
|
|
88
|
+
if (prompt) {
|
|
89
|
+
let path;
|
|
90
|
+
try {
|
|
91
|
+
path = this.opts.saveFixPrompt?.(this.runId, prompt);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// The prompt is still shown in the UI.
|
|
95
|
+
}
|
|
96
|
+
this.emit({ type: 'fix.ready', at: this.now(), fix: { prompt, path, commands: path ? agentCommands(path) : undefined } });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
onLine({ text: rawText, transient }) {
|
|
100
|
+
const raw = this.redactor.line(rawText);
|
|
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);
|
|
113
|
+
let line;
|
|
114
|
+
// A redrawn spinner/progress line replaces the previous one instead of piling up.
|
|
115
|
+
if (this.last?.transient && this.last.key === key) {
|
|
116
|
+
if (this.last.line.text === text) {
|
|
117
|
+
this.last.transient = transient;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
line = { seq: this.last.line.seq, at, text };
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
line = { seq: ++this.seq, at, text };
|
|
124
|
+
}
|
|
125
|
+
this.last = { line, transient, key };
|
|
126
|
+
this.emit({ type: 'log', line });
|
|
127
|
+
for (const signal of this.parser.feed({ ...line, text: raw }))
|
|
128
|
+
this.emit({ type: 'signal', at, seq: line.seq, signal });
|
|
129
|
+
}
|
|
130
|
+
emit(event) {
|
|
131
|
+
this.reducer.apply(event);
|
|
132
|
+
const state = this.reducer.state;
|
|
133
|
+
for (const fn of this.listeners)
|
|
134
|
+
fn(event, state);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Runs are saved outside the project so they are never committed by accident. */
|
|
5
|
+
export function runsDir() {
|
|
6
|
+
return process.env.UNFOLD_HOME ? join(process.env.UNFOLD_HOME, 'runs') : join(homedir(), '.unfold', 'runs');
|
|
7
|
+
}
|
|
8
|
+
/** Appends already-redacted events as JSON lines, readable only by the current user. */
|
|
9
|
+
export class RunRecorder {
|
|
10
|
+
path;
|
|
11
|
+
constructor(runId) {
|
|
12
|
+
const dir = runsDir();
|
|
13
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
this.path = join(dir, `${runId}.jsonl`);
|
|
15
|
+
pruneRuns();
|
|
16
|
+
}
|
|
17
|
+
write(event) {
|
|
18
|
+
appendFileSync(this.path, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** How many runs to keep; older ones (and their fix prompts) are deleted. `UNFOLD_KEEP_RUNS` overrides. */
|
|
22
|
+
export const KEEP_RUNS = 100;
|
|
23
|
+
export function pruneRuns(keep = Number(process.env.UNFOLD_KEEP_RUNS) || KEEP_RUNS) {
|
|
24
|
+
try {
|
|
25
|
+
const dir = runsDir();
|
|
26
|
+
const runs = readdirSync(dir)
|
|
27
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
28
|
+
.map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
|
|
29
|
+
.sort((a, b) => b.t - a.t);
|
|
30
|
+
for (const { f } of runs.slice(keep)) {
|
|
31
|
+
rmSync(join(dir, f), { force: true });
|
|
32
|
+
rmSync(join(dir, f.replace(/\.jsonl$/, '.fix-prompt.md')), { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Pruning is best effort.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Saves a fix prompt next to the run, readable only by the current user. */
|
|
40
|
+
export function saveFixPrompt(runId, prompt) {
|
|
41
|
+
const dir = runsDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
const path = join(dir, `${runId}.fix-prompt.md`);
|
|
44
|
+
writeFileSync(path, prompt, { mode: 0o600 });
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
export function readRun(path) {
|
|
48
|
+
return readFileSync(path, 'utf8')
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter((l) => l.trim())
|
|
51
|
+
.map((l) => JSON.parse(l));
|
|
52
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Turns raw PTY output (ANSI colors, OSC hyperlinks, spinner redraws, CRLF)
|
|
2
|
+
// into plain text lines. Lines that are overwritten in place (spinners,
|
|
3
|
+
// progress bars) are reported as `transient` so the caller can collapse them.
|
|
4
|
+
const MAX_LINE = 4000;
|
|
5
|
+
// Braille / circle spinner frames at the start of a line.
|
|
6
|
+
const SPINNER_PREFIX = /^\s{0,3}(?:[⠀-⣿◐◓◑◒]\s*)+/u;
|
|
7
|
+
export function stripSpinner(text) {
|
|
8
|
+
return text.replace(SPINNER_PREFIX, '');
|
|
9
|
+
}
|
|
10
|
+
export class TerminalDecoder {
|
|
11
|
+
emit;
|
|
12
|
+
maxLine;
|
|
13
|
+
buf = '';
|
|
14
|
+
esc = 'none';
|
|
15
|
+
csiParams = '';
|
|
16
|
+
pendingCR = false;
|
|
17
|
+
constructor(emit, maxLine = MAX_LINE) {
|
|
18
|
+
this.emit = emit;
|
|
19
|
+
this.maxLine = maxLine;
|
|
20
|
+
}
|
|
21
|
+
write(chunk) {
|
|
22
|
+
for (const ch of chunk)
|
|
23
|
+
this.char(ch);
|
|
24
|
+
}
|
|
25
|
+
end() {
|
|
26
|
+
this.pendingCR = false;
|
|
27
|
+
this.flush(false);
|
|
28
|
+
}
|
|
29
|
+
char(ch) {
|
|
30
|
+
if (this.pendingCR) {
|
|
31
|
+
this.pendingCR = false;
|
|
32
|
+
if (ch === '\n') {
|
|
33
|
+
this.flush(false);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Bare carriage return: the line is about to be redrawn.
|
|
37
|
+
this.flush(true);
|
|
38
|
+
}
|
|
39
|
+
switch (this.esc) {
|
|
40
|
+
case 'esc':
|
|
41
|
+
if (ch === '[') {
|
|
42
|
+
this.esc = 'csi';
|
|
43
|
+
this.csiParams = '';
|
|
44
|
+
}
|
|
45
|
+
else if (ch === ']') {
|
|
46
|
+
this.esc = 'osc';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
this.esc = 'none';
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
case 'csi':
|
|
53
|
+
if (ch >= '@' && ch <= '~') {
|
|
54
|
+
this.esc = 'none';
|
|
55
|
+
this.csi(ch, this.csiParams);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
this.csiParams += ch;
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
case 'osc':
|
|
62
|
+
if (ch === '\x07')
|
|
63
|
+
this.esc = 'none';
|
|
64
|
+
else if (ch === '\x1b')
|
|
65
|
+
this.esc = 'osc-esc';
|
|
66
|
+
return;
|
|
67
|
+
case 'osc-esc':
|
|
68
|
+
this.esc = ch === '\\' ? 'none' : 'osc';
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (ch === '\x1b') {
|
|
72
|
+
this.esc = 'esc';
|
|
73
|
+
}
|
|
74
|
+
else if (ch === '\r') {
|
|
75
|
+
this.pendingCR = true;
|
|
76
|
+
}
|
|
77
|
+
else if (ch === '\n') {
|
|
78
|
+
this.flush(false);
|
|
79
|
+
}
|
|
80
|
+
else if (ch === '\b') {
|
|
81
|
+
this.buf = this.buf.slice(0, -1);
|
|
82
|
+
}
|
|
83
|
+
else if (ch === '\t') {
|
|
84
|
+
this.buf += ' ';
|
|
85
|
+
}
|
|
86
|
+
else if (ch >= ' ' || ch > '\x7f') {
|
|
87
|
+
if (this.buf.length < this.maxLine)
|
|
88
|
+
this.buf += ch;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
csi(final, params) {
|
|
92
|
+
// Erase-whole-line, cursor-to-column, cursor-up and clear-screen all mean the
|
|
93
|
+
// current text is being replaced rather than continued.
|
|
94
|
+
if ((final === 'K' && (params === '2' || params === '1')) || final === 'G' || final === 'A' || final === 'J' || final === 'H') {
|
|
95
|
+
this.flush(true);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
flush(transient) {
|
|
99
|
+
const text = stripSpinner(this.buf.replace(/\s+$/, ''));
|
|
100
|
+
this.buf = '';
|
|
101
|
+
if (text.trim() === '')
|
|
102
|
+
return;
|
|
103
|
+
this.emit({ text, transient });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Key used to decide whether a redrawn line replaces the previous one. */
|
|
107
|
+
export function redrawKey(text) {
|
|
108
|
+
return text.replace(/[\d.,=\-#>\s[\]()/KMGTB%]+/g, '').slice(0, 24);
|
|
109
|
+
}
|