@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.
- package/CHANGELOG.md +160 -0
- package/README.md +7 -7
- package/docs/distributions.md +244 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +38 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +66 -7
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +97 -6
- package/src/shared/dev-metrics/emit.ts +31 -4
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +495 -53
- package/src/ui/cli/agents-command.ts +170 -14
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +119 -2
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/cli/web-command.ts +3 -1
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -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
|
+
}
|
|
@@ -1196,7 +1196,9 @@ export function displayWebHelp(): void {
|
|
|
1196
1196
|
console.log(" --port, -p <port> Use a non-default web port");
|
|
1197
1197
|
console.log(
|
|
1198
1198
|
" --no-open Start or check the server without opening a browser",
|
|
1199
|
-
|
|
1199
|
+
);
|
|
1200
|
+
console.log(
|
|
1201
|
+
" --json log on stderr; stdout: up, restart { ok, url, port, version? }; down { ok, url, port, running, stopped }",
|
|
1200
1202
|
);
|
|
1201
1203
|
console.log(
|
|
1202
1204
|
" --setup-browser-access Request macOS Automation access for browser tab reuse",
|
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 ?? "");
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -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`);
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -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
|
+
}
|