@zenera/cli 1.1.24 → 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 (167) hide show
  1. package/README.md +53 -0
  2. package/dist/ask.js +28 -5
  3. package/dist/banner.js +2 -2
  4. package/dist/batch.d.ts +6 -0
  5. package/dist/batch.js +76 -1
  6. package/dist/catalog.d.ts +11 -1
  7. package/dist/catalog.js +80 -7
  8. package/dist/commands/inspect.d.ts +47 -1
  9. package/dist/commands/inspect.js +167 -18
  10. package/dist/commands/memory.js +128 -29
  11. package/dist/commands/meta.js +311 -21
  12. package/dist/commands/models.js +96 -12
  13. package/dist/commands/open.js +4 -4
  14. package/dist/commands/run.js +38 -4
  15. package/dist/commands/sandbox.js +76 -14
  16. package/dist/engine.d.ts +5 -0
  17. package/dist/engine.js +1 -0
  18. package/dist/eventlog.d.ts +59 -0
  19. package/dist/eventlog.js +146 -0
  20. package/dist/liveness.d.ts +7 -0
  21. package/dist/liveness.js +15 -1
  22. package/dist/meta/editor.d.ts +9 -0
  23. package/dist/meta/editor.js +60 -0
  24. package/dist/meta/finetune/children.d.ts +8 -0
  25. package/dist/meta/finetune/children.js +76 -0
  26. package/dist/meta/finetune/command.d.ts +30 -0
  27. package/dist/meta/finetune/command.js +338 -0
  28. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  29. package/dist/meta/finetune/dataset/anchors.js +239 -0
  30. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  31. package/dist/meta/finetune/dataset/changes.js +319 -0
  32. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  33. package/dist/meta/finetune/dataset/command.js +1011 -0
  34. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  35. package/dist/meta/finetune/dataset/drift.js +71 -0
  36. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  37. package/dist/meta/finetune/dataset/export.js +51 -0
  38. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  39. package/dist/meta/finetune/dataset/index.js +2 -0
  40. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  41. package/dist/meta/finetune/dataset/sample.js +197 -0
  42. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  43. package/dist/meta/finetune/dataset/schema.js +216 -0
  44. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  45. package/dist/meta/finetune/dataset/store.js +177 -0
  46. package/dist/meta/finetune/dataset/types.d.ts +125 -0
  47. package/dist/meta/finetune/dataset/types.js +39 -0
  48. package/dist/meta/finetune/failure.d.ts +15 -0
  49. package/dist/meta/finetune/failure.js +26 -0
  50. package/dist/meta/finetune/feedback.d.ts +45 -0
  51. package/dist/meta/finetune/feedback.js +85 -0
  52. package/dist/meta/finetune/improvements.d.ts +51 -0
  53. package/dist/meta/finetune/improvements.js +244 -0
  54. package/dist/meta/finetune/index.d.ts +2 -0
  55. package/dist/meta/finetune/index.js +2 -0
  56. package/dist/meta/finetune/journal.d.ts +35 -0
  57. package/dist/meta/finetune/journal.js +135 -0
  58. package/dist/meta/finetune/live.d.ts +74 -0
  59. package/dist/meta/finetune/live.js +226 -0
  60. package/dist/meta/finetune/loop.d.ts +3 -0
  61. package/dist/meta/finetune/loop.js +114 -0
  62. package/dist/meta/finetune/memories.d.ts +47 -0
  63. package/dist/meta/finetune/memories.js +154 -0
  64. package/dist/meta/finetune/report.d.ts +33 -0
  65. package/dist/meta/finetune/report.js +1284 -0
  66. package/dist/meta/finetune/retry.d.ts +24 -0
  67. package/dist/meta/finetune/retry.js +96 -0
  68. package/dist/meta/finetune/sampler.d.ts +18 -0
  69. package/dist/meta/finetune/sampler.js +41 -0
  70. package/dist/meta/finetune/system.d.ts +27 -0
  71. package/dist/meta/finetune/system.js +132 -0
  72. package/dist/meta/finetune/train.d.ts +39 -0
  73. package/dist/meta/finetune/train.js +391 -0
  74. package/dist/meta/finetune/tui.d.ts +16 -0
  75. package/dist/meta/finetune/tui.js +291 -0
  76. package/dist/meta/finetune/tuning.d.ts +167 -0
  77. package/dist/meta/finetune/tuning.js +289 -0
  78. package/dist/meta/finetune/usage.d.ts +62 -0
  79. package/dist/meta/finetune/usage.js +225 -0
  80. package/dist/meta/host.d.ts +14 -0
  81. package/dist/meta/host.js +20 -0
  82. package/dist/meta/index.d.ts +11 -0
  83. package/dist/meta/index.js +11 -0
  84. package/dist/meta/launch.d.ts +81 -0
  85. package/dist/meta/launch.js +286 -0
  86. package/dist/meta/model.d.ts +44 -0
  87. package/dist/meta/model.js +119 -0
  88. package/dist/meta/prompts.d.ts +18 -0
  89. package/dist/meta/prompts.js +61 -0
  90. package/dist/meta/relay.d.ts +20 -0
  91. package/dist/meta/relay.js +123 -0
  92. package/dist/meta/tokens.d.ts +52 -0
  93. package/dist/meta/tokens.js +226 -0
  94. package/dist/meta/window.d.ts +61 -0
  95. package/dist/meta/window.js +198 -0
  96. package/dist/meta/wire.d.ts +42 -0
  97. package/dist/meta/wire.js +129 -0
  98. package/dist/modelref.d.ts +16 -0
  99. package/dist/modelref.js +41 -0
  100. package/dist/narrate.js +5 -2
  101. package/dist/podman.d.ts +26 -0
  102. package/dist/podman.js +83 -3
  103. package/dist/projects.d.ts +0 -5
  104. package/dist/projects.js +2 -15
  105. package/dist/sandbox.d.ts +2 -0
  106. package/dist/sandbox.js +10 -3
  107. package/dist/scaffold.d.ts +15 -6
  108. package/dist/scaffold.js +5 -60
  109. package/dist/session.js +4 -29
  110. package/dist/term.d.ts +2 -0
  111. package/dist/term.js +11 -2
  112. package/dist/trace.js +53 -2
  113. package/dist/tui/models.d.ts +33 -0
  114. package/dist/tui/models.js +389 -0
  115. package/dist/tui/wrap.d.ts +5 -0
  116. package/dist/tui/wrap.js +12 -0
  117. package/dist/usage.d.ts +78 -0
  118. package/dist/usage.js +51 -0
  119. package/package.json +3 -2
  120. package/templates/editor/.github/copilot-instructions.md +31 -102
  121. package/templates/editor/.github/prompts/analyze.prompt.md +31 -0
  122. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  123. package/templates/editor/.github/prompts/finetune-apply.prompt.md +91 -0
  124. package/templates/editor/.github/prompts/finetune.prompt.md +691 -198
  125. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  126. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  127. package/templates/editor/.github/prompts/project-review.prompt.md +4 -3
  128. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +483 -0
  129. package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -1
  130. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  131. package/templates/editor/.github/skills/zen-cli/references/finetune.md +77 -0
  132. package/templates/editor/.github/skills/zen-cli/references/inspect.md +47 -9
  133. package/templates/editor/.github/skills/zen-cli/references/memory.md +33 -10
  134. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  135. package/templates/editor/.github/skills/zen-cli/references/models.md +5 -1
  136. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  137. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +10 -1
  138. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  139. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  140. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  141. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1449 -658
  142. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  143. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  144. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  145. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  146. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  147. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  148. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  149. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  150. package/templates/editor/.github/skills/zen-inspect/SKILL.md +60 -25
  151. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +270 -0
  152. package/templates/editor/.github/skills/zen-memory/SKILL.md +18 -14
  153. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  154. package/templates/editor/.github/skills/zen-review/SKILL.md +5 -3
  155. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -4
  156. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +7 -3
  157. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +2 -2
  158. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  159. package/templates/editor/.vscode/settings.json +1 -0
  160. package/templates/project/agents/files-instructions.md +29 -20
  161. package/templates/project/agents/memory-instructions.md +31 -32
  162. package/templates/project/agents/tools-instructions.md +7 -4
  163. package/templates/project/gitignore +2 -0
  164. package/dist/meta.d.ts +0 -221
  165. package/dist/meta.js +0 -684
  166. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +0 -432
  167. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +0 -199
@@ -11,14 +11,14 @@
11
11
  // instead", and prints a ref on stdout that can be pasted straight back into
12
12
  // agents.yaml — or read by an agent doing the pasting.
13
13
  // ---------------------------------------------------------------------------
14
- import { createEmbedder, createModel, ModelRegistry } from '@zenera/neo';
14
+ import { createEmbedder, ModelRegistry } from '@zenera/neo';
15
15
  import { parse } from "../args.js";
16
- import { loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
16
+ import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
17
17
  import { ensureHome } from "../home.js";
18
18
  import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
19
- import { probeModel } from "../liveness.js";
20
- import { ago, bold, credentialError, cyan, dim, green, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
- const USAGE = 'zen models <providers|ls|search|show|test|pick> [ref] [options]';
19
+ import { probeChat, probeModel } from "../liveness.js";
20
+ import { ago, bold, CliError, credentialError, cyan, dim, EXIT, green, isInteractive, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
+ const USAGE = 'zen models <providers|ls|search|show|test|pick|browse> [ref] [options]';
22
22
  /**
23
23
  * The keyring is materialised here, and only here.
24
24
  *
@@ -112,15 +112,32 @@ function freshness(cat) {
112
112
  return cat.origin === 'live' ? green('fetched now') : dim(`cached ${ago(cat.fetchedAt)}`);
113
113
  }
114
114
  const roleMark = (row) => row.roles.join('+');
115
+ const maker = (row) => !row.publisher ? '' : isPartner(row) ? yellow(row.publisher) : green(row.publisher);
115
116
  function row(entry) {
116
117
  return [
117
118
  cyan(entry.ref),
119
+ maker(entry),
118
120
  dim(roleMark(entry)),
119
121
  dim(tokens(entry.contextLength)),
120
122
  dim(entry.pricing?.free ? 'free' : ''),
121
123
  dim(entry.name ?? ''),
122
124
  ];
123
125
  }
126
+ /** The table, with a heading wherever a reseller's rows change maker. */
127
+ function grouped(entries) {
128
+ const lines = table(entries.map(row));
129
+ const out = [];
130
+ let last;
131
+ entries.forEach((e, i) => {
132
+ const group = groupOf(e);
133
+ if (group && group !== last) {
134
+ out.push(...(out.length > 0 ? [''] : []), bold(group));
135
+ }
136
+ last = group;
137
+ out.push(lines[i]);
138
+ });
139
+ return out;
140
+ }
124
141
  /**
125
142
  * Says how a listing was obtained whenever it was not the vendor's own word —
126
143
  * on stderr, because it is narration about the answer rather than the answer.
@@ -175,6 +192,7 @@ const providers = async (ctx, args) => {
175
192
  })));
176
193
  note('');
177
194
  note(dim('zen models ls <provider> what one of them serves'));
195
+ note(dim('zen models browse walk them with the arrow keys'));
178
196
  note(dim('zen models pick --chat the first one that answers'));
179
197
  };
180
198
  // ---------------------------------------------------------------------------
@@ -198,7 +216,7 @@ const ls = async (ctx, args) => {
198
216
  const all = cats
199
217
  .flatMap((c) => c.entries)
200
218
  .filter((e) => matches(e, '', { roles }))
201
- .sort((a, b) => a.ref.localeCompare(b.ref));
219
+ .sort(byGroup);
202
220
  if (ctx.json) {
203
221
  json({
204
222
  models: all,
@@ -215,10 +233,13 @@ const ls = async (ctx, args) => {
215
233
  note(dim('nothing matched — try: zen models ls --refresh'));
216
234
  return;
217
235
  }
218
- writeAll(table(all.slice(0, limit).map(row)));
236
+ writeAll(grouped(all.slice(0, limit)));
219
237
  if (all.length > limit) {
220
238
  note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
221
239
  }
240
+ if (all.some(isPartner)) {
241
+ note(dim('partner models are billed through Vertex and must be enabled in Model Garden first: https://console.cloud.google.com/vertex-ai/model-garden'));
242
+ }
222
243
  };
223
244
  // ---------------------------------------------------------------------------
224
245
  // search
@@ -255,7 +276,7 @@ const search = async (ctx, args) => {
255
276
  // Cheapest first when price is known: search is usually the step before
256
277
  // picking one, and the free ones are what most people are looking for.
257
278
  .sort((a, b) => Number(b.pricing?.free ?? false) - Number(a.pricing?.free ?? false) ||
258
- a.ref.localeCompare(b.ref));
279
+ byGroup(a, b));
259
280
  if (ctx.json) {
260
281
  json({ query, matched: hits.length, models: hits.slice(0, limit) });
261
282
  return;
@@ -377,9 +398,7 @@ function target(ref, provider, id, role) {
377
398
  if (role !== 'chat') {
378
399
  throw usageError(`${ref} is ${role}-only, and there is no way to ask it a question from here`, 'zen models test only exercises chat and embedding models');
379
400
  }
380
- // 16 tokens is enough for "ok" and not enough to matter. Anthropic requires
381
- // a cap at all, so this is not merely thrift.
382
- return { ref, kind: 'model', model: createModel({ provider, model: id, maxTokens: 16 }) };
401
+ return { ref, kind: 'model', model: probeChat(provider, id) };
383
402
  }
384
403
  function verdict(probe) {
385
404
  switch (probe.check.state) {
@@ -536,6 +555,67 @@ const pick = async (ctx, args) => {
536
555
  throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
537
556
  };
538
557
  // ---------------------------------------------------------------------------
558
+ // browse
559
+ // ---------------------------------------------------------------------------
560
+ /**
561
+ * The same catalog, walked with the arrow keys: providers, what one serves,
562
+ * one model in full, with a key to ask it a question and a key to take it.
563
+ *
564
+ * The interface is drawn on stderr, so a pick reaches stdout alone and
565
+ * `$(zen models browse)` is the ref, the same contract `pick` keeps.
566
+ */
567
+ const browse = async (ctx, args) => {
568
+ const { values, positionals } = parse(args, { refresh: { type: 'boolean' }, theme: { type: 'string' } }, 'zen models browse [provider] [--refresh] [--theme dark|light|auto]');
569
+ const initial = positionals[0];
570
+ if (initial !== undefined && !isProvider(initial)) {
571
+ throw usageError(`unknown provider "${initial}"`, `known: ${PROVIDERS.join(', ')}`);
572
+ }
573
+ if (ctx.json || !isInteractive()) {
574
+ throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json, or zen models pick --chat');
575
+ }
576
+ const where = await credentials();
577
+ // Offline, like `providers`: the overview should not cost five listings.
578
+ // A provider is fetched when it is opened.
579
+ const cats = await loadCatalogs(PROVIDERS, { offline: true });
580
+ const { browse: draw } = await import("../tui/models.js");
581
+ const chosen = await draw({
582
+ providers: cats.map((c) => ({
583
+ provider: c.provider,
584
+ credential: source(where, c.provider),
585
+ models: c.entries.length,
586
+ origin: c.origin,
587
+ fetchedAt: c.fetchedAt,
588
+ })),
589
+ initial,
590
+ theme: values.theme,
591
+ load: (provider, refresh) => loadCatalog(provider, { refresh: refresh || values.refresh }),
592
+ test: async (entry) => {
593
+ const role = entry.roles.includes('chat')
594
+ ? 'chat'
595
+ : entry.roles.includes('embedding')
596
+ ? 'embedding'
597
+ : (entry.roles[0] ?? 'chat');
598
+ const probe = await probeModel(target(entry.ref, entry.provider, entry.id, role));
599
+ return {
600
+ state: probe.check.state,
601
+ ms: probe.ms,
602
+ detail: probe.check.detail,
603
+ fix: probe.check.fix,
604
+ dimensions: probe.dimensions,
605
+ };
606
+ },
607
+ });
608
+ if (!chosen) {
609
+ // Leaving is a fine way to end a browse, but not an answer to a
610
+ // substitution that was waiting for one.
611
+ if (!process.stdout.isTTY) {
612
+ throw new CliError('nothing picked', EXIT.failed);
613
+ }
614
+ return;
615
+ }
616
+ write(chosen.ref);
617
+ };
618
+ // ---------------------------------------------------------------------------
539
619
  // Dispatch
540
620
  // ---------------------------------------------------------------------------
541
621
  const SUBS = {
@@ -548,6 +628,8 @@ const SUBS = {
548
628
  test,
549
629
  check: test,
550
630
  pick,
631
+ browse,
632
+ ui: browse,
551
633
  };
552
634
  export const models = {
553
635
  summary: 'What this machine can use: list, search, test and pick models.',
@@ -570,6 +652,8 @@ export const models = {
570
652
  ' zen models show <ref> One model, in full.',
571
653
  ' zen models test <ref> … Ask it one real question.',
572
654
  ' zen models pick --embedding The first ref that answers, on stdout.',
655
+ ' zen models browse [provider] Walk providers and models with the',
656
+ ' arrow keys; t asks one, p prints its ref.',
573
657
  ],
574
658
  run: async (ctx) => {
575
659
  const [name, ...rest] = ctx.args;
@@ -588,7 +672,7 @@ export const models = {
588
672
  await ls(ctx, ctx.args);
589
673
  return;
590
674
  }
591
- throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
675
+ throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
592
676
  },
593
677
  };
594
678
  //# sourceMappingURL=models.js.map
@@ -3,9 +3,9 @@ import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { delimiter, join, resolve, sep } from 'node:path';
5
5
  import { one, parse } from "../args.js";
6
+ import { editorFiles } from "../meta/editor.js";
6
7
  import { project as resolveProject } from "../resolve.js";
7
- import { editorFiles } from "../scaffold.js";
8
- import { CliError, cyan, dim, EXIT, json, note, usageError } from "../term.js";
8
+ import { CliError, cyan, dim, EXIT, isInteractive, json, note, usageError } from "../term.js";
9
9
  const USAGE = 'zen open [project] [--editor <cmd>] [--wait]';
10
10
  /**
11
11
  * A project is resolved by name or from the cwd, and the path is handed to an
@@ -247,8 +247,8 @@ function verify(editor) {
247
247
  ? 'name one with --editor, or set $ZENERA_EDITOR'
248
248
  : `${editor.from} names a command that is not on PATH`);
249
249
  }
250
- if ((editor.from === '$VISUAL' || editor.from === '$EDITOR') && !process.stdin.isTTY) {
251
- throw usageError(`${editor.from} names a terminal editor and there is no terminal here`, 'name a windowed one with --editor, or set $ZENERA_EDITOR');
250
+ if ((editor.from === '$VISUAL' || editor.from === '$EDITOR') && !isInteractive()) {
251
+ throw usageError(`${editor.from} names a terminal editor and there is no one at a terminal here`, 'name a windowed one with --editor, or set $ZENERA_EDITOR');
252
252
  }
253
253
  }
254
254
  async function launch(editor, dir) {
@@ -1,15 +1,18 @@
1
+ import { usageByModel } from '@zenera/neo';
1
2
  import { writeFile } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
4
  import { parse } from "../args.js";
4
5
  import { concurrency, runBatch } from "../batch.js";
5
6
  import * as Engine from "../engine.js";
7
+ import { agentEventLog } from "../eventlog.js";
6
8
  import { duration, Narrator, stopMark, summary } from "../narrate.js";
7
9
  import * as Projects from "../projects.js";
8
10
  import { readBatch, readRequest } from "../request.js";
9
11
  import { target } from "../resolve.js";
10
12
  import { display } from "../session.js";
11
- import { bold, cyan, dim, json, jsonText, note, readStdin, usageError, write } from "../term.js";
13
+ import { bold, cyan, dim, drivenByAgent, json, jsonText, note, readStdin, usageError, write, } from "../term.js";
12
14
  import { parseChoice } from "../tui/theme.js";
15
+ import { appendUsage } from "../usage.js";
13
16
  const USAGE = 'zen run [project] [prompt] [options]';
14
17
  export const run = {
15
18
  summary: 'Run the project — the TUI on a terminal, one shot otherwise.',
@@ -40,6 +43,10 @@ export const run = {
40
43
  ' --theme <dark|light> Colours for the TUI. Default: auto.',
41
44
  ' --out <file> Put the answer in this file instead of on screen.',
42
45
  ' With --json, the file gets the whole JSON envelope.',
46
+ ' --events <file> Append what the agent does to this file as it does it:',
47
+ ' one JSON line per model call, tool call and message.',
48
+ ' --disposable Remove its containers when it ends, whatever `persist:`',
49
+ ' says: for a session that will never be continued.',
43
50
  ' --yes Answer yes to every question.',
44
51
  ' --json Print machine-readable JSON (session, run, mounts, output, etc...).',
45
52
  '',
@@ -121,6 +128,8 @@ export const run = {
121
128
  plain: { type: 'boolean' },
122
129
  theme: { type: 'string' },
123
130
  out: { type: 'string' },
131
+ events: { type: 'string' },
132
+ disposable: { type: 'boolean' },
124
133
  'batch-dir': { type: 'string' },
125
134
  concurrency: { type: 'string' },
126
135
  }, USAGE);
@@ -158,6 +167,9 @@ export const run = {
158
167
  // TUI, where there is someone to ask, still asks.
159
168
  const input = request ? request.input : prompt;
160
169
  const shot = Boolean(prompt) || Boolean(request);
170
+ if (values.events && !shot) {
171
+ throw usageError('--events records a one-shot run', 'give a prompt or --input');
172
+ }
161
173
  const where = await target({
162
174
  cwd: ctx.cwd,
163
175
  project: request?.project ?? values.project ?? (named ? head : undefined),
@@ -179,6 +191,7 @@ export const run = {
179
191
  memoryReadOnly: values['memory-read-only'],
180
192
  keys: values['no-keys'] ? false : undefined,
181
193
  yes: values.yes || ctx.json,
194
+ disposable: values.disposable,
182
195
  });
183
196
  try {
184
197
  // The TUI is for the case it is actually good at: a person at a
@@ -188,7 +201,8 @@ export const run = {
188
201
  const drawing = !shot &&
189
202
  !values.plain &&
190
203
  !ctx.json &&
191
- Boolean(process.stdout.isTTY && process.stdin.isTTY);
204
+ Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
205
+ !drivenByAgent();
192
206
  if (drawing) {
193
207
  const { start } = await import("../tui/app.js");
194
208
  await start(engine, {
@@ -222,6 +236,8 @@ const NOT_IN_A_BATCH = [
222
236
  ['workspace', 'they cannot share one; each item gets its own, or names one in the file'],
223
237
  ['plain', 'a batch never draws the TUI'],
224
238
  ['theme', 'a batch never draws the TUI'],
239
+ ['events', 'each item already has its own output.json'],
240
+ ['disposable', 'a batch removes its containers already'],
225
241
  ];
226
242
  async function batch(ctx, values, rest, piped) {
227
243
  if (rest.length > 0) {
@@ -261,11 +277,18 @@ async function once(engine, input, values, asJson, cwd, where) {
261
277
  quiet: asJson,
262
278
  live: Boolean(process.stderr.isTTY),
263
279
  });
280
+ const record = values.events ? agentEventLog(resolve(cwd, values.events)) : undefined;
281
+ const onEvent = (event) => {
282
+ narrator.handle(event);
283
+ record?.(event);
284
+ };
264
285
  // Ctrl-C asks the run to stop rather than killing the process, so the turn
265
- // still lands on disk and the session stays resumable.
286
+ // still lands on disk and the session stays resumable. SIGTERM too: it is
287
+ // how a tuning stops a run it left behind, and its containers go with it.
266
288
  const stopping = new AbortController();
267
289
  const onInterrupt = () => stopping.abort();
268
290
  process.once('SIGINT', onInterrupt);
291
+ process.once('SIGTERM', onInterrupt);
269
292
  if (!asJson) {
270
293
  // Two questions were just answered, possibly without being asked. Say
271
294
  // which way they went: a run that quietly resumed the wrong session, or
@@ -278,12 +301,23 @@ async function once(engine, input, values, asJson, cwd, where) {
278
301
  }
279
302
  let outcome;
280
303
  try {
281
- outcome = await Engine.run(engine, input, narrator.handle, stopping.signal);
304
+ outcome = await Engine.run(engine, input, onEvent, stopping.signal);
282
305
  }
283
306
  finally {
284
307
  narrator.done();
285
308
  process.off('SIGINT', onInterrupt);
309
+ process.off('SIGTERM', onInterrupt);
286
310
  }
311
+ appendUsage(where.project.dir, {
312
+ kind: 'run',
313
+ ts: new Date().toISOString(),
314
+ runDir: outcome.run.dir,
315
+ session: engine.session.id,
316
+ ok: true,
317
+ stopReason: outcome.result.stopReason,
318
+ durationMs: outcome.durationMs,
319
+ models: usageByModel(outcome.result.state.trajectory),
320
+ });
287
321
  if (asJson) {
288
322
  // --out is a destination, not a copy, and with --json the answer *is*
289
323
  // the envelope — so that is what lands in the file, not the prose.
@@ -1,9 +1,9 @@
1
1
  import { readProjectConfig, sandboxLimits, } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
3
  import { resolveBuild } from "../image.js";
4
- import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
4
+ import { engineDisk, ensurePodmanReady, idleContainers, ownedContainers, podmanStatus, removeContainers, sessionOwners, } from "../podman.js";
5
5
  import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
6
- import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
6
+ import { ago, bold, bytes, count, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
7
  const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
8
8
  /** Enough to see the pattern; the rest are a number. */
9
9
  const LISTED = 6;
@@ -11,6 +11,8 @@ const LISTED = 6;
11
11
  const INDENT = ' '.repeat(11);
12
12
  /** `agents.yaml` states memory in MiB; `bytes` prints bytes. */
13
13
  const MIB = 1024 * 1024;
14
+ /** Below this many free locks, `status` says so: a batch can spend that in minutes. */
15
+ const FEW_LOCKS = 256;
14
16
  // ---------------------------------------------------------------------------
15
17
  // The container engine, on its own
16
18
  //
@@ -33,6 +35,8 @@ export const sandbox = {
33
35
  '',
34
36
  ' --project <name|dir> Which project the image and containers belong to.',
35
37
  ' --image <ref> Use this image instead of the project\u2019s.',
38
+ ' --idle With clean: only what no live run is using — stopped ones,',
39
+ ' and running ones whose run was killed before it cleaned up.',
36
40
  '',
37
41
  'Without --project this is the project you are standing in, and standing',
38
42
  'nowhere is an answer: `status` then reports the engine and every container',
@@ -45,6 +49,8 @@ export const sandbox = {
45
49
  const { values, positionals } = parse(ctx.args, {
46
50
  project: { type: 'string' },
47
51
  image: { type: 'string' },
52
+ stopped: { type: 'boolean' },
53
+ idle: { type: 'boolean' },
48
54
  }, USAGE);
49
55
  const what = positionals[0] ?? 'status';
50
56
  if (!['status', 'up', 'pull', 'clean', 'disk'].includes(what)) {
@@ -70,7 +76,7 @@ export const sandbox = {
70
76
  }
71
77
  return up(image, build, true, ctx.json, true);
72
78
  case 'clean':
73
- return clean(ctx.json);
79
+ return clean(ctx.json, Boolean(values.idle || values.stopped));
74
80
  case 'disk':
75
81
  return disk(ctx.json);
76
82
  }
@@ -116,6 +122,7 @@ async function status(project, image, asJson) {
116
122
  // a report about one project must not list another's — or the faker's,
117
123
  // which wears the same label and belongs to no project at all.
118
124
  const containers = project ? ofProject(project.dir, all) : all;
125
+ const idle = all.length > 0 ? idleContainers(all, await sessionOwners()) : [];
119
126
  if (asJson) {
120
127
  json({
121
128
  ...found,
@@ -124,6 +131,7 @@ async function status(project, image, asJson) {
124
131
  limits: project?.limits ?? null,
125
132
  fits: fits(found.capacity?.memory, project?.limits?.memory) ?? null,
126
133
  containers,
134
+ idle: idle.map((c) => c.name),
127
135
  });
128
136
  return;
129
137
  }
@@ -153,7 +161,8 @@ async function status(project, image, asJson) {
153
161
  if (build) {
154
162
  write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
155
163
  }
156
- writeAll(containerLines(containers, project?.name));
164
+ writeAll(lockLines(found.freeLocks, idle.length));
165
+ writeAll(containerLines(containers, idle, project?.name, all.length));
157
166
  if (!found.installed || !found.ready) {
158
167
  note('');
159
168
  note(dim('run `zen sandbox up` to fix what can be fixed.'));
@@ -225,6 +234,22 @@ function ofProject(dir, containers) {
225
234
  const sessions = new Set(sessionIds(dir));
226
235
  return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
227
236
  }
237
+ /**
238
+ * Every container the engine holds takes one of its locks, stopped or not, and
239
+ * at zero nothing can be created — for any project. Said only when it is close.
240
+ */
241
+ function lockLines(free, idle) {
242
+ if (free === undefined || free >= FEW_LOCKS) {
243
+ return [];
244
+ }
245
+ const left = free === 0 ? red('none free') : yellow(`${free} free`);
246
+ return [
247
+ `${bold('locks')} ${left} ${dim('· one per container, stopped ones included')}`,
248
+ ...(idle > 0
249
+ ? [`${INDENT}${dim(`${idle} no live run is using — zen sandbox clean --idle`)}`]
250
+ : []),
251
+ ];
252
+ }
228
253
  /**
229
254
  * One per line rather than one long line, because there is normally more than
230
255
  * one and the interesting part — how old, and whether anything is still up —
@@ -235,21 +260,31 @@ function ofProject(dir, containers) {
235
260
  * stopped ones behind. `scope` names the project these belong to, and saying
236
261
  * so is half the answer — the other half is that there are more elsewhere.
237
262
  */
238
- function containerLines(containers, scope) {
263
+ function containerLines(containers, idle, scope, total = containers.length) {
239
264
  const tail = scope
240
265
  ? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
241
266
  : 'one per session, kept by `persist: true` — see: zen sandbox disk';
267
+ const elsewhere = total > containers.length ? ` · ${total} on the machine` : '';
242
268
  if (containers.length === 0) {
243
- return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
269
+ return [`${bold('containers')} ${dim(scope ? `none in ${scope}${elsewhere}` : 'none')}`];
244
270
  }
271
+ // Running, but the run that started it is gone: nothing will ever stop it.
272
+ const orphans = new Set(idle.filter((c) => c.state === 'running').map((c) => c.name));
245
273
  const running = containers.filter((c) => c.state === 'running').length;
246
- const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
274
+ const lost = containers.filter((c) => orphans.has(c.name)).length;
275
+ const head = `${bold('containers')} ${containers.length} ${dim((running ? `· ${running} running` : '· none running') +
276
+ (lost ? `, ${lost} of them orphaned` : '') +
277
+ elsewhere)}`;
247
278
  const rows = containers
248
279
  .slice(0, LISTED)
249
280
  .map((c) => [
250
281
  INDENT.slice(2),
251
282
  c.name,
252
- c.state === 'running' ? green('running') : dim(c.state),
283
+ orphans.has(c.name)
284
+ ? yellow('orphaned')
285
+ : c.state === 'running'
286
+ ? green('running')
287
+ : dim(c.state),
253
288
  dim(ago(c.createdAt)),
254
289
  ]);
255
290
  const rest = containers.length - LISTED;
@@ -258,6 +293,9 @@ function containerLines(containers, scope) {
258
293
  ...table(rows),
259
294
  ...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
260
295
  `${INDENT}${dim(tail)}`,
296
+ ...(lost
297
+ ? [`${INDENT}${dim('orphaned: its run was killed — zen sandbox clean --idle')}`]
298
+ : []),
261
299
  ];
262
300
  }
263
301
  async function up(image, build, yes, asJson, rebuild = false) {
@@ -268,8 +306,9 @@ async function up(image, build, yes, asJson, rebuild = false) {
268
306
  }
269
307
  write(`${green('ready')}${image ? ` ${dim(image)}` : ''}`);
270
308
  }
271
- async function clean(asJson) {
272
- const containers = await ownedContainers(undefined, undefined, { sizes: true });
309
+ async function clean(asJson, idle) {
310
+ const owned = await ownedContainers(undefined, undefined, { sizes: true });
311
+ const containers = idle ? idleContainers(owned, await sessionOwners()) : owned;
273
312
  const names = containers.map((c) => c.name);
274
313
  const freed = containers.reduce((n, c) => n + (c.size ?? 0), 0);
275
314
  await removeContainers(names);
@@ -307,10 +346,30 @@ async function disk(asJson) {
307
346
  write('');
308
347
  }
309
348
  writeAll(projectRows(projects, loose));
310
- if (usage && usage.images.reclaimable > 0) {
349
+ const idle = idleContainers(containers, await sessionOwners());
350
+ const live = containers.length - idle.length;
351
+ const sessions = projects.reduce((n, p) => n + p.files, 0);
352
+ const hints = [
353
+ ...(idle.length > 0
354
+ ? [`zen sandbox clean --idle ${count(idle.length, 'container')} no live run is using`]
355
+ : []),
356
+ ...(live > 0
357
+ ? [`zen sandbox clean also the ${live} in use, cutting off whatever runs there`]
358
+ : []),
359
+ ...(usage && usage.images.reclaimable > 0
360
+ ? [`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`]
361
+ : []),
362
+ // The ON DISK column is the bulk of the report and no command above touches it.
363
+ ...(sessions > 0
364
+ ? [
365
+ `${bytes(sessions)} ON DISK is project directories on this host, not the engine: ` +
366
+ 'removing containers frees none of it',
367
+ ]
368
+ : []),
369
+ ];
370
+ if (hints.length > 0) {
311
371
  write('');
312
- write(dim(`zen sandbox clean every container above`));
313
- write(dim(`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`));
372
+ writeAll(hints.map((h) => dim(h)));
314
373
  }
315
374
  }
316
375
  function engineRows(usage) {
@@ -382,13 +441,16 @@ function projectRows(projects, loose) {
382
441
  // Containers whose session directory is gone, and faker's, which are
383
442
  // labelled the same way and belong to no project at all.
384
443
  const size = loose.reduce((n, c) => n + (c.size ?? 0), 0);
444
+ const fakers = loose.filter((c) => c.key?.startsWith('faker-')).length;
385
445
  rows.push([
386
446
  dim('(unclaimed)'),
387
447
  dim('—'),
388
448
  dim('—'),
389
449
  dim(String(loose.length)),
390
450
  dim(bytes(size)),
391
- dim('no session owns these'),
451
+ dim(fakers === loose.length
452
+ ? `zen faker ${fakers === 1 ? 'server' : 'servers'}, no project`
453
+ : 'no session owns these'),
392
454
  ]);
393
455
  }
394
456
  const total = (pick) => projects.reduce((n, p) => n + pick(p), 0);
package/dist/engine.d.ts CHANGED
@@ -24,6 +24,11 @@ export interface EngineOptions {
24
24
  * complaint is a worse report than one.
25
25
  */
26
26
  quiet?: boolean;
27
+ /**
28
+ * The session will never be resumed, so its containers are removed on close
29
+ * whatever `persist:` says. A stopped one would only hold an engine lock.
30
+ */
31
+ disposable?: boolean;
27
32
  }
28
33
  export interface Engine {
29
34
  project: AgentProject;
package/dist/engine.js CHANGED
@@ -64,6 +64,7 @@ export async function open(opts) {
64
64
  keys: opts.keys,
65
65
  env: env?.names,
66
66
  mounts,
67
+ ...(opts.disposable ? { persist: false } : {}),
67
68
  });
68
69
  const workspaceOptions = {
69
70
  root: workspace,
@@ -0,0 +1,59 @@
1
+ import { type AgentEvent } from '@zenera/neo';
2
+ export type EventBody = {
3
+ type: 'llm';
4
+ model: string;
5
+ in: number;
6
+ cached: number;
7
+ out: number;
8
+ reasoning: number;
9
+ ms?: number;
10
+ } | {
11
+ type: 'tool';
12
+ phase: 'start';
13
+ id: string;
14
+ name: string;
15
+ subject: string;
16
+ } | {
17
+ type: 'tool';
18
+ phase: 'end';
19
+ id: string;
20
+ name: string;
21
+ ok: boolean;
22
+ ms?: number;
23
+ } | {
24
+ type: 'say';
25
+ text: string;
26
+ };
27
+ export type EventLine = EventBody & {
28
+ t: string;
29
+ };
30
+ /** A line that cannot be written is dropped: watching a run must not break it. */
31
+ export declare function eventLog(path: string): (body: EventBody) => void;
32
+ /** For `zen run`: the runtime's own events, folded to the shared lines. */
33
+ export declare function agentEventLog(path: string): (event: AgentEvent) => void;
34
+ /** One line of copilot's JSONL, as much of it as is read here. */
35
+ export interface CopilotEvent {
36
+ type: string;
37
+ data?: Record<string, unknown>;
38
+ }
39
+ /** A model call read off copilot's span file - see `meta/tokens.ts`. */
40
+ export interface CopilotCall {
41
+ model: string;
42
+ usage: {
43
+ inputTokens: number;
44
+ cachedInputTokens: number;
45
+ outputTokens: number;
46
+ reasoningTokens: number;
47
+ };
48
+ durationMs: number;
49
+ }
50
+ /**
51
+ * For `zen meta run`: copilot's events and spans, folded to the same lines.
52
+ * Its JSONL has no token counts, so `llm` lines come from the spans instead,
53
+ * about a second after each call.
54
+ */
55
+ export declare function copilotEventLog(path: string): {
56
+ event: (event: CopilotEvent) => void;
57
+ call: (call: CopilotCall) => void;
58
+ };
59
+ //# sourceMappingURL=eventlog.d.ts.map