@monster-orange/den 0.4.7 → 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 (36) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +120 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +19 -0
  7. package/src/shared/agents/personas.ts +11 -1
  8. package/src/shared/agents/records.ts +114 -0
  9. package/src/shared/agents/runtime.ts +12 -0
  10. package/src/shared/den-config.ts +30 -0
  11. package/src/shared/plugins/manifest.ts +3 -0
  12. package/src/shared/usage/paths.ts +16 -0
  13. package/src/shared/usage/report.ts +283 -0
  14. package/src/shared/usage/sources.ts +127 -0
  15. package/src/shared/usage/transcript.ts +262 -0
  16. package/src/ui/cli/agent-ops.ts +488 -50
  17. package/src/ui/cli/agents-command.ts +120 -14
  18. package/src/ui/cli/index.ts +13 -1
  19. package/src/ui/cli/usage-command.ts +256 -0
  20. package/src/ui/mcp/api.ts +10 -1
  21. package/src/ui/mcp/server.ts +34 -0
  22. package/src/ui/mcp/tools.ts +50 -0
  23. package/src/ui/server/agent-resume-grant.ts +53 -0
  24. package/src/ui/server/agent-routes.ts +230 -9
  25. package/src/ui/server/audit.ts +68 -1
  26. package/src/ui/server/index.ts +16 -5
  27. package/src/ui/server/public/agents-page.js +333 -11
  28. package/src/ui/server/public/agents-section.js +17 -3
  29. package/src/ui/server/public/agents.html +44 -0
  30. package/src/ui/server/public/i18n/es.js +28 -0
  31. package/src/ui/server/public/i18n/ja.js +25 -0
  32. package/src/ui/server/public/shell.js +66 -0
  33. package/src/ui/server/request-guard.ts +21 -1
  34. package/src/ui/server/route-classes.ts +23 -0
  35. package/src/ui/server/session-routes.ts +52 -4
  36. 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
@@ -45,14 +52,18 @@ import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
45
52
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
46
53
  import { HostError } from "../../shared/sessions/client.js";
47
54
  import { checkHandback } from "../../shared/agents/handback.js";
55
+ import { listRecords } from "../../shared/agents/records.js";
48
56
  import {
49
57
  AgentError,
50
58
  listAgents,
51
59
  listPersonas as personaList,
52
60
  listRuntimes,
53
61
  loadAgentWorkspaces,
62
+ removeAgent,
63
+ removeStopped,
54
64
  resolveAgentsDeps,
55
65
  resumeAgent,
66
+ resumeInterrupted,
56
67
  startAgent,
57
68
  stopAgent,
58
69
  type AgentsDeps,
@@ -80,19 +91,31 @@ export {
80
91
  } from "./agent-ops.js";
81
92
 
82
93
  export const AGENTS_HELP = {
83
- usage: "agents <list|start|stop|resume|personas|doctor|handback-check> ...",
94
+ usage:
95
+ "agents <list|start|stop|resume|rm|personas|doctor|handback-check> ...",
84
96
  lines: [
85
97
  "Agents: a persona and a workspace, run by a runtime in a session that",
86
98
  "outlives the web server (see `{cli} sessions`).",
87
99
  "",
88
- "{cli} agents list -w <ws> [--json]",
100
+ "{cli} agents list -w <ws> [--stopped] [--json]",
101
+ " --stopped: only the stopped, interrupted and exited ones",
89
102
  "{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
90
103
  " runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
91
104
  " (plus agents.<id>.grants); capped by agents.max_concurrent",
92
- "{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",
93
108
  "{cli} agents resume <id> [-w <ws>]",
94
- " only when its runtime declares resume and its session has ended;",
95
- " 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",
96
119
  "{cli} agents personas [--json]",
97
120
  " Den's default pack, then agents.persona_packs (a later pack wins by id)",
98
121
  "{cli} agents doctor [-w <ws>] [--json]",
@@ -120,17 +143,27 @@ export const AGENTS_HELP = {
120
143
 
121
144
  async function list(args: string[], d: AgentsResolved): Promise<number> {
122
145
  const where = "agents list";
123
- 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
+ });
124
151
  noExtra(where, p);
125
152
  const ws = workspaceFlag(where, p);
126
153
  if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
127
- const rows = await listAgents(d, ws);
154
+ const rows = await listAgents(d, ws, {
155
+ stopped: p.flags["--stopped"] === true,
156
+ });
128
157
  if (p.flags["--json"]) {
129
158
  d.out(JSON.stringify(rows, null, 2));
130
159
  return 0;
131
160
  }
132
161
  if (!rows.length) {
133
- 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
+ );
134
167
  return 0;
135
168
  }
136
169
  for (const l of columns([
@@ -232,22 +265,40 @@ function idAndWorkspace(
232
265
  }
233
266
 
234
267
  async function stop(args: string[], d: AgentsResolved): Promise<number> {
235
- const { id, ws } = idAndWorkspace("agents stop", args);
236
- 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
+ }
237
287
  d.out(
238
288
  s
239
- ? `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" : ""})`
240
290
  : `agent ${rec.id} was not running; marked stopped`,
241
291
  );
242
292
  return 0;
243
293
  }
244
294
 
245
295
  async function resume(args: string[], d: AgentsResolved): Promise<number> {
296
+ if (args.includes("--all-interrupted")) return resumeAll(args, d);
246
297
  const { id, ws } = idAndWorkspace("agents resume", args);
247
298
  const s = await resumeAgent(d, id, ws);
248
299
  if (s.fresh)
249
300
  d.out(
250
- `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)`,
251
302
  );
252
303
  return report(
253
304
  d,
@@ -256,6 +307,58 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
256
307
  );
257
308
  }
258
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
+
259
362
  async function listPersonas(
260
363
  args: string[],
261
364
  d: AgentsResolved,
@@ -441,6 +544,9 @@ export async function cmdAgents(
441
544
  case "resume":
442
545
  await loadAgentWorkspaces(deps);
443
546
  return await resume(rest, d);
547
+ case "rm":
548
+ await loadAgentWorkspaces(deps);
549
+ return await rm(rest, d);
444
550
  case "personas":
445
551
  return await listPersonas(rest, d);
446
552
  case "doctor":
@@ -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 {
@@ -1082,6 +1087,10 @@ class DenCLI {
1082
1087
  process.exitCode = await cmdAgents(args.slice(1));
1083
1088
  break;
1084
1089
 
1090
+ case "usage":
1091
+ process.exitCode = await cmdUsage(args.slice(1));
1092
+ break;
1093
+
1085
1094
  case "mcp": {
1086
1095
  // Setup subcommands: register the server with agents.
1087
1096
  const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
@@ -5298,7 +5307,10 @@ class DenCLI {
5298
5307
  );
5299
5308
  console.log(" agents list -w <ws> The workspace's agents");
5300
5309
  console.log(
5301
- " 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)",
5302
5314
  );
5303
5315
  console.log();
5304
5316
  console.log(
@@ -0,0 +1,256 @@
1
+ /**
2
+ * `<cli> usage` (den#6): how agents spend tokens and time, read from the
3
+ * runtimes' own transcripts on this machine (usage sources, plugin kind
4
+ * `usage`). Nothing is sent anywhere: it reads local files and writes
5
+ * local files.
6
+ *
7
+ * agents [-w <ws>] [--json] each agent's tokens and outcome (PR opened
8
+ * or merged, tests green, wall-clock), joined
9
+ * on its record's resumeToken, and kept in its
10
+ * record; the share attributed
11
+ * report [--days N] [--json] the week's numbers (default 7 days), written
12
+ * to <state dir>/usage/<date>.{json,md} and
13
+ * compared with the last report there
14
+ */
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import {
18
+ listRecords,
19
+ readRecord,
20
+ writeRecord,
21
+ } from "../../shared/agents/records.js";
22
+ import { getStateDir } from "../../shared/paths.js";
23
+ import { product } from "../../shared/product.js";
24
+ import {
25
+ agentUsage,
26
+ attributedPct,
27
+ renderReport,
28
+ weeklyMetrics,
29
+ type AgentUsageRow,
30
+ type UsageMetrics,
31
+ } from "../../shared/usage/report.js";
32
+ import { usageSources } from "../../shared/usage/sources.js";
33
+ import type { TranscriptStats } from "../../shared/usage/transcript.js";
34
+ import {
35
+ columns,
36
+ parseArgs,
37
+ noExtra,
38
+ UsageError,
39
+ workspaceFlag,
40
+ } from "./sessions-command.js";
41
+
42
+ export const USAGE_HELP = {
43
+ usage: "usage <agents|report> ...",
44
+ lines: [
45
+ "How agents spend tokens and time, from the runtimes' own transcripts on",
46
+ "this machine (Claude Code's: ~/.claude/projects). Read locally; nothing",
47
+ "is ever sent anywhere.",
48
+ "",
49
+ "{cli} usage agents [-w <ws>] [--json]",
50
+ " each agent's tokens and outcome (pull request opened or merged, tests",
51
+ " green, wall-clock), joined on its conversation id, and saved in its",
52
+ " record; then the share of agents with both",
53
+ "{cli} usage report [--days <n>] [--json]",
54
+ " the last n days (default 7): tokens, context re-reads, polling, denials,",
55
+ " tool errors, corrections, subagents on Opus; written to",
56
+ " <state dir>/usage/<date>.json and .md, compared with the last report",
57
+ ],
58
+ };
59
+
60
+ export interface UsageDeps {
61
+ stateDir?: () => string;
62
+ env?: NodeJS.ProcessEnv;
63
+ now?: () => Date;
64
+ out?: (line: string) => void;
65
+ err?: (line: string) => void;
66
+ cli?: string;
67
+ }
68
+
69
+ function resolve(d: UsageDeps) {
70
+ return {
71
+ stateDir: d.stateDir ?? (() => getStateDir()),
72
+ env: d.env ?? process.env,
73
+ now: d.now ?? (() => new Date()),
74
+ out: d.out ?? ((l: string) => console.log(l)),
75
+ err: d.err ?? ((l: string) => console.error(l)),
76
+ cli: d.cli ?? product().cli,
77
+ };
78
+ }
79
+
80
+ const k = (n: number) =>
81
+ n >= 1e9
82
+ ? `${(n / 1e9).toFixed(1)}B`
83
+ : n >= 1e6
84
+ ? `${(n / 1e6).toFixed(1)}M`
85
+ : n >= 1e3
86
+ ? `${Math.round(n / 1e3)}k`
87
+ : String(n);
88
+ const mins = (ms?: number) =>
89
+ ms === undefined
90
+ ? "-"
91
+ : ms < 3_600_000
92
+ ? `${Math.round(ms / 60_000)}m`
93
+ : `${(ms / 3_600_000).toFixed(1)}h`;
94
+
95
+ /** Save each attributed row's usage and outcome in its agent record. */
96
+ function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
97
+ for (const r of rows) {
98
+ if (!r.usage) continue;
99
+ const rec = readRecord(stateDir, r.agent);
100
+ if (!rec) continue;
101
+ writeRecord(stateDir, {
102
+ ...rec,
103
+ usage: r.usage,
104
+ ...(r.outcome ? { outcome: r.outcome } : {}),
105
+ });
106
+ }
107
+ }
108
+
109
+ async function agents(
110
+ args: string[],
111
+ d: ReturnType<typeof resolve>,
112
+ ): Promise<number> {
113
+ const where = "usage agents";
114
+ const p = parseArgs(where, args, { "--workspace": true, "--json": false });
115
+ noExtra(where, p);
116
+ const ws = workspaceFlag(where, p);
117
+ const recs = listRecords(d.stateDir()).filter(
118
+ (r) => !ws || r.workspace === ws,
119
+ );
120
+ const rows = await agentUsage(recs, { env: d.env });
121
+ save(d.stateDir(), rows);
122
+ const pct = attributedPct(rows);
123
+ if (p.flags["--json"]) {
124
+ d.out(JSON.stringify({ agents: rows, attributedPct: pct }, null, 2));
125
+ return 0;
126
+ }
127
+ if (!rows.length) {
128
+ d.out(`no agents${ws ? ` in ${ws}` : ""}`);
129
+ return 0;
130
+ }
131
+ for (const l of columns([
132
+ [
133
+ "AGENT",
134
+ "WORKSPACE",
135
+ "CALLS",
136
+ "OUTPUT",
137
+ "CACHE READ",
138
+ "WALL",
139
+ "TESTS",
140
+ "PR",
141
+ ],
142
+ ...rows.map((r) => [
143
+ r.agent,
144
+ r.workspace,
145
+ r.usage ? String(r.usage.calls) : "-",
146
+ r.usage ? k(r.usage.output) : "-",
147
+ r.usage ? k(r.usage.cacheRead) : "-",
148
+ mins(r.outcome?.wallClockMs),
149
+ r.outcome?.testsGreen === undefined
150
+ ? "-"
151
+ : r.outcome.testsGreen
152
+ ? "green"
153
+ : "red",
154
+ r.outcome?.prOpened
155
+ ? `${r.outcome.prOpened}${r.outcome.prMerged ? " (merged)" : ""}`
156
+ : "-",
157
+ ]),
158
+ ]))
159
+ d.out(l);
160
+ for (const r of rows) if (r.why) d.out(` ${r.agent}: ${r.why}`);
161
+ d.out("");
162
+ d.out(
163
+ `${pct}% of agents have their tokens and outcome (${rows.filter((r) => r.attributed).length} of ${rows.length}). Read locally; nothing was sent.`,
164
+ );
165
+ return 0;
166
+ }
167
+
168
+ /** The newest report in `dir` dated before `date`, if any. */
169
+ function previous(
170
+ dir: string,
171
+ date: string,
172
+ ): { date: string; metrics: Partial<UsageMetrics> } | undefined {
173
+ let names: string[];
174
+ try {
175
+ names = fs.readdirSync(dir);
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ const older = names
180
+ .filter((n) => /^\d{4}-\d{2}-\d{2}\.json$/.test(n) && n < `${date}.json`)
181
+ .sort();
182
+ const last = older[older.length - 1];
183
+ if (!last) return undefined;
184
+ try {
185
+ return {
186
+ date: last.slice(0, -5),
187
+ metrics: JSON.parse(
188
+ fs.readFileSync(path.join(dir, last), "utf-8"),
189
+ ) as Partial<UsageMetrics>,
190
+ };
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ async function report(
197
+ args: string[],
198
+ d: ReturnType<typeof resolve>,
199
+ ): Promise<number> {
200
+ const where = "usage report";
201
+ const p = parseArgs(where, args, { "--days": true, "--json": false });
202
+ noExtra(where, p);
203
+ const days = p.flags["--days"] === undefined ? 7 : Number(p.flags["--days"]);
204
+ if (!Number.isFinite(days) || days <= 0 || days > 366)
205
+ throw new UsageError(`${where}: --days must be a number of days, 1 to 366`);
206
+ const now = d.now();
207
+ const since = now.getTime() - days * 86_400_000;
208
+ const stats: TranscriptStats[] = [];
209
+ for (const src of usageSources())
210
+ for (const f of src.files(since, d.env))
211
+ stats.push(await src.mine(f, { since }));
212
+ const rows = await agentUsage(listRecords(d.stateDir()), { env: d.env });
213
+ save(d.stateDir(), rows);
214
+ const m = weeklyMetrics(stats, rows);
215
+ const date = now.toISOString().slice(0, 10);
216
+ const dir = path.join(d.stateDir(), "usage");
217
+ const prev = previous(dir, date);
218
+ const md = renderReport(m, date, prev);
219
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
220
+ fs.writeFileSync(
221
+ path.join(dir, `${date}.json`),
222
+ JSON.stringify(m, null, 1) + "\n",
223
+ { mode: 0o600 },
224
+ );
225
+ fs.writeFileSync(path.join(dir, `${date}.md`), md, { mode: 0o600 });
226
+ if (p.flags["--json"]) d.out(JSON.stringify(m, null, 2));
227
+ else {
228
+ for (const l of md.trimEnd().split("\n")) d.out(l);
229
+ d.out("");
230
+ d.out(
231
+ `Written to ${path.join(dir, `${date}.md`)} (${stats.length} transcripts, ${days} days).`,
232
+ );
233
+ }
234
+ return 0;
235
+ }
236
+
237
+ /** `<cli> usage ...`; returns the exit code. */
238
+ export async function cmdUsage(
239
+ args: string[],
240
+ deps: UsageDeps = {},
241
+ ): Promise<number> {
242
+ const d = resolve(deps);
243
+ const sub = args[0] ?? "agents";
244
+ try {
245
+ if (sub === "agents") return await agents(args.slice(1), d);
246
+ if (sub === "report") return await report(args.slice(1), d);
247
+ throw new UsageError(`unknown usage command "${sub}"`);
248
+ } catch (e) {
249
+ if (e instanceof UsageError) {
250
+ d.err(`✗ ${e.message}`);
251
+ d.err(`Usage: ${d.cli} ${USAGE_HELP.usage}`);
252
+ return 2;
253
+ }
254
+ throw e;
255
+ }
256
+ }
package/src/ui/mcp/api.ts CHANGED
@@ -140,6 +140,14 @@ const ACTION_ROUTES: Record<string, string> = {
140
140
  "/api/workspace/sync": "POST",
141
141
  };
142
142
 
143
+ /** Action routes with a parameter: an agent's resume (MCP agent_resume). */
144
+ const ACTION_ROUTE_PATTERNS: { re: RegExp; method: string }[] = [
145
+ {
146
+ re: /^\/api\/workspaces\/[A-Za-z0-9._-]{1,64}\/agents\/[A-Za-z0-9._-]{1,64}\/resume$/,
147
+ method: "POST",
148
+ },
149
+ ];
150
+
143
151
  /**
144
152
  * Throws a Refusal unless (method, path) is an allowed route. Never reached:
145
153
  * sudo-password, aws/login, terminal/open, editor/open, workspace/exec,
@@ -155,7 +163,8 @@ export function assertAllowedRequest(
155
163
  (READ_ROUTES.has(path) &&
156
164
  (method === "GET" ||
157
165
  (method === "POST" && path === "/api/manifest/validate"))) ||
158
- ACTION_ROUTES[path] === method;
166
+ ACTION_ROUTES[path] === method ||
167
+ ACTION_ROUTE_PATTERNS.some((r) => r.method === method && r.re.test(path));
159
168
  if (!ok) throw new Refusal(`${method} ${path} is not exposed to agents.`);
160
169
  if (path === "/api/service/exec") {
161
170
  const cmd = String(query.cmd ?? "");
@@ -179,6 +179,32 @@ export const MCP_USAGE = (cli: string): string =>
179
179
  `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
180
180
  ].join("\n");
181
181
 
182
+ /**
183
+ * The first argument `<cli> mcp` doesn't take (after the scope flags), or
184
+ * null. It takes --port <n> (or --port=<n>), --list, and --call <tool>
185
+ * ['<json>']; anything else fails closed (exit 2), never ignored.
186
+ */
187
+ export function unknownMcpArg(argv: readonly string[]): string | null {
188
+ for (let i = 0; i < argv.length; i++) {
189
+ const a = argv[i];
190
+ if (a === "--list") continue;
191
+ if (a === "--port") {
192
+ i++;
193
+ continue;
194
+ }
195
+ if (a.startsWith("--port=")) continue;
196
+ if (a === "--call") {
197
+ // The tool, then its JSON arguments when they aren't a flag.
198
+ i++;
199
+ const next = argv[i + 1];
200
+ if (next !== undefined && !next.startsWith("--")) i++;
201
+ continue;
202
+ }
203
+ return a;
204
+ }
205
+ return null;
206
+ }
207
+
182
208
  /** A one-shot invocation: `--list`, or `--call <tool> [json]`. */
183
209
  export type OneShot =
184
210
  | { kind: "list" }
@@ -351,6 +377,14 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
351
377
  process.exitCode = 2;
352
378
  return;
353
379
  }
380
+ const unknown = unknownMcpArg(argv);
381
+ if (unknown !== null) {
382
+ process.stderr.write(
383
+ `${cli} mcp: unknown argument "${unknown.replace(/[^\x20-\x7e]/g, "?").slice(0, 80)}"\n${MCP_USAGE(cli)}\n`,
384
+ );
385
+ process.exitCode = 2;
386
+ return;
387
+ }
354
388
  const refusedToken = actorTokenRefusal(scope, process.env, getStateDir());
355
389
  if (refusedToken) {
356
390
  process.stderr.write(`${cli} mcp: ${refusedToken}\n`);
@@ -728,6 +728,54 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
728
728
  };
729
729
  }
730
730
 
731
+ /** An agent id or workspace name (WORKSPACE_NAME_RE). */
732
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
733
+
734
+ /**
735
+ * MCP agent_resume (AG-14.4): resume a stopped or interrupted agent of this
736
+ * workspace (or a granted one) in its own conversation. Opt-in through
737
+ * mcp.actions; for orchestrators. The web server checks the scope again.
738
+ */
739
+ const agentResumeTool: DenTool = {
740
+ name: "agent_resume",
741
+ title: "Resume an agent",
742
+ description:
743
+ "Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself.",
744
+ action: true,
745
+ shape: {
746
+ agent: z.string().describe("The agent's id"),
747
+ workspace: workspaceArg,
748
+ },
749
+ async run(a, { api, scope }) {
750
+ const agent = str(a.agent);
751
+ if (!agent || !NAME_RE.test(agent))
752
+ throw new Refusal("agent_resume: agent must be an agent id");
753
+ let ws = str(a.workspace) ?? scope?.workspace;
754
+ if (!ws) {
755
+ const focused = (await api.get("/api/workspaces")) as {
756
+ focus?: unknown;
757
+ };
758
+ ws = typeof focused?.focus === "string" ? focused.focus : undefined;
759
+ }
760
+ if (!ws || !NAME_RE.test(ws))
761
+ throw new Refusal("agent_resume: name the agent's workspace");
762
+ // Scoped: callTool's scopeArgs pinned `workspace` to the scope (or a
763
+ // granted one, reported) before this runs.
764
+ if (scope && ws !== scope.workspace && !(scope.grants ?? []).includes(ws))
765
+ throw new ScopeRefusal(
766
+ `agent_resume: workspace "${ws}" is outside this agent's scope ("${scope.workspace}")`,
767
+ );
768
+ if (scope && `agent:${agent}` === scope.actor)
769
+ throw new Refusal("agent_resume: an agent can't resume itself");
770
+ return api.get(
771
+ `/api/workspaces/${encodeURIComponent(ws)}/agents/${encodeURIComponent(agent)}/resume`,
772
+ {},
773
+ "POST",
774
+ {},
775
+ );
776
+ },
777
+ };
778
+
731
779
  const ACTION_TOOLS: Record<McpAction, DenTool> = {
732
780
  service_up: lifecycleTool("up"),
733
781
  service_down: lifecycleTool("down"),
@@ -816,6 +864,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
816
864
  return actionResult(r, 300, { pillar, env });
817
865
  },
818
866
  },
867
+ agent_resume: agentResumeTool,
819
868
  };
820
869
 
821
870
  export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
@@ -853,6 +902,7 @@ const WORKSPACE_TOOLS = new Set([
853
902
  "service_restart",
854
903
  "workspace_sync",
855
904
  "run_tests",
905
+ "agent_resume",
856
906
  ]);
857
907
 
858
908
  /**