@monster-orange/den 0.4.0 → 0.4.1

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 (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * `<cli> agents list|start|stop|resume|personas` (AG-2a, AG spec §5c):
3
3
  * an agent is a persona and a workspace, run by a runtime in a session of
4
- * the AG-3a host.
4
+ * the AG-3a host. This is the CLI's front: it parses the arguments and
5
+ * prints; what each operation does is agent-ops.ts, which the Workspaces
6
+ * page's routes (server/agent-routes.ts) call too.
5
7
  *
6
8
  * start resolves the persona (Den's default pack, then
7
9
  * agents.persona_packs) and the runtime, asks the runtime for argv,
@@ -26,62 +28,40 @@
26
28
  * Nothing here takes a grant: grants come from the org config only. The
27
29
  * host enforces agents.max_concurrent on every start.
28
30
  */
29
- import * as fs from "fs";
30
- import * as path from "path";
31
+ import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
32
+ import { HostError } from "../../shared/sessions/client.js";
31
33
  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";
34
+ AgentError,
35
+ listAgents,
36
+ listPersonas as personaList,
37
+ loadAgentWorkspaces,
38
+ resolveAgentsDeps,
39
+ resumeAgent,
40
+ startAgent,
41
+ stopAgent,
42
+ type AgentsDeps,
43
+ type AgentsResolved,
44
+ type EarlyExit,
45
+ type Started,
46
+ } from "./agent-ops.js";
69
47
  import {
70
48
  columns,
71
- contractInfo,
72
49
  guarded,
73
- loadWorkspaces,
74
50
  noExtra,
75
51
  parseArgs,
76
- resolveDeps,
77
- state,
78
52
  UsageError,
79
- withClient,
80
53
  workspaceFlag,
81
- type Resolved,
82
- type SessionsDeps,
83
54
  } from "./sessions-command.js";
84
55
 
56
+ export {
57
+ agentMcp,
58
+ agentRows,
59
+ EXIT_WATCH_MS,
60
+ plainLines,
61
+ type AgentRow,
62
+ type AgentsDeps,
63
+ } from "./agent-ops.js";
64
+
85
65
  export const AGENTS_HELP = {
86
66
  usage: "agents <list|start|stop|resume|personas> ...",
87
67
  lines: [
@@ -106,193 +86,13 @@ export const AGENTS_HELP = {
106
86
  ],
107
87
  };
108
88
 
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
89
  async function list(args: string[], d: AgentsResolved): Promise<number> {
286
90
  const where = "agents list";
287
91
  const p = parseArgs(where, args, { "--workspace": true, "--json": false });
288
92
  noExtra(where, p);
289
93
  const ws = workspaceFlag(where, p);
290
94
  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);
95
+ const rows = await listAgents(d, ws);
296
96
  if (p.flags["--json"]) {
297
97
  d.out(JSON.stringify(rows, null, 2));
298
98
  return 0;
@@ -327,210 +127,36 @@ async function list(args: string[], d: AgentsResolved): Promise<number> {
327
127
  return 0;
328
128
  }
329
129
 
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);
130
+ /** An early exit, as the terminal shows it: its code and last lines. */
131
+ function printEarlyExit(d: AgentsResolved, id: string, e: EarlyExit): void {
132
+ const code = e.exitCode === undefined ? "" : ` (exit ${e.exitCode})`;
133
+ d.err(`✗ agent ${id} exited at once${code}`);
134
+ if (e.tail.length) {
135
+ d.err(` its last ${e.tail.length === 1 ? "line" : "lines"}:`);
136
+ for (const l of e.tail) d.err(` | ${l}`);
338
137
  }
138
+ d.err(` All of it: ${d.cli} sessions attach ${e.sessionName}`);
339
139
  }
340
140
 
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);
141
+ /** A start's or resume's warnings and outcome; its exit code. */
142
+ function report(d: AgentsResolved, s: Started, line: string): number {
143
+ for (const w of s.warnings) d.err(w);
144
+ if (s.early) {
145
+ printEarlyExit(d, s.rec.id, s.early);
146
+ return 1;
350
147
  }
148
+ d.out(line);
149
+ d.out(` attach: ${d.cli} sessions attach ${s.rec.sessionName}`);
150
+ return 0;
351
151
  }
352
152
 
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
- ]);
153
+ /** A flag's string value, or undefined. */
154
+ function str(
155
+ p: ReturnType<typeof parseArgs>,
156
+ flag: string,
157
+ ): string | undefined {
158
+ const v = p.flags[flag];
159
+ return typeof v === "string" ? v : undefined;
534
160
  }
535
161
 
536
162
  async function start(args: string[], d: AgentsResolved): Promise<number> {
@@ -545,188 +171,57 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
545
171
  noExtra(where, p);
546
172
  const ws = workspaceFlag(where, p);
547
173
  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;
174
+ const persona = str(p, "--persona");
175
+ if (!persona) throw new UsageError(`${where} needs --persona <p>`);
176
+ const s = await startAgent(d, {
177
+ workspace: ws,
178
+ persona,
179
+ runtime: str(p, "--runtime"),
180
+ model: str(p, "--model"),
181
+ id: str(p, "--id"),
623
182
  });
183
+ return report(
184
+ d,
185
+ s,
186
+ `started agent ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) as ${s.rec.sessionName} in ${s.rec.cwd}`,
187
+ );
624
188
  }
625
189
 
626
- // ── stop, resume ────────────────────────────────────────────────────────────
627
-
628
- function recordFor(
190
+ /** `<id> [-w <ws>]`. */
191
+ function idAndWorkspace(
629
192
  where: string,
630
- p: ReturnType<typeof parseArgs>,
631
- d: AgentsResolved,
632
- ): AgentRecord {
193
+ args: string[],
194
+ ): { id: string; ws?: string } {
195
+ const p = parseArgs(where, args, { "--workspace": true });
196
+ noExtra(where, p, 1);
633
197
  const id = p.positional[0];
634
198
  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;
199
+ return { id, ws: workspaceFlag(where, p) };
643
200
  }
644
201
 
645
202
  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
- });
203
+ const { id, ws } = idAndWorkspace("agents stop", args);
204
+ const { rec, session: s } = await stopAgent(d, id, ws);
205
+ d.out(
206
+ s
207
+ ? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid})`
208
+ : `agent ${rec.id} was not running; marked stopped`,
209
+ );
210
+ return 0;
661
211
  }
662
212
 
663
213
  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;
214
+ const { id, ws } = idAndWorkspace("agents resume", args);
215
+ const s = await resumeAgent(d, id, ws);
216
+ if (s.fresh)
724
217
  d.out(
725
- `${fresh ? "started" : "resumed"} agent ${rec.id} as ${next.sessionName} in ${next.cwd}`,
218
+ `agent ${s.rec.id}: starting fresh: it was never prompted, so there is no conversation to resume`,
726
219
  );
727
- d.out(` attach: ${d.cli} sessions attach ${next.sessionName}`);
728
- return 0;
729
- });
220
+ return report(
221
+ d,
222
+ s,
223
+ `${s.fresh ? "started" : "resumed"} agent ${s.rec.id} as ${s.rec.sessionName} in ${s.rec.cwd}`,
224
+ );
730
225
  }
731
226
 
732
227
  async function listPersonas(
@@ -736,17 +231,9 @@ async function listPersonas(
736
231
  const where = "agents personas";
737
232
  const p = parseArgs(where, args, { "--json": false });
738
233
  noExtra(where, p);
739
- const all = [...personas(d, agentsConfig(d)).values()].sort((a, b) =>
740
- a.id.localeCompare(b.id),
741
- );
234
+ const all = personaList(d);
742
235
  if (p.flags["--json"]) {
743
- d.out(
744
- JSON.stringify(
745
- all.map(({ prompt: _p, ...rest }) => rest),
746
- null,
747
- 2,
748
- ),
749
- );
236
+ d.out(JSON.stringify(all, null, 2));
750
237
  return 0;
751
238
  }
752
239
  for (const l of columns([
@@ -778,14 +265,12 @@ export async function cmdAgents(
778
265
  case "list":
779
266
  return await list(rest, d);
780
267
  case "start":
781
- await loadWorkspaces(deps);
782
- await loadWorkspaceDir(deps);
268
+ await loadAgentWorkspaces(deps);
783
269
  return await start(rest, d);
784
270
  case "stop":
785
271
  return await stop(rest, d);
786
272
  case "resume":
787
- await loadWorkspaces(deps);
788
- await loadWorkspaceDir(deps);
273
+ await loadAgentWorkspaces(deps);
789
274
  return await resume(rest, d);
790
275
  case "personas":
791
276
  return await listPersonas(rest, d);
@@ -793,17 +278,14 @@ export async function cmdAgents(
793
278
  throw new UsageError(`unknown agents command "${sub}"`);
794
279
  }
795
280
  } catch (e) {
796
- if (e instanceof Refused || e instanceof LaunchError) {
281
+ if (e instanceof AgentError) {
282
+ if (e.usage) throw new UsageError(e.message);
797
283
  d.err(`✗ ${e.message}`);
798
- for (const h of e instanceof Refused ? e.hints : []) d.err(` ${h}`);
284
+ for (const h of e.hints) d.err(` ${h}`);
799
285
  return 1;
800
286
  }
801
287
  if (e instanceof HostError) {
802
288
  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
289
  return 1;
808
290
  }
809
291
  throw e;