sf snapshot: pre-dispatch, uncommitted changes after 31m inactivity

This commit is contained in:
Mikael Hugo 2026-04-30 23:13:30 +02:00
parent 51202225ec
commit 9843425836
9 changed files with 155 additions and 42 deletions

View file

@ -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}`,
),
);
}

View file

@ -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);
}

View file

@ -1,11 +1,13 @@
import { importExtensionModule } from "@singularity-forge/pi-coding-agent";
export { registerSFCommand } from "./commands/index.js";
export async function handleSFCommand(
...args: Parameters<typeof import("./commands/dispatcher.js").handleSFCommand>
) {
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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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) => {

View file

@ -166,8 +166,6 @@ const KNOWN_COMMAND_PREFIXES = new Set([
"npx",
"yarn",
"pnpm",
"bun",
"bunx",
"deno",
"node",
"ts-node",

View file

@ -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<number>((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<string, unknown>,
};
}
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<string, unknown>,
details: {
operation: "call_scout",
exitCode,
siftBin,
query,
scope,
strategy,
} as Record<string, unknown>,
};
}

View file

@ -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(