diff --git a/src/cli.ts b/src/cli.ts index 0a8ab6235..dcb42f7e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,11 +20,11 @@ import { parseCliArgs, runWebCliBranch, } from "./cli-web-branch.js"; +import { error, formatStructuredError } from "./errors.js"; import { printHelp, printSubcommandHelp } from "./help-text.js"; import { runOnboarding, shouldRunOnboarding } from "./onboarding.js"; import { migratePiCredentials } from "./pi-migration.js"; import { getProjectSessionsDir } from "./project-sessions.js"; -import { error, formatStructuredError } from "./errors.js"; import { buildResourceLoader, getNewerManagedResourceVersion, @@ -129,7 +129,7 @@ function printExtensionErrors(errors: ReadonlyArray<{ error: string }>): void { process.stderr.write( formatStructuredError( error(err.error, { operation: "loadExtension", guidance }), - "[sf]", + `[sf] ${prefix}`, ), ); } diff --git a/src/resources/extensions/sf/commands-do.ts b/src/resources/extensions/sf/commands-do.ts index 90260f72e..da9ace74d 100644 --- a/src/resources/extensions/sf/commands-do.ts +++ b/src/resources/extensions/sf/commands-do.ts @@ -9,6 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext, } from "@singularity-forge/pi-coding-agent"; +import { importExtensionModule } from "@singularity-forge/pi-coding-agent"; interface Route { keywords: string[]; @@ -160,13 +161,17 @@ export async function handleDo( ctx.ui.notify(`→ /sf ${fullCommand}`, "info"); // Re-dispatch through the main dispatcher - const { handleSFCommand } = await import("./commands/dispatcher.js"); + const { handleSFCommand } = await importExtensionModule< + typeof import("./commands/dispatcher.js") + >(import.meta.url, "./commands/dispatcher.js"); await handleSFCommand(fullCommand, ctx, pi); return; } // No keyword match → treat as quick task ctx.ui.notify(`→ /sf quick ${args}`, "info"); - const { handleQuick } = await import("./quick.js"); + const { handleQuick } = await importExtensionModule< + typeof import("./quick.js") + >(import.meta.url, "./quick.js"); await handleQuick(args, ctx, pi); } diff --git a/src/resources/extensions/sf/commands.ts b/src/resources/extensions/sf/commands.ts index 534d3145b..9d6a1e453 100644 --- a/src/resources/extensions/sf/commands.ts +++ b/src/resources/extensions/sf/commands.ts @@ -1,11 +1,13 @@ +import { importExtensionModule } from "@singularity-forge/pi-coding-agent"; + export { registerSFCommand } from "./commands/index.js"; export async function handleSFCommand( ...args: Parameters ) { - const { handleSFCommand: dispatch } = await import( - "./commands/dispatcher.js" - ); + const { handleSFCommand: dispatch } = await importExtensionModule< + typeof import("./commands/dispatcher.js") + >(import.meta.url, "./commands/dispatcher.js"); return dispatch(...args); } @@ -14,8 +16,8 @@ export async function fireStatusViaCommand( typeof import("./commands/handlers/core.js").fireStatusViaCommand > ) { - const { fireStatusViaCommand: fireStatus } = await import( - "./commands/handlers/core.js" - ); + const { fireStatusViaCommand: fireStatus } = await importExtensionModule< + typeof import("./commands/handlers/core.js") + >(import.meta.url, "./commands/handlers/core.js"); return fireStatus(...args); } diff --git a/src/resources/extensions/sf/commands/index.ts b/src/resources/extensions/sf/commands/index.ts index 3a87513af..fed166014 100644 --- a/src/resources/extensions/sf/commands/index.ts +++ b/src/resources/extensions/sf/commands/index.ts @@ -2,19 +2,21 @@ import type { ExtensionAPI, ExtensionCommandContext, } from "@singularity-forge/pi-coding-agent"; +import { importExtensionModule } from "@singularity-forge/pi-coding-agent"; -import { - getSfArgumentCompletions, - SF_COMMAND_DESCRIPTION, -} from "./catalog.js"; +import { getSfArgumentCompletions, SF_COMMAND_DESCRIPTION } from "./catalog.js"; export function registerSFCommand(pi: ExtensionAPI): void { pi.registerCommand("sf", { description: SF_COMMAND_DESCRIPTION, getArgumentCompletions: getSfArgumentCompletions, handler: async (args: string, ctx: ExtensionCommandContext) => { - const { handleSFCommand } = await import("./dispatcher.js"); - const { setStderrLoggingEnabled } = await import("../workflow-logger.js"); + const { handleSFCommand } = await importExtensionModule< + typeof import("./dispatcher.js") + >(import.meta.url, "./dispatcher.js"); + const { setStderrLoggingEnabled } = await importExtensionModule< + typeof import("../workflow-logger.js") + >(import.meta.url, "../workflow-logger.js"); const previousStderrSetting = setStderrLoggingEnabled(false); try { await handleSFCommand(args, ctx, pi); diff --git a/src/resources/extensions/sf/detection.ts b/src/resources/extensions/sf/detection.ts index 9002f45f0..be82c5ec9 100644 --- a/src/resources/extensions/sf/detection.ts +++ b/src/resources/extensions/sf/detection.ts @@ -682,18 +682,41 @@ function detectXcodePlatforms(basePath: string): XcodePlatform[] { // ─── Package Manager Detection ────────────────────────────────────────────────── function detectPackageManager(basePath: string): string | undefined { + const declared = readPackageJsonPackageManager(basePath); + if (declared) return declared; + if (existsSync(join(basePath, "pnpm-lock.yaml"))) return "pnpm"; if (existsSync(join(basePath, "yarn.lock"))) return "yarn"; if ( existsSync(join(basePath, "bun.lockb")) || existsSync(join(basePath, "bun.lock")) ) - return "bun"; + return existsSync(join(basePath, "package.json")) ? "npm" : undefined; if (existsSync(join(basePath, "package-lock.json"))) return "npm"; if (existsSync(join(basePath, "package.json"))) return "npm"; return undefined; } +function readPackageJsonPackageManager(basePath: string): string | undefined { + try { + const raw = readFileSync(join(basePath, "package.json"), "utf-8"); + const pkg = JSON.parse(raw); + if (typeof pkg.packageManager !== "string") return undefined; + const name = pkg.packageManager.split("@")[0]; + if ( + name === "npm" || + name === "pnpm" || + name === "yarn" + ) { + return name; + } + if (name === "bun") return "npm"; + return undefined; + } catch { + return undefined; + } +} + // ─── Verification Command Detection ───────────────────────────────────────────── /** @@ -712,9 +735,7 @@ function detectVerificationCommands( ? "npm run" : pm === "yarn" ? "yarn" - : pm === "bun" - ? "bun run" - : `${pm} run`; + : `${pm} run`; if (detectedFiles.includes("package.json")) { const scripts = readPackageJsonScripts(basePath); diff --git a/src/resources/extensions/sf/tests/detection.test.ts b/src/resources/extensions/sf/tests/detection.test.ts index c550a8228..34335f5af 100644 --- a/src/resources/extensions/sf/tests/detection.test.ts +++ b/src/resources/extensions/sf/tests/detection.test.ts @@ -303,7 +303,40 @@ test("detectProjectSignals: package manager detection", (t) => { writeFileSync(join(dir3, "bun.lockb"), "", "utf-8"); writeFileSync(join(dir3, "package.json"), "{}", "utf-8"); - assert.equal(detectProjectSignals(dir3).packageManager, "bun"); + assert.equal(detectProjectSignals(dir3).packageManager, "npm"); +}); + +test("detectProjectSignals: packageManager field overrides lockfile heuristic", (t) => { + const dir = makeTempDir("pm-declared-npm"); + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "bun.lockb"), "", "utf-8"); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ packageManager: "npm@10.9.3" }), + "utf-8", + ); + + assert.equal(detectProjectSignals(dir).packageManager, "npm"); +}); + +test("detectProjectSignals: bun packageManager is normalized to npm for verification", (t) => { + const dir = makeTempDir("pm-declared-bun"); + t.after(() => cleanup(dir)); + + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + packageManager: "bun@1.3.3", + scripts: { test: "node --test" }, + }), + "utf-8", + ); + + const signals = detectProjectSignals(dir); + assert.equal(signals.packageManager, "npm"); + assert.ok(signals.verificationCommands.includes("npm test")); + assert.ok(!signals.verificationCommands.some((cmd) => cmd.includes("bun"))); }); test("detectProjectSignals: skips default npm test script", (t) => { diff --git a/src/resources/extensions/sf/verification-gate.ts b/src/resources/extensions/sf/verification-gate.ts index a15a8668c..d69a2d49b 100644 --- a/src/resources/extensions/sf/verification-gate.ts +++ b/src/resources/extensions/sf/verification-gate.ts @@ -166,8 +166,6 @@ const KNOWN_COMMAND_PREFIXES = new Set([ "npx", "yarn", "pnpm", - "bun", - "bunx", "deno", "node", "ts-node", diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 7e645d751..b5a1bdeb3 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -2354,44 +2354,78 @@ export default function (pi: ExtensionAPI) { return fs.existsSync(homeBin) ? homeBin : "sift"; })(); - const args = [ - "search", - "--strategy", - strategy, - "--agent", - query, - scope, - ]; + const args = ["search", "--strategy", strategy, "--agent", query, scope]; const stderr: string[] = []; const stdout: string[] = []; + let wasAborted = false; const proc = spawn(siftBin, args, { cwd: scope, shell: false, stdio: ["ignore", "pipe", "pipe"], }); + liveSubagentProcesses.add(proc); // Collect output proc.stdout.on("data", (chunk) => stdout.push(chunk.toString())); proc.stderr.on("data", (chunk) => stderr.push(chunk.toString())); // Handle abort signal - if (signal) { - signal.addEventListener("abort", () => { - try { - proc.kill("SIGTERM"); - } catch { - // ignore + const killProc = () => { + wasAborted = true; + try { + proc.kill("SIGTERM"); + } catch { + // ignore + } + setTimeout(() => { + if (proc.exitCode === null) { + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } } - }); + }, 5000).unref?.(); + }; + if (signal) { + if (signal.aborted) killProc(); + else signal.addEventListener("abort", killProc, { once: true }); } const exitCode = await new Promise((resolve) => { - proc.on("close", (code) => resolve(code ?? 0)); - proc.on("error", () => resolve(1)); + proc.on("close", (code) => { + liveSubagentProcesses.delete(proc); + if (signal) signal.removeEventListener("abort", killProc); + resolve(code ?? 0); + }); + proc.on("error", () => { + liveSubagentProcesses.delete(proc); + if (signal) signal.removeEventListener("abort", killProc); + resolve(1); + }); }); + if (wasAborted) { + return { + content: [ + { + type: "text", + text: "call_scout aborted.", + }, + ], + details: { + operation: "call_scout", + aborted: true, + siftBin, + query, + scope, + strategy, + } as Record, + }; + } + const out = stdout.join(""); const err = stderr.join("").trim(); @@ -2409,7 +2443,14 @@ export default function (pi: ExtensionAPI) { text: `call_scout failed (exit ${exitCode}). Is sift installed?${hint}`, }, ], - details: { operation: "call_scout", exitCode, siftBin, query, scope, strategy } as Record, + details: { + operation: "call_scout", + exitCode, + siftBin, + query, + scope, + strategy, + } as Record, }; } diff --git a/src/resources/extensions/subagent/tests/node-launch.test.ts b/src/resources/extensions/subagent/tests/node-launch.test.ts index aef4daeb6..4a2290758 100644 --- a/src/resources/extensions/subagent/tests/node-launch.test.ts +++ b/src/resources/extensions/subagent/tests/node-launch.test.ts @@ -19,11 +19,22 @@ test("subagent launcher resolves Node command specs instead of shelling through test("normal subagent execution spawns the resolved Node command with argv array", () => { assert.match( subagentSrc, - /spawn\(\s*launchSpec\.command,\s*\[\.\.\.extensionArgs,\s*\.\.\.launchSpec\.args\]/, + /spawn\(\s*launchSpec\.command,\s*launchSpec\.args,/, ); assert.match(subagentSrc, /shell:\s*false/); }); +test("call_scout subprocesses are tracked and killed on abort", () => { + const scoutIdx = subagentSrc.indexOf('name: "call_scout"'); + assert.ok(scoutIdx > 0, "call_scout tool must be registered"); + const scoutSrc = subagentSrc.slice(scoutIdx); + + assert.match(scoutSrc, /liveSubagentProcesses\.add\(proc\)/); + assert.match(scoutSrc, /liveSubagentProcesses\.delete\(proc\)/); + assert.match(scoutSrc, /signal\.addEventListener\("abort", killProc, \{ once: true \}\)/); + assert.match(scoutSrc, /proc\.kill\("SIGKILL"\)/); +}); + test("cmux launcher writes only explicit environment patch, not the full process env", () => { assert.match(subagentSrc, /function writeNodeSubagentLauncher\(/); assert.match(