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,282 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) server core: JSON-RPC 2.0 dispatch for
3
+ * the tools capability only. No I/O here; the stdio transport feeds it parsed
4
+ * messages and writes back whatever it returns.
5
+ *
6
+ * Supports the initialize-based protocol versions and the 2026-07-28
7
+ * `server/discover` entry point with per-request version metadata.
8
+ */
9
+
10
+ export const SUPPORTED_VERSIONS = [
11
+ "2026-07-28",
12
+ "2025-11-25",
13
+ "2025-06-18",
14
+ "2025-03-26",
15
+ "2024-11-05",
16
+ ] as const;
17
+ const LATEST_INITIALIZE_VERSION = "2025-11-25";
18
+ const VERSION_META = "io.modelcontextprotocol/protocolVersion";
19
+
20
+ export const PARSE_ERROR = -32700;
21
+ export const INVALID_REQUEST = -32600;
22
+ export const METHOD_NOT_FOUND = -32601;
23
+ export const INVALID_PARAMS = -32602;
24
+ export const INTERNAL_ERROR = -32603;
25
+ export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
26
+
27
+ export interface McpContent {
28
+ type: "text";
29
+ text: string;
30
+ }
31
+ export interface McpCallResult {
32
+ content: McpContent[];
33
+ isError?: boolean;
34
+ }
35
+ export interface McpTool {
36
+ name: string;
37
+ title?: string;
38
+ description: string;
39
+ inputSchema: { type: "object"; [key: string]: unknown };
40
+ annotations?: {
41
+ title?: string;
42
+ readOnlyHint?: boolean;
43
+ destructiveHint?: boolean;
44
+ idempotentHint?: boolean;
45
+ openWorldHint?: boolean;
46
+ };
47
+ call(
48
+ args: Record<string, unknown>,
49
+ signal: AbortSignal,
50
+ ): Promise<McpCallResult>;
51
+ }
52
+ export interface McpServerInfo {
53
+ name: string;
54
+ version: string;
55
+ instructions?: string;
56
+ }
57
+
58
+ type Id = string | number;
59
+ export type JsonRpcResponse =
60
+ | { jsonrpc: "2.0"; id: Id | null; result: Record<string, unknown> }
61
+ | {
62
+ jsonrpc: "2.0";
63
+ id: Id | null;
64
+ error: { code: number; message: string; data?: unknown };
65
+ };
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+ function isId(value: unknown): value is Id {
71
+ return (
72
+ typeof value === "string" ||
73
+ (typeof value === "number" && Number.isFinite(value))
74
+ );
75
+ }
76
+
77
+ export class McpServer {
78
+ private readonly tools: Map<string, McpTool>;
79
+ private readonly info: McpServerInfo;
80
+ private readonly inFlight = new Map<Id, AbortController>();
81
+
82
+ constructor(info: McpServerInfo, tools: readonly McpTool[]) {
83
+ this.info = info;
84
+ this.tools = new Map(tools.map((tool) => [tool.name, tool]));
85
+ }
86
+
87
+ /** Abort every running tool call, e.g. when stdin closes. */
88
+ abortAll(): void {
89
+ for (const controller of this.inFlight.values()) controller.abort();
90
+ this.inFlight.clear();
91
+ }
92
+
93
+ /** Handle one decoded JSON-RPC message; notifications return undefined. */
94
+ async handle(message: unknown): Promise<JsonRpcResponse | undefined> {
95
+ if (!isRecord(message) || message.jsonrpc !== "2.0")
96
+ return this.error(
97
+ isRecord(message) && isId(message.id) ? message.id : null,
98
+ INVALID_REQUEST,
99
+ "Invalid JSON-RPC 2.0 message.",
100
+ );
101
+ const { method, id } = message;
102
+ const params = isRecord(message.params) ? message.params : {};
103
+ // Responses to server-initiated requests: this server sends none.
104
+ if (method === undefined && ("result" in message || "error" in message))
105
+ return undefined;
106
+ if (typeof method !== "string")
107
+ return this.error(
108
+ isId(id) ? id : null,
109
+ INVALID_REQUEST,
110
+ "Missing method.",
111
+ );
112
+ if (id === undefined) {
113
+ this.notify(method, params);
114
+ return undefined;
115
+ }
116
+ if (!isId(id))
117
+ return this.error(
118
+ null,
119
+ INVALID_REQUEST,
120
+ "Request id must be a string or number.",
121
+ );
122
+ const meta = isRecord(params._meta) ? params._meta : {};
123
+ const requested = meta[VERSION_META];
124
+ if (
125
+ typeof requested === "string" &&
126
+ !(SUPPORTED_VERSIONS as readonly string[]).includes(requested)
127
+ )
128
+ return this.error(
129
+ id,
130
+ UNSUPPORTED_PROTOCOL_VERSION,
131
+ "Unsupported protocol version.",
132
+ {
133
+ supported: [...SUPPORTED_VERSIONS],
134
+ requested,
135
+ },
136
+ );
137
+ try {
138
+ switch (method) {
139
+ case "initialize":
140
+ return this.result(id, this.initialize(params));
141
+ case "server/discover":
142
+ return this.result(id, {
143
+ supportedVersions: [...SUPPORTED_VERSIONS],
144
+ capabilities: { tools: { listChanged: false } },
145
+ _meta: {
146
+ "io.modelcontextprotocol/serverInfo": {
147
+ name: this.info.name,
148
+ version: this.info.version,
149
+ },
150
+ },
151
+ ...(this.info.instructions
152
+ ? { instructions: this.info.instructions }
153
+ : {}),
154
+ ttlMs: 0,
155
+ cacheScope: "private",
156
+ });
157
+ case "ping":
158
+ return this.result(id, {});
159
+ case "tools/list":
160
+ return this.result(id, {
161
+ tools: [...this.tools.values()].map(
162
+ ({ call: _call, ...tool }) => tool,
163
+ ),
164
+ // CacheableResult requires these from 2026-07-28; 0/private is
165
+ // conservative (immediately stale, same authorization context)
166
+ // and ignored by earlier clients via the open result shape.
167
+ ttlMs: 0,
168
+ cacheScope: "private",
169
+ });
170
+ case "tools/call":
171
+ return await this.callTool(id, params);
172
+ default:
173
+ return this.error(
174
+ id,
175
+ METHOD_NOT_FOUND,
176
+ `Method not found: ${method}`,
177
+ );
178
+ }
179
+ } catch (error) {
180
+ return this.error(
181
+ id,
182
+ INTERNAL_ERROR,
183
+ error instanceof Error ? error.message : String(error),
184
+ );
185
+ }
186
+ }
187
+
188
+ private initialize(params: Record<string, unknown>): Record<string, unknown> {
189
+ const requested = params.protocolVersion;
190
+ // Echo a supported initialize-era version, else offer our latest one.
191
+ const protocolVersion =
192
+ typeof requested === "string" &&
193
+ requested !== "2026-07-28" &&
194
+ (SUPPORTED_VERSIONS as readonly string[]).includes(requested)
195
+ ? requested
196
+ : LATEST_INITIALIZE_VERSION;
197
+ return {
198
+ protocolVersion,
199
+ capabilities: { tools: { listChanged: false } },
200
+ serverInfo: { name: this.info.name, version: this.info.version },
201
+ ...(this.info.instructions
202
+ ? { instructions: this.info.instructions }
203
+ : {}),
204
+ };
205
+ }
206
+
207
+ private notify(method: string, params: Record<string, unknown>): void {
208
+ if (method === "notifications/cancelled" && isId(params.requestId)) {
209
+ this.inFlight.get(params.requestId)?.abort();
210
+ this.inFlight.delete(params.requestId);
211
+ }
212
+ // notifications/initialized and unknown notifications need no action.
213
+ }
214
+
215
+ private async callTool(
216
+ id: Id,
217
+ params: Record<string, unknown>,
218
+ ): Promise<JsonRpcResponse | undefined> {
219
+ const tool =
220
+ typeof params.name === "string" ? this.tools.get(params.name) : undefined;
221
+ if (!tool)
222
+ return this.error(
223
+ id,
224
+ INVALID_PARAMS,
225
+ `Unknown tool: ${String(params.name)}`,
226
+ );
227
+ if (params.arguments !== undefined && !isRecord(params.arguments))
228
+ return this.error(
229
+ id,
230
+ INVALID_PARAMS,
231
+ "Tool arguments must be an object.",
232
+ );
233
+ const controller = new AbortController();
234
+ this.inFlight.set(id, controller);
235
+ // A cancelled request gets no response on any path: 2025-11-25 says
236
+ // receivers SHOULD NOT respond, and the 2026-07-28 stdio transport says
237
+ // servers MUST NOT send further messages for it. The signal stays aborted
238
+ // after the notification removes the entry, so the check is race-free.
239
+ const cancelled = () => controller.signal.aborted;
240
+ try {
241
+ const result = await tool.call(params.arguments ?? {}, controller.signal);
242
+ if (cancelled()) return undefined;
243
+ return this.result(id, { ...result });
244
+ } catch (error) {
245
+ if (cancelled()) return undefined;
246
+ // Tool execution failures are results the model can read, not protocol errors.
247
+ return this.result(id, {
248
+ content: [
249
+ {
250
+ type: "text",
251
+ text: `${tool.name} failed: ${error instanceof Error ? error.message : String(error)}`,
252
+ },
253
+ ],
254
+ isError: true,
255
+ });
256
+ } finally {
257
+ this.inFlight.delete(id);
258
+ }
259
+ }
260
+
261
+ private result(id: Id, result: Record<string, unknown>): JsonRpcResponse {
262
+ // resultType is required from 2026-07-28 and ignored by earlier clients.
263
+ return {
264
+ jsonrpc: "2.0",
265
+ id,
266
+ result: { resultType: "complete", ...result },
267
+ };
268
+ }
269
+
270
+ error(
271
+ id: Id | null,
272
+ code: number,
273
+ message: string,
274
+ data?: unknown,
275
+ ): JsonRpcResponse {
276
+ return {
277
+ jsonrpc: "2.0",
278
+ id,
279
+ error: { code, message, ...(data === undefined ? {} : { data }) },
280
+ };
281
+ }
282
+ }
@@ -0,0 +1,166 @@
1
+ import type { TSchema } from "@sinclair/typebox";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ import { spawnExec } from "../adapters/exec.ts";
4
+ import { ConfigController } from "../configuration.ts";
5
+ import { MCP_VALIDATION_MAX_ERRORS } from "../constants.ts";
6
+ import type { GitExec } from "../core/git.ts";
7
+ import { Guide } from "../guide.ts";
8
+ import { mcpHost } from "../host.ts";
9
+ import type { JevClient } from "../jev/types.ts";
10
+ import type { ToolDependencies } from "../runtime.ts";
11
+ import { readSessionLimits, Session } from "../session.ts";
12
+ import { createAskTool } from "../tools/ask.ts";
13
+ import { createAskFilesTool } from "../tools/ask-files.ts";
14
+ import { createCheckDiffTool } from "../tools/check-diff.ts";
15
+ import { createFindFilesTool } from "../tools/find.ts";
16
+ import { createLocateTool } from "../tools/locate.ts";
17
+ import { createSelectTestsTool } from "../tools/select-tests.ts";
18
+ import type { McpTool } from "./protocol.ts";
19
+
20
+ /** The shape every tool factory already returns for pi/omp. */
21
+ interface HarnessTool {
22
+ name: string;
23
+ label: string;
24
+ description: string;
25
+ parameters: TSchema;
26
+ promptGuidelines?: string[];
27
+ execute(
28
+ id: string,
29
+ args: never,
30
+ signal: AbortSignal | undefined,
31
+ update: unknown,
32
+ ctx: { cwd: string },
33
+ ): Promise<{ content: { type: "text"; text: string }[] }>;
34
+ }
35
+
36
+ export interface McpToolOptions {
37
+ /** Repository directory the tools operate in. */
38
+ root: string;
39
+ env?: NodeJS.ProcessEnv;
40
+ /** Test seams; production uses the configured HTTP client and spawn. */
41
+ client?: JevClient;
42
+ exec?: GitExec;
43
+ /** Saved-configuration directory; defaults to the pi/omp setup location. */
44
+ configDirectory?: string;
45
+ }
46
+
47
+ function validationError(schema: TSchema, value: unknown): string | undefined {
48
+ if (Value.Check(schema, value)) return undefined;
49
+ const problems: string[] = [];
50
+ for (const error of Value.Errors(schema, value)) {
51
+ problems.push(`${error.path || "/"}: ${error.message}`);
52
+ if (problems.length === MCP_VALIDATION_MAX_ERRORS) break;
53
+ }
54
+ return `Invalid arguments. ${problems.join("; ")}`;
55
+ }
56
+
57
+ /**
58
+ * Effective Jev client for MCP, with the same precedence as pi/omp minus the
59
+ * interactive layers: environment variables, then the configuration saved by
60
+ * `/jev-setup` in pi or omp. Storage problems never stop the server; the tools
61
+ * then explain the missing configuration and `warning` says why.
62
+ */
63
+ export async function loadMcpClient(
64
+ env: NodeJS.ProcessEnv,
65
+ configDirectory?: string,
66
+ ): Promise<{ client?: JevClient; warning?: string }> {
67
+ try {
68
+ const controller = new ConfigController({
69
+ env,
70
+ ...(configDirectory ? { directory: configDirectory } : {}),
71
+ });
72
+ try {
73
+ await controller.initialize({});
74
+ } catch (error) {
75
+ // Saved storage unusable: environment configuration (if any) still applies.
76
+ return {
77
+ ...(controller.client ? { client: controller.client } : {}),
78
+ warning: error instanceof Error ? error.message : String(error),
79
+ };
80
+ }
81
+ return controller.client ? { client: controller.client } : {};
82
+ } catch (error) {
83
+ return { warning: error instanceof Error ? error.message : String(error) };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Reuse the six harness tool factories unchanged. One MCP server process is
89
+ * one session: limits, cache and counters live as long as the connection.
90
+ */
91
+ export async function createMcpTools(options: McpToolOptions): Promise<{
92
+ tools: McpTool[];
93
+ instructions: string;
94
+ configured: boolean;
95
+ warning?: string;
96
+ }> {
97
+ const env = options.env ?? process.env;
98
+ const host = mcpHost();
99
+ const loaded = options.client
100
+ ? { client: options.client }
101
+ : await loadMcpClient(env, options.configDirectory);
102
+ const client = loaded.client;
103
+ const dependencies: ToolDependencies = {
104
+ client,
105
+ host,
106
+ runtime: {
107
+ session: new Session(readSessionLimits(env)),
108
+ guide: new Guide(host),
109
+ },
110
+ exec: options.exec ?? spawnExec,
111
+ };
112
+ const harness = [
113
+ createAskTool(dependencies),
114
+ createAskFilesTool(dependencies),
115
+ createFindFilesTool(dependencies),
116
+ createLocateTool(dependencies),
117
+ createCheckDiffTool(dependencies),
118
+ createSelectTestsTool(dependencies),
119
+ ] as unknown as HarnessTool[];
120
+ let id = 0;
121
+ const tools = harness.map((tool): McpTool => {
122
+ const runsCommands =
123
+ tool.name === "jev_ask" &&
124
+ JSON.stringify(tool.parameters).includes('"command"');
125
+ return {
126
+ name: tool.name,
127
+ title: tool.label,
128
+ description: tool.description,
129
+ inputSchema: JSON.parse(
130
+ JSON.stringify(tool.parameters),
131
+ ) as McpTool["inputSchema"],
132
+ annotations: {
133
+ title: tool.label,
134
+ // Evidence is read-only unless jev_ask may run a shell command.
135
+ readOnlyHint: !runsCommands,
136
+ destructiveHint: runsCommands,
137
+ idempotentHint: false,
138
+ // Evidence is sent to the configured judgment endpoint.
139
+ openWorldHint: true,
140
+ },
141
+ async call(args, signal) {
142
+ const invalid = validationError(tool.parameters, args);
143
+ if (invalid)
144
+ return { content: [{ type: "text", text: invalid }], isError: true };
145
+ const result = await tool.execute(
146
+ `mcp-${++id}`,
147
+ args as never,
148
+ signal,
149
+ undefined,
150
+ { cwd: options.root },
151
+ );
152
+ return { content: result.content };
153
+ },
154
+ };
155
+ });
156
+ const guidelines = harness.flatMap((tool) => tool.promptGuidelines ?? []);
157
+ const instructions = [dependencies.runtime.guide.text, ...guidelines].join(
158
+ "\n\n",
159
+ );
160
+ return {
161
+ tools,
162
+ instructions,
163
+ configured: client !== undefined,
164
+ ...(loaded.warning ? { warning: loaded.warning } : {}),
165
+ };
166
+ }
package/src/session.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Envelope } from "./core/output.ts";
2
+ import type { JudgmentOptions } from "./jev/types.ts";
2
3
  import type { Result } from "./result.ts";
3
4
 
4
5
  export interface SessionLimits {
@@ -53,6 +54,10 @@ export class Session {
53
54
  cacheHits: 0,
54
55
  };
55
56
  private readonly limits: SessionLimits;
57
+ // USD gate: at most one admitted request in flight under a USD limit.
58
+ private gateHeld = false;
59
+ private gateEpoch = 0;
60
+ private gateWaiters = new Set<() => void>();
56
61
  constructor(limits: SessionLimits) {
57
62
  this.limits = limits;
58
63
  }
@@ -75,6 +80,56 @@ export class Session {
75
80
  this.counters.questions += questions;
76
81
  return { ok: true };
77
82
  }
83
+ /**
84
+ * Under a USD limit, admit one request at a time so each admission sees the
85
+ * cost reported by every earlier request; without it, concurrent batches are
86
+ * all admitted before any cost arrives and overshoot the limit. Only the
87
+ * final admitted request can then exceed the limit, as documented.
88
+ *
89
+ * `awaitAdmission` reserves the gate atomically: the availability check and
90
+ * `gateHeld = true` run with no await between them, so two callers cannot
91
+ * both pass. It resolves to a release function that the client calls on
92
+ * every exit path (response, refusal, failure, abort); release is
93
+ * idempotent and ignores a gate that `reset()` already replaced.
94
+ */
95
+ requestGate(): Pick<JudgmentOptions, "awaitAdmission"> {
96
+ return {
97
+ awaitAdmission: async (signal) => {
98
+ if (this.limits.maxUsd === undefined) return undefined;
99
+ while (this.gateHeld) {
100
+ signal?.throwIfAborted();
101
+ await new Promise<void>((resolve, reject) => {
102
+ const onAbort = () => {
103
+ this.gateWaiters.delete(wake);
104
+ reject(signal?.reason);
105
+ };
106
+ const wake = () => {
107
+ signal?.removeEventListener("abort", onAbort);
108
+ resolve();
109
+ };
110
+ this.gateWaiters.add(wake);
111
+ signal?.addEventListener("abort", onAbort, { once: true });
112
+ });
113
+ }
114
+ signal?.throwIfAborted();
115
+ this.gateHeld = true;
116
+ const epoch = this.gateEpoch;
117
+ let released = false;
118
+ return () => {
119
+ if (released || epoch !== this.gateEpoch) return;
120
+ released = true;
121
+ this.gateHeld = false;
122
+ this.wakeGateWaiters();
123
+ };
124
+ },
125
+ };
126
+ }
127
+ // Every waiter rechecks; the first to run reserves, the rest wait again.
128
+ private wakeGateWaiters(): void {
129
+ const waiters = [...this.gateWaiters];
130
+ this.gateWaiters.clear();
131
+ for (const wake of waiters) wake();
132
+ }
78
133
  recordUsage(usage: { costUsd: number }): void {
79
134
  this.counters.costUsd += usage.costUsd;
80
135
  }
@@ -93,6 +148,10 @@ export class Session {
93
148
  return { ...this.counters };
94
149
  }
95
150
  reset(): void {
151
+ // A new session generation: outstanding release functions become no-ops.
152
+ this.gateEpoch++;
153
+ this.gateHeld = false;
154
+ this.wakeGateWaiters();
96
155
  this.counters = {
97
156
  calls: 0,
98
157
  questions: 0,
package/src/setup.ts CHANGED
@@ -62,11 +62,11 @@ export class Setup {
62
62
  )
63
63
  return;
64
64
  this.offered = true;
65
- const choice = await ctx.ui.select(
66
- "Jev Tools needs an endpoint and API key",
67
- ["Configure now", "Later"],
68
- );
69
- if (choice === "Configure now") await this.configure(ctx);
65
+ // Host event deadlines bound work, not the time needed to find credentials.
66
+ // Return before waiting for input so omp clears the handler's UI abort timer.
67
+ void this.offer(ctx).catch((error: unknown) => {
68
+ ctx.ui.notify(this.message(error), "error");
69
+ });
70
70
  } catch (error) {
71
71
  if (this.interactive(ctx)) ctx.ui.notify(this.message(error), "error");
72
72
  }
@@ -102,6 +102,14 @@ export class Setup {
102
102
  : "Jev configuration failed; the previous configuration was kept.";
103
103
  }
104
104
 
105
+ private async offer(ctx: SetupContext): Promise<void> {
106
+ const choice = await ctx.ui.select(
107
+ "Jev Tools needs an endpoint and API key",
108
+ ["Configure now", "Later"],
109
+ );
110
+ if (choice === "Configure now") await this.configure(ctx);
111
+ }
112
+
105
113
  private async configure(ctx: SetupContext): Promise<void> {
106
114
  if (this.busy) return;
107
115
  this.busy = true;
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
5
  import { collectAskFiles } from "../adapters/ask-files.ts";
6
+ import { canonicalPath } from "../adapters/canonical-path.ts";
6
7
  import { shareGitInventory } from "../adapters/git-inventory.ts";
7
8
  import { hostUsage } from "../adapters/usage.ts";
8
9
  import { compileAsks, readAsks, reverseQuestions } from "../core/asks.ts";
@@ -67,6 +68,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
67
68
  ctx: { cwd: string } & GuideContext,
68
69
  ) {
69
70
  const client = dependencies.client;
71
+ const cwd = await canonicalPath(ctx.cwd);
70
72
  const started = performance.now();
71
73
  const exec = shareGitInventory(execute);
72
74
  const totals = {
@@ -99,12 +101,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
99
101
  if (!client) return finish({ refusal: NOT_CONFIGURED });
100
102
  const plan = compileAsks(args.asks, { surface: "files" });
101
103
  if (!plan.ok) return finish({ refusal: plan.error });
102
- const collected = await collectAskFiles(
103
- ctx.cwd,
104
- args.paths,
105
- signal,
106
- exec,
107
- );
104
+ const collected = await collectAskFiles(cwd, args.paths, signal, exec);
108
105
  if (!collected.ok) return finish({ refusal: collected.error });
109
106
  skipped = collected.skipped;
110
107
  if (!collected.files.length && skipped.length)
@@ -115,6 +112,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
115
112
  let budget: BudgetRefusal | undefined;
116
113
  const options = {
117
114
  signal,
115
+ ...runtime.session.requestGate(),
118
116
  beforeRequest: (count: number) => {
119
117
  if (args.max_calls !== undefined && sent >= args.max_calls) {
120
118
  budget = {
@@ -148,7 +146,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
148
146
  const integrity = checkIntegrity(state, plan.asks, [
149
147
  {
150
148
  ...file.identity,
151
- insertedPath: resolve(ctx.cwd, state.path),
149
+ insertedPath: resolve(cwd, state.path),
152
150
  content: state.content,
153
151
  insertedSha256: createHash("sha256")
154
152
  .update(state.content)