odai-dsh-agent 0.0.1

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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/bin/odai-dsh-agent.mjs +87 -0
  4. package/package.json +68 -0
  5. package/preset/odai/agent.cordis.yml +260 -0
  6. package/preset/odai/preset.yml +3 -0
  7. package/preset/odai/runtime/governance.mjs +120 -0
  8. package/preset/odai/runtime/index.mjs +584 -0
  9. package/preset/odai/runtime/router.mjs +290 -0
  10. package/preset/odai/runtime/routing-config.mjs +322 -0
  11. package/preset/odai/skills/odai/SKILL.md +65 -0
  12. package/preset/odai/skills/odai/agents/openai.yaml +4 -0
  13. package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -0
  14. package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -0
  15. package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -0
  16. package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -0
  17. package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -0
  18. package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -0
  19. package/preset/odai/skills/odai/assets/routing-roles/executor.md +11 -0
  20. package/preset/odai/skills/odai/assets/routing-roles/planner.md +29 -0
  21. package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -0
  22. package/preset/odai/skills/odai/assets/task-state.md +12 -0
  23. package/preset/odai/skills/odai/references/craft.md +53 -0
  24. package/preset/odai/skills/odai/references/dao.md +30 -0
  25. package/preset/odai/skills/odai/references/leverage.md +45 -0
  26. package/preset/odai/skills/odai/references/support.md +36 -0
  27. package/preset/odai/skills/odai/references/verification.md +23 -0
  28. package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -0
  29. package/preset/odai/skills/odai/scripts/build-routing.mjs +239 -0
  30. package/preset/odai/skills/odai/scripts/install-routing.mjs +375 -0
  31. package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -0
  32. package/preset/odai/skills/odai/scripts/run-role.mjs +336 -0
  33. package/preset/odai/skills/odai/scripts/run-routing.mjs +1154 -0
  34. package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -0
  35. package/scripts/verify-agent-load.mjs +217 -0
  36. package/src/installer.mjs +270 -0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { createHash } from "node:crypto";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
11
+ const args = parseArgs(process.argv.slice(2));
12
+ const mappings = new Map([["odai_planner", "planner"], ["odai_executor", "executor"], ["odai_reviewer", "reviewer"]]);
13
+
14
+ if (args.help) {
15
+ console.log(`Usage:
16
+ node odai-verify-routing.mjs --host codex --project <directory> \\
17
+ --role <odai_planner|odai_executor|odai_reviewer> --after <ISO timestamp> \\
18
+ [--agent-path </root/task>] [--sessions <directory>]
19
+
20
+ 只读取近期 Codex rollout metadata,把实际角色、模型与推理档和 odai 托管清单对账。`);
21
+ process.exit(0);
22
+ }
23
+ if (args.host !== "codex") fail(`不支持的 host:${args.host || "(missing)"}`);
24
+ if (!mappings.has(args.role)) fail("--role 必须是 odai_planner、odai_executor 或 odai_reviewer");
25
+ if (!args.project || !args.after) fail("缺少 --project 或 --after");
26
+ const after = Date.parse(args.after);
27
+ if (!Number.isFinite(after)) fail(`无效的 --after:${args.after}`);
28
+
29
+ const project = path.resolve(args.project);
30
+ const manifestFile = path.join(scriptDir, "odai-routing.json");
31
+ if (!existsSync(manifestFile)) fail(`缺少路由清单:${manifestFile}`);
32
+ const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
33
+ if (manifest.id !== "odai-routing-installation" || manifest.host !== "codex") fail("路由清单无效");
34
+ for (const [relative, expected] of Object.entries(manifest.files || {})) {
35
+ const target = path.join(scriptDir, relative);
36
+ if (!existsSync(target) || sha256(readFileSync(target)) !== expected) fail(`路由托管文件缺失或已变化:${relative}`);
37
+ }
38
+ const expected = manifest.mapping?.[mappings.get(args.role)];
39
+ if (!expected?.model) fail("路由清单缺少角色模型映射");
40
+
41
+ const sessions = path.resolve(args.sessions || path.join(process.env.CODEX_HOME || path.join(homedir(), ".codex"), "sessions"));
42
+ if (!existsSync(sessions)) fail(`Codex sessions 不存在:${sessions}`);
43
+ const candidates = [];
44
+ for (const file of recentJsonl(sessions, after - 5000)) {
45
+ const metadata = readMetadata(file);
46
+ const session = metadata.session;
47
+ const source = session?.source?.subagent?.thread_spawn;
48
+ if (!source || session.agent_role !== args.role || path.resolve(session.cwd || "") !== project) continue;
49
+ const started = Date.parse(session.timestamp || "");
50
+ if (!Number.isFinite(started) || started < after) continue;
51
+ if (args.agentPath && source.agent_path !== args.agentPath) continue;
52
+ if (metadata.context) candidates.push({ session, context: metadata.context, source });
53
+ }
54
+ if (candidates.length !== 1) fail(candidates.length ? `找到 ${candidates.length} 个候选子会话;请用 --agent-path 消除歧义` : "未找到满足边界的子会话运行时记录");
55
+ const match = candidates[0];
56
+ const actualModel = match.context.model;
57
+ const actualEffort = match.context.effort || match.context.collaboration_mode?.settings?.reasoning_effort || null;
58
+ if (actualModel !== expected.model) fail(`模型不匹配:期望 ${expected.model},实际 ${actualModel || "(missing)"}`);
59
+ if (expected.reasoning_effort && actualEffort !== expected.reasoning_effort) fail(`推理档不匹配:期望 ${expected.reasoning_effort},实际 ${actualEffort || "(missing)"}`);
60
+
61
+ process.stdout.write(`${JSON.stringify({
62
+ verified: true, role: match.session.agent_role, model: actualModel, reasoning_effort: actualEffort,
63
+ agent_path: match.source.agent_path || null, child_thread_id: match.session.id,
64
+ parent_thread_id: match.session.parent_thread_id || null, project,
65
+ }, null, 2)}\n`);
66
+
67
+ function recentJsonl(root, threshold) {
68
+ const files = [];
69
+ const pending = [root];
70
+ while (pending.length) {
71
+ const directory = pending.pop();
72
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
73
+ const full = path.join(directory, entry.name);
74
+ if (entry.isDirectory()) pending.push(full);
75
+ else if (entry.isFile() && entry.name.endsWith(".jsonl") && statSync(full).mtimeMs >= threshold) files.push(full);
76
+ }
77
+ }
78
+ return files;
79
+ }
80
+ function readMetadata(file) {
81
+ let session = null; let context = null;
82
+ for (const line of readFileSync(file, "utf8").split("\n")) {
83
+ if (!line) continue;
84
+ let event; try { event = JSON.parse(line); } catch { continue; }
85
+ if (event.type === "session_meta") session = event.payload;
86
+ if (event.type === "turn_context") context = event.payload;
87
+ if (session && context) break;
88
+ }
89
+ return { session, context };
90
+ }
91
+ function parseArgs(values) {
92
+ const result = { host: "", project: "", role: "", after: "", agentPath: "", sessions: "", help: false };
93
+ const fields = new Map([["--host", "host"], ["--project", "project"], ["--role", "role"], ["--after", "after"], ["--agent-path", "agentPath"], ["--sessions", "sessions"]]);
94
+ for (let index = 0; index < values.length; index += 1) {
95
+ const value = values[index];
96
+ if (value === "--help" || value === "-h") result.help = true;
97
+ else if (fields.has(value)) { const next = values[++index] || ""; if (!next || next.startsWith("--")) fail(`${value} 需要一个值`); result[fields.get(value)] = next; }
98
+ else fail(`未知参数:${value}`);
99
+ }
100
+ return result;
101
+ }
102
+ function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
103
+ function fail(message) { console.error(message); process.exit(1); }
@@ -0,0 +1,217 @@
1
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { createServer } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { execFileSync, spawn } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ import { installAgentPreset, SUPPORTED_DSH_VERSION } from "../src/installer.mjs";
11
+
12
+ const agentRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const repoRoot = resolve(agentRoot, "../..");
14
+ const dsh = process.env.DSH_BIN ?? "dsh";
15
+ const dshRoot = process.env.DSH_PACKAGE_ROOT
16
+ ? resolve(process.env.DSH_PACKAGE_ROOT)
17
+ : findDshPackageRoot(dsh);
18
+ const scopeModule = resolve(dshRoot, "node_modules/@deepseek-ai/dsh-scope/lib/index.js");
19
+ await verifyPinnedComposition();
20
+ const scratch = await mkdtemp(resolve(tmpdir(), "odai-agent-scope-"));
21
+ const home = resolve(scratch, "home");
22
+ const workspace = resolve(scratch, "workspace");
23
+ const sourceRoot = resolve(scratch, "source-preset");
24
+ const markerPath = resolve(scratch, "scope-results.json");
25
+ const probePluginPath = resolve(scratch, "scope-probe-plugin.mjs");
26
+ const patchPath = resolve(scratch, "scope-probe.patch.yml");
27
+
28
+ const yaml = (value) => JSON.stringify(value);
29
+
30
+ function findDshPackageRoot(command) {
31
+ const locator = process.platform === "win32" ? "where" : "which";
32
+ const located = execFileSync(locator, [command], { encoding: "utf8" }).trim().split(/\r?\n/u)[0];
33
+ let current = dirname(realpathSync(located));
34
+ while (dirname(current) !== current) {
35
+ try {
36
+ const metadata = JSON.parse(readFileSync(resolve(current, "package.json"), "utf8"));
37
+ if (metadata.name === "@deepseek-ai/dsh") return current;
38
+ } catch (error) {
39
+ if (error?.code !== "ENOENT") throw error;
40
+ }
41
+ current = dirname(current);
42
+ }
43
+ throw new Error(`cannot locate the @deepseek-ai/dsh package behind ${command}`);
44
+ }
45
+
46
+ async function verifyPinnedComposition() {
47
+ const dshMetadata = JSON.parse(await readFile(resolve(dshRoot, "package.json"), "utf8"));
48
+ if (dshMetadata.version !== SUPPORTED_DSH_VERSION) {
49
+ throw new Error(`agent preset expects DSH ${SUPPORTED_DSH_VERSION}, found ${dshMetadata.version}`);
50
+ }
51
+
52
+ const standard = await readFile(resolve(dshRoot, "config/agent-presets/standard/agent.cordis.yml"), "utf8");
53
+ const odaiSuffix = [
54
+ "# Odai contributes scoped prompt, guard, routing, user-owned responsibility",
55
+ "# mappings, and evidence listeners. Base controller selection stays host-owned.",
56
+ "- id: odai-governance",
57
+ " name: ./runtime/index.mjs",
58
+ " config:",
59
+ " routing:",
60
+ " mode: auto",
61
+ " provider: spawn",
62
+ ].join("\n");
63
+ const expected = `${standard
64
+ .replace(
65
+ "# The preset's own persona, shadowing the deployment default for this agent.\n# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.",
66
+ "# The preset's own model-neutral persona shadows the deployment default. Auto\n# routing can upgrade after prompt assembly; `{{cwd}}` remains workspace-local.",
67
+ )
68
+ .replace(
69
+ "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.",
70
+ "You are Odai, a coding agent. Your working directory is {{cwd}}.",
71
+ )
72
+ .trimEnd()}\n\n${odaiSuffix}`;
73
+ const actual = (await readFile(resolve(agentRoot, "preset/odai/agent.cordis.yml"), "utf8")).trimEnd();
74
+ if (actual !== expected) {
75
+ throw new Error("Odai Agent composition drifted from the pinned DSH standard preset");
76
+ }
77
+ }
78
+
79
+ async function freePort() {
80
+ return await new Promise((accept, reject) => {
81
+ const server = createServer();
82
+ server.once("error", reject);
83
+ server.listen(0, "127.0.0.1", () => {
84
+ const address = server.address();
85
+ const port = typeof address === "object" && address ? address.port : undefined;
86
+ server.close((error) => error ? reject(error) : accept(port));
87
+ });
88
+ });
89
+ }
90
+
91
+ async function rpc(baseUrl, method, payload) {
92
+ const rpcId = randomUUID();
93
+ const response = await fetch(`${baseUrl}/api/${method}`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({ type: "client-request", rpcId, method, payload }),
97
+ });
98
+ const text = await response.text();
99
+ if (!response.ok) throw new Error(`${method} HTTP ${response.status}: ${text}`);
100
+ const body = JSON.parse(text);
101
+ if (body.result?.ok !== true) throw new Error(`${method} failed: ${text}`);
102
+ return body.result.value;
103
+ }
104
+
105
+ async function waitForServer(baseUrl, child, output) {
106
+ const deadline = Date.now() + 20_000;
107
+ while (Date.now() < deadline) {
108
+ if (child.exitCode !== null) throw new Error(`dsh web exited early (${child.exitCode})\n${output()}`);
109
+ try {
110
+ await rpc(baseUrl, "agentPreset.list", {});
111
+ return;
112
+ } catch {
113
+ await new Promise((accept) => setTimeout(accept, 75));
114
+ }
115
+ }
116
+ throw new Error(`timed out waiting for dsh web\n${output()}`);
117
+ }
118
+
119
+ async function waitForMarker(child, output) {
120
+ const deadline = Date.now() + 15_000;
121
+ while (Date.now() < deadline) {
122
+ if (existsSync(markerPath)) return JSON.parse(await readFile(markerPath, "utf8"));
123
+ if (child.exitCode !== null) throw new Error(`dsh web exited before probe completed (${child.exitCode})\n${output()}`);
124
+ await new Promise((accept) => setTimeout(accept, 50));
125
+ }
126
+ throw new Error(`timed out waiting for scope marker\n${output()}`);
127
+ }
128
+
129
+ await mkdir(workspace, { recursive: true });
130
+ await cp(resolve(agentRoot, "preset/odai"), sourceRoot, { recursive: true });
131
+ const developmentRuntime = resolve(repoRoot, "dsh/runtime/src");
132
+ const developmentSkill = resolve(repoRoot, "skills/odai");
133
+ if (existsSync(developmentRuntime) && existsSync(developmentSkill)) {
134
+ await Promise.all([
135
+ cp(developmentRuntime, resolve(sourceRoot, "runtime"), { recursive: true }),
136
+ cp(developmentSkill, resolve(sourceRoot, "skills/odai"), { recursive: true }),
137
+ ]);
138
+ } else if (!existsSync(resolve(sourceRoot, "runtime/index.mjs"))
139
+ || !existsSync(resolve(sourceRoot, "skills/odai/SKILL.md"))) {
140
+ throw new Error("Odai Agent verification requires either repository sources or packaged runtime and skill files");
141
+ }
142
+ await installAgentPreset({ dshHome: home, sourceRoot });
143
+
144
+ const probePlugin = `import { existsSync, readFileSync, writeFileSync } from "node:fs";\nimport { bindScopeParent } from ${JSON.stringify(pathToFileURL(scopeModule).href)};\n\nexport const name = "odai-agent-scope-probe";\nexport const inject = ["systemPrompt", "tools"];\n\nexport function apply(ctx, config) {\n const results = {};\n let writing = Promise.resolve();\n\n ctx.on("agent/created", ({ agent }) => {\n writing = writing.then(async () => {\n const preset = agent.session?.header?.agentPreset;\n if (preset !== "standard" && preset !== "odai") return;\n\n const assembly = await ctx.systemPrompt.assemble({ scope: agent });\n const canonicalSections = assembly.sections.filter((section) => section.name === "odai:canonical-governance");\n const writePath = preset === "odai" ? config.odaiWritePath : config.standardWritePath;\n const childSession = new Proxy(agent.session, {\n get(target, property) {\n if (property === "header") {\n return { ...target.header, origin: "subagent", delegationDepth: 1 };\n }\n return Reflect.get(target, property, target);\n },\n });\n const child = { id: agent.id, session: childSession };\n bindScopeParent(child, agent);\n const toolResult = await ctx.tools.execute({\n callId: \`scope-probe-\${preset}\`,\n name: "write",\n arguments: { file_path: writePath, content: \`\${preset} child write reached body\\n\` },\n agent: child,\n signal: new AbortController().signal,\n });\n\n let routingConfigured;\n if (preset === "odai") {\n const routingResult = await ctx.tools.execute({\n callId: "scope-probe-odai-routing-config",\n name: "odai_routing_config",\n arguments: { action: "set", responsibility: "executor", provider: "probe-provider", model: "probe-executor", reasoningEffort: "high" },\n agent,\n signal: new AbortController().signal,\n });\n if (routingResult.isError) throw new Error(\`odai routing config tool failed: \${routingResult.error?.message}\`);\n const stored = JSON.parse(readFileSync(config.routingConfigPath, "utf8"));\n routingConfigured = stored.roles?.executor?.provider === "probe-provider"\n && stored.roles?.executor?.model === "probe-executor";\n }\n\n results[preset] = {\n canonicalSectionCount: canonicalSections.length,\n toolIsError: toolResult.isError === true,\n toolError: toolResult.isError === true ? toolResult.error?.message : undefined,\n writeReachedBody: existsSync(writePath),\n ...(routingConfigured === undefined ? {} : { routingConfigured }),\n };\n if (results.standard && results.odai) {\n writeFileSync(config.markerPath, JSON.stringify(results, null, 2) + "\\n", "utf8");\n }\n }).catch((error) => {\n writeFileSync(config.markerPath, JSON.stringify({ probeError: error?.stack ?? String(error) }, null, 2) + "\\n", "utf8");\n });\n }, { global: true });\n}\n`;
145
+ await writeFile(probePluginPath, probePlugin, "utf8");
146
+ await writeFile(patchPath, [
147
+ "- insert:",
148
+ " - id: odai-agent-scope-probe",
149
+ ` name: ${yaml(probePluginPath)}`,
150
+ " config:",
151
+ ` markerPath: ${yaml(markerPath)}`,
152
+ ` standardWritePath: ${yaml(resolve(workspace, "standard-child-write.txt"))}`,
153
+ ` odaiWritePath: ${yaml(resolve(workspace, "odai-child-write.txt"))}`,
154
+ ` routingConfigPath: ${yaml(resolve(home, "odai/routing.json"))}`,
155
+ "",
156
+ ].join("\n"), "utf8");
157
+
158
+ const port = await freePort();
159
+ const baseUrl = `http://127.0.0.1:${port}`;
160
+ const child = spawn(dsh, [
161
+ "--profile", "web",
162
+ "--patch", patchPath,
163
+ "--host", "127.0.0.1",
164
+ "--port", String(port),
165
+ ], {
166
+ cwd: workspace,
167
+ env: {
168
+ ...process.env,
169
+ DSH_HOME: home,
170
+ DSH_TELEMETRY_MODE: "DISABLED",
171
+ },
172
+ stdio: ["ignore", "pipe", "pipe"],
173
+ });
174
+ let output = "";
175
+ child.stdout.on("data", (chunk) => { output += chunk.toString(); });
176
+ child.stderr.on("data", (chunk) => { output += chunk.toString(); });
177
+ const capturedOutput = () => output;
178
+
179
+ try {
180
+ await waitForServer(baseUrl, child, capturedOutput);
181
+ const roster = await rpc(baseUrl, "agentPreset.list", {});
182
+ const ids = roster.presets.map((preset) => preset.id);
183
+ if (!ids.includes("standard") || !ids.includes("odai")) {
184
+ throw new Error(`expected standard and odai presets, got ${JSON.stringify(ids)}`);
185
+ }
186
+ await rpc(baseUrl, "session.create", { cwd: workspace, agentPreset: "standard" });
187
+ await rpc(baseUrl, "session.create", { cwd: workspace, agentPreset: "odai" });
188
+ const results = await waitForMarker(child, capturedOutput);
189
+ if (results.probeError) throw new Error(results.probeError);
190
+ if (results.standard?.canonicalSectionCount !== 0) {
191
+ throw new Error(`standard saw odai canonical prompt: ${JSON.stringify(results)}`);
192
+ }
193
+ if (results.standard?.toolError?.startsWith("ODAI_SUBAGENT_BOUNDARY:")) {
194
+ throw new Error(`standard child call reached the odai guard: ${JSON.stringify(results)}`);
195
+ }
196
+ if (results.odai?.canonicalSectionCount !== 1) {
197
+ throw new Error(`odai canonical prompt count was not exactly one: ${JSON.stringify(results)}`);
198
+ }
199
+ if (results.odai?.writeReachedBody !== false || results.odai?.toolIsError !== true
200
+ || !results.odai?.toolError?.startsWith("ODAI_SUBAGENT_BOUNDARY:")) {
201
+ throw new Error(`odai child write was not denied by odai guard: ${JSON.stringify(results)}`);
202
+ }
203
+ if (results.odai?.routingConfigured !== true) {
204
+ throw new Error(`odai natural routing tool did not persist through DSH: ${JSON.stringify(results)}`);
205
+ }
206
+ process.stdout.write(`${JSON.stringify({ scratch, roster: ids, results }, null, 2)}\n`);
207
+ } finally {
208
+ child.kill("SIGTERM");
209
+ await new Promise((accept) => {
210
+ if (child.exitCode !== null) return accept();
211
+ const timeout = setTimeout(() => { child.kill("SIGKILL"); accept(); }, 3_000);
212
+ child.once("exit", () => { clearTimeout(timeout); accept(); });
213
+ });
214
+ if (process.env.KEEP_ODAI_SCOPE_PROBE !== "1") {
215
+ await rm(scratch, { recursive: true, force: true });
216
+ }
217
+ }
@@ -0,0 +1,270 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ cp,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, relative, resolve, sep } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { parse as parseYaml } from "yaml";
18
+
19
+ export const DEFAULT_PRESET_ID = "odai";
20
+ export const MANIFEST_FILE = ".odai-agent.json";
21
+
22
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
+ const defaultSourceRoot = resolve(packageRoot, "preset/odai");
24
+ const packageMetadata = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
25
+ export const SUPPORTED_DSH_VERSION = packageMetadata.peerDependencies["@deepseek-ai/dsh"];
26
+ const requiredFiles = Object.freeze([
27
+ "agent.cordis.yml",
28
+ "preset.yml",
29
+ "runtime/index.mjs",
30
+ "skills/odai/SKILL.md",
31
+ ]);
32
+
33
+ export function resolveDshHome(configured, env = process.env) {
34
+ const value = configured ?? env.DSH_HOME ?? resolve(homedir(), ".dsh");
35
+ if (typeof value !== "string" || value.trim() === "") {
36
+ throw new TypeError("DSH home must be a non-empty path");
37
+ }
38
+ const trimmed = value.trim();
39
+ if (trimmed === "~") return homedir();
40
+ if (trimmed.startsWith(`~${sep}`)) return resolve(homedir(), trimmed.slice(2));
41
+ return resolve(trimmed);
42
+ }
43
+
44
+ export async function inspectAgentInstallation(options = {}) {
45
+ const presetId = assertPresetId(options.presetId ?? DEFAULT_PRESET_ID);
46
+ const dshHome = resolveDshHome(options.dshHome);
47
+ const target = resolve(dshHome, ".agent-presets", presetId);
48
+ const state = await inspectTarget(target, presetId);
49
+ return Object.freeze({ dshHome, presetId, target, ...state });
50
+ }
51
+
52
+ export async function installAgentPreset(options = {}) {
53
+ const presetId = assertPresetId(options.presetId ?? DEFAULT_PRESET_ID);
54
+ const dshHome = resolveDshHome(options.dshHome);
55
+ const sourceRoot = resolve(options.sourceRoot ?? defaultSourceRoot);
56
+ const targetRoot = resolve(dshHome, ".agent-presets");
57
+ const target = resolve(targetRoot, presetId);
58
+ const current = await inspectTarget(target, presetId);
59
+ const previousCompositionSize = current.status === "installed"
60
+ ? Buffer.byteLength(await readFile(resolve(target, "agent.cordis.yml")))
61
+ : undefined;
62
+
63
+ if (current.status === "drifted") {
64
+ throw new Error(`refusing to replace modified preset at ${target}: ${current.issues.join("; ")}`);
65
+ }
66
+
67
+ await assertSource(sourceRoot);
68
+ await mkdir(targetRoot, { recursive: true, mode: 0o700 });
69
+ const staging = resolve(targetRoot, `.${presetId}.tmp-${process.pid}-${randomUUID()}`);
70
+ const backup = resolve(targetRoot, `.${presetId}.backup-${process.pid}-${randomUUID()}`);
71
+ let backupCreated = false;
72
+
73
+ try {
74
+ await cp(sourceRoot, staging, { recursive: true, errorOnExist: true });
75
+ await tightenTree(staging);
76
+ const compositionPath = resolve(staging, "agent.cordis.yml");
77
+ const composition = await readFile(compositionPath, "utf8");
78
+ let stampedComposition = `${composition.trimEnd()}\n# odai-dsh-agent generation ${packageMetadata.version}:${randomUUID()}\n`;
79
+ // DSH rc.6 keys standing generations by mtime and size, so size must change even on same-tick updates.
80
+ if (previousCompositionSize === Buffer.byteLength(stampedComposition)) {
81
+ stampedComposition += "# odai-dsh-agent generation size bump\n";
82
+ }
83
+ await writeFile(compositionPath, stampedComposition, "utf8");
84
+ const files = await hashTree(staging);
85
+ const manifest = {
86
+ schemaVersion: 1,
87
+ package: packageMetadata.name,
88
+ version: packageMetadata.version,
89
+ presetId,
90
+ files,
91
+ };
92
+ const manifestPath = resolve(staging, MANIFEST_FILE);
93
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
94
+ await chmod(manifestPath, 0o600);
95
+
96
+ if (current.status === "installed") {
97
+ await rename(target, backup);
98
+ backupCreated = true;
99
+ }
100
+ await rename(staging, target);
101
+ if (backupCreated) {
102
+ await rm(backup, { recursive: true, force: true });
103
+ backupCreated = false;
104
+ }
105
+
106
+ return Object.freeze({
107
+ operation: current.status === "installed" ? "updated" : "installed",
108
+ dshHome,
109
+ presetId,
110
+ target,
111
+ version: packageMetadata.version,
112
+ trust: "user",
113
+ security: "DSH user presets have the same privileges as shell access; install only reviewed preset code.",
114
+ });
115
+ } catch (error) {
116
+ await rm(staging, { recursive: true, force: true });
117
+ if (backupCreated) {
118
+ const targetState = await pathState(target);
119
+ if (targetState === "missing") await rename(backup, target);
120
+ }
121
+ throw error;
122
+ } finally {
123
+ await rm(backup, { recursive: true, force: true });
124
+ }
125
+ }
126
+
127
+ export async function uninstallAgentPreset(options = {}) {
128
+ const inspected = await inspectAgentInstallation(options);
129
+ if (inspected.status === "absent") {
130
+ return Object.freeze({ operation: "absent", target: inspected.target, presetId: inspected.presetId });
131
+ }
132
+ if (inspected.status === "drifted") {
133
+ throw new Error(`refusing to remove modified preset at ${inspected.target}: ${inspected.issues.join("; ")}`);
134
+ }
135
+ await assertPresetIsNotDefault(inspected.dshHome, inspected.presetId);
136
+ await rm(inspected.target, { recursive: true, force: true });
137
+ return Object.freeze({ operation: "uninstalled", target: inspected.target, presetId: inspected.presetId });
138
+ }
139
+
140
+ async function assertPresetIsNotDefault(dshHome, presetId) {
141
+ const settingsPath = resolve(dshHome, "settings.yaml");
142
+ let text;
143
+ try {
144
+ text = await readFile(settingsPath, "utf8");
145
+ } catch (error) {
146
+ if (error?.code === "ENOENT") return;
147
+ throw error;
148
+ }
149
+ let settings;
150
+ try {
151
+ settings = parseYaml(text) ?? {};
152
+ } catch (error) {
153
+ throw new Error(`cannot verify the default agent preset in ${settingsPath}: ${error.message}`);
154
+ }
155
+ if (settings?.["agent-presets"]?.default === presetId) {
156
+ throw new Error(`refusing to uninstall the default preset ${presetId}; select another agent-presets.default first`);
157
+ }
158
+ }
159
+
160
+ async function inspectTarget(target, presetId) {
161
+ const state = await pathState(target);
162
+ if (state === "missing") return { status: "absent", issues: [] };
163
+ if (state !== "directory") return { status: "drifted", issues: ["target is not a regular directory"] };
164
+
165
+ let manifest;
166
+ try {
167
+ manifest = JSON.parse(await readFile(resolve(target, MANIFEST_FILE), "utf8"));
168
+ } catch (error) {
169
+ return { status: "drifted", issues: [`managed manifest is unavailable: ${error.message}`] };
170
+ }
171
+
172
+ const issues = validateManifest(manifest, presetId);
173
+ let actualFiles = {};
174
+ try {
175
+ actualFiles = await hashTree(target, { exclude: new Set([MANIFEST_FILE]) });
176
+ } catch (error) {
177
+ issues.push(error.message);
178
+ }
179
+
180
+ if (issues.length === 0) {
181
+ const expectedFiles = manifest.files;
182
+ for (const [path, hash] of Object.entries(expectedFiles)) {
183
+ if (!(path in actualFiles)) issues.push(`missing managed file ${path}`);
184
+ else if (actualFiles[path] !== hash) issues.push(`modified managed file ${path}`);
185
+ }
186
+ for (const path of Object.keys(actualFiles)) {
187
+ if (!(path in expectedFiles)) issues.push(`unmanaged file ${path}`);
188
+ }
189
+ }
190
+
191
+ return {
192
+ status: issues.length === 0 ? "installed" : "drifted",
193
+ issues,
194
+ version: typeof manifest?.version === "string" ? manifest.version : undefined,
195
+ };
196
+ }
197
+
198
+ function validateManifest(manifest, presetId) {
199
+ const issues = [];
200
+ if (manifest?.schemaVersion !== 1) issues.push("unsupported managed manifest schema");
201
+ if (manifest?.package !== packageMetadata.name) issues.push("managed manifest package mismatch");
202
+ if (manifest?.presetId !== presetId) issues.push("managed manifest preset id mismatch");
203
+ if (!manifest?.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
204
+ issues.push("managed manifest file map is invalid");
205
+ }
206
+ return issues;
207
+ }
208
+
209
+ async function assertSource(sourceRoot) {
210
+ for (const path of requiredFiles) {
211
+ const state = await pathState(resolve(sourceRoot, path));
212
+ if (state !== "file") throw new Error(`agent package is incomplete: missing ${path}`);
213
+ }
214
+ }
215
+
216
+ async function tightenTree(root) {
217
+ await chmod(root, 0o700);
218
+ const entries = await readdir(root, { withFileTypes: true });
219
+ for (const entry of entries) {
220
+ const path = resolve(root, entry.name);
221
+ if (entry.isSymbolicLink()) throw new Error(`symbolic link is not allowed in managed preset: ${entry.name}`);
222
+ if (entry.isDirectory()) await tightenTree(path);
223
+ else if (entry.isFile()) await chmod(path, 0o600);
224
+ else throw new Error(`unsupported filesystem entry in managed preset: ${entry.name}`);
225
+ }
226
+ }
227
+
228
+ async function hashTree(root, options = {}) {
229
+ const found = {};
230
+ await walk(root, "");
231
+ return Object.fromEntries(Object.entries(found).sort(([left], [right]) => left.localeCompare(right)));
232
+
233
+ async function walk(base, prefix) {
234
+ const entries = await readdir(base, { withFileTypes: true });
235
+ entries.sort((left, right) => left.name.localeCompare(right.name));
236
+ for (const entry of entries) {
237
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
238
+ if (options.exclude?.has(relativePath)) continue;
239
+ const absolutePath = resolve(base, entry.name);
240
+ if (entry.isSymbolicLink()) throw new Error(`symbolic link is not allowed in managed preset: ${relativePath}`);
241
+ if (entry.isDirectory()) await walk(absolutePath, relativePath);
242
+ else if (entry.isFile()) found[relativePath] = await hashFile(absolutePath);
243
+ else throw new Error(`unsupported filesystem entry in managed preset: ${relativePath}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ async function hashFile(path) {
249
+ return createHash("sha256").update(await readFile(path)).digest("hex");
250
+ }
251
+
252
+ async function pathState(path) {
253
+ try {
254
+ const stats = await lstat(path);
255
+ if (stats.isSymbolicLink()) return "symlink";
256
+ if (stats.isDirectory()) return "directory";
257
+ if (stats.isFile()) return "file";
258
+ return "other";
259
+ } catch (error) {
260
+ if (error?.code === "ENOENT") return "missing";
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ function assertPresetId(value) {
266
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)) {
267
+ throw new TypeError("preset id must contain only letters, digits, dots, underscores, or hyphens");
268
+ }
269
+ return value;
270
+ }