@zenera/cli 1.1.22 → 1.1.24
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/README.md +72 -2
- package/dist/archive.d.ts +139 -0
- package/dist/archive.js +376 -0
- package/dist/ask.d.ts +51 -0
- package/dist/ask.js +143 -0
- package/dist/batch.d.ts +77 -0
- package/dist/batch.js +338 -0
- package/dist/command.d.ts +6 -0
- package/dist/commands/export.d.ts +3 -0
- package/dist/commands/export.js +255 -0
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +173 -0
- package/dist/commands/index.d.ts +5 -1
- package/dist/commands/index.js +13 -1
- package/dist/commands/inspect.d.ts +11 -0
- package/dist/commands/inspect.js +388 -89
- package/dist/commands/list.js +128 -19
- package/dist/commands/memory.js +453 -16
- package/dist/commands/models.js +14 -2
- package/dist/commands/run.js +139 -34
- package/dist/commands/sandbox.js +72 -1
- package/dist/engine.d.ts +19 -0
- package/dist/engine.js +60 -8
- package/dist/liveness.js +6 -3
- package/dist/main.js +5 -2
- package/dist/podman.d.ts +16 -0
- package/dist/podman.js +115 -2
- package/dist/progress.d.ts +33 -0
- package/dist/progress.js +402 -0
- package/dist/request.d.ts +45 -0
- package/dist/request.js +300 -0
- package/dist/sandbox.js +10 -0
- package/dist/scaffold.d.ts +8 -0
- package/dist/scaffold.js +4 -1
- package/dist/session.d.ts +26 -1
- package/dist/session.js +42 -3
- package/dist/term.d.ts +3 -0
- package/dist/term.js +17 -1
- package/dist/trace.d.ts +84 -0
- package/dist/trace.js +686 -0
- package/dist/validate.js +93 -4
- package/package.json +12 -6
- package/templates/editor/.github/copilot-instructions.md +11 -6
- package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
- package/templates/editor/.github/prompts/spec-sync-project.prompt.md +25 -2
- package/templates/editor/.github/skills/zen-cli/SKILL.md +14 -3
- package/templates/editor/.github/skills/zen-cli/references/check.md +13 -5
- package/templates/editor/.github/skills/zen-cli/references/export.md +107 -0
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
- package/templates/editor/.github/skills/zen-cli/references/memory.md +123 -15
- package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
- package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
- package/templates/editor/.github/skills/zen-cli/references/rag.md +6 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
- package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
- package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
- package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -28
- package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +370 -408
- package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
- package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +6 -5
- package/templates/editor/.vscode/agents.schema.json +1 -1
- package/templates/editor/.vscode/settings.json +1 -0
- package/templates/project/SPECIFICATION.md.tmpl +2 -0
- package/templates/project/agents/files-instructions.md +13 -0
- package/templates/project/agents/fork-instructions.md +27 -0
- package/templates/project/agents/memory-instructions.md +80 -7
- package/templates/project/gitignore +3 -0
- package/templates/project/scripts/_setup.sh +49 -7
package/dist/podman.d.ts
CHANGED
|
@@ -28,12 +28,28 @@ export interface PodmanStatus {
|
|
|
28
28
|
running: boolean;
|
|
29
29
|
starting: boolean;
|
|
30
30
|
};
|
|
31
|
+
/** how this client reaches the engine, and whether one connection carries every call */
|
|
32
|
+
connection?: {
|
|
33
|
+
uri: string;
|
|
34
|
+
shared: boolean;
|
|
35
|
+
};
|
|
31
36
|
/** whether `podman info` answered */
|
|
32
37
|
ready: boolean;
|
|
38
|
+
/** what the engine has to hand out: the machine's on macOS and Windows, the host's on Linux */
|
|
39
|
+
capacity?: {
|
|
40
|
+
memory: number;
|
|
41
|
+
swap: number;
|
|
42
|
+
cpus: number;
|
|
43
|
+
};
|
|
33
44
|
image?: string;
|
|
34
45
|
imagePresent?: boolean;
|
|
35
46
|
}
|
|
36
47
|
export declare function ensurePodmanReady(opts?: PodmanOptions): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* The machine's forwarded endpoint as a URI this client accepts, or nothing
|
|
50
|
+
* when there is no machine to ask.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sharedEndpoint(engine?: string, exec?: import("@zenera/neo").Runner): Promise<string | undefined>;
|
|
37
53
|
/**
|
|
38
54
|
* A build that ran and failed, rather than a host that could not be asked.
|
|
39
55
|
*
|
package/dist/podman.js
CHANGED
|
@@ -56,11 +56,19 @@ async function preflight(opts) {
|
|
|
56
56
|
// containers natively and has no machine to list, so asking would fail
|
|
57
57
|
// with a message about an unknown command rather than about anything
|
|
58
58
|
// true.
|
|
59
|
+
let shared;
|
|
59
60
|
if (platform() !== 'linux') {
|
|
60
61
|
await machine(engine, call, opts, run);
|
|
62
|
+
shared = await shareConnection(engine, run);
|
|
61
63
|
}
|
|
62
64
|
// 3. The socket. Everything above can be true while the engine is wedged.
|
|
63
|
-
|
|
65
|
+
let info = await call(['info'], 60_000);
|
|
66
|
+
if (info.code !== 0 && shared && process.env.CONTAINER_HOST === shared) {
|
|
67
|
+
// The shared endpoint is an optimisation, so it never gets to be the
|
|
68
|
+
// reason a run fails: put the default connection back and ask again.
|
|
69
|
+
delete process.env.CONTAINER_HOST;
|
|
70
|
+
info = await call(['info'], 60_000);
|
|
71
|
+
}
|
|
64
72
|
if (info.code !== 0) {
|
|
65
73
|
throw sandboxError(`${engine} is installed but not responding`, first(info) || `try: ${engine} machine start`);
|
|
66
74
|
}
|
|
@@ -226,6 +234,93 @@ function parseMachines(stdout) {
|
|
|
226
234
|
throw sandboxError('could not read `podman machine list --format json`');
|
|
227
235
|
}
|
|
228
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The machine's forwarded endpoint as a URI this client accepts, or nothing
|
|
239
|
+
* when there is no machine to ask.
|
|
240
|
+
*/
|
|
241
|
+
export async function sharedEndpoint(engine = 'podman', exec = runProcess) {
|
|
242
|
+
if (platform() === 'linux') {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const res = await exec(engine, ['machine', 'inspect'], { timeoutMs: 30_000 }).catch(() => undefined);
|
|
246
|
+
if (!res || res.code !== 0) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
let raw;
|
|
250
|
+
try {
|
|
251
|
+
raw = JSON.parse(res.stdout.trim() || '[]');
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const one = (Array.isArray(raw) ? raw : [])[0];
|
|
257
|
+
// Windows first: a named pipe is the only endpoint there, and `\\.\pipe\x`
|
|
258
|
+
// is spelled `//./pipe/x` in a URI.
|
|
259
|
+
const pipe = one?.ConnectionInfo?.PodmanPipe?.Path;
|
|
260
|
+
if (pipe) {
|
|
261
|
+
return `npipe://${pipe.replaceAll('\\', '/')}`;
|
|
262
|
+
}
|
|
263
|
+
const socket = one?.ConnectionInfo?.PodmanSocket?.Path;
|
|
264
|
+
return socket ? `unix://${socket}` : undefined;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Points this process at the shared endpoint, and returns what it set so the
|
|
268
|
+
* caller can take it back if the engine then fails to answer.
|
|
269
|
+
*
|
|
270
|
+
* An explicit `CONTAINER_HOST` or `CONTAINER_CONNECTION` is the user's choice
|
|
271
|
+
* about which engine to talk to, which is not ours to overrule for a
|
|
272
|
+
* throughput win.
|
|
273
|
+
*/
|
|
274
|
+
async function shareConnection(engine, exec) {
|
|
275
|
+
if (process.env.CONTAINER_HOST || process.env.CONTAINER_CONNECTION) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const uri = await sharedEndpoint(engine, exec);
|
|
279
|
+
if (uri) {
|
|
280
|
+
process.env.CONTAINER_HOST = uri;
|
|
281
|
+
}
|
|
282
|
+
return uri;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The connection a run will use, which is what a report about concurrency has
|
|
286
|
+
* to name — not the one that is configured today.
|
|
287
|
+
*
|
|
288
|
+
* `status` deliberately changes nothing, so it does not set `CONTAINER_HOST`
|
|
289
|
+
* the way `ensurePodmanReady` does; it asks the same question in the same
|
|
290
|
+
* order instead, and so answers for the run that has not happened yet. The
|
|
291
|
+
* `ssh://` fallback is therefore the machine where the shared endpoint could
|
|
292
|
+
* not be found, which is exactly the one worth saying so about.
|
|
293
|
+
*/
|
|
294
|
+
async function connectionOf(engine, exec) {
|
|
295
|
+
const held = process.env.CONTAINER_HOST;
|
|
296
|
+
if (held) {
|
|
297
|
+
return { uri: held, shared: !held.startsWith('ssh://') };
|
|
298
|
+
}
|
|
299
|
+
const shared = process.env.CONTAINER_CONNECTION
|
|
300
|
+
? undefined
|
|
301
|
+
: await sharedEndpoint(engine, exec);
|
|
302
|
+
if (shared) {
|
|
303
|
+
return { uri: shared, shared: true };
|
|
304
|
+
}
|
|
305
|
+
const res = await exec(engine, ['system', 'connection', 'list', '--format', 'json'], {
|
|
306
|
+
timeoutMs: 30_000,
|
|
307
|
+
}).catch(() => undefined);
|
|
308
|
+
if (!res || res.code !== 0) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
let raw;
|
|
312
|
+
try {
|
|
313
|
+
raw = JSON.parse(res.stdout.trim() || '[]');
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
const all = Array.isArray(raw) ? raw : [];
|
|
319
|
+
const named = process.env.CONTAINER_CONNECTION;
|
|
320
|
+
const uri = (named ? all.find((c) => c.Name === named) : undefined)?.URI ??
|
|
321
|
+
(all.find((c) => c.Default) ?? all[0])?.URI;
|
|
322
|
+
return uri ? { uri, shared: !uri.startsWith('ssh://') } : undefined;
|
|
323
|
+
}
|
|
229
324
|
// ---------------------------------------------------------------------------
|
|
230
325
|
// The image
|
|
231
326
|
// ---------------------------------------------------------------------------
|
|
@@ -294,9 +389,27 @@ export async function podmanStatus(opts = {}) {
|
|
|
294
389
|
starting: Boolean(chosen.Starting),
|
|
295
390
|
};
|
|
296
391
|
}
|
|
392
|
+
status.connection = await connectionOf(engine, run);
|
|
297
393
|
}
|
|
298
|
-
|
|
394
|
+
// The same `info` answers both questions, and the memory is the one worth
|
|
395
|
+
// printing: off Linux it is the machine's, which is neither the host's nor
|
|
396
|
+
// anything the container limits in agents.yaml were written against.
|
|
397
|
+
const info = await call([
|
|
398
|
+
'info',
|
|
399
|
+
'--format',
|
|
400
|
+
'{{.Host.MemTotal}} {{.Host.SwapTotal}} {{.Host.CPUs}}',
|
|
401
|
+
]);
|
|
299
402
|
status.ready = info?.code === 0;
|
|
403
|
+
if (info?.code === 0) {
|
|
404
|
+
const [memory, swap, cpus] = info.stdout.trim().split(/\s+/).map(Number);
|
|
405
|
+
if (Number.isFinite(memory) && memory > 0) {
|
|
406
|
+
status.capacity = {
|
|
407
|
+
memory: Math.round(memory / 1048576),
|
|
408
|
+
swap: Number.isFinite(swap) ? Math.round(swap / 1048576) : 0,
|
|
409
|
+
cpus: Number.isFinite(cpus) ? cpus : 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
300
413
|
if (opts.image) {
|
|
301
414
|
status.image = opts.image;
|
|
302
415
|
const exists = status.ready ? await call(['image', 'exists', opts.image]) : undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AgentEvent, type ModelUsage, type TokenUsage } from '@zenera/neo';
|
|
2
|
+
import type { BatchItem } from './request.ts';
|
|
3
|
+
export type ItemStatus = 'queued' | 'running' | 'ok' | 'failed';
|
|
4
|
+
export interface ProgressOptions {
|
|
5
|
+
dir: string;
|
|
6
|
+
project: string;
|
|
7
|
+
projectDir: string;
|
|
8
|
+
/** the batch file this came from */
|
|
9
|
+
input: string;
|
|
10
|
+
concurrency: number;
|
|
11
|
+
memory: string;
|
|
12
|
+
items: readonly BatchItem[];
|
|
13
|
+
}
|
|
14
|
+
export declare class BatchProgress {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(opts: ProgressOptions);
|
|
17
|
+
get file(): string;
|
|
18
|
+
/** Starts the ticking. Unref'd: the dashboard never holds the process open. */
|
|
19
|
+
begin(): void;
|
|
20
|
+
/** The event sink for one item, ready to hand to `Engine.run`. */
|
|
21
|
+
watch(id: string): (event: AgentEvent) => void;
|
|
22
|
+
finish(id: string, outcome: {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
usage?: TokenUsage;
|
|
26
|
+
models?: readonly ModelUsage[];
|
|
27
|
+
}): void;
|
|
28
|
+
/** Stops the clock and leaves the file as the record of the finished batch. */
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
/** Exported for the tests: the whole file, from the state in hand. */
|
|
31
|
+
render(): string;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=progress.d.ts.map
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { addUsage, isCheckpoint, mergeModelUsage, zeroUsage, } from '@zenera/neo';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { duration, format } from "./narrate.js";
|
|
5
|
+
/**
|
|
6
|
+
* What a batch looks like while it is still running.
|
|
7
|
+
*
|
|
8
|
+
* A hundred interleaved event streams on one terminal is not progress, it is
|
|
9
|
+
* noise — but the same events, folded per item and written to one file, are a
|
|
10
|
+
* dashboard. So the batch directory keeps a `README.md` that is rewritten once
|
|
11
|
+
* a second: open it in any editor with a preview and it is a live view of
|
|
12
|
+
* every run, and when the batch ends the last write leaves the same file as
|
|
13
|
+
* the report of what happened.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here is load-bearing. Every number is derived from events that are
|
|
16
|
+
* also recorded properly elsewhere, and a failed write is swallowed: a batch
|
|
17
|
+
* must not die because its dashboard could not be refreshed.
|
|
18
|
+
*/
|
|
19
|
+
const TICK_MS = 1000;
|
|
20
|
+
/** How much of the model's last words to keep. Enough to recognise, not to read. */
|
|
21
|
+
const TAIL = 220;
|
|
22
|
+
const BAR_WIDTH = 28;
|
|
23
|
+
/** A line no wider than this is one line in the panel, on any screen. */
|
|
24
|
+
const PANEL_WIDTH = 116;
|
|
25
|
+
export class BatchProgress {
|
|
26
|
+
#opts;
|
|
27
|
+
#items;
|
|
28
|
+
#startedAt = Date.now();
|
|
29
|
+
#timer;
|
|
30
|
+
#writing;
|
|
31
|
+
#again = false;
|
|
32
|
+
#finishedAt;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.#opts = opts;
|
|
35
|
+
this.#items = opts.items.map((item) => ({
|
|
36
|
+
id: item.id,
|
|
37
|
+
index: item.index,
|
|
38
|
+
question: question(item.input),
|
|
39
|
+
status: 'queued',
|
|
40
|
+
stage: 'queued',
|
|
41
|
+
turns: 0,
|
|
42
|
+
tools: 0,
|
|
43
|
+
usage: zeroUsage(),
|
|
44
|
+
models: [],
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
get file() {
|
|
48
|
+
return join(this.#opts.dir, 'README.md');
|
|
49
|
+
}
|
|
50
|
+
/** Starts the ticking. Unref'd: the dashboard never holds the process open. */
|
|
51
|
+
begin() {
|
|
52
|
+
this.#timer ??= setInterval(() => void this.#flush(), TICK_MS);
|
|
53
|
+
this.#timer.unref?.();
|
|
54
|
+
void this.#flush();
|
|
55
|
+
}
|
|
56
|
+
/** The event sink for one item, ready to hand to `Engine.run`. */
|
|
57
|
+
watch(id) {
|
|
58
|
+
const item = this.#find(id);
|
|
59
|
+
item.status = 'running';
|
|
60
|
+
item.stage = 'starting';
|
|
61
|
+
item.startedAt = Date.now();
|
|
62
|
+
return (event) => this.#on(item, event);
|
|
63
|
+
}
|
|
64
|
+
finish(id, outcome) {
|
|
65
|
+
const item = this.#find(id);
|
|
66
|
+
item.status = outcome.ok ? 'ok' : 'failed';
|
|
67
|
+
item.stage = outcome.ok ? 'done' : 'failed';
|
|
68
|
+
item.finishedAt = Date.now();
|
|
69
|
+
item.error = outcome.error;
|
|
70
|
+
// The result's own accounting is the authority; the running total is
|
|
71
|
+
// only what the events happened to carry.
|
|
72
|
+
if (outcome.usage) {
|
|
73
|
+
item.usage = outcome.usage;
|
|
74
|
+
}
|
|
75
|
+
if (outcome.models) {
|
|
76
|
+
item.models = outcome.models;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Stops the clock and leaves the file as the record of the finished batch. */
|
|
80
|
+
async close() {
|
|
81
|
+
if (this.#timer) {
|
|
82
|
+
clearInterval(this.#timer);
|
|
83
|
+
this.#timer = undefined;
|
|
84
|
+
}
|
|
85
|
+
this.#finishedAt = Date.now();
|
|
86
|
+
for (const item of this.#items) {
|
|
87
|
+
if (item.status === 'running') {
|
|
88
|
+
item.status = 'failed';
|
|
89
|
+
item.stage = 'interrupted';
|
|
90
|
+
item.error = 'interrupted before it finished';
|
|
91
|
+
item.finishedAt = this.#finishedAt;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await this.#flush();
|
|
95
|
+
}
|
|
96
|
+
#find(id) {
|
|
97
|
+
const item = this.#items.find((i) => i.id === id);
|
|
98
|
+
if (!item) {
|
|
99
|
+
throw new Error(`no such batch item: ${id}`);
|
|
100
|
+
}
|
|
101
|
+
return item;
|
|
102
|
+
}
|
|
103
|
+
#on(item, event) {
|
|
104
|
+
item.branch = event.branch?.name;
|
|
105
|
+
if (!isCheckpoint(event)) {
|
|
106
|
+
switch (event.type) {
|
|
107
|
+
case 'thinking_delta':
|
|
108
|
+
item.stage = 'thinking';
|
|
109
|
+
item.thinking = tail(item.thinking, event.delta);
|
|
110
|
+
break;
|
|
111
|
+
case 'text_delta':
|
|
112
|
+
item.stage = 'writing';
|
|
113
|
+
item.text = tail(item.text, event.delta);
|
|
114
|
+
break;
|
|
115
|
+
case 'tool_call_detected':
|
|
116
|
+
item.stage = `calling ${event.name}`;
|
|
117
|
+
break;
|
|
118
|
+
default:
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
switch (event.type) {
|
|
124
|
+
case 'run_created':
|
|
125
|
+
item.agent = event.agent;
|
|
126
|
+
item.stage = 'starting';
|
|
127
|
+
break;
|
|
128
|
+
case 'before_llm_call':
|
|
129
|
+
item.agent = event.agent;
|
|
130
|
+
item.stage = 'waiting on the model';
|
|
131
|
+
// A new turn's reasoning is not the last turn's.
|
|
132
|
+
item.thinking = undefined;
|
|
133
|
+
item.text = undefined;
|
|
134
|
+
break;
|
|
135
|
+
case 'after_llm_call':
|
|
136
|
+
item.turns += 1;
|
|
137
|
+
item.usage = addUsage(item.usage, event.node.usage);
|
|
138
|
+
item.models = mergeModelUsage(item.models, [
|
|
139
|
+
{ model: event.node.model, calls: 1, usage: event.node.usage },
|
|
140
|
+
]);
|
|
141
|
+
break;
|
|
142
|
+
case 'before_tool_call':
|
|
143
|
+
item.tools += 1;
|
|
144
|
+
item.lastTool = event.call.name;
|
|
145
|
+
item.lastToolFailed = undefined;
|
|
146
|
+
item.stage = `running ${event.call.name}`;
|
|
147
|
+
break;
|
|
148
|
+
case 'after_tool_call':
|
|
149
|
+
item.lastTool = event.node.name;
|
|
150
|
+
item.lastToolFailed = event.node.isError;
|
|
151
|
+
item.stage = 'working';
|
|
152
|
+
break;
|
|
153
|
+
case 'handoff':
|
|
154
|
+
item.agent = event.to;
|
|
155
|
+
item.stage = `handed to ${event.to}`;
|
|
156
|
+
break;
|
|
157
|
+
case 'before_fork':
|
|
158
|
+
item.stage = `forked into ${event.node.branches.map((b) => b.name).join(', ')}`;
|
|
159
|
+
break;
|
|
160
|
+
case 'branch_finished':
|
|
161
|
+
item.stage = `branch ${event.child.name} ${event.status}`;
|
|
162
|
+
break;
|
|
163
|
+
case 'run_finished':
|
|
164
|
+
if (!event.branch) {
|
|
165
|
+
item.stage = 'finishing';
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
default:
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* One write at a time, and never a stale last write: a tick that lands
|
|
174
|
+
* mid-write is remembered and repeated afterwards, so the file always ends
|
|
175
|
+
* up holding the newest render rather than whichever call returned last.
|
|
176
|
+
*/
|
|
177
|
+
#flush() {
|
|
178
|
+
if (this.#writing) {
|
|
179
|
+
this.#again = true;
|
|
180
|
+
return this.#writing;
|
|
181
|
+
}
|
|
182
|
+
this.#writing = (async () => {
|
|
183
|
+
try {
|
|
184
|
+
await writeFile(this.file, this.render(), 'utf8');
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// A dashboard nobody can write is not a reason to lose a batch.
|
|
188
|
+
}
|
|
189
|
+
this.#writing = undefined;
|
|
190
|
+
if (this.#again) {
|
|
191
|
+
this.#again = false;
|
|
192
|
+
await this.#flush();
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
return this.#writing;
|
|
196
|
+
}
|
|
197
|
+
/** Exported for the tests: the whole file, from the state in hand. */
|
|
198
|
+
render() {
|
|
199
|
+
const now = this.#finishedAt ?? Date.now();
|
|
200
|
+
const done = this.#items.filter((i) => i.status === 'ok' || i.status === 'failed');
|
|
201
|
+
const failed = done.filter((i) => i.status === 'failed');
|
|
202
|
+
const running = this.#items.filter((i) => i.status === 'running');
|
|
203
|
+
const queued = this.#items.filter((i) => i.status === 'queued');
|
|
204
|
+
const total = this.#items.reduce((sum, i) => addUsage(sum, i.usage), zeroUsage());
|
|
205
|
+
const out = [];
|
|
206
|
+
out.push(`# ${this.#opts.project} — batch`);
|
|
207
|
+
out.push('');
|
|
208
|
+
out.push(this.#finishedAt
|
|
209
|
+
? `**Finished** ${stampOf(now)} · ${duration(now - this.#startedAt)}`
|
|
210
|
+
: `**Running** · updated ${stampOf(now)} · ${duration(now - this.#startedAt)} elapsed`);
|
|
211
|
+
out.push('');
|
|
212
|
+
out.push(`\`${bar(done.length, this.#items.length)}\` **${done.length}/${this.#items.length}**` +
|
|
213
|
+
` · ${done.length - failed.length} ok · ${failed.length} failed` +
|
|
214
|
+
` · ${running.length} running · ${queued.length} queued`);
|
|
215
|
+
out.push('');
|
|
216
|
+
out.push(...this.#facts(total, done, now));
|
|
217
|
+
const slots = Math.min(this.#opts.concurrency, running.length + queued.length);
|
|
218
|
+
if (slots > 0) {
|
|
219
|
+
out.push('', '## Running now', '');
|
|
220
|
+
out.push(...panel(running, slots, now));
|
|
221
|
+
}
|
|
222
|
+
const models = mergeModelUsage(...this.#items.map((i) => i.models));
|
|
223
|
+
if (models.length > 0) {
|
|
224
|
+
// Below the panel on purpose: this table gains a row whenever a new
|
|
225
|
+
// model is first used, and anything above the panel that grows
|
|
226
|
+
// moves the panel down between ticks.
|
|
227
|
+
out.push('', '## Tokens by model', '');
|
|
228
|
+
out.push(...byModel(models));
|
|
229
|
+
}
|
|
230
|
+
if (done.length > 0) {
|
|
231
|
+
out.push('', '## Finished', '');
|
|
232
|
+
out.push(...table(done));
|
|
233
|
+
}
|
|
234
|
+
if (failed.length > 0) {
|
|
235
|
+
out.push('', '## What went wrong', '');
|
|
236
|
+
for (const item of failed) {
|
|
237
|
+
out.push(`- **${item.id}** — ${oneLine(item.error ?? 'failed')}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (queued.length > 0) {
|
|
241
|
+
out.push('', '## Waiting', '');
|
|
242
|
+
out.push(queued.map((i) => `\`${i.id}\``).join(' · '));
|
|
243
|
+
}
|
|
244
|
+
out.push('', '---', '');
|
|
245
|
+
out.push(this.#finishedAt
|
|
246
|
+
? `Every answer is in \`batch.json\`, and each item's own \`output.json\`.`
|
|
247
|
+
: `This file is rewritten every second while the batch runs.`);
|
|
248
|
+
out.push('');
|
|
249
|
+
return out.join('\n');
|
|
250
|
+
}
|
|
251
|
+
#facts(total, done, now) {
|
|
252
|
+
const spent = done
|
|
253
|
+
.map((i) => (i.finishedAt ?? now) - (i.startedAt ?? now))
|
|
254
|
+
.reduce((a, b) => a + b, 0);
|
|
255
|
+
const rows = [
|
|
256
|
+
['Batch', `\`${this.#opts.dir}\``],
|
|
257
|
+
['Project', `\`${this.#opts.projectDir}\``],
|
|
258
|
+
['Input', `\`${this.#opts.input}\``],
|
|
259
|
+
['Memory', this.#opts.memory],
|
|
260
|
+
['Concurrency', `${this.#opts.concurrency} at a time`],
|
|
261
|
+
['Tokens', tokens(total)],
|
|
262
|
+
// Always present, even before the first item lands: a row that
|
|
263
|
+
// appears later pushes everything below it down a line.
|
|
264
|
+
['Average run', done.length === 0 ? '—' : duration(Math.round(spent / done.length))],
|
|
265
|
+
];
|
|
266
|
+
return [
|
|
267
|
+
'| | |',
|
|
268
|
+
'| :-- | :-- |',
|
|
269
|
+
...rows.map(([name, value]) => `| **${name}** | ${value} |`),
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Drawing
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
/**
|
|
277
|
+
* The live part of the dashboard, and the only part that is redrawn rather
|
|
278
|
+
* than appended to — so it is drawn at a **fixed height**. A preformatted
|
|
279
|
+
* block never wraps, every item gets the same four lines whether or not it has
|
|
280
|
+
* anything to say, and a free slot is drawn as a free slot rather than left
|
|
281
|
+
* out. What is being read therefore stays where it was a second ago.
|
|
282
|
+
*/
|
|
283
|
+
function panel(running, slots, now) {
|
|
284
|
+
const out = ['```text'];
|
|
285
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
286
|
+
if (slot > 0) {
|
|
287
|
+
out.push('');
|
|
288
|
+
}
|
|
289
|
+
const item = running[slot];
|
|
290
|
+
out.push(...(item ? lines(item, now) : IDLE));
|
|
291
|
+
}
|
|
292
|
+
out.push('```');
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
const IDLE = ['· idle', ' waiting for a free slot', ' —', ' ↳ —'];
|
|
296
|
+
function lines(item, now) {
|
|
297
|
+
const words = item.text ?? item.thinking;
|
|
298
|
+
const doing = words
|
|
299
|
+
? oneLine(words)
|
|
300
|
+
: item.lastTool
|
|
301
|
+
? `${item.lastTool}${item.lastToolFailed === true ? ' (failed)' : ''}`
|
|
302
|
+
: '—';
|
|
303
|
+
return [
|
|
304
|
+
`▶ #${item.index} ${item.id}`,
|
|
305
|
+
` ${oneLine(item.question)}`,
|
|
306
|
+
` ${[
|
|
307
|
+
item.agent,
|
|
308
|
+
item.branch ? `${item.stage} ⑂ ${item.branch}` : item.stage,
|
|
309
|
+
`${duration(now - (item.startedAt ?? now))} in`,
|
|
310
|
+
`turn ${item.turns + 1}`,
|
|
311
|
+
`${item.tools} ${item.tools === 1 ? 'tool' : 'tools'}`,
|
|
312
|
+
tokens(item.usage),
|
|
313
|
+
]
|
|
314
|
+
.filter(Boolean)
|
|
315
|
+
.join(' · ')}`,
|
|
316
|
+
` ↳ ${doing}`,
|
|
317
|
+
].map((line) => clip(line, PANEL_WIDTH));
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Which model spent what. A batch can route agents — and forks — to different
|
|
321
|
+
* models, so one total belongs to none of them; these rows add back up to the
|
|
322
|
+
* `Tokens` fact above.
|
|
323
|
+
*/
|
|
324
|
+
function byModel(models) {
|
|
325
|
+
return [
|
|
326
|
+
'| model | calls | in | cached | out | thinking |',
|
|
327
|
+
'| :-- | --: | --: | --: | --: | --: |',
|
|
328
|
+
...models.map((m) => `| ${[
|
|
329
|
+
`\`${m.model}\``,
|
|
330
|
+
String(m.calls),
|
|
331
|
+
format(m.usage.inputTokens),
|
|
332
|
+
format(m.usage.cachedInputTokens),
|
|
333
|
+
format(m.usage.outputTokens),
|
|
334
|
+
format(m.usage.reasoningTokens),
|
|
335
|
+
].join(' | ')} |`),
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
function table(done) {
|
|
339
|
+
const rows = [...done].sort((a, b) => a.index - b.index);
|
|
340
|
+
return [
|
|
341
|
+
'| # | item | status | took | turns | tools | tokens | question |',
|
|
342
|
+
'| --: | :-- | :-- | --: | --: | --: | :-- | :-- |',
|
|
343
|
+
...rows.map((i) => `| ${[
|
|
344
|
+
String(i.index),
|
|
345
|
+
`\`${i.id}\``,
|
|
346
|
+
i.status === 'ok' ? '✓ ok' : '✗ failed',
|
|
347
|
+
duration((i.finishedAt ?? 0) - (i.startedAt ?? i.finishedAt ?? 0)),
|
|
348
|
+
String(i.turns),
|
|
349
|
+
String(i.tools),
|
|
350
|
+
tokens(i.usage),
|
|
351
|
+
clip(oneLine(i.question), 60),
|
|
352
|
+
].join(' | ')} |`),
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
function bar(done, total) {
|
|
356
|
+
const filled = total === 0 ? 0 : Math.round((done / total) * BAR_WIDTH);
|
|
357
|
+
return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
358
|
+
}
|
|
359
|
+
function tokens(usage) {
|
|
360
|
+
if (usage.inputTokens === 0 && usage.outputTokens === 0) {
|
|
361
|
+
return '—';
|
|
362
|
+
}
|
|
363
|
+
const thinking = usage.reasoningTokens ? ` (${format(usage.reasoningTokens)} thinking)` : '';
|
|
364
|
+
return `${format(usage.inputTokens)} in · ${format(usage.outputTokens)} out${thinking}`;
|
|
365
|
+
}
|
|
366
|
+
/** The question, as one line of a table cell rather than a document. */
|
|
367
|
+
function question(input) {
|
|
368
|
+
if (typeof input === 'string') {
|
|
369
|
+
return input;
|
|
370
|
+
}
|
|
371
|
+
return input.map(part).join(' ');
|
|
372
|
+
}
|
|
373
|
+
/** Media is named, not repeated: the whole point is one readable line. */
|
|
374
|
+
function part(one) {
|
|
375
|
+
if (typeof one === 'string') {
|
|
376
|
+
return one;
|
|
377
|
+
}
|
|
378
|
+
if ('text' in one) {
|
|
379
|
+
return one.text;
|
|
380
|
+
}
|
|
381
|
+
for (const kind of ['image', 'audio', 'video', 'file']) {
|
|
382
|
+
if (kind in one) {
|
|
383
|
+
return `[${kind}]`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return '[media]';
|
|
387
|
+
}
|
|
388
|
+
/** Markdown tables and blockquotes are line-oriented; the text is not. */
|
|
389
|
+
function oneLine(text) {
|
|
390
|
+
return clip(text.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'), 400);
|
|
391
|
+
}
|
|
392
|
+
function clip(text, max) {
|
|
393
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
394
|
+
}
|
|
395
|
+
function tail(had, delta) {
|
|
396
|
+
const next = (had ?? '') + delta;
|
|
397
|
+
return next.length > TAIL ? `…${next.slice(next.length - TAIL)}` : next;
|
|
398
|
+
}
|
|
399
|
+
function stampOf(ms) {
|
|
400
|
+
return new Date(ms).toTimeString().slice(0, 8);
|
|
401
|
+
}
|
|
402
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Input } from '@zenera/neo';
|
|
2
|
+
export interface RunRequest {
|
|
3
|
+
/** a registered name, or a directory — paths resolved against the file */
|
|
4
|
+
project?: string;
|
|
5
|
+
input: Input;
|
|
6
|
+
/** absolute */
|
|
7
|
+
workspace?: string;
|
|
8
|
+
/** absolute */
|
|
9
|
+
memory?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface BatchItem {
|
|
12
|
+
/** names this item's directory under the batch dir; defaults to its index */
|
|
13
|
+
id: string;
|
|
14
|
+
index: number;
|
|
15
|
+
input: Input;
|
|
16
|
+
/** absolute; without one the item gets a directory of its own */
|
|
17
|
+
workspace?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface BatchRequest {
|
|
20
|
+
items: BatchItem[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `-` is stdin, and then there is no file to resolve paths against, so the cwd
|
|
24
|
+
* answers instead — the only place a piped request could have meant.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readRequest(file: string, cwd: string, piped?: string): Promise<RunRequest>;
|
|
27
|
+
/**
|
|
28
|
+
* `base` is the directory every relative path in the request is resolved
|
|
29
|
+
* against — the file's own, so a case travels with the images beside it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseRequest(body: string, base: string, where: string): RunRequest;
|
|
32
|
+
/** The batch file. `-` is stdin, and then the cwd is what paths mean. */
|
|
33
|
+
export declare function readBatch(file: string, cwd: string, piped?: string): Promise<BatchRequest>;
|
|
34
|
+
/**
|
|
35
|
+
* Everything refusable is refused here, before a single model is called: a
|
|
36
|
+
* batch that fails on item 80 has already spent seventy-nine runs finding out
|
|
37
|
+
* something its file could have been read for.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseBatch(body: string, base: string, where: string): BatchRequest;
|
|
40
|
+
/**
|
|
41
|
+
* Canonical `ContentPart[]` out, whatever went in: the shorthands exist for
|
|
42
|
+
* whoever writes the file, and stop existing the moment it is read.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toInput(raw: unknown, base: string, where: string): Input;
|
|
45
|
+
//# sourceMappingURL=request.d.ts.map
|