@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.
- package/CHANGELOG.md +94 -0
- package/README.md +7 -7
- package/docs/distributions.md +120 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/personas.ts +11 -1
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +30 -0
- 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 +488 -50
- package/src/ui/cli/agents-command.ts +120 -14
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +256 -0
- 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
|
@@ -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
|
|
24
|
-
*
|
|
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:
|
|
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
|
-
"
|
|
95
|
-
"
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
236
|
-
const
|
|
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}:
|
|
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":
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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 ?? "");
|
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
|
/**
|