@monster-orange/den 0.3.0 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/docs/distributions.md +229 -8
  3. package/package.json +4 -1
  4. package/src/shared/agents/default-pack/be-dev.md +20 -0
  5. package/src/shared/agents/default-pack/devops.md +14 -0
  6. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  7. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  8. package/src/shared/agents/default-pack/planner.md +17 -0
  9. package/src/shared/agents/default-pack/reviewer.md +14 -0
  10. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  11. package/src/shared/agents/den-claude.ts +173 -0
  12. package/src/shared/agents/generic.ts +52 -0
  13. package/src/shared/agents/personas.ts +218 -0
  14. package/src/shared/agents/records.ts +118 -0
  15. package/src/shared/agents/runtime.ts +192 -0
  16. package/src/shared/agents/runtimes.ts +47 -0
  17. package/src/shared/den-config.ts +116 -0
  18. package/src/shared/engine.ts +23 -0
  19. package/src/shared/environment-manager/workspaces.ts +19 -0
  20. package/src/shared/secret-env.ts +70 -0
  21. package/src/shared/sessions/client.ts +277 -0
  22. package/src/shared/sessions/den-pty.ts +97 -0
  23. package/src/shared/sessions/env.ts +79 -0
  24. package/src/shared/sessions/host.ts +667 -0
  25. package/src/shared/sessions/names.ts +85 -0
  26. package/src/shared/sessions/paths.ts +27 -0
  27. package/src/shared/sessions/protocol.ts +72 -0
  28. package/src/shared/sessions/pty.ts +141 -0
  29. package/src/shared/sessions/ring-buffer.ts +160 -0
  30. package/src/shared/sessions/types.ts +81 -0
  31. package/src/ui/cli/agents-command.ts +812 -0
  32. package/src/ui/cli/doctor-command.ts +138 -9
  33. package/src/ui/cli/env-probe.ts +266 -0
  34. package/src/ui/cli/flag-parser.ts +11 -3
  35. package/src/ui/cli/index.ts +34 -2
  36. package/src/ui/cli/sessions-command.ts +718 -0
  37. package/src/ui/mcp/scope.ts +123 -19
  38. package/src/ui/mcp/server.ts +27 -2
  39. package/src/ui/mcp/tools.ts +44 -5
  40. package/src/ui/server/audit.ts +35 -0
  41. package/src/ui/server/index.ts +7 -0
  42. package/src/ui/server/readonly-routes.ts +25 -2
  43. package/src/ui/server/workspace-routes.ts +3 -15
@@ -0,0 +1,812 @@
1
+ /**
2
+ * `<cli> agents list|start|stop|resume|personas` (AG-2a, AG spec §5c):
3
+ * an agent is a persona and a workspace, run by a runtime in a session of
4
+ * the AG-3a host.
5
+ *
6
+ * start resolves the persona (Den's default pack, then
7
+ * agents.persona_packs) and the runtime, asks the runtime for argv,
8
+ * env and files (its MCP config: `<cli> mcp --workspace <ws>
9
+ * --actor agent:<id>`, plus a `--grant` per agents.<id>.grants),
10
+ * starts `<dist>-<ws>:<id>` in the workspace's dir, and writes the
11
+ * agent record (<stateDir>/agents/<id>.json, 0600; no env, no argv).
12
+ * The agent's env is an allowlist plus what its runtime declares,
13
+ * less every runtime's scrubEnv (sessions/env.ts), never the
14
+ * caller's whole environment; no --keep-env. A
15
+ * session that exits non-zero in its first moments (exitWatchMs)
16
+ * is reported with its last lines, exit 1.
17
+ * list the workspace's agents (sessions with an agent record), with
18
+ * persona, runtime and model; a record whose session is gone is
19
+ * listed as stopped or ended. A plain session is not an agent.
20
+ * stop ends its session (TERM) and marks the record stopped.
21
+ * resume only when its runtime declares resume and its session is not
22
+ * running: a new session, in the same cwd, continuing the last run;
23
+ * when that session was never prompted, a fresh start under the
24
+ * same conversation id (there is nothing to continue).
25
+ *
26
+ * Nothing here takes a grant: grants come from the org config only. The
27
+ * host enforces agents.max_concurrent on every start.
28
+ */
29
+ import * as fs from "fs";
30
+ import * as path from "path";
31
+ import {
32
+ AGENTS_RESERVED_KEYS,
33
+ readAgentsConfig,
34
+ type ResolvedAgentsConfig,
35
+ } from "../../shared/den-config.js";
36
+ import { cliEntrypoint } from "../../shared/engine.js";
37
+ import { getDistRoot } from "../../shared/paths.js";
38
+ import { envKey } from "../../shared/product.js";
39
+ import { redactSecrets } from "../../shared/redact.js";
40
+ import {
41
+ loadPersonas,
42
+ resolvePackDirs,
43
+ type Persona,
44
+ } from "../../shared/agents/personas.js";
45
+ import {
46
+ AGENT_ID_RE,
47
+ agentFilesDir,
48
+ listRecords,
49
+ readRecord,
50
+ writeRecord,
51
+ writeRuntimeFiles,
52
+ type AgentRecord,
53
+ } from "../../shared/agents/records.js";
54
+ import {
55
+ agentSessionEnv,
56
+ DEFAULT_RUNTIME_ID,
57
+ getRuntime,
58
+ LaunchError,
59
+ runtimeIds,
60
+ type AgentMcp,
61
+ type LaunchInput,
62
+ type Runtime,
63
+ } from "../../shared/agents/runtimes.js";
64
+ import { HostError, type HostClient } from "../../shared/sessions/client.js";
65
+ import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
66
+ import { inWorkspace, sessionName } from "../../shared/sessions/names.js";
67
+ import type { SessionInfo } from "../../shared/sessions/types.js";
68
+ import { serverSpec } from "../mcp/connect.js";
69
+ import {
70
+ columns,
71
+ contractInfo,
72
+ guarded,
73
+ loadWorkspaces,
74
+ noExtra,
75
+ parseArgs,
76
+ resolveDeps,
77
+ state,
78
+ UsageError,
79
+ withClient,
80
+ workspaceFlag,
81
+ type Resolved,
82
+ type SessionsDeps,
83
+ } from "./sessions-command.js";
84
+
85
+ export const AGENTS_HELP = {
86
+ usage: "agents <list|start|stop|resume|personas> ...",
87
+ lines: [
88
+ "Agents: a persona and a workspace, run by a runtime in a session that",
89
+ "outlives the web server (see `{cli} sessions`).",
90
+ "",
91
+ "{cli} agents list -w <ws> [--json]",
92
+ "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
93
+ " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
94
+ " (plus agents.<id>.grants); capped by agents.max_concurrent",
95
+ "{cli} agents stop <id> [-w <ws>]",
96
+ "{cli} agents resume <id> [-w <ws>]",
97
+ " only when its runtime declares resume and its session has ended;",
98
+ " a session never prompted starts fresh under the same conversation id",
99
+ "{cli} agents personas [--json]",
100
+ " Den's default pack, then agents.persona_packs (a later pack wins by id)",
101
+ "",
102
+ "Runtimes: den-claude (default; needs claude on PATH) and generic (the",
103
+ "persona's argv template). Attach with `{cli} sessions attach <ws>:<id>`.",
104
+ "An agent's env is PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG,",
105
+ "LC_*, TZ and TMPDIR, plus what its runtime declares: nothing else of yours.",
106
+ ],
107
+ };
108
+
109
+ export interface AgentsDeps extends SessionsDeps {
110
+ agentsConfig?: () => ResolvedAgentsConfig;
111
+ distRoot?: () => string;
112
+ /** A workspace's checkout directory: where its agents run. */
113
+ workspaceDir?: (ws: string) => string;
114
+ /** The MCP server binary and its env; Den adds the scoped args. */
115
+ mcpServer?: () => { command: string[] | string; env: Record<string, string> };
116
+ /**
117
+ * How long start and resume watch a new session for an early non-zero
118
+ * exit (default 3000 ms; 0: not at all).
119
+ */
120
+ exitWatchMs?: number;
121
+ }
122
+
123
+ interface AgentsResolved extends Resolved {
124
+ agentsConfig: () => ResolvedAgentsConfig;
125
+ distRoot: () => string;
126
+ workspaceDir: (ws: string) => string;
127
+ mcpServer: () => { command: string[] | string; env: Record<string, string> };
128
+ exitWatchMs: number;
129
+ }
130
+
131
+ /** The early-exit watch: at most this long, polling the host. */
132
+ export const EXIT_WATCH_MS = 3000;
133
+ const EXIT_POLL_MS = 100;
134
+ /** The most lines of an early exit's scrollback printed. */
135
+ const EXIT_TAIL_LINES = 10;
136
+
137
+ let workspaceDirFn: ((ws: string) => string) | null = null;
138
+ async function loadWorkspaceDir(d: AgentsDeps): Promise<void> {
139
+ if (d.workspaceDir || workspaceDirFn) return;
140
+ const ws = await import("../../shared/environment-manager/workspaces.js");
141
+ const { getReposRoot } = await import("../../shared/den-config.js");
142
+ workspaceDirFn = (name) => {
143
+ const cfg = ws.readWorkspacesFile().workspaces[name];
144
+ if (!cfg) throw new UsageError(`workspace "${name}" does not exist`);
145
+ return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
146
+ };
147
+ }
148
+
149
+ /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
150
+ function defaultMcpServer(cli: string): {
151
+ command: string[];
152
+ env: Record<string, string>;
153
+ } {
154
+ const s = serverSpec({
155
+ cli,
156
+ distRoot: getDistRoot(),
157
+ nodeBinDir: path.dirname(process.execPath),
158
+ platform: process.platform,
159
+ });
160
+ if (fs.existsSync(s.command)) return { command: [s.command], env: s.env };
161
+ const e = cliEntrypoint();
162
+ return { command: [e.command, ...e.args], env: s.env };
163
+ }
164
+
165
+ function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
166
+ const base = resolveDeps(d);
167
+ return {
168
+ ...base,
169
+ agentsConfig: d.agentsConfig ?? (() => readAgentsConfig()),
170
+ distRoot: d.distRoot ?? getDistRoot,
171
+ workspaceDir:
172
+ d.workspaceDir ??
173
+ ((ws) => {
174
+ if (!workspaceDirFn) throw new Error("workspaces not loaded");
175
+ return workspaceDirFn(ws);
176
+ }),
177
+ mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
178
+ exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
179
+ };
180
+ }
181
+
182
+ // ── the agent's MCP ─────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Where the distribution is, from the caller's env (DEN_ROOT,
186
+ * `<PREFIX>_ROOT`, `<PREFIX>_HOME`): the agent's env is an allowlist, so
187
+ * its MCP server gets them in its own config instead, and reaches the same
188
+ * distribution and state dir as the CLI that started it. Paths, never
189
+ * secrets.
190
+ */
191
+ function distLocationEnv(env: NodeJS.ProcessEnv): Record<string, string> {
192
+ const out: Record<string, string> = {};
193
+ for (const k of new Set(["DEN_ROOT", envKey("ROOT"), envKey("HOME")])) {
194
+ const v = env[k];
195
+ if (v) out[k] = v;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /** `<cli> mcp --workspace <ws> --actor agent:<id> [--grant <g>…]`. */
201
+ export function agentMcp(
202
+ d: AgentsResolved,
203
+ ws: string,
204
+ id: string,
205
+ grants: string[],
206
+ ): AgentMcp {
207
+ const server = d.mcpServer();
208
+ const bin = Array.isArray(server.command) ? server.command : [server.command];
209
+ return {
210
+ name: d.cli,
211
+ command: [
212
+ ...bin,
213
+ "mcp",
214
+ "--workspace",
215
+ ws,
216
+ "--actor",
217
+ `agent:${id}`,
218
+ ...grants.flatMap((g) => ["--grant", g]),
219
+ ],
220
+ env: { ...distLocationEnv(d.env), ...server.env },
221
+ scope: ws,
222
+ };
223
+ }
224
+
225
+ // ── rows ────────────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * One row per agent of a workspace: each session joined to the agent record
229
+ * that started it (by session id), and each record whose session is gone,
230
+ * as stopped (after `agents stop`) or ended. A session without a record is
231
+ * a plain one (`sessions start`), not an agent: `sessions list` shows it.
232
+ */
233
+ export interface AgentRow {
234
+ agent: string;
235
+ persona?: string;
236
+ runtime?: string;
237
+ model?: string;
238
+ workspace?: string;
239
+ cwd?: string;
240
+ /** running, exited (<code>), stopped or ended. */
241
+ state: string;
242
+ session?: SessionInfo;
243
+ }
244
+
245
+ function rowState(rec: AgentRecord | undefined, s?: SessionInfo): string {
246
+ if (s && !s.exited) return "running";
247
+ if (rec?.stoppedAt) return "stopped";
248
+ return s ? state(s) : "ended";
249
+ }
250
+
251
+ export function agentRows(
252
+ sessions: SessionInfo[],
253
+ records: AgentRecord[] = [],
254
+ ): AgentRow[] {
255
+ const bySession = new Map(records.map((r) => [r.session.id, r]));
256
+ const rows: AgentRow[] = [];
257
+ const shown = new Set<string>();
258
+ for (const s of sessions) {
259
+ const rec = bySession.get(s.ref.id);
260
+ if (!rec) continue;
261
+ shown.add(rec.id);
262
+ rows.push({
263
+ agent: rec.id,
264
+ ...recordFields(rec),
265
+ state: rowState(rec, s),
266
+ session: contractInfo(s),
267
+ });
268
+ }
269
+ for (const rec of records)
270
+ if (!shown.has(rec.id))
271
+ rows.push({ agent: rec.id, ...recordFields(rec), state: rowState(rec) });
272
+ return rows;
273
+ }
274
+
275
+ function recordFields(rec: AgentRecord): Partial<AgentRow> {
276
+ return {
277
+ persona: rec.persona,
278
+ runtime: rec.runtime,
279
+ ...(rec.model ? { model: rec.model } : {}),
280
+ workspace: rec.workspace,
281
+ cwd: rec.cwd,
282
+ };
283
+ }
284
+
285
+ async function list(args: string[], d: AgentsResolved): Promise<number> {
286
+ const where = "agents list";
287
+ const p = parseArgs(where, args, { "--workspace": true, "--json": false });
288
+ noExtra(where, p);
289
+ const ws = workspaceFlag(where, p);
290
+ if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
291
+ const sessions = (await withClient(d, (c) => c.list())).filter((s) =>
292
+ inWorkspace(s, d.dist, ws),
293
+ );
294
+ const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
295
+ const rows = agentRows(sessions, records);
296
+ if (p.flags["--json"]) {
297
+ d.out(JSON.stringify(rows, null, 2));
298
+ return 0;
299
+ }
300
+ if (!rows.length) {
301
+ d.out(`no agents in ${ws}`);
302
+ return 0;
303
+ }
304
+ for (const l of columns([
305
+ [
306
+ "AGENT",
307
+ "PERSONA",
308
+ "RUNTIME",
309
+ "STATE",
310
+ "MODEL",
311
+ "PID",
312
+ "STARTED",
313
+ "SESSION",
314
+ ],
315
+ ...rows.map((r) => [
316
+ r.agent,
317
+ r.persona ?? "-",
318
+ r.runtime ?? "-",
319
+ r.state,
320
+ r.model ?? "-",
321
+ r.session && !r.session.exited ? String(r.session.pid) : "-",
322
+ r.session?.startedAt ?? "-",
323
+ r.session?.name ?? "-",
324
+ ]),
325
+ ]))
326
+ d.out(l);
327
+ return 0;
328
+ }
329
+
330
+ // ── start ───────────────────────────────────────────────────────────────────
331
+
332
+ /** The agents config; an invalid one is a refusal naming the key. */
333
+ function agentsConfig(d: AgentsResolved): ResolvedAgentsConfig {
334
+ try {
335
+ return d.agentsConfig();
336
+ } catch (e) {
337
+ throw new Refused((e as Error).message);
338
+ }
339
+ }
340
+
341
+ /** Every persona; a bad pack or persona file is a refusal naming it. */
342
+ function personas(
343
+ d: AgentsResolved,
344
+ cfg: ResolvedAgentsConfig,
345
+ ): Map<string, Persona> {
346
+ try {
347
+ return loadPersonas(resolvePackDirs(cfg.personaPacks, d.distRoot()));
348
+ } catch (e) {
349
+ throw new Refused((e as Error).message);
350
+ }
351
+ }
352
+
353
+ function runtimeFor(id: string): Runtime {
354
+ const rt = getRuntime(id);
355
+ if (!rt)
356
+ throw new UsageError(
357
+ `no runtime "${id}" (runtimes: ${runtimeIds().join(", ")})`,
358
+ );
359
+ return rt;
360
+ }
361
+
362
+ /** The running session of `rec`, if any; else its exited one, if listed. */
363
+ function sessionOf(
364
+ sessions: SessionInfo[],
365
+ rec: AgentRecord,
366
+ ): SessionInfo | undefined {
367
+ return sessions.find((s) => s.ref.id === rec.session.id);
368
+ }
369
+
370
+ /** Refuse: exit 1 with the message (and hints). */
371
+ class Refused extends Error {
372
+ constructor(
373
+ message: string,
374
+ readonly hints: string[] = [],
375
+ ) {
376
+ super(message);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * The id an agent gets: --id, else the persona's id while it is free (or
382
+ * this workspace's), else `<persona>-<ws>`.
383
+ */
384
+ function defaultId(stateDir: string, persona: string, ws: string): string {
385
+ const rec = readRecord(stateDir, persona);
386
+ if (!rec || rec.workspace === ws) return persona;
387
+ return `${persona}-${ws}`.slice(0, 64);
388
+ }
389
+
390
+ function checkId(where: string, id: string): void {
391
+ if (!AGENT_ID_RE.test(id) || AGENTS_RESERVED_KEYS.includes(id))
392
+ throw new UsageError(
393
+ `${where}: --id "${id}" must be letters, digits, ".", "_" or "-" (1-64), ` +
394
+ `and not ${AGENTS_RESERVED_KEYS.join(" or ")}`,
395
+ );
396
+ }
397
+
398
+ interface Launch {
399
+ rec: Omit<AgentRecord, "session" | "startedAt">;
400
+ argv: string[];
401
+ env: Record<string, string>;
402
+ /** The runtime's envPassthrough. */
403
+ passthrough?: readonly string[];
404
+ warnings?: string[];
405
+ }
406
+
407
+ /** Start `launch` as the agent's session (replacing an exited one) and record it. */
408
+ async function startSession(
409
+ d: AgentsResolved,
410
+ c: HostClient,
411
+ launch: Launch,
412
+ old: SessionInfo | undefined,
413
+ extra: Partial<AgentRecord>,
414
+ ): Promise<{ rec: AgentRecord; info: SessionInfo }> {
415
+ if (old?.exited) await c.forget(old.ref.id);
416
+ const tty = d.stdout.isTTY;
417
+ const info = await c.start(
418
+ {
419
+ name: launch.rec.sessionName,
420
+ cwd: launch.rec.cwd,
421
+ argv: launch.argv,
422
+ // Never the caller's whole env: the allowlist, then the runtime's.
423
+ env: agentSessionEnv(
424
+ d.env,
425
+ { envPassthrough: launch.passthrough },
426
+ launch.env,
427
+ launch.rec.id,
428
+ ),
429
+ cols: (tty && d.stdout.columns) || 80,
430
+ rows: (tty && d.stdout.rows) || 24,
431
+ },
432
+ { limit: d.maxConcurrent() },
433
+ );
434
+ const rec: AgentRecord = {
435
+ ...launch.rec,
436
+ session: { backend: info.ref.backend, id: info.ref.id },
437
+ startedAt: info.startedAt,
438
+ ...extra,
439
+ };
440
+ delete rec.stoppedAt;
441
+ writeRecord(d.stateDir(), rec);
442
+ for (const w of launch.warnings ?? []) d.err(w);
443
+ return { rec, info };
444
+ }
445
+
446
+ /** CSI, OSC (BEL or ST ended), and other two-byte escapes. */
447
+ /* eslint-disable no-control-regex */
448
+ const TERMINAL_ESCAPE =
449
+ /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
450
+ /* eslint-enable no-control-regex */
451
+
452
+ /** Printable lines of terminal output: escapes and control bytes removed. */
453
+ export function plainLines(text: string): string[] {
454
+ return (
455
+ text
456
+ .replace(TERMINAL_ESCAPE, "")
457
+ .split(/\r?\n/)
458
+ // A carriage return redraws the line: what stays is after the last.
459
+ .map((l) => l.slice(l.lastIndexOf("\r") + 1))
460
+ // eslint-disable-next-line no-control-regex
461
+ .map((l) => l.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd())
462
+ .filter((l) => l.trim())
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Watch a new session for an early exit (d.exitWatchMs, polling the host).
468
+ * A non-zero exit is reported with its code and the last lines it printed
469
+ * (secret-shaped values redacted); true when it did.
470
+ */
471
+ async function exitedAtOnce(
472
+ d: AgentsResolved,
473
+ c: HostClient,
474
+ id: string,
475
+ info: SessionInfo,
476
+ ): Promise<boolean> {
477
+ const deadline = Date.now() + d.exitWatchMs;
478
+ while (Date.now() < deadline) {
479
+ await new Promise((r) =>
480
+ setTimeout(r, Math.min(EXIT_POLL_MS, Math.max(0, deadline - Date.now()))),
481
+ );
482
+ const s = (await c.list()).find((x) => x.ref.id === info.ref.id);
483
+ if (!s) return false;
484
+ if (!s.exited) continue;
485
+ if (s.exitCode === 0) return false;
486
+ const code = s.exitCode === undefined ? "" : ` (exit ${s.exitCode})`;
487
+ d.err(`✗ agent ${id} exited at once${code}`);
488
+ const tail = plainLines(
489
+ (await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
490
+ ).slice(-EXIT_TAIL_LINES);
491
+ if (tail.length) {
492
+ d.err(` its last ${tail.length === 1 ? "line" : "lines"}:`);
493
+ for (const l of tail) d.err(` | ${redactSecrets(l)}`);
494
+ }
495
+ d.err(` All of it: ${d.cli} sessions attach ${s.name}`);
496
+ return true;
497
+ }
498
+ return false;
499
+ }
500
+
501
+ function launchInput(
502
+ d: AgentsResolved,
503
+ cfg: ResolvedAgentsConfig,
504
+ persona: Persona,
505
+ ws: string,
506
+ id: string,
507
+ model: string | undefined,
508
+ ): LaunchInput {
509
+ const grants = cfg.agents[id]?.grants ?? [];
510
+ for (const g of grants)
511
+ if (!d.workspaceExists(g))
512
+ throw new Refused(`agents.${id}.grants: workspace "${g}" does not exist`);
513
+ const dir = d.workspaceDir(ws);
514
+ if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
515
+ throw new Refused(`workspace ${ws}'s directory ${dir} does not exist`);
516
+ return {
517
+ persona,
518
+ workspace: { name: ws, dir },
519
+ agentId: id,
520
+ worktree: dir,
521
+ ...(model ? { model } : {}),
522
+ mcp: agentMcp(d, ws, id, grants),
523
+ filesDir: agentFilesDir(d.stateDir(), id),
524
+ env: d.env,
525
+ };
526
+ }
527
+
528
+ function ptyOrRefuse(d: AgentsResolved): void {
529
+ const pty = d.pty();
530
+ if (!pty.available)
531
+ throw new Refused(`${DEN_PTY_ID} is unavailable: ${pty.reason}`, [
532
+ `\`${d.cli} doctor\` shows the session backend's state.`,
533
+ ]);
534
+ }
535
+
536
+ async function start(args: string[], d: AgentsResolved): Promise<number> {
537
+ const where = "agents start";
538
+ const p = parseArgs(where, args, {
539
+ "--workspace": true,
540
+ "--persona": true,
541
+ "--runtime": true,
542
+ "--model": true,
543
+ "--id": true,
544
+ });
545
+ noExtra(where, p);
546
+ const ws = workspaceFlag(where, p);
547
+ if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
548
+ const personaId = p.flags["--persona"];
549
+ if (typeof personaId !== "string")
550
+ throw new UsageError(`${where} needs --persona <p>`);
551
+ if (!d.workspaceExists(ws))
552
+ throw new UsageError(`${where}: workspace "${ws}" does not exist`);
553
+ const cfg = agentsConfig(d);
554
+ const all = personas(d, cfg);
555
+ const persona = all.get(personaId);
556
+ if (!persona)
557
+ throw new UsageError(
558
+ `${where}: no persona "${personaId}" (personas: ${[...all.keys()].sort().join(", ")})`,
559
+ );
560
+ const idFlag = p.flags["--id"];
561
+ const id =
562
+ typeof idFlag === "string"
563
+ ? idFlag
564
+ : defaultId(d.stateDir(), persona.id, ws);
565
+ checkId(where, id);
566
+ const rtFlag = p.flags["--runtime"];
567
+ const rt = runtimeFor(
568
+ typeof rtFlag === "string"
569
+ ? rtFlag
570
+ : (persona.runtime ?? DEFAULT_RUNTIME_ID),
571
+ );
572
+ const det = rt.detect(d.env);
573
+ if (!det.available)
574
+ throw new Refused(`${rt.id} is unavailable: ${det.why ?? "unknown"}`);
575
+ const modelFlag = p.flags["--model"];
576
+ const model = typeof modelFlag === "string" ? modelFlag : persona.model;
577
+
578
+ const prev = readRecord(d.stateDir(), id);
579
+ if (prev && prev.workspace !== ws)
580
+ throw new Refused(
581
+ `agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
582
+ );
583
+ ptyOrRefuse(d);
584
+ const input = launchInput(d, cfg, persona, ws, id, model);
585
+
586
+ return withClient(d, async (c) => {
587
+ const sessions = await c.list();
588
+ const old = prev ? sessionOf(sessions, prev) : undefined;
589
+ if (old && !old.exited)
590
+ throw new Refused(`agent ${id} is running (${old.name})`, [
591
+ `Attach: ${d.cli} sessions attach ${old.name}`,
592
+ ]);
593
+ const out = rt.launch(input);
594
+ writeRuntimeFiles(input.filesDir, out.files);
595
+ const { rec, info } = await startSession(
596
+ d,
597
+ c,
598
+ {
599
+ rec: {
600
+ id,
601
+ persona: persona.id,
602
+ runtime: rt.id,
603
+ ...(model ? { model } : {}),
604
+ workspace: ws,
605
+ cwd: input.worktree,
606
+ sessionName: sessionName(d.dist, ws, id),
607
+ ...(out.resumeToken ? { resumeToken: out.resumeToken } : {}),
608
+ },
609
+ argv: out.argv,
610
+ env: out.env,
611
+ passthrough: rt.envPassthrough,
612
+ warnings: out.warnings,
613
+ },
614
+ old,
615
+ {},
616
+ );
617
+ if (await exitedAtOnce(d, c, id, info)) return 1;
618
+ d.out(
619
+ `started agent ${id} (${persona.id}, ${rt.id}) as ${rec.sessionName} in ${rec.cwd}`,
620
+ );
621
+ d.out(` attach: ${d.cli} sessions attach ${rec.sessionName}`);
622
+ return 0;
623
+ });
624
+ }
625
+
626
+ // ── stop, resume ────────────────────────────────────────────────────────────
627
+
628
+ function recordFor(
629
+ where: string,
630
+ p: ReturnType<typeof parseArgs>,
631
+ d: AgentsResolved,
632
+ ): AgentRecord {
633
+ const id = p.positional[0];
634
+ if (!id) throw new UsageError(`${where} needs <id>`);
635
+ const rec = readRecord(d.stateDir(), id);
636
+ if (!rec) throw new Refused(`no agent "${id}"`);
637
+ const ws = workspaceFlag(where, p);
638
+ if (ws && ws !== rec.workspace)
639
+ throw new Refused(
640
+ `agent ${id} is in workspace ${rec.workspace}, not ${ws}`,
641
+ );
642
+ return rec;
643
+ }
644
+
645
+ async function stop(args: string[], d: AgentsResolved): Promise<number> {
646
+ const where = "agents stop";
647
+ const p = parseArgs(where, args, { "--workspace": true });
648
+ noExtra(where, p, 1);
649
+ const rec = recordFor(where, p, d);
650
+ return withClient(d, async (c) => {
651
+ const s = sessionOf(await c.list(), rec);
652
+ if (s && !s.exited) await c.kill(s.ref.id, "TERM");
653
+ writeRecord(d.stateDir(), { ...rec, stoppedAt: new Date().toISOString() });
654
+ d.out(
655
+ s && !s.exited
656
+ ? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid})`
657
+ : `agent ${rec.id} was not running; marked stopped`,
658
+ );
659
+ return 0;
660
+ });
661
+ }
662
+
663
+ async function resume(args: string[], d: AgentsResolved): Promise<number> {
664
+ const where = "agents resume";
665
+ const p = parseArgs(where, args, { "--workspace": true });
666
+ noExtra(where, p, 1);
667
+ const rec = recordFor(where, p, d);
668
+ const rt = runtimeFor(rec.runtime);
669
+ if (!rt.resume)
670
+ throw new Refused(`runtime ${rt.id} does not declare resume`, [
671
+ `Start it again: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
672
+ ]);
673
+ const cfg = agentsConfig(d);
674
+ const persona = personas(d, cfg).get(rec.persona);
675
+ if (!persona)
676
+ throw new Refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
677
+ ptyOrRefuse(d);
678
+ return withClient(d, async (c) => {
679
+ const old = sessionOf(await c.list(), rec);
680
+ if (old && !old.exited)
681
+ throw new Refused(`agent ${rec.id} is running (${old.name})`, [
682
+ `Attach: ${d.cli} sessions attach ${old.name}`,
683
+ ]);
684
+ const input = launchInput(
685
+ d,
686
+ cfg,
687
+ persona,
688
+ rec.workspace,
689
+ rec.id,
690
+ rec.model,
691
+ );
692
+ // Resume in the cwd the agent had, wherever the workspace points now.
693
+ const at = {
694
+ ...input,
695
+ worktree: rec.cwd,
696
+ ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
697
+ };
698
+ // A session nobody prompted left no conversation: continuing it fails,
699
+ // so it starts afresh under the same id. A session the host no longer
700
+ // lists is assumed prompted.
701
+ const fresh = Boolean(old && !old.prompted && rec.resumeToken);
702
+ const out = fresh ? rt.launch(at) : rt.resume!(at);
703
+ if (!out) throw new Refused(`agent ${rec.id} has nothing to resume`);
704
+ writeRuntimeFiles(input.filesDir, out.files);
705
+ const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
706
+ if (fresh)
707
+ d.out(
708
+ `agent ${rec.id}: starting fresh: it was never prompted, so there is no conversation to resume`,
709
+ );
710
+ const { rec: next, info } = await startSession(
711
+ d,
712
+ c,
713
+ {
714
+ rec: { ...base, cwd: rec.cwd },
715
+ argv: out.argv,
716
+ env: out.env,
717
+ passthrough: rt.envPassthrough,
718
+ warnings: out.warnings,
719
+ },
720
+ old,
721
+ fresh ? {} : { resumedAt: new Date().toISOString() },
722
+ );
723
+ if (await exitedAtOnce(d, c, rec.id, info)) return 1;
724
+ d.out(
725
+ `${fresh ? "started" : "resumed"} agent ${rec.id} as ${next.sessionName} in ${next.cwd}`,
726
+ );
727
+ d.out(` attach: ${d.cli} sessions attach ${next.sessionName}`);
728
+ return 0;
729
+ });
730
+ }
731
+
732
+ async function listPersonas(
733
+ args: string[],
734
+ d: AgentsResolved,
735
+ ): Promise<number> {
736
+ const where = "agents personas";
737
+ const p = parseArgs(where, args, { "--json": false });
738
+ noExtra(where, p);
739
+ const all = [...personas(d, agentsConfig(d)).values()].sort((a, b) =>
740
+ a.id.localeCompare(b.id),
741
+ );
742
+ if (p.flags["--json"]) {
743
+ d.out(
744
+ JSON.stringify(
745
+ all.map(({ prompt: _p, ...rest }) => rest),
746
+ null,
747
+ 2,
748
+ ),
749
+ );
750
+ return 0;
751
+ }
752
+ for (const l of columns([
753
+ ["PERSONA", "STANDING", "RUNTIME", "MODEL", "ROLE"],
754
+ ...all.map((x) => [
755
+ x.id,
756
+ x.standing ? "yes" : "-",
757
+ x.runtime ?? DEFAULT_RUNTIME_ID,
758
+ x.model ?? "-",
759
+ x.role,
760
+ ]),
761
+ ]))
762
+ d.out(l);
763
+ return 0;
764
+ }
765
+
766
+ /** `<cli> agents ...`; returns the exit code. */
767
+ export async function cmdAgents(
768
+ args: string[],
769
+ deps: AgentsDeps = {},
770
+ ): Promise<number> {
771
+ const d = resolveAgentsDeps(deps);
772
+ const sub =
773
+ args[0] === undefined || args[0].startsWith("-") ? "list" : args[0];
774
+ const rest = sub === args[0] ? args.slice(1) : args;
775
+ return guarded(d, AGENTS_HELP, "agents", async () => {
776
+ try {
777
+ switch (sub) {
778
+ case "list":
779
+ return await list(rest, d);
780
+ case "start":
781
+ await loadWorkspaces(deps);
782
+ await loadWorkspaceDir(deps);
783
+ return await start(rest, d);
784
+ case "stop":
785
+ return await stop(rest, d);
786
+ case "resume":
787
+ await loadWorkspaces(deps);
788
+ await loadWorkspaceDir(deps);
789
+ return await resume(rest, d);
790
+ case "personas":
791
+ return await listPersonas(rest, d);
792
+ default:
793
+ throw new UsageError(`unknown agents command "${sub}"`);
794
+ }
795
+ } catch (e) {
796
+ if (e instanceof Refused || e instanceof LaunchError) {
797
+ d.err(`✗ ${e.message}`);
798
+ for (const h of e instanceof Refused ? e.hints : []) d.err(` ${h}`);
799
+ return 1;
800
+ }
801
+ if (e instanceof HostError) {
802
+ d.err(`✗ ${e.message}`);
803
+ if (e.code === "cap")
804
+ d.err(
805
+ ` Stop one first (\`${d.cli} agents stop <id>\`), or raise agents.max_concurrent.`,
806
+ );
807
+ return 1;
808
+ }
809
+ throw e;
810
+ }
811
+ });
812
+ }