@zenera/cli 1.1.0 → 1.1.2

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/dist/podman.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { runProcess } from '@zenera/neo';
2
+ import type { ResolvedBuild } from './image.ts';
3
+ import { CliError } from './term.ts';
2
4
  export interface PodmanOptions {
3
5
  /** the image the project needs on disk before the first run */
4
6
  image?: string;
7
+ /** a Dockerfile to build into `image`, instead of a registry to pull it from */
8
+ build?: ResolvedBuild;
9
+ /** build even when the tag is already on disk; `zen sandbox pull` */
10
+ rebuild?: boolean;
5
11
  /** machine size, when one has to be created */
6
12
  cpus?: number;
7
13
  /** MiB */
@@ -28,12 +34,36 @@ export interface PodmanStatus {
28
34
  imagePresent?: boolean;
29
35
  }
30
36
  export declare function ensurePodmanReady(opts?: PodmanOptions): Promise<void>;
37
+ /**
38
+ * Builds the project's Dockerfile under its tag.
39
+ *
40
+ * Run every time rather than skipped when the tag already exists, because the
41
+ * tag is a hash of the Dockerfile and its context and the engine's layer cache
42
+ * is a hash of the same thing plus the base image. Asking it is cheap, and it
43
+ * is the only thing that notices when `FROM node:24` starts meaning a different
44
+ * node:24.
45
+ */
46
+ /**
47
+ * A build that ran and failed, rather than a host that could not be asked.
48
+ *
49
+ * The difference is invisible at the command line — both stop the run and
50
+ * print why — but it decides everything for `zen check`: a Dockerfile that
51
+ * does not build is a broken project, and a laptop without podman on it is
52
+ * not. Only one of them is an error.
53
+ */
54
+ export declare class BuildError extends CliError {
55
+ }
31
56
  /** What `zn sandbox status` prints. Changes nothing, and never throws. */
32
57
  export declare function podmanStatus(opts?: PodmanOptions): Promise<PodmanStatus>;
33
58
  export interface OwnedContainer {
34
59
  name: string;
35
60
  /** podman's own word: `running`, `exited`, `created`, `paused` */
36
61
  state: string;
62
+ /** the session that owns it, from the `zenera.key` label */
63
+ key?: string;
64
+ createdAt?: string;
65
+ /** bytes written on top of the image; only present when `sizes` is asked for */
66
+ size?: number;
37
67
  }
38
68
  /**
39
69
  * Containers this CLI created, whatever session they belong to, and whether
@@ -41,6 +71,32 @@ export interface OwnedContainer {
41
71
  * *stopped* container behind, and a listing that only showed running ones
42
72
  * would say nothing is there while the disk says otherwise.
43
73
  */
44
- export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner): Promise<OwnedContainer[]>;
74
+ export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner, opts?: {
75
+ sizes?: boolean;
76
+ }): Promise<OwnedContainer[]>;
45
77
  export declare function removeContainers(names: readonly string[], engine?: string, exec?: import("@zenera/neo").Runner): Promise<void>;
78
+ export interface DiskLine {
79
+ count: number;
80
+ active: number;
81
+ size: number;
82
+ reclaimable: number;
83
+ }
84
+ export interface EngineDisk {
85
+ images: DiskLine;
86
+ containers: DiskLine;
87
+ volumes: DiskLine;
88
+ /** the filesystem images and layers are kept on — inside the machine, if there is one */
89
+ store?: {
90
+ used: number;
91
+ capacity: number;
92
+ };
93
+ /** the machine's disk image, as it costs this host; absent on Linux */
94
+ image?: {
95
+ name: string;
96
+ path: string;
97
+ allocated: number;
98
+ };
99
+ }
100
+ /** What the engine is using. Never throws: a disk report is not worth a crash. */
101
+ export declare function engineDisk(engine?: string, exec?: import("@zenera/neo").Runner): Promise<EngineDisk | undefined>;
46
102
  //# sourceMappingURL=podman.d.ts.map
package/dist/podman.js CHANGED
@@ -1,10 +1,14 @@
1
- import { platform } from 'node:os';
2
1
  import { runProcess, SandboxError } from '@zenera/neo';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { join } from 'node:path';
3
5
  import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
4
6
  const DEFAULT_MACHINE_CPUS = 2;
5
7
  const DEFAULT_MACHINE_MEMORY = 2048;
6
8
  /** Starting a virtual machine and pulling an image are both slow on purpose. */
7
9
  const SLOW_MS = 600_000;
10
+ /** ...and a build from a cold cache is slower than either. */
11
+ const BUILD_MS = 900_000;
8
12
  // One process asks once. Several agents starting containers in the same run
9
13
  // must not each decide to boot a virtual machine.
10
14
  const settled = new Map();
@@ -62,7 +66,10 @@ async function preflight(opts) {
62
66
  }
63
67
  // 4. The image, so the first command is not a five-minute pull that looks
64
68
  // like a hung model.
65
- if (opts.image) {
69
+ if (opts.build) {
70
+ await build(engine, opts.build, run, opts.rebuild);
71
+ }
72
+ else if (opts.image) {
66
73
  const present = await call(['image', 'exists', opts.image], 30_000);
67
74
  if (present.code !== 0) {
68
75
  note(dim(`pulling ${opts.image} — this happens once`));
@@ -158,6 +165,52 @@ function parseMachines(stdout) {
158
165
  }
159
166
  }
160
167
  // ---------------------------------------------------------------------------
168
+ // The image
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Builds the project's Dockerfile under its tag.
172
+ *
173
+ * Run every time rather than skipped when the tag already exists, because the
174
+ * tag is a hash of the Dockerfile and its context and the engine's layer cache
175
+ * is a hash of the same thing plus the base image. Asking it is cheap, and it
176
+ * is the only thing that notices when `FROM node:24` starts meaning a different
177
+ * node:24.
178
+ */
179
+ /**
180
+ * A build that ran and failed, rather than a host that could not be asked.
181
+ *
182
+ * The difference is invisible at the command line — both stop the run and
183
+ * print why — but it decides everything for `zen check`: a Dockerfile that
184
+ * does not build is a broken project, and a laptop without podman on it is
185
+ * not. Only one of them is an error.
186
+ */
187
+ export class BuildError extends CliError {
188
+ }
189
+ /**
190
+ * Builds the project's Dockerfile under its tag, unless that tag is already on
191
+ * disk.
192
+ *
193
+ * Skipping is safe here in a way it would not be for an ordinary tag, because
194
+ * this one is a hash of the Dockerfile and its context: the image existing
195
+ * *means* the content is unchanged. What it does not cover is a moved base
196
+ * image — `podman build` defaults to `--pull=missing` and reuses whatever
197
+ * `FROM node:24` resolved to last time — so `zen sandbox pull` forces the
198
+ * build, and `--pull` is where that would be fixed if it ever needs to be.
199
+ */
200
+ async function build(engine, spec, run, force = false) {
201
+ if (!force) {
202
+ const present = await run(engine, ['image', 'exists', spec.tag], { timeoutMs: 30_000 });
203
+ if (present.code === 0) {
204
+ return;
205
+ }
206
+ }
207
+ note(dim(`building ${spec.tag} from ${spec.dockerfile}`));
208
+ const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
209
+ if (built.code !== 0) {
210
+ throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
161
214
  // Reporting
162
215
  // ---------------------------------------------------------------------------
163
216
  /** What `zn sandbox status` prints. Changes nothing, and never throws. */
@@ -210,19 +263,41 @@ function safeMachines(stdout) {
210
263
  * *stopped* container behind, and a listing that only showed running ones
211
264
  * would say nothing is there while the disk says otherwise.
212
265
  */
213
- export async function ownedContainers(engine = 'podman', exec = runProcess) {
214
- const res = await exec(engine, ['ps', '--all', '--filter', 'label=zenera=1', '--format', '{{.Names}}\t{{.State}}'], { timeoutMs: 30_000 }).catch(() => undefined);
266
+ export async function ownedContainers(engine = 'podman', exec = runProcess, opts = {}) {
267
+ const args = ['ps', '--all', '--filter', 'label=zenera=1', '--format', 'json'];
268
+ // Asked for by name only: podman works a size out by diffing the layer,
269
+ // which costs more than everything else `status` does put together.
270
+ if (opts.sizes) {
271
+ args.push('--size');
272
+ }
273
+ const res = await exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
215
274
  if (!res || res.code !== 0) {
216
275
  return [];
217
276
  }
218
- return res.stdout
219
- .split('\n')
220
- .map((l) => l.trim())
221
- .filter(Boolean)
222
- .map((line) => {
223
- const [name, state] = line.split('\t');
224
- return { name, state: state?.trim() || 'unknown' };
225
- });
277
+ return parseContainers(res.stdout);
278
+ }
279
+ /** Newest first, which is the order someone reading a list of them wants. */
280
+ function parseContainers(stdout) {
281
+ let raw;
282
+ try {
283
+ raw = JSON.parse(stdout.trim() || '[]');
284
+ }
285
+ catch {
286
+ return [];
287
+ }
288
+ if (!Array.isArray(raw)) {
289
+ return [];
290
+ }
291
+ return raw
292
+ .map((c) => ({
293
+ name: c.Names?.[0] ?? '',
294
+ state: c.State?.trim() || 'unknown',
295
+ key: c.Labels?.['zenera.key'],
296
+ createdAt: c.Created ? new Date(c.Created * 1000).toISOString() : undefined,
297
+ size: c.Size?.rwSize,
298
+ }))
299
+ .filter((c) => c.name)
300
+ .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
226
301
  }
227
302
  export async function removeContainers(names, engine = 'podman', exec = runProcess) {
228
303
  if (names.length === 0) {
@@ -230,6 +305,91 @@ export async function removeContainers(names, engine = 'podman', exec = runProce
230
305
  }
231
306
  await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
232
307
  }
308
+ /** What the engine is using. Never throws: a disk report is not worth a crash. */
309
+ export async function engineDisk(engine = 'podman', exec = runProcess) {
310
+ const call = (args) => exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
311
+ const res = await call(['system', 'df', '--format', 'json']);
312
+ if (!res || res.code !== 0) {
313
+ return undefined;
314
+ }
315
+ let raw;
316
+ try {
317
+ raw = JSON.parse(res.stdout.trim() || '[]');
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ const rows = Array.isArray(raw) ? raw : [];
323
+ const pick = (type) => {
324
+ const row = rows.find((r) => r.Type === type);
325
+ return {
326
+ count: row?.TotalCount ?? row?.Total ?? 0,
327
+ active: row?.Active ?? 0,
328
+ size: row?.RawSize ?? 0,
329
+ reclaimable: row?.RawReclaimable ?? 0,
330
+ };
331
+ };
332
+ const info = await call([
333
+ 'info',
334
+ '--format',
335
+ '{{.Store.GraphRootUsed}}\t{{.Store.GraphRootAllocated}}',
336
+ ]);
337
+ const [used, capacity] = (info?.code === 0 ? info.stdout.trim() : '').split('\t').map(Number);
338
+ return {
339
+ images: pick('Images'),
340
+ containers: pick('Containers'),
341
+ volumes: pick('Local Volumes'),
342
+ store: used > 0 && capacity > 0 ? { used, capacity } : undefined,
343
+ image: platform() === 'linux' ? undefined : await machineImage(engine, exec),
344
+ };
345
+ }
346
+ /**
347
+ * What the machine costs the host, which is not what it says it costs: the
348
+ * disk image is created sparse at its full size, so only its allocated blocks
349
+ * are real, and blocks freed inside the machine are not handed back until
350
+ * something trims them. That is why this can exceed the machine's own `used`.
351
+ *
352
+ * The path is not something podman will tell us — `machine inspect` stopped
353
+ * carrying it — so this is the documented default location and nothing is
354
+ * reported when the file is not there.
355
+ */
356
+ async function machineImage(engine, exec) {
357
+ const listed = await exec(engine, ['machine', 'list', '--format', 'json'], {
358
+ timeoutMs: 30_000,
359
+ }).catch(() => undefined);
360
+ if (!listed || listed.code !== 0) {
361
+ return undefined;
362
+ }
363
+ const machines = safeMachines(listed.stdout);
364
+ const name = (machines.find((m) => m.Default) ?? machines[0])?.Name;
365
+ if (!name) {
366
+ return undefined;
367
+ }
368
+ const base = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'containers', 'podman', 'machine');
369
+ for (const provider of children(base)) {
370
+ for (const file of children(join(base, provider))) {
371
+ if (!file.startsWith(name) || !/\.(raw|qcow2|img)$/.test(file)) {
372
+ continue;
373
+ }
374
+ const path = join(base, provider, file);
375
+ try {
376
+ return { name, path, allocated: statSync(path).blocks * 512 };
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ }
382
+ }
383
+ return undefined;
384
+ }
385
+ function children(dir) {
386
+ try {
387
+ return readdirSync(dir);
388
+ }
389
+ catch {
390
+ return [];
391
+ }
392
+ }
233
393
  // ---------------------------------------------------------------------------
234
394
  /**
235
395
  * Long steps print as they go. A five-minute pull with no output is
@@ -248,6 +408,11 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
248
408
  function first(res) {
249
409
  return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
250
410
  }
411
+ /** A build says what went wrong on its last line, not its first. */
412
+ function last(res) {
413
+ const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
414
+ return lines.slice(-2).join(' — ');
415
+ }
251
416
  function sandboxError(message, hint) {
252
417
  return new CliError(message, EXIT.sandbox, hint);
253
418
  }
@@ -1,3 +1,4 @@
1
+ import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
1
2
  /** Whether a directory is a project: whether the loader has something to read. */
2
3
  export declare function isProjectDir(dir: string): boolean;
3
4
  /** A project resolved on disk. */
@@ -6,6 +7,16 @@ export interface Project {
6
7
  /** what it is called: the registry's name, or the directory's own */
7
8
  name: string;
8
9
  }
10
+ /**
11
+ * The trees a run mounts besides the workspace itself.
12
+ *
13
+ * One array, handed to the file tools and to the container both, because the
14
+ * two have to agree on the name: a path `run_command` prints has to be a path
15
+ * `read_file` takes. Everything here is read-only — material an agent consults
16
+ * and does not edit — and the paths are resolved, because the podman machine on
17
+ * macOS shares the real path or nothing.
18
+ */
19
+ export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
9
20
  /** Walks up from `start` looking for a project configuration. */
10
21
  export declare function findUp(start: string): Promise<Project | undefined>;
11
22
  export declare function openDir(dir: string): Promise<Project>;
@@ -59,6 +70,13 @@ export declare const sessionsDir: (projectDir: string) => string;
59
70
  /** Session ids, oldest first. Only well-formed stamps count as sessions. */
60
71
  export declare function sessionIds(projectDir: string): string[];
61
72
  export declare function runIds(sessionDir: string): string[];
73
+ /**
74
+ * What a tree occupies, in allocated blocks rather than in bytes, because a
75
+ * sparse file costs what it was given and not what it claims. Symlinks are
76
+ * counted and never followed: a link out of the tree is not part of it, and
77
+ * one that points back in would otherwise be counted twice — or forever.
78
+ */
79
+ export declare function dirSize(dir: string): number;
62
80
  /** True when a session's lock names a process that is still alive. */
63
81
  export declare function isBusy(sessionDir: string): boolean;
64
82
  /**
package/dist/projects.js CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
4
  import { paths, readJson, writeJson } from "./home.js";
4
5
  import { isStamp } from "./ids.js";
@@ -25,6 +26,29 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
25
26
  export function isProjectDir(dir) {
26
27
  return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
27
28
  }
29
+ /**
30
+ * The trees a run mounts besides the workspace itself.
31
+ *
32
+ * One array, handed to the file tools and to the container both, because the
33
+ * two have to agree on the name: a path `run_command` prints has to be a path
34
+ * `read_file` takes. Everything here is read-only — material an agent consults
35
+ * and does not edit — and the paths are resolved, because the podman machine on
36
+ * macOS shares the real path or nothing.
37
+ */
38
+ export function projectMounts(root, config) {
39
+ const mounts = [];
40
+ const assets = assetsDir(root, config);
41
+ if (assets) {
42
+ mounts.push({ host: realpathSync(assets), at: ASSETS_MOUNT, readOnly: true });
43
+ }
44
+ // The catalog goes in whole, at fixed names, before anything is loaded: a
45
+ // container's mounts are decided when it is created, so a skill folder
46
+ // cannot be added at the moment `skill_load` asks for it.
47
+ for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
48
+ mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
49
+ }
50
+ return mounts;
51
+ }
28
52
  // ---------------------------------------------------------------------------
29
53
  // The project directory
30
54
  // ---------------------------------------------------------------------------
@@ -201,6 +225,41 @@ function stampedChildren(dir) {
201
225
  .map((e) => e.name)
202
226
  .sort();
203
227
  }
228
+ /**
229
+ * What a tree occupies, in allocated blocks rather than in bytes, because a
230
+ * sparse file costs what it was given and not what it claims. Symlinks are
231
+ * counted and never followed: a link out of the tree is not part of it, and
232
+ * one that points back in would otherwise be counted twice — or forever.
233
+ */
234
+ export function dirSize(dir) {
235
+ let total = 0;
236
+ const stack = [dir];
237
+ while (stack.length > 0) {
238
+ const at = stack.pop();
239
+ let entries;
240
+ try {
241
+ entries = readdirSync(at, { withFileTypes: true });
242
+ }
243
+ catch {
244
+ continue;
245
+ }
246
+ for (const entry of entries) {
247
+ const path = join(at, entry.name);
248
+ if (entry.isDirectory()) {
249
+ stack.push(path);
250
+ continue;
251
+ }
252
+ try {
253
+ total += lstatSync(path).blocks * 512;
254
+ }
255
+ catch {
256
+ // Gone between the listing and the stat: a live session is
257
+ // writing, which is normal and not worth failing a report for.
258
+ }
259
+ }
260
+ }
261
+ return total;
262
+ }
204
263
  /** True when a session's lock names a process that is still alive. */
205
264
  export function isBusy(sessionDir) {
206
265
  const path = join(sessionDir, '.lock');
package/dist/sandbox.d.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxSpec } from '@zenera/neo';
1
+ import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
2
+ import { type ResolvedBuild } from './image.ts';
2
3
  import type { SessionPaths } from './session.ts';
3
4
  export interface SandboxSetup {
4
5
  pool: SandboxPool;
5
6
  /** the resolved base spec, for `zn sandbox status` and the image pre-warm */
6
7
  spec: SandboxSpec;
7
8
  image: string;
9
+ /** the Dockerfile behind `image`, when it came from one */
10
+ build?: ResolvedBuild;
8
11
  /** host side of the container's `$HOME`, created only if it is ever needed */
9
12
  home: string;
10
13
  }
@@ -12,7 +15,15 @@ export interface SandboxInputs {
12
15
  config: ProjectConfig;
13
16
  session: SessionPaths;
14
17
  workspace: string;
18
+ /** the project directory, which `build:` paths are relative to */
19
+ root: string;
15
20
  readOnly?: boolean;
21
+ /**
22
+ * Trees to mount besides the workspace and the home — the project's assets
23
+ * and its skill catalog. The same array goes to the file tools, so a path
24
+ * a command prints is a path `read_file` accepts.
25
+ */
26
+ mounts?: readonly SandboxMount[];
16
27
  /** `--image` */
17
28
  image?: string;
18
29
  }
package/dist/sandbox.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
1
2
  import { mkdirSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
- import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
4
+ import { resolveBuild } from "./image.js";
4
5
  import { ensurePodmanReady } from "./podman.js";
5
6
  import { warn } from "./term.js";
6
7
  // ---------------------------------------------------------------------------
@@ -23,14 +24,25 @@ import { warn } from "./term.js";
23
24
  /** Where the persistent home lives, inside the container. */
24
25
  const HOME = '/home/agent';
25
26
  export function buildSandbox(opts) {
26
- const base = { ...(opts.config.sandbox ?? {}), ...(opts.image ? { image: opts.image } : {}) };
27
+ // An explicit --image is an answer, so there is nothing left to build.
28
+ const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
29
+ const base = {
30
+ ...(opts.config.sandbox ?? {}),
31
+ ...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
32
+ };
27
33
  const home = join(opts.session.data, 'sandbox', 'home');
28
- const mounts = [{ host: home, at: HOME }];
29
- const spec = toSpec(base, { HOME });
34
+ const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
35
+ // Skills and assets are mounted read-only, and a python script run from a
36
+ // read-only directory fails on writing its own `__pycache__` — a confusing
37
+ // error about a file nobody asked for.
38
+ const spec = toSpec(base, { HOME, PYTHONDONTWRITEBYTECODE: '1' });
30
39
  const agents = {};
31
40
  for (const agent of opts.config.agents) {
32
41
  if (agent.sandbox) {
33
- agents[agent.name] = toSpec({ ...base, ...agent.sandbox }, { HOME });
42
+ agents[agent.name] = toSpec(merge(base, agent.sandbox), {
43
+ HOME,
44
+ PYTHONDONTWRITEBYTECODE: '1',
45
+ });
34
46
  }
35
47
  }
36
48
  const pool = new SandboxPool({
@@ -41,7 +53,23 @@ export function buildSandbox(opts) {
41
53
  readOnly: opts.readOnly,
42
54
  mounts,
43
55
  });
44
- return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, home };
56
+ return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
57
+ }
58
+ /**
59
+ * An agent's overrides on the project's block. `image` and `build` answer the
60
+ * same question, so naming either one drops the other: a plain spread would
61
+ * leave an agent's `image` sitting next to the project's `build`, and the
62
+ * schema forbids exactly that combination when it is written down.
63
+ */
64
+ function merge(base, agent) {
65
+ const merged = { ...base, ...agent };
66
+ if (agent.image !== undefined) {
67
+ delete merged.build;
68
+ }
69
+ if (agent.build !== undefined) {
70
+ delete merged.image;
71
+ }
72
+ return merged;
45
73
  }
46
74
  /**
47
75
  * Config names a variable; this reads it. A name that is not set on this host
@@ -87,6 +115,7 @@ export async function preflight(setup, yes) {
87
115
  mkdirSync(setup.home, { recursive: true });
88
116
  await ensurePodmanReady({
89
117
  image: setup.image,
118
+ build: setup.build,
90
119
  cpus: setup.spec.cpus,
91
120
  memory: setup.spec.memory,
92
121
  yes,
@@ -11,6 +11,12 @@ export declare function editorSettings(dir: string): string;
11
11
  * one is the only one worth having. Returns the relative paths written.
12
12
  */
13
13
  export declare function copilotInstructions(dir: string): string[];
14
+ /**
15
+ * Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
16
+ * the editor files this becomes the project's own — it is meant to be edited —
17
+ * so anything already there is left alone.
18
+ */
19
+ export declare function sandboxTemplate(dir: string): string[];
14
20
  export interface ScaffoldOptions {
15
21
  /** the project directory */
16
22
  dir: string;
package/dist/scaffold.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // ---------------------------------------------------------------------------
@@ -65,14 +65,26 @@ version: 1
65
65
 
66
66
  ${MODEL_SECTION(model, options)}
67
67
 
68
- # The container \`sandbox:*\` commands run in. \`persist: true\` keeps it between
69
- # runs instead of throwing it away, so what the agent installs is still there
70
- # next time otherwise only /workspace and its home directory survive, and an
71
- # \`apt-get\` or a root \`pip install\` is repeated on every run. \`zen sandbox
68
+ # Anything in assets/ is mounted read-only at /assets for every agent: they can
69
+ # read, list and search it, and no tool can change it. It is a convention, so
70
+ # the folder is enough set \`assets: <path>\` only to keep the material
71
+ # somewhere else in this project.
72
+
73
+ # The container \`sandbox:*\` commands run in. \`build:\` names a Dockerfile to
74
+ # build instead of an image to pull, so anything this project always needs is
75
+ # in the image rather than installed by an agent on every run — put it in
76
+ # sandbox/Dockerfile. Swap the whole block for \`image: <ref>\` to pull a
77
+ # published one instead; the two cannot both be set.
78
+ #
79
+ # \`persist: true\` keeps the container between runs rather than throwing it
80
+ # away, so what an agent installs for itself is still there next time —
81
+ # otherwise only /workspace and its home directory survive. \`zen sandbox
72
82
  # clean\` removes the ones left behind. Everything else has a default; see the
73
- # sandbox: block in docs/agents-yaml.md to size or pin the image.
83
+ # sandbox: block in docs/agents-yaml.md to size it.
74
84
  sandbox:
75
85
  persist: true
86
+ build:
87
+ dockerfile: sandbox/Dockerfile
76
88
 
77
89
  agents:
78
90
  - name: default
@@ -111,6 +123,19 @@ const GITIGNORE = `# Sessions hold run state, memory, blobs and whatever the age
111
123
  # None of it is source.
112
124
  sessions/
113
125
  `;
126
+ const ASSETS_README = `# assets
127
+
128
+ Everything in this folder is mounted at /assets when an agent runs. Every agent
129
+ in this project can read, list and search it, and no tool of theirs can change
130
+ it — so this is where reference material goes: handbooks, specifications,
131
+ schemas, worked examples, the style guide the output is supposed to follow.
132
+
133
+ It is the project's own files that agents get without being asked. The
134
+ workspace they are pointed at is the work; this is what they consult while
135
+ doing it.
136
+
137
+ Delete this file once there is something here to read.
138
+ `;
114
139
  // ---------------------------------------------------------------------------
115
140
  // Telling the editor which instructions are not for it
116
141
  //
@@ -163,6 +188,7 @@ export function editorSettings(dir) {
163
188
  // adding a skill or a prompt file is adding a file there and nothing else.
164
189
  // ---------------------------------------------------------------------------
165
190
  const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.meta.url));
191
+ const SANDBOX_TEMPLATE = fileURLToPath(new URL('../templates/sandbox', import.meta.url));
166
192
  /**
167
193
  * Writes the `.github/` tree under `dir`, replacing what is there — it
168
194
  * describes the file formats of the version of `zen` in hand, so the current
@@ -171,11 +197,19 @@ const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.met
171
197
  export function copilotInstructions(dir) {
172
198
  return copyTree(GITHUB_TEMPLATE, dir, '.github');
173
199
  }
200
+ /**
201
+ * Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
202
+ * the editor files this becomes the project's own — it is meant to be edited —
203
+ * so anything already there is left alone.
204
+ */
205
+ export function sandboxTemplate(dir) {
206
+ return copyTree(SANDBOX_TEMPLATE, dir, 'sandbox', { keep: true });
207
+ }
174
208
  /**
175
209
  * Copies one template directory into `dir` at `rel`, depth first, sorted so
176
210
  * the list it returns is the same on every machine.
177
211
  */
178
- function copyTree(from, dir, rel) {
212
+ function copyTree(from, dir, rel, opts) {
179
213
  const written = [];
180
214
  mkdirSync(join(dir, rel), { recursive: true });
181
215
  const entries = readdirSync(from, { withFileTypes: true });
@@ -183,12 +217,14 @@ function copyTree(from, dir, rel) {
183
217
  for (const entry of entries) {
184
218
  const child = join(rel, entry.name);
185
219
  if (entry.isDirectory()) {
186
- written.push(...copyTree(join(from, entry.name), dir, child));
220
+ written.push(...copyTree(join(from, entry.name), dir, child, opts));
221
+ continue;
187
222
  }
188
- else {
189
- writeFileSync(join(dir, child), readFileSync(join(from, entry.name)));
190
- written.push(child);
223
+ if (opts?.keep && existsSync(join(dir, child))) {
224
+ continue;
191
225
  }
226
+ writeFileSync(join(dir, child), readFileSync(join(from, entry.name)));
227
+ written.push(child);
192
228
  }
193
229
  return written;
194
230
  }
@@ -211,7 +247,9 @@ export function scaffold(opts) {
211
247
  put('INSTRUCTIONS.md', INSTRUCTIONS_MD);
212
248
  put('agents.yaml', AGENTS_YAML(opts.model, opts.modelOptions, opts.web));
213
249
  put(join('agents', 'prompts', 'default.md'), PROMPT);
250
+ put(join('assets', 'README.md'), ASSETS_README);
214
251
  put('.gitignore', GITIGNORE);
252
+ written.push(...sandboxTemplate(opts.dir));
215
253
  // The project directory is what `zen open` opens, so this is where the
216
254
  // editor actually reads them.
217
255
  written.push(editorSettings(opts.dir), ...copilotInstructions(opts.dir));
package/dist/term.d.ts CHANGED
@@ -66,4 +66,6 @@ export declare function choose<T>(title: string, choices: readonly Choice<T>[]):
66
66
  export declare function readStdin(): Promise<string | undefined>;
67
67
  export declare function ago(iso: string | undefined): string;
68
68
  export declare function count(n: number, singular: string, plural?: string): string;
69
+ /** Powers of 1000, as the container engine prints them, so the two agree. */
70
+ export declare function bytes(n: number): string;
69
71
  //# sourceMappingURL=term.d.ts.map