fix(hooks): shared spawn-bin helper — all three AskUserQuestion hooks were inert on Windows

The plan-tune hooks resolved bin scripts via new URL(import.meta.url).pathname
(which doubles the drive letter on Windows: /C:/C:/...) and spawnSync'd
extensionless bash scripts directly (unrunnable without a shell association)
— so question logging, preferences, and the error fallback all silently
no-op'd on Windows, and /plan-tune collected no data. A single spawn-bin.ts
helper now owns bin resolution (fileURLToPath) and win32 bash routing for
every hook, with static tripwires so a future hook can't reintroduce the
raw pattern. This is the one Windows-spawn idiom for hook code.

Fixes #2356.

Contributed by @rafassousa (PR #2504; supersedes PR #2399 by @chuchu2781).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 20:20:53 -07:00
co-authored by Claude Fable 5
parent 99e2718943
commit d7ce124092
5 changed files with 182 additions and 19 deletions
@@ -31,7 +31,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { runBin } from './spawn-bin';
interface HookStdin {
tool_name?: string;
@@ -126,9 +126,7 @@ export function isErrorResponse(response: unknown): boolean {
* echoes). Falls back to 'interactive' (degrade-safe) on any failure. */
export function sessionKind(cwd?: string): 'spawned' | 'headless' | 'interactive' {
try {
const here = path.dirname(new URL(import.meta.url).pathname);
const bin = path.resolve(here, '..', '..', '..', 'bin', 'gstack-session-kind');
const res = spawnSync(bin, [], {
const res = runBin('gstack-session-kind', [], {
encoding: 'utf-8',
timeout: 3000,
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
+2 -7
View File
@@ -36,7 +36,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { runBin } from './spawn-bin';
interface HookStdin {
session_id?: string;
@@ -250,12 +250,7 @@ function detectSkill(cwd: string | undefined): string {
}
function spawnLog(payload: Record<string, unknown>, cwd?: string): void {
// Locate the bin relative to this script's directory.
const here = path.dirname(new URL(import.meta.url).pathname);
// hosts/claude/hooks/ -> ../../../bin/
const repoRoot = path.resolve(here, '..', '..', '..');
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
const res = spawnSync(bin, [JSON.stringify(payload)], {
const res = runBin('gstack-question-log', [JSON.stringify(payload)], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 3000,
@@ -43,7 +43,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { runBin, repoRoot } from './spawn-bin';
import { isConductor } from '../../../lib/is-conductor';
import { classifyQuestion } from '../../../scripts/one-way-doors';
@@ -240,9 +240,7 @@ function loadRegistry(): Record<string, RegistryEntry> {
registryCache = {};
try {
// Hook lives at hosts/claude/hooks/; registry at scripts/question-registry.ts
const here = path.dirname(new URL(import.meta.url).pathname);
const repoRoot = path.resolve(here, '..', '..', '..');
const regPath = path.join(repoRoot, 'scripts', 'question-registry.ts');
const regPath = path.join(repoRoot(), 'scripts', 'question-registry.ts');
if (!fs.existsSync(regPath)) return registryCache;
const src = fs.readFileSync(regPath, 'utf-8');
// Cheap regex extraction so the hook doesn't need to import the TS file
@@ -334,9 +332,6 @@ function logAutoDecided(
cwd: string | undefined,
): void {
try {
const here = path.dirname(new URL(import.meta.url).pathname);
const repoRoot = path.resolve(here, '..', '..', '..');
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
const payload: Record<string, unknown> = {
skill: 'unknown',
question_id: questionId,
@@ -348,7 +343,7 @@ function logAutoDecided(
session_id: sessionId?.slice(0, 64),
tool_use_id: toolUseId?.slice(0, 128),
};
spawnSync(bin, [JSON.stringify(payload)], {
runBin('gstack-question-log', [JSON.stringify(payload)], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 3000,
+43
View File
@@ -0,0 +1,43 @@
/**
* Windows-safe resolution + spawn for gstack's bash bins. Two Windows-only
* bugs made every hook subprocess a silent no-op; both are fixed here so all
* call sites are covered at once.
*
* 1. `new URL(import.meta.url).pathname` yields `/C:/Users/...`; path.resolve
* then rebases it onto the drive root as `C:\C:\Users\...`. fileURLToPath
* is the correct conversion. (ENOENT before the bin ever ran.)
* 2. `bin/gstack-*` are extensionless bash scripts. Windows has no shebang
* support, so they must be handed to bash explicitly.
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync, type SpawnSyncOptions } from 'child_process';
// Forward slashes on purpose: Bun's spawnSync on Windows returns ENOENT for a
// backslash exe path containing spaces.
const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe';
/** bash Windows itself can execute — env override, Git Bash, then PATH. */
function bashExe(): string {
return process.env.GSTACK_BASH || (fs.existsSync(GIT_BASH) ? GIT_BASH : 'bash');
}
/** gstack install root. This file lives at hosts/claude/hooks/. */
export function repoRoot(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, '..', '..', '..');
}
/** Absolute path to a `bin/` script. */
export function binPath(name: string): string {
return path.join(repoRoot(), 'bin', name);
}
/** Resolve `name` under bin/ and run it, via bash on Windows. */
export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
const bin = binPath(name);
return process.platform === 'win32'
? spawnSync(bashExe(), [bin, ...args], opts)
: spawnSync(bin, args, opts);
}