@adhdev/daemon-core 0.9.82-rc.333 → 0.9.82-rc.334

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.
@@ -82,7 +82,24 @@ export declare class MeshRuntimeStore {
82
82
  sessionId?: string;
83
83
  message: string;
84
84
  }>;
85
- findAssignedBySession(meshId: string, sessionId: string, occurredAtIso?: string): MeshWorkQueueEntry | null;
85
+ /**
86
+ * Resolve the `assigned` queue row a completion event belongs to.
87
+ *
88
+ * Clock-skew safety (C2): a completion event's `occurredAtIso` carries the
89
+ * REMOTE WORKER's clock, while `updated_at` carries the COORDINATOR's clock
90
+ * (set at assignment and re-bumped on every mutation). For a remote node,
91
+ * coordinator-clock > worker-clock skew used to make an `updated_at <= occurredAt`
92
+ * filter return nothing, stranding the finished task as `assigned` forever.
93
+ *
94
+ * We therefore NEVER filter completion-matching on the mutable `updated_at`:
95
+ * 1. If `taskId` is given, match the exact `assigned` row by id (no time filter).
96
+ * 2. Otherwise a session holds at most one `assigned` task — match it without a
97
+ * time filter. If several exist (shouldn't normally), disambiguate by the
98
+ * IMMUTABLE `dispatchTimestamp`: latest `dispatchTimestamp <= occurredAt`,
99
+ * and if skew makes ALL of them later than `occurredAt`, fall back to the
100
+ * most-recent `dispatchTimestamp` rather than returning null.
101
+ */
102
+ findAssignedBySession(meshId: string, sessionId: string, occurredAtIso?: string, taskId?: string): MeshWorkQueueEntry | null;
86
103
  private toRow;
87
104
  insertDirectDispatch(entry: {
88
105
  taskId: string;
@@ -225,6 +225,7 @@ export declare function requeueTask(meshId: string, taskId: string, opts?: {
225
225
  */
226
226
  export declare function updateSessionTaskStatus(meshId: string, sessionId: string, status: MeshTaskStatus, opts?: {
227
227
  occurredAt?: string;
228
+ taskId?: string;
228
229
  }): MeshWorkQueueEntry | null;
229
230
  /**
230
231
  * M1-3: true when at least one pending task is waiting on the given task.
@@ -194,6 +194,7 @@ export declare const MESH_REFINE_CONFIG_SCHEMA: {
194
194
  };
195
195
  };
196
196
  export declare function isMeshConfigRecord(value: unknown): value is Record<string, unknown>;
197
+ export declare function tokenizeCommandString(command: string): string[] | null;
197
198
  export declare function normalizeMeshCommandConfig(entry: unknown, source: string): {
198
199
  command?: MeshRefineValidationCommandPlan;
199
200
  rejected?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.333",
3
+ "version": "0.9.82-rc.334",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.333",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.334",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -93,6 +93,7 @@ import * as fs from 'fs';
93
93
  import { execFileSync } from 'node:child_process';
94
94
  import { normalizeInteractivePromptResponse } from '../providers/types/interactive-prompt.js';
95
95
  import { workingDirBasename } from '../providers/working-dir.js';
96
+ import { resolveWin32Executable } from '../cli-adapters/resolve-executable.js';
96
97
 
97
98
  type ReleaseChannel = 'stable' | 'preview';
98
99
  const CHANNEL_NPM_TAG: Record<ReleaseChannel, 'latest' | 'next'> = { stable: 'latest', preview: 'next' };
@@ -1675,6 +1676,8 @@ type MeshRefineValidationSummary = {
1675
1676
  skippedReason?: string;
1676
1677
  failureKind?: string;
1677
1678
  failureCode?: string;
1679
+ /** Human-readable cause when failureKind === 'spawn_resolution_failed' (win32 .cmd shim, etc). */
1680
+ spawnResolutionError?: string;
1678
1681
  timeoutMs: number;
1679
1682
  outputLimitBytes: number;
1680
1683
  configSource?: string;
@@ -1895,6 +1898,32 @@ function truncateValidationOutput(value: unknown): string {
1895
1898
  return `${text.slice(0, REFINE_VALIDATION_SUMMARY_CHARS)}\n[truncated ${text.length - REFINE_VALIDATION_SUMMARY_CHARS} chars]`;
1896
1899
  }
1897
1900
 
1901
+ /**
1902
+ * A spawn-resolution failure is when the executable itself could not be found by
1903
+ * the OS spawn boundary — `spawn <cmd> ENOENT` — as opposed to the command
1904
+ * running and exiting non-zero. On win32 this is the .cmd-shim case: libuv's
1905
+ * spawn search appends only .com/.exe, so a bare `npm`/`npx`/`tsc` (which are
1906
+ * .cmd shims) ENOENTs even though it is installed. It carries no stderr, so it
1907
+ * must be detected by error.code/syscall, not by string-matching output.
1908
+ */
1909
+ export function isSpawnResolutionError(error: any): boolean {
1910
+ if (!error) return false;
1911
+ if (error.code === 'ENOENT' && typeof error.syscall === 'string' && error.syscall.startsWith('spawn')) return true;
1912
+ // Fall back to code alone: execFile sets syscall on the spawn boundary error,
1913
+ // but guard for environments/mocks that only surface the code.
1914
+ return error.code === 'ENOENT' && (error.syscall === undefined || String(error.syscall).startsWith('spawn'));
1915
+ }
1916
+
1917
+ export function describeSpawnError(error: any, command: string, spawnResolutionFailed: boolean): string {
1918
+ if (spawnResolutionFailed) {
1919
+ const hint = process.platform === 'win32'
1920
+ ? ' On Windows, npm-family commands (npm/npx/tsc/vitest) are .cmd shims that the bare-command spawn search does not resolve; configure an absolute path or ensure the command is on PATH.'
1921
+ : '';
1922
+ return `Could not resolve executable "${command}" (spawn ENOENT).${hint}`;
1923
+ }
1924
+ return String(error?.message || error);
1925
+ }
1926
+
1898
1927
  function recordMeshRefineStage(
1899
1928
  stages: Array<Record<string, unknown>>,
1900
1929
  stage: string,
@@ -3119,8 +3148,13 @@ async function runMeshRefineValidationGate(
3119
3148
  const startedAt = Date.now();
3120
3149
  const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
3121
3150
  const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
3151
+ // On win32, libuv's spawn search only appends .com/.exe (not .cmd/.bat),
3152
+ // so a bare `npm`/`npx`/`tsc` (which are .cmd shims) throws spawn ENOENT.
3153
+ // Resolve to an absolute path via the same helper the PTY path uses
3154
+ // (no-op on non-win32 and when the command is already absolute).
3155
+ const resolvedCommand = resolveWin32Executable(candidate.command);
3122
3156
  try {
3123
- const result = await execFileAsync(candidate.command, candidate.args, {
3157
+ const result = await execFileAsync(resolvedCommand, candidate.args, {
3124
3158
  cwd,
3125
3159
  encoding: 'utf8',
3126
3160
  timeout,
@@ -3129,16 +3163,19 @@ async function runMeshRefineValidationGate(
3129
3163
  });
3130
3164
  summary.bootstrapCommandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
3131
3165
  } catch (error: any) {
3166
+ const spawnResolutionFailed = isSpawnResolutionError(error);
3132
3167
  summary.bootstrapCommandsRun.push(commandRecord(candidate, cwd, startedAt, error, false, {
3133
3168
  exitCode: typeof error?.code === 'number' ? error.code : null,
3134
3169
  signal: typeof error?.signal === 'string' ? error.signal : null,
3135
3170
  timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
3136
- failureKind: 'dependency_bootstrap_failed',
3171
+ ...(spawnResolutionFailed
3172
+ ? { failureKind: 'spawn_resolution_failed', resolvedCommand }
3173
+ : { failureKind: 'dependency_bootstrap_failed' }),
3137
3174
  }));
3138
- summary.bootstrap = { stage: 'failed', error: String(error?.message || error) };
3175
+ summary.bootstrap = { stage: 'failed', error: describeSpawnError(error, candidate.command, spawnResolutionFailed) };
3139
3176
  summary.status = 'failed';
3140
- summary.failureKind = 'dependency_bootstrap_failed';
3141
- summary.failureCode = 'dependency_bootstrap_failed';
3177
+ summary.failureKind = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
3178
+ summary.failureCode = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
3142
3179
  return summary;
3143
3180
  }
3144
3181
  }
@@ -3162,8 +3199,11 @@ async function runMeshRefineValidationGate(
3162
3199
  summary.failureCode = 'missing_dependencies';
3163
3200
  return summary;
3164
3201
  }
3202
+ // See the bootstrap loop above: resolve the win32 .cmd shim to an
3203
+ // absolute path before handing it to the spawn boundary.
3204
+ const resolvedCommand = resolveWin32Executable(candidate.command);
3165
3205
  try {
3166
- const result = await execFileAsync(candidate.command, candidate.args, {
3206
+ const result = await execFileAsync(resolvedCommand, candidate.args, {
3167
3207
  cwd,
3168
3208
  encoding: 'utf8',
3169
3209
  timeout,
@@ -3172,16 +3212,28 @@ async function runMeshRefineValidationGate(
3172
3212
  });
3173
3213
  summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
3174
3214
  } catch (error: any) {
3215
+ // ENOENT check first: a spawn-resolution failure ("spawn npm ENOENT")
3216
+ // carries no stderr and would otherwise fall through to an
3217
+ // unclassified generic failure. Classify it distinctly so the
3218
+ // coordinator surfaces the real cause (win32 .cmd resolution).
3219
+ const spawnResolutionFailed = isSpawnResolutionError(error);
3175
3220
  const stderr = truncateValidationOutput(error?.stderr || error?.message);
3176
- const missingDependencyFailure = /Cannot find module|MODULE_NOT_FOUND|node_modules|command not found|not found/i.test(stderr);
3221
+ const missingDependencyFailure = !spawnResolutionFailed
3222
+ && /Cannot find module|MODULE_NOT_FOUND|node_modules|command not found|not found/i.test(stderr);
3177
3223
  summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, error, false, {
3178
3224
  exitCode: typeof error?.code === 'number' ? error.code : null,
3179
3225
  signal: typeof error?.signal === 'string' ? error.signal : null,
3180
3226
  timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
3181
- ...(missingDependencyFailure ? { failureKind: 'missing_dependencies' } : {}),
3227
+ ...(spawnResolutionFailed
3228
+ ? { failureKind: 'spawn_resolution_failed', resolvedCommand }
3229
+ : missingDependencyFailure ? { failureKind: 'missing_dependencies' } : {}),
3182
3230
  }));
3183
3231
  summary.status = 'failed';
3184
- if (missingDependencyFailure) {
3232
+ if (spawnResolutionFailed) {
3233
+ summary.failureKind = 'spawn_resolution_failed';
3234
+ summary.failureCode = 'spawn_resolution_failed';
3235
+ summary.spawnResolutionError = describeSpawnError(error, candidate.command, true);
3236
+ } else if (missingDependencyFailure) {
3185
3237
  summary.failureKind = 'missing_dependencies';
3186
3238
  summary.failureCode = 'missing_dependencies';
3187
3239
  }
@@ -4776,7 +4828,10 @@ export class DaemonCommandRouter {
4776
4828
  ? 'Refinery validation dependencies are missing; merge/refine was not attempted. Configure validation.bootstrapCommands if Refinery should bootstrap dependencies before validation.'
4777
4829
  : validationSummary.failureCode === 'dependency_bootstrap_failed'
4778
4830
  ? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
4779
- : 'Refinery validation gate failed; merge/refine was not attempted.';
4831
+ : validationSummary.failureCode === 'spawn_resolution_failed'
4832
+ ? (validationSummary.spawnResolutionError
4833
+ || 'Refinery validation command could not be spawned (executable not found); merge/refine was not attempted.')
4834
+ : 'Refinery validation gate failed; merge/refine was not attempted.';
4780
4835
  if (!firstFailedCmd) return base;
4781
4836
  const cmdName = typeof firstFailedCmd.displayCommand === 'string' ? firstFailedCmd.displayCommand
4782
4837
  : typeof firstFailedCmd.command === 'string'
@@ -1534,8 +1534,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1534
1534
  }
1535
1535
 
1536
1536
  function markSessionTerminal(sessionId: string, outcome: 'completed' | 'failed', occurredAtMs?: number | null): { id?: string } | null {
1537
+ // C2: prefer an exact taskId match when the completion event carries one —
1538
+ // it's immune to coordinator↔worker clock skew that can hide the assigned row.
1539
+ const eventTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1537
1540
  const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1538
1541
  occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1542
+ taskId: eventTaskId,
1539
1543
  });
1540
1544
  updateDirectDispatchStatus(args.meshId, sessionId, outcome);
1541
1545
  markSessionDeliveriesTerminal(args.meshId, sessionId, outcome);
@@ -698,15 +698,66 @@ export class MeshRuntimeStore {
698
698
  });
699
699
  }
700
700
 
701
- findAssignedBySession(meshId: string, sessionId: string, occurredAtIso?: string): MeshWorkQueueEntry | null {
701
+ /**
702
+ * Resolve the `assigned` queue row a completion event belongs to.
703
+ *
704
+ * Clock-skew safety (C2): a completion event's `occurredAtIso` carries the
705
+ * REMOTE WORKER's clock, while `updated_at` carries the COORDINATOR's clock
706
+ * (set at assignment and re-bumped on every mutation). For a remote node,
707
+ * coordinator-clock > worker-clock skew used to make an `updated_at <= occurredAt`
708
+ * filter return nothing, stranding the finished task as `assigned` forever.
709
+ *
710
+ * We therefore NEVER filter completion-matching on the mutable `updated_at`:
711
+ * 1. If `taskId` is given, match the exact `assigned` row by id (no time filter).
712
+ * 2. Otherwise a session holds at most one `assigned` task — match it without a
713
+ * time filter. If several exist (shouldn't normally), disambiguate by the
714
+ * IMMUTABLE `dispatchTimestamp`: latest `dispatchTimestamp <= occurredAt`,
715
+ * and if skew makes ALL of them later than `occurredAt`, fall back to the
716
+ * most-recent `dispatchTimestamp` rather than returning null.
717
+ */
718
+ findAssignedBySession(
719
+ meshId: string,
720
+ sessionId: string,
721
+ occurredAtIso?: string,
722
+ taskId?: string,
723
+ ): MeshWorkQueueEntry | null {
702
724
  this.ensureLegacyQueueMigrated(meshId);
703
- // Use updated_at (≈ dispatchTimestamp when status='assigned') for the occurredAt filter.
704
- const sql = occurredAtIso
705
- ? `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' AND updated_at <= ? ORDER BY updated_at DESC LIMIT 1`
706
- : `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' ORDER BY updated_at DESC LIMIT 1`;
707
- const args: string[] = occurredAtIso ? [meshId, sessionId, occurredAtIso] : [meshId, sessionId];
708
- const row = this.db.prepare(sql).get(...args as [string, string, string?]) as { payload: string } | undefined;
709
- return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
725
+
726
+ // 1. Exact taskId match — robust against clock skew and stale rows.
727
+ if (taskId) {
728
+ const row = this.db.prepare(
729
+ `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' AND id = ? LIMIT 1`
730
+ ).get(meshId, sessionId, taskId) as { payload: string } | undefined;
731
+ if (row) return JSON.parse(row.payload) as MeshWorkQueueEntry;
732
+ // Fall through to session-based matching if the id didn't line up
733
+ // (e.g. event carried a stale/foreign taskId).
734
+ }
735
+
736
+ // 2. Session-based match WITHOUT the mutable updated_at filter.
737
+ const rows = this.db.prepare(
738
+ `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned'`
739
+ ).all(meshId, sessionId) as Array<{ payload: string }>;
740
+ if (rows.length === 0) return null;
741
+
742
+ const entries = rows
743
+ .map(r => { try { return JSON.parse(r.payload) as MeshWorkQueueEntry; } catch { return null; } })
744
+ .filter((e): e is MeshWorkQueueEntry => e !== null);
745
+ if (entries.length === 0) return null;
746
+ if (entries.length === 1) return entries[0];
747
+
748
+ // Multiple assigned rows for one session: disambiguate by the immutable
749
+ // dispatchTimestamp (falling back to updated_at only for legacy rows that
750
+ // predate dispatchTimestamp). We use these to ORDER, never to FILTER —
751
+ // so a skewed occurredAt can never drop the live row to null.
752
+ const orderKey = (e: MeshWorkQueueEntry) => e.dispatchTimestamp ?? e.updatedAt ?? '';
753
+ const byDispatchDesc = [...entries].sort((a, b) => orderKey(b).localeCompare(orderKey(a)));
754
+ if (occurredAtIso) {
755
+ const atOrBefore = byDispatchDesc.find(e => orderKey(e) <= occurredAtIso);
756
+ if (atOrBefore) return atOrBefore;
757
+ }
758
+ // Skew made every dispatch later than occurredAt — fall back to the
759
+ // most-recently dispatched row rather than stranding the completion.
760
+ return byDispatchDesc[0];
710
761
  }
711
762
 
712
763
  private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
@@ -4,6 +4,7 @@ import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
4
  import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
5
5
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
6
  import { getMesh } from '../config/mesh-config.js';
7
+ import { LOG } from '../logging/logger.js';
7
8
 
8
9
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
9
10
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -880,14 +881,27 @@ export function updateSessionTaskStatus(
880
881
  meshId: string,
881
882
  sessionId: string,
882
883
  status: MeshTaskStatus,
883
- opts?: { occurredAt?: string },
884
+ opts?: { occurredAt?: string; taskId?: string },
884
885
  ): MeshWorkQueueEntry | null {
885
886
  return withQueueLock(meshId, () => {
887
+ const store = MeshRuntimeStore.getInstance();
886
888
  const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
887
- const entry = MeshRuntimeStore.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
888
- if (!entry) return null;
889
+ const entry = store.findAssignedBySession(meshId, sessionId, occurredAtIso, opts?.taskId);
890
+ if (!entry) {
891
+ // C2: the silent null here is exactly what stranded a finished task as
892
+ // `assigned` for 19 minutes. If the session still has an assigned row we
893
+ // failed to resolve, surface it loudly instead of dropping the completion.
894
+ const assignedRows = store.getActiveAssignmentDetails(meshId)
895
+ .filter(r => r.sessionId === sessionId);
896
+ if (assignedRows.length > 0) {
897
+ LOG.warn('MeshQueue', `No assigned queue row matched completion for mesh ${meshId} session ${sessionId} `
898
+ + `(taskId=${opts?.taskId ?? 'none'}, occurredAt=${occurredAtIso ?? 'none'}); `
899
+ + `${assignedRows.length} assigned row(s) exist: ${assignedRows.map(r => r.id).join(',')}`);
900
+ }
901
+ return null;
902
+ }
889
903
  entry.status = status;
890
- MeshRuntimeStore.getInstance().updateQueueEntry(entry);
904
+ store.updateQueueEntry(entry);
891
905
  if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
892
906
  return entry;
893
907
  });
@@ -164,15 +164,35 @@ export function isMeshConfigRecord(value: unknown): value is Record<string, unkn
164
164
  return !!value && typeof value === 'object' && !Array.isArray(value);
165
165
  }
166
166
 
167
- function tokenizeCommandString(command: string): string[] | null {
167
+ // True shell metacharacters never allowed anywhere in a config command, on
168
+ // any platform. Note: backslash is deliberately NOT in this set; it is handled
169
+ // separately (allowed only in the win32 executable token).
170
+ const SHELL_METACHAR_RE = /[;&|<>`$\n\r'"]/;
171
+ // Per-token allowlist for argument tokens and non-win32 executable tokens.
172
+ const SAFE_TOKEN_RE = /^[A-Za-z0-9_@./:=+-]+$/;
173
+ // Executable token on win32: same as SAFE_TOKEN_RE but also permits backslash so
174
+ // an absolute path like C:\Users\me\AppData\Roaming\npm\npm.cmd is accepted.
175
+ const SAFE_WIN32_EXEC_TOKEN_RE = /^[A-Za-z0-9_@./:=+\\-]+$/;
176
+
177
+ export function tokenizeCommandString(command: string): string[] | null {
168
178
  const trimmed = command.trim();
169
179
  if (!trimmed) return null;
170
180
  // Explicit config may name any executable, but the Refinery never invokes a shell.
171
181
  // Reject shell syntax, quotes and substitutions so config cannot smuggle a compound command.
172
- if (/[;&|<>`$\\\n\r'\"]/.test(trimmed)) return null;
182
+ // Backslash is the one exception (handled per-token below): rejecting it here
183
+ // would block legitimate win32 absolute .cmd paths, but it is never a shell
184
+ // metacharacter in the no-shell execFile boundary we run under.
185
+ if (SHELL_METACHAR_RE.test(trimmed)) return null;
173
186
  const tokens = trimmed.split(/\s+/).filter(Boolean);
174
187
  if (!tokens.length) return null;
175
- if (tokens.some(token => !/^[A-Za-z0-9_@./:=+-]+$/.test(token))) return null;
188
+ const isWin32 = process.platform === 'win32';
189
+ for (let i = 0; i < tokens.length; i++) {
190
+ // Backslash is permitted ONLY in the executable (first) token, ONLY on
191
+ // win32. Every other token (and all tokens off win32) uses the strict
192
+ // allowlist with no backslash.
193
+ const re = (isWin32 && i === 0) ? SAFE_WIN32_EXEC_TOKEN_RE : SAFE_TOKEN_RE;
194
+ if (!re.test(tokens[i])) return null;
195
+ }
176
196
  return tokens;
177
197
  }
178
198
 
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { promisify } from 'node:util';
6
6
  import * as yaml from 'js-yaml';
7
+ import { resolveWin32Executable } from '../cli-adapters/resolve-executable.js';
7
8
  import {
8
9
  isMeshConfigRecord,
9
10
  normalizeMeshCommandConfig,
@@ -268,8 +269,12 @@ export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Pr
268
269
  const cwd = command.cwd ? pathResolve(workspace, command.cwd) : workspace;
269
270
  const startedAt = Date.now();
270
271
  state.lastCommand = command.displayCommand;
272
+ // On win32 a bare npm/npx/tsc is a .cmd shim that libuv's spawn search
273
+ // (which appends only .com/.exe) cannot resolve → spawn ENOENT. Resolve
274
+ // to an absolute path first (no-op on non-win32 / already-absolute).
275
+ const resolvedCommand = resolveWin32Executable(command.command);
271
276
  try {
272
- const result = await execFileAsync(command.command, command.args, {
277
+ const result = await execFileAsync(resolvedCommand, command.args, {
273
278
  cwd,
274
279
  encoding: 'utf8',
275
280
  timeout: command.timeoutMs || DEFAULT_TIMEOUT_MS,