@monster-orange/den 0.4.6 → 0.4.8

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 (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -20,10 +20,17 @@
20
20
  * persona, runtime and model; a record whose session is gone is
21
21
  * listed as stopped or ended. A plain session is not an agent.
22
22
  * stop ends its session (TERM) and marks the record stopped.
23
- * resume only when its runtime declares resume and its session is not
24
- * running: a new session, in the same cwd, continuing the last run;
23
+ * resume only when its runtime declares resume, its record holds the
24
+ * runtime's resume token, and its session is not running: a new
25
+ * session, in the same cwd and workspace with the grants it had,
26
+ * continuing the last conversation (den-claude: --resume <token>);
25
27
  * when that session was never prompted, a fresh start under the
26
28
  * same conversation id (there is nothing to continue).
29
+ * --all-interrupted (AG-14.3): every agent interrupted when Den or
30
+ * the machine restarted that can resume.
31
+ * rm (AG-14.1b) forgets a stopped, interrupted or exited agent (never
32
+ * a running one); --stopped forgets every one of -w <ws> (or of
33
+ * every workspace). The runtime's own history stays.
27
34
  * doctor (AG-0.3, AG-0.5) each runtime of the catalog, installed
28
35
  * first: its status, the CLI it runs and where that came from
29
36
  * (workspace or user.yml setting, login shell, PATH, install
@@ -33,21 +40,30 @@
33
40
  * fix when it is missing. Exit 1 when the default runtime is
34
41
  * unavailable. It resolves the login shell's PATH first, as the
35
42
  * web server does at start.
43
+ * handback-check <file|-> (den#5) the hand-back check (handback.ts) on a
44
+ * file or stdin: each problem on its own line, exit 1 when there
45
+ * is one, 0 when the hand-back is complete and plain.
36
46
  *
37
47
  * Nothing here takes a grant: grants come from the org config only. The
38
48
  * host enforces agents.max_concurrent on every start, counting agents only.
39
49
  */
50
+ import * as fs from "fs";
40
51
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
41
52
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
42
53
  import { HostError } from "../../shared/sessions/client.js";
54
+ import { checkHandback } from "../../shared/agents/handback.js";
55
+ import { listRecords } from "../../shared/agents/records.js";
43
56
  import {
44
57
  AgentError,
45
58
  listAgents,
46
59
  listPersonas as personaList,
47
60
  listRuntimes,
48
61
  loadAgentWorkspaces,
62
+ removeAgent,
63
+ removeStopped,
49
64
  resolveAgentsDeps,
50
65
  resumeAgent,
66
+ resumeInterrupted,
51
67
  startAgent,
52
68
  stopAgent,
53
69
  type AgentsDeps,
@@ -75,25 +91,41 @@ export {
75
91
  } from "./agent-ops.js";
76
92
 
77
93
  export const AGENTS_HELP = {
78
- usage: "agents <list|start|stop|resume|personas|doctor> ...",
94
+ usage:
95
+ "agents <list|start|stop|resume|rm|personas|doctor|handback-check> ...",
79
96
  lines: [
80
97
  "Agents: a persona and a workspace, run by a runtime in a session that",
81
98
  "outlives the web server (see `{cli} sessions`).",
82
99
  "",
83
- "{cli} agents list -w <ws> [--json]",
100
+ "{cli} agents list -w <ws> [--stopped] [--json]",
101
+ " --stopped: only the stopped, interrupted and exited ones",
84
102
  "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
85
103
  " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
86
104
  " (plus agents.<id>.grants); capped by agents.max_concurrent",
87
- "{cli} agents stop <id> [-w <ws>]",
105
+ "{cli} agents stop <id> [-w <ws>] [--no-wait]",
106
+ " waits up to 5 s for the session to end (else: still stopping, exit 1);",
107
+ " --no-wait: return once TERM is sent",
88
108
  "{cli} agents resume <id> [-w <ws>]",
89
- " only when its runtime declares resume and its session has ended;",
90
- " a session never prompted starts fresh under the same conversation id",
109
+ "{cli} agents resume --all-interrupted [-w <ws>]",
110
+ " the same conversation, persona, workspace and folder, when its runtime",
111
+ " can resume one and its session has ended; with nothing to resume yet,",
112
+ " it starts fresh under the same conversation id. --all-interrupted: every",
113
+ " agent interrupted when Den or the machine restarted",
114
+ "{cli} agents rm <id> [-w <ws>]",
115
+ "{cli} agents rm --stopped [-w <ws>]",
116
+ " forget a stopped, interrupted or exited agent (never a running one);",
117
+ " the runtime keeps its own history. agents.remove_stopped_after in",
118
+ " user.yml (never, 1d, 7d) forgets stopped ones on its own",
91
119
  "{cli} agents personas [--json]",
92
120
  " Den's default pack, then agents.persona_packs (a later pack wins by id)",
93
121
  "{cli} agents doctor [-w <ws>] [--json]",
94
122
  " each runtime, installed first: status, the CLI it runs and where it came",
95
123
  " from, its version, the directories searched, and the fix; exit 1 when the",
96
124
  " default is missing. -w: as that workspace's agents find them",
125
+ "{cli} agents handback-check <file|-> [--json]",
126
+ " checks a hand-back to a person: URL, machine, branch, what changed, what",
127
+ " to click and decisions are all there, every ID is explained, and each",
128
+ " decision is a numbered yes/no question; exit 1 with what to fix",
97
129
  "",
98
130
  "Runtimes: den-claude (default; Claude Code's claude), codex (OpenAI's Codex",
99
131
  "CLI), gemini (Google's Gemini CLI), grok (xAI's Grok Build), and generic (the",
@@ -111,17 +143,27 @@ export const AGENTS_HELP = {
111
143
 
112
144
  async function list(args: string[], d: AgentsResolved): Promise<number> {
113
145
  const where = "agents list";
114
- const p = parseArgs(where, args, { "--workspace": true, "--json": false });
146
+ const p = parseArgs(where, args, {
147
+ "--workspace": true,
148
+ "--json": false,
149
+ "--stopped": false,
150
+ });
115
151
  noExtra(where, p);
116
152
  const ws = workspaceFlag(where, p);
117
153
  if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
118
- const rows = await listAgents(d, ws);
154
+ const rows = await listAgents(d, ws, {
155
+ stopped: p.flags["--stopped"] === true,
156
+ });
119
157
  if (p.flags["--json"]) {
120
158
  d.out(JSON.stringify(rows, null, 2));
121
159
  return 0;
122
160
  }
123
161
  if (!rows.length) {
124
- d.out(`no agents in ${ws}`);
162
+ d.out(
163
+ p.flags["--stopped"]
164
+ ? `no stopped agents in ${ws}`
165
+ : `no agents in ${ws}`,
166
+ );
125
167
  return 0;
126
168
  }
127
169
  for (const l of columns([
@@ -223,22 +265,40 @@ function idAndWorkspace(
223
265
  }
224
266
 
225
267
  async function stop(args: string[], d: AgentsResolved): Promise<number> {
226
- const { id, ws } = idAndWorkspace("agents stop", args);
227
- const { rec, session: s } = await stopAgent(d, id, ws);
268
+ // --no-wait: return once TERM is sent (before 0.4.8, always).
269
+ const wait = !args.includes("--no-wait");
270
+ const { id, ws } = idAndWorkspace(
271
+ "agents stop",
272
+ args.filter((a) => a !== "--no-wait"),
273
+ );
274
+ const {
275
+ rec,
276
+ session: s,
277
+ stillStopping,
278
+ } = await stopAgent(d, id, ws, {
279
+ wait,
280
+ });
281
+ if (stillStopping) {
282
+ d.err(
283
+ `${rec.id} is still stopping (sent TERM to ${s!.name}, pid ${s!.pid}; not ended after ${d.stopWaitMs / 1000} s)`,
284
+ );
285
+ return 1;
286
+ }
228
287
  d.out(
229
288
  s
230
- ? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid})`
289
+ ? `stopped agent ${rec.id} (sent TERM to ${s.name}, pid ${s.pid}${wait && d.stopWaitMs > 0 ? "; it has ended" : ""})`
231
290
  : `agent ${rec.id} was not running; marked stopped`,
232
291
  );
233
292
  return 0;
234
293
  }
235
294
 
236
295
  async function resume(args: string[], d: AgentsResolved): Promise<number> {
296
+ if (args.includes("--all-interrupted")) return resumeAll(args, d);
237
297
  const { id, ws } = idAndWorkspace("agents resume", args);
238
298
  const s = await resumeAgent(d, id, ws);
239
299
  if (s.fresh)
240
300
  d.out(
241
- `agent ${s.rec.id}: starting fresh: it was never prompted, so there is no conversation to resume`,
301
+ `agent ${s.rec.id}: nothing to resume yet; started fresh (same conversation id, folder and scope)`,
242
302
  );
243
303
  return report(
244
304
  d,
@@ -247,6 +307,58 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
247
307
  );
248
308
  }
249
309
 
310
+ /** `agents resume --all-interrupted [-w <ws>]` (AG-14.3). */
311
+ async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
312
+ const where = "agents resume";
313
+ const p = parseArgs(where, args, {
314
+ "--workspace": true,
315
+ "--all-interrupted": false,
316
+ });
317
+ noExtra(where, p);
318
+ const ws = workspaceFlag(where, p);
319
+ const r = await resumeInterrupted(d, ws ? [ws] : undefined);
320
+ if (!r.resumed.length && !r.failed.length) {
321
+ d.out(`no interrupted agents${ws ? ` in ${ws}` : ""}`);
322
+ return 0;
323
+ }
324
+ for (const x of r.resumed)
325
+ d.out(
326
+ `${x.fresh ? "started" : "resumed"} agent ${x.rec.id} as ${x.rec.sessionName} in ${x.rec.cwd}`,
327
+ );
328
+ for (const f of r.failed) d.err(`✗ agent ${f.agent}: ${f.why}`);
329
+ return r.failed.length ? 1 : 0;
330
+ }
331
+
332
+ /** `agents rm <id> [-w <ws>]`, `agents rm --stopped [-w <ws>]` (AG-14.1b). */
333
+ async function rm(args: string[], d: AgentsResolved): Promise<number> {
334
+ const where = "agents rm";
335
+ const p = parseArgs(where, args, { "--workspace": true, "--stopped": false });
336
+ noExtra(where, p, 1);
337
+ const ws = workspaceFlag(where, p);
338
+ const id = p.positional[0];
339
+ if (p.flags["--stopped"]) {
340
+ if (id) throw new UsageError(`${where}: give <id> or --stopped, not both`);
341
+ if (ws && !d.workspaceExists(ws))
342
+ throw new UsageError(`${where}: workspace "${ws}" does not exist`);
343
+ const wss = ws
344
+ ? [ws]
345
+ : [...new Set(listRecords(d.stateDir()).map((r) => r.workspace))];
346
+ const gone = await removeStopped(d, wss);
347
+ d.out(
348
+ gone.length
349
+ ? `removed ${gone.length} stopped ${gone.length === 1 ? "agent" : "agents"}: ${gone.map((g) => g.agent).join(", ")}`
350
+ : `no stopped agents${ws ? ` in ${ws}` : ""}`,
351
+ );
352
+ return 0;
353
+ }
354
+ if (!id) throw new UsageError(`${where} needs <id> or --stopped`);
355
+ const [gone] = await removeAgent(d, id, ws);
356
+ d.out(
357
+ `removed agent ${gone?.agent ?? id}; its conversation stays in the runtime's own history`,
358
+ );
359
+ return 0;
360
+ }
361
+
250
362
  async function listPersonas(
251
363
  args: string[],
252
364
  d: AgentsResolved,
@@ -276,6 +388,45 @@ async function listPersonas(
276
388
  export interface AgentsDoctorDeps {
277
389
  /** Resolve the login shell's PATH first (default: initLoginPath). */
278
390
  loginPath?: () => Promise<void>;
391
+ /** Read a hand-back: a file path, or "-" for stdin (handback-check). */
392
+ readHandback?: (src: string) => Promise<string>;
393
+ }
394
+
395
+ async function readFileOrStdin(src: string): Promise<string> {
396
+ if (src !== "-") return fs.promises.readFile(src, "utf-8");
397
+ let text = "";
398
+ for await (const chunk of process.stdin) text += String(chunk);
399
+ return text;
400
+ }
401
+
402
+ /** `agents handback-check <file|-> [--json]`. */
403
+ async function handbackCheck(
404
+ args: string[],
405
+ d: AgentsResolved,
406
+ deps: AgentsDoctorDeps,
407
+ ): Promise<number> {
408
+ const where = "agents handback-check";
409
+ const p = parseArgs(where, args, { "--json": false });
410
+ if (p.positional.length !== 1 || p.rest)
411
+ throw new UsageError(`${where} takes one file, or - for stdin`);
412
+ const src = p.positional[0];
413
+ let text: string;
414
+ try {
415
+ text = await (deps.readHandback ?? readFileOrStdin)(src);
416
+ } catch (e) {
417
+ d.err(
418
+ `✗ ${where}: can't read ${src} (${(e as NodeJS.ErrnoException).code ?? "error"})`,
419
+ );
420
+ return 1;
421
+ }
422
+ const r = checkHandback(text);
423
+ if (p.flags["--json"]) d.out(JSON.stringify(r, null, 2));
424
+ else if (r.ok) d.out("✓ the hand-back is complete and plain");
425
+ else {
426
+ d.out("✗ the hand-back is not ready:");
427
+ for (const l of r.problems) d.out(` - ${l}`);
428
+ }
429
+ return r.ok ? 0 : 1;
279
430
  }
280
431
 
281
432
  const SOURCE_WORD: Record<string, string> = {
@@ -393,12 +544,17 @@ export async function cmdAgents(
393
544
  case "resume":
394
545
  await loadAgentWorkspaces(deps);
395
546
  return await resume(rest, d);
547
+ case "rm":
548
+ await loadAgentWorkspaces(deps);
549
+ return await rm(rest, d);
396
550
  case "personas":
397
551
  return await listPersonas(rest, d);
398
552
  case "doctor":
399
553
  // -w checks the workspace exists, and reads its runtime paths.
400
554
  await loadAgentWorkspaces(deps);
401
555
  return await doctor(rest, d, deps);
556
+ case "handback-check":
557
+ return await handbackCheck(rest, d, deps);
402
558
  default:
403
559
  throw new UsageError(`unknown agents command "${sub}"`);
404
560
  }
@@ -15,12 +15,19 @@
15
15
  * plain session and each available runtime's agent env (env-probe.ts), and
16
16
  * exits 1 when a runtime's scrubEnv name or a credential-shaped variable
17
17
  * reaches one. Without the PTY module the probe is unavailable, not failed.
18
+ *
19
+ * It also reads the tail of the local .dev-metrics/events.jsonl (bounded,
20
+ * polling.ts) and warns when one command, run from one place, ran more than
21
+ * 600 times in an hour: an agent polling it. A warning, never a failure;
22
+ * nothing is sent.
18
23
  */
19
24
  import chalk from "chalk";
20
25
  import { readDenConfig, type DenConfig } from "../../shared/den-config.js";
21
26
  import { getStateDir } from "../../shared/paths.js";
22
27
  import { product } from "../../shared/product.js";
23
28
  import { listRecords } from "../../shared/agents/records.js";
29
+ import { eventsFile } from "../../shared/dev-metrics/emit.js";
30
+ import { pollingWarnings } from "../../shared/dev-metrics/polling.js";
24
31
  import { readBrand } from "../../shared/brand.js";
25
32
  import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
26
33
  import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
@@ -219,6 +226,18 @@ export function doctorNotes(cli: string, configured: boolean): string[] {
219
226
  : [];
220
227
  }
221
228
 
229
+ /** The polling-loop warnings for this events file, as doctor prints them. */
230
+ export function pollingLines(file: string, title: string): string[] {
231
+ const warnings = pollingWarnings(file);
232
+ if (!warnings.length) return [];
233
+ return [
234
+ "",
235
+ chalk.bold(`${title} doctor: dev-metrics (local, never sent)`),
236
+ "",
237
+ ...warnings.map((w) => chalk.yellow(` ⚠ ${w}`)),
238
+ ];
239
+ }
240
+
222
241
  function readConfigOrNull(): DenConfig | null {
223
242
  try {
224
243
  return readDenConfig();
@@ -293,6 +312,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
293
312
  }
294
313
  const rows = doctorRows();
295
314
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
315
+ for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
296
316
  const cli = product().cli;
297
317
  if (!probe) {
298
318
  for (const l of doctorNotes(
@@ -215,6 +215,7 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
215
215
  "--json",
216
216
  ],
217
217
  down: [
218
+ "--json",
218
219
  "--manifest",
219
220
  "--no-derive",
220
221
  "-a",
@@ -196,6 +196,7 @@ import { cmdEdge } from "./edge-command.js";
196
196
  import { cmdDoctor } from "./doctor-command.js";
197
197
  import { cmdSessions, SESSIONS_HELP } from "./sessions-command.js";
198
198
  import { AGENTS_HELP, cmdAgents } from "./agents-command.js";
199
+ import { USAGE_HELP, cmdUsage } from "./usage-command.js";
199
200
  import { cmdService } from "./service-command.js";
200
201
  import { cmdStack } from "./stack-command.js";
201
202
  import { cmdTheme } from "./theme-command.js";
@@ -536,6 +537,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
536
537
  },
537
538
  sessions: SESSIONS_HELP,
538
539
  agents: AGENTS_HELP,
540
+ usage: USAGE_HELP,
539
541
  };
540
542
 
541
543
  function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
@@ -588,6 +590,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
588
590
  "mcp",
589
591
  "sessions",
590
592
  "agents",
593
+ "usage",
591
594
  "service",
592
595
  "theme",
593
596
  "plugin",
@@ -616,6 +619,8 @@ const CONFIG_FREE_COMMANDS = new Set<string>([
616
619
  // listing a session is never stranded by a config that fails (AG-3a).
617
620
  "sessions",
618
621
  "agents",
622
+ // den#6: reads local transcripts and agent records, no org data.
623
+ "usage",
619
624
  ]);
620
625
 
621
626
  function configCheckedCommand(command: string): boolean {
@@ -900,6 +905,12 @@ class DenCLI {
900
905
 
901
906
  case "web": {
902
907
  const sub = args[1];
908
+ if (args.includes("--json") && (sub === "down" || sub === "stop")) {
909
+ process.exitCode = await this.cmdWebDownJson(
910
+ args.slice(1).filter((a) => a !== "--json"),
911
+ );
912
+ break;
913
+ }
903
914
  if (
904
915
  args.includes("--json") &&
905
916
  (sub === undefined ||
@@ -926,7 +937,11 @@ class DenCLI {
926
937
  break;
927
938
 
928
939
  case "down":
929
- await this.cmdServiceDown(args.slice(1));
940
+ if (args.slice(1).includes("--json"))
941
+ process.exitCode = await this.cmdServiceDownJson(
942
+ args.slice(1).filter((a) => a !== "--json"),
943
+ );
944
+ else await this.cmdServiceDown(args.slice(1));
930
945
  break;
931
946
 
932
947
  case "restart":
@@ -1072,6 +1087,10 @@ class DenCLI {
1072
1087
  process.exitCode = await cmdAgents(args.slice(1));
1073
1088
  break;
1074
1089
 
1090
+ case "usage":
1091
+ process.exitCode = await cmdUsage(args.slice(1));
1092
+ break;
1093
+
1075
1094
  case "mcp": {
1076
1095
  // Setup subcommands: register the server with agents.
1077
1096
  const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
@@ -3276,6 +3295,101 @@ class DenCLI {
3276
3295
  return out.ok ? 0 : 1;
3277
3296
  }
3278
3297
 
3298
+ /**
3299
+ * `down --json`: down as it always runs, its log on stderr, then one JSON
3300
+ * object on stdout, `{ ok, workspace, services, all? }`. Safe to re-run:
3301
+ * stopping what is already stopped is ok. A failure names a fix.
3302
+ */
3303
+ private async cmdServiceDownJson(args: string[]): Promise<number> {
3304
+ const bin = fs.existsSync(
3305
+ nodePath.join(getDistRoot(), "bin", product().cli),
3306
+ )
3307
+ ? `./bin/${product().cli}`
3308
+ : product().cli;
3309
+ const before = process.exitCode;
3310
+ process.exitCode = undefined;
3311
+ let error: string | undefined;
3312
+ try {
3313
+ await quietStdout(() => this.cmdServiceDown(args));
3314
+ } catch (e) {
3315
+ if (!(e instanceof ExitRequested)) error = (e as Error).message;
3316
+ else if (e.code !== 0) process.exitCode = e.code;
3317
+ }
3318
+ const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
3319
+ process.exitCode = before;
3320
+ const terminator = args.indexOf("--");
3321
+ const own = terminator === -1 ? args : args.slice(0, terminator);
3322
+ const all = own.includes("-a") || own.includes("--all");
3323
+ const out: Record<string, unknown> = {
3324
+ ok: !error && !failedExit,
3325
+ workspace:
3326
+ process.env.DEN_WORKSPACE_OVERRIDE ?? getFocusedWorkspaceName() ?? null,
3327
+ services: own.filter((a) => !a.startsWith("-")),
3328
+ ...(all ? { all: true } : {}),
3329
+ };
3330
+ if (!out.ok) {
3331
+ out.error = error ?? "down failed (its log is on stderr)";
3332
+ out.fix = `${bin} doctor --json`;
3333
+ }
3334
+ printJson(out);
3335
+ return out.ok ? 0 : 1;
3336
+ }
3337
+
3338
+ /**
3339
+ * `web down --json`: the web server stopped as usual (its log on stderr),
3340
+ * then `{ ok, url, port, running: false, stopped }` once nothing answers
3341
+ * (up to 10s). `stopped` is false when it wasn't running: re-running is ok.
3342
+ */
3343
+ private async cmdWebDownJson(args: string[]): Promise<number> {
3344
+ const { parseWebCommandArgs } = await import("./web-command.js");
3345
+ const { probeWebServer } = await import("./mcp-connect.js");
3346
+ const bin = fs.existsSync(
3347
+ nodePath.join(getDistRoot(), "bin", product().cli),
3348
+ )
3349
+ ? `./bin/${product().cli}`
3350
+ : product().cli;
3351
+ let port: number;
3352
+ try {
3353
+ port = parseWebCommandArgs(args).port;
3354
+ } catch (e) {
3355
+ printJson({
3356
+ ok: false,
3357
+ error: (e as Error).message,
3358
+ fix: `${bin} web down --json`,
3359
+ });
3360
+ return 2;
3361
+ }
3362
+ const url = `http://localhost:${port}`;
3363
+ const was = (await probeWebServer(url)).kind === "up";
3364
+ let error: string | undefined;
3365
+ try {
3366
+ await quietStdout(() => cmdWeb(args));
3367
+ } catch (e) {
3368
+ if (!(e instanceof ExitRequested) || e.code !== 0)
3369
+ error = (e as Error).message;
3370
+ }
3371
+ let probe = await probeWebServer(url);
3372
+ const deadline = Date.now() + 10_000;
3373
+ while (!error && probe.kind === "up" && Date.now() < deadline) {
3374
+ await new Promise((r) => setTimeout(r, 250));
3375
+ probe = await probeWebServer(url);
3376
+ }
3377
+ const running = probe.kind === "up";
3378
+ const out: Record<string, unknown> = {
3379
+ ok: !error && !running,
3380
+ url,
3381
+ port,
3382
+ running,
3383
+ stopped: was && !running,
3384
+ };
3385
+ if (!out.ok) {
3386
+ out.error = error ?? `the web server still answers at ${url}`;
3387
+ out.fix = `${bin} doctor --json`;
3388
+ }
3389
+ printJson(out);
3390
+ return out.ok ? 0 : 1;
3391
+ }
3392
+
3279
3393
  /**
3280
3394
  * `web up|restart --json` (ON-2): the web server started as usual (its
3281
3395
  * log on stderr), then `{ ok, url, port, version? }` once it answers
@@ -5193,7 +5307,10 @@ class DenCLI {
5193
5307
  );
5194
5308
  console.log(" agents list -w <ws> The workspace's agents");
5195
5309
  console.log(
5196
- " agents start -w <ws> --persona <p> Run a persona in the workspace (stop, resume, personas)",
5310
+ " agents start -w <ws> --persona <p> Run a persona in the workspace (stop, resume, rm, personas)",
5311
+ );
5312
+ console.log(
5313
+ " usage agents|report Agents' tokens and outcomes, from local transcripts (never sent)",
5197
5314
  );
5198
5315
  console.log();
5199
5316
  console.log(