@hanzo/build 0.2.30 → 0.2.32

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.
@@ -1,556 +0,0 @@
1
- /** Colour codes a terminal would have drawn; a sandbox is not a terminal, but a harness may still print them. */
2
- const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g;
3
- /**
4
- * A shell command's words, as `sh` splits them: quotes grouped, `'"'"'`
5
- * joined, a backslash escaping the next character outside single quotes.
6
- */
7
- export function words(line) {
8
- const out = [];
9
- let cur = '';
10
- let open = false;
11
- let q = '';
12
- for (let i = 0; i < line.length; i++) {
13
- const c = line[i];
14
- if (q === "'") {
15
- if (c === "'")
16
- q = '';
17
- else
18
- cur += c;
19
- continue;
20
- }
21
- if (q === '"') {
22
- if (c === '"')
23
- q = '';
24
- else if (c === '\\' && i + 1 < line.length)
25
- cur += line[++i];
26
- else
27
- cur += c;
28
- continue;
29
- }
30
- if (c === "'" || c === '"') {
31
- q = c;
32
- open = true;
33
- }
34
- else if (c === '\\' && i + 1 < line.length) {
35
- cur += line[++i];
36
- open = true;
37
- }
38
- else if (/\s/.test(c)) {
39
- if (open)
40
- out.push(cur);
41
- cur = '';
42
- open = false;
43
- }
44
- else {
45
- cur += c;
46
- open = true;
47
- }
48
- }
49
- if (open)
50
- out.push(cur);
51
- return out;
52
- }
53
- /** The command a harness ran, out of the `bash -lc '…'` it wraps each one in. */
54
- export function unwrap(command) {
55
- const w = words(command);
56
- if (w.length === 3 && /(^|\/)(ba|z)?sh$/.test(w[0]) && (w[1] === '-lc' || w[1] === '-c'))
57
- return w[2];
58
- return command;
59
- }
60
- /**
61
- * The file a command only reads, or ''. `cat f`, `nl -ba f`, `sed -n '1,80p' f`,
62
- * `head -n 40 f`, `tail f` — one file, and nothing piped, chained or redirected.
63
- */
64
- export function reads(command) {
65
- const inner = unwrap(command);
66
- if (/[|;&<>`$]/.test(inner))
67
- return '';
68
- const [cmd, ...args] = words(inner);
69
- const one = (xs) => {
70
- const files = xs.filter((a) => !a.startsWith('-'));
71
- return files.length === 1 ? files[0] : '';
72
- };
73
- switch (cmd) {
74
- case 'cat':
75
- case 'nl':
76
- return one(args);
77
- case 'sed':
78
- return args.length === 3 && args[0] === '-n' && /^\d+(,\d+)?p$/.test(args[1]) ? args[2] : '';
79
- case 'head':
80
- case 'tail':
81
- return one(args[0] === '-n' ? args.slice(2) : args);
82
- }
83
- return '';
84
- }
85
- /** A path to show: a relative one as it is, an absolute one by its name — never the sandbox's layout. */
86
- export const shown = (path) => {
87
- if (!path.startsWith('/'))
88
- return path;
89
- const cut = path.replace(/\/+$/, '');
90
- return cut.slice(cut.lastIndexOf('/') + 1);
91
- };
92
- /**
93
- * The paragraphs the platform puts ahead of a task — the ask_user line and the
94
- * environment brief (apps/coding sandboxrunner.go), a plan's or a setup's
95
- * instructions and the setup's answer block (mode.go) — and the one a follow-up
96
- * puts after the person's words (coding.ts `followUp`). None is theirs.
97
- */
98
- const AHEAD = /^(When a decision needs the person you are working for, call ask_user|These environment variables are named on this codebase|This codebase's (install script|start command)|Plan only\. Read the repository|Set up this repository's development environment\.|```environment\n)/;
99
- const AFTER = /^(This follows an earlier run on this codebase|An earlier run already worked on this;|Continue where the earlier run left off\.)/;
100
- /** What the person asked, out of the task the agent was given. A follow-up with nothing said keeps its own line. */
101
- export function asked(task) {
102
- const parts = task.trim().split(/\n\s*\n/);
103
- while (parts.length && AHEAD.test(parts[0]))
104
- parts.shift();
105
- if (parts.length > 1 && AFTER.test(parts[parts.length - 1]))
106
- parts.pop();
107
- return parts.join('\n\n');
108
- }
109
- /** A unified diff's sections, by the path each changes, from its first hunk on. */
110
- export function sections(diff) {
111
- const out = new Map();
112
- let at = '';
113
- let lines = [];
114
- const close = () => {
115
- if (at)
116
- out.set(at, lines.join('\n'));
117
- };
118
- for (const line of diff.split('\n')) {
119
- const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
120
- if (m) {
121
- close();
122
- at = m[2];
123
- lines = [];
124
- }
125
- else if (at && (lines.length || line.startsWith('@@')))
126
- lines.push(line);
127
- }
128
- close();
129
- return out;
130
- }
131
- const HEAD = /^(exec|codex|thinking|user|apply patch|tokens used|turn interrupted|context compacted)$/;
132
- /** The older harness's stamp on each item it starts. */
133
- const STAMP = /^\[\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z?\] /;
134
- /** How long a stamped command or tool took: `14ms`, `2.1s`, `1m 05s`, `1h 02m`. */
135
- const TOOK = '(?: in [\\dhms. ]+)?';
136
- const RAN = new RegExp(`^(.+) (succeeded|exited (-?\\d+))${TOOK}:$`);
137
- const CALLED = new RegExp(`^(.+) (success|failed)${TOOK}:$`);
138
- const PATCHED = new RegExp(`^apply_patch\\(.*\\) exited (-?\\d+)${TOOK}:$`);
139
- const LEAD = /^(patch|mcp|web search|warning|ERROR|deprecated|model rerouted|hook): /;
140
- const RESULT = /^\s*(succeeded|exited (-?\d+)|declined|in progress)( in [\d.]+m?s)?:$/;
141
- /** A reader that appends the cards it finds to `out`, keyed by `mint`. */
142
- export function harness(out, mint) {
143
- let rest = '';
144
- let mode = 'prose';
145
- // The banner is a rule, the settings, and a rule.
146
- let rules = 0;
147
- let card = null;
148
- let final = [];
149
- const open = [];
150
- const edits = [];
151
- let edit = null;
152
- let diff = null;
153
- // Each task the agent was given, as the person's words once the whole block is read.
154
- const tasks = [];
155
- const said = (text) => {
156
- const c = { kind: 'said', key: mint(), who: 'agent', text };
157
- out.push(c);
158
- return c;
159
- };
160
- const patch = () => {
161
- const c = { kind: 'edit', key: mint(), files: [], patch: '', ran: 'running' };
162
- out.push(c);
163
- edit = { card: c, paths: [] };
164
- edits.push(edit);
165
- return edit;
166
- };
167
- // The diff printed after a patch is the turn's so far; the edit keeps the latest.
168
- const settle = () => {
169
- if (diff && edit)
170
- edit.card.patch = diff.join('\n');
171
- diff = null;
172
- };
173
- const step = (name, detail, ran) => {
174
- const c = { kind: 'step', key: mint(), name, detail, output: '', ran };
175
- out.push(c);
176
- return c;
177
- };
178
- // The agent's log: its warnings, errors and diagnostics, one step for the run.
179
- let log = null;
180
- const logged = (text) => {
181
- if (!log)
182
- log = step('Agent log', '', 'done');
183
- log.output += log.output ? `\n${text}` : text;
184
- };
185
- let stamped = false;
186
- let sink = 'none';
187
- let into = null;
188
- // A stamped command or tool is ended by name: its text as the harness printed it.
189
- const running = new Map();
190
- const begin = (item, name) => {
191
- running.set(name, item);
192
- };
193
- const end = (name, ok) => {
194
- const item = running.get(name);
195
- if (!item)
196
- return null;
197
- running.delete(name);
198
- item.ran = ok ? 'done' : 'error';
199
- return item;
200
- };
201
- const stamp = (l) => {
202
- settle();
203
- into = null;
204
- sink = 'none';
205
- if (/^Hanzo Dev v\S+$/.test(l)) {
206
- rules = 0;
207
- sink = 'banner';
208
- return;
209
- }
210
- if (l.startsWith('binary: ')) {
211
- sink = 'banner';
212
- return;
213
- }
214
- if (l === 'User instructions:') {
215
- const c = { kind: 'said', key: mint(), who: 'person', text: '' };
216
- out.push(c);
217
- tasks.push({ card: c, lines: [] });
218
- sink = 'task';
219
- return;
220
- }
221
- if (l === 'dev') {
222
- into = said('');
223
- sink = 'said';
224
- return;
225
- }
226
- if (l === 'thinking') {
227
- into = step('Thinking', '', 'done');
228
- sink = 'out';
229
- return;
230
- }
231
- const exec = /^exec (.+) in (\S+)$/.exec(l);
232
- if (exec) {
233
- const file = reads(exec[1]);
234
- const c = file
235
- ? { kind: 'read', key: mint(), file: shown(file), ran: 'running' }
236
- : { kind: 'shell', key: mint(), command: unwrap(exec[1]), output: '', ran: 'running' };
237
- out.push(c);
238
- begin(c, exec[1]);
239
- return;
240
- }
241
- const ran = RAN.exec(l);
242
- if (ran) {
243
- const c = end(ran[1], ran[2] === 'succeeded');
244
- if (c) {
245
- // A read's output is the file it read, which the chip already names.
246
- into = c;
247
- sink = c.kind === 'shell' ? 'out' : 'drop';
248
- return;
249
- }
250
- }
251
- const custom = /^tool (.+) (success|failed)$/.exec(l);
252
- if (custom) {
253
- into = end(custom[1], custom[2] === 'success') ?? step(custom[1], 'tool', custom[2] === 'success' ? 'done' : 'error');
254
- sink = 'out';
255
- return;
256
- }
257
- const tool = /^tool (.+)$/.exec(l);
258
- if (tool) {
259
- into = step(tool[1], 'tool', 'running');
260
- begin(into, tool[1]);
261
- sink = 'out';
262
- return;
263
- }
264
- const called = CALLED.exec(l);
265
- if (called && running.has(called[1])) {
266
- into = end(called[1], called[2] === 'success');
267
- sink = 'out';
268
- return;
269
- }
270
- const search = /^🌐 Search: (.*)$/.exec(l);
271
- if (search) {
272
- step('Web search', search[1], 'done');
273
- return;
274
- }
275
- if (/^apply_patch auto_approved=\w+:$/.test(l)) {
276
- patch();
277
- sink = 'patch';
278
- return;
279
- }
280
- const patched = PATCHED.exec(l);
281
- if (patched) {
282
- if (edit)
283
- edit.card.ran = patched[1] === '0' ? 'done' : 'error';
284
- sink = 'drop';
285
- return;
286
- }
287
- if (l === 'turn diff:') {
288
- diff = [];
289
- sink = 'diff';
290
- return;
291
- }
292
- if (/^tokens used: [\d,]+$/.test(l))
293
- return;
294
- if (/^task (interrupted|aborted)/.test(l)) {
295
- for (const item of running.values())
296
- item.ran = 'cancelled';
297
- running.clear();
298
- out.push({ kind: 'note', key: mint(), text: 'The agent was interrupted' });
299
- return;
300
- }
301
- logged(l);
302
- sink = 'log';
303
- };
304
- // An unstamped line of the stamped grammar, where the last stamped line said.
305
- const under = (l) => {
306
- switch (sink) {
307
- case 'banner':
308
- if (l === '--------' && ++rules === 3)
309
- sink = 'none';
310
- return;
311
- case 'task':
312
- tasks[tasks.length - 1].lines.push(l);
313
- return;
314
- case 'said':
315
- if (into && into.kind === 'said')
316
- into.text += into.text ? `\n${l}` : l;
317
- return;
318
- case 'out':
319
- if (into && (into.kind === 'shell' || into.kind === 'step'))
320
- into.output += into.output ? `\n${l}` : l;
321
- return;
322
- case 'patch': {
323
- const file = /^[AMD] (.+?)(?: -> (.+))?$/.exec(l);
324
- if (file && edit)
325
- edit.paths.push(file[2] ?? file[1]);
326
- return;
327
- }
328
- case 'diff':
329
- diff.push(l);
330
- return;
331
- case 'log':
332
- if (l.trim())
333
- logged(l);
334
- return;
335
- case 'drop':
336
- return;
337
- }
338
- if (l.trim())
339
- logged(l);
340
- };
341
- const line = (raw) => {
342
- const l = raw.replace(ANSI, '').replace(/\r$/, '');
343
- const at = STAMP.exec(l);
344
- if (at) {
345
- stamped = true;
346
- stamp(l.slice(at[0].length));
347
- return;
348
- }
349
- if (stamped) {
350
- under(l);
351
- return;
352
- }
353
- if (mode === 'config') {
354
- if (l === '--------' && ++rules === 2)
355
- mode = 'prose';
356
- return;
357
- }
358
- if (mode === 'command') {
359
- const cut = l.lastIndexOf(' in ');
360
- const command = cut > 0 ? l.slice(0, cut) : l;
361
- const file = reads(command);
362
- const c = file
363
- ? { kind: 'read', key: mint(), file: shown(file), ran: 'running' }
364
- : { kind: 'shell', key: mint(), command: unwrap(command), output: '', ran: 'running' };
365
- out.push(c);
366
- open.push(c);
367
- card = null;
368
- mode = 'prose';
369
- return;
370
- }
371
- if (mode === 'tokens') {
372
- mode = 'final';
373
- return;
374
- }
375
- if (mode === 'diff' && !HEAD.test(l) && !LEAD.test(l)) {
376
- diff.push(l);
377
- return;
378
- }
379
- // A task is the person's words to the next header, whatever they look like.
380
- if (mode === 'user' && !HEAD.test(l)) {
381
- tasks[tasks.length - 1].lines.push(l);
382
- return;
383
- }
384
- const result = RESULT.exec(l);
385
- if (result && open.length) {
386
- settle();
387
- const c = open.pop();
388
- c.ran = result[1] === 'succeeded' ? 'done' : result[1] === 'declined' ? 'cancelled' : result[1] === 'in progress' ? 'running' : 'error';
389
- card = c;
390
- mode = 'output';
391
- return;
392
- }
393
- if (HEAD.test(l)) {
394
- settle();
395
- card = null;
396
- switch (l) {
397
- case 'exec':
398
- mode = 'command';
399
- return;
400
- case 'codex':
401
- case 'thinking':
402
- card = said('');
403
- mode = 'agent';
404
- return;
405
- case 'user': {
406
- const c = { kind: 'said', key: mint(), who: 'person', text: '' };
407
- out.push(c);
408
- tasks.push({ card: c, lines: [] });
409
- mode = 'user';
410
- return;
411
- }
412
- case 'apply patch':
413
- patch();
414
- mode = 'prose';
415
- return;
416
- case 'tokens used':
417
- mode = 'tokens';
418
- return;
419
- default:
420
- out.push({ kind: 'note', key: mint(), text: l === 'turn interrupted' ? 'The agent was interrupted' : 'The agent compacted its context' });
421
- mode = 'prose';
422
- return;
423
- }
424
- }
425
- if (/^Hanzo Dev v\S+$/.test(l)) {
426
- settle();
427
- mode = 'config';
428
- rules = 0;
429
- return;
430
- }
431
- const patched = /^patch: (completed|failed|declined|in_progress)$/.exec(l);
432
- if (patched) {
433
- settle();
434
- const e = edit && edit.card.ran === 'running' ? edit : patch();
435
- e.card.ran = patched[1] === 'completed' ? 'done' : patched[1] === 'in_progress' ? 'running' : 'error';
436
- card = null;
437
- mode = 'patch';
438
- return;
439
- }
440
- if (mode === 'patch' && l.startsWith('diff --git ')) {
441
- diff = [l];
442
- mode = 'diff';
443
- return;
444
- }
445
- const mcp = /^mcp: (\S+) (started|\((completed|failed|in_progress)\))$/.exec(l);
446
- if (mcp) {
447
- settle();
448
- const ran = mcp[3] === 'completed' ? 'done' : mcp[3] === 'failed' ? 'error' : 'running';
449
- const started = [...out].reverse().find((c) => c.kind === 'step' && c.name === mcp[1] && c.ran === 'running');
450
- if (started && started.kind === 'step' && mcp[2] !== 'started')
451
- started.ran = ran;
452
- else
453
- step(mcp[1], 'tool', ran);
454
- card = null;
455
- mode = 'prose';
456
- return;
457
- }
458
- const web = /^web search: (.*)$/.exec(l);
459
- if (web) {
460
- settle();
461
- const prior = out[out.length - 1];
462
- // Printed when the search starts and again when it lands: one card.
463
- if (!(prior && prior.kind === 'step' && prior.name === 'Web search' && prior.detail === web[1]))
464
- step('Web search', web[1], 'done');
465
- card = null;
466
- mode = 'prose';
467
- return;
468
- }
469
- const warn = mode === 'output' || mode === 'agent' ? null : /^(warning|ERROR|deprecated|model rerouted): ?(.*)$/.exec(l);
470
- if (warn) {
471
- settle();
472
- logged(l);
473
- card = null;
474
- mode = 'prose';
475
- return;
476
- }
477
- if (l.startsWith('hook: '))
478
- return;
479
- const todo = /^ {2}(✓|→|•) (.*)$/.exec(l);
480
- if (todo && mode !== 'output' && mode !== 'agent') {
481
- const row = `${todo[1] === '✓' ? '✓' : todo[1] === '→' ? '●' : '○'} ${todo[2]}`;
482
- if (card && card.kind === 'step' && card.name === 'Plan')
483
- card.output += `\n${row}`;
484
- else {
485
- card = step('Plan', '', 'done');
486
- card.output = row;
487
- }
488
- mode = 'prose';
489
- return;
490
- }
491
- switch (mode) {
492
- case 'patch':
493
- if (l.trim() && edit)
494
- edit.paths.push(l.trim());
495
- return;
496
- case 'final':
497
- final.push(l);
498
- return;
499
- case 'output':
500
- if (card && card.kind === 'shell')
501
- card.output += card.output ? `\n${l}` : l;
502
- return;
503
- }
504
- // Prose: what the agent said under its header, or lines no header owns.
505
- if (card && card.kind === 'said') {
506
- card.text += card.text ? `\n${l}` : l;
507
- return;
508
- }
509
- if (!l.trim())
510
- return;
511
- card = said(l);
512
- mode = 'agent';
513
- };
514
- return {
515
- feed(chunk) {
516
- const text = rest + chunk;
517
- const lines = text.split('\n');
518
- rest = lines.pop();
519
- for (const l of lines)
520
- line(l);
521
- },
522
- /** The last line even without its newline, and what only the whole output can decide. */
523
- end() {
524
- if (rest)
525
- line(rest);
526
- rest = '';
527
- settle();
528
- // The final message is printed twice: under its header, and bare at the end.
529
- const tail = final.join('\n').trim();
530
- const spoken = out.filter((c) => c.kind === 'said' && c.who === 'agent').map((c) => c.text.trim());
531
- if (tail && !spoken.includes(tail))
532
- said(tail);
533
- final = [];
534
- // A patch names the sandbox's paths; the diff names them as the repository
535
- // does, and is the whole turn's, so an edit keeps the sections of its own files.
536
- for (const e of edits) {
537
- const parts = sections(e.card.patch);
538
- const known = [...parts.keys()];
539
- e.card.files = e.paths.map((p) => known.find((k) => p === k || p.endsWith(`/${k}`)) ?? shown(p));
540
- if (!e.card.files.length)
541
- e.card.files = known;
542
- const mine = e.card.files.filter((f) => parts.has(f));
543
- e.card.patch = mine.length === 1 ? parts.get(mine[0]) : mine.map((f) => `${f}\n${parts.get(f)}`).join('\n');
544
- }
545
- edits.length = 0;
546
- for (const k of tasks)
547
- k.card.text = asked(k.lines.join('\n'));
548
- tasks.length = 0;
549
- for (let i = out.length - 1; i >= 0; i--) {
550
- const c = out[i];
551
- if (c.kind === 'said' && !c.text.trim())
552
- out.splice(i, 1);
553
- }
554
- },
555
- };
556
- }