@zenera/cli 1.1.23 → 1.1.25

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 (184) hide show
  1. package/README.md +88 -2
  2. package/dist/ask.d.ts +51 -0
  3. package/dist/ask.js +166 -0
  4. package/dist/banner.js +2 -2
  5. package/dist/batch.d.ts +83 -0
  6. package/dist/batch.js +413 -0
  7. package/dist/catalog.d.ts +11 -1
  8. package/dist/catalog.js +80 -7
  9. package/dist/command.d.ts +6 -0
  10. package/dist/commands/inspect.d.ts +57 -0
  11. package/dist/commands/inspect.js +539 -91
  12. package/dist/commands/list.js +128 -19
  13. package/dist/commands/memory.js +461 -32
  14. package/dist/commands/meta.js +311 -21
  15. package/dist/commands/models.js +110 -14
  16. package/dist/commands/open.js +4 -4
  17. package/dist/commands/run.js +175 -36
  18. package/dist/commands/sandbox.js +148 -15
  19. package/dist/engine.d.ts +24 -0
  20. package/dist/engine.js +61 -8
  21. package/dist/eventlog.d.ts +59 -0
  22. package/dist/eventlog.js +146 -0
  23. package/dist/liveness.d.ts +7 -0
  24. package/dist/liveness.js +20 -3
  25. package/dist/main.js +5 -2
  26. package/dist/meta/editor.d.ts +9 -0
  27. package/dist/meta/editor.js +60 -0
  28. package/dist/meta/finetune/children.d.ts +8 -0
  29. package/dist/meta/finetune/children.js +76 -0
  30. package/dist/meta/finetune/command.d.ts +30 -0
  31. package/dist/meta/finetune/command.js +338 -0
  32. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  33. package/dist/meta/finetune/dataset/anchors.js +239 -0
  34. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  35. package/dist/meta/finetune/dataset/changes.js +319 -0
  36. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  37. package/dist/meta/finetune/dataset/command.js +1011 -0
  38. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  39. package/dist/meta/finetune/dataset/drift.js +71 -0
  40. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  41. package/dist/meta/finetune/dataset/export.js +51 -0
  42. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  43. package/dist/meta/finetune/dataset/index.js +2 -0
  44. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  45. package/dist/meta/finetune/dataset/sample.js +197 -0
  46. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  47. package/dist/meta/finetune/dataset/schema.js +216 -0
  48. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  49. package/dist/meta/finetune/dataset/store.js +177 -0
  50. package/dist/meta/finetune/dataset/types.d.ts +125 -0
  51. package/dist/meta/finetune/dataset/types.js +39 -0
  52. package/dist/meta/finetune/failure.d.ts +15 -0
  53. package/dist/meta/finetune/failure.js +26 -0
  54. package/dist/meta/finetune/feedback.d.ts +45 -0
  55. package/dist/meta/finetune/feedback.js +85 -0
  56. package/dist/meta/finetune/improvements.d.ts +51 -0
  57. package/dist/meta/finetune/improvements.js +244 -0
  58. package/dist/meta/finetune/index.d.ts +2 -0
  59. package/dist/meta/finetune/index.js +2 -0
  60. package/dist/meta/finetune/journal.d.ts +35 -0
  61. package/dist/meta/finetune/journal.js +135 -0
  62. package/dist/meta/finetune/live.d.ts +74 -0
  63. package/dist/meta/finetune/live.js +226 -0
  64. package/dist/meta/finetune/loop.d.ts +3 -0
  65. package/dist/meta/finetune/loop.js +114 -0
  66. package/dist/meta/finetune/memories.d.ts +47 -0
  67. package/dist/meta/finetune/memories.js +154 -0
  68. package/dist/meta/finetune/report.d.ts +33 -0
  69. package/dist/meta/finetune/report.js +1284 -0
  70. package/dist/meta/finetune/retry.d.ts +24 -0
  71. package/dist/meta/finetune/retry.js +96 -0
  72. package/dist/meta/finetune/sampler.d.ts +18 -0
  73. package/dist/meta/finetune/sampler.js +41 -0
  74. package/dist/meta/finetune/system.d.ts +27 -0
  75. package/dist/meta/finetune/system.js +132 -0
  76. package/dist/meta/finetune/train.d.ts +39 -0
  77. package/dist/meta/finetune/train.js +391 -0
  78. package/dist/meta/finetune/tui.d.ts +16 -0
  79. package/dist/meta/finetune/tui.js +291 -0
  80. package/dist/meta/finetune/tuning.d.ts +167 -0
  81. package/dist/meta/finetune/tuning.js +289 -0
  82. package/dist/meta/finetune/usage.d.ts +62 -0
  83. package/dist/meta/finetune/usage.js +225 -0
  84. package/dist/meta/host.d.ts +14 -0
  85. package/dist/meta/host.js +20 -0
  86. package/dist/meta/index.d.ts +11 -0
  87. package/dist/meta/index.js +11 -0
  88. package/dist/meta/launch.d.ts +81 -0
  89. package/dist/meta/launch.js +286 -0
  90. package/dist/meta/model.d.ts +44 -0
  91. package/dist/meta/model.js +119 -0
  92. package/dist/meta/prompts.d.ts +18 -0
  93. package/dist/meta/prompts.js +61 -0
  94. package/dist/meta/relay.d.ts +20 -0
  95. package/dist/meta/relay.js +123 -0
  96. package/dist/meta/tokens.d.ts +52 -0
  97. package/dist/meta/tokens.js +226 -0
  98. package/dist/meta/window.d.ts +61 -0
  99. package/dist/meta/window.js +198 -0
  100. package/dist/meta/wire.d.ts +42 -0
  101. package/dist/meta/wire.js +129 -0
  102. package/dist/modelref.d.ts +16 -0
  103. package/dist/modelref.js +41 -0
  104. package/dist/narrate.js +5 -2
  105. package/dist/podman.d.ts +42 -0
  106. package/dist/podman.js +198 -5
  107. package/dist/progress.d.ts +33 -0
  108. package/dist/progress.js +402 -0
  109. package/dist/projects.d.ts +0 -5
  110. package/dist/projects.js +2 -15
  111. package/dist/request.d.ts +45 -0
  112. package/dist/request.js +300 -0
  113. package/dist/sandbox.d.ts +2 -0
  114. package/dist/sandbox.js +20 -3
  115. package/dist/scaffold.d.ts +15 -6
  116. package/dist/scaffold.js +5 -60
  117. package/dist/session.d.ts +26 -1
  118. package/dist/session.js +46 -32
  119. package/dist/term.d.ts +5 -0
  120. package/dist/term.js +28 -3
  121. package/dist/trace.d.ts +84 -0
  122. package/dist/trace.js +737 -0
  123. package/dist/tui/models.d.ts +33 -0
  124. package/dist/tui/models.js +389 -0
  125. package/dist/tui/wrap.d.ts +5 -0
  126. package/dist/tui/wrap.js +12 -0
  127. package/dist/usage.d.ts +78 -0
  128. package/dist/usage.js +51 -0
  129. package/dist/validate.js +25 -3
  130. package/package.json +3 -2
  131. package/templates/editor/.github/copilot-instructions.md +41 -107
  132. package/templates/editor/.github/prompts/analyze.prompt.md +31 -0
  133. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  134. package/templates/editor/.github/prompts/finetune-apply.prompt.md +91 -0
  135. package/templates/editor/.github/prompts/finetune.prompt.md +744 -0
  136. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  137. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  138. package/templates/editor/.github/prompts/project-review.prompt.md +4 -3
  139. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +483 -0
  140. package/templates/editor/.github/skills/zen-cli/SKILL.md +18 -4
  141. package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
  142. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  143. package/templates/editor/.github/skills/zen-cli/references/finetune.md +77 -0
  144. package/templates/editor/.github/skills/zen-cli/references/inspect.md +300 -21
  145. package/templates/editor/.github/skills/zen-cli/references/memory.md +121 -28
  146. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  147. package/templates/editor/.github/skills/zen-cli/references/models.md +6 -2
  148. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  149. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  150. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  151. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +10 -1
  152. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  153. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  154. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  155. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1791 -0
  156. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  157. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  158. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  159. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  160. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  161. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  162. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  163. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  164. package/templates/editor/.github/skills/zen-inspect/SKILL.md +431 -0
  165. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +270 -0
  166. package/templates/editor/.github/skills/zen-memory/SKILL.md +78 -29
  167. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
  168. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  169. package/templates/editor/.github/skills/zen-review/SKILL.md +5 -3
  170. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -4
  171. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +25 -2
  172. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  173. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  174. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  175. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  176. package/templates/editor/.vscode/agents.schema.json +1 -1
  177. package/templates/editor/.vscode/settings.json +2 -0
  178. package/templates/project/agents/files-instructions.md +35 -13
  179. package/templates/project/agents/fork-instructions.md +27 -0
  180. package/templates/project/agents/memory-instructions.md +106 -34
  181. package/templates/project/agents/tools-instructions.md +7 -4
  182. package/templates/project/gitignore +5 -0
  183. package/dist/meta.d.ts +0 -221
  184. package/dist/meta.js +0 -684
package/dist/podman.js CHANGED
@@ -1,7 +1,8 @@
1
- import { runProcess, SandboxError } from '@zenera/neo';
1
+ import { pidAlive, runProcess, SandboxError } from '@zenera/neo';
2
2
  import { readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { isBusy, isProjectDir, Registry, sessionIds, sessionsDir } from "./projects.js";
5
6
  import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
6
7
  const DEFAULT_MACHINE_CPUS = 2;
7
8
  const DEFAULT_MACHINE_MEMORY = 2048;
@@ -56,11 +57,19 @@ async function preflight(opts) {
56
57
  // containers natively and has no machine to list, so asking would fail
57
58
  // with a message about an unknown command rather than about anything
58
59
  // true.
60
+ let shared;
59
61
  if (platform() !== 'linux') {
60
62
  await machine(engine, call, opts, run);
63
+ shared = await shareConnection(engine, run);
61
64
  }
62
65
  // 3. The socket. Everything above can be true while the engine is wedged.
63
- const info = await call(['info'], 60_000);
66
+ let info = await call(['info'], 60_000);
67
+ if (info.code !== 0 && shared && process.env.CONTAINER_HOST === shared) {
68
+ // The shared endpoint is an optimisation, so it never gets to be the
69
+ // reason a run fails: put the default connection back and ask again.
70
+ delete process.env.CONTAINER_HOST;
71
+ info = await call(['info'], 60_000);
72
+ }
64
73
  if (info.code !== 0) {
65
74
  throw sandboxError(`${engine} is installed but not responding`, first(info) || `try: ${engine} machine start`);
66
75
  }
@@ -226,6 +235,93 @@ function parseMachines(stdout) {
226
235
  throw sandboxError('could not read `podman machine list --format json`');
227
236
  }
228
237
  }
238
+ /**
239
+ * The machine's forwarded endpoint as a URI this client accepts, or nothing
240
+ * when there is no machine to ask.
241
+ */
242
+ export async function sharedEndpoint(engine = 'podman', exec = runProcess) {
243
+ if (platform() === 'linux') {
244
+ return undefined;
245
+ }
246
+ const res = await exec(engine, ['machine', 'inspect'], { timeoutMs: 30_000 }).catch(() => undefined);
247
+ if (!res || res.code !== 0) {
248
+ return undefined;
249
+ }
250
+ let raw;
251
+ try {
252
+ raw = JSON.parse(res.stdout.trim() || '[]');
253
+ }
254
+ catch {
255
+ return undefined;
256
+ }
257
+ const one = (Array.isArray(raw) ? raw : [])[0];
258
+ // Windows first: a named pipe is the only endpoint there, and `\\.\pipe\x`
259
+ // is spelled `//./pipe/x` in a URI.
260
+ const pipe = one?.ConnectionInfo?.PodmanPipe?.Path;
261
+ if (pipe) {
262
+ return `npipe://${pipe.replaceAll('\\', '/')}`;
263
+ }
264
+ const socket = one?.ConnectionInfo?.PodmanSocket?.Path;
265
+ return socket ? `unix://${socket}` : undefined;
266
+ }
267
+ /**
268
+ * Points this process at the shared endpoint, and returns what it set so the
269
+ * caller can take it back if the engine then fails to answer.
270
+ *
271
+ * An explicit `CONTAINER_HOST` or `CONTAINER_CONNECTION` is the user's choice
272
+ * about which engine to talk to, which is not ours to overrule for a
273
+ * throughput win.
274
+ */
275
+ async function shareConnection(engine, exec) {
276
+ if (process.env.CONTAINER_HOST || process.env.CONTAINER_CONNECTION) {
277
+ return undefined;
278
+ }
279
+ const uri = await sharedEndpoint(engine, exec);
280
+ if (uri) {
281
+ process.env.CONTAINER_HOST = uri;
282
+ }
283
+ return uri;
284
+ }
285
+ /**
286
+ * The connection a run will use, which is what a report about concurrency has
287
+ * to name — not the one that is configured today.
288
+ *
289
+ * `status` deliberately changes nothing, so it does not set `CONTAINER_HOST`
290
+ * the way `ensurePodmanReady` does; it asks the same question in the same
291
+ * order instead, and so answers for the run that has not happened yet. The
292
+ * `ssh://` fallback is therefore the machine where the shared endpoint could
293
+ * not be found, which is exactly the one worth saying so about.
294
+ */
295
+ async function connectionOf(engine, exec) {
296
+ const held = process.env.CONTAINER_HOST;
297
+ if (held) {
298
+ return { uri: held, shared: !held.startsWith('ssh://') };
299
+ }
300
+ const shared = process.env.CONTAINER_CONNECTION
301
+ ? undefined
302
+ : await sharedEndpoint(engine, exec);
303
+ if (shared) {
304
+ return { uri: shared, shared: true };
305
+ }
306
+ const res = await exec(engine, ['system', 'connection', 'list', '--format', 'json'], {
307
+ timeoutMs: 30_000,
308
+ }).catch(() => undefined);
309
+ if (!res || res.code !== 0) {
310
+ return undefined;
311
+ }
312
+ let raw;
313
+ try {
314
+ raw = JSON.parse(res.stdout.trim() || '[]');
315
+ }
316
+ catch {
317
+ return undefined;
318
+ }
319
+ const all = Array.isArray(raw) ? raw : [];
320
+ const named = process.env.CONTAINER_CONNECTION;
321
+ const uri = (named ? all.find((c) => c.Name === named) : undefined)?.URI ??
322
+ (all.find((c) => c.Default) ?? all[0])?.URI;
323
+ return uri ? { uri, shared: !uri.startsWith('ssh://') } : undefined;
324
+ }
229
325
  // ---------------------------------------------------------------------------
230
326
  // The image
231
327
  // ---------------------------------------------------------------------------
@@ -294,9 +390,30 @@ export async function podmanStatus(opts = {}) {
294
390
  starting: Boolean(chosen.Starting),
295
391
  };
296
392
  }
393
+ status.connection = await connectionOf(engine, run);
297
394
  }
298
- const info = await call(['info']);
395
+ // The same `info` answers both questions, and the memory is the one worth
396
+ // printing: off Linux it is the machine's, which is neither the host's nor
397
+ // anything the container limits in agents.yaml were written against.
398
+ const info = await call([
399
+ 'info',
400
+ '--format',
401
+ '{{.Host.MemTotal}} {{.Host.SwapTotal}} {{.Host.CPUs}}',
402
+ ]);
299
403
  status.ready = info?.code === 0;
404
+ if (info?.code === 0) {
405
+ const [memory, swap, cpus] = info.stdout.trim().split(/\s+/).map(Number);
406
+ if (Number.isFinite(memory) && memory > 0) {
407
+ status.capacity = {
408
+ memory: Math.round(memory / 1048576),
409
+ swap: Number.isFinite(swap) ? Math.round(swap / 1048576) : 0,
410
+ cpus: Number.isFinite(cpus) ? cpus : 0,
411
+ };
412
+ }
413
+ // Asked apart from the line above: an engine without the field fails
414
+ // the whole template, and that must not cost the report its memory.
415
+ status.freeLocks = await freeLocks(engine, run);
416
+ }
300
417
  if (opts.image) {
301
418
  status.image = opts.image;
302
419
  const exists = status.ready ? await call(['image', 'exists', opts.image]) : undefined;
@@ -312,6 +429,76 @@ function safeMachines(stdout) {
312
429
  return [];
313
430
  }
314
431
  }
432
+ /** Containers the engine can still create; undefined when it will not say. */
433
+ export async function freeLocks(engine = 'podman', exec = runProcess) {
434
+ const res = await exec(engine, ['info', '--format', '{{.Host.FreeLocks}}'], {
435
+ timeoutMs: 30_000,
436
+ }).catch(() => undefined);
437
+ const free = res?.code === 0 ? res.stdout.trim() : '';
438
+ return /^\d+$/.test(free) ? Number(free) : undefined;
439
+ }
440
+ /**
441
+ * Refuses a run the engine cannot hold before anything is spent on it. Every
442
+ * container takes a lock, stopped ones included, and at zero the failure would
443
+ * otherwise land on the first shell command — mid-turn, or once per item.
444
+ */
445
+ export async function assertLocks(need, opts = {}) {
446
+ const engine = opts.engine ?? 'podman';
447
+ const exec = opts.exec ?? runProcess;
448
+ const free = await freeLocks(engine, exec);
449
+ if (free === undefined || free >= need) {
450
+ return;
451
+ }
452
+ if (opts.reuses) {
453
+ const there = await exec(engine, ['container', 'exists', opts.reuses], {
454
+ timeoutMs: 30_000,
455
+ }).catch(() => undefined);
456
+ if (there?.code === 0) {
457
+ return;
458
+ }
459
+ }
460
+ const idle = idleContainers(await ownedContainers(engine, exec), opts.owners ?? (await sessionOwners())).length;
461
+ throw new CliError(`the container engine has ${free} locks free and this run needs ${need} — ` +
462
+ 'every container holds one, stopped ones included', EXIT.sandbox, idle > 0
463
+ ? `zen sandbox clean --idle frees ${idle}`
464
+ : `every container zen made is in use — \`${engine} ps --all\` shows what holds them`);
465
+ }
466
+ /** Every session on this machine, by id, from the projects the registry knows. */
467
+ export async function sessionOwners() {
468
+ const owners = new Map();
469
+ for (const entry of (await Registry.open()).entries) {
470
+ if (isProjectDir(entry.path)) {
471
+ for (const id of sessionIds(entry.path)) {
472
+ owners.set(id, join(sessionsDir(entry.path), id));
473
+ }
474
+ }
475
+ }
476
+ return owners;
477
+ }
478
+ /**
479
+ * Containers no live process is using: every stopped one, and a running one
480
+ * whose run exited without tearing it down (a kill, a crash, a closed lid).
481
+ * A running container that cannot be traced to its owner is left alone.
482
+ */
483
+ export function idleContainers(containers, owners) {
484
+ return containers.filter((c) => {
485
+ if (c.state !== 'running') {
486
+ return true;
487
+ }
488
+ const faker = /^faker-(\d+)$/.exec(c.key ?? '');
489
+ if (faker) {
490
+ return !pidAlive(Number(faker[1]));
491
+ }
492
+ const dir = c.key === undefined ? undefined : owners.get(c.key);
493
+ return dir !== undefined && !isBusy(dir);
494
+ });
495
+ }
496
+ /**
497
+ * `ps --format json` is ~1 kB a container, so the runner's default 64 kB cap cut
498
+ * the listing at about sixty, the JSON stopped parsing, and every report said
499
+ * "none" while two thousand sat on the machine.
500
+ */
501
+ const LISTING_BYTES = 64 * 1024 * 1024;
315
502
  /**
316
503
  * Containers this CLI created, whatever session they belong to, and whether
317
504
  * each is up. `--all` is the point: with `persist: true` a session leaves a
@@ -325,7 +512,7 @@ export async function ownedContainers(engine = 'podman', exec = runProcess, opts
325
512
  if (opts.sizes) {
326
513
  args.push('--size');
327
514
  }
328
- const res = await exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
515
+ const res = await exec(engine, args, { timeoutMs: 120_000, maxBytes: LISTING_BYTES }).catch(() => undefined);
329
516
  if (!res || res.code !== 0) {
330
517
  return [];
331
518
  }
@@ -358,8 +545,14 @@ export async function removeContainers(names, engine = 'podman', exec = runProce
358
545
  if (names.length === 0) {
359
546
  return;
360
547
  }
361
- await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
548
+ // In slices, so two thousand of them get a timeout each slice can meet.
549
+ for (let i = 0; i < names.length; i += REMOVE_SLICE) {
550
+ await exec(engine, ['rm', '--force', '--volumes', ...names.slice(i, i + REMOVE_SLICE)], {
551
+ timeoutMs: 120_000,
552
+ });
553
+ }
362
554
  }
555
+ const REMOVE_SLICE = 100;
363
556
  /** What the engine is using. Never throws: a disk report is not worth a crash. */
364
557
  export async function engineDisk(engine = 'podman', exec = runProcess) {
365
558
  const call = (args) => exec(engine, args, { timeoutMs: 120_000 }).catch(() => 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
@@ -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