claude-spotter 1.4.24 → 1.4.26

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.26 — 2026-07-20
4
+
5
+ - **SessionEnd cleanupを冪等化。** daemonが未起動または既に停止済みの`E_UNREACHABLE`は
6
+ `already-stopped`として記録し、正常な終了状態をstderr warningへ誤分類しない。他のcleanup
7
+ failureは従来どおりwarningと構造eventを残す。
8
+ - **Windows native Codex hookをPowerShell実行可能に修正。** quoted Node commandの先頭へ
9
+ Windowsだけcall operator `&`を付ける。installerのownership判定は新旧両形式を認識し、
10
+ 再installで旧形式をcanonicalへ更新、uninstallでも除去できる。
11
+ - **Windows runtime error storeのACL処理を安定化。** 混雑したWindows runnerでも
12
+ PowerShellによるprivate ACL設定が誤ってtimeoutしないよう、上限を15秒へ拡張した。
13
+
14
+ ## 1.4.25 — 2026-07-14
15
+
16
+ - **Windows Codex CLI実行経路を修正。** `spotter install`、`spotter doctor`、Codex hook /
17
+ factory diagnostics、Codex CLI auditorは、Windowsのnpm shim `codex.cmd`を`cmd.exe`経由で
18
+ 実行する。PowerShellから利用可能なCodexを未導入扱いせず、生成後のhook監査まで通す。
19
+ - **timeout時のプロセスツリー終了を追加。** Windowsのshim経由監査は`taskkill /T /F`で
20
+ 子Codexを残さず、終了確認に失敗すれば別codeでfail-loudにする。npm生成shimは
21
+ 検証済みNode entrypointへ解決してから直接起動し、project pathを`cmd.exe`に
22
+ 再解釈させない。Codex Sidecarの診断・監査・workflowも同じ解決契約へ揃える。
23
+ POSIXと実体`.exe`の直接spawn、stdin prompt、非0終了時のfail-loud診断は維持する。
24
+ - **検証。** 530 tests(528 pass / 2 platform skip)、68-file pack、FOX Windows nativeの
25
+ tarball仮導入、install、warning 0のdoctor、Codex hooks / catalog、実Codex CLI auditor、
26
+ codex-sidecar auditor diagnostics、dotagents `verify-install`を通過した。公開commit
27
+ `65bccf8`をnpm `latest`、`v1.4.25` tag、GitHub Releaseへ同期し、Mac、main-server、
28
+ FOX WSL2、FOX Windows nativeのregistry版global installとwarning 0のdoctorを確認した。
29
+
3
30
  ## 1.4.24 — 2026-07-14
4
31
 
5
32
  - **installerがruntime error state rootをowner-privateへ正規化。** `spotter install`は
@@ -9,6 +36,11 @@
9
36
  - **unsafe pathを修復前に拒否。** POSIXのtype・symlink・owner検証を`chmod`より前へ移し、
10
37
  `~/.spotter`がsymlinkの場合にリンク先のmodeを変えてから失敗する副作用を防いだ。
11
38
  read-timeのmode検証、Windows DACL、collection明示opt-in、network非依存契約は変更しない。
39
+ - **検証・公開。** 519 tests(517 pass / 2 platform skip)、macOS/Linux/Windows ×
40
+ Node 22.13/22.xのCI run `29274984927`、67-file pack検査、隔離installを通過した。
41
+ 公開commit `52d6086`をnpm `latest`、`v1.4.24` tag、GitHub Releaseへ同期し、Mac、
42
+ main-server、FOX WSL2のregistry版global install、state root `0700`、runtime snapshot、
43
+ dotagents Mac BugHub canaryを確認した。
12
44
 
13
45
  ## 1.4.23 — 2026-07-13
14
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.24",
3
+ "version": "1.4.26",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@ import { delimiter, dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { createAuditorBackend, selectAuditorBackend } from '../core/auditor-backend.mjs';
8
8
  import { resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
9
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
9
10
  import { codexLastAssistantMessage, readCodexToolUsage } from '../core/codex-transcript.mjs';
10
11
  import { readLocal } from '../tool-db/refresh.mjs';
11
12
  import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
@@ -463,11 +464,11 @@ export async function runCodexHookDiagnosticsCommand({
463
464
  writeOutput(JSON.stringify(result, null, 2) + '\n');
464
465
  }
465
466
 
466
- export async function installCodexHooks({ codexHome = defaultCodexHome(), nodePath = resolveCodexHookNodePath(), spotterBin = SPOTTER_BIN } = {}) {
467
+ export async function installCodexHooks({ codexHome = defaultCodexHome(), nodePath = resolveCodexHookNodePath(), spotterBin = SPOTTER_BIN, platform = process.platform } = {}) {
467
468
  const hooksPath = join(codexHome, 'hooks.json');
468
469
  const configPath = join(codexHome, 'config.toml');
469
470
  const current = await loadJson(hooksPath);
470
- const next = mergeCodexHooks(current, { nodePath, spotterBin });
471
+ const next = mergeCodexHooks(current, { nodePath, spotterBin, platform });
471
472
  const hooksChanged = JSON.stringify(current) !== JSON.stringify(next);
472
473
  if (hooksChanged) {
473
474
  await mkdir(dirname(hooksPath), { recursive: true });
@@ -549,13 +550,23 @@ export async function codexHookDiagnostics({
549
550
  projectRoot = null,
550
551
  env = process.env,
551
552
  spawnSyncFn = spawnSync,
553
+ platform = process.platform,
552
554
  resolveModelSelectionFn = resolveCodexAuditorModelSelection,
553
555
  } = {}) {
554
556
  const auditorBackend = resolveCodexHookAuditorBackend({ env });
555
557
  const auditorModelSelection = auditorBackend === 'codex-cli'
556
558
  ? resolveModelSelectionFn({ env })
557
559
  : null;
558
- const features = spawnSyncFn('codex', ['features', 'list'], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
560
+ const invocation = buildWindowsCompatibleInvocation({
561
+ command: 'codex',
562
+ args: ['features', 'list'],
563
+ platform,
564
+ });
565
+ const features = spawnSyncFn(invocation.command, invocation.args, {
566
+ encoding: 'utf8',
567
+ maxBuffer: 1024 * 1024,
568
+ windowsHide: true,
569
+ });
559
570
  const featureOutput = [features.stdout, features.stderr].filter(Boolean).join('\n');
560
571
  const hooks = await loadJson(join(codexHome, 'hooks.json'));
561
572
  const evidence = featureOutput.split('\n').find((line) => isEnabledCodexHookFeatureLine(line)) ?? null;
@@ -868,14 +879,15 @@ async function loadJson(path) {
868
879
  }
869
880
  }
870
881
 
871
- function mergeCodexHooks(current, { nodePath, spotterBin }) {
882
+ function mergeCodexHooks(current, { nodePath, spotterBin, platform = process.platform }) {
872
883
  const next = structuredClone(current ?? {});
873
884
  next.hooks = next.hooks ?? {};
874
- addCodexHook(next, 'SessionStart', `${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook session-start`, {
885
+ const prefix = platform === 'win32' ? '& ' : '';
886
+ addCodexHook(next, 'SessionStart', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook session-start`, {
875
887
  timeout: 5,
876
888
  });
877
- addCodexHook(next, 'UserPromptSubmit', `${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook user-prompt-submit`);
878
- addCodexHook(next, 'Stop', `${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook stop`);
889
+ addCodexHook(next, 'UserPromptSubmit', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook user-prompt-submit`);
890
+ addCodexHook(next, 'Stop', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook stop`);
879
891
  return next;
880
892
  }
881
893
 
@@ -932,7 +944,7 @@ function isSpotterCodexHook(hook) {
932
944
  }
933
945
 
934
946
  function isSpotterCodexCommand(command, event = null) {
935
- const match = command.match(/^(?:"((?:\\.|[^"\\])*)"|(\S+))\s+(?:"((?:\\.|[^"\\])*)"|(\S+))\s+codex-hook\s+(session-start|user-prompt-submit|stop)\s*$/);
947
+ const match = command.match(/^(?:&\s+)?(?:"((?:\\.|[^"\\])*)"|(\S+))\s+(?:"((?:\\.|[^"\\])*)"|(\S+))\s+codex-hook\s+(session-start|user-prompt-submit|stop)\s*$/);
936
948
  if (!match) return false;
937
949
  const nodePath = unescapeQuotedCommandToken(match[1] ?? match[2]);
938
950
  const spotterPath = unescapeQuotedCommandToken(match[3] ?? match[4]);
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
9
9
  import { findSpotterMarker } from '../hooks/lib.mjs';
10
10
  import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
11
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
11
12
 
12
13
  const execFileP = promisify(execFile);
13
14
 
@@ -51,8 +52,8 @@ export async function runDoctor() {
51
52
  // Codex native readiness. These are warnings because Claude-backed installs may
52
53
  // still be valid, but Codex-first tuning needs the signal in one place.
53
54
  try {
54
- const { stdout } = await execFileP('codex', ['--version'], { timeout: 5_000, windowsHide: true });
55
- mark(true, `codex CLI: ${stdout.trim()}`);
55
+ const version = await inspectCodexCliVersion();
56
+ mark(true, `codex CLI: ${version}`);
56
57
  } catch (err) {
57
58
  mark(false, 'codex CLI', `not found or failed: ${err.message}`);
58
59
  warnings += 1;
@@ -115,6 +116,23 @@ export async function runDoctor() {
115
116
  console.log(`result: OK (${warnings} warnings)`);
116
117
  }
117
118
 
119
+ export async function inspectCodexCliVersion({
120
+ codexBin = 'codex',
121
+ platform = process.platform,
122
+ execFileFn = execFileP,
123
+ } = {}) {
124
+ const invocation = buildWindowsCompatibleInvocation({
125
+ command: codexBin,
126
+ args: ['--version'],
127
+ platform,
128
+ });
129
+ const { stdout } = await execFileFn(invocation.command, invocation.args, {
130
+ timeout: 5_000,
131
+ windowsHide: true,
132
+ });
133
+ return stdout.trim();
134
+ }
135
+
118
136
  export async function inspectCodexHookConfiguration({ projectRoot = null, diagnosticsFn = codexHookDiagnostics } = {}) {
119
137
  const diagnostics = await diagnosticsFn({ projectRoot });
120
138
  const issues = Object.entries(diagnostics.validation ?? {})
@@ -209,7 +227,13 @@ async function codexSidecarAuditorReadiness(projectRoot) {
209
227
  const cmd = cliPath ? process.execPath : 'codex-sidecar';
210
228
  const finalArgs = cliPath ? [cliPath, ...args] : args;
211
229
  try {
212
- const { stdout } = await execFileP(cmd, finalArgs, {
230
+ const invocation = buildWindowsCompatibleInvocation({
231
+ command: cmd,
232
+ args: finalArgs,
233
+ env: process.env,
234
+ allowCmdFallback: false,
235
+ });
236
+ const { stdout } = await execFileP(invocation.command, invocation.args, {
213
237
  timeout: 15_000,
214
238
  windowsHide: true,
215
239
  maxBuffer: 1024 * 1024,
@@ -15,6 +15,7 @@
15
15
  import { mkdir, writeFile, readFile, access, realpath } from 'node:fs/promises';
16
16
  import { constants as fsConstants } from 'node:fs';
17
17
  import { spawnSync } from 'node:child_process';
18
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
18
19
  import { homedir } from 'node:os';
19
20
  import { join, resolve, dirname, delimiter, extname } from 'node:path';
20
21
  import { fileURLToPath } from 'node:url';
@@ -278,8 +279,17 @@ async function exists(path) {
278
279
  }
279
280
  }
280
281
 
281
- function isCodexCliPresent() {
282
- const result = spawnSync('codex', ['--version'], {
282
+ export function isCodexCliPresent({
283
+ platform = process.platform,
284
+ spawnSyncFn = spawnSync,
285
+ codexBin = 'codex',
286
+ } = {}) {
287
+ const invocation = buildWindowsCompatibleInvocation({
288
+ command: codexBin,
289
+ args: ['--version'],
290
+ platform,
291
+ });
292
+ const result = spawnSyncFn(invocation.command, invocation.args, {
283
293
  encoding: 'utf8',
284
294
  stdio: ['ignore', 'pipe', 'pipe'],
285
295
  timeout: 5_000,
@@ -10,6 +10,7 @@ import {
10
10
  resolveCodexAuditorModelSelection,
11
11
  } from './codex-auditor-model-policy.mjs';
12
12
  import { serializeAuditorPromptData, validateRecentContext } from './auditor-prompt-data.mjs';
13
+ import { buildWindowsCompatibleInvocation, terminateProcessTree } from './windows-cli-shim.mjs';
13
14
 
14
15
  const DEFAULT_CODEX_CLI_TIMEOUT_MS = 45_000;
15
16
  const DEFAULT_CODEX_CLI_MODEL = CODEX_AUDITOR_MODEL_POLICY.production.model;
@@ -129,6 +130,8 @@ export function createCodexCliAuditorBackend({
129
130
  codexBin = 'codex',
130
131
  modelProfile = null,
131
132
  resolveModelSelectionFn = resolveCodexAuditorModelSelection,
133
+ platform = process.platform,
134
+ terminateChildFn = terminateProcessTree,
132
135
  } = {}) {
133
136
  if (!Array.isArray(catalog)) {
134
137
  throw new TypeError('createCodexCliAuditorBackend: catalog must be an array');
@@ -168,6 +171,8 @@ export function createCodexCliAuditorBackend({
168
171
  spawnFn,
169
172
  timeoutMs,
170
173
  modelSelection,
174
+ platform,
175
+ terminateChildFn,
171
176
  });
172
177
  let rawFinal;
173
178
  try {
@@ -321,6 +326,8 @@ async function runCodexExec({
321
326
  spawnFn,
322
327
  timeoutMs,
323
328
  modelSelection,
329
+ platform,
330
+ terminateChildFn,
324
331
  }) {
325
332
  const args = buildCodexExecArgs({
326
333
  schemaPath,
@@ -330,10 +337,36 @@ async function runCodexExec({
330
337
  reasoningEffort: modelSelection.effectiveReasoningEffort,
331
338
  });
332
339
  const options = buildCodexCliSpawnOptions({ projectRoot, env });
340
+ let invocation;
341
+ try {
342
+ invocation = buildWindowsCompatibleInvocation({
343
+ command: codexBin,
344
+ args,
345
+ platform,
346
+ env,
347
+ allowCmdFallback: false,
348
+ });
349
+ } catch (err) {
350
+ throw new AuditorBackendError('E_CODEX_CLI_SPAWN', `failed to resolve ${codexBin}: ${err.message}`, {
351
+ backend: 'codex-cli',
352
+ stage,
353
+ diagnostics: {
354
+ durationMs: 0,
355
+ processCount: 0,
356
+ processCountMethod: 'resolve_failed',
357
+ stdout: '',
358
+ stderr: '',
359
+ stdoutTruncated: false,
360
+ stderrTruncated: false,
361
+ modelSelection,
362
+ },
363
+ cause: err,
364
+ });
365
+ }
333
366
  const startedAt = Date.now();
334
367
  let child;
335
368
  try {
336
- child = spawnFn(codexBin, args, options);
369
+ child = spawnFn(invocation.command, invocation.args, options);
337
370
  } catch (err) {
338
371
  throw new AuditorBackendError('E_CODEX_CLI_SPAWN', `failed to spawn ${codexBin}: ${err.message}`, {
339
372
  backend: 'codex-cli',
@@ -368,8 +401,21 @@ async function runCodexExec({
368
401
  const timer = setTimeout(() => {
369
402
  settle(async () => {
370
403
  const completed = await readSchemaValidLastMessage({ lastMessagePath, stage });
404
+ try {
405
+ await terminateChildFn(child, { platform });
406
+ } catch (cause) {
407
+ reject(new AuditorBackendError('E_CODEX_CLI_TERMINATION', 'codex-cli process tree termination could not be verified', {
408
+ backend: 'codex-cli',
409
+ stage,
410
+ diagnostics: {
411
+ ...diagnostics(),
412
+ lastMessageCheck: completed.reason,
413
+ },
414
+ cause,
415
+ }));
416
+ return;
417
+ }
371
418
  if (completed.ok) {
372
- if (typeof child.kill === 'function') child.kill();
373
419
  resolve({
374
420
  diagnostics: {
375
421
  ...diagnostics(),
@@ -379,7 +425,6 @@ async function runCodexExec({
379
425
  });
380
426
  return;
381
427
  }
382
- if (typeof child.kill === 'function') child.kill();
383
428
  reject(new AuditorBackendError('E_CODEX_CLI_TIMEOUT', `codex-cli did not respond within ${timeoutMs}ms`, {
384
429
  backend: 'codex-cli',
385
430
  stage,
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { toSpotterJudgment } from './judgment.mjs';
7
7
  import { filterCatalogMisses } from './auditor-response.mjs';
8
8
  import { AuditorBackendError } from './auditor-error.mjs';
9
+ import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
9
10
 
10
11
  const execFileP = promisify(execFile);
11
12
  const DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS = 45_000;
@@ -18,6 +19,7 @@ export function createCodexSidecarAuditorBackend({
18
19
  projectRoot,
19
20
  env = process.env,
20
21
  execFileFn = execFileP,
22
+ buildInvocationFn = buildWindowsCompatibleInvocation,
21
23
  timeoutMs = DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS,
22
24
  } = {}) {
23
25
  if (!Array.isArray(catalog)) {
@@ -47,6 +49,7 @@ export function createCodexSidecarAuditorBackend({
47
49
  contextFilePath,
48
50
  env,
49
51
  execFileFn,
52
+ buildInvocationFn,
50
53
  timeoutMs,
51
54
  });
52
55
  const parsed = parseCodexSidecarAuditorResult(run.value, { stage });
@@ -166,14 +169,27 @@ function buildExecOptions({ projectRoot, env, timeoutMs }) {
166
169
  };
167
170
  }
168
171
 
169
- async function runCodexSidecarAuditor({ projectRoot, contextFilePath, env, execFileFn, timeoutMs }) {
172
+ async function runCodexSidecarAuditor({
173
+ projectRoot,
174
+ contextFilePath,
175
+ env,
176
+ execFileFn,
177
+ buildInvocationFn,
178
+ timeoutMs,
179
+ }) {
170
180
  const { cmd, args } = buildCodexSidecarAuditorCommand({ projectRoot, contextFilePath, env, timeoutMs });
171
181
  const options = buildExecOptions({ projectRoot, env, timeoutMs });
182
+ const invocation = buildInvocationFn({
183
+ command: cmd,
184
+ args,
185
+ env,
186
+ allowCmdFallback: false,
187
+ });
172
188
  const startedAt = Date.now();
173
189
  let stdout = '';
174
190
  let stderr = '';
175
191
  try {
176
- const result = await execFileFn(cmd, args, options);
192
+ const result = await execFileFn(invocation.command, invocation.args, options);
177
193
  stdout = result.stdout ?? '';
178
194
  stderr = result.stderr ?? '';
179
195
  } catch (err) {
@@ -16,6 +16,7 @@ import {
16
16
  createSidecarResultRecord,
17
17
  spotterFindingsToSidecarContextBlocks,
18
18
  } from './sidecar-context.mjs';
19
+ import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
19
20
 
20
21
  const execFileP = promisify(execFile);
21
22
  const WORKFLOW_MAP = {
@@ -36,6 +37,7 @@ export async function runCodexReadOnlyWorkflow({
36
37
  save = true,
37
38
  outPath = null,
38
39
  execFileFn = execFileP,
40
+ buildInvocationFn = buildWindowsCompatibleInvocation,
39
41
  gitStatusFn = execFileP,
40
42
  env = process.env,
41
43
  now = () => new Date(),
@@ -54,6 +56,7 @@ export async function runCodexReadOnlyWorkflow({
54
56
  const effectiveHostAgent = detectHostAgent({ explicitHostAgent: hostAgent, env });
55
57
  const contextBlocks = spotterFindingsToSidecarContextBlocks(findings);
56
58
  const spawnOptions = buildSidecarSpawnOptions({ projectRoot, env });
59
+ spawnOptions.buildInvocationFn = buildInvocationFn;
57
60
  const diagnostics = await runDiagnostics({ projectRoot, preset: workflowSpec.preset, execFileFn, spawnOptions });
58
61
  const availability = classifySidecarAvailability({ diagnostics });
59
62
  const decision = decideCodexSidecarUse({
@@ -129,6 +132,7 @@ export async function runCodexWork({
129
132
  save = true,
130
133
  outPath = null,
131
134
  execFileFn = execFileP,
135
+ buildInvocationFn = buildWindowsCompatibleInvocation,
132
136
  gitStatusFn = execFileP,
133
137
  env = process.env,
134
138
  now = () => new Date(),
@@ -163,6 +167,7 @@ export async function runCodexWork({
163
167
  gitStatusFn,
164
168
  });
165
169
  const spawnOptions = buildSidecarSpawnOptions({ projectRoot, env, marker: 'codex-work' });
170
+ spawnOptions.buildInvocationFn = buildInvocationFn;
166
171
  const diagnostics = dirtyScope.ok
167
172
  ? await runDiagnostics({ projectRoot, preset: workflowSpec.preset, execFileFn, spawnOptions })
168
173
  : null;
@@ -481,10 +486,13 @@ function execPortable(execFileFn, cmd, args, spawnOptions) {
481
486
  windowsHide: true,
482
487
  maxBuffer: 10 * 1024 * 1024,
483
488
  };
484
- if (process.platform === 'win32' && cmd !== process.execPath) {
485
- return execFileFn('cmd.exe', ['/c', cmd, ...args], options);
486
- }
487
- return execFileFn(cmd, args, options);
489
+ const invocation = spawnOptions.buildInvocationFn({
490
+ command: cmd,
491
+ args,
492
+ env: spawnOptions.env,
493
+ allowCmdFallback: false,
494
+ });
495
+ return execFileFn(invocation.command, invocation.args, options);
488
496
  }
489
497
 
490
498
  function parseJsonOutput(stdout) {
@@ -635,7 +635,7 @@ async function runWindowsAcl(path, { directory }, options) {
635
635
  const executable = options.powerShellPath ?? 'powershell.exe';
636
636
  const script = buildWindowsAclPowerShell({ directory });
637
637
  await spawnForExit(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
638
- timeoutMs: options.aclTimeoutMs ?? 3_000,
638
+ timeoutMs: options.aclTimeoutMs ?? 15_000,
639
639
  env: { ...process.env, SPOTTER_ACL_PATH: path },
640
640
  });
641
641
  }
@@ -0,0 +1,162 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { win32 } from 'node:path';
4
+
5
+ // Windows の npm global CLI は多くが `<name>.cmd` shim であり、Node の shell:false
6
+ // 直接 spawn では PATHEXT 解決されない。cmd.exe を明示して Node 24 の shell:true
7
+ // deprecation を避けつつ、POSIX と実体 .exe は従来どおり直接起動する。
8
+ export function buildWindowsCompatibleInvocation({
9
+ command,
10
+ args = [],
11
+ platform = process.platform,
12
+ env = process.env,
13
+ processExecPath = process.execPath,
14
+ fileExistsFn = defaultFileExists,
15
+ readFileFn = defaultReadFile,
16
+ allowCmdFallback = true,
17
+ } = {}) {
18
+ if (typeof command !== 'string' || command.length === 0) {
19
+ throw new TypeError('buildWindowsCompatibleInvocation: command must be a non-empty string');
20
+ }
21
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
22
+ throw new TypeError('buildWindowsCompatibleInvocation: args must be an array of strings');
23
+ }
24
+ if (platform !== 'win32' || /\.exe$/i.test(command)) {
25
+ return { command, args };
26
+ }
27
+ const direct = resolveWindowsNpmShim({
28
+ command,
29
+ args,
30
+ env,
31
+ processExecPath,
32
+ fileExistsFn,
33
+ readFileFn,
34
+ });
35
+ if (direct) return direct;
36
+ if (!allowCmdFallback) {
37
+ const error = new Error(`Windows CLI shim could not be resolved safely: ${command}`);
38
+ error.code = 'E_WINDOWS_CLI_SHIM_UNRESOLVED';
39
+ throw error;
40
+ }
41
+ return {
42
+ command: 'cmd.exe',
43
+ args: ['/d', '/s', '/c', command, ...args],
44
+ };
45
+ }
46
+
47
+ export function resolveWindowsNpmShim({
48
+ command,
49
+ args = [],
50
+ env = process.env,
51
+ processExecPath = process.execPath,
52
+ fileExistsFn = defaultFileExists,
53
+ readFileFn = defaultReadFile,
54
+ } = {}) {
55
+ const pathValue = env?.Path ?? env?.PATH ?? env?.path ?? '';
56
+ const hasPathSeparator = /[\\/]/.test(command);
57
+ const candidates = [];
58
+ if (hasPathSeparator) {
59
+ candidates.push(command);
60
+ if (!/\.[^\\/]+$/.test(command)) candidates.push(`${command}.exe`, `${command}.cmd`);
61
+ } else {
62
+ for (const rawDir of String(pathValue).split(';')) {
63
+ const dir = rawDir.trim();
64
+ if (!dir) continue;
65
+ candidates.push(win32.join(dir, `${command}.exe`), win32.join(dir, `${command}.cmd`));
66
+ }
67
+ }
68
+
69
+ for (const candidate of candidates) {
70
+ if (!fileExistsFn(candidate)) continue;
71
+ if (/\.exe$/i.test(candidate)) return { command: candidate, args };
72
+ if (!/\.cmd$/i.test(candidate)) continue;
73
+ const script = npmShimEntryPath(readFileFn(candidate), win32.dirname(candidate));
74
+ if (!script || !fileExistsFn(script)) continue;
75
+ const bundledNode = win32.join(win32.dirname(candidate), 'node.exe');
76
+ return {
77
+ command: fileExistsFn(bundledNode) ? bundledNode : processExecPath,
78
+ args: [script, ...args],
79
+ };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function npmShimEntryPath(source, shimDir) {
85
+ if (typeof source !== 'string') return null;
86
+ const match = source.match(/"%dp0%\\(node_modules\\[^"\r\n]+\.(?:cjs|mjs|js))"\s+%\*\s*$/im);
87
+ if (!match) return null;
88
+ const segments = match[1].split(/[\\/]/);
89
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) return null;
90
+ return win32.join(shimDir, ...segments);
91
+ }
92
+
93
+ // cmd.exe shim のみ kill すると孫の CLI が残り得るため、Windows は process tree を
94
+ // taskkill で終了する。taskkill 自体の起動・終了に失敗した時だけ direct kill へ戻す。
95
+ export function terminateProcessTree(child, {
96
+ platform = process.platform,
97
+ spawnFn = spawn,
98
+ timeoutMs = 5_000,
99
+ } = {}) {
100
+ if (!child || typeof child.kill !== 'function') return Promise.resolve();
101
+ if (platform !== 'win32' || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
102
+ child.kill();
103
+ return Promise.resolve();
104
+ }
105
+
106
+ const fallback = () => {
107
+ try { child.kill(); } catch { /* process may already have exited */ }
108
+ };
109
+ return new Promise((resolve, reject) => {
110
+ let settled = false;
111
+ const finish = (error = null) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ clearTimeout(timer);
115
+ if (error) {
116
+ fallback();
117
+ reject(error);
118
+ } else {
119
+ resolve();
120
+ }
121
+ };
122
+ const timer = setTimeout(() => {
123
+ const error = new Error(`taskkill did not finish within ${timeoutMs}ms`);
124
+ error.code = 'E_PROCESS_TREE_TERMINATION_TIMEOUT';
125
+ finish(error);
126
+ }, timeoutMs);
127
+ try {
128
+ const killer = spawnFn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
129
+ stdio: 'ignore',
130
+ windowsHide: true,
131
+ });
132
+ killer.once('error', (cause) => {
133
+ const error = new Error(`taskkill failed to start: ${cause.message}`);
134
+ error.code = 'E_PROCESS_TREE_TERMINATION';
135
+ error.cause = cause;
136
+ finish(error);
137
+ });
138
+ killer.once('close', (code) => {
139
+ if (code === 0) {
140
+ finish();
141
+ return;
142
+ }
143
+ const error = new Error(`taskkill exited with code ${code}`);
144
+ error.code = 'E_PROCESS_TREE_TERMINATION';
145
+ finish(error);
146
+ });
147
+ } catch (cause) {
148
+ const error = new Error(`taskkill spawn failed: ${cause.message}`);
149
+ error.code = 'E_PROCESS_TREE_TERMINATION';
150
+ error.cause = cause;
151
+ finish(error);
152
+ }
153
+ });
154
+ }
155
+
156
+ function defaultFileExists(path) {
157
+ try { return statSync(path).isFile(); } catch { return false; }
158
+ }
159
+
160
+ function defaultReadFile(path) {
161
+ try { return readFileSync(path, 'utf8'); } catch { return ''; }
162
+ }
@@ -20,6 +20,7 @@ export async function runSessionEnd({
20
20
  readInput = readStdinJson,
21
21
  sendRequestFn = sendRequest,
22
22
  recordHookEventFn = recordClaudeHookEvent,
23
+ writeError = (text) => process.stderr.write(text),
23
24
  } = {}) {
24
25
  if (isChildCall()) return;
25
26
  const input = await readInput();
@@ -41,6 +42,13 @@ export async function runSessionEnd({
41
42
  event: { hook: 'SessionEnd', status: 'shutdown', durationMs: Date.now() - startedAt },
42
43
  });
43
44
  } catch (err) {
45
+ if (err?.code === 'E_UNREACHABLE') {
46
+ await recordHookEventFn({
47
+ projectRoot,
48
+ event: { hook: 'SessionEnd', status: 'already-stopped', durationMs: Date.now() - startedAt },
49
+ });
50
+ return;
51
+ }
44
52
  await recordHookEventFn({
45
53
  projectRoot,
46
54
  event: {
@@ -51,8 +59,7 @@ export async function runSessionEnd({
51
59
  },
52
60
  });
53
61
  // §14.1 exception — don't fail the Claude Code session just because cleanup failed.
54
- process.stderr.write(`spotter-hook: session-end shutdown warning: ${err.code ?? '?'}: ${err.message}\n`);
55
- process.exit(0);
62
+ writeError(`spotter-hook: session-end shutdown warning: ${err.code ?? '?'}: ${err.message}\n`);
56
63
  }
57
64
  }
58
65