diff --git a/package.json b/package.json index 42e9bf32d..c4b4dea1b 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,8 @@ "@singularity-forge/engine-linux-x64-gnu": ">=2.10.2", "@singularity-forge/engine-win32-x64-msvc": ">=2.10.2", "fsevents": "~2.3.3", - "koffi": "^2.9.0" + "koffi": "^2.9.0", + "vectordrive": "^0.1.35" }, "overrides": { "gaxios": "^6.7.1" diff --git a/packages/pi-agent-core/src/agent-loop.test.ts b/packages/pi-agent-core/src/agent-loop.test.ts index 799f3435f..a6e463bb2 100644 --- a/packages/pi-agent-core/src/agent-loop.test.ts +++ b/packages/pi-agent-core/src/agent-loop.test.ts @@ -100,6 +100,110 @@ describe("agent-loop — pauseTurn handling (#2869)", () => { }); }); +describe("agent-loop — steering during tool batches", () => { + it("defers queued steering until after the current tool batch when configured", async () => { + const calls: string[] = []; + const tool = { + name: "record", + label: "Record", + description: "Record a value", + parameters: Type.Object({ value: Type.String() }), + execute: async (_id: string, args: { value: string }) => { + calls.push(args.value); + return { + content: [{ type: "text" as const, text: `recorded ${args.value}` }], + details: {}, + }; + }, + } satisfies AgentTool<{ value: string }>; + + const first = makeAssistantMessage({ + content: [ + { + type: "toolCall", + id: "tc-1", + name: "record", + arguments: { value: "one" }, + }, + { + type: "toolCall", + id: "tc-2", + name: "record", + arguments: { value: "two" }, + }, + ], + stopReason: "toolUse", + }); + const second = makeAssistantMessage({ + content: [{ type: "text", text: "saw steering" }], + stopReason: "stop", + }); + const mockStream = createMockStreamFn([first, second]); + let steeringPolls = 0; + const steering: AgentMessage = { + role: "user", + content: [{ type: "text", text: "do not interrupt" }], + timestamp: Date.now(), + }; + + const context: AgentContext = { + systemPrompt: "You are a test agent.", + messages: [ + { + role: "user", + content: [{ type: "text", text: "record values" }], + timestamp: Date.now(), + }, + ], + tools: [tool], + }; + + const config: AgentLoopConfig = { + model: TEST_MODEL, + convertToLlm: (msgs) => msgs.filter((m): m is any => m.role !== "custom"), + toolExecution: "sequential", + interruptToolExecutionOnSteering: false, + getSteeringMessages: async () => { + steeringPolls += 1; + return steeringPolls === 1 ? [steering] : []; + }, + }; + + const stream = agentLoop( + [ + { + role: "user", + content: [{ type: "text", text: "record values" }], + timestamp: Date.now(), + }, + ], + context, + config, + undefined, + mockStream as any, + ); + + const events = await collectEvents(stream); + const skipped = events.filter( + (event) => + event.type === "tool_execution_end" && + JSON.stringify(event.result.content).includes( + "Skipped due to queued user message", + ), + ); + + assert.deepEqual(calls, ["one", "two"]); + assert.equal(skipped.length, 0); + assert.ok( + events.some( + (event) => + event.type === "message_start" && event.message === steering, + ), + "queued steering should still be delivered after the tool batch", + ); + }); +}); + /** * Regression tests for #2783: Stuck-loop on execute-task — tool-call schema * overload causes unbounded retry + budget burn. diff --git a/packages/pi-agent-core/src/agent-loop.ts b/packages/pi-agent-core/src/agent-loop.ts index 2ca1df976..23fbfdad2 100644 --- a/packages/pi-agent-core/src/agent-loop.ts +++ b/packages/pi-agent-core/src/agent-loop.ts @@ -517,6 +517,7 @@ async function executeToolCallsSequential( const results: ToolResultMessage[] = []; let steeringMessages: AgentMessage[] | undefined; let preparationErrorCount = 0; + const interruptOnSteering = config.interruptToolExecutionOnSteering !== false; for (let index = 0; index < toolCalls.length; index++) { const toolCall = toolCalls[index]; @@ -551,12 +552,14 @@ async function executeToolCallsSequential( if (config.getSteeringMessages) { const steering = await config.getSteeringMessages(); if (steering.length > 0) { - steeringMessages = steering; - const remainingCalls = toolCalls.slice(index + 1); - for (const skipped of remainingCalls) { - results.push(skipToolCall(skipped, stream)); + steeringMessages = [...(steeringMessages ?? []), ...steering]; + if (interruptOnSteering) { + const remainingCalls = toolCalls.slice(index + 1); + for (const skipped of remainingCalls) { + results.push(skipToolCall(skipped, stream)); + } + break; } - break; } } } @@ -576,6 +579,7 @@ async function executeToolCallsParallel( const runnableCalls: PreparedToolCall[] = []; let steeringMessages: AgentMessage[] | undefined; let preparationErrorCount = 0; + const interruptOnSteering = config.interruptToolExecutionOnSteering !== false; for (let index = 0; index < toolCalls.length; index++) { const toolCall = toolCalls[index]; @@ -599,15 +603,17 @@ async function executeToolCallsParallel( if (config.getSteeringMessages) { const steering = await config.getSteeringMessages(); if (steering.length > 0) { - steeringMessages = steering; - for (const runnable of runnableCalls) { - results.push(skipToolCall(runnable.toolCall, stream, { emitStart: false })); + steeringMessages = [...(steeringMessages ?? []), ...steering]; + if (interruptOnSteering) { + for (const runnable of runnableCalls) { + results.push(skipToolCall(runnable.toolCall, stream, { emitStart: false })); + } + const remainingCalls = toolCalls.slice(index + 1); + for (const skipped of remainingCalls) { + results.push(skipToolCall(skipped, stream)); + } + return { toolResults: results, steeringMessages, preparationErrorCount }; } - const remainingCalls = toolCalls.slice(index + 1); - for (const skipped of remainingCalls) { - results.push(skipToolCall(skipped, stream)); - } - return { toolResults: results, steeringMessages, preparationErrorCount }; } } } diff --git a/packages/pi-agent-core/src/agent.ts b/packages/pi-agent-core/src/agent.ts index d582154b4..118eda0be 100644 --- a/packages/pi-agent-core/src/agent.ts +++ b/packages/pi-agent-core/src/agent.ts @@ -57,6 +57,12 @@ export interface AgentOptions { */ steeringMode?: "all" | "one-at-a-time"; + /** + * Whether steering messages interrupt the current assistant tool batch. + * Defaults to true for interactive sessions. + */ + interruptToolExecutionOnSteering?: boolean; + /** * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn */ @@ -161,6 +167,7 @@ export class Agent { private _afterToolCall?: AgentLoopConfig["afterToolCall"]; private _externalToolExecution?: (model: Model) => boolean; private _getProviderOptions?: AgentOptions["getProviderOptions"]; + private _interruptToolExecutionOnSteering: boolean; constructor(opts: AgentOptions = {}) { this._state = { ...this._state, ...opts.initialState }; @@ -177,6 +184,8 @@ export class Agent { this._maxRetryDelayMs = opts.maxRetryDelayMs; this._externalToolExecution = opts.externalToolExecution; this._getProviderOptions = opts.getProviderOptions; + this._interruptToolExecutionOnSteering = + opts.interruptToolExecutionOnSteering ?? true; } /** @@ -520,6 +529,7 @@ export class Agent { getFollowUpMessages: async () => this.dequeueFollowUpMessages(), beforeToolCall: this._beforeToolCall, afterToolCall: this._afterToolCall, + interruptToolExecutionOnSteering: this._interruptToolExecutionOnSteering, externalToolExecution: this._externalToolExecution?.(model) ?? false, }; diff --git a/packages/pi-agent-core/src/types.ts b/packages/pi-agent-core/src/types.ts index d7b407291..4e45f6a46 100644 --- a/packages/pi-agent-core/src/types.ts +++ b/packages/pi-agent-core/src/types.ts @@ -153,6 +153,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ getSteeringMessages?: () => Promise; + /** + * Whether steering messages interrupt the current assistant tool batch. + * + * Default true preserves interactive behavior: a user message typed while + * tools are running skips remaining tool calls and lets the model react + * immediately. Headless/auto runners can set this false so incidental + * queued messages are deferred until the current tool batch finishes. + */ + interruptToolExecutionOnSteering?: boolean; + /** * Returns follow-up messages to process after the agent would otherwise stop. * diff --git a/packages/pi-ai/scripts/generate-models.ts b/packages/pi-ai/scripts/generate-models.ts index 6f5ccb8ec..5c6d29a71 100644 --- a/packages/pi-ai/scripts/generate-models.ts +++ b/packages/pi-ai/scripts/generate-models.ts @@ -605,18 +605,21 @@ async function loadModelsDevData(): Promise[]> { } } - // Process Kimi For Coding models + // Process Kimi For Coding models. The kimi-coding endpoint is + // permissive on model slug, so we surface a single canonical id + // (kimi-k2.6) instead of the upstream "kimi-for-coding" alias to + // match opencode-go's slug for the same Moonshot K2.6 model. if (data["kimi-for-coding"]?.models) { for (const [modelId, model] of Object.entries(data["kimi-for-coding"].models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + const canonicalId = modelId === "kimi-for-coding" ? "kimi-k2.6" : modelId; models.push({ - id: modelId, - name: m.name || modelId, + id: canonicalId, + name: m.name || canonicalId, api: "anthropic-messages", provider: "kimi-coding", - // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages baseUrl: "https://api.kimi.com/coding", reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], @@ -1249,7 +1252,7 @@ async function generateModels() { const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding"; const kimiCodingModels: Model<"anthropic-messages">[] = [ { - id: "kimi-for-coding", + id: "kimi-k2.6", name: "Kimi K2.6", api: "anthropic-messages", provider: "kimi-coding", diff --git a/packages/pi-ai/src/models.generated.ts b/packages/pi-ai/src/models.generated.ts index 743c13c02..45d2fb886 100644 --- a/packages/pi-ai/src/models.generated.ts +++ b/packages/pi-ai/src/models.generated.ts @@ -4616,8 +4616,8 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "kimi-coding": { - "kimi-for-coding": { - id: "kimi-for-coding", + "kimi-k2.6": { + id: "kimi-k2.6", name: "Kimi K2.6", api: "anthropic-messages", provider: "kimi-coding", diff --git a/packages/pi-coding-agent/src/core/model-discovery.ts b/packages/pi-coding-agent/src/core/model-discovery.ts index 2ff50ad37..63f49a99b 100644 --- a/packages/pi-coding-agent/src/core/model-discovery.ts +++ b/packages/pi-coding-agent/src/core/model-discovery.ts @@ -436,12 +436,7 @@ const adapters: Record = { executionBaseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", }), - "kimi-coding": new ProviderModelListAdapter({ - provider: "kimi-coding", - discoveryBaseUrl: "https://api.kimi.com/coding/v1", - executionBaseUrl: "https://api.kimi.com/coding", - api: "anthropic-messages", - }), + "kimi-coding": new StaticDiscoveryAdapter("kimi-coding"), mistral: new ProviderModelListAdapter({ provider: "mistral", discoveryBaseUrl: "https://api.mistral.ai/v1", diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 35d2202be..e8c8d9fdb 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -383,6 +383,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), + interruptToolExecutionOnSteering: process.env.SF_HEADLESS !== "1", transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs, diff --git a/scripts/model-smoke-benchmark.mjs b/scripts/model-smoke-benchmark.mjs index ad00ba586..7bb86e1e3 100644 --- a/scripts/model-smoke-benchmark.mjs +++ b/scripts/model-smoke-benchmark.mjs @@ -22,7 +22,7 @@ const { getModel, streamSimpleOpenAICompletions } = await import("../packages/pi const modelIds = modelsArg ? modelsArg.split(",").map((s) => s.trim()).filter(Boolean) : [ - "kimi-coding/kimi-for-coding", + "kimi-coding/kimi-k2.6", "minimax/MiniMax-M2.7-highspeed", "zai/glm-4.5", "mistral/devstral-latest", diff --git a/src/headless-events.ts b/src/headless-events.ts index 50601f28c..a93f84373 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -15,6 +15,7 @@ export const EXIT_SUCCESS = 0; export const EXIT_ERROR = 1; export const EXIT_BLOCKED = 10; export const EXIT_CANCELLED = 11; +export const EXIT_RELOAD = 12; /** * Map a headless session status string to its standardized exit code. diff --git a/src/headless.ts b/src/headless.ts index 8a1e04472..fee0cee35 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -10,6 +10,7 @@ * 1 — error or timeout * 10 — blocked (command reported a blocker) * 11 — cancelled (SIGINT/SIGTERM received) + * 12 — reload (agent requested restart-with-resume, same session) */ import type { ChildProcess } from "node:child_process"; @@ -32,6 +33,7 @@ import { EXIT_BLOCKED, EXIT_CANCELLED, EXIT_ERROR, + EXIT_RELOAD, EXIT_SUCCESS, FIRE_AND_FORGET_METHODS, IDLE_TIMEOUT_MS, diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index 9e70c3396..5ba46edf7 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -76,6 +76,7 @@ function readConfigs(): McpServerConfig[] { join(process.cwd(), ".sf", "mcp.json"), join(sfHome, "mcp.json"), // global: ~/.sf/mcp.json join(sfHome, "agent", "mcp.json"), // global: ~/.sf/agent/mcp.json (legacy alt) + join(homedir(), ".mcp.json"), // user-global: ~/.mcp.json (Claude Code, npx, etc.) ]; for (const configPath of configPaths) { diff --git a/src/resources/extensions/sf/agentic-docs-scaffold.ts b/src/resources/extensions/sf/agentic-docs-scaffold.ts index 621ce52c2..14b7a63b2 100644 --- a/src/resources/extensions/sf/agentic-docs-scaffold.ts +++ b/src/resources/extensions/sf/agentic-docs-scaffold.ts @@ -7,6 +7,29 @@ interface ScaffoldFile { } const SCAFFOLD_FILES: ScaffoldFile[] = [ + { + path: ".siftignore", + content: `.git/** +.sf/** +.bg-shell/** +.pytest_cache/** +.venv/** +venv/** +node_modules/** +**/node_modules/** +**/__pycache__/** +*.pyc +*.egg-info/** +build/** +dist/** +target/** +vendor/** +coverage/** +.cache/** +tmp/** +*.log +`, + }, { path: "AGENTS.md", content: `# Agent Map diff --git a/src/resources/extensions/sf/auto-unit-closeout.ts b/src/resources/extensions/sf/auto-unit-closeout.ts index 42c358e24..72bf37fea 100644 --- a/src/resources/extensions/sf/auto-unit-closeout.ts +++ b/src/resources/extensions/sf/auto-unit-closeout.ts @@ -36,7 +36,9 @@ export async function closeoutUnit( startedAt: number, opts?: CloseoutOptions, ): Promise { - const modelId = ctx.model?.id ?? "unknown"; + const provider = ctx.model?.provider; + const id = ctx.model?.id; + const modelId = provider && id ? `${provider}/${id}` : (id ?? "unknown"); snapshotUnitMetrics(ctx, unitType, unitId, startedAt, modelId, opts); const activityFile = saveActivityLog(ctx, basePath, unitType, unitId); diff --git a/src/resources/extensions/sf/bootstrap/exec-tools.ts b/src/resources/extensions/sf/bootstrap/exec-tools.ts index 28e388657..48f3d6bd8 100644 --- a/src/resources/extensions/sf/bootstrap/exec-tools.ts +++ b/src/resources/extensions/sf/bootstrap/exec-tools.ts @@ -32,7 +32,12 @@ export function registerExecTools(pi: ExtensionAPI): void { ], parameters: Type.Object({ runtime: Type.Union( - [Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")], + [ + Type.Literal("bash"), + Type.Literal("node"), + Type.Literal("python"), + Type.Literal("python3"), + ], { description: "Interpreter: bash (-c), node (-e), or python3 (-c)." }, ), script: Type.String({ @@ -92,7 +97,12 @@ export function registerExecTools(pi: ExtensionAPI): void { ), runtime: Type.Optional( Type.Union( - [Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")], + [ + Type.Literal("bash"), + Type.Literal("node"), + Type.Literal("python"), + Type.Literal("python3"), + ], { description: "Restrict to one runtime.", }, @@ -139,4 +149,37 @@ export function registerExecTools(pi: ExtensionAPI): void { }); }, }); + + /** + * Kill the current pi-agent subprocess. In headless mode: the supervisor + * (sf run-headless) will detect the non-zero exit and restart the agent (up to + * max-restarts=3, default). In interactive TUI mode: the process exits and + * the user restarts manually. + * + * Use this to force-reload extension code (e.g. after a `~/.mcp.json` change) + * or to escape a stuck state that cannot be resolved via normal tool calls. + */ + pi.registerTool({ + name: "kill_agent", + label: "Kill Agent", + description: + "Kill the current pi-agent subprocess so the supervisor restarts it with fresh code. " + + "Use after updating extension config files (e.g. ~/.mcp.json) that require a process restart " + + "to take effect. The supervisor will restart the agent automatically (headless mode) or " + + "you restart manually (interactive TUI).", + promptSnippet: + "Kill and restart the pi-agent to reload fresh extension code", + promptGuidelines: [ + "Use this only when normal tool calls cannot achieve the desired effect and a clean restart is needed.", + "In headless mode: the supervisor will restart the agent automatically.", + "In interactive TUI: you will need to restart sf manually.", + ], + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + // Exit with 1 so headless supervisor treats it as a crash and restarts. + // Exit 0 would cause headless to exit normally without restarting. + process.exit(1); + // unreachable + }, + }); } diff --git a/src/resources/extensions/sf/code-intelligence.ts b/src/resources/extensions/sf/code-intelligence.ts index 8452912bc..db5b08674 100644 --- a/src/resources/extensions/sf/code-intelligence.ts +++ b/src/resources/extensions/sf/code-intelligence.ts @@ -343,7 +343,17 @@ function isFreshMarker( ): boolean { try { const stat = statSync(markerPath); - return now - stat.mtimeMs < ttlMs; + if (now - stat.mtimeMs >= ttlMs) return false; + const parsed = JSON.parse(readFileSync(markerPath, "utf-8")) as { + schemaVersion?: number; + cwd?: string; + args?: unknown; + }; + return ( + parsed.schemaVersion === 2 && + Array.isArray(parsed.args) && + parsed.args.at(-2) === "." + ); } catch { return false; } @@ -403,7 +413,7 @@ export function ensureSiftIndexWarmup( options.retrieverTimeoutMs ?? DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS, ), - projectRoot, + ".", options.query ?? DEFAULT_SIFT_WARMUP_QUERY, ]; @@ -413,8 +423,10 @@ export function ensureSiftIndexWarmup( markerPath, `${JSON.stringify( { + schemaVersion: 2, startedAt: new Date(now).toISOString(), command: detection.binaryPath, + cwd: projectRoot, args, }, null, diff --git a/src/resources/extensions/sf/exec-sandbox.ts b/src/resources/extensions/sf/exec-sandbox.ts index 7677db312..5a455f14d 100644 --- a/src/resources/extensions/sf/exec-sandbox.ts +++ b/src/resources/extensions/sf/exec-sandbox.ts @@ -12,7 +12,7 @@ import { resolve } from "node:path"; export interface ExecSandboxRequest { /** Interpreter to use. */ - runtime: "bash" | "node" | "python"; + runtime: "bash" | "node" | "python" | "python3"; /** Script body. Executed via the runtime's -c equivalent. */ script: string; /** Optional purpose/label recorded in meta.json. */ @@ -122,6 +122,7 @@ function resolveCommand(runtime: ExecSandboxRequest["runtime"]): { case "node": return { cmd: process.execPath, args: ["-e"] }; case "python": + case "python3": return { cmd: "python3", args: ["-c"] }; } } diff --git a/src/resources/extensions/sf/learning/data/primary-provider-chain.json b/src/resources/extensions/sf/learning/data/primary-provider-chain.json index 170ac9c66..125d5ced1 100644 --- a/src/resources/extensions/sf/learning/data/primary-provider-chain.json +++ b/src/resources/extensions/sf/learning/data/primary-provider-chain.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, "entries": [ - { "provider": "kimi-coding", "model": "kimi-for-coding", "priority": 0 } + { "provider": "kimi-coding", "model": "kimi-k2.6", "priority": 0 } ] } diff --git a/src/resources/extensions/sf/metrics.ts b/src/resources/extensions/sf/metrics.ts index 114470eba..c2f568934 100644 --- a/src/resources/extensions/sf/metrics.ts +++ b/src/resources/extensions/sf/metrics.ts @@ -35,34 +35,6 @@ export { formatTokenCount } from "../shared/format-utils.js"; // ─── Learning Integration ───────────────────────────────────────────────────── -/** - * Infer provider from a model ID when not explicitly prefixed with "provider/". - */ -function inferProviderFromBareModelId(modelId: string): string { - const lower = modelId.toLowerCase(); - if ( - lower === "kimi-for-coding" || - lower === "kimi-k2-thinking" - ) - return "kimi-coding"; - if (lower.startsWith("minimax-m")) return "ollama-cloud"; - if (lower.startsWith("minimax-") || modelId.startsWith("MiniMax-")) - return "minimax"; - if (lower.startsWith("glm-")) return "zai"; - if (lower.startsWith("mimo-")) return "xiaomi"; - if (lower.startsWith("gemini-")) return "google-gemini-cli"; - if ( - lower.startsWith("magistral-") || - lower.startsWith("mistral-") || - lower.startsWith("devstral-") || - lower.startsWith("codestral-") || - lower.startsWith("ministral-") || - lower.startsWith("pixtral-") - ) - return "mistral"; - return "unknown"; -} - function formatAggregateModelIdentity(modelId: string): string { const slashIdx = modelId.indexOf("/"); if (slashIdx === -1) return formatModelIdentity({ id: modelId }); @@ -81,13 +53,12 @@ async function recordUnitOutcome(unit: UnitMetrics): Promise { try { const { recordOutcome } = await import("./learning/outcome-recorder.mjs"); - let provider: string; const modelId = unit.model; - if (modelId.includes("/")) { - [provider] = modelId.split("/"); - } else { - provider = inferProviderFromBareModelId(modelId); - } + const slashIdx = modelId.indexOf("/"); + // Unit metrics always carry full "provider/model" slugs upstream; + // drop bare-id entries rather than guessing the provider. + if (slashIdx === -1) return; + const provider = modelId.slice(0, slashIdx); recordOutcome(db, { modelId, diff --git a/src/resources/extensions/sf/tests/agentic-docs-scaffold.test.ts b/src/resources/extensions/sf/tests/agentic-docs-scaffold.test.ts index 31114c943..45b7f32b7 100644 --- a/src/resources/extensions/sf/tests/agentic-docs-scaffold.test.ts +++ b/src/resources/extensions/sf/tests/agentic-docs-scaffold.test.ts @@ -46,6 +46,14 @@ test("ensureAgenticDocsScaffold creates repo docs and nested AGENTS files withou readFileSync(join(dir, "docs", "QUALITY_SCORE.md"), "utf-8"), /Make code legible to agents/, ); + assert.match( + readFileSync(join(dir, ".siftignore"), "utf-8"), + /\*\*\/node_modules\/\*\*/, + ); + assert.match( + readFileSync(join(dir, ".siftignore"), "utf-8"), + /\.venv\/\*\*/, + ); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/resources/extensions/sf/tests/code-intelligence.test.ts b/src/resources/extensions/sf/tests/code-intelligence.test.ts index 525739738..96c8bd2e3 100644 --- a/src/resources/extensions/sf/tests/code-intelligence.test.ts +++ b/src/resources/extensions/sf/tests/code-intelligence.test.ts @@ -354,9 +354,9 @@ test("ensureSiftIndexWarmup starts page-index-hybrid warmup and writes marker", const projectRoot = makeProject(); try { const fakeSift = writeFakeSiftBinary(projectRoot); - const calls: Array<{ command: string; args: string[] }> = []; - const fakeSpawn = ((command: string, args: string[]) => { - calls.push({ command, args }); + const calls: Array<{ command: string; args: string[]; cwd?: string }> = []; + const fakeSpawn = ((command: string, args: string[], options?: { cwd?: string }) => { + calls.push({ command, args, cwd: options?.cwd }); return { unref() {} }; }) as unknown as typeof import("node:child_process").spawn; @@ -378,7 +378,8 @@ test("ensureSiftIndexWarmup starts page-index-hybrid warmup and writes marker", "1", "--retriever-timeout-ms", ]); - assert.equal(calls[0].args.at(-2), projectRoot); + assert.equal(calls[0].args.at(-2), "."); + assert.equal(calls[0].cwd, projectRoot); assert.match(calls[0].args.at(-1) ?? "", /repo architecture/); assert.match( readFileSync( @@ -446,6 +447,46 @@ test("ensureSiftIndexWarmup skips recent marker and explicit non-sift backends", } }); +test("ensureSiftIndexWarmup ignores stale absolute-path warmup markers", () => { + const projectRoot = makeProject(); + try { + writeFakeSiftBinary(projectRoot); + mkdirSync(join(projectRoot, ".sf", "runtime"), { recursive: true }); + writeFileSync( + join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"), + `${JSON.stringify({ + startedAt: "2026-04-30T12:00:00.000Z", + command: join(projectRoot, "bin", "sift"), + args: ["search", projectRoot, "repo architecture"], + })}\n`, + "utf-8", + ); + let spawnCount = 0; + const fakeSpawn = (() => { + spawnCount += 1; + return { unref() {} }; + }) as unknown as typeof import("node:child_process").spawn; + + const result = ensureSiftIndexWarmup(projectRoot, undefined, { + env: { PATH: join(projectRoot, "bin") }, + spawnFn: fakeSpawn, + now: Date.parse("2026-04-30T12:01:00.000Z"), + }); + + assert.equal(result.status, "started"); + assert.equal(spawnCount, 1); + assert.match( + readFileSync( + join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"), + "utf-8", + ), + /"schemaVersion": 2/, + ); + } finally { + cleanup(projectRoot); + } +}); + test("resolveSiftBinary honors SIFT_PATH before PATH lookup", () => { const projectRoot = makeProject(); try { diff --git a/src/resources/extensions/sf/tools/exec-tool.ts b/src/resources/extensions/sf/tools/exec-tool.ts index 6a847c975..8974d6d43 100644 --- a/src/resources/extensions/sf/tools/exec-tool.ts +++ b/src/resources/extensions/sf/tools/exec-tool.ts @@ -121,9 +121,9 @@ export async function executeSfExec( if (!isContextModeEnabled(deps.preferences)) return disabledResult(); const runtime = params.runtime; - if (runtime !== "bash" && runtime !== "node" && runtime !== "python") { + if (runtime !== "bash" && runtime !== "node" && runtime !== "python" && runtime !== "python3") { return paramError( - `invalid runtime "${String(runtime)}" — must be bash | node | python`, + `invalid runtime "${String(runtime)}" — must be bash | node | python | python3`, ); } const script = typeof params.script === "string" ? params.script : ""; diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 5ba6d5696..6e59cc52c 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -1070,9 +1070,12 @@ async function runSingleAgent( .map((s) => s.trim()) .filter(Boolean); const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]); - const proc = spawn( - process.execPath, - [process.env.SF_BIN_PATH!, ...extensionArgs, ...args], + // Execute SF_BIN_PATH directly — it is an executable shell script (sf-from-source) + // with a proper shebang. Do NOT pass it to process.execPath as a node script arg, + // otherwise Node parses the bash file as JavaScript and fails with a syntax error. + const proc = spawn( + process.env.SF_BIN_PATH!, + [...extensionArgs, ...args], { cwd: cwd ?? defaultCwd, shell: false, @@ -1247,8 +1250,12 @@ async function runSingleAgentInCmuxSplit( .map((s) => s.trim()) .filter(Boolean); const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]); + // SF_BIN_PATH is an executable shell script with a shebang. + // Execute it directly — do NOT pass it to node as a module arg (node would + // try to parse the shell script as JavaScript and fail with a syntax error). + // The OS honors the shebang when the file is exec'd directly. + const sfBinPath = process.env.SF_BIN_PATH!; const processArgs = [ - process.env.SF_BIN_PATH!, ...extensionArgs, ...buildSubagentProcessArgs(agent, task, tmpPromptPath, modelOverride), ]; @@ -1259,7 +1266,7 @@ async function runSingleAgentInCmuxSplit( const innerScript = [ `cd ${bashPath(cwd ?? defaultCwd)}`, "set -o pipefail", - `${bashPath(process.execPath)} ${processArgs.map((a) => bashPath(a)).join(" ")} 2> >(tee ${bashPath(stderrPath)} >&2) | tee ${bashPath(stdoutPath)}`, + `${bashPath(sfBinPath)} ${processArgs.map((a) => bashPath(a)).join(" ")} 2> >(tee ${bashPath(stderrPath)} >&2) | tee ${bashPath(stdoutPath)}`, // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — bash variable syntax "status=${PIPESTATUS[0]}", `printf '%s' "$status" > ${bashPath(exitPath)}`,