jev-agent-tools 0.1.3 → 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 (135) hide show
  1. package/CHANGELOG.md +88 -3
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +64 -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 +294 -0
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host-tui.d.ts +14 -0
  117. package/src/host.ts +11 -0
  118. package/src/index.ts +13 -5
  119. package/src/jev/client.ts +12 -0
  120. package/src/jev/types.ts +6 -0
  121. package/src/mcp/main.ts +135 -0
  122. package/src/mcp/protocol.ts +282 -0
  123. package/src/mcp/tools.ts +166 -0
  124. package/src/secret-input.ts +222 -0
  125. package/src/session.ts +59 -0
  126. package/src/setup.ts +170 -0
  127. package/src/texts/configuration.ts +1 -1
  128. package/src/tools/ask-files.ts +8 -13
  129. package/src/tools/ask.ts +29 -28
  130. package/src/tools/check-diff.ts +11 -11
  131. package/src/tools/docs-check.ts +1 -0
  132. package/src/tools/find.ts +8 -8
  133. package/src/tools/locate.ts +8 -9
  134. package/src/tools/select-tests.ts +10 -10
  135. 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
+ }
@@ -0,0 +1,294 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import {
7
+ type PrivateStorage,
8
+ privateStorage,
9
+ } from "./adapters/private-storage.ts";
10
+ import { createJevClient } from "./jev/client.ts";
11
+ import type { JevClient } from "./jev/types.ts";
12
+
13
+ export interface ConfigurationValues {
14
+ url: string;
15
+ apiKey: string;
16
+ model: string;
17
+ }
18
+
19
+ type SavedConfiguration = Partial<ConfigurationValues>;
20
+ const fields = ["url", "apiKey", "model"] as const;
21
+ const defaults: ConfigurationValues = { url: "", apiKey: "", model: "openjev" };
22
+ const storageError = () =>
23
+ new Error(
24
+ "Cannot read or save Jev configuration. Check that the configuration directory and config.json are owned by you, private, regular storage without symlinks, and contain valid configuration JSON.",
25
+ );
26
+
27
+ function nonempty(value: string | undefined): value is string {
28
+ return value !== undefined && value.trim().length > 0;
29
+ }
30
+
31
+ function validate(values: ConfigurationValues): void {
32
+ let validUrl = false;
33
+ try {
34
+ const url = new URL(values.url);
35
+ validUrl =
36
+ /^https?:\/\//i.test(values.url) &&
37
+ (url.protocol === "http:" || url.protocol === "https:") &&
38
+ url.hostname !== "" &&
39
+ url.username === "" &&
40
+ url.password === "";
41
+ } catch {
42
+ // Keep user-controlled URL and credentials out of error messages.
43
+ }
44
+ if (
45
+ !validUrl ||
46
+ !nonempty(values.apiKey) ||
47
+ /[^\x20-\x7e\x80-\xff]/.test(values.apiKey) ||
48
+ !nonempty(values.model)
49
+ ) {
50
+ throw new Error(
51
+ "Jev configuration requires a full HTTP(S) URL without embedded credentials, a nonempty HTTP-header-compatible API key, and a nonempty model.",
52
+ );
53
+ }
54
+ }
55
+
56
+ /** Effective configuration and its client move together, only after a save succeeds. */
57
+ export class ConfigController {
58
+ private readonly environment: SavedConfiguration = {};
59
+ private flags: SavedConfiguration = {};
60
+ private saved: SavedConfiguration = {};
61
+ private session: SavedConfiguration = {};
62
+ private readonly directory: string;
63
+ private readonly storage: PrivateStorage;
64
+ private initialization: Promise<void> | undefined;
65
+ private currentClient: JevClient | undefined;
66
+
67
+ constructor(
68
+ options: {
69
+ env?: NodeJS.ProcessEnv;
70
+ directory?: string;
71
+ storage?: PrivateStorage;
72
+ } = {},
73
+ ) {
74
+ const env = options.env ?? process.env;
75
+ this.storage = options.storage ?? privateStorage();
76
+ for (const [field, name] of [
77
+ ["url", "JEV_TOOLS_URL"],
78
+ ["apiKey", "JEV_TOOLS_API_KEY"],
79
+ ["model", "JEV_TOOLS_MODEL"],
80
+ ] as const) {
81
+ if (nonempty(env[name])) this.environment[field] = env[name];
82
+ }
83
+ const base =
84
+ nonempty(env.XDG_CONFIG_HOME) && isAbsolute(env.XDG_CONFIG_HOME)
85
+ ? env.XDG_CONFIG_HOME
86
+ : join(homedir(), ".config");
87
+ this.directory = resolve(
88
+ options.directory ?? join(base, "jev-agent-tools"),
89
+ );
90
+ if (nonempty(this.environment.url) && nonempty(this.environment.apiKey)) {
91
+ const effective = this.values();
92
+ validate(effective);
93
+ this.currentClient = createJevClient(effective);
94
+ }
95
+ }
96
+
97
+ get client(): JevClient | undefined {
98
+ return this.currentClient;
99
+ }
100
+
101
+ initialize(flags: { url?: string; model?: string }): Promise<void> {
102
+ if (this.initialization) return this.initialization;
103
+ this.initialization = this.load(flags).catch((error: unknown) => {
104
+ this.initialization = undefined;
105
+ throw error;
106
+ });
107
+ return this.initialization;
108
+ }
109
+
110
+ values(): ConfigurationValues {
111
+ return {
112
+ ...defaults,
113
+ ...this.saved,
114
+ ...this.session,
115
+ ...this.flags,
116
+ ...this.environment,
117
+ };
118
+ }
119
+
120
+ locked(field: keyof ConfigurationValues): boolean {
121
+ return (
122
+ this.environment[field] !== undefined || this.flags[field] !== undefined
123
+ );
124
+ }
125
+
126
+ async apply(values: ConfigurationValues, persist: boolean): Promise<void> {
127
+ if (!this.initialization) {
128
+ throw new Error("Initialize Jev configuration before applying changes.");
129
+ }
130
+ await this.initialization;
131
+ const session = { ...this.session };
132
+ for (const field of fields) {
133
+ if (!this.locked(field)) session[field] = values[field];
134
+ }
135
+ const effective = {
136
+ ...defaults,
137
+ ...this.saved,
138
+ ...session,
139
+ ...this.flags,
140
+ ...this.environment,
141
+ };
142
+ validate(effective);
143
+ const saved = { ...this.saved, ...session };
144
+ if (persist) await this.save(saved);
145
+ const previous = this.values();
146
+ this.session = session;
147
+ if (persist) this.saved = saved;
148
+ if (fields.some((field) => previous[field] !== effective[field])) {
149
+ this.currentClient?.clearCache();
150
+ this.currentClient = createJevClient(effective);
151
+ } else {
152
+ this.currentClient ??= createJevClient(effective);
153
+ }
154
+ }
155
+
156
+ private async load(flags: { url?: string; model?: string }): Promise<void> {
157
+ const saved = await this.readSaved();
158
+ const supplied: SavedConfiguration = {};
159
+ if (nonempty(flags.url)) supplied.url = flags.url;
160
+ if (nonempty(flags.model)) supplied.model = flags.model;
161
+ const effective = {
162
+ ...defaults,
163
+ ...saved,
164
+ ...supplied,
165
+ ...this.environment,
166
+ };
167
+ if (nonempty(effective.url) && nonempty(effective.apiKey)) {
168
+ validate(effective);
169
+ this.currentClient?.clearCache();
170
+ this.currentClient = createJevClient(effective);
171
+ }
172
+ this.saved = saved;
173
+ this.flags = supplied;
174
+ }
175
+
176
+ private async checkDirectory(): Promise<boolean> {
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.
180
+ let path = this.directory;
181
+ let exists = true;
182
+ while (true) {
183
+ try {
184
+ const stat = await lstat(path);
185
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw storageError();
186
+ } catch (error) {
187
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT")
188
+ throw storageError();
189
+ if (path === this.directory) exists = false;
190
+ }
191
+ const parent = dirname(path);
192
+ if (parent === path) break;
193
+ path = parent;
194
+ }
195
+ return exists;
196
+ }
197
+
198
+ private async readSaved(): Promise<SavedConfiguration> {
199
+ try {
200
+ if (!(await this.checkDirectory())) return {};
201
+ const directory = {
202
+ path: this.directory,
203
+ stat: await lstat(this.directory),
204
+ };
205
+ const path = join(this.directory, "config.json");
206
+ try {
207
+ const stat = await lstat(path);
208
+ if (!stat.isFile() || stat.isSymbolicLink()) throw storageError();
209
+ } catch (error) {
210
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
211
+ if (!(await this.storage.isPrivate([directory])))
212
+ throw storageError();
213
+ return {};
214
+ }
215
+ throw error;
216
+ }
217
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
218
+ try {
219
+ const stat = await file.stat();
220
+ if (
221
+ !stat.isFile() ||
222
+ stat.nlink !== 1 ||
223
+ !(await this.storage.isPrivate([directory, { path, stat }]))
224
+ ) {
225
+ throw storageError();
226
+ }
227
+ const value: unknown = JSON.parse(await file.readFile("utf8"));
228
+ if (
229
+ value === null ||
230
+ typeof value !== "object" ||
231
+ Array.isArray(value) ||
232
+ Object.entries(value).some(
233
+ ([key, entry]) =>
234
+ !fields.some((field) => field === key) ||
235
+ typeof entry !== "string",
236
+ )
237
+ ) {
238
+ throw storageError();
239
+ }
240
+ return value as SavedConfiguration;
241
+ } finally {
242
+ await file.close();
243
+ }
244
+ } catch {
245
+ throw storageError();
246
+ }
247
+ }
248
+
249
+ private async save(values: SavedConfiguration): Promise<void> {
250
+ let temporary: string | undefined;
251
+ try {
252
+ const existed = await this.checkDirectory();
253
+ if (!existed) {
254
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
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);
268
+ // Refuse to replace malformed, insecure or newly introduced storage.
269
+ await this.readSaved();
270
+ temporary = join(this.directory, `.config-${randomUUID()}.tmp`);
271
+ const file = await open(
272
+ temporary,
273
+ constants.O_WRONLY |
274
+ constants.O_CREAT |
275
+ constants.O_EXCL |
276
+ constants.O_NOFOLLOW,
277
+ 0o600,
278
+ );
279
+ try {
280
+ await file.writeFile(`${JSON.stringify(values, null, 2)}\n`, "utf8");
281
+ await file.sync();
282
+ } finally {
283
+ await file.close();
284
+ }
285
+ await this.readSaved();
286
+ await rename(temporary, join(this.directory, "config.json"));
287
+ temporary = undefined;
288
+ } catch {
289
+ throw storageError();
290
+ } finally {
291
+ if (temporary) await unlink(temporary).catch(() => {});
292
+ }
293
+ }
294
+ }
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
+ }
@@ -0,0 +1,14 @@
1
+ // Runtime implementations are supplied by the pi/omp extension loader.
2
+ declare module "@earendil-works/pi-tui" {
3
+ export function matchesKey(
4
+ data: string,
5
+ key: "enter" | "escape" | "backspace" | "ctrl+c" | "ctrl+u",
6
+ ): boolean;
7
+ export const decodePrintableKey:
8
+ | ((data: string) => string | undefined)
9
+ | undefined;
10
+ export const decodeKittyPrintable:
11
+ | ((data: string) => string | undefined)
12
+ | undefined;
13
+ export function isKeyRelease(data: string): boolean;
14
+ }
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/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ConfigController } from "./configuration.ts";
2
3
  import { Guide } from "./guide.ts";
3
4
  import { detectHost } from "./host.ts";
4
- import { createJevClient, readConfig } from "./jev/client.ts";
5
5
  import { RunEnd, type RunEndHost } from "./run-end.ts";
6
6
  import { readSessionLimits, Session } from "./session.ts";
7
+ import { Setup } from "./setup.ts";
7
8
  import { createAskTool } from "./tools/ask.ts";
8
9
  import { createAskFilesTool } from "./tools/ask-files.ts";
9
10
  import { createCheckDiffTool } from "./tools/check-diff.ts";
@@ -12,8 +13,7 @@ import { createLocateTool } from "./tools/locate.ts";
12
13
  import { createSelectTestsTool } from "./tools/select-tests.ts";
13
14
  export default function extension(api: ExtensionAPI & { pi?: unknown }): void {
14
15
  const host = detectHost(api);
15
- const config = readConfig(process.env);
16
- const client = config ? createJevClient(config) : undefined;
16
+ const config = new ConfigController();
17
17
  const runtime = {
18
18
  session: new Session(readSessionLimits(process.env)),
19
19
  guide: new Guide(host),
@@ -22,14 +22,22 @@ export default function extension(api: ExtensionAPI & { pi?: unknown }): void {
22
22
  api.on("session_start", async () => {
23
23
  runtime.session.reset();
24
24
  runtime.guide.reset();
25
- client?.clearCache();
25
+ config.client?.clearCache();
26
26
  if (host.isOmp) {
27
27
  const active = api.getActiveTools();
28
28
  if (!active.includes("find"))
29
29
  await api.setActiveTools([...active, "jev_find_files"]);
30
30
  }
31
31
  });
32
- const dependencies = { client, host, runtime, exec: api.exec.bind(api) };
32
+ new Setup(config).attach(api);
33
+ const dependencies = {
34
+ get client() {
35
+ return config.client;
36
+ },
37
+ host,
38
+ runtime,
39
+ exec: api.exec.bind(api),
40
+ };
33
41
  new RunEnd(dependencies).attach(api as unknown as RunEndHost);
34
42
  api.registerTool(createAskTool(dependencies));
35
43
  api.registerTool(createSelectTestsTool(dependencies));