@monster-orange/den 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -19,6 +19,7 @@ import type { OrgConfig } from "./org.js";
19
19
  import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
20
  import { ConfigError, isConfigError } from "./config-error.js";
21
21
  import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
22
+ import type { Guardrails } from "./agents/guardrails.js";
22
23
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
23
24
 
24
25
  import { product } from "./product.js";
@@ -390,6 +391,11 @@ export interface UserConfig {
390
391
  runtimes?: Record<string, { bin?: unknown } | null>;
391
392
  /** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
392
393
  runtime_catalog?: unknown;
394
+ /**
395
+ * AG-14.1b: forget stopped and exited agents after this long: never
396
+ * (the default), 1d or 7d. Interrupted agents are never forgotten.
397
+ */
398
+ remove_stopped_after?: unknown;
393
399
  };
394
400
  /**
395
401
  * The web server's per-machine keys (src/shared/web-session.ts
@@ -464,6 +470,29 @@ export function configuredRuntimeCatalog(
464
470
  );
465
471
  }
466
472
 
473
+ /** The values `agents.remove_stopped_after` takes, in days (AG-14.1b). */
474
+ export const REMOVE_STOPPED_AFTER: Readonly<Record<string, number>> = {
475
+ never: 0,
476
+ "1d": 1,
477
+ "7d": 7,
478
+ };
479
+
480
+ /**
481
+ * user.yml's `agents.remove_stopped_after`, in days (0: never, the
482
+ * default). Throws naming the key when it is none of never, 1d, 7d.
483
+ */
484
+ export function configuredRemoveStoppedAfter(
485
+ read: () => UserConfig = readUserConfig,
486
+ ): number {
487
+ const v = read().agents?.remove_stopped_after;
488
+ if (v === undefined || v === null) return 0;
489
+ if (typeof v === "string" && Object.hasOwn(REMOVE_STOPPED_AFTER, v))
490
+ return REMOVE_STOPPED_AFTER[v];
491
+ throw new Error(
492
+ `agents.remove_stopped_after (${path.basename(USER_CONFIG_PATH)}): must be never, 1d or 7d (got ${JSON.stringify(v)})`,
493
+ );
494
+ }
495
+
467
496
  /**
468
497
  * Set (a path) or clear (null) `agents.runtimes.<id>.bin` in user.yml,
469
498
  * keeping everything else there. The caller checks the path first.
@@ -772,6 +801,8 @@ export const MCP_ACTIONS = [
772
801
  "service_restart",
773
802
  "workspace_sync",
774
803
  "run_tests",
804
+ // AG-14.4: resume another agent of its workspace (or a granted one).
805
+ "agent_resume",
775
806
  ] as const;
776
807
  export type McpAction = (typeof MCP_ACTIONS)[number];
777
808
 
@@ -833,9 +864,16 @@ export interface AgentsConfig {
833
864
  /**
834
865
  * Persona packs (AG-2a): directories of persona files, relative to the
835
866
  * distribution root. Den's default pack is always read first; a pack's
836
- * persona replaces one of the same id.
867
+ * persona replaces one of the same id. An entry may be a mapping,
868
+ * `{ path, hooks, instructions }`, to turn Den's guardrails on for that
869
+ * pack (agents/guardrails.ts; off for a distribution's pack by default).
870
+ */
871
+ persona_packs?: (string | PersonaPackConfig)[];
872
+ /**
873
+ * Den's default pack's guardrails, `{ hooks, instructions }` (both on
874
+ * by default): false turns that one off for every persona of the pack.
837
875
  */
838
- persona_packs?: string[];
876
+ default_pack?: Partial<Guardrails>;
839
877
  /**
840
878
  * More runtime catalog entries (AG-0.5, agents/catalog.ts): a vendor CLI
841
879
  * Den doesn't ship, with the same fields and checks as a built-in one.
@@ -849,6 +887,13 @@ export interface AgentsConfig {
849
887
  [agentId: string]: unknown;
850
888
  }
851
889
 
890
+ /** A `persona_packs` entry written as a mapping. */
891
+ export interface PersonaPackConfig {
892
+ path: string;
893
+ hooks?: boolean;
894
+ instructions?: boolean;
895
+ }
896
+
852
897
  export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
853
898
 
854
899
  /** The keys of `agents:` that are not an agent id. */
@@ -856,6 +901,7 @@ export const AGENTS_RESERVED_KEYS = [
856
901
  "max_concurrent",
857
902
  "persona_packs",
858
903
  "runtime_catalog",
904
+ "default_pack",
859
905
  ];
860
906
 
861
907
  /** One agent's own block, `agents.<id>`. */
@@ -869,6 +915,10 @@ export interface ResolvedAgentsConfig {
869
915
  maxConcurrent: number;
870
916
  /** As configured (relative to the distribution root). */
871
917
  personaPacks: string[];
918
+ /** Each pack's guardrails as configured, by index of personaPacks. */
919
+ packGuardrails?: (Partial<Guardrails> | undefined)[];
920
+ /** agents.default_pack as configured. */
921
+ defaultPackGuardrails?: Partial<Guardrails>;
872
922
  /** `agents.<id>` blocks, by agent id. */
873
923
  agents: Record<string, AgentGrantsConfig>;
874
924
  /** The distribution's runtime catalog entries (AG-0.5), checked. */
@@ -881,6 +931,36 @@ const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
881
931
  const isMapping = (v: unknown): v is Record<string, unknown> =>
882
932
  v !== null && typeof v === "object" && !Array.isArray(v);
883
933
 
934
+ /**
935
+ * `{ hooks, instructions }` (agents.default_pack, or a persona_packs
936
+ * mapping with its `path`): each true or false; any other key throws.
937
+ */
938
+ function guardrailsBlock(
939
+ v: unknown,
940
+ where: string,
941
+ withPath: boolean,
942
+ ): Partial<Guardrails> {
943
+ if (v === undefined || v === null) return {};
944
+ if (!isMapping(v))
945
+ throw new Error(
946
+ `${where}: must be a mapping ({ hooks: true|false, instructions: true|false })`,
947
+ );
948
+ const out: Partial<Guardrails> = {};
949
+ for (const [k, x] of Object.entries(v)) {
950
+ if (withPath && k === "path") continue;
951
+ if (k !== "hooks" && k !== "instructions")
952
+ throw new Error(
953
+ `${where}.${k}: unknown key (takes ${withPath ? "path, " : ""}hooks, instructions)`,
954
+ );
955
+ if (typeof x !== "boolean")
956
+ throw new Error(
957
+ `${where}.${k}: must be true or false (got ${JSON.stringify(x)})`,
958
+ );
959
+ out[k] = x;
960
+ }
961
+ return out;
962
+ }
963
+
884
964
  /**
885
965
  * The effective agents config. Anything malformed throws, naming the key:
886
966
  * a typo must not lift the cap, drop a pack or widen a grant.
@@ -894,6 +974,12 @@ export function readAgentsConfig(
894
974
  const out: ResolvedAgentsConfig = {
895
975
  maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
896
976
  personaPacks: [],
977
+ packGuardrails: [],
978
+ defaultPackGuardrails: guardrailsBlock(
979
+ raw.default_pack,
980
+ "agents.default_pack",
981
+ false,
982
+ ),
897
983
  agents: {},
898
984
  runtimeCatalog: validateCatalog(
899
985
  raw.runtime_catalog,
@@ -915,19 +1001,24 @@ export function readAgentsConfig(
915
1001
  `agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
916
1002
  );
917
1003
  packs.forEach((p, i) => {
918
- if (typeof p !== "string" || !p.trim())
1004
+ const where = `agents.persona_packs[${i}]`;
1005
+ const dir = isMapping(p) ? p.path : p;
1006
+ if (typeof dir !== "string" || !dir.trim())
919
1007
  throw new Error(
920
- `agents.persona_packs[${i}]: must be a non-empty path (got ${JSON.stringify(p)})`,
1008
+ `${where}: must be a non-empty path or { path, hooks, instructions } (got ${JSON.stringify(p)})`,
921
1009
  );
1010
+ out.personaPacks.push(dir);
1011
+ out.packGuardrails!.push(
1012
+ isMapping(p) ? guardrailsBlock(p, where, true) : undefined,
1013
+ );
922
1014
  });
923
- out.personaPacks = packs as string[];
924
1015
  }
925
1016
  for (const [id, block] of Object.entries(raw)) {
926
1017
  if (AGENTS_RESERVED_KEYS.includes(id)) continue;
927
1018
  const where = `agents.${id}`;
928
1019
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
929
1020
  throw new Error(
930
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, ` +
1021
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, ` +
931
1022
  `or an agent id mapping to { grants: [...] })`,
932
1023
  );
933
1024
  for (const k of Object.keys(block))
@@ -19,6 +19,12 @@
19
19
  * else). A DSN, in the env or a synced dev-metrics.config, is where to send
20
20
  * once opted in; on its own it never sends.
21
21
  *
22
+ * Each line also has `cwd` (den#5): where the command ran, relative to the
23
+ * distribution root ("." for the root itself), or "outside" for anywhere
24
+ * else, never an absolute or home path. `<cli> doctor` reads it to spot
25
+ * polling loops (polling.ts). Like every field, it is sent only when opted
26
+ * in; the envelope format is unchanged.
27
+ *
22
28
  * Guarantees: never throws, never blocks the caller (the Sentry ship is
23
29
  * fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
24
30
  * shipping but still buffer the JSONL locally.
@@ -115,6 +121,29 @@ function commonFields(event: string, root: string): Record<string, string> {
115
121
  };
116
122
  }
117
123
 
124
+ /** The JSONL file, under the repo root. */
125
+ export function eventsFile(root: string = repoRoot()): string {
126
+ return path.join(root, ".dev-metrics", "events.jsonl");
127
+ }
128
+
129
+ /**
130
+ * `cwd` for a local line: the path from the root to cwd ("." for the root,
131
+ * "/"-separated), or "outside" when cwd is not under the root.
132
+ */
133
+ export function cwdField(root: string, cwd: string = process.cwd()): string {
134
+ const real = (p: string): string => {
135
+ try {
136
+ return fs.realpathSync(p);
137
+ } catch {
138
+ return path.resolve(p);
139
+ }
140
+ };
141
+ const rel = path.relative(real(root), real(cwd));
142
+ if (rel === "") return ".";
143
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return "outside";
144
+ return rel.split(path.sep).join("/");
145
+ }
146
+
118
147
  function isoNoMillis(): string {
119
148
  return new Date().toISOString().replace(/\.\d+Z$/, "Z");
120
149
  }
@@ -334,14 +363,12 @@ export function record(
334
363
  for (const [k, v] of Object.entries(fields)) {
335
364
  obj[k] = v == null ? "" : String(v);
336
365
  }
366
+ obj.cwd = cwdField(root);
337
367
 
338
368
  try {
339
369
  const dir = path.join(root, ".dev-metrics");
340
370
  fs.mkdirSync(dir, { recursive: true });
341
- fs.appendFileSync(
342
- path.join(dir, "events.jsonl"),
343
- JSON.stringify(obj) + "\n",
344
- );
371
+ fs.appendFileSync(eventsFile(root), JSON.stringify(obj) + "\n");
345
372
  } catch {
346
373
  /* non-writable dir → fail-open */
347
374
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Polling-loop warning (den#5) for `<cli> doctor`: one command, run from
3
+ * one place, more than POLL_LIMIT times in any POLL_WINDOW_MS window looks
4
+ * like an agent polling it. Local only: it reads the tail of the local
5
+ * .dev-metrics/events.jsonl and sends nothing anywhere.
6
+ *
7
+ * A line's place is its `cwd` (emit.ts). An older line has none, so it is
8
+ * keyed by its `repo` instead.
9
+ */
10
+ import * as fs from "fs";
11
+
12
+ /** More calls than this in one window is a polling loop. */
13
+ export const POLL_LIMIT = 600;
14
+ /** The window: an hour. */
15
+ export const POLL_WINDOW_MS = 60 * 60 * 1000;
16
+ /** At most this much of the file's end is read, however big it is. */
17
+ export const TAIL_BYTES = 8 * 1024 * 1024;
18
+
19
+ /**
20
+ * The file's last `max` bytes as whole lines: a line cut by the start of
21
+ * the slice is dropped. A missing or unreadable file is no lines.
22
+ */
23
+ export function readTailLines(
24
+ file: string,
25
+ max: number = TAIL_BYTES,
26
+ ): string[] {
27
+ let fd: number | undefined;
28
+ try {
29
+ fd = fs.openSync(file, "r");
30
+ const size = fs.fstatSync(fd).size;
31
+ const len = Math.min(size, max);
32
+ const buf = Buffer.alloc(len);
33
+ let got = 0;
34
+ while (got < len) {
35
+ const n = fs.readSync(fd, buf, got, len - got, size - len + got);
36
+ if (n <= 0) break;
37
+ got += n;
38
+ }
39
+ const lines = buf.subarray(0, got).toString("utf-8").split("\n");
40
+ if (len < size) lines.shift();
41
+ return lines.filter((l) => l.trim());
42
+ } catch {
43
+ return [];
44
+ } finally {
45
+ if (fd !== undefined) fs.closeSync(fd);
46
+ }
47
+ }
48
+
49
+ export interface PollingLoop {
50
+ command: string;
51
+ /** Where: the line's cwd, or `repo <name>` for an older line. */
52
+ where: string;
53
+ /** Calls in the busiest window. */
54
+ calls: number;
55
+ /** The busiest window's first and last call (ISO, no millis). */
56
+ from: string;
57
+ to: string;
58
+ }
59
+
60
+ const iso = (ms: number): string =>
61
+ new Date(ms).toISOString().replace(/\.\d+Z$/, "Z");
62
+
63
+ /**
64
+ * Every command+place with more than `limit` calls in some `windowMs`
65
+ * window, with its busiest window. Lines that aren't JSON, or have no
66
+ * command or time, are skipped.
67
+ */
68
+ export function findPollingLoops(
69
+ lines: string[],
70
+ limit: number = POLL_LIMIT,
71
+ windowMs: number = POLL_WINDOW_MS,
72
+ ): PollingLoop[] {
73
+ const byKey = new Map<
74
+ string,
75
+ { command: string; where: string; times: number[] }
76
+ >();
77
+ for (const line of lines) {
78
+ let o: Record<string, unknown>;
79
+ try {
80
+ o = JSON.parse(line) as Record<string, unknown>;
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (!o || typeof o !== "object") continue;
85
+ const command = typeof o.command === "string" ? o.command : "";
86
+ const t = typeof o.ts === "string" ? Date.parse(o.ts) : NaN;
87
+ if (!command || Number.isNaN(t)) continue;
88
+ const where =
89
+ typeof o.cwd === "string" && o.cwd
90
+ ? o.cwd
91
+ : `repo ${typeof o.repo === "string" && o.repo ? o.repo : "?"}`;
92
+ const key = `${command}\u0000${where}`;
93
+ let e = byKey.get(key);
94
+ if (!e) byKey.set(key, (e = { command, where, times: [] }));
95
+ e.times.push(t);
96
+ }
97
+ const out: PollingLoop[] = [];
98
+ for (const { command, where, times } of byKey.values()) {
99
+ if (times.length <= limit) continue;
100
+ times.sort((a, b) => a - b);
101
+ let best = 0;
102
+ let bi = 0;
103
+ let bj = 0;
104
+ for (let i = 0, j = 0; j < times.length; j++) {
105
+ while (times[j] - times[i] >= windowMs) i++;
106
+ if (j - i + 1 > best) {
107
+ best = j - i + 1;
108
+ bi = i;
109
+ bj = j;
110
+ }
111
+ }
112
+ if (best > limit)
113
+ out.push({
114
+ command,
115
+ where,
116
+ calls: best,
117
+ from: iso(times[bi]),
118
+ to: iso(times[bj]),
119
+ });
120
+ }
121
+ return out.sort((a, b) => b.calls - a.calls);
122
+ }
123
+
124
+ /** One warning line, as doctor prints it. */
125
+ export function pollingWarning(p: PollingLoop): string {
126
+ return `looks like a polling loop: ${p.command} in ${p.where}, ${p.calls} calls in an hour (${p.from}–${p.to})`;
127
+ }
128
+
129
+ /** doctor's warnings for this events file (none when it is fine or absent). */
130
+ export function pollingWarnings(
131
+ file: string,
132
+ max: number = TAIL_BYTES,
133
+ ): string[] {
134
+ return findPollingLoops(readTailLines(file, max)).map(pollingWarning);
135
+ }
@@ -93,11 +93,23 @@ export function playwrightCommand(...args: string[]): EngineCommand {
93
93
  * or with DEN_USE_DIST=1. Mirrors the web server's resolveServerEntrypoint.
94
94
  */
95
95
  export function cliEntrypoint(engine: string = getEngineRoot()): EngineCommand {
96
- const built = path.join(engine, "dist/src/ui/cli/index.js");
96
+ return scriptEntrypoint("src/ui/cli/index.ts", engine);
97
+ }
98
+
99
+ /**
100
+ * How to run one of Den's TypeScript entry points (`src/….ts`, relative to
101
+ * the engine root) as a node process: cliEntrypoint's rules, for any
102
+ * script (an agent's hook, guardrails/stop-hook.ts).
103
+ */
104
+ export function scriptEntrypoint(
105
+ rel: string,
106
+ engine: string = getEngineRoot(),
107
+ ): EngineCommand {
108
+ const built = path.join(engine, "dist", rel.replace(/\.ts$/, ".js"));
97
109
  if (process.env.DEN_USE_DIST === "1" && fs.existsSync(built))
98
110
  return { command: process.execPath, args: [built] };
99
111
  const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
100
- const src = path.join(engine, "src/ui/cli/index.ts");
112
+ const src = path.join(engine, rel);
101
113
  if (loader && fs.existsSync(src))
102
114
  return {
103
115
  command: process.execPath,
@@ -58,6 +58,9 @@ export const KIND_TIERS: Readonly<Record<string, PluginTier>> = {
58
58
  secrets: "server",
59
59
  identity: "server",
60
60
  "health-source": "server",
61
+ // den#6: reads a runtime's own transcripts on this machine, for tokens
62
+ // and outcomes per agent; never sends them anywhere.
63
+ usage: "server",
61
64
  };
62
65
 
63
66
  export interface PluginProvide {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where Claude Code keeps its transcripts. No registration here, so the
3
+ * den-claude runtime can ask (conversationExists) without loading the
4
+ * usage sources (sources.ts registers one when it loads).
5
+ */
6
+ import * as os from "os";
7
+ import * as path from "path";
8
+
9
+ /** Claude Code's transcripts: $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects. */
10
+ export function claudeProjectsDir(
11
+ env: NodeJS.ProcessEnv = process.env,
12
+ ): string {
13
+ const base =
14
+ env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), ".claude");
15
+ return path.join(base, "projects");
16
+ }