@monster-orange/den 0.3.0 → 0.4.0

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 (43) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/docs/distributions.md +229 -8
  3. package/package.json +4 -1
  4. package/src/shared/agents/default-pack/be-dev.md +20 -0
  5. package/src/shared/agents/default-pack/devops.md +14 -0
  6. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  7. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  8. package/src/shared/agents/default-pack/planner.md +17 -0
  9. package/src/shared/agents/default-pack/reviewer.md +14 -0
  10. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  11. package/src/shared/agents/den-claude.ts +173 -0
  12. package/src/shared/agents/generic.ts +52 -0
  13. package/src/shared/agents/personas.ts +218 -0
  14. package/src/shared/agents/records.ts +118 -0
  15. package/src/shared/agents/runtime.ts +192 -0
  16. package/src/shared/agents/runtimes.ts +47 -0
  17. package/src/shared/den-config.ts +116 -0
  18. package/src/shared/engine.ts +23 -0
  19. package/src/shared/environment-manager/workspaces.ts +19 -0
  20. package/src/shared/secret-env.ts +70 -0
  21. package/src/shared/sessions/client.ts +277 -0
  22. package/src/shared/sessions/den-pty.ts +97 -0
  23. package/src/shared/sessions/env.ts +79 -0
  24. package/src/shared/sessions/host.ts +667 -0
  25. package/src/shared/sessions/names.ts +85 -0
  26. package/src/shared/sessions/paths.ts +27 -0
  27. package/src/shared/sessions/protocol.ts +72 -0
  28. package/src/shared/sessions/pty.ts +141 -0
  29. package/src/shared/sessions/ring-buffer.ts +160 -0
  30. package/src/shared/sessions/types.ts +81 -0
  31. package/src/ui/cli/agents-command.ts +812 -0
  32. package/src/ui/cli/doctor-command.ts +138 -9
  33. package/src/ui/cli/env-probe.ts +266 -0
  34. package/src/ui/cli/flag-parser.ts +11 -3
  35. package/src/ui/cli/index.ts +34 -2
  36. package/src/ui/cli/sessions-command.ts +718 -0
  37. package/src/ui/mcp/scope.ts +123 -19
  38. package/src/ui/mcp/server.ts +27 -2
  39. package/src/ui/mcp/tools.ts +44 -5
  40. package/src/ui/server/audit.ts +35 -0
  41. package/src/ui/server/index.ts +7 -0
  42. package/src/ui/server/readonly-routes.ts +25 -2
  43. package/src/ui/server/workspace-routes.ts +3 -15
@@ -0,0 +1,173 @@
1
+ /**
2
+ * den-claude: Den's built-in runtime for Claude Code (AG spec §4).
3
+ *
4
+ * claude --session-id <uuid> --mcp-config <filesDir>/mcp.json
5
+ * --strict-mcp-config --permission-mode <mode> [--model <m>]
6
+ * --append-system-prompt <role prompt>
7
+ *
8
+ * - The MCP config (0600) holds one server, Den's, scoped to the agent's
9
+ * workspace; --strict-mcp-config keeps any other MCP server out, so the
10
+ * agent acts only through Den (§1).
11
+ * - The role prompt is appended to Claude Code's own system prompt.
12
+ * - Each launch picks a fresh conversation id (--session-id); resume
13
+ * continues it with --resume <id>, in the same cwd, with the same MCP.
14
+ * - Permission prompts stay in the terminal (approvals: "terminal"):
15
+ * --permission-mode default, unless the persona's `permission_mode` sets
16
+ * one of Claude Code's modes; bypassPermissions warns at every start. The
17
+ * Notification hook comes with agent states (AG-6a).
18
+ * - Its environment is the allowlist (sessions/env.ts) plus
19
+ * CLAUDE_CONFIG_DIR, where a user keeps Claude Code's config when not in
20
+ * HOME. Claude Code reads its own credentials from there; no token is
21
+ * passed. Its scrubEnv, CLAUDECODE and CLAUDE_CODE_*, never reaches any
22
+ * session Den starts (an agent is not a child of the session that
23
+ * started it).
24
+ *
25
+ * detect runs nothing: `claude` on PATH, and its version read from where it
26
+ * is installed (a native install's versions/<version>, or the npm package's
27
+ * package.json).
28
+ */
29
+ import { randomUUID } from "crypto";
30
+ import * as fs from "fs";
31
+ import * as path from "path";
32
+ import { rolePrompt } from "./personas.js";
33
+ import {
34
+ LaunchError,
35
+ mcpConfigJson,
36
+ mcpConfigPath,
37
+ whichBin,
38
+ type LaunchInput,
39
+ type LaunchOutput,
40
+ type Runtime,
41
+ type RuntimeDetect,
42
+ } from "./runtime.js";
43
+
44
+ export const DEN_CLAUDE_ID = "den-claude";
45
+ const BIN = "claude";
46
+ const PACKAGE = "@anthropic-ai/claude-code";
47
+ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
48
+
49
+ /** Claude Code's permission modes (`claude --permission-mode`). */
50
+ export const PERMISSION_MODES = [
51
+ "default",
52
+ "acceptEdits",
53
+ "plan",
54
+ "bypassPermissions",
55
+ ] as const;
56
+ export const DEFAULT_PERMISSION_MODE = "default";
57
+
58
+ /** The persona's permission mode, checked; a bad one names the key. */
59
+ function permissionMode(input: LaunchInput): string {
60
+ const m = input.persona.permissionMode ?? DEFAULT_PERMISSION_MODE;
61
+ if (!(PERMISSION_MODES as readonly string[]).includes(m))
62
+ throw new LaunchError(
63
+ `${input.persona.source}: permission_mode "${m.replace(/[^\x20-\x7e]/g, "?").slice(0, 40)}" must be one of ${PERMISSION_MODES.join(", ")}`,
64
+ );
65
+ return m;
66
+ }
67
+
68
+ /** The installed version, from the install's own files; undefined when unknown. */
69
+ export function installedVersion(bin: string): string | undefined {
70
+ let real: string;
71
+ try {
72
+ real = fs.realpathSync(bin);
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ if (SEMVER.test(path.basename(real))) return path.basename(real);
77
+ for (let dir = path.dirname(real), i = 0; i < 6; i++) {
78
+ try {
79
+ const pkg = JSON.parse(
80
+ fs.readFileSync(path.join(dir, "package.json"), "utf-8"),
81
+ ) as { name?: unknown; version?: unknown };
82
+ if (pkg.name === PACKAGE && typeof pkg.version === "string")
83
+ return pkg.version;
84
+ } catch {
85
+ /* not here */
86
+ }
87
+ const up = path.dirname(dir);
88
+ if (up === dir) break;
89
+ dir = up;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function detect(env: NodeJS.ProcessEnv = process.env): RuntimeDetect {
95
+ const bin = whichBin(BIN, env);
96
+ if (!bin) return { available: false, why: `${BIN} is not on PATH` };
97
+ const version = installedVersion(bin);
98
+ return version ? { available: true, version } : { available: true };
99
+ }
100
+
101
+ /** The flags every run shares: Den's MCP only, the model, the role prompt. */
102
+ function common(input: LaunchInput): {
103
+ argv: string[];
104
+ files: Record<string, string>;
105
+ warnings: string[];
106
+ } {
107
+ const file = mcpConfigPath(input);
108
+ const mode = permissionMode(input);
109
+ return {
110
+ argv: [
111
+ "--mcp-config",
112
+ file,
113
+ "--strict-mcp-config",
114
+ "--permission-mode",
115
+ mode,
116
+ ...(input.model ? ["--model", input.model] : []),
117
+ "--append-system-prompt",
118
+ rolePrompt(
119
+ input.persona,
120
+ input.agentId,
121
+ input.workspace.name,
122
+ input.mcp.name,
123
+ ),
124
+ ],
125
+ files: { [file]: mcpConfigJson(input.mcp) },
126
+ warnings:
127
+ mode === "bypassPermissions"
128
+ ? [
129
+ `⚠ persona ${input.persona.id} sets permission_mode: bypassPermissions: Claude Code will not ask before running any tool`,
130
+ ]
131
+ : [],
132
+ };
133
+ }
134
+
135
+ function binFor(input: LaunchInput): string {
136
+ const bin = whichBin(BIN, input.env);
137
+ if (!bin) throw new LaunchError(`${DEN_CLAUDE_ID}: ${BIN} is not on PATH`);
138
+ return bin;
139
+ }
140
+
141
+ export const denClaude: Runtime = {
142
+ id: DEN_CLAUDE_ID,
143
+ detect,
144
+ launch(input): LaunchOutput {
145
+ const bin = binFor(input);
146
+ const id = input.resumeToken ?? randomUUID();
147
+ const c = common(input);
148
+ return {
149
+ argv: [bin, "--session-id", id, ...c.argv],
150
+ env: {},
151
+ files: c.files,
152
+ resumeToken: id,
153
+ warnings: c.warnings,
154
+ };
155
+ },
156
+ resume(input) {
157
+ if (!input.resumeToken) return null;
158
+ const bin = binFor(input);
159
+ const c = common(input);
160
+ return {
161
+ argv: [bin, "--resume", input.resumeToken, ...c.argv],
162
+ env: {},
163
+ files: c.files,
164
+ warnings: c.warnings,
165
+ };
166
+ },
167
+ approvals: "terminal",
168
+ envPassthrough: ["CLAUDE_CONFIG_DIR"],
169
+ // Claude Code's session plumbing: the session id, its messaging socket and
170
+ // token, the child-session marker (which turns transcripts off). An agent
171
+ // is not part of the session that started it.
172
+ scrubEnv: ["CLAUDECODE", "CLAUDE_CODE_*"],
173
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * generic: any CLI, from the persona's argv template (AG spec §4):
3
+ *
4
+ * argv: ["aider", "--model", "{model}", "--mcp-config", "{mcp_config}"]
5
+ *
6
+ * `{model}` is the agent's model; `{mcp_config}` is the path of a per-agent
7
+ * MCP config (0600) holding Den's scoped server, written only when the
8
+ * template references it. A CLI that speaks no MCP gets a terminal and no
9
+ * Den tools. generic declares no resume.
10
+ */
11
+ import {
12
+ LaunchError,
13
+ mcpConfigJson,
14
+ mcpConfigPath,
15
+ type LaunchOutput,
16
+ type Runtime,
17
+ } from "./runtime.js";
18
+
19
+ export const GENERIC_ID = "generic";
20
+
21
+ export const generic: Runtime = {
22
+ id: GENERIC_ID,
23
+ // The CLI is the persona's; there is nothing to look for until launch.
24
+ detect: () => ({ available: true }),
25
+ scrubEnv: [],
26
+ launch(input): LaunchOutput {
27
+ const template = input.persona.argv;
28
+ if (!template?.length)
29
+ throw new LaunchError(
30
+ `${GENERIC_ID}: persona ${input.persona.id} has no argv template (argv: [...] in its front matter)`,
31
+ );
32
+ const usesModel = template.some((a) => a.includes("{model}"));
33
+ if (usesModel && !input.model)
34
+ throw new LaunchError(
35
+ `${GENERIC_ID}: persona ${input.persona.id}'s argv uses {model}; give --model or set model in the persona`,
36
+ );
37
+ const file = mcpConfigPath(input);
38
+ const usesMcp = template.some((a) => a.includes("{mcp_config}"));
39
+ const argv = template.map((a) =>
40
+ a
41
+ .split("{model}")
42
+ .join(input.model ?? "")
43
+ .split("{mcp_config}")
44
+ .join(file),
45
+ );
46
+ return {
47
+ argv,
48
+ env: {},
49
+ ...(usesMcp ? { files: { [file]: mcpConfigJson(input.mcp) } } : {}),
50
+ };
51
+ },
52
+ };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Personas (AG-2a, AG spec §4): who an agent is. A persona is a markdown
3
+ * file whose YAML front matter says what it is and whose body is its role
4
+ * prompt. A persona-pack is a directory of them.
5
+ *
6
+ * The format is Claude Code's agent file, read as it is, so a pack can
7
+ * point at a Claude Code plugin's agents/ directory without converting or
8
+ * forking it:
9
+ *
10
+ * name the id (Den's `id` wins when both are given)
11
+ * description the one-line role (Den's `role` wins)
12
+ * model the default model; `inherit` means none of its own
13
+ * tools "Read, Grep" or a list: hints, never a restriction Den applies
14
+ *
15
+ * Den's own keys sit beside them: `runtime` (default den-claude),
16
+ * `standing` (starts with its workspace, §5), `context` (what to read
17
+ * first), `argv` (the generic runtime's template), and `permission_mode`
18
+ * (den-claude's Claude Code permission mode; it checks the value). Any other key (a
19
+ * Claude Code `color`) is ignored. A file without front matter (a pack's
20
+ * README) is not a persona.
21
+ */
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import * as yaml from "yaml";
25
+ import { WORKSPACE_NAME_RE } from "../agent-actor.js";
26
+ import { enginePath } from "../engine.js";
27
+
28
+ export interface Persona {
29
+ id: string;
30
+ /** One line: what it does. */
31
+ role: string;
32
+ /** The role prompt: the file's body. */
33
+ prompt: string;
34
+ /** The runtime it runs on unless an agent says otherwise. */
35
+ runtime?: string;
36
+ /** The model it runs unless an agent says otherwise. */
37
+ model?: string;
38
+ /** Started with its workspace (planner, orchestrator). */
39
+ standing: boolean;
40
+ /** Tool hints. */
41
+ tools: string[];
42
+ /** What to read first (specs, the design system, workspace config). */
43
+ context: string[];
44
+ /** The generic runtime's argv template ({model}, {mcp_config}). */
45
+ argv?: string[];
46
+ /** `permission_mode`: the runtime's permission mode (den-claude checks it). */
47
+ permissionMode?: string;
48
+ /** The file it came from. */
49
+ source: string;
50
+ }
51
+
52
+ /** A persona id: the same rules as an agent id or a workspace name. */
53
+ export const PERSONA_ID_RE = WORKSPACE_NAME_RE;
54
+
55
+ /** Den's own pack, shipped in the package (src/ ships whole). */
56
+ export const DEFAULT_PACK_DIR = enginePath(
57
+ "src",
58
+ "shared",
59
+ "agents",
60
+ "default-pack",
61
+ );
62
+
63
+ const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
64
+
65
+ function optString(
66
+ fm: Record<string, unknown>,
67
+ key: string,
68
+ where: string,
69
+ ): string | undefined {
70
+ const v = fm[key];
71
+ if (v === undefined || v === null) return undefined;
72
+ if (typeof v !== "string" && typeof v !== "number")
73
+ throw new Error(`${where}: ${key} must be a string`);
74
+ const s = String(v).trim();
75
+ return s || undefined;
76
+ }
77
+
78
+ /** `tools: Read, Grep` (Claude Code) or `tools: [read, grep]`. */
79
+ function stringList(
80
+ fm: Record<string, unknown>,
81
+ key: string,
82
+ where: string,
83
+ ): string[] {
84
+ const v = fm[key];
85
+ if (v === undefined || v === null) return [];
86
+ if (typeof v === "string")
87
+ return v
88
+ .split(",")
89
+ .map((s) => s.trim())
90
+ .filter(Boolean);
91
+ if (Array.isArray(v) && v.every((x) => typeof x === "string"))
92
+ return (v as string[]).map((s) => s.trim()).filter(Boolean);
93
+ throw new Error(`${where}: ${key} must be a list of strings`);
94
+ }
95
+
96
+ /** One persona file, or null when it has no front matter. */
97
+ export function parsePersona(text: string, file: string): Persona | null {
98
+ const m = FRONT_MATTER.exec(text);
99
+ if (!m) return null;
100
+ let fm: unknown;
101
+ try {
102
+ fm = yaml.parse(m[1]);
103
+ } catch (e) {
104
+ throw new Error(
105
+ `${file}: front matter is not YAML (${(e as Error).message.split("\n")[0]})`,
106
+ );
107
+ }
108
+ if (fm === null || fm === undefined) fm = {};
109
+ if (typeof fm !== "object" || Array.isArray(fm))
110
+ throw new Error(`${file}: front matter must be a mapping`);
111
+ const f = fm as Record<string, unknown>;
112
+ const id =
113
+ optString(f, "id", file) ??
114
+ optString(f, "name", file) ??
115
+ path.basename(file, path.extname(file));
116
+ if (!PERSONA_ID_RE.test(id))
117
+ throw new Error(
118
+ `${file}: id "${id}" must be letters, digits, ".", "_" or "-", 1-64 long, starting with a letter or digit`,
119
+ );
120
+ const standing = f.standing ?? false;
121
+ if (typeof standing !== "boolean")
122
+ throw new Error(`${file}: standing must be true or false`);
123
+ let argv: string[] | undefined;
124
+ if (f.argv !== undefined && f.argv !== null) {
125
+ if (
126
+ !Array.isArray(f.argv) ||
127
+ !f.argv.length ||
128
+ !f.argv.every((a) => typeof a === "string")
129
+ )
130
+ throw new Error(`${file}: argv must be a list of strings`);
131
+ argv = f.argv as string[];
132
+ }
133
+ const model = optString(f, "model", file);
134
+ const runtime = optString(f, "runtime", file);
135
+ const permissionMode = optString(f, "permission_mode", file);
136
+ return {
137
+ id,
138
+ role: optString(f, "role", file) ?? optString(f, "description", file) ?? "",
139
+ prompt: text.slice(m[0].length).trim(),
140
+ ...(runtime ? { runtime } : {}),
141
+ ...(model && model !== "inherit" ? { model } : {}),
142
+ standing,
143
+ tools: stringList(f, "tools", file),
144
+ context: stringList(f, "context", file),
145
+ ...(argv ? { argv } : {}),
146
+ ...(permissionMode ? { permissionMode } : {}),
147
+ source: file,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * A pack's personas, by file name. A directory with an `agents/` inside (a
153
+ * Claude Code plugin) is read from there.
154
+ */
155
+ export function readPack(dir: string): Persona[] {
156
+ const agents = path.join(dir, "agents");
157
+ const from = fs.statSync(agents, { throwIfNoEntry: false })?.isDirectory()
158
+ ? agents
159
+ : dir;
160
+ const out: Persona[] = [];
161
+ const seen = new Map<string, string>();
162
+ for (const name of fs.readdirSync(from).sort()) {
163
+ if (!name.endsWith(".md")) continue;
164
+ const file = path.join(from, name);
165
+ if (!fs.statSync(file).isFile()) continue;
166
+ const p = parsePersona(fs.readFileSync(file, "utf-8"), file);
167
+ if (!p) continue;
168
+ const prev = seen.get(p.id);
169
+ if (prev) throw new Error(`${prev} and ${file} both define "${p.id}"`);
170
+ seen.set(p.id, file);
171
+ out.push(p);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** `agents.persona_packs`, absolute; each must be a directory. */
177
+ export function resolvePackDirs(packs: string[], distRoot: string): string[] {
178
+ return packs.map((p, i) => {
179
+ const abs = path.resolve(distRoot, p);
180
+ if (!fs.statSync(abs, { throwIfNoEntry: false })?.isDirectory())
181
+ throw new Error(`agents.persona_packs[${i}]: ${abs} is not a directory`);
182
+ return abs;
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Every persona: Den's default pack, then each pack in order. A later
188
+ * pack's persona replaces an earlier one of the same id.
189
+ */
190
+ export function loadPersonas(
191
+ packDirs: string[],
192
+ defaultPack: string = DEFAULT_PACK_DIR,
193
+ ): Map<string, Persona> {
194
+ const all = new Map<string, Persona>();
195
+ for (const dir of [defaultPack, ...packDirs])
196
+ for (const p of readPack(dir)) all.set(p.id, p);
197
+ return all;
198
+ }
199
+
200
+ /**
201
+ * The role prompt as an agent gets it: who and where it is, what to read
202
+ * first, then the persona's own prompt. Runtime-neutral; a runtime only
203
+ * chooses how to pass it in.
204
+ */
205
+ export function rolePrompt(
206
+ persona: Persona,
207
+ agentId: string,
208
+ workspace: string,
209
+ mcpName: string,
210
+ ): string {
211
+ const lines = [
212
+ `You are agent ${agentId} in workspace ${workspace}, with the ${persona.id} persona.`,
213
+ `Act on services, logs and tests only through the ${mcpName} MCP server; it serves this workspace.`,
214
+ ];
215
+ if (persona.context.length)
216
+ lines.push(`Read first: ${persona.context.join("; ")}.`);
217
+ return `${lines.join("\n")}\n\n${persona.prompt}`;
218
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The agent record (AG-2a): what an agent is and where it runs, kept in the
3
+ * distribution's state dir so the CLI and the web server (AG-4a) see the
4
+ * same agents, and so an agent outlives the session it runs in.
5
+ *
6
+ * agents/<id>.json the record (0600, its directory 0700)
7
+ * agent-files/<id>/ files its runtime wrote (0600): its MCP config
8
+ *
9
+ * A record never holds the env or the argv: the role prompt, tokens and
10
+ * anything else a launch passes stay out of it. An agent id is unique in a
11
+ * distribution.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import { WORKSPACE_NAME_RE } from "../agent-actor.js";
16
+ import type { SessionRef } from "../sessions/types.js";
17
+
18
+ export interface AgentRecord {
19
+ id: string;
20
+ persona: string;
21
+ runtime: string;
22
+ model?: string;
23
+ workspace: string;
24
+ cwd: string;
25
+ /** The session it runs in now (or last ran in). */
26
+ session: SessionRef;
27
+ /** `<dist>-<ws>:<id>`. */
28
+ sessionName: string;
29
+ /** ISO 8601. */
30
+ startedAt: string;
31
+ /** Set by `agents stop`; cleared by a resume. */
32
+ stoppedAt?: string;
33
+ /** Set when a resume started its current session. */
34
+ resumedAt?: string;
35
+ /** The runtime's handle for resume (den-claude: the conversation id). */
36
+ resumeToken?: string;
37
+ }
38
+
39
+ /** An agent id: the same rules as a workspace or session name. */
40
+ export const AGENT_ID_RE = WORKSPACE_NAME_RE;
41
+
42
+ export function agentsDir(stateDir: string): string {
43
+ return path.join(stateDir, "agents");
44
+ }
45
+
46
+ export function agentFilesDir(stateDir: string, id: string): string {
47
+ return path.join(stateDir, "agent-files", id);
48
+ }
49
+
50
+ function ensureDir(dir: string): void {
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ fs.chmodSync(dir, 0o700);
53
+ }
54
+
55
+ /** Write `content` to `file`, owner-only from the start, atomically. */
56
+ export function writePrivate(file: string, content: string): void {
57
+ ensureDir(path.dirname(file));
58
+ const tmp = `${file}.${process.pid}.tmp`;
59
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
60
+ fs.chmodSync(tmp, 0o600);
61
+ fs.renameSync(tmp, file);
62
+ }
63
+
64
+ function recordFile(stateDir: string, id: string): string {
65
+ if (!AGENT_ID_RE.test(id)) throw new Error(`"${id}" is not an agent id`);
66
+ return path.join(agentsDir(stateDir), `${id}.json`);
67
+ }
68
+
69
+ export function writeRecord(stateDir: string, rec: AgentRecord): void {
70
+ writePrivate(
71
+ recordFile(stateDir, rec.id),
72
+ JSON.stringify(rec, null, 2) + "\n",
73
+ );
74
+ }
75
+
76
+ export function readRecord(stateDir: string, id: string): AgentRecord | null {
77
+ if (!AGENT_ID_RE.test(id)) return null;
78
+ try {
79
+ return JSON.parse(
80
+ fs.readFileSync(recordFile(stateDir, id), "utf-8"),
81
+ ) as AgentRecord;
82
+ } catch (e) {
83
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ export function listRecords(stateDir: string): AgentRecord[] {
89
+ let names: string[];
90
+ try {
91
+ names = fs.readdirSync(agentsDir(stateDir));
92
+ } catch {
93
+ return [];
94
+ }
95
+ const out: AgentRecord[] = [];
96
+ for (const n of names.sort()) {
97
+ if (!n.endsWith(".json")) continue;
98
+ const rec = readRecord(stateDir, n.slice(0, -".json".length));
99
+ if (rec) out.push(rec);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Write a runtime's files, each 0600 and only inside `dir`: a runtime
106
+ * names where in its own directory, never elsewhere.
107
+ */
108
+ export function writeRuntimeFiles(
109
+ dir: string,
110
+ files: Record<string, string> | undefined,
111
+ ): void {
112
+ for (const [file, content] of Object.entries(files ?? {})) {
113
+ const abs = path.resolve(file);
114
+ if (!abs.startsWith(path.resolve(dir) + path.sep))
115
+ throw new Error(`a runtime file must be inside ${dir}: ${file}`);
116
+ writePrivate(abs, content);
117
+ }
118
+ }