jeo-code 0.12.16 → 0.12.18

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.
@@ -162,20 +162,29 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
162
162
  return effect.result.promise;
163
163
  }
164
164
 
165
+ /** A Windows host path that matches no mount is not "relative" to the Linux guest; it must never be forwarded. */
165
166
  function guestPath(value: string): string {
166
- if (!path.isAbsolute(value)) return value;
167
- const mount = [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value));
168
- if (!mount) return value;
169
- const relative = path.relative(mount.host, value).split(path.sep).join("/");
170
- return relative ? `${mount.guest}/${relative}` : mount.guest;
167
+ const mount = path.isAbsolute(value) ? [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value)) : undefined;
168
+ if (mount) {
169
+ const relative = path.relative(mount.host, value).split(path.sep).join("/");
170
+ return relative ? `${mount.guest}/${relative}` : mount.guest;
171
+ }
172
+ if (process.platform === "win32" && /^(?:[A-Za-z]:|[\\/]{2}|\\)/.test(value)) throw new Error("SANDBOX_POLICY: host path is outside approved mounts");
173
+ return value;
171
174
  }
172
175
 
173
176
  function call(tool: string, args: Record<string, unknown>, callOptions?: SandboxCallOptions): Promise<ToolResult> {
174
177
  const mapped = { ...args };
175
- for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
176
- if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
178
+ let cwd: string | undefined;
179
+ try {
180
+ for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
181
+ if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
182
+ }
183
+ if (callOptions?.cwd) cwd = guestPath(callOptions.cwd);
184
+ } catch (error) {
185
+ return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) });
177
186
  }
178
- return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(callOptions?.cwd ? { cwd: guestPath(callOptions.cwd) } : {}) }, callOptions);
187
+ return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(cwd ? { cwd } : {}) }, callOptions);
179
188
  }
180
189
 
181
190
  return {
@@ -184,7 +193,10 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
184
193
  return Object.fromEntries(names.map(name => [name, (args: Record<string, unknown>, cwd: string, onProgress?: (text: string) => void) => call(name, args, { cwd, onProgress })]));
185
194
  },
186
195
  exec(argv: readonly string[], execOptions: SandboxExecOptions = {}): Promise<SandboxExecResult> {
187
- return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(execOptions.cwd ? { cwd: guestPath(execOptions.cwd) } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
196
+ let cwd: string | undefined;
197
+ try { if (execOptions.cwd) cwd = guestPath(execOptions.cwd); }
198
+ catch (error) { return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) }); }
199
+ return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(cwd ? { cwd } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
188
200
  },
189
201
  async replacePolicy(next): Promise<void> {
190
202
  if (state === "closed" || state === "changing" || next.sessionKey !== policy.sessionKey || next.revision <= policy.revision) throw new Error("SANDBOX_POLICY: closed, changing or invalid policy revision");
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Session-start model resolution against the models jeo is actually logged in to.
3
+ *
4
+ * A session opens on the configured default model. That default is honored only
5
+ * when the stored login can serve it; otherwise the first turn would fail with
6
+ * "No credential for provider …" even though another logged-in model works. In that
7
+ * case the session starts on the first authenticated candidate (recents, then role
8
+ * tiers) and says so. The saved config is never rewritten here: the fallback is
9
+ * session-local, so the configured default is untouched.
10
+ *
11
+ * Fail-open: a probe that throws keeps the configured default.
12
+ */
13
+ import { describeModel, modelServableWithConfig } from "../ai/model-manager";
14
+ import { describeProvider } from "../ai/provider-status";
15
+ import { customProviderDef } from "../ai/providers/custom-providers";
16
+ import type { Config } from "./state";
17
+
18
+ export type ModelAuthCheck = (model: string, cfg: Config) => Promise<boolean>;
19
+
20
+ export interface StartupModel {
21
+ /** The model the session should treat as its default. */
22
+ model: string;
23
+ /** The configured default this replaced, when no login could serve it. */
24
+ fellBackFrom?: string;
25
+ /** One-line explanation for the user, present only when a fallback happened. */
26
+ notice?: string;
27
+ }
28
+
29
+ /**
30
+ * Whether jeo's stored credentials can serve `model` right now. Same two gates
31
+ * routing uses: the provider has a live credential, and, for built-in providers,
32
+ * that credential serves this specific model (ChatGPT OAuth serves only Codex ids).
33
+ * agy runs as an external CLI session and custom providers resolve their own key,
34
+ * so for those only provider readiness applies. Local providers are keyless.
35
+ */
36
+ export const isModelAuthenticated: ModelAuthCheck = async (model, cfg) => {
37
+ const { resolved, provider } = await describeModel(model, cfg);
38
+ const status = await describeProvider(provider, cfg);
39
+ if (!status.ready) return false;
40
+ if (provider === "agy" || customProviderDef(provider)) return true;
41
+ return modelServableWithConfig(provider, resolved, cfg);
42
+ };
43
+
44
+ const FALLBACK_ROLES = ["medium", "high", "smol", "xhigh", "slow", "plan"] as const;
45
+
46
+ /** Fallback order: the user's recently picked models, then their configured role tiers. */
47
+ function fallbackCandidates(cfg: Config): string[] {
48
+ const ordered = [...(cfg.recentModels ?? []), ...FALLBACK_ROLES.map(role => cfg.roles?.[role])];
49
+ const seen = new Set<string>([cfg.defaultModel]);
50
+ const out: string[] = [];
51
+ for (const model of ordered) {
52
+ if (typeof model !== "string" || !model.trim() || seen.has(model)) continue;
53
+ seen.add(model);
54
+ out.push(model);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Resolve the model a new session should open on: the configured default when the
61
+ * login serves it, else the first authenticated fallback, else the configured
62
+ * default unchanged (the first turn then reports the real credential error).
63
+ */
64
+ export async function resolveStartupDefaultModel(cfg: Config, check: ModelAuthCheck = isModelAuthenticated): Promise<StartupModel> {
65
+ const configured = cfg.defaultModel;
66
+ try {
67
+ if (await check(configured, cfg)) return { model: configured };
68
+ } catch {
69
+ return { model: configured };
70
+ }
71
+ for (const candidate of fallbackCandidates(cfg)) {
72
+ let ok = false;
73
+ try { ok = await check(candidate, cfg); } catch { ok = false; }
74
+ if (!ok) continue;
75
+ let provider = "its provider";
76
+ try { provider = (await describeModel(configured, cfg)).provider; } catch { /* keep the generic wording */ }
77
+ return {
78
+ model: candidate,
79
+ fellBackFrom: configured,
80
+ notice: `[model] default '${configured}' has no usable ${provider} login; this session starts on authenticated '${candidate}'. Run 'jeo auth login ${provider}' to restore it, or /model to pick another.`,
81
+ };
82
+ }
83
+ return { model: configured };
84
+ }
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import * as cp from "node:child_process";
35
35
  import * as fs from "node:fs";
36
+ import { resolvePosixTool } from "../util/posix-tools";
36
37
  import * as os from "node:os";
37
38
  import * as path from "node:path";
38
39
  import { Readable } from "node:stream";
@@ -96,7 +97,7 @@ export function spawnLeasedShell(command: string, cwd: string): LeasedShell {
96
97
  }
97
98
 
98
99
  function spawnDirect(command: string, cwd: string): LeasedShell {
99
- const proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
100
+ const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
100
101
  const stdout = proc.stdout.getReader();
101
102
  const stderr = proc.stderr.getReader();
102
103
  const reaped = proc.exited.then(() => undefined);
@@ -467,6 +467,7 @@ export async function runSubagentOnce(
467
467
  const failoverEnabled = !!opts.config.routing?.enabled;
468
468
  let activeModel = initialModel;
469
469
  emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
470
+ let contractCorrections = 0;
470
471
  const runOnce = () => {
471
472
  return runAgentLoop(history, {
472
473
  cwd,
@@ -474,14 +475,27 @@ export async function runSubagentOnce(
474
475
  maxSteps,
475
476
  maxTokens: resolveMaxOutputTokens(activeModel, thinking),
476
477
  sessionKey,
477
- // Bounded delegation: a subagent's step contract stays exact — the parent
478
- // owns any retry/extension decision, so the gjc retry flow is disabled here.
479
- budget: { maxExtensions: 0 },
478
+ // Allow bounded step-budget extensions when a subagent is actively making
479
+ // progress so multi-file edits + test verification do not get cut off mid-run.
480
+ budget: { maxExtensions: role.readOnly ? 1 : 2 },
480
481
  signal,
481
482
  steer,
482
483
  tools: subagentToolset(role),
483
484
  ...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
484
485
  events: {
486
+ onBeforeDone: reason => {
487
+ const hasToolActivity = fileMutations > 0 || bashRuns > 0 || readOnlyEvidenceCalls > 0;
488
+ if (!hasToolActivity || contractCorrections >= 1) return null;
489
+ const check = validateSubagentDoneReason(role, reason);
490
+ if (!check.ok) {
491
+ contractCorrections++;
492
+ return (
493
+ `Your done.reason is missing required section(s) for ${role.title}: ${check.missing?.join(", ")}. ` +
494
+ `Call done again with non-empty content under every required section header.`
495
+ );
496
+ }
497
+ return null;
498
+ },
485
499
  onEvidence: evidence => {
486
500
  if (evidence.sawMutation) mutationMayHaveOccurred = true;
487
501
  },
@@ -1,4 +1,5 @@
1
1
  import { applyBashFixups } from "./bash-fixups";
2
+ import { resolvePosixTool } from "../util/posix-tools";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { readWorkflowStateStrict, type WorkflowState } from "./state";
@@ -727,7 +728,7 @@ export async function bashTool(
727
728
  const reapBackground = isBackgroundReapEnabled();
728
729
  const grouped = reapBackground && process.platform !== "win32";
729
730
  if (grouped) ensureBackgroundSweeper();
730
- const proc = Bun.spawn(["bash", "-c", command], {
731
+ const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], {
731
732
  cwd: runCwd,
732
733
  // Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
733
734
  // a REPL, an interactive prompt) then gets immediate EOF and exits instead of
@@ -439,7 +439,7 @@ export function captureLivePromptInputChunk(
439
439
  const plain = start === -1 ? rest : rest.slice(0, start);
440
440
  if (start !== -1) state.inPaste = true;
441
441
  rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
442
- if (feedLivePromptSegment(state, plain)) accepted = true;
442
+ if (feedLivePromptSegment(state, decodeExtendedKeys(stripMouseReports(plain)))) accepted = true;
443
443
  }
444
444
  }
445
445
  return accepted;
@@ -506,10 +506,17 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
506
506
  opts.onBufferedInput?.(raw);
507
507
  return;
508
508
  }
509
+ // Strip mouse reports and terminal capability replies (OSC/CSI device attributes)
510
+ // first so terminal responses arriving mid-turn never leak into the live draft.
511
+ const stripped = stripMouseReports(raw);
512
+ if (!stripped && raw.length > 0) {
513
+ opts.onNoise?.();
514
+ return;
515
+ }
509
516
  // Kitty/modifyOtherKeys re-encodings → legacy bytes FIRST, so Esc (`CSI 27u`) and
510
517
  // Ctrl+C (`CSI 99;5u` / `CSI 27;5;99~`) cancel a live turn on every terminal —
511
518
  // previously they fell into the "noise" branch and did NOTHING mid-turn.
512
- const text = decodeExtendedKeys(raw);
519
+ const text = decodeExtendedKeys(stripped);
513
520
  if (text === "\u000f") {
514
521
  opts.onDetailKey?.();
515
522
  return;
@@ -579,7 +586,22 @@ export function classifyMidTurnLine(line: string): "command" | "steer" | "empty"
579
586
  // A lone sigil ("/" or "$") has no command name — ignore it instead of aborting the
580
587
  // running turn to dispatch an empty command (a stray slash should not interrupt work).
581
588
  if (t === "/" || t === "$") return "empty";
582
- return /^[/$]/.test(t) ? "command" : "steer";
589
+ const firstToken = t.split(/\s+/, 1)[0] ?? "";
590
+ if (firstToken.startsWith("/")) {
591
+ // A slash command is a single leading token (/model, /help, /?); a token containing
592
+ // additional slashes (/Users/foo/bar, /tmp/a.ts, //comment) is a path or comment,
593
+ // never a REPL command that should abort the running turn.
594
+ const name = firstToken.slice(1);
595
+ if (!name || name.includes("/")) return "steer";
596
+ return /^[a-zA-Z?][a-zA-Z0-9_-]*$/.test(name) ? "command" : "steer";
597
+ }
598
+ if (firstToken.startsWith("$")) {
599
+ // A skill invocation starts with a skill identifier ($team, $ralplan), not a
600
+ // numeric dollar amount ($100) or path ($/foo).
601
+ const name = firstToken.slice(1);
602
+ return /^[a-zA-Z][a-zA-Z0-9_:-]*$/.test(name) ? "command" : "steer";
603
+ }
604
+ return "steer";
583
605
  }
584
606
  /** Default window (ms) over which the several delivery paths of ONE physical Ctrl+C
585
607
  * press — readline 'keypress', the rl/process 'SIGINT' event, and the raw \u0003 stdin
@@ -188,8 +188,6 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
188
188
  history.length = 1;
189
189
  for (const m of messages) history.push(m);
190
190
  sessionId = rid;
191
- // Restore the model this session was last using (per-session model).
192
- if (header.model) { sessionModel = header.model; sessionModelChanged = true; }
193
191
  // Restore the unsent input-box draft left over at last save, if any.
194
192
  if (header.draft) draft = header.draft;
195
193
 
@@ -66,6 +66,7 @@ import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
66
66
  import { runAgySessionTurn } from "./launch/agy-turn";
67
67
  import { listProviderModels } from "../ai/model-discovery";
68
68
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
69
+ import { resolveStartupDefaultModel } from "../agent/session-model";
69
70
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
70
71
  import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
71
72
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
@@ -544,7 +545,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
544
545
  }
545
546
  } catch {}
546
547
  const cfg = await readGlobalConfig();
547
- const defaultModel = cfg.defaultModel;
548
548
  let initialSessionModel =
549
549
  flags.model ??
550
550
  (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? providerDefaultModel(flags.provider) : undefined);
@@ -564,6 +564,11 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
564
564
  return;
565
565
  }
566
566
  }
567
+ // A session always opens on the configured default model, or, when the stored login cannot
568
+ // serve it, on the first authenticated model (session-local; the saved default is untouched).
569
+ // An explicit --model/--model-role/--provider pin takes precedence and skips this.
570
+ const startupModel = initialSessionModel === undefined ? await resolveStartupDefaultModel(cfg) : { model: cfg.defaultModel, notice: undefined };
571
+ const defaultModel = startupModel.model;
567
572
 
568
573
  if (flags.tmux) {
569
574
  if (!process.env.TMUX && jeoEnv("TMUX_LAUNCHED") !== "1") {
@@ -987,7 +992,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
987
992
  const { header, messages } = await loadSession(id, cwd);
988
993
  for (const m of messages) history.push(m);
989
994
  sessionId = id;
990
- if (!initialSessionModel && header.model) sessionModel = header.model;
991
995
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
992
996
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
993
997
  // Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
@@ -1049,9 +1053,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1049
1053
  const { header, messages } = await loadSession(id, cwd);
1050
1054
  for (const m of messages) history.push(m);
1051
1055
  sessionId = id;
1052
- // Restore the model this session was last using unless the CLI explicitly
1053
- // pinned one (flags.model/role/provider → initialSessionModel wins).
1054
- if (!initialSessionModel && header.model) sessionModel = header.model;
1055
1056
  // Restore the unsent input-box draft (gajae-code parity) — see
1056
1057
  // startupDraft's own doc comment for why this path can't feed
1057
1058
  // queuedPromptInput.partial directly yet.
@@ -1967,7 +1968,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
1967
1968
  if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
1968
1969
  return (
1969
1970
  `Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
1970
- `Run a relevant test/build/typecheck/lint after the last change, then call done again.`
1971
+ `Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
1971
1972
  );
1972
1973
  }
1973
1974
 
@@ -2497,6 +2498,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2497
2498
  };
2498
2499
 
2499
2500
  if (isOneShot) {
2501
+ if (startupModel.notice) console.error(startupModel.notice);
2500
2502
  // A one-shot run has ~17 exit points below. The session notify endpoint (a
2501
2503
  // listening socket + a discovery file the Telegram daemon polls) is created for
2502
2504
  // the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
@@ -2843,6 +2845,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2843
2845
  if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
2844
2846
  else console.log(renderWelcome(welcomeData).join("\n"));
2845
2847
  }
2848
+ if (startupModel.notice) console.log(chalk.yellow(startupModel.notice));
2846
2849
 
2847
2850
 
2848
2851
 
@@ -3177,7 +3180,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
3177
3180
  queueBusySnapshot = () => ({
3178
3181
  text: queuedPromptInput.partial,
3179
3182
  });
3180
- queueBusyClear = () => { queuedPromptInput.partial = ""; };
3183
+ queueBusyClear = () => {
3184
+ queuedPromptInput.partial = "";
3185
+ const r = rl as unknown as { line?: string; cursor?: number };
3186
+ if (typeof r.line === "string") {
3187
+ r.line = "";
3188
+ r.cursor = 0;
3189
+ }
3190
+ };
3181
3191
  queueBusyCommand = (line: string) => {
3182
3192
  // NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
3183
3193
  // default routes to the immediate-dispatch queue served at the top of the loop.
@@ -80,15 +80,28 @@ export async function runConsensusCriticGate(args: {
80
80
  // is gated the SAME way: zero observed read/search/find/ast_grep/lsp calls means
81
81
  // the [OKAY]/[ITERATE]/[REJECT] text is unevidenced regardless of what it claims.
82
82
  let evidenceCalls = 0;
83
+ let contractCorrections = 0;
83
84
  const runOnce = () => runAgentLoop(history, {
84
85
  cwd: args.cwd,
85
86
  model: activeModel,
86
87
  maxSteps,
87
- budget: { maxExtensions: 0 },
88
+ budget: { maxExtensions: 1 },
88
89
  signal: args.signal,
89
90
  tools: subagentToolset(role),
90
91
  rateLimitFallbackAvailable,
91
92
  events: {
93
+ onBeforeDone: reason => {
94
+ if (evidenceCalls === 0 || contractCorrections >= 1) return null;
95
+ const check = validateSubagentDoneReason(role, reason);
96
+ if (!check.ok) {
97
+ contractCorrections++;
98
+ return (
99
+ `Your critic report is missing required section(s): ${check.missing?.join(", ")}. ` +
100
+ `Call done again starting with [OKAY], [ITERATE], or [REJECT] and include a non-empty 'Justification:' section.`
101
+ );
102
+ }
103
+ return null;
104
+ },
92
105
  onToolResult: (tool, success) => {
93
106
  if (success && (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp")) evidenceCalls++;
94
107
  },
@@ -23,7 +23,7 @@ Bundled system prompts, role definitions, and workflow skill documentation for t
23
23
  - Do not remove existing constraints without explicit reason.
24
24
 
25
25
  ### Testing Requirements
26
- - Prompt changes should be verified via end-to-end testing or `bun run run-evolution.sh`.
26
+ - Prompt changes should be verified via end-to-end testing.
27
27
 
28
28
  ### Common Patterns
29
29
  - XML-like tags for structural organization within prompts.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * POSIX tool resolution for Windows hosts.
3
+ *
4
+ * `Bun.spawn(["bash"|"grep"|"find", …])` resolves from PATH, where Windows
5
+ * ships imposters ahead of Git for Windows:
6
+ * - `C:\Windows\System32\bash.exe` launches WSL — Windows paths
7
+ * (`C:\Users\…`) do not exist inside it and caller env vars are dropped.
8
+ * - `C:\Windows\System32\find.exe` is the legacy DOS FIND (substring
9
+ * filter), not POSIX find.
10
+ *
11
+ * Resolve to real Git for Windows binaries instead, with an env override.
12
+ * Non-Windows platforms pass the bare name through unchanged.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export type PosixTool = "bash" | "grep" | "find";
18
+
19
+ export interface ResolveOptions {
20
+ /** Platform override for tests (default: process.platform). */
21
+ readonly platform?: NodeJS.Platform;
22
+ /** Env override for tests (default: process.env). */
23
+ readonly env?: Record<string, string | undefined>;
24
+ /** File-existence probe override for tests (default: fs.existsSync). */
25
+ readonly exists?: (p: string) => boolean;
26
+ }
27
+
28
+ /** Candidate absolute paths for a tool, in preference order. */
29
+ export function candidatePaths(
30
+ tool: PosixTool,
31
+ env: Record<string, string | undefined>
32
+ ): string[] {
33
+ const override = env[`JOC_${tool.toUpperCase()}_PATH`];
34
+ const roots = [
35
+ env["ProgramFiles"] && path.join(env["ProgramFiles"], "Git"),
36
+ env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "Git"),
37
+ env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Programs", "Git"),
38
+ ].filter((r): r is string => Boolean(r));
39
+ // bash lives in both bin/ and usr/bin/; grep/find only in usr/bin/.
40
+ const subdirs = tool === "bash" ? ["bin", path.join("usr", "bin")] : [path.join("usr", "bin")];
41
+ return [
42
+ ...(override ? [override] : []),
43
+ ...roots.flatMap(root => subdirs.map(sub => path.join(root, sub, `${tool}.exe`))),
44
+ ];
45
+ }
46
+
47
+ const cache = new Map<PosixTool, string>();
48
+
49
+ /**
50
+ * Resolve `tool` to a Unix-semantics executable. Returns an absolute Git
51
+ * for Windows path when found, otherwise the bare name (PATH fallback).
52
+ */
53
+ export function resolvePosixTool(tool: PosixTool, opts: ResolveOptions = {}): string {
54
+ const platform = opts.platform ?? process.platform;
55
+ if (platform !== "win32") return tool;
56
+ const injected = opts.env || opts.exists;
57
+ if (!injected) {
58
+ const hit = cache.get(tool);
59
+ if (hit) return hit;
60
+ }
61
+ const env = opts.env ?? process.env;
62
+ const exists = opts.exists ?? existsSync;
63
+ const found = candidatePaths(tool, env).find(c => exists(c)) ?? tool;
64
+ if (!injected) cache.set(tool, found);
65
+ return found;
66
+ }