@zenera/cli 1.1.0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/dist/args.d.ts +40 -0
  4. package/dist/args.js +99 -0
  5. package/dist/audit.d.ts +53 -0
  6. package/dist/audit.js +144 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +103 -0
  9. package/dist/command.d.ts +14 -0
  10. package/dist/command.js +12 -0
  11. package/dist/commands/check.d.ts +3 -0
  12. package/dist/commands/check.js +287 -0
  13. package/dist/commands/index.d.ts +22 -0
  14. package/dist/commands/index.js +56 -0
  15. package/dist/commands/init.d.ts +3 -0
  16. package/dist/commands/init.js +157 -0
  17. package/dist/commands/inspect.d.ts +3 -0
  18. package/dist/commands/inspect.js +158 -0
  19. package/dist/commands/key.d.ts +3 -0
  20. package/dist/commands/key.js +335 -0
  21. package/dist/commands/list.d.ts +3 -0
  22. package/dist/commands/list.js +101 -0
  23. package/dist/commands/models.d.ts +9 -0
  24. package/dist/commands/models.js +120 -0
  25. package/dist/commands/open.d.ts +9 -0
  26. package/dist/commands/open.js +270 -0
  27. package/dist/commands/run.d.ts +3 -0
  28. package/dist/commands/run.js +167 -0
  29. package/dist/commands/sandbox.d.ts +3 -0
  30. package/dist/commands/sandbox.js +112 -0
  31. package/dist/commands/version.d.ts +6 -0
  32. package/dist/commands/version.js +39 -0
  33. package/dist/engine.d.ts +49 -0
  34. package/dist/engine.js +208 -0
  35. package/dist/external.d.ts +10 -0
  36. package/dist/external.js +56 -0
  37. package/dist/home.d.ts +31 -0
  38. package/dist/home.js +108 -0
  39. package/dist/ids.d.ts +12 -0
  40. package/dist/ids.js +44 -0
  41. package/dist/keys.d.ts +124 -0
  42. package/dist/keys.js +309 -0
  43. package/dist/lib.d.ts +9 -0
  44. package/dist/lib.js +31 -0
  45. package/dist/liveness.d.ts +23 -0
  46. package/dist/liveness.js +221 -0
  47. package/dist/main.d.ts +3 -0
  48. package/dist/main.js +155 -0
  49. package/dist/narrate.d.ts +19 -0
  50. package/dist/narrate.js +124 -0
  51. package/dist/podman.d.ts +46 -0
  52. package/dist/podman.js +254 -0
  53. package/dist/projects.d.ts +70 -0
  54. package/dist/projects.js +232 -0
  55. package/dist/resolve.d.ts +27 -0
  56. package/dist/resolve.js +138 -0
  57. package/dist/sandbox.d.ts +36 -0
  58. package/dist/sandbox.js +104 -0
  59. package/dist/scaffold.d.ts +29 -0
  60. package/dist/scaffold.js +220 -0
  61. package/dist/session.d.ts +77 -0
  62. package/dist/session.js +156 -0
  63. package/dist/term.d.ts +69 -0
  64. package/dist/term.js +242 -0
  65. package/dist/tui/app.d.ts +8 -0
  66. package/dist/tui/app.js +257 -0
  67. package/dist/tui/theme.d.ts +23 -0
  68. package/dist/tui/theme.js +134 -0
  69. package/dist/tui/wrap.d.ts +12 -0
  70. package/dist/tui/wrap.js +62 -0
  71. package/dist/validate.d.ts +145 -0
  72. package/dist/validate.js +959 -0
  73. package/package.json +76 -0
  74. package/templates/.github/copilot-instructions.md +1579 -0
  75. package/templates/.github/prompts/new-agent.prompt.md +38 -0
  76. package/templates/.github/prompts/new-skill.prompt.md +37 -0
  77. package/templates/.github/prompts/review-project.prompt.md +31 -0
  78. package/templates/.github/skills/zen-cli/SKILL.md +110 -0
package/dist/podman.js ADDED
@@ -0,0 +1,254 @@
1
+ import { platform } from 'node:os';
2
+ import { runProcess, SandboxError } from '@zenera/neo';
3
+ import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
4
+ const DEFAULT_MACHINE_CPUS = 2;
5
+ const DEFAULT_MACHINE_MEMORY = 2048;
6
+ /** Starting a virtual machine and pulling an image are both slow on purpose. */
7
+ const SLOW_MS = 600_000;
8
+ // One process asks once. Several agents starting containers in the same run
9
+ // must not each decide to boot a virtual machine.
10
+ const settled = new Map();
11
+ export async function ensurePodmanReady(opts = {}) {
12
+ const key = `${opts.engine ?? 'podman'}::${opts.image ?? ''}`;
13
+ const pending = settled.get(key);
14
+ if (pending) {
15
+ return pending;
16
+ }
17
+ const attempt = preflight(opts).catch((err) => {
18
+ // A failure is not a settled answer: the user may well go and install
19
+ // the thing we just complained about and try again in the same TUI.
20
+ settled.delete(key);
21
+ throw err;
22
+ });
23
+ settled.set(key, attempt);
24
+ return attempt;
25
+ }
26
+ async function preflight(opts) {
27
+ const engine = opts.engine ?? 'podman';
28
+ const run = opts.exec ?? runProcess;
29
+ const call = async (args, timeoutMs = 60_000) => {
30
+ try {
31
+ return await run(engine, args, { timeoutMs });
32
+ }
33
+ catch (err) {
34
+ if (err instanceof SandboxError) {
35
+ return {
36
+ code: 127,
37
+ stdout: '',
38
+ stderr: err.message,
39
+ truncated: false,
40
+ timedOut: false,
41
+ };
42
+ }
43
+ throw err;
44
+ }
45
+ };
46
+ // 1. The binary.
47
+ const version = await call([`--version`], 15_000);
48
+ if (version.code !== 0) {
49
+ await install(engine, opts, run);
50
+ }
51
+ // 2. The virtual machine, on the platforms that have one. Linux runs
52
+ // containers natively and has no machine to list, so asking would fail
53
+ // with a message about an unknown command rather than about anything
54
+ // true.
55
+ if (platform() !== 'linux') {
56
+ await machine(engine, call, opts, run);
57
+ }
58
+ // 3. The socket. Everything above can be true while the engine is wedged.
59
+ const info = await call(['info'], 60_000);
60
+ if (info.code !== 0) {
61
+ throw sandboxError(`${engine} is installed but not responding`, first(info) || `try: ${engine} machine start`);
62
+ }
63
+ // 4. The image, so the first command is not a five-minute pull that looks
64
+ // like a hung model.
65
+ if (opts.image) {
66
+ const present = await call(['image', 'exists', opts.image], 30_000);
67
+ if (present.code !== 0) {
68
+ note(dim(`pulling ${opts.image} — this happens once`));
69
+ const pulled = await stream(engine, ['pull', opts.image], run);
70
+ if (pulled.code !== 0) {
71
+ throw sandboxError(`could not pull ${opts.image}`, first(pulled) || 'check the image name and your network');
72
+ }
73
+ }
74
+ }
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Installing
78
+ // ---------------------------------------------------------------------------
79
+ async function install(engine, opts, run) {
80
+ if (engine !== 'podman') {
81
+ throw sandboxError(`${engine} is not installed`, `install ${engine} and try again`);
82
+ }
83
+ const how = instructions();
84
+ if (platform() !== 'darwin' || opts.yes || !isInteractive()) {
85
+ throw sandboxError('podman is not installed', how);
86
+ }
87
+ const brew = await run('brew', ['--version'], { timeoutMs: 15_000 }).catch(() => undefined);
88
+ if (!brew || brew.code !== 0) {
89
+ throw sandboxError('podman is not installed', how);
90
+ }
91
+ if (!(await confirm('Podman is not installed. Install it with Homebrew now?', true))) {
92
+ throw sandboxError('podman is not installed', how);
93
+ }
94
+ note(dim('installing podman — this takes a few minutes'));
95
+ const done = await stream('brew', ['install', 'podman'], run);
96
+ if (done.code !== 0) {
97
+ throw sandboxError('could not install podman', first(done) || how);
98
+ }
99
+ }
100
+ function instructions() {
101
+ switch (platform()) {
102
+ case 'darwin':
103
+ return 'install it with: brew install podman';
104
+ case 'win32':
105
+ return 'install it with: winget install RedHat.Podman';
106
+ default:
107
+ return 'install it with your package manager, e.g. apt install podman';
108
+ }
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // The machine
112
+ // ---------------------------------------------------------------------------
113
+ async function machine(engine, call, opts, run) {
114
+ const listed = await call(['machine', 'list', '--format', 'json'], 30_000);
115
+ if (listed.code !== 0) {
116
+ throw sandboxError(`${engine} machine list failed`, first(listed));
117
+ }
118
+ const machines = parseMachines(listed.stdout);
119
+ if (machines.length === 0) {
120
+ const cpus = String(opts.cpus ?? DEFAULT_MACHINE_CPUS);
121
+ const memory = String(opts.memory ?? DEFAULT_MACHINE_MEMORY);
122
+ note(dim(`initialising the podman machine (${cpus} cpus, ${memory} MiB) — once per host`));
123
+ const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run);
124
+ if (created.code !== 0) {
125
+ throw sandboxError('could not create the podman machine', first(created));
126
+ }
127
+ }
128
+ const chosen = machines.find((m) => m.Default) ?? machines[0];
129
+ if (machines.length > 0 && chosen?.Running) {
130
+ return;
131
+ }
132
+ // A machine that is already starting is not a machine to start again;
133
+ // `machine start` on one mid-boot is an error, not a no-op.
134
+ const args = ['machine', 'start'];
135
+ if (chosen && !chosen.Starting) {
136
+ args.push(chosen.Name);
137
+ }
138
+ note(dim('starting the podman machine'));
139
+ const started = await stream(engine, args, run, SLOW_MS);
140
+ if (started.code !== 0 && !/already running/i.test(started.stderr)) {
141
+ throw sandboxError('could not start the podman machine', first(started));
142
+ }
143
+ }
144
+ function parseMachines(stdout) {
145
+ const text = stdout.trim();
146
+ if (!text) {
147
+ return [];
148
+ }
149
+ try {
150
+ const parsed = JSON.parse(text);
151
+ return Array.isArray(parsed) ? parsed : [];
152
+ }
153
+ catch {
154
+ // A podman that answers `--format json` with something else is a podman
155
+ // we cannot reason about; treating it as "no machines" would try to
156
+ // create a second one.
157
+ throw sandboxError('could not read `podman machine list --format json`');
158
+ }
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // Reporting
162
+ // ---------------------------------------------------------------------------
163
+ /** What `zn sandbox status` prints. Changes nothing, and never throws. */
164
+ export async function podmanStatus(opts = {}) {
165
+ const engine = opts.engine ?? 'podman';
166
+ const run = opts.exec ?? runProcess;
167
+ const call = (args) => run(engine, args, { timeoutMs: 30_000 }).catch(() => undefined);
168
+ const version = await call(['--version']);
169
+ if (!version || version.code !== 0) {
170
+ return { engine, installed: false, ready: false };
171
+ }
172
+ const status = {
173
+ engine,
174
+ installed: true,
175
+ version: version.stdout.trim().split(' ').at(-1),
176
+ ready: false,
177
+ };
178
+ if (platform() !== 'linux') {
179
+ const listed = await call(['machine', 'list', '--format', 'json']);
180
+ const machines = listed?.code === 0 ? safeMachines(listed.stdout) : [];
181
+ const chosen = machines.find((m) => m.Default) ?? machines[0];
182
+ if (chosen) {
183
+ status.machine = {
184
+ name: chosen.Name,
185
+ running: Boolean(chosen.Running),
186
+ starting: Boolean(chosen.Starting),
187
+ };
188
+ }
189
+ }
190
+ const info = await call(['info']);
191
+ status.ready = info?.code === 0;
192
+ if (opts.image) {
193
+ status.image = opts.image;
194
+ const exists = status.ready ? await call(['image', 'exists', opts.image]) : undefined;
195
+ status.imagePresent = exists?.code === 0;
196
+ }
197
+ return status;
198
+ }
199
+ function safeMachines(stdout) {
200
+ try {
201
+ return parseMachines(stdout);
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
207
+ /**
208
+ * Containers this CLI created, whatever session they belong to, and whether
209
+ * each is up. `--all` is the point: with `persist: true` a session leaves a
210
+ * *stopped* container behind, and a listing that only showed running ones
211
+ * would say nothing is there while the disk says otherwise.
212
+ */
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);
215
+ if (!res || res.code !== 0) {
216
+ return [];
217
+ }
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
+ });
226
+ }
227
+ export async function removeContainers(names, engine = 'podman', exec = runProcess) {
228
+ if (names.length === 0) {
229
+ return;
230
+ }
231
+ await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ /**
235
+ * Long steps print as they go. A five-minute pull with no output is
236
+ * indistinguishable from a hang, and the one thing worse than waiting is not
237
+ * knowing whether you are waiting.
238
+ */
239
+ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
240
+ const res = await run(bin, args, { timeoutMs });
241
+ for (const line of res.stderr.split('\n').slice(-3)) {
242
+ if (line.trim()) {
243
+ note(dim(` ${line.trim()}`));
244
+ }
245
+ }
246
+ return res;
247
+ }
248
+ function first(res) {
249
+ return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
250
+ }
251
+ function sandboxError(message, hint) {
252
+ return new CliError(message, EXIT.sandbox, hint);
253
+ }
254
+ //# sourceMappingURL=podman.js.map
@@ -0,0 +1,70 @@
1
+ /** Whether a directory is a project: whether the loader has something to read. */
2
+ export declare function isProjectDir(dir: string): boolean;
3
+ /** A project resolved on disk. */
4
+ export interface Project {
5
+ dir: string;
6
+ /** what it is called: the registry's name, or the directory's own */
7
+ name: string;
8
+ }
9
+ /** Walks up from `start` looking for a project configuration. */
10
+ export declare function findUp(start: string): Promise<Project | undefined>;
11
+ export declare function openDir(dir: string): Promise<Project>;
12
+ /**
13
+ * A name from the registry, or a path. A value that looks like either is tried
14
+ * as both, path first, because a directory that exists is unambiguous evidence
15
+ * and a stale registry entry is not.
16
+ */
17
+ export declare function open(nameOrDir: string): Promise<Project>;
18
+ /**
19
+ * `open` where a miss is an answer rather than an error. It exists for the one
20
+ * place a word might be a project name and might be something else entirely —
21
+ * `zen run <project>` against `zen run <prompt>` — and the difference decides
22
+ * how the rest of the line is read.
23
+ */
24
+ export declare function find(nameOrDir: string): Promise<Project | undefined>;
25
+ /** The project a bare command means: the one you are standing in. */
26
+ export declare function current(cwd?: string): Promise<Project | undefined>;
27
+ export interface RegistryEntry {
28
+ name: string;
29
+ path: string;
30
+ addedAt: string;
31
+ }
32
+ export declare class Registry {
33
+ #private;
34
+ private constructor();
35
+ static open(): Promise<Registry>;
36
+ get entries(): readonly RegistryEntry[];
37
+ find(name: string): RegistryEntry | undefined;
38
+ findPath(dir: string): RegistryEntry | undefined;
39
+ /** Idempotent: re-registering the same directory refreshes it in place. */
40
+ add(name: string, dir: string): RegistryEntry;
41
+ remove(name: string): boolean;
42
+ /** Drops entries whose directory is gone. Returns what it dropped. */
43
+ prune(): RegistryEntry[];
44
+ save(): void;
45
+ }
46
+ export interface ProjectSummary {
47
+ name: string;
48
+ path: string;
49
+ /** false when the directory has gone away since it was registered */
50
+ present: boolean;
51
+ sessions: number;
52
+ runs: number;
53
+ lastRunAt?: string;
54
+ /** a session currently held by a live process */
55
+ busy: boolean;
56
+ }
57
+ export declare function summarize(entry: RegistryEntry): Promise<ProjectSummary>;
58
+ export declare const sessionsDir: (projectDir: string) => string;
59
+ /** Session ids, oldest first. Only well-formed stamps count as sessions. */
60
+ export declare function sessionIds(projectDir: string): string[];
61
+ export declare function runIds(sessionDir: string): string[];
62
+ /** True when a session's lock names a process that is still alive. */
63
+ export declare function isBusy(sessionDir: string): boolean;
64
+ /**
65
+ * `kill(pid, 0)` sends no signal and only asks whether the process exists.
66
+ * EPERM means it exists and belongs to someone else, which still counts.
67
+ */
68
+ export declare function alive(pid: number): boolean;
69
+ export declare function projectName(dir: string): string;
70
+ //# sourceMappingURL=projects.d.ts.map
@@ -0,0 +1,232 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { paths, readJson, writeJson } from "./home.js";
4
+ import { isStamp } from "./ids.js";
5
+ import { invalidError, usageError } from "./term.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Projects
8
+ //
9
+ // The directory is the truth, and what makes a directory a project is the one
10
+ // file the runtime cannot do without: `agents.yaml`. There is no marker file
11
+ // beside it — that would be a second thing to keep in sync, holding a name the
12
+ // directory already has.
13
+ //
14
+ // `~/.zenera/neo/projects.json` is an index so that `zen list` and `zen open` do
15
+ // not have to search the filesystem, and it is allowed to be wrong: every entry
16
+ // can be rebuilt by pointing `zen` at the directory again, and an entry whose
17
+ // path has vanished is reported, not fatal.
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * The loader's own names, in its own order — `packages/neo/src/project/load.ts`.
21
+ * Anything the library would load, `zen` finds.
22
+ */
23
+ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents/agents.yml'];
24
+ /** Whether a directory is a project: whether the loader has something to read. */
25
+ export function isProjectDir(dir) {
26
+ return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // The project directory
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * What a project answers to. The registry is asked first, so one registered
33
+ * under a name of its own keeps it; the directory's name answers for a project
34
+ * that was never registered — which is a working project, just not a listed one.
35
+ */
36
+ async function nameOf(dir) {
37
+ return (await Registry.open()).findPath(dir)?.name ?? basename(dir);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Finding one
41
+ // ---------------------------------------------------------------------------
42
+ /** Walks up from `start` looking for a project configuration. */
43
+ export async function findUp(start) {
44
+ let dir = resolve(start);
45
+ for (;;) {
46
+ if (isProjectDir(dir)) {
47
+ return { dir, name: await nameOf(dir) };
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir) {
51
+ return undefined;
52
+ }
53
+ dir = parent;
54
+ }
55
+ }
56
+ export async function openDir(dir) {
57
+ const at = resolve(dir);
58
+ if (!isProjectDir(at)) {
59
+ throw invalidError(`${at} is not a project`, `no ${CONFIG_NAMES[0]} — run: zen init`);
60
+ }
61
+ return { dir: at, name: await nameOf(at) };
62
+ }
63
+ /**
64
+ * A name from the registry, or a path. A value that looks like either is tried
65
+ * as both, path first, because a directory that exists is unambiguous evidence
66
+ * and a stale registry entry is not.
67
+ */
68
+ export async function open(nameOrDir) {
69
+ if (isAbsolute(nameOrDir) || nameOrDir.startsWith('.') || existsSync(nameOrDir)) {
70
+ return openDir(nameOrDir);
71
+ }
72
+ const entry = (await Registry.open()).find(nameOrDir);
73
+ if (!entry) {
74
+ throw usageError(`no project named "${nameOrDir}"`, 'see: zen list');
75
+ }
76
+ return openDir(entry.path);
77
+ }
78
+ /**
79
+ * `open` where a miss is an answer rather than an error. It exists for the one
80
+ * place a word might be a project name and might be something else entirely —
81
+ * `zen run <project>` against `zen run <prompt>` — and the difference decides
82
+ * how the rest of the line is read.
83
+ */
84
+ export async function find(nameOrDir) {
85
+ if (isAbsolute(nameOrDir) || nameOrDir.startsWith('.') || existsSync(nameOrDir)) {
86
+ const dir = resolve(nameOrDir);
87
+ return isProjectDir(dir) ? { dir, name: await nameOf(dir) } : undefined;
88
+ }
89
+ const entry = (await Registry.open()).find(nameOrDir);
90
+ return entry ? openDir(entry.path) : undefined;
91
+ }
92
+ /** The project a bare command means: the one you are standing in. */
93
+ export async function current(cwd = process.cwd()) {
94
+ return findUp(cwd);
95
+ }
96
+ export class Registry {
97
+ #file;
98
+ constructor(file) {
99
+ this.#file = file;
100
+ }
101
+ static async open() {
102
+ const file = await readJson(paths.projects(), {
103
+ version: 1,
104
+ projects: [],
105
+ });
106
+ return new Registry({ version: 1, projects: file.projects ?? [] });
107
+ }
108
+ get entries() {
109
+ return this.#file.projects;
110
+ }
111
+ find(name) {
112
+ return this.#file.projects.find((p) => p.name === name);
113
+ }
114
+ findPath(dir) {
115
+ const at = resolve(dir);
116
+ return this.#file.projects.find((p) => resolve(p.path) === at);
117
+ }
118
+ /** Idempotent: re-registering the same directory refreshes it in place. */
119
+ add(name, dir) {
120
+ const path = resolve(dir);
121
+ const existing = this.find(name);
122
+ if (existing && resolve(existing.path) !== path) {
123
+ throw usageError(`a different project is already named "${name}"`, `it lives at ${existing.path} — use --name`);
124
+ }
125
+ const byPath = this.findPath(path);
126
+ if (byPath) {
127
+ byPath.name = name;
128
+ return byPath;
129
+ }
130
+ const entry = { name, path, addedAt: new Date().toISOString() };
131
+ this.#file.projects.push(entry);
132
+ return entry;
133
+ }
134
+ remove(name) {
135
+ const at = this.#file.projects.findIndex((p) => p.name === name);
136
+ if (at < 0) {
137
+ return false;
138
+ }
139
+ this.#file.projects.splice(at, 1);
140
+ return true;
141
+ }
142
+ /** Drops entries whose directory is gone. Returns what it dropped. */
143
+ prune() {
144
+ const gone = this.#file.projects.filter((p) => !isProjectDir(p.path));
145
+ this.#file.projects = this.#file.projects.filter((p) => !gone.includes(p));
146
+ return gone;
147
+ }
148
+ save() {
149
+ writeJson(paths.projects(), this.#file, 0o600);
150
+ }
151
+ }
152
+ export async function summarize(entry) {
153
+ const base = {
154
+ name: entry.name,
155
+ path: entry.path,
156
+ present: false,
157
+ sessions: 0,
158
+ runs: 0,
159
+ busy: false,
160
+ };
161
+ if (!isProjectDir(entry.path)) {
162
+ return base;
163
+ }
164
+ const summary = { ...base, present: true };
165
+ for (const session of sessionIds(entry.path)) {
166
+ summary.sessions++;
167
+ const dir = join(entry.path, 'sessions', session);
168
+ if (isBusy(dir)) {
169
+ summary.busy = true;
170
+ }
171
+ const ids = runIds(dir);
172
+ summary.runs += ids.length;
173
+ const newest = ids.at(-1);
174
+ if (newest && (!summary.lastRunAt || newest > summary.lastRunAt)) {
175
+ summary.lastRunAt = newest;
176
+ }
177
+ }
178
+ return summary;
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Sessions and runs
182
+ //
183
+ // Kept here rather than in session.ts because listing them is a read-only
184
+ // question about a directory, and `zen list` must be able to ask it without
185
+ // pulling in the runtime.
186
+ // ---------------------------------------------------------------------------
187
+ export const sessionsDir = (projectDir) => join(projectDir, 'sessions');
188
+ /** Session ids, oldest first. Only well-formed stamps count as sessions. */
189
+ export function sessionIds(projectDir) {
190
+ return stampedChildren(sessionsDir(projectDir));
191
+ }
192
+ export function runIds(sessionDir) {
193
+ return stampedChildren(join(sessionDir, 'runs'));
194
+ }
195
+ function stampedChildren(dir) {
196
+ if (!existsSync(dir)) {
197
+ return [];
198
+ }
199
+ return readdirSync(dir, { withFileTypes: true })
200
+ .filter((e) => e.isDirectory() && isStamp(e.name))
201
+ .map((e) => e.name)
202
+ .sort();
203
+ }
204
+ /** True when a session's lock names a process that is still alive. */
205
+ export function isBusy(sessionDir) {
206
+ const path = join(sessionDir, '.lock');
207
+ try {
208
+ const { pid } = JSON.parse(readFileSync(path, 'utf8'));
209
+ return typeof pid === 'number' && alive(pid);
210
+ }
211
+ catch {
212
+ // Absent, unreadable or malformed: nothing is holding it.
213
+ return false;
214
+ }
215
+ }
216
+ /**
217
+ * `kill(pid, 0)` sends no signal and only asks whether the process exists.
218
+ * EPERM means it exists and belongs to someone else, which still counts.
219
+ */
220
+ export function alive(pid) {
221
+ try {
222
+ process.kill(pid, 0);
223
+ return true;
224
+ }
225
+ catch (err) {
226
+ return err.code === 'EPERM';
227
+ }
228
+ }
229
+ export function projectName(dir) {
230
+ return basename(resolve(dir));
231
+ }
232
+ //# sourceMappingURL=projects.js.map
@@ -0,0 +1,27 @@
1
+ import * as Projects from './projects.ts';
2
+ import { type SessionPaths } from './session.ts';
3
+ export interface Wanted {
4
+ project?: string;
5
+ session?: string;
6
+ fresh?: boolean;
7
+ workspace?: string;
8
+ yes?: boolean;
9
+ cwd: string;
10
+ }
11
+ export interface Target {
12
+ project: Projects.Project;
13
+ session: SessionPaths;
14
+ /** true when this call created the session */
15
+ created: boolean;
16
+ }
17
+ export declare function project(want: Wanted): Promise<Projects.Project>;
18
+ export declare function target(want: Wanted): Promise<Target>;
19
+ /**
20
+ * The workspace is what the agent can read and write, so pointing it outside
21
+ * the session is the useful case and the dangerous one. It is confirmed once,
22
+ * explicitly, naming the path — and a script has to say `--yes` to skip that.
23
+ * An agent with file tools rooted at `$HOME` should take more than one
24
+ * keystroke to arrange.
25
+ */
26
+ export declare function chooseWorkspace(session: SessionPaths, want: Wanted): Promise<string>;
27
+ //# sourceMappingURL=resolve.d.ts.map