@monster-orange/den 0.4.7 → 0.4.9

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 (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -0,0 +1,323 @@
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`). None of it is sent anywhere: it reads local files and writes
5
+ * local files. The one question it asks outside: when an agent ran
6
+ * `gh pr merge`, `gh pr view <ref> --json state` confirms the merge
7
+ * (gh-pr-state.ts) before it counts.
8
+ *
9
+ * agents [-w <ws>] [--json] each agent's tokens and outcome (PR opened
10
+ * or merged, tests green, wall-clock), joined
11
+ * on its record's resumeToken, and kept in its
12
+ * record; the share attributed
13
+ * report [--days N] [--json] the week's numbers (default 7 days), written
14
+ * to <state dir>/usage/<date>.{json,md} and
15
+ * compared with the last report there
16
+ */
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import {
20
+ agentFilesDir,
21
+ listRecords,
22
+ readRecord,
23
+ writeRecord,
24
+ } from "../../shared/agents/records.js";
25
+ import { markDue } from "../../shared/agents/rotation.js";
26
+ import { ghPrState } from "../../shared/git/gh-pr-state.js";
27
+ import { readAgentsConfig } from "../../shared/den-config.js";
28
+ import { getStateDir } from "../../shared/paths.js";
29
+ import { product } from "../../shared/product.js";
30
+ import {
31
+ agentUsage,
32
+ attributedPct,
33
+ renderReport,
34
+ weeklyMetrics,
35
+ type AgentUsageRow,
36
+ type UsageMetrics,
37
+ } from "../../shared/usage/report.js";
38
+ import { usageSources } from "../../shared/usage/sources.js";
39
+ import type { TranscriptStats } from "../../shared/usage/transcript.js";
40
+ import {
41
+ columns,
42
+ parseArgs,
43
+ noExtra,
44
+ UsageError,
45
+ workspaceFlag,
46
+ } from "./sessions-command.js";
47
+
48
+ export const USAGE_HELP = {
49
+ usage: "usage <agents|report> ...",
50
+ lines: [
51
+ "How agents spend tokens and time, from the runtimes' own transcripts on",
52
+ "this machine (Claude Code's: ~/.claude/projects). Read locally; nothing",
53
+ "is ever sent anywhere.",
54
+ "",
55
+ "{cli} usage agents [-w <ws>] [--json]",
56
+ " each agent's tokens and outcome (pull request opened or merged, tests",
57
+ " green, wall-clock), compactions and rotations, joined on its",
58
+ " conversation id, and saved in its record; then the share of agents",
59
+ " with both. A merged pull request makes the agent's rotation due",
60
+ "{cli} usage report [--days <n>] [--json]",
61
+ " the last n days (default 7): tokens, context re-reads, polling, denials,",
62
+ " tool errors, corrections, subagents on Opus, the longest session's context",
63
+ " per call, the share of re-reads from never-ending sessions (more",
64
+ " compactions than agents.rotate.after_compactions), rotations; written to",
65
+ " <state dir>/usage/<date>.json and .md, compared with the last report",
66
+ ],
67
+ };
68
+
69
+ export interface UsageDeps {
70
+ stateDir?: () => string;
71
+ env?: NodeJS.ProcessEnv;
72
+ now?: () => Date;
73
+ out?: (line: string) => void;
74
+ err?: (line: string) => void;
75
+ cli?: string;
76
+ /** agents.rotate (default: the org config's). */
77
+ rotateAfter?: () => { afterCompactions: number } | undefined;
78
+ /**
79
+ * A pull request's state, to confirm a `gh pr merge` the agent ran
80
+ * (default: `gh pr view <ref> --json state` in the agent's folder).
81
+ */
82
+ prState?: (
83
+ ref: string,
84
+ o: { repo?: string; cwd: string },
85
+ ) => Promise<string | undefined>;
86
+ }
87
+
88
+ /** The check, counting how often it asked (the summary line says so). */
89
+ function counted(
90
+ f: NonNullable<UsageDeps["prState"]>,
91
+ ): NonNullable<UsageDeps["prState"]> & { asked: number } {
92
+ const g = Object.assign(
93
+ (ref: string, o: { repo?: string; cwd: string }) => {
94
+ g.asked++;
95
+ return f(ref, o);
96
+ },
97
+ { asked: 0 },
98
+ );
99
+ return g;
100
+ }
101
+
102
+ function resolve(d: UsageDeps) {
103
+ return {
104
+ stateDir: d.stateDir ?? (() => getStateDir()),
105
+ env: d.env ?? process.env,
106
+ now: d.now ?? (() => new Date()),
107
+ out: d.out ?? ((l: string) => console.log(l)),
108
+ err: d.err ?? ((l: string) => console.error(l)),
109
+ cli: d.cli ?? product().cli,
110
+ rotateLimit: () => rotateLimit(d),
111
+ prState: counted(d.prState ?? ghPrState(d.env ?? process.env)),
112
+ };
113
+ }
114
+
115
+ const k = (n: number) =>
116
+ n >= 1e9
117
+ ? `${(n / 1e9).toFixed(1)}B`
118
+ : n >= 1e6
119
+ ? `${(n / 1e6).toFixed(1)}M`
120
+ : n >= 1e3
121
+ ? `${Math.round(n / 1e3)}k`
122
+ : String(n);
123
+ const mins = (ms?: number) =>
124
+ ms === undefined
125
+ ? "-"
126
+ : ms < 3_600_000
127
+ ? `${Math.round(ms / 60_000)}m`
128
+ : `${(ms / 3_600_000).toFixed(1)}h`;
129
+
130
+ /**
131
+ * Save each attributed row's usage and outcome in its agent record. An
132
+ * agent whose pull request merged has its rotation marked due (den#4): its
133
+ * Stop hook then asks it for its handoff.
134
+ */
135
+ function save(stateDir: string, rows: readonly AgentUsageRow[]): void {
136
+ for (const r of rows) {
137
+ if (!r.usage) continue;
138
+ const rec = readRecord(stateDir, r.agent);
139
+ if (!rec) continue;
140
+ writeRecord(stateDir, {
141
+ ...rec,
142
+ usage: r.usage,
143
+ ...(r.outcome ? { outcome: r.outcome } : {}),
144
+ });
145
+ if (r.outcome?.prMerged)
146
+ markDue(agentFilesDir(stateDir, rec.id), "pr-merged", rec.resumeToken);
147
+ }
148
+ }
149
+
150
+ /** agents.rotate.after_compactions; the default when the config can't be read. */
151
+ function rotateLimit(d: UsageDeps): number {
152
+ try {
153
+ return (
154
+ (d.rotateAfter ?? (() => readAgentsConfig().rotate))()
155
+ ?.afterCompactions ?? 3
156
+ );
157
+ } catch {
158
+ return 3;
159
+ }
160
+ }
161
+
162
+ async function agents(
163
+ args: string[],
164
+ d: ReturnType<typeof resolve>,
165
+ ): Promise<number> {
166
+ const where = "usage agents";
167
+ const p = parseArgs(where, args, { "--workspace": true, "--json": false });
168
+ noExtra(where, p);
169
+ const ws = workspaceFlag(where, p);
170
+ const recs = listRecords(d.stateDir()).filter(
171
+ (r) => !ws || r.workspace === ws,
172
+ );
173
+ const rows = await agentUsage(recs, {
174
+ env: d.env,
175
+ prState: d.prState,
176
+ });
177
+ save(d.stateDir(), rows);
178
+ const pct = attributedPct(rows);
179
+ if (p.flags["--json"]) {
180
+ d.out(JSON.stringify({ agents: rows, attributedPct: pct }, null, 2));
181
+ return 0;
182
+ }
183
+ if (!rows.length) {
184
+ d.out(`no agents${ws ? ` in ${ws}` : ""}`);
185
+ return 0;
186
+ }
187
+ for (const l of columns([
188
+ [
189
+ "AGENT",
190
+ "WORKSPACE",
191
+ "CALLS",
192
+ "OUTPUT",
193
+ "CACHE READ",
194
+ "WALL",
195
+ "TESTS",
196
+ "PR",
197
+ "COMPACTIONS",
198
+ "ROTATIONS",
199
+ ],
200
+ ...rows.map((r) => [
201
+ r.agent,
202
+ r.workspace,
203
+ r.usage ? String(r.usage.calls) : "-",
204
+ r.usage ? k(r.usage.output) : "-",
205
+ r.usage ? k(r.usage.cacheRead) : "-",
206
+ mins(r.outcome?.wallClockMs),
207
+ r.outcome?.testsGreen === undefined
208
+ ? "-"
209
+ : r.outcome.testsGreen
210
+ ? "green"
211
+ : "red",
212
+ r.outcome?.prOpened
213
+ ? `${r.outcome.prOpened}${r.outcome.prMerged ? " (merged)" : ""}`
214
+ : "-",
215
+ r.compactions === undefined ? "-" : String(r.compactions),
216
+ String(r.rotations),
217
+ ]),
218
+ ]))
219
+ d.out(l);
220
+ for (const r of rows) if (r.why) d.out(` ${r.agent}: ${r.why}`);
221
+ d.out("");
222
+ d.out(
223
+ `${pct}% of agents have their tokens and outcome (${rows.filter((r) => r.attributed).length} of ${rows.length}). Read locally; none of this was sent.`,
224
+ );
225
+ if (d.prState.asked)
226
+ d.out(
227
+ `To confirm a merge, it asked GitHub for the state of ${d.prState.asked} pull request${d.prState.asked === 1 ? "" : "s"} (gh pr view).`,
228
+ );
229
+ return 0;
230
+ }
231
+
232
+ /** The newest report in `dir` dated before `date`, if any. */
233
+ function previous(
234
+ dir: string,
235
+ date: string,
236
+ ): { date: string; metrics: Partial<UsageMetrics> } | undefined {
237
+ let names: string[];
238
+ try {
239
+ names = fs.readdirSync(dir);
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ const older = names
244
+ .filter((n) => /^\d{4}-\d{2}-\d{2}\.json$/.test(n) && n < `${date}.json`)
245
+ .sort();
246
+ const last = older[older.length - 1];
247
+ if (!last) return undefined;
248
+ try {
249
+ return {
250
+ date: last.slice(0, -5),
251
+ metrics: JSON.parse(
252
+ fs.readFileSync(path.join(dir, last), "utf-8"),
253
+ ) as Partial<UsageMetrics>,
254
+ };
255
+ } catch {
256
+ return undefined;
257
+ }
258
+ }
259
+
260
+ async function report(
261
+ args: string[],
262
+ d: ReturnType<typeof resolve>,
263
+ ): Promise<number> {
264
+ const where = "usage report";
265
+ const p = parseArgs(where, args, { "--days": true, "--json": false });
266
+ noExtra(where, p);
267
+ const days = p.flags["--days"] === undefined ? 7 : Number(p.flags["--days"]);
268
+ if (!Number.isFinite(days) || days <= 0 || days > 366)
269
+ throw new UsageError(`${where}: --days must be a number of days, 1 to 366`);
270
+ const now = d.now();
271
+ const since = now.getTime() - days * 86_400_000;
272
+ const stats: TranscriptStats[] = [];
273
+ for (const src of usageSources())
274
+ for (const f of src.files(since, d.env))
275
+ stats.push(await src.mine(f, { since }));
276
+ const rows = await agentUsage(listRecords(d.stateDir()), {
277
+ env: d.env,
278
+ prState: d.prState,
279
+ });
280
+ save(d.stateDir(), rows);
281
+ const m = weeklyMetrics(stats, rows, d.rotateLimit());
282
+ const date = now.toISOString().slice(0, 10);
283
+ const dir = path.join(d.stateDir(), "usage");
284
+ const prev = previous(dir, date);
285
+ const md = renderReport(m, date, prev);
286
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
287
+ fs.writeFileSync(
288
+ path.join(dir, `${date}.json`),
289
+ JSON.stringify(m, null, 1) + "\n",
290
+ { mode: 0o600 },
291
+ );
292
+ fs.writeFileSync(path.join(dir, `${date}.md`), md, { mode: 0o600 });
293
+ if (p.flags["--json"]) d.out(JSON.stringify(m, null, 2));
294
+ else {
295
+ for (const l of md.trimEnd().split("\n")) d.out(l);
296
+ d.out("");
297
+ d.out(
298
+ `Written to ${path.join(dir, `${date}.md`)} (${stats.length} transcripts, ${days} days).`,
299
+ );
300
+ }
301
+ return 0;
302
+ }
303
+
304
+ /** `<cli> usage ...`; returns the exit code. */
305
+ export async function cmdUsage(
306
+ args: string[],
307
+ deps: UsageDeps = {},
308
+ ): Promise<number> {
309
+ const d = resolve(deps);
310
+ const sub = args[0] ?? "agents";
311
+ try {
312
+ if (sub === "agents") return await agents(args.slice(1), d);
313
+ if (sub === "report") return await report(args.slice(1), d);
314
+ throw new UsageError(`unknown usage command "${sub}"`);
315
+ } catch (e) {
316
+ if (e instanceof UsageError) {
317
+ d.err(`✗ ${e.message}`);
318
+ d.err(`Usage: ${d.cli} ${USAGE_HELP.usage}`);
319
+ return 2;
320
+ }
321
+ throw e;
322
+ }
323
+ }
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
  /**
@@ -0,0 +1,53 @@
1
+ /**
2
+ * MCP `agent_resume` (AG-14.4): the one agent route an agent's own token
3
+ * may reach, and only when all of these hold:
4
+ *
5
+ * - the org config opts in: `mcp.actions` lists agent_resume (off by
6
+ * default, like every MCP action);
7
+ * - the agent to resume is in the caller's workspace, or one its
8
+ * agents.<id>.grants gives it (the scope agent_spawn has, AG spec §5);
9
+ * - it isn't the caller itself.
10
+ *
11
+ * The request checks (request-guard.ts agentRefusal) and the route
12
+ * (agent-routes.ts) both ask. Starting, stopping and removing agents stay a
13
+ * person's.
14
+ */
15
+ export const AGENT_RESUME_ACTION = "agent_resume";
16
+
17
+ /** POST /api/workspaces/<ws>/agents/<id>/resume. */
18
+ export const AGENT_RESUME_ROUTE =
19
+ /^\/api\/workspaces\/([^/]+)\/agents\/([^/]+)\/resume$/;
20
+
21
+ export interface ResumingAgent {
22
+ actor: string;
23
+ workspace: string;
24
+ grants: readonly string[];
25
+ }
26
+
27
+ /** Why this agent may not resume agent `id` of `ws`, or null when it may. */
28
+ export function agentResumeRefusal(
29
+ p: ResumingAgent,
30
+ ws: string,
31
+ id: string,
32
+ actions: readonly string[],
33
+ ): string | null {
34
+ if (!actions.includes(AGENT_RESUME_ACTION))
35
+ return `${p.actor} can't resume agents: the org config's mcp.actions doesn't list ${AGENT_RESUME_ACTION}.`;
36
+ if (ws !== p.workspace && !p.grants.includes(ws))
37
+ return `${p.actor} can resume agents of workspace ${p.workspace} only${p.grants.length ? ` (or ${p.grants.join(", ")}, its grants)` : ""}, not ${ws}.`;
38
+ if (`agent:${id}` === p.actor) return `${p.actor} can't resume itself.`;
39
+ return null;
40
+ }
41
+
42
+ /** The route's workspace and agent id, decoded; null when it isn't it. */
43
+ export function agentResumeTarget(
44
+ pathname: string,
45
+ ): { ws: string; id: string } | null {
46
+ const m = AGENT_RESUME_ROUTE.exec(pathname);
47
+ if (!m) return null;
48
+ try {
49
+ return { ws: decodeURIComponent(m[1]), id: decodeURIComponent(m[2]) };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }