odai-dsh-agent 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -35,7 +35,7 @@ Open a new DSH session and select `Odai` from the Agent preset picker. Existing
35
35
 
36
36
  In `dsh@0.1.0-rc.6`, the one-shot `--profile headless` driver creates a global agent directly and neither mounts nor accepts a session Agent preset. Automated Agent runs must create a Web session with `agentPreset: odai`; use the profile-wide Plugin for one-shot headless tasks.
37
37
 
38
- The installer copies through a mode-tightened staging directory and atomically publishes the preset. Updates verify every previously managed file first, refuse to overwrite local edits, and always change the composition generation key so new sessions in a running DSH process do not reuse stale runtime code.
38
+ The installer copies through a mode-tightened staging directory and atomically publishes the preset. Updates verify every previously managed file first, refuse to overwrite local edits, and always change the composition generation key so new sessions in a running DSH process do not reuse stale runtime code. If install or update finds recognized historical Odai audit records, it refuses to change the preset until every DSH process is stopped and the command is rerun with `--yes`. The confirmed migration adds DSH's official `ignorable: true` envelope marker, covers both plaintext and concatenated-frame Zstandard session artifacts, verifies each replacement, and retains a content-addressed backup without deleting messages or evidence. Confirmation alone is insufficient: migration also refuses when local process inspection fails or finds any active DSH process. Keep DSH stopped until the installer exits because historical runtimes do not participate in a migration lock. An unknown unmarked `odai/*` type blocks the operation instead of being assumed safe to ignore.
39
39
 
40
40
  DSH classifies this as a `trust: user` preset. User presets have the same privileges as shell access, so install only reviewed package versions; the installer repeats this trust notice in both plain and JSON output.
41
41
 
@@ -49,7 +49,7 @@ The Agent ships no planner, executor, or reviewer model mapping. It stays quiet
49
49
  验收改用 provider-z/model-review,推理档 max。
50
50
  ```
51
51
 
52
- The controller calls `odai_routing_config` to persist that explicit choice. The user does not edit Agent files, YAML, or JSON and does not add trigger terms to later tasks. Mappings live in `$DSH_HOME/odai/routing.json`, outside the managed preset, so installer updates do not report them as drift. Changes apply from the next user turn. If reasoning effort is omitted, the target provider/model uses its own default rather than inheriting the source controller's setting. Plugin and Agent read the same store when both are deliberately present.
52
+ The controller calls `odai_routing_config` to persist that explicit choice. The user does not edit Agent files, YAML, or JSON and does not add trigger terms to later tasks. Mappings live in `$DSH_HOME/odai/routing.json`, outside the managed preset, so installer updates do not report them as drift. Audit evidence likewise lives under `$DSH_HOME/odai/session-evidence/` instead of using private DSH session-event types, so changing or removing the preset cannot make a session unreadable. Changes apply from the next user turn. If reasoning effort is omitted, the target provider/model uses its own default rather than inheriting the source controller's setting. Plugin and Agent read the same stores when both are deliberately present.
53
53
 
54
54
  Planner, executor, and reviewer are independent optional responsibilities. If any needed one has no mapping, high-impact work fails closed and remains read-only; lower-impact work continues only where it does not depend on the missing independent responsibility. Odai never chooses a model on the user's behalf.
55
55
 
@@ -61,7 +61,7 @@ npx odai-dsh-agent status --json
61
61
  npx odai-dsh-agent uninstall
62
62
  ```
63
63
 
64
- `status` reports `absent`, `installed`, or `drifted`. Update and uninstall fail closed when managed files were changed or unmanaged files were added. Uninstall also refuses while `agent-presets.default` still names `odai`; select another default first so the next session cannot fail on a missing preset.
64
+ `status` reports `absent`, `installed`, or `drifted`. Update and uninstall fail closed when managed files were changed or unmanaged files were added. Uninstall first checks for legacy Odai session events and, when any exist, requires the same stopped-DSH `--yes` confirmation before making them ignorable; a failed inspection or migration refuses removal. It also refuses while `agent-presets.default` still names `odai`; select another default first so the next session cannot fail on a missing preset. Stop DSH before install, update, or uninstall so session artifacts are not being written concurrently.
65
65
 
66
66
  ## Plugin versus Agent
67
67
 
@@ -71,7 +71,7 @@ Use only the surface that matches the desired scope:
71
71
  - `odai-dsh-agent`: selectable Odai governance for only sessions using this preset.
72
72
  - both: supported only for a deliberate combination of profile-wide and Agent-scoped behavior; normally redundant, so it is not the default recommendation.
73
73
 
74
- When both are deliberate, durable tool and route evidence is deduplicated and denials remain monotonic. Neither package installs or changes the provider-neutral `odai-cli`.
74
+ When both are deliberate, the shared compatibility-safe evidence store deduplicates tool and route records and denials remain monotonic. Neither package installs or changes the provider-neutral `odai-cli`.
75
75
 
76
76
  ## Development
77
77
 
@@ -18,6 +18,7 @@ Commands:
18
18
  Options:
19
19
  --dsh-home <path> Override DSH_HOME
20
20
  --json Print JSON
21
+ --yes Confirm DSH is stopped; active-process verification still applies
21
22
  -h, --help Show this help
22
23
  `;
23
24
 
@@ -27,13 +28,19 @@ try {
27
28
  process.stdout.write(HELP);
28
29
  } else if (args.command === "install") {
29
30
  assertDshVersion();
30
- print(await installAgentPreset({ dshHome: args.dshHome }), args.json);
31
+ print(await installAgentPreset({
32
+ dshHome: args.dshHome,
33
+ confirmDshStopped: args.yes,
34
+ }), args.json);
31
35
  } else if (args.command === "status") {
32
36
  const result = await inspectAgentInstallation({ dshHome: args.dshHome });
33
37
  print(result, args.json);
34
38
  if (result.status === "drifted") process.exitCode = 2;
35
39
  } else if (args.command === "uninstall") {
36
- print(await uninstallAgentPreset({ dshHome: args.dshHome }), args.json);
40
+ print(await uninstallAgentPreset({
41
+ dshHome: args.dshHome,
42
+ confirmDshStopped: args.yes,
43
+ }), args.json);
37
44
  } else {
38
45
  throw new Error("a command is required\n\n" + HELP);
39
46
  }
@@ -56,11 +63,12 @@ function assertDshVersion() {
56
63
  }
57
64
 
58
65
  function parseArgs(argv) {
59
- const parsed = { command: undefined, dshHome: undefined, json: false, help: false };
66
+ const parsed = { command: undefined, dshHome: undefined, json: false, yes: false, help: false };
60
67
  for (let index = 0; index < argv.length; index += 1) {
61
68
  const arg = argv[index];
62
69
  if (arg === "-h" || arg === "--help") parsed.help = true;
63
70
  else if (arg === "--json") parsed.json = true;
71
+ else if (arg === "--yes") parsed.yes = true;
64
72
  else if (arg === "--dsh-home") parsed.dshHome = argv[++index];
65
73
  else if (!parsed.command && ["install", "status", "uninstall"].includes(arg)) parsed.command = arg;
66
74
  else throw new Error(`unknown argument: ${arg}`);
@@ -82,5 +90,17 @@ function print(result, json) {
82
90
  return;
83
91
  }
84
92
  process.stdout.write(`${result.operation}: ${result.target}\n`);
93
+ if (result.sessionCompatibility?.repairedEvents > 0) {
94
+ process.stdout.write(`session compatibility: made ${result.sessionCompatibility.repairedEvents} legacy Odai event(s) ignorable in ${result.sessionCompatibility.repairedArtifacts} session artifact(s)\n`);
95
+ }
96
+ if (result.sessionCompatibility?.backupPaths?.length > 0) {
97
+ process.stdout.write(`session compatibility: retained ${result.sessionCompatibility.backupPaths.length} verified backup artifact(s)\n`);
98
+ }
99
+ for (const path of result.sessionCompatibility?.tornArtifacts ?? []) {
100
+ process.stdout.write(`session compatibility warning: preserved DSH-recoverable torn tail: ${path}\n`);
101
+ }
102
+ for (const failure of result.sessionCompatibility?.failures ?? []) {
103
+ process.stdout.write(`session compatibility warning: ${failure.path}: ${failure.error}\n`);
104
+ }
85
105
  if (result.security) process.stdout.write(`security: ${result.security}\n`);
86
106
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "odai-dsh-agent",
3
- "version": "0.0.3",
4
- "description": "完整继承 DSH Standard 全部能力的 Odai Agent preset。",
3
+ "version": "0.0.4",
4
+ "description": "完整继承 DSH 标准模式 全部能力的 Odai Agent preset。主要用于治理、规划、测试、验证 等,可当成默认总控使用。",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -56,7 +56,7 @@
56
56
  }
57
57
  },
58
58
  "engines": {
59
- "node": ">=20"
59
+ "node": ">=22.15.0"
60
60
  },
61
61
  "keywords": [
62
62
  "agent",
@@ -1,3 +1,3 @@
1
- name: Odai
1
+ name: odai 治理模式
2
2
  order: 40
3
- description: 完整继承 DSH Standard 全部能力,并叠加 Odai 治理、证据与自动路由。
3
+ description: 完整继承 DSH 标准模式 全部能力,并叠加 odai 治理、证据与自动路由,以 odai 为总控。
@@ -57,9 +57,9 @@ export function createChildToolGuard(options = {}) {
57
57
  };
58
58
  }
59
59
 
60
- export function activeRouteProtection(agent) {
60
+ export function activeRouteProtection(agent, recordedEvents = agent?.session?.events) {
61
61
  if (isSubagent(agent)) return undefined;
62
- const events = agent?.session?.events;
62
+ const events = recordedEvents;
63
63
  if (!Array.isArray(events)) return undefined;
64
64
 
65
65
  let currentTurn;
@@ -26,6 +26,7 @@ import {
26
26
  resolveRoleRoute,
27
27
  resolveRoutingConfigPath,
28
28
  } from "./routing-config.mjs";
29
+ import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
29
30
 
30
31
  export const name = "odai-dsh-runtime";
31
32
  export const inject = ["systemPrompt", "tools", "subagents"];
@@ -142,20 +143,6 @@ function loggerFor(ctx) {
142
143
  return ctx.logger(name);
143
144
  }
144
145
 
145
- function appendEvent(agent, type, data, logger) {
146
- try {
147
- agent?.session?.append(type, data);
148
- } catch (error) {
149
- logger.warn(`failed to append ${type}: ${error instanceof Error ? error.message : String(error)}`);
150
- }
151
- }
152
-
153
- function hasSessionEvent(agent, type, predicate) {
154
- const events = agent?.session?.events;
155
- if (!Array.isArray(events)) return false;
156
- return events.some((event) => event?.type === type && predicate(event.data));
157
- }
158
-
159
146
  function outputText(blocks) {
160
147
  if (!Array.isArray(blocks)) return "";
161
148
  return blocks
@@ -303,6 +290,18 @@ function routedRoleOf(agent) {
303
290
  export function apply(ctx, rawConfig) {
304
291
  const config = resolveConfig(rawConfig);
305
292
  const logger = loggerFor(ctx);
293
+ const evidence = createSessionEvidence({
294
+ root: resolveSessionEvidenceRoot(config.routing.configPath),
295
+ logger,
296
+ });
297
+ const appendEvent = (agent, type, data) => {
298
+ try {
299
+ evidence.append(agent, type, data);
300
+ } catch (error) {
301
+ logger.warn(`failed to record ${type}: ${error instanceof Error ? error.message : String(error)}`);
302
+ }
303
+ };
304
+ const hasSessionEvent = (agent, type, predicate) => evidence.has(agent, type, predicate);
306
305
  const skillPath = resolveSkillPath(config.skillPath);
307
306
  const skillText = readFileSync(skillPath, "utf8").trim();
308
307
  if (!skillText) throw new Error(`odai canonical skill is empty: ${skillPath}`);
@@ -347,10 +346,14 @@ export function apply(ctx, rawConfig) {
347
346
  onDenied,
348
347
  protectionFor(agent) {
349
348
  if (config.routing.mode === "off") return undefined;
350
- return routeProtections.get(agent) ?? activeRouteProtection(agent);
349
+ return routeProtections.get(agent) ?? activeRouteProtection(agent, evidence.events(agent));
351
350
  },
352
351
  });
353
- ctx.tools.register(createRoutingConfigTool(config.routing.configPath));
352
+ ctx.tools.register(createRoutingConfigTool(config.routing.configPath, {
353
+ onConfigured(agent, data) {
354
+ appendEvent(agent, "odai/routing-configured", data);
355
+ },
356
+ }));
354
357
  ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
355
358
 
356
359
  ctx.on("tools/result", (execution, result) => {
@@ -180,7 +180,8 @@ function resultFor(configPath, action, roles, responsibility, recoveredInvalidSt
180
180
  };
181
181
  }
182
182
 
183
- export function createRoutingConfigTool(configPath) {
183
+ export function createRoutingConfigTool(configPath, options = {}) {
184
+ const onConfigured = typeof options.onConfigured === "function" ? options.onConfigured : () => {};
184
185
  return {
185
186
  name: "odai_routing_config",
186
187
  description: [
@@ -301,7 +302,7 @@ export function createRoutingConfigTool(configPath) {
301
302
  if (args.action === "remove") delete roles[args.responsibility];
302
303
  else roles[args.responsibility] = proposedRoute;
303
304
  writeRoutingStore(configPath, roles);
304
- execution.agent.session?.append?.("odai/routing-configured", {
305
+ onConfigured(execution.agent, {
305
306
  action: args.action,
306
307
  responsibility: args.responsibility,
307
308
  route: roles[args.responsibility],
@@ -0,0 +1,387 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ constants as fsConstants,
6
+ copyFileSync,
7
+ existsSync,
8
+ fsyncSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, resolve, sep } from "node:path";
19
+ import * as zlib from "node:zlib";
20
+
21
+ const ZSTD_MAGIC = 4247762216;
22
+ const ODAI_EVENT_PREFIX = "odai/";
23
+ const LEGACY_ODAI_AUDIT_EVENT_TYPES = new Set([
24
+ "odai/governance-denied",
25
+ "odai/route-config-missing",
26
+ "odai/route-decided",
27
+ "odai/route-protection",
28
+ "odai/route-result",
29
+ "odai/route-upgrade",
30
+ "odai/routing-configured",
31
+ "odai/tool-observed",
32
+ ]);
33
+
34
+ export function resolveDshHome(configured, env = process.env) {
35
+ const value = configured ?? env.DSH_HOME ?? resolve(homedir(), ".dsh");
36
+ if (typeof value !== "string" || value.trim() === "") {
37
+ throw new TypeError("DSH home must be a non-empty path");
38
+ }
39
+ const trimmed = value.trim();
40
+ if (trimmed === "~") return homedir();
41
+ if (trimmed.startsWith(`~${sep}`)) return resolve(homedir(), trimmed.slice(2));
42
+ return resolve(trimmed);
43
+ }
44
+
45
+ function isDshCommand(commandLine) {
46
+ if (typeof commandLine !== "string") return false;
47
+ return /@deepseek-ai[\\/]dsh(?:[\\/]|$)/iu.test(commandLine)
48
+ || /(?:^|[\s"'])[^\s"']*[\\/]dsh(?:\.cmd|\.mjs|\.js)?(?=$|[\s"'])/iu.test(commandLine)
49
+ || /(?:^|[\s"'])dsh(?:\.cmd)?(?=$|[\s"'])/iu.test(commandLine);
50
+ }
51
+
52
+ function listWindowsProcesses() {
53
+ const command = "Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress";
54
+ const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
55
+ encoding: "utf8",
56
+ windowsHide: true,
57
+ }).trim();
58
+ if (output === "") return [];
59
+ const parsed = JSON.parse(output);
60
+ return (Array.isArray(parsed) ? parsed : [parsed]).map((entry) => ({
61
+ pid: Number(entry.ProcessId),
62
+ name: typeof entry.Name === "string" ? entry.Name : "unknown",
63
+ commandLine: entry.CommandLine,
64
+ }));
65
+ }
66
+
67
+ function listPosixProcesses() {
68
+ const output = execFileSync("ps", ["-axo", "pid=,comm=,args="], { encoding: "utf8" });
69
+ return output.split(/\r?\n/u).flatMap((line) => {
70
+ const matched = /^\s*(\d+)\s+(\S+)\s+(.*)$/u.exec(line);
71
+ return matched ? [{ pid: Number(matched[1]), name: matched[2], commandLine: matched[3] }] : [];
72
+ });
73
+ }
74
+
75
+ export function listActiveDshProcesses(platform = process.platform) {
76
+ const processes = platform === "win32" ? listWindowsProcesses() : listPosixProcesses();
77
+ return processes
78
+ .filter((entry) => Number.isSafeInteger(entry.pid) && entry.pid > 0 && entry.pid !== process.pid)
79
+ .filter((entry) => isDshCommand(entry.commandLine))
80
+ .map(({ pid, name }) => Object.freeze({ pid, name }));
81
+ }
82
+
83
+ function assertDshStopped(options) {
84
+ let active;
85
+ try {
86
+ const scanner = options.processScanner ?? listActiveDshProcesses;
87
+ active = scanner();
88
+ } catch {
89
+ throw new Error("cannot verify that DSH is stopped; run the repair from a normal terminal with permission to inspect local process command lines");
90
+ }
91
+ if (!Array.isArray(active)) throw new Error("DSH process scanner returned an invalid result");
92
+ if (active.length > 0) {
93
+ const owners = active.map((entry) => `${entry.name ?? "dsh"} (pid ${entry.pid ?? "unknown"})`).join(", ");
94
+ throw new Error(`refusing to rewrite session artifacts while DSH is running: ${owners}`);
95
+ }
96
+ }
97
+
98
+ function listSessionArtifacts(root) {
99
+ if (!existsSync(root)) return [];
100
+ const found = [];
101
+ const pending = [root];
102
+ while (pending.length > 0) {
103
+ const current = pending.pop();
104
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
105
+ const path = resolve(current, entry.name);
106
+ if (entry.isDirectory()) pending.push(path);
107
+ else if (entry.isFile() && (entry.name === "session.jsonl" || entry.name === "session.jsonl.zstd")) {
108
+ found.push(path);
109
+ }
110
+ }
111
+ }
112
+ return found.sort();
113
+ }
114
+
115
+ function transformLine(line) {
116
+ if (line === "") return { line, repaired: 0 };
117
+ const record = JSON.parse(line);
118
+ if (typeof record?.type !== "string" || !record.type.startsWith(ODAI_EVENT_PREFIX) || record.ignorable === true) {
119
+ return { line, repaired: 0 };
120
+ }
121
+ if (!LEGACY_ODAI_AUDIT_EVENT_TYPES.has(record.type)) {
122
+ throw new Error(`refusing unknown Odai session event type ${JSON.stringify(record.type)}; use a compatible Odai repair version`);
123
+ }
124
+ return { line: JSON.stringify({ ...record, ignorable: true }), repaired: 1 };
125
+ }
126
+
127
+ function transformCompleteJsonl(text) {
128
+ const output = [];
129
+ let repaired = 0;
130
+ let offset = 0;
131
+ for (;;) {
132
+ const newline = text.indexOf("\n", offset);
133
+ if (newline < 0) break;
134
+ const raw = text.slice(offset, newline);
135
+ const carriageReturn = raw.endsWith("\r");
136
+ const line = carriageReturn ? raw.slice(0, -1) : raw;
137
+ const transformed = transformLine(line);
138
+ output.push(transformed.line, carriageReturn ? "\r\n" : "\n");
139
+ repaired += transformed.repaired;
140
+ offset = newline + 1;
141
+ }
142
+ const tornTail = text.slice(offset);
143
+ output.push(tornTail);
144
+ return { text: output.join(""), repaired, torn: tornTail.length > 0 };
145
+ }
146
+
147
+ function scanZstdFrames(buffer) {
148
+ const frames = [];
149
+ let offset = 0;
150
+ while (offset < buffer.length) {
151
+ const start = offset;
152
+ if (buffer.length - offset < 4) return { frames, tornStart: start };
153
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
154
+ throw new Error(`invalid Zstandard frame magic at byte ${offset}`);
155
+ }
156
+ offset += 4;
157
+ if (offset === buffer.length) return { frames, tornStart: start };
158
+ const descriptor = buffer.readUInt8(offset);
159
+ offset += 1;
160
+ if ((descriptor & 24) !== 0) throw new Error(`reserved Zstandard frame-header bit at byte ${offset - 1}`);
161
+ const contentSizeFlag = descriptor >>> 6;
162
+ const singleSegment = (descriptor & 32) !== 0;
163
+ const checksum = (descriptor & 4) !== 0;
164
+ const dictionaryFlag = descriptor & 3;
165
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
166
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
167
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
168
+ if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start };
169
+ offset += remainingHeaderBytes;
170
+ for (;;) {
171
+ if (buffer.length - offset < 3) return { frames, tornStart: start };
172
+ const blockHeader = buffer.readUIntLE(offset, 3);
173
+ offset += 3;
174
+ const lastBlock = (blockHeader & 1) !== 0;
175
+ const blockType = (blockHeader >>> 1) & 3;
176
+ const blockSize = blockHeader >>> 3;
177
+ if (blockType === 3) throw new Error(`reserved Zstandard block type at byte ${offset - 3}`);
178
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
179
+ if (buffer.length - offset < payloadBytes) return { frames, tornStart: start };
180
+ offset += payloadBytes;
181
+ if (lastBlock) break;
182
+ }
183
+ if (checksum) {
184
+ if (buffer.length - offset < 4) return { frames, tornStart: start };
185
+ offset += 4;
186
+ }
187
+ frames.push({ start, end: offset });
188
+ }
189
+ return { frames };
190
+ }
191
+
192
+ function assertZstdAvailable() {
193
+ if (typeof zlib.zstdCompressSync !== "function" || typeof zlib.zstdDecompressSync !== "function") {
194
+ throw new Error("this Node.js build cannot migrate Zstandard session logs");
195
+ }
196
+ }
197
+
198
+ function compressZstdFrame(plaintext) {
199
+ return zlib.zstdCompressSync(plaintext, {
200
+ params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 },
201
+ });
202
+ }
203
+
204
+ function transformZstd(source) {
205
+ assertZstdAvailable();
206
+ const { frames, tornStart } = scanZstdFrames(source);
207
+ if (frames.length === 0) throw new Error("Zstandard session log has no complete header frame");
208
+ const output = [];
209
+ let repaired = 0;
210
+ for (const [index, frame] of frames.entries()) {
211
+ const encoded = source.subarray(frame.start, frame.end);
212
+ if (index === 0) {
213
+ const header = zlib.zstdDecompressSync(encoded).toString("utf8");
214
+ if (!header.endsWith("\n") || JSON.parse(header.trimEnd())?.type !== "session") {
215
+ throw new Error("Zstandard session log has an invalid header frame");
216
+ }
217
+ output.push(encoded);
218
+ continue;
219
+ }
220
+ const plaintext = zlib.zstdDecompressSync(encoded);
221
+ const transformed = transformCompleteJsonl(plaintext.toString("utf8"));
222
+ if (transformed.torn) throw new Error("complete Zstandard event frame has an unterminated JSONL record");
223
+ repaired += transformed.repaired;
224
+ if (transformed.repaired === 0) {
225
+ output.push(encoded);
226
+ continue;
227
+ }
228
+ const replacementPlaintext = Buffer.from(transformed.text, "utf8");
229
+ const replacement = compressZstdFrame(replacementPlaintext);
230
+ if (!zlib.zstdDecompressSync(replacement).equals(replacementPlaintext)) {
231
+ throw new Error("Zstandard migration verification failed");
232
+ }
233
+ output.push(replacement);
234
+ }
235
+ if (tornStart !== undefined) output.push(source.subarray(tornStart));
236
+ return {
237
+ buffer: repaired > 0 ? Buffer.concat(output) : source,
238
+ repaired,
239
+ torn: tornStart !== undefined,
240
+ };
241
+ }
242
+
243
+ function transformArtifact(path) {
244
+ const source = readFileSync(path);
245
+ if (path.endsWith(".zstd")) return { source, ...transformZstd(source) };
246
+ const transformed = transformCompleteJsonl(source.toString("utf8"));
247
+ return {
248
+ source,
249
+ buffer: transformed.repaired > 0 ? Buffer.from(transformed.text, "utf8") : source,
250
+ repaired: transformed.repaired,
251
+ torn: transformed.torn,
252
+ };
253
+ }
254
+
255
+ function writeDurably(path, content, mode) {
256
+ const handle = openSync(path, "wx", mode);
257
+ try {
258
+ let offset = 0;
259
+ while (offset < content.length) {
260
+ const written = writeSync(handle, content, offset, content.length - offset);
261
+ if (written <= 0) throw new Error(`short write while preparing session artifact: ${path}`);
262
+ offset += written;
263
+ }
264
+ fsyncSync(handle);
265
+ } finally {
266
+ closeSync(handle);
267
+ }
268
+ }
269
+
270
+ function syncDirectory(path) {
271
+ let handle;
272
+ try {
273
+ handle = openSync(path, "r");
274
+ fsyncSync(handle);
275
+ } catch {
276
+ // Windows does not expose directory fsync; the file itself was already synced.
277
+ } finally {
278
+ if (handle !== undefined) closeSync(handle);
279
+ }
280
+ }
281
+
282
+ function backupPathFor(path, source) {
283
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 16);
284
+ return `${path}.odai-compat-${digest}.bak`;
285
+ }
286
+
287
+ function createVerifiedBackup(path, source) {
288
+ const backupPath = backupPathFor(path, source);
289
+ if (!existsSync(backupPath)) {
290
+ copyFileSync(path, backupPath, fsConstants.COPYFILE_EXCL);
291
+ const handle = openSync(backupPath, "r+");
292
+ try {
293
+ fsyncSync(handle);
294
+ } finally {
295
+ closeSync(handle);
296
+ }
297
+ syncDirectory(dirname(path));
298
+ }
299
+ if (!readFileSync(backupPath).equals(source)) {
300
+ throw new Error(`session backup does not match the migration source: ${backupPath}`);
301
+ }
302
+ return backupPath;
303
+ }
304
+
305
+ function replaceAtomically(path, source, replacement, backupPath) {
306
+ if (!readFileSync(path).equals(source)) {
307
+ throw new Error("session artifact changed while compatibility migration was preparing it; stop every DSH process and retry");
308
+ }
309
+ const temporary = `${path}.odai-compat-${process.pid}-${randomUUID()}.tmp`;
310
+ const mode = statSync(path).mode & 0o777;
311
+ let published = false;
312
+ try {
313
+ writeDurably(temporary, replacement, mode);
314
+ renameSync(temporary, path);
315
+ published = true;
316
+ syncDirectory(dirname(path));
317
+ if (!readFileSync(path).equals(replacement)) throw new Error("published session artifact failed byte verification");
318
+ const verified = transformArtifact(path);
319
+ if (verified.repaired !== 0) throw new Error("published session artifact still contains required Odai events");
320
+ } catch (error) {
321
+ if (published) {
322
+ const restore = `${path}.odai-restore-${process.pid}-${randomUUID()}.tmp`;
323
+ try {
324
+ writeDurably(restore, readFileSync(backupPath), mode);
325
+ renameSync(restore, path);
326
+ syncDirectory(dirname(path));
327
+ } finally {
328
+ rmSync(restore, { force: true });
329
+ }
330
+ }
331
+ throw error;
332
+ } finally {
333
+ rmSync(temporary, { force: true });
334
+ }
335
+ }
336
+
337
+ function processLegacySessionLogs(options, writeChanges) {
338
+ const dshHome = resolveDshHome(options.dshHome);
339
+ const sessionRoot = resolve(options.sessionRoot ?? resolve(dshHome, "sessions"));
340
+ const artifacts = listSessionArtifacts(sessionRoot);
341
+ const result = {
342
+ dshHome,
343
+ sessionRoot,
344
+ scannedArtifacts: artifacts.length,
345
+ matchedArtifacts: 0,
346
+ matchedEvents: 0,
347
+ repairedArtifacts: 0,
348
+ repairedEvents: 0,
349
+ backupPaths: [],
350
+ tornArtifacts: [],
351
+ failures: [],
352
+ };
353
+
354
+ for (const path of artifacts) {
355
+ try {
356
+ const transformed = transformArtifact(path);
357
+ if (transformed.torn) result.tornArtifacts.push(path);
358
+ if (transformed.repaired === 0) continue;
359
+ result.matchedArtifacts += 1;
360
+ result.matchedEvents += transformed.repaired;
361
+ if (!writeChanges) continue;
362
+ const backupPath = createVerifiedBackup(path, transformed.source);
363
+ replaceAtomically(path, transformed.source, transformed.buffer, backupPath);
364
+ result.backupPaths.push(backupPath);
365
+ result.repairedArtifacts += 1;
366
+ result.repairedEvents += transformed.repaired;
367
+ } catch (error) {
368
+ result.failures.push({
369
+ path,
370
+ error: error instanceof Error ? error.message : String(error),
371
+ });
372
+ }
373
+ }
374
+ return result;
375
+ }
376
+
377
+ export function inspectLegacySessionLogs(options = {}) {
378
+ return processLegacySessionLogs(options, false);
379
+ }
380
+
381
+ export function repairLegacySessionLogs(options = {}) {
382
+ if (options.confirmDshStopped !== true) {
383
+ throw new Error("refusing to rewrite session artifacts until confirmDshStopped is true; stop every DSH process first");
384
+ }
385
+ assertDshStopped(options);
386
+ return processLegacySessionLogs(options, true);
387
+ }
@@ -0,0 +1,344 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ ftruncateSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeSync,
13
+ } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+
16
+ const STORE_SCHEMA_VERSION = 1;
17
+ const GLOBAL_STATE_KEY = Symbol.for("odai.dsh.session-evidence.v2");
18
+
19
+ function sharedState() {
20
+ if (!globalThis[GLOBAL_STATE_KEY]) {
21
+ Object.defineProperty(globalThis, GLOBAL_STATE_KEY, {
22
+ configurable: false,
23
+ enumerable: false,
24
+ writable: false,
25
+ value: { bySession: new WeakMap() },
26
+ });
27
+ }
28
+ return globalThis[GLOBAL_STATE_KEY];
29
+ }
30
+
31
+ function isOdaiEvent(event) {
32
+ return typeof event?.type === "string" && event.type.startsWith("odai/");
33
+ }
34
+
35
+ function sessionIdOf(agent) {
36
+ const id = agent?.session?.header?.id;
37
+ return typeof id === "string" && id !== "" ? id : undefined;
38
+ }
39
+
40
+ function evidencePath(root, sessionId) {
41
+ const key = createHash("sha256").update(sessionId).digest("hex");
42
+ return resolve(root, `${key}.jsonl`);
43
+ }
44
+
45
+ function stableJson(value) {
46
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
47
+ if (value !== null && typeof value === "object") {
48
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
49
+ }
50
+ return JSON.stringify(value);
51
+ }
52
+
53
+ function evidenceId(type, data) {
54
+ let identity;
55
+ if (Number.isSafeInteger(data?.turn) && Number.isSafeInteger(data?.step)) {
56
+ identity = `${type}:${data.turn}:${data.step}`;
57
+ } else if (typeof data?.callId === "string" && data.callId !== "") {
58
+ identity = `${type}:${data.callId}`;
59
+ } else {
60
+ identity = `${type}:${stableJson(data)}`;
61
+ }
62
+ return createHash("sha256").update(identity).digest("hex");
63
+ }
64
+
65
+ function snapshotData(data, type) {
66
+ let encoded;
67
+ try {
68
+ encoded = JSON.stringify(data);
69
+ } catch (error) {
70
+ throw new Error(`odai evidence ${type} is not JSON-serializable`, { cause: error });
71
+ }
72
+ if (encoded === undefined) throw new Error(`odai evidence ${type} is not JSON-serializable`);
73
+ return JSON.parse(encoded);
74
+ }
75
+
76
+ function readCompleteLines(path, strict) {
77
+ if (!existsSync(path)) return [];
78
+ const text = readFileSync(path, "utf8");
79
+ if (strict && text !== "" && !text.endsWith("\n")) {
80
+ throw new Error(`odai evidence log has an unterminated final record: ${path}`);
81
+ }
82
+ const completeLength = text.endsWith("\n") ? text.length : text.lastIndexOf("\n") + 1;
83
+ if (completeLength <= 0) return [];
84
+ return text.slice(0, completeLength).split("\n").filter(Boolean);
85
+ }
86
+
87
+ function readStoredState(root, sessionId, logger, strict = false) {
88
+ const path = evidencePath(root, sessionId);
89
+ const events = [];
90
+ const ids = new Set();
91
+ for (const [index, line] of readCompleteLines(path, strict).entries()) {
92
+ try {
93
+ const record = JSON.parse(line);
94
+ if (record?.schemaVersion !== STORE_SCHEMA_VERSION
95
+ || record.sessionId !== sessionId
96
+ || !isOdaiEvent(record)
97
+ || !Number.isSafeInteger(record.time)
98
+ || record.time < 0
99
+ || record.data === undefined) {
100
+ throw new Error("invalid evidence record");
101
+ }
102
+ const id = typeof record.id === "string" && record.id !== ""
103
+ ? record.id
104
+ : evidenceId(record.type, record.data);
105
+ if (ids.has(id)) continue;
106
+ ids.add(id);
107
+ events.push(Object.freeze({ id, type: record.type, time: record.time, data: record.data }));
108
+ } catch (error) {
109
+ if (strict) throw new Error(`invalid odai evidence at ${path}:${index + 1}`, { cause: error });
110
+ logger.warn(`ignored invalid odai evidence at ${path}:${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
111
+ }
112
+ }
113
+ return { events, ids };
114
+ }
115
+
116
+ function mergeUnique(target, events) {
117
+ for (const event of events) {
118
+ const id = event.id ?? evidenceId(event.type, event.data);
119
+ if (target.ids.has(id)) continue;
120
+ target.ids.add(id);
121
+ target.events.push(Object.freeze({ id, type: event.type, time: event.time, data: event.data }));
122
+ }
123
+ }
124
+
125
+ function stateFor(agent, root, logger) {
126
+ const session = agent?.session;
127
+ const owner = session && typeof session === "object"
128
+ ? session
129
+ : agent && typeof agent === "object"
130
+ ? agent
131
+ : undefined;
132
+ if (!owner) return { events: [], ids: new Set(), sessionId: undefined };
133
+
134
+ const shared = sharedState();
135
+ let roots = shared.bySession.get(owner);
136
+ if (!roots) {
137
+ roots = new Map();
138
+ shared.bySession.set(owner, roots);
139
+ }
140
+ const existing = roots.get(root);
141
+ if (existing) return existing;
142
+
143
+ const sessionId = sessionIdOf(agent);
144
+ const state = { events: [], ids: new Set(), sessionId };
145
+ const legacy = Array.isArray(session?.events) ? session.events.filter(isOdaiEvent) : [];
146
+ mergeUnique(state, legacy);
147
+ if (sessionId) mergeUnique(state, readStoredState(root, sessionId, logger).events);
148
+ roots.set(root, state);
149
+ return state;
150
+ }
151
+
152
+ function readLockOwner(lockPath) {
153
+ try {
154
+ const value = readFileSync(lockPath, "utf8").trim();
155
+ const separator = value.indexOf(":");
156
+ const pid = Number(separator < 0 ? "" : value.slice(0, separator));
157
+ return Number.isSafeInteger(pid) && pid > 0 && separator < value.length - 1
158
+ ? { value, pid }
159
+ : undefined;
160
+ } catch (error) {
161
+ if (error?.code === "ENOENT") return undefined;
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ function processIsAlive(pid) {
167
+ try {
168
+ process.kill(pid, 0);
169
+ return true;
170
+ } catch (error) {
171
+ return error?.code !== "ESRCH";
172
+ }
173
+ }
174
+
175
+ function createLock(lockPath, owner) {
176
+ const handle = openSync(lockPath, "wx", 0o600);
177
+ try {
178
+ const content = Buffer.from(`${owner}\n`, "utf8");
179
+ if (writeSync(handle, content, 0, content.length) !== content.length) {
180
+ throw new Error(`short write while acquiring odai evidence lock: ${lockPath}`);
181
+ }
182
+ fsyncSync(handle);
183
+ return handle;
184
+ } catch (error) {
185
+ closeSync(handle);
186
+ rmSync(lockPath, { force: true });
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ function releaseLock(handle, lockPath, owner) {
192
+ closeSync(handle);
193
+ const current = readLockOwner(lockPath);
194
+ if (current === undefined) throw new Error(`odai evidence lock disappeared before release: ${lockPath}`);
195
+ if (current.value !== owner) throw new Error(`odai evidence lock ownership changed before release: ${lockPath}`);
196
+ rmSync(lockPath);
197
+ }
198
+
199
+ function acquireLock(path) {
200
+ const lockPath = `${path}.lock`;
201
+ const claimPath = `${lockPath}.claim`;
202
+ const owner = `${process.pid}:${randomUUID()}`;
203
+ const claimOwner = `${process.pid}:${randomUUID()}`;
204
+ let claimHandle;
205
+ try {
206
+ claimHandle = createLock(claimPath, claimOwner);
207
+ } catch (error) {
208
+ if (error?.code === "EEXIST") {
209
+ throw new Error(`odai evidence lock acquisition is already in progress: ${path}`);
210
+ }
211
+ throw error;
212
+ }
213
+
214
+ let handle;
215
+ try {
216
+ try {
217
+ handle = createLock(lockPath, owner);
218
+ } catch (error) {
219
+ if (error?.code !== "EEXIST") throw error;
220
+ const current = readLockOwner(lockPath);
221
+ if (current === undefined || processIsAlive(current.pid)) {
222
+ throw new Error(`odai evidence is being updated by another runtime: ${path}`);
223
+ }
224
+ rmSync(lockPath);
225
+ handle = createLock(lockPath, owner);
226
+ }
227
+ } finally {
228
+ try {
229
+ releaseLock(claimHandle, claimPath, claimOwner);
230
+ } catch (error) {
231
+ if (handle !== undefined) {
232
+ try {
233
+ releaseLock(handle, lockPath, owner);
234
+ } catch {
235
+ // Preserve the claim failure; lock cleanup already failed closed.
236
+ }
237
+ handle = undefined;
238
+ }
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ if (handle === undefined) throw new Error(`could not lock odai evidence: ${path}`);
244
+ return () => releaseLock(handle, lockPath, owner);
245
+ }
246
+
247
+ function syncDirectory(path) {
248
+ let handle;
249
+ try {
250
+ handle = openSync(path, "r");
251
+ fsyncSync(handle);
252
+ } catch {
253
+ // Windows does not expose directory fsync; the evidence file itself was synced.
254
+ } finally {
255
+ if (handle !== undefined) closeSync(handle);
256
+ }
257
+ }
258
+
259
+ function persistRecord(root, sessionId, record, logger) {
260
+ mkdirSync(root, { recursive: true, mode: 0o700 });
261
+ const path = evidencePath(root, sessionId);
262
+ const release = acquireLock(path);
263
+ try {
264
+ const stored = readStoredState(root, sessionId, logger, true);
265
+ if (stored.ids.has(record.id)) return false;
266
+ const encoded = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
267
+ const originalSize = existsSync(path) ? statSync(path).size : 0;
268
+ const handle = openSync(path, "a", 0o600);
269
+ try {
270
+ let offset = 0;
271
+ while (offset < encoded.length) {
272
+ const written = writeSync(handle, encoded, offset, encoded.length - offset);
273
+ if (written <= 0) throw new Error(`short write while persisting odai evidence: ${path}`);
274
+ offset += written;
275
+ }
276
+ fsyncSync(handle);
277
+ } catch (error) {
278
+ ftruncateSync(handle, originalSize);
279
+ fsyncSync(handle);
280
+ throw error;
281
+ } finally {
282
+ closeSync(handle);
283
+ }
284
+ syncDirectory(root);
285
+ return true;
286
+ } finally {
287
+ release();
288
+ }
289
+ }
290
+
291
+ export function resolveSessionEvidenceRoot(routingConfigPath) {
292
+ return resolve(dirname(routingConfigPath), "session-evidence");
293
+ }
294
+
295
+ export function readStoredSessionEvidence(root, sessionId, logger = { warn() {} }) {
296
+ if (typeof sessionId !== "string" || sessionId === "") return [];
297
+ return readStoredState(resolve(root), sessionId, logger).events;
298
+ }
299
+
300
+ export function createSessionEvidence(options) {
301
+ const root = resolve(options.root);
302
+ const logger = options.logger ?? { warn() {} };
303
+
304
+ const events = (agent) => stateFor(agent, root, logger).events.slice();
305
+ const has = (agent, type, predicate = () => true) => stateFor(agent, root, logger)
306
+ .events
307
+ .some((event) => event.type === type && predicate(event.data));
308
+ const append = (agent, type, data) => {
309
+ if (typeof type !== "string" || !type.startsWith("odai/")) {
310
+ throw new TypeError("odai evidence type must start with odai/");
311
+ }
312
+ const state = stateFor(agent, root, logger);
313
+ const snapshot = snapshotData(data, type);
314
+ const id = evidenceId(type, snapshot);
315
+ const event = Object.freeze({ id, type, time: Date.now(), data: snapshot });
316
+ if (state.ids.has(id)) return state.events.find((candidate) => candidate.id === id);
317
+
318
+ if (!state.sessionId) {
319
+ state.ids.add(id);
320
+ state.events.push(event);
321
+ try {
322
+ agent?.session?.append?.(type, event.data);
323
+ } catch (error) {
324
+ logger.warn(`failed to append transient ${type}: ${error instanceof Error ? error.message : String(error)}`);
325
+ }
326
+ return event;
327
+ }
328
+
329
+ const record = {
330
+ schemaVersion: STORE_SCHEMA_VERSION,
331
+ id,
332
+ sessionId: state.sessionId,
333
+ type,
334
+ time: event.time,
335
+ data: event.data,
336
+ };
337
+ persistRecord(root, state.sessionId, record, logger);
338
+ state.ids.add(id);
339
+ state.events.push(event);
340
+ return event;
341
+ };
342
+
343
+ return Object.freeze({ append, events, has, root });
344
+ }
@@ -30,16 +30,29 @@ const yaml = (value) => JSON.stringify(value);
30
30
 
31
31
  function findDshPackageRoot(command) {
32
32
  const locator = process.platform === "win32" ? "where" : "which";
33
- const located = execFileSync(locator, [command], { encoding: "utf8" }).trim().split(/\r?\n/u)[0];
34
- let current = dirname(realpathSync(located));
35
- while (dirname(current) !== current) {
36
- try {
37
- const metadata = JSON.parse(readFileSync(resolve(current, "package.json"), "utf8"));
38
- if (metadata.name === "@deepseek-ai/dsh") return current;
39
- } catch (error) {
40
- if (error?.code !== "ENOENT") throw error;
33
+ const located = execFileSync(locator, [command], { encoding: "utf8" })
34
+ .trim()
35
+ .split(/\r?\n/u)
36
+ .filter(Boolean);
37
+ const candidates = new Set();
38
+ for (const path of located) {
39
+ const commandDir = dirname(realpathSync(path));
40
+ candidates.add(commandDir);
41
+ candidates.add(resolve(commandDir, "node_modules/@deepseek-ai/dsh"));
42
+ }
43
+ for (const candidate of candidates) {
44
+ let current = candidate;
45
+ for (;;) {
46
+ try {
47
+ const metadata = JSON.parse(readFileSync(resolve(current, "package.json"), "utf8"));
48
+ if (metadata.name === "@deepseek-ai/dsh") return current;
49
+ } catch (error) {
50
+ if (error?.code !== "ENOENT") throw error;
51
+ }
52
+ const parent = dirname(current);
53
+ if (parent === current) break;
54
+ current = parent;
41
55
  }
42
- current = dirname(current);
43
56
  }
44
57
  throw new Error(`cannot locate the @deepseek-ai/dsh package behind ${command}`);
45
58
  }
@@ -50,7 +63,8 @@ async function verifyPinnedComposition() {
50
63
  throw new Error(`agent preset expects DSH ${SUPPORTED_DSH_VERSION}, found ${dshMetadata.version}`);
51
64
  }
52
65
 
53
- const standard = await readFile(resolve(dshRoot, "config/agent-presets/standard/agent.cordis.yml"), "utf8");
66
+ const standard = (await readFile(resolve(dshRoot, "config/agent-presets/standard/agent.cordis.yml"), "utf8"))
67
+ .replace(/\r\n/gu, "\n");
54
68
  const odaiSuffix = [
55
69
  "# Odai contributes scoped prompt, guard, routing, user-owned responsibility",
56
70
  "# mappings, and evidence listeners. Base controller selection stays host-owned.",
@@ -71,7 +85,9 @@ async function verifyPinnedComposition() {
71
85
  "You are Odai, a coding agent. Your working directory is {{cwd}}.",
72
86
  )
73
87
  .trimEnd()}\n\n${odaiSuffix}`;
74
- const actual = (await readFile(resolve(agentRoot, "preset/odai/agent.cordis.yml"), "utf8")).trimEnd();
88
+ const actual = (await readFile(resolve(agentRoot, "preset/odai/agent.cordis.yml"), "utf8"))
89
+ .replace(/\r\n/gu, "\n")
90
+ .trimEnd();
75
91
  if (actual !== expected) {
76
92
  throw new Error("Odai Agent composition drifted from the pinned DSH standard preset");
77
93
  }
@@ -127,6 +143,23 @@ async function waitForMarker(child, output) {
127
143
  throw new Error(`timed out waiting for scope marker\n${output()}`);
128
144
  }
129
145
 
146
+ async function terminateChild(child) {
147
+ if (process.platform === "win32" && child.pid) {
148
+ try {
149
+ execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
150
+ } catch {
151
+ if (child.exitCode === null) child.kill("SIGTERM");
152
+ }
153
+ } else if (child.exitCode === null) {
154
+ child.kill("SIGTERM");
155
+ }
156
+ await new Promise((accept) => {
157
+ if (child.exitCode !== null) return accept();
158
+ const timeout = setTimeout(() => { child.kill("SIGKILL"); accept(); }, 3_000);
159
+ child.once("exit", () => { clearTimeout(timeout); accept(); });
160
+ });
161
+ }
162
+
130
163
  await mkdir(workspace, { recursive: true });
131
164
  await cp(resolve(agentRoot, "preset/odai"), sourceRoot, { recursive: true });
132
165
  const developmentRuntime = resolve(repoRoot, "dsh/runtime/src");
@@ -147,7 +180,7 @@ await writeFile(probePluginPath, probePlugin, "utf8");
147
180
  await writeFile(patchPath, [
148
181
  "- insert:",
149
182
  " - id: odai-agent-scope-probe",
150
- ` name: ${yaml(probePluginPath)}`,
183
+ ` name: ${yaml(pathToFileURL(probePluginPath).href)}`,
151
184
  " config:",
152
185
  ` markerPath: ${yaml(markerPath)}`,
153
186
  ` standardWritePath: ${yaml(resolve(workspace, "standard-child-write.txt"))}`,
@@ -206,13 +239,8 @@ try {
206
239
  }
207
240
  process.stdout.write(`${JSON.stringify({ scratch, roster: ids, results }, null, 2)}\n`);
208
241
  } finally {
209
- child.kill("SIGTERM");
210
- await new Promise((accept) => {
211
- if (child.exitCode !== null) return accept();
212
- const timeout = setTimeout(() => { child.kill("SIGKILL"); accept(); }, 3_000);
213
- child.once("exit", () => { clearTimeout(timeout); accept(); });
214
- });
242
+ await terminateChild(child);
215
243
  if (process.env.KEEP_ODAI_SCOPE_PROBE !== "1") {
216
- await rm(scratch, { recursive: true, force: true });
244
+ await rm(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
217
245
  }
218
246
  }
package/src/installer.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, relative, resolve, sep } from "node:path";
15
- import { fileURLToPath } from "node:url";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
 
17
17
  import { parse as parseYaml } from "yaml";
18
18
 
@@ -27,6 +27,8 @@ const requiredFiles = Object.freeze([
27
27
  "agent.cordis.yml",
28
28
  "preset.yml",
29
29
  "runtime/index.mjs",
30
+ "runtime/session-compat.mjs",
31
+ "runtime/session-evidence.mjs",
30
32
  "skills/odai/SKILL.md",
31
33
  ]);
32
34
 
@@ -65,6 +67,12 @@ export async function installAgentPreset(options = {}) {
65
67
  }
66
68
 
67
69
  await assertSource(sourceRoot);
70
+ const sessionCompatibility = await repairSessionCompatibility(
71
+ dshHome,
72
+ [sourceRoot],
73
+ options.confirmDshStopped === true,
74
+ options.processScanner,
75
+ );
68
76
  await mkdir(targetRoot, { recursive: true, mode: 0o700 });
69
77
  const staging = resolve(targetRoot, `.${presetId}.tmp-${process.pid}-${randomUUID()}`);
70
78
  const backup = resolve(targetRoot, `.${presetId}.backup-${process.pid}-${randomUUID()}`);
@@ -111,6 +119,7 @@ export async function installAgentPreset(options = {}) {
111
119
  version: packageMetadata.version,
112
120
  trust: "user",
113
121
  security: "DSH user presets have the same privileges as shell access; install only reviewed preset code.",
122
+ sessionCompatibility,
114
123
  });
115
124
  } catch (error) {
116
125
  await rm(staging, { recursive: true, force: true });
@@ -133,8 +142,22 @@ export async function uninstallAgentPreset(options = {}) {
133
142
  throw new Error(`refusing to remove modified preset at ${inspected.target}: ${inspected.issues.join("; ")}`);
134
143
  }
135
144
  await assertPresetIsNotDefault(inspected.dshHome, inspected.presetId);
145
+ const sessionCompatibility = await repairSessionCompatibility(
146
+ inspected.dshHome,
147
+ [defaultSourceRoot, inspected.target],
148
+ options.confirmDshStopped === true,
149
+ options.processScanner,
150
+ );
151
+ if (sessionCompatibility.failures.length > 0) {
152
+ throw new Error(`refusing to uninstall before legacy Odai session evidence is made ignorable: ${sessionCompatibility.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ")}`);
153
+ }
136
154
  await rm(inspected.target, { recursive: true, force: true });
137
- return Object.freeze({ operation: "uninstalled", target: inspected.target, presetId: inspected.presetId });
155
+ return Object.freeze({
156
+ operation: "uninstalled",
157
+ target: inspected.target,
158
+ presetId: inspected.presetId,
159
+ sessionCompatibility,
160
+ });
138
161
  }
139
162
 
140
163
  async function assertPresetIsNotDefault(dshHome, presetId) {
@@ -206,6 +229,36 @@ function validateManifest(manifest, presetId) {
206
229
  return issues;
207
230
  }
208
231
 
232
+ async function repairSessionCompatibility(dshHome, sourceRoots, confirmDshStopped, processScanner) {
233
+ for (const sourceRoot of sourceRoots) {
234
+ const modulePath = resolve(sourceRoot, "runtime/session-compat.mjs");
235
+ if (await pathState(modulePath) !== "file") continue;
236
+ const compatibility = await import(pathToFileURL(modulePath).href);
237
+ if (typeof compatibility.inspectLegacySessionLogs !== "function"
238
+ || typeof compatibility.repairLegacySessionLogs !== "function") {
239
+ throw new Error(`agent package session compatibility module is invalid: ${modulePath}`);
240
+ }
241
+ const inspected = compatibility.inspectLegacySessionLogs({ dshHome });
242
+ if (inspected.failures.length > 0) {
243
+ throw new Error(`cannot inspect legacy Odai session evidence safely: ${inspected.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ")}`);
244
+ }
245
+ if (inspected.matchedEvents === 0) return inspected;
246
+ if (!confirmDshStopped) {
247
+ throw new Error(`found ${inspected.matchedEvents} legacy Odai session event(s); stop every DSH process and rerun with --yes before changing the Agent preset`);
248
+ }
249
+ const repaired = compatibility.repairLegacySessionLogs({
250
+ dshHome,
251
+ confirmDshStopped: true,
252
+ processScanner,
253
+ });
254
+ if (repaired.failures.length > 0) {
255
+ throw new Error(`cannot repair legacy Odai session evidence safely: ${repaired.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ")}`);
256
+ }
257
+ return repaired;
258
+ }
259
+ throw new Error("agent package is incomplete: missing runtime/session-compat.mjs");
260
+ }
261
+
209
262
  async function assertSource(sourceRoot) {
210
263
  for (const path of requiredFiles) {
211
264
  const state = await pathState(resolve(sourceRoot, path));