jev-agent-tools 0.1.4 → 0.2.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 (131) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +42 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +39 -12
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host.ts +11 -0
  117. package/src/jev/client.ts +12 -0
  118. package/src/jev/types.ts +6 -0
  119. package/src/mcp/main.ts +135 -0
  120. package/src/mcp/protocol.ts +282 -0
  121. package/src/mcp/tools.ts +166 -0
  122. package/src/session.ts +59 -0
  123. package/src/setup.ts +13 -5
  124. package/src/tools/ask-files.ts +5 -7
  125. package/src/tools/ask.ts +26 -22
  126. package/src/tools/check-diff.ts +8 -5
  127. package/src/tools/docs-check.ts +1 -0
  128. package/src/tools/find.ts +5 -2
  129. package/src/tools/locate.ts +5 -8
  130. package/src/tools/select-tests.ts +7 -4
  131. package/src/tools/spec-check.ts +1 -0
@@ -0,0 +1,143 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { Stats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { PRIVATE_STORAGE_TIMEOUT_MS } from "../constants.ts";
5
+
6
+ /**
7
+ * Owner-only storage, checked with each operating system's own model.
8
+ *
9
+ * POSIX: no group/other mode bits and owned by the current user.
10
+ * Windows: mode bits are synthesized (always 0o666), so the real ACL is read.
11
+ * Storage is private when every allow entry belongs to the current user,
12
+ * SYSTEM or the Administrators group (the Windows equivalent of root), and
13
+ * the owner is the current user or Administrators. Entries are compared as
14
+ * SIDs, so localized account names do not matter.
15
+ */
16
+ export interface PrivateStorage {
17
+ /** True when every path is private. One check for all paths. */
18
+ isPrivate(
19
+ entries: readonly { path: string; stat: Stats }[],
20
+ ): Promise<boolean>;
21
+ /** Restrict a directory the current user owns so new files inherit privacy. */
22
+ restrictDirectory(path: string): Promise<void>;
23
+ }
24
+
25
+ const SYSTEM = "S-1-5-18";
26
+ const ADMINISTRATORS = "S-1-5-32-544";
27
+ const CREATOR_OWNER = "S-1-3-0";
28
+
29
+ export const posixStorage: PrivateStorage = {
30
+ async isPrivate(entries) {
31
+ return entries.every(
32
+ ({ stat }) =>
33
+ (stat.mode & 0o077) === 0 &&
34
+ (!process.getuid || stat.uid === process.getuid()),
35
+ );
36
+ },
37
+ async restrictDirectory() {
38
+ // mkdir(..., { mode: 0o700 }) already creates POSIX directories privately.
39
+ },
40
+ };
41
+
42
+ // Paths arrive as JSON in an environment variable, never in the command text.
43
+ const CHECK = `$ErrorActionPreference = 'Stop'
44
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
45
+ $trusted = @($me, '${SYSTEM}', '${ADMINISTRATORS}')
46
+ $sid = [System.Security.Principal.SecurityIdentifier]
47
+ $results = foreach ($path in (ConvertFrom-Json $env:JEV_PRIVATE_PATHS)) {
48
+ try {
49
+ $acl = Get-Acl -LiteralPath $path
50
+ $owner = $acl.GetOwner($sid).Value
51
+ $private = ($owner -eq $me) -or ($owner -eq '${ADMINISTRATORS}')
52
+ foreach ($rule in $acl.GetAccessRules($true, $true, $sid)) {
53
+ if ($rule.AccessControlType -ne 'Allow') { continue }
54
+ $id = $rule.IdentityReference.Value
55
+ $inheritOnly = ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0
56
+ if ($id -eq '${CREATOR_OWNER}' -and $inheritOnly) { continue }
57
+ if ($trusted -notcontains $id) { $private = $false }
58
+ }
59
+ $private
60
+ } catch { $false }
61
+ }
62
+ ConvertTo-Json -Compress @($results)`;
63
+
64
+ const RESTRICT = `$ErrorActionPreference = 'Stop'
65
+ $path = $env:JEV_PRIVATE_PATH
66
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
67
+ $acl = Get-Acl -LiteralPath $path
68
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'not owner' }
69
+ $acl.SetAccessRuleProtection($true, $false)
70
+ foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleAll($rule) }
71
+ $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
72
+ foreach ($id in @($me.Value, '${SYSTEM}', '${ADMINISTRATORS}')) {
73
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
74
+ (New-Object System.Security.Principal.SecurityIdentifier($id)),
75
+ 'FullControl', $inherit, 'None', 'Allow')
76
+ $acl.AddAccessRule($rule)
77
+ }
78
+ Set-Acl -LiteralPath $path -AclObject $acl`;
79
+
80
+ function powershell(
81
+ script: string,
82
+ env: Record<string, string>,
83
+ ): Promise<string> {
84
+ const root = process.env.SystemRoot ?? "C:\\Windows";
85
+ // A fixed system path, so a powershell.exe earlier on PATH is never used.
86
+ const executable = join(
87
+ root,
88
+ "System32",
89
+ "WindowsPowerShell",
90
+ "v1.0",
91
+ "powershell.exe",
92
+ );
93
+ // PowerShell 7 parents export PSModulePath, which stops Windows PowerShell
94
+ // 5.1 from loading its own Get-Acl module; 5.1 rebuilds it when unset.
95
+ const childEnv: NodeJS.ProcessEnv = { ...process.env, ...env };
96
+ for (const key of Object.keys(childEnv))
97
+ if (key.toLowerCase() === "psmodulepath") delete childEnv[key];
98
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
99
+ execFile(
100
+ executable,
101
+ [
102
+ "-NoProfile",
103
+ "-NonInteractive",
104
+ "-ExecutionPolicy",
105
+ "Bypass",
106
+ // UTF-16LE base64 runs the script as one unit; stdin runs line by line.
107
+ "-EncodedCommand",
108
+ Buffer.from(script, "utf16le").toString("base64"),
109
+ ],
110
+ { env: childEnv, windowsHide: true, timeout: PRIVATE_STORAGE_TIMEOUT_MS },
111
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
112
+ );
113
+ return promise;
114
+ }
115
+
116
+ export const windowsStorage: PrivateStorage = {
117
+ async isPrivate(entries) {
118
+ if (!entries.length) return true;
119
+ try {
120
+ const output = await powershell(CHECK, {
121
+ JEV_PRIVATE_PATHS: JSON.stringify(entries.map(({ path }) => path)),
122
+ });
123
+ const results: unknown = JSON.parse(output.trim());
124
+ return (
125
+ Array.isArray(results) &&
126
+ results.length === entries.length &&
127
+ results.every((result) => result === true)
128
+ );
129
+ } catch {
130
+ // An ACL that cannot be read is never treated as private.
131
+ return false;
132
+ }
133
+ },
134
+ async restrictDirectory(path) {
135
+ await powershell(RESTRICT, { JEV_PRIVATE_PATH: path });
136
+ },
137
+ };
138
+
139
+ export function privateStorage(
140
+ platform: NodeJS.Platform = process.platform,
141
+ ): PrivateStorage {
142
+ return platform === "win32" ? windowsStorage : posixStorage;
143
+ }
@@ -1,5 +1,5 @@
1
1
  import { type FileHandle, lstat } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { posix } from "node:path";
3
3
  import type { SgNode } from "@ast-grep/napi";
4
4
  import { CONCURRENCY, STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
5
5
  import type { GitExec } from "../core/git.ts";
@@ -460,8 +460,10 @@ export async function collectRiskCallers(
460
460
  typeof source.beforePath === "string"
461
461
  ? source.beforePath
462
462
  : source.path;
463
+ // Repository paths are always "/"-separated; the platform
464
+ // resolve would yield "C:\\src\\..." on Windows.
463
465
  const basePath = specifier.startsWith(".")
464
- ? resolve("/", sourcePath, "..", specifier).slice(1)
466
+ ? posix.resolve("/", sourcePath, "..", specifier).slice(1)
465
467
  : specifier.replaceAll(".", "/");
466
468
  const runtimeSource = basePath.replace(
467
469
  /\.(js|jsx|mjs|cjs)$/,
@@ -0,0 +1,97 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ // Candidates are Windows paths whatever the host OS, so the helpers use win32
3
+ // semantics explicitly; the host-native `node:path` splits PATH on ":" on POSIX.
4
+ import { win32 } from "node:path";
5
+
6
+ export interface ShellInvocation {
7
+ executable: string;
8
+ /** Arguments placed before the `-c` script. */
9
+ prefix: string[];
10
+ /** Exports prepended to the script when the launcher cannot set them. */
11
+ scriptPrefix: string;
12
+ }
13
+
14
+ export type ShellResolution =
15
+ | ({ ok: true } & ShellInvocation)
16
+ | { ok: false; error: string };
17
+
18
+ function executable(path: string): boolean {
19
+ try {
20
+ accessSync(path, constants.X_OK);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Windows ships `bash.exe` launchers for WSL in System32 (and SysWOW64,
29
+ * Sysnative) and in WindowsApps. They run in a Linux VM that cannot see Windows
30
+ * temporary paths, so they are never used for command evidence, including
31
+ * when named explicitly in JEV_TOOLS_BASH.
32
+ */
33
+ export function isWslLauncher(path: string): boolean {
34
+ const lower = win32.normalize(path).toLowerCase();
35
+ return (
36
+ /\\windows\\(system32|syswow64|sysnative)\\/.test(lower) ||
37
+ lower.includes("\\microsoft\\windowsapps\\")
38
+ );
39
+ }
40
+
41
+ /** Permitted Git for Windows bash candidates, most specific first. */
42
+ export function windowsBashCandidates(env: NodeJS.ProcessEnv): string[] {
43
+ const candidates: string[] = [];
44
+ if (env.JEV_TOOLS_BASH?.trim()) candidates.push(env.JEV_TOOLS_BASH.trim());
45
+ const pathEntries = (env.PATH ?? env.Path ?? "")
46
+ .split(win32.delimiter)
47
+ .filter(Boolean);
48
+ for (const entry of pathEntries) {
49
+ candidates.push(win32.join(entry, "bash.exe"));
50
+ // Git\cmd\git.exe or Git\bin\git.exe on PATH implies Git\bin\bash.exe.
51
+ if (/[\\/](cmd|bin)$/i.test(entry))
52
+ candidates.push(win32.join(win32.dirname(entry), "bin", "bash.exe"));
53
+ }
54
+ for (const root of [
55
+ env.ProgramFiles,
56
+ env["ProgramFiles(x86)"],
57
+ env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs"),
58
+ ])
59
+ if (root) candidates.push(win32.join(root, "Git", "bin", "bash.exe"));
60
+ // Filter every source, so no candidate can reintroduce a WSL launcher.
61
+ return [...new Set(candidates)].filter(
62
+ (candidate) => win32.isAbsolute(candidate) && !isWslLauncher(candidate),
63
+ );
64
+ }
65
+
66
+ const UNAVAILABLE =
67
+ "No permitted bash found: install Git for Windows or set JEV_TOOLS_BASH to the full path of a bash that is not the WSL launcher.";
68
+ let cached: ShellResolution | undefined;
69
+
70
+ /**
71
+ * POSIX hosts keep `env CI=1 bash -c`. Windows has no `env` and its default
72
+ * `bash` is the WSL launcher, so resolve Git for Windows bash (or
73
+ * JEV_TOOLS_BASH) to an absolute, permitted path and export CI inside the
74
+ * script. When none is found this fails closed: no bare name is returned,
75
+ * because spawning one would search PATH again without the WSL exclusion.
76
+ */
77
+ export function resolveShell(
78
+ platform: NodeJS.Platform = process.platform,
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ isExecutable: (path: string) => boolean = executable,
81
+ ): ShellResolution {
82
+ if (platform !== "win32")
83
+ return {
84
+ ok: true,
85
+ executable: "env",
86
+ prefix: ["CI=1", "bash"],
87
+ scriptPrefix: "",
88
+ };
89
+ const useCache = isExecutable === executable && env === process.env;
90
+ if (useCache && cached) return cached;
91
+ const found = windowsBashCandidates(env).find((path) => isExecutable(path));
92
+ const resolved: ShellResolution = found
93
+ ? { ok: true, executable: found, prefix: [], scriptPrefix: "export CI=1; " }
94
+ : { ok: false, error: UNAVAILABLE };
95
+ if (useCache && found) cached = resolved;
96
+ return resolved;
97
+ }
@@ -3,6 +3,10 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import {
7
+ type PrivateStorage,
8
+ privateStorage,
9
+ } from "./adapters/private-storage.ts";
6
10
  import { createJevClient } from "./jev/client.ts";
7
11
  import type { JevClient } from "./jev/types.ts";
8
12
 
@@ -56,11 +60,19 @@ export class ConfigController {
56
60
  private saved: SavedConfiguration = {};
57
61
  private session: SavedConfiguration = {};
58
62
  private readonly directory: string;
63
+ private readonly storage: PrivateStorage;
59
64
  private initialization: Promise<void> | undefined;
60
65
  private currentClient: JevClient | undefined;
61
66
 
62
- constructor(options: { env?: NodeJS.ProcessEnv; directory?: string } = {}) {
67
+ constructor(
68
+ options: {
69
+ env?: NodeJS.ProcessEnv;
70
+ directory?: string;
71
+ storage?: PrivateStorage;
72
+ } = {},
73
+ ) {
63
74
  const env = options.env ?? process.env;
75
+ this.storage = options.storage ?? privateStorage();
64
76
  for (const [field, name] of [
65
77
  ["url", "JEV_TOOLS_URL"],
66
78
  ["apiKey", "JEV_TOOLS_API_KEY"],
@@ -163,19 +175,14 @@ export class ConfigController {
163
175
 
164
176
  private async checkDirectory(): Promise<boolean> {
165
177
  // Check ancestors too: recursive mkdir and path-based reads must not follow symlinks.
178
+ // Privacy of the directory itself is checked with its file in readSaved,
179
+ // using the operating system's own permission model.
166
180
  let path = this.directory;
167
181
  let exists = true;
168
182
  while (true) {
169
183
  try {
170
184
  const stat = await lstat(path);
171
185
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw storageError();
172
- if (
173
- path === this.directory &&
174
- ((stat.mode & 0o077) !== 0 ||
175
- (process.getuid && stat.uid !== process.getuid()))
176
- ) {
177
- throw storageError();
178
- }
179
186
  } catch (error) {
180
187
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT")
181
188
  throw storageError();
@@ -191,12 +198,20 @@ export class ConfigController {
191
198
  private async readSaved(): Promise<SavedConfiguration> {
192
199
  try {
193
200
  if (!(await this.checkDirectory())) return {};
201
+ const directory = {
202
+ path: this.directory,
203
+ stat: await lstat(this.directory),
204
+ };
194
205
  const path = join(this.directory, "config.json");
195
206
  try {
196
207
  const stat = await lstat(path);
197
208
  if (!stat.isFile() || stat.isSymbolicLink()) throw storageError();
198
209
  } catch (error) {
199
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return {};
210
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
211
+ if (!(await this.storage.isPrivate([directory])))
212
+ throw storageError();
213
+ return {};
214
+ }
200
215
  throw error;
201
216
  }
202
217
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -205,8 +220,7 @@ export class ConfigController {
205
220
  if (
206
221
  !stat.isFile() ||
207
222
  stat.nlink !== 1 ||
208
- (stat.mode & 0o077) !== 0 ||
209
- (process.getuid && stat.uid !== process.getuid())
223
+ !(await this.storage.isPrivate([directory, { path, stat }]))
210
224
  ) {
211
225
  throw storageError();
212
226
  }
@@ -235,9 +249,22 @@ export class ConfigController {
235
249
  private async save(values: SavedConfiguration): Promise<void> {
236
250
  let temporary: string | undefined;
237
251
  try {
238
- if (!(await this.checkDirectory())) {
252
+ const existed = await this.checkDirectory();
253
+ if (!existed) {
239
254
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
240
255
  }
256
+ // Windows ignores mkdir's mode, so restrict the ACL before any secret is
257
+ // written: always for a new directory, and for an existing one only while
258
+ // it holds no configuration (e.g. left behind by an earlier failed save).
259
+ // Existing configuration is never re-permissioned; readSaved refuses it.
260
+ if (
261
+ !existed ||
262
+ !(await lstat(join(this.directory, "config.json")).then(
263
+ () => true,
264
+ () => false,
265
+ ))
266
+ )
267
+ await this.storage.restrictDirectory(this.directory);
241
268
  // Refuse to replace malformed, insecure or newly introduced storage.
242
269
  await this.readSaved();
243
270
  temporary = join(this.directory, `.config-${randomUUID()}.tmp`);
package/src/constants.ts CHANGED
@@ -5,6 +5,17 @@ export const ASK_NOTE_MAX_CHARS = 8_000;
5
5
  export const ASK_MAX_FILES = 20;
6
6
  export const ASK_TIMEOUT_S = 60;
7
7
  export const ASK_TIMEOUT_MAX_S = 300;
8
+ export const PROCESS_KILL_GRACE_MS = 2_000;
9
+ export const PROCESS_PIPE_GRACE_MS = 1_000;
10
+ export const PROCESS_TREE_TIMEOUT_MS = 5_000;
11
+ export const MCP_SHUTDOWN_FLUSH_TIMEOUT_MS = 1_000;
12
+ export const PRIVATE_STORAGE_TIMEOUT_MS = 20_000;
13
+ export const MCP_VALIDATION_MAX_ERRORS = 5;
14
+ export const MCP_PACKAGE_REPLY_TIMEOUT_MS = 20_000;
15
+ export const MCP_PACKAGE_EXIT_TIMEOUT_MS = 10_000;
16
+ export const MCP_PACKAGE_KILL_TIMEOUT_MS = 2_000;
17
+ export const MCP_PACKAGE_INSTALL_TIMEOUT_MS = 300_000;
18
+ export const MCP_PACKAGE_STDERR_MAX_CHARS = 2_000;
8
19
  export const OUTPUT_REPEAT_MIN = 5;
9
20
  export const OUTPUT_CHUNK_CHARS = 2_500;
10
21
  export const OUTPUT_FIND_MAX_CALLS = 40;
@@ -241,9 +241,25 @@ export function failureTargets(text: string): {
241
241
  // window when no failing-tests block exists (short outputs).
242
242
  if (node && (inSecondWindow || (secondAnchor < 0 && inFirstWindow)))
243
243
  targets.push({
244
- path: node[1]?.replace(/^file:\/\//, "") ?? "",
244
+ path: nodeTestPath(node[1] ?? ""),
245
245
  assertion: true,
246
246
  });
247
247
  }
248
248
  return { assertion: signature === "assertion", signature, targets };
249
249
  }
250
+
251
+ /**
252
+ * node:test reports absolute locations as file URLs: file:///repo/a.mjs on
253
+ * POSIX and file:///C:/repo/a%20b.mjs on Windows. Convert them to plain,
254
+ * decoded paths; other locations are returned unchanged.
255
+ */
256
+ export function nodeTestPath(location: string): string {
257
+ if (!location.startsWith("file://")) return location;
258
+ let path = location.slice("file://".length);
259
+ if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1);
260
+ try {
261
+ return decodeURIComponent(path);
262
+ } catch {
263
+ return path;
264
+ }
265
+ }
package/src/host.ts CHANGED
@@ -20,3 +20,14 @@ export interface Names {
20
20
  byName: string;
21
21
  semantic: string;
22
22
  }
23
+ /** MCP clients name their native tools differently; describe them generically. */
24
+ export function mcpHost(): Host {
25
+ return {
26
+ isOmp: false,
27
+ names: {
28
+ grep: "your text search tool",
29
+ byName: "your file-name search tool",
30
+ semantic: "jev_find_files",
31
+ },
32
+ };
33
+ }
package/src/jev/client.ts CHANGED
@@ -259,6 +259,7 @@ export function createJevClient(
259
259
  });
260
260
  for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
261
261
  let release: (() => void) | undefined;
262
+ let settle: (() => void) | undefined;
262
263
  const timeoutCancellation = new AbortController();
263
264
  let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
264
265
  try {
@@ -267,6 +268,13 @@ export function createJevClient(
267
268
  missing(ids, stopped);
268
269
  return;
269
270
  }
271
+ // The reservation is held from here on; `finally` releases it on
272
+ // every exit path (stopped, refused, failed, aborted, answered).
273
+ settle = await options.awaitAdmission?.(options.signal);
274
+ if (stopped) {
275
+ missing(ids, stopped);
276
+ return;
277
+ }
270
278
  const admission = options.beforeRequest?.(ids.length);
271
279
  if (admission && !admission.ok) {
272
280
  // A diagnostic denied admission supplies no judgment batch and
@@ -302,6 +310,9 @@ export function createJevClient(
302
310
  body = undefined;
303
311
  }
304
312
  addMetadata(body);
313
+ // Release the USD gate before any subdivision re-enters send().
314
+ settle?.();
315
+ settle = undefined;
305
316
  release();
306
317
  release = undefined;
307
318
  if (
@@ -438,6 +449,7 @@ export function createJevClient(
438
449
  return;
439
450
  }
440
451
  } finally {
452
+ settle?.();
441
453
  release?.();
442
454
  timeoutCancellation.abort();
443
455
  }
package/src/jev/types.ts CHANGED
@@ -48,6 +48,12 @@ export interface JudgmentOptions {
48
48
  witnesses?: readonly string[];
49
49
  cache?: boolean;
50
50
  beforeRequest?: (questionCount: number) => Result<object>;
51
+ /**
52
+ * Awaited before each admission check. Resolves to a release function when
53
+ * it reserved a slot (a session under a USD limit), which the client calls
54
+ * exactly once on every exit path; rejects if `signal` aborts while waiting.
55
+ */
56
+ awaitAdmission?: (signal?: AbortSignal) => Promise<(() => void) | undefined>;
51
57
  onUsage?: (usage: { inputTokens: number; costUsd: number }) => void;
52
58
  }
53
59
  export interface JevClient {
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { canonicalPath } from "../adapters/canonical-path.ts";
6
+ import { MCP_SHUTDOWN_FLUSH_TIMEOUT_MS } from "../constants.ts";
7
+ import { type JsonRpcResponse, McpServer, PARSE_ERROR } from "./protocol.ts";
8
+ import { createMcpTools } from "./tools.ts";
9
+
10
+ const usage = `jev-agent-tools-mcp - MCP stdio server for the six jev_* tools
11
+
12
+ Usage: jev-agent-tools-mcp [--root <repository-directory>]
13
+
14
+ The repository directory is --root, else JEV_TOOLS_ROOT, else the current
15
+ directory. Configuration uses the same JEV_TOOLS_* environment variables as
16
+ the pi and omp extension. Protocol messages use stdout; diagnostics stderr.`;
17
+
18
+ function packageVersion(): string {
19
+ try {
20
+ // src/mcp/main.ts and dist/mcp/main.js both sit two levels below the root.
21
+ const text = readFileSync(
22
+ new URL("../../package.json", import.meta.url),
23
+ "utf8",
24
+ );
25
+ const parsed = JSON.parse(text) as { version?: unknown };
26
+ return typeof parsed.version === "string" ? parsed.version : "0.0.0";
27
+ } catch {
28
+ return "0.0.0";
29
+ }
30
+ }
31
+
32
+ function parseArgs(argv: readonly string[]): { root?: string; exit?: string } {
33
+ let root: string | undefined;
34
+ for (let index = 0; index < argv.length; index++) {
35
+ const arg = argv[index];
36
+ if (arg === "--help" || arg === "-h") return { exit: usage };
37
+ if (arg === "--version") return { exit: packageVersion() };
38
+ if (arg === "--root") {
39
+ root = argv[++index];
40
+ if (!root) throw new Error("--root requires a directory");
41
+ } else if (arg?.startsWith("--root=")) root = arg.slice("--root=".length);
42
+ else throw new Error(`Unknown argument: ${arg}\n\n${usage}`);
43
+ }
44
+ return { root };
45
+ }
46
+
47
+ async function main(): Promise<void> {
48
+ const parsed = parseArgs(process.argv.slice(2));
49
+ if (parsed.exit !== undefined) {
50
+ process.stdout.write(`${parsed.exit}\n`);
51
+ return;
52
+ }
53
+ const root = await canonicalPath(
54
+ resolve(parsed.root ?? process.env.JEV_TOOLS_ROOT ?? process.cwd()),
55
+ );
56
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
57
+ throw new Error(`Repository directory not found: ${root}`);
58
+ const { tools, instructions, configured, warning } = await createMcpTools({
59
+ root,
60
+ });
61
+ const server = new McpServer(
62
+ { name: "jev-agent-tools", version: packageVersion(), instructions },
63
+ tools,
64
+ );
65
+ if (warning) process.stderr.write(`jev-agent-tools MCP: ${warning}\n`);
66
+ process.stderr.write(
67
+ `jev-agent-tools MCP server ready (root ${root}; ${configured ? "endpoint configured" : "no endpoint: set JEV_TOOLS_URL and JEV_TOOLS_API_KEY, or save them with /jev-setup in pi or omp"})\n`,
68
+ );
69
+ let closing = false;
70
+ const send = (response: JsonRpcResponse | JsonRpcResponse[] | undefined) => {
71
+ if (
72
+ closing ||
73
+ response === undefined ||
74
+ (Array.isArray(response) && !response.length)
75
+ )
76
+ return;
77
+ // Newline-delimited JSON; JSON.stringify never emits raw newlines.
78
+ process.stdout.write(`${JSON.stringify(response)}\n`);
79
+ };
80
+ const pending = new Set<Promise<void>>();
81
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
82
+ const shutdown = async (code: number) => {
83
+ if (closing) return;
84
+ closing = true;
85
+ lines.close();
86
+ process.stdin.destroy();
87
+ server.abortAll();
88
+ // Tool promises include bounded process-tree escalation. Do not exit when
89
+ // only the direct shell has closed: descendants may still need SIGKILL.
90
+ await Promise.allSettled(pending);
91
+ if (process.stdout.destroyed) process.exit(code);
92
+ // A client may leave its stdout pipe open without draining it. Cleanup
93
+ // is already complete; never let that client's backpressure hold us alive.
94
+ setTimeout(() => process.exit(code), MCP_SHUTDOWN_FLUSH_TIMEOUT_MS);
95
+ process.stdout.end(() => process.exit(code));
96
+ };
97
+ process.on("SIGTERM", () => void shutdown(143));
98
+ process.on("SIGINT", () => void shutdown(130));
99
+ process.stdout.on("error", () => void shutdown(1));
100
+ lines.on("line", (line) => {
101
+ if (closing || !line.trim()) return;
102
+ let message: unknown;
103
+ try {
104
+ message = JSON.parse(line);
105
+ } catch {
106
+ send(server.error(null, PARSE_ERROR, "Parse error: invalid JSON."));
107
+ return;
108
+ }
109
+ const work = (async () => {
110
+ if (Array.isArray(message)) {
111
+ // JSON-RPC batches (protocol 2025-03-26) answer as one array.
112
+ const responses = await Promise.all(
113
+ message.map((item) => server.handle(item)),
114
+ );
115
+ send(
116
+ responses.filter(
117
+ (item): item is JsonRpcResponse => item !== undefined,
118
+ ),
119
+ );
120
+ } else send(await server.handle(message));
121
+ })().catch((error: unknown) => {
122
+ process.stderr.write(`jev-agent-tools MCP: ${String(error)}\n`);
123
+ });
124
+ pending.add(work);
125
+ void work.finally(() => pending.delete(work));
126
+ });
127
+ lines.on("close", () => void shutdown(0));
128
+ }
129
+
130
+ main().catch((error: unknown) => {
131
+ process.stderr.write(
132
+ `jev-agent-tools MCP: ${error instanceof Error ? error.message : String(error)}\n`,
133
+ );
134
+ process.exit(1);
135
+ });