@withtruing/companion 0.0.0-stage → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The 3b backend (see the local-companion plan's Section 3) — no CLI dependency, calls the
3
+ * model directly with a locally-configured API key. Structurally mirrors server/drift.ts's
4
+ * verifyModuleAgainstCode almost exactly (same two-pass shape: pick candidate files, then judge
5
+ * against their contents) — the two things that actually change are which machine runs it and
6
+ * that file reads go through node:fs against the local checkout instead of the GitHub API, so
7
+ * nothing here truncates a file's contents the way MAX_FILE_CHARS does server-side.
8
+ *
9
+ * fileSelectSchema below is a deliberate duplicate of server/drift.ts's own (not imported —
10
+ * this package is meant to be independently publishable, see types.ts's own comment). Keep the
11
+ * two in sync by hand if either changes.
12
+ */
13
+ import Anthropic from '@anthropic-ai/sdk';
14
+ import { readFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { buildLocalDesignContext } from './design-context.js';
17
+ import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
18
+ import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
19
+ import { replyText, streamWithFallback } from './text-model.js';
20
+ const MAX_CANDIDATE_FILES = 8;
21
+ /** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
22
+ * (with a note in the log), rather than read and cut off partway through. The whole point of
23
+ * this backend is that what IS read is never truncated; this only guards against an
24
+ * accidentally-selected generated/binary/data file blowing up the prompt. */
25
+ const MAX_FILE_BYTES = 512 * 1024;
26
+ /** Structured model-only call used by the investigation reader. The reader, not this call and
27
+ * not the model, owns every repository read and evidence id. */
28
+ export async function runStructuredModelCall(client, request) {
29
+ const stream = streamWithFallback(client, {
30
+ model: request.model,
31
+ max_tokens: request.maxTokens,
32
+ thinking: { type: 'adaptive' },
33
+ output_config: {
34
+ effort: request.effort,
35
+ format: { type: 'json_schema', schema: request.schema },
36
+ },
37
+ system: request.system,
38
+ messages: [{ role: 'user', content: request.prompt }],
39
+ });
40
+ let message;
41
+ try {
42
+ message = await stream.finalMessage();
43
+ }
44
+ catch (error) {
45
+ if (error instanceof Anthropic.RateLimitError) {
46
+ throw new Error('budget_exhausted: Anthropic API rate limit (HTTP 429).', { cause: error });
47
+ }
48
+ throw error;
49
+ }
50
+ if (message.stop_reason === 'max_tokens')
51
+ throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
52
+ const text = replyText(message, 'The model declined the structured investigation call.');
53
+ if (!text)
54
+ throw new Error('The structured investigation call returned no text.');
55
+ return JSON.parse(text);
56
+ }
57
+ export const fileSelectSchema = {
58
+ type: 'object',
59
+ additionalProperties: false,
60
+ required: ['paths'],
61
+ properties: {
62
+ paths: {
63
+ type: 'array',
64
+ items: { type: 'string' },
65
+ description: `Up to ${MAX_CANDIDATE_FILES} repo file paths (verbatim from the tree) most likely to implement this module. Prefer source over tests/config. Empty if nothing in the tree plausibly implements it.`,
66
+ },
67
+ },
68
+ };
69
+ async function readLocalFile(repoPath, relPath) {
70
+ try {
71
+ const buf = await readFile(join(repoPath, relPath));
72
+ if (buf.byteLength > MAX_FILE_BYTES) {
73
+ console.warn(` [skip] ${relPath} is ${Math.round(buf.byteLength / 1024)}KB — over the ${MAX_FILE_BYTES / 1024}KB safety threshold, leaving it out rather than truncating it`);
74
+ return null;
75
+ }
76
+ return buf.toString('utf8');
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Runs the whole verify_module task locally and returns exactly what
83
+ * POST /api/companion/tasks/:id/result expects — `result` validated server-side against the
84
+ * exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
85
+ * provenance note. Throws on anything that should surface as a task error (caller reports it
86
+ * via POST .../error). */
87
+ export async function runVerifyModuleTask(callModel, task, repoPath) {
88
+ const payload = task.payload;
89
+ const spec = payload.spec;
90
+ const allFiles = await listRepoFiles(repoPath);
91
+ console.log(` scanning ${allFiles.length} files in ${repoPath}…`);
92
+ // Pass 1: which files implement this module? Reuses the module-spec text the server already
93
+ // wrote into spec.volatileText (buildVerifyModuleTaskSpec) as context — it already contains
94
+ // the full spec, so this doesn't need to re-derive it.
95
+ const selectPrompt = `${spec.volatileText}
96
+
97
+ # The repository file tree (${allFiles.length} files)
98
+
99
+ ${allFiles.join('\n')}
100
+
101
+ # Before judging
102
+
103
+ First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
104
+ likely to implement this module's behaviour. Prefer application source over tests, config, and
105
+ generated files. Empty list if nothing plausibly implements it.`;
106
+ const selectMsg = await callModel({
107
+ purpose: 'verify_module:file_select',
108
+ model: spec.model,
109
+ maxTokens: 2000,
110
+ effort: 'low',
111
+ system: spec.system,
112
+ stableText: '',
113
+ text: selectPrompt,
114
+ schema: fileSelectSchema,
115
+ });
116
+ const known = new Set(allFiles);
117
+ const chosen = JSON.parse(selectMsg.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
118
+ if (chosen.length === 0) {
119
+ return {
120
+ filesRead: [],
121
+ result: {
122
+ verdict: 'differs',
123
+ specSaid: 'See the module spec above.',
124
+ codeDid: 'No file in the local checkout appears to implement this module.',
125
+ differences: ['Nothing in the local checkout plausibly implements this spec — it may be unbuilt, or the code lives outside this checkout.'],
126
+ },
127
+ };
128
+ }
129
+ const files = (await Promise.all(chosen.map(async (p) => {
130
+ const content = await readLocalFile(repoPath, p);
131
+ return content !== null ? { path: p, content } : null;
132
+ }))).filter((f) => f !== null);
133
+ console.log(` reading ${files.map((f) => f.path).join(', ') || '(nothing readable)'}`);
134
+ // Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
135
+ // by the server unchanged — this is the exact same prompt+schema the server-execution path
136
+ // would use, just pointed at unbounded local file contents instead of truncated GitHub reads.
137
+ const verdictMsg = await callModel({
138
+ purpose: 'verify_module:judge',
139
+ model: spec.model,
140
+ maxTokens: spec.maxTokens,
141
+ effort: spec.effort,
142
+ system: spec.system,
143
+ stableText: spec.stableText,
144
+ text: `${spec.volatileText}\n\n# The actual code, as it currently stands locally\n\n${files.map((f) => `## ${f.path}\n\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')}`,
145
+ schema: spec.schema,
146
+ });
147
+ return { result: JSON.parse(verdictMsg.text), filesRead: files.map((f) => f.path) };
148
+ }
149
+ /* ------------------------------------------------------------------ generate_spec_batch */
150
+ /** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
151
+ * batch multiplies by however many modules it holds, but a real end-to-end test caught it
152
+ * under-reading: a module genuinely spread across companion/, server/, and src/ (this repo's
153
+ * own "Local Companion Agent" module, dogfooded during Phase 3 development) got 4 real files
154
+ * read and produced an accurate spec for what it saw, but never even considered
155
+ * server/companion.ts or src/CompanionModal.tsx — half the actual implementation. Comfortably
156
+ * beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
157
+ * ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
158
+ const MAX_FILES_PER_MODULE_IN_BATCH = 8;
159
+ export const batchFileSelectSchema = {
160
+ type: 'object',
161
+ additionalProperties: false,
162
+ required: ['selections'],
163
+ properties: {
164
+ selections: {
165
+ type: 'array',
166
+ description: 'One entry per module in the batch, in the same order.',
167
+ items: {
168
+ type: 'object',
169
+ additionalProperties: false,
170
+ required: ['moduleId', 'paths'],
171
+ properties: {
172
+ moduleId: { type: 'string' },
173
+ paths: {
174
+ type: 'array',
175
+ items: { type: 'string' },
176
+ description: `Up to ${MAX_FILES_PER_MODULE_IN_BATCH} repo file paths (verbatim from the tree) most likely to implement this module. Empty if nothing plausibly does.`,
177
+ },
178
+ },
179
+ },
180
+ },
181
+ },
182
+ };
183
+ /** Runs a whole generate_spec_batch task locally — writes full specs for every module in the
184
+ * batch (up to server/bootstrap.ts's BATCH_SIZE, currently 5) in one task. Same two-pass shape
185
+ * as runVerifyModuleTask (pick files, then use them), just per-module in pass 1 and producing
186
+ * the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
187
+ * purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
188
+ * server/bootstrap.ts) — this only adds the file tree and, in pass 2, what was actually read. */
189
+ export async function runGenerateSpecBatchTask(callModel, task, repoPath) {
190
+ const payload = task.payload;
191
+ const spec = payload.spec;
192
+ const moduleIds = payload.moduleIds;
193
+ if (payload.connectionless) {
194
+ // The PRD is already in spec.stableText (stableTextForLocalDispatch); there is nothing local to read.
195
+ const writeMsg = await callModel({
196
+ purpose: 'generate_spec_batch:write',
197
+ model: spec.model,
198
+ maxTokens: spec.maxTokens,
199
+ effort: spec.effort,
200
+ system: spec.system,
201
+ stableText: spec.stableText,
202
+ text: spec.volatileText,
203
+ schema: spec.schema,
204
+ });
205
+ return { result: JSON.parse(writeMsg.text), filesRead: [] };
206
+ }
207
+ const allFiles = await listRepoFiles(repoPath);
208
+ console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
209
+ // Pass 1: which files relate to each module in this batch?
210
+ const selectPrompt = `${spec.volatileText}
211
+
212
+ # The repository file tree (${allFiles.length} files)
213
+
214
+ ${allFiles.join('\n')}
215
+
216
+ # Before writing specs
217
+
218
+ For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
219
+ file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
220
+ in the same order as given.`;
221
+ const selectMsg = await callModel({
222
+ purpose: 'generate_spec_batch:file_select',
223
+ model: spec.model,
224
+ maxTokens: 3000,
225
+ effort: 'low',
226
+ system: spec.system,
227
+ stableText: '',
228
+ text: selectPrompt,
229
+ schema: batchFileSelectSchema,
230
+ });
231
+ const known = new Set(allFiles);
232
+ const selections = JSON.parse(selectMsg.text).selections;
233
+ const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
234
+ const fileMap = new Map();
235
+ await Promise.all(uniquePaths.map(async (p) => {
236
+ const content = await readLocalFile(repoPath, p);
237
+ if (content !== null)
238
+ fileMap.set(p, content);
239
+ }));
240
+ console.log(` reading ${[...fileMap.keys()].join(', ') || '(nothing readable)'}`);
241
+ // Pass 2: write the actual specs, grounded in what was read.
242
+ const filesSection = selections
243
+ .map((s) => {
244
+ const found = s.paths.filter((p) => fileMap.has(p));
245
+ return `## Files read for ${s.moduleId}\n\n${found.length ? found.map((p) => `### ${p}\n\`\`\`\n${fileMap.get(p)}\n\`\`\``).join('\n\n') : '(nothing readable found for this module)'}`;
246
+ })
247
+ .join('\n\n');
248
+ const writeMsg = await callModel({
249
+ purpose: 'generate_spec_batch:write',
250
+ model: spec.model,
251
+ maxTokens: spec.maxTokens,
252
+ effort: spec.effort,
253
+ system: spec.system,
254
+ stableText: spec.stableText,
255
+ text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}`,
256
+ schema: spec.schema,
257
+ });
258
+ return { result: JSON.parse(writeMsg.text), filesRead: [...fileMap.keys()] };
259
+ }
260
+ /* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
261
+ /**
262
+ * Both propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks — server
263
+ * sends stableText/volatileText built with NO repo content at all (see bootstrap.ts's
264
+ * stableTextForLocalDispatch / buildCapabilityScanVolatile's repoCtx-null branch), because
265
+ * there's nothing GitHub-sourced to send: the whole point of dispatching is that the companion
266
+ * reads the repo itself. Unlike verify_module/generate_spec_batch, there's no per-module drilling
267
+ * here — just one shallow local scan (file tree + README/CHANGELOG/package.json, mirroring
268
+ * server/bootstrap.ts's own RepoContext shape) appended after spec.volatileText, then one model
269
+ * call. filesRead is always empty for these two: neither result shape uses it (check
270
+ * server/index.ts's completeAiJob payloads for /api/setup/skeleton and
271
+ * /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
272
+ */
273
+ async function runShallowRepoTask(callModel, purpose, spec, repoPath, connectionless = false) {
274
+ let repoSection = '';
275
+ if (!connectionless) {
276
+ const repoCtx = await buildLocalRepoContext(repoPath);
277
+ console.log(` scanning ${repoCtx.fileList.length} files, README ${repoCtx.readme ? 'found' : 'not found'}, CHANGELOG ${repoCtx.changelog ? 'found' : 'not found'}, package.json ${repoCtx.packageJson ? 'found' : 'not found'}`);
278
+ repoSection = `
279
+ # Repository context (your local checkout)
280
+
281
+ ## File tree (${repoCtx.fileList.length} files)
282
+ ${repoCtx.fileList.join('\n')}
283
+
284
+ ## README
285
+ ${repoCtx.readme ?? '(none found)'}
286
+
287
+ ## CHANGELOG
288
+ ${repoCtx.changelog ?? '(none found)'}
289
+
290
+ ## package.json
291
+ ${repoCtx.packageJson ?? '(none found)'}
292
+ `.trim();
293
+ }
294
+ const message = await callModel({
295
+ purpose,
296
+ model: spec.model,
297
+ maxTokens: spec.maxTokens,
298
+ effort: spec.effort,
299
+ system: spec.system,
300
+ stableText: spec.stableText,
301
+ text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}`,
302
+ schema: spec.schema,
303
+ documentBase64: spec.stableDocumentBase64,
304
+ });
305
+ return { result: spec.schema ? JSON.parse(message.text) : message.text, filesRead: [] };
306
+ }
307
+ /** Forward-compatible fallback for server-created graph/spec reasoning tasks. The server sends
308
+ * the complete self-describing spec, so these calls never need or receive filesystem context. */
309
+ export async function runGenericTask(callModel, task, repoPath) {
310
+ const spec = task.payload?.spec;
311
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
312
+ throw new Error(`Cannot run task kind "${task.kind}": payload.spec is missing or not an object.`);
313
+ }
314
+ return runShallowRepoTask(callModel, 'generic', spec, repoPath, true);
315
+ }
316
+ export async function runProposeSkeletonTask(callModel, task, repoPath) {
317
+ const payload = task.payload;
318
+ return runShallowRepoTask(callModel, 'propose_skeleton', payload.spec, repoPath, payload.connectionless);
319
+ }
320
+ export async function runProposeNewCapabilitiesTask(callModel, task, repoPath) {
321
+ const payload = task.payload;
322
+ return runShallowRepoTask(callModel, 'propose_new_capabilities', payload.spec, repoPath);
323
+ }
324
+ /* --------------------------------------------------------------------- design_prompt */
325
+ /**
326
+ * The one task kind with no schema — spec.schema is undefined, so the model's raw text answer
327
+ * IS the result, never JSON.parse'd (unlike every other task kind here). Looks for local design
328
+ * signal (findDesignFiles, copied verbatim from server/design-context.ts — see design-context.ts's
329
+ * own comment) and appends it if found; spec.volatileText already carries the right instructions
330
+ * for both the "honor what you find" and "propose from scratch" cases (see export.ts's
331
+ * generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
332
+ * same `direction` the server path already respects, before this ever reaches the companion).
333
+ */
334
+ export async function runDesignPromptTask(callModel, task, repoPath) {
335
+ const payload = task.payload;
336
+ const spec = payload.spec;
337
+ const designContext = await buildLocalDesignContext(repoPath);
338
+ console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
339
+ const message = await callModel({
340
+ purpose: 'design_prompt',
341
+ model: spec.model,
342
+ maxTokens: spec.maxTokens,
343
+ effort: spec.effort,
344
+ system: spec.system,
345
+ stableText: spec.stableText,
346
+ text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
347
+ schema: spec.schema,
348
+ });
349
+ return { result: message.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
350
+ }
351
+ /* --------------------------------------------------------------------- analyse_shipment */
352
+ /**
353
+ * Runs a whole analyse_shipment task locally. Unlike every other task kind above, this one's
354
+ * "find the relevant content" step is git-level, not file-level (prepareShipmentAnalysis, see
355
+ * shipment.ts), and can conclude the task WITHOUT ever calling the model at all — a commit the
356
+ * companion's own local looksTrivial filter rejects is reported straight back as an empty
357
+ * result ({touched: [], possibleNewModules: []}), exactly the shape driftSchema already treats
358
+ * as a valid "nothing plausible here" answer, so there's no separate result shape to teach the
359
+ * server about. sha_not_local throws — the caller (cli.ts's runOneTask) reports it as a task
360
+ * error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
361
+ * (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
362
+ */
363
+ export async function runAnalyseShipmentTask(callModel, task, repoPath) {
364
+ const payload = task.payload;
365
+ const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
366
+ if (prep.outcome === 'sha_not_local') {
367
+ throw new Error(`sha_not_local: commit ${payload.sha} was not found in the local checkout at ${repoPath}, even after a git fetch.`);
368
+ }
369
+ if (prep.outcome === 'trivial') {
370
+ console.log(` ${payload.sha.slice(0, 7)} looks trivial (lockfile/CI-config/build-output only) — skipping the AI call`);
371
+ return { result: { touched: [], possibleNewModules: [] }, filesRead: [] };
372
+ }
373
+ console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
374
+ const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
375
+ const message = await callModel({
376
+ purpose: 'analyse_shipment',
377
+ model: spec.model,
378
+ maxTokens: spec.maxTokens,
379
+ effort: spec.effort,
380
+ system: spec.system,
381
+ stableText: spec.stableText,
382
+ text: spec.volatileText,
383
+ schema: spec.schema,
384
+ });
385
+ // filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
386
+ // dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
387
+ return { result: JSON.parse(message.text), filesRead: [] };
388
+ }
@@ -0,0 +1,122 @@
1
+ import crossSpawn from 'cross-spawn';
2
+ import { spawn as nodeSpawn } from 'node:child_process';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ const CMD_UNSAFE_ARGUMENT = /[\^<>|&%!]/;
6
+ const FALLBACK_ARGUMENT_ERROR = "claude resolves to a .cmd shim and this argument can't pass through cmd.exe intact; install Claude Code so claude.exe is reachable, or update the companion";
7
+ const defaultFs = { existsSync, readFileSync };
8
+ let cachedCommand = null;
9
+ function envValue(env, name) {
10
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
11
+ return key ? env[key] : undefined;
12
+ }
13
+ function windowsPathEntries(env) {
14
+ return (envValue(env, 'PATH') ?? '')
15
+ .split(';')
16
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
17
+ .filter(Boolean);
18
+ }
19
+ function windowsExtensions(env) {
20
+ const pathExt = envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
21
+ return pathExt
22
+ .split(';')
23
+ .map((extension) => extension.trim().toLowerCase())
24
+ .filter(Boolean)
25
+ .map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
26
+ }
27
+ function existingCandidate(directory, extension, fs) {
28
+ const lowerCaseCandidate = join(directory, `claude${extension.toLowerCase()}`);
29
+ if (fs.existsSync(lowerCaseCandidate))
30
+ return resolve(lowerCaseCandidate);
31
+ const originalCaseCandidate = join(directory, `claude${extension}`);
32
+ return fs.existsSync(originalCaseCandidate) ? resolve(originalCaseCandidate) : null;
33
+ }
34
+ function parsedShimExecutable(shimPath, fs) {
35
+ let contents;
36
+ try {
37
+ contents = fs.readFileSync(shimPath, 'utf8');
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ const shimDirectory = dirname(shimPath);
43
+ for (const match of contents.matchAll(/"([^"\r\n]*claude\.exe)"/gi)) {
44
+ let target = match[1].replace(/%(?:~dp0|dp0)%?/gi, shimDirectory);
45
+ // Tests inject win32 while running on Linux CI; normalizing both separator forms keeps the
46
+ // resolver testable with a real temporary filesystem and is a no-op for native Windows paths.
47
+ target = target.replace(/[\\/]/g, sep);
48
+ if (!isAbsolute(target))
49
+ target = resolve(shimDirectory, target);
50
+ if (fs.existsSync(target))
51
+ return resolve(target);
52
+ }
53
+ return null;
54
+ }
55
+ function executableForShim(shimPath, fs) {
56
+ const parsed = parsedShimExecutable(shimPath, fs);
57
+ if (parsed)
58
+ return parsed;
59
+ const conventional = join(dirname(shimPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
60
+ return fs.existsSync(conventional) ? resolve(conventional) : null;
61
+ }
62
+ function resolveUncached(platform, env, fs) {
63
+ if (platform !== 'win32') {
64
+ return { command: { command: 'claude', viaShell: true }, fallbackReason: null };
65
+ }
66
+ const directories = windowsPathEntries(env);
67
+ const extensions = windowsExtensions(env);
68
+ for (const directory of directories) {
69
+ for (const extension of extensions) {
70
+ if (extension !== '.cmd' && extension !== '.exe')
71
+ continue;
72
+ const candidate = existingCandidate(directory, extension, fs);
73
+ if (!candidate)
74
+ continue;
75
+ if (extension === '.exe') {
76
+ return { command: { command: candidate, viaShell: false }, fallbackReason: null };
77
+ }
78
+ const executable = executableForShim(candidate, fs);
79
+ if (executable) {
80
+ return { command: { command: executable, viaShell: false }, fallbackReason: null };
81
+ }
82
+ return {
83
+ command: { command: 'claude', viaShell: true },
84
+ fallbackReason: `Found ${candidate}, but its claude.exe target was not reachable; falling back to the .cmd shim.`,
85
+ };
86
+ }
87
+ }
88
+ return {
89
+ command: { command: 'claude', viaShell: true },
90
+ fallbackReason: 'No claude.exe or claude.cmd was found on PATH; falling back to cross-spawn command lookup.',
91
+ };
92
+ }
93
+ /** Resolve once in production. Injected dependencies bypass the cache so Linux CI can exercise
94
+ * the Windows lookup rules without mutating process globals or depending on Windows filesystem
95
+ * semantics. */
96
+ export function resolveClaudeCommand(options = {}) {
97
+ const injected = options.platform !== undefined || options.env !== undefined || options.fs !== undefined;
98
+ if (!injected && cachedCommand)
99
+ return cachedCommand;
100
+ const result = resolveUncached(options.platform ?? process.platform, options.env ?? process.env, options.fs ?? defaultFs);
101
+ if (!injected) {
102
+ cachedCommand = result.command;
103
+ if (result.fallbackReason)
104
+ console.warn(`[truing] ${result.fallbackReason}`);
105
+ }
106
+ return result.command;
107
+ }
108
+ export function assertClaudeArgumentsSafeForFallback(args, platform = process.platform) {
109
+ if (platform === 'win32' && args.some((argument) => CMD_UNSAFE_ARGUMENT.test(argument))) {
110
+ throw new Error(FALLBACK_ARGUMENT_ERROR);
111
+ }
112
+ }
113
+ /** Native executables go straight through CreateProcess via Node. cross-spawn remains only for
114
+ * the unresolved command-name path, where it supplies the existing .cmd compatibility behavior. */
115
+ export function spawnClaude(args, options = {}, resolution = resolveClaudeCommand()) {
116
+ const hiddenOptions = { ...options, windowsHide: true };
117
+ if (resolution.viaShell) {
118
+ assertClaudeArgumentsSafeForFallback(args);
119
+ return crossSpawn(resolution.command, args, hiddenOptions);
120
+ }
121
+ return nodeSpawn(resolution.command, args, hiddenOptions);
122
+ }