import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { Api, Model } from "@singularity-forge/pi-ai"; import { AuthStorage, createAgentSession, DefaultResourceLoader, InteractiveMode, listModels, ModelRegistry, runPackageCommand, runPrintMode, runRpcMode, SessionManager, SettingsManager, } from "@singularity-forge/pi-coding-agent"; import chalk from "chalk"; import { agentDir, authFilePath, sessionsDir } from "./app-paths.js"; import { migrateLegacyFlatSessions, parseCliArgs, runWebCliBranch, } from "./cli-web-branch.js"; import { error, formatStructuredError } from "./errors.js"; import { printHelp, printSubcommandHelp } from "./help-text.js"; import { acquireInteractiveSessionLock } from "./interactive-session-lock.js"; import { configureLogger } from "./logger.js"; import { runOnboarding, shouldRunOnboarding } from "./onboarding.js"; import { migratePiCredentials } from "./pi-migration.js"; import { getProjectSessionsDir } from "./project-sessions.js"; import { buildResourceLoader, getNewerManagedResourceVersion, initResources, } from "./resource-loader.js"; import { loadEffectiveSFPreferences } from "./resources/extensions/sf/preferences.js"; import { isProviderAllowedByLists } from "./resources/extensions/sf/preferences-models.js"; import { bootstrapRtk, SF_RTK_DISABLED_ENV } from "./rtk.js"; import { applySecurityOverrides } from "./security-overrides.js"; import { validateConfiguredModel } from "./startup-model-validation.js"; import { markStartup, printStartupTimings } from "./startup-timings.js"; import { ensureManagedTools } from "./tool-bootstrap.js"; import { checkForUpdates } from "./update-check.js"; import { stopWebMode } from "./web-mode.js"; import { loadStoredEnvKeys } from "./wizard.js"; // --------------------------------------------------------------------------- // V8 compile cache — Node 26+ can cache compiled bytecode across runs, // eliminating repeated parse/compile overhead for unchanged modules. // Must be set early so dynamic imports (extensions, lazy subcommands) benefit. // --------------------------------------------------------------------------- if (parseInt(process.versions.node.split(".")[0], 10) >= 22) { process.env.NODE_COMPILE_CACHE ??= join(agentDir, ".compile-cache"); } // --------------------------------------------------------------------------- // Logger initialization — configure LogTape early so all downstream modules // emit structured logs instead of raw console.* calls. // --------------------------------------------------------------------------- await configureLogger({ sessionId: process.env.SF_SESSION_ID || `cli-${Date.now()}`, mode: process.env.SF_AUTONOMOUS === "1" || process.env.NODE_ENV === "production" ? "autonomous" : "dev", }); function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { const currentVersion = process.env.SF_VERSION || "0.0.0"; const managedVersion = getNewerManagedResourceVersion( currentAgentDir, currentVersion, ); if (!managedVersion) { return; } process.stderr.write( `[sf] ${chalk.yellow("Version mismatch detected")}\n` + `[sf] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`sf\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` + `[sf] Run ${chalk.bold("npm install -g singularity-forge@latest")} or ${chalk.bold("sf update")}, then try again.\n`, ); process.exit(1); } async function warmDiscoveryBackedProviders( modelRegistry: ModelRegistry, ): Promise { const providers = ["ollama-cloud", "xiaomi"].filter((provider) => modelRegistry.isProviderRequestReady(provider), ); if (providers.length === 0) return; await modelRegistry.discoverModels(providers); } // --------------------------------------------------------------------------- // Shared helpers used by both the print and interactive code paths // --------------------------------------------------------------------------- /** * Print the non-interactive-mode error and exit. Called both from the early * TTY gate (before heavy init) and from the interactive-mode TTY gate right * before `InteractiveMode.run()`. The `includeWebHint` variant also lists * `--web` and `headless` as alternatives. */ function printNonTtyErrorAndExit( missing: string | undefined, includeWebHint: boolean, ): never { const suffix = missing ? ` but ${missing} not a TTY` : ""; process.stderr.write( `[sf] Error: Interactive mode requires a terminal (TTY)${suffix}.\n`, ); process.stderr.write("[sf] Non-interactive alternatives:\n"); process.stderr.write( "[sf] sf autonomous Autonomous mode via machine surface\n", ); process.stderr.write( '[sf] sf --print "your message" Single-shot prompt\n', ); if (includeWebHint) { process.stderr.write( "[sf] sf --web [path] Browser-only web mode\n", ); } process.stderr.write( "[sf] sf --mode rpc Session I/O mode: JSON-RPC over stdin/stdout\n", ); process.stderr.write( '[sf] sf --mode text "message" Session I/O mode: text print format\n', ); if (includeWebHint) { process.stderr.write( "[sf] sf headless autonomous Machine surface for autonomous mode\n", ); } process.exit(1); } /** * Print extension load/conflict errors from an extensions result. Downgrades * conflicts with built-in tools to warnings (#1347). */ function printExtensionErrors(errors: ReadonlyArray<{ error: string }>): void { for (const err of errors) { const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with"); const prefix = isConflict ? "Extension conflict" : "Extension load error"; const guidance = isConflict ? "Disable one of the conflicting extensions in settings" : "Check the extension path and reinstall if necessary"; process.stderr.write( formatStructuredError( error(err.error, { operation: "loadExtension", guidance }), `[sf] ${prefix}`, ), ); } } /** * Re-apply the validated model to the session when `createAgentSession()` * reports that it had to use a fallback. Prevents silently overriding the * persisted model of resumed conversations (#3534). */ async function reapplyValidatedModelOnFallback( session: { setModel(model: { provider: string; id: string; }): unknown | Promise; }, modelRegistry: ModelRegistry, settingsManager: SettingsManager, fallbackMessage: string | undefined, ): Promise { if (!fallbackMessage) return; const validatedProvider = settingsManager.getDefaultProvider(); const validatedModelId = settingsManager.getDefaultModel(); if (!validatedProvider || !validatedModelId) return; const correctModel = modelRegistry .getAvailable() .find((m) => m.provider === validatedProvider && m.id === validatedModelId); if (!correctModel) return; try { await session.setModel(correctModel); } catch { // Provider not ready — leave session on its current model } } const cliFlags = parseCliArgs(process.argv); const isPrintMode = cliFlags.print || cliFlags.mode !== undefined; // `sf [subcommand] --help` / `-h` — print help before any subcommand runs. // loader.ts only catches --help/-h as the *first* arg; here we handle the // case where it appears later (e.g. `sf update --help`, `sf --foo --help`). // Prefer subcommand-specific help when the first positional is a known // subcommand, otherwise fall back to general help. if (process.argv.includes("--help") || process.argv.includes("-h")) { const helpSubcommand = cliFlags.messages[0]; const version = process.env.SF_VERSION || "0.0.0"; if (!helpSubcommand || !printSubcommandHelp(helpSubcommand, version)) { printHelp(version); } process.exit(0); } // RTK bootstrap — runs once per process, memoized via a module-level promise // so concurrent callers await the same initialization. let rtkBootstrapPromise: Promise | undefined; async function doRtkBootstrap(): Promise { // RTK is opt-in via experimental.rtk preference. Default: disabled. // Honor SF_RTK_DISABLED if already explicitly set in the environment // (env var takes precedence over preferences for manual override). if (!process.env[SF_RTK_DISABLED_ENV]) { const prefs = loadEffectiveSFPreferences() as { preferences?: { experimental?: { rtk?: boolean } }; }; const rtkEnabled = prefs?.preferences?.experimental?.rtk === true; if (!rtkEnabled) { process.env[SF_RTK_DISABLED_ENV] = "1"; } } const rtkStatus = await bootstrapRtk(); markStartup("bootstrapRtk"); if ( !rtkStatus.available && rtkStatus.supported && rtkStatus.enabled && rtkStatus.reason ) { process.stderr.write( `[sf] Warning: RTK unavailable — continuing without shell-command compression (${rtkStatus.reason}).\n`, ); } } function ensureRtkBootstrap(): Promise { rtkBootstrapPromise ??= doRtkBootstrap(); return rtkBootstrapPromise; } // `sf update` — update to the latest version via npm if (cliFlags.messages[0] === "update") { const { runUpdate } = await import("./update-cmd.js"); await runUpdate(); process.exit(0); } // --------------------------------------------------------------------------- // Graph subcommand — `sf graph build|status|query|diff` // --------------------------------------------------------------------------- if (cliFlags.messages[0] === "graph") { const sub = cliFlags.messages[1]; const { buildGraph, graphStatus, graphQuery, graphDiff, resolveSFRoot, writeGraph, } = await import("@singularity-forge/pi-agent-core"); const projectDir = process.cwd(); const sfRoot = resolveSFRoot(projectDir); if (!sub || sub === "build") { try { const graph = await buildGraph(projectDir); await writeGraph(sfRoot, graph); process.stdout.write( `Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges\n`, ); } catch (err) { process.stderr.write( formatStructuredError( error("graph build failed", { operation: "buildGraph", file: projectDir, guidance: "Ensure the project has a valid .sf/ directory, or run 'sf headless init'", cause: err, }), "[sf]", ), ); process.exit(1); } } else if (sub === "status") { try { const result = await graphStatus(projectDir); if (!result.exists) { process.stdout.write("Graph: not built yet. Run: sf graph build\n"); } else { process.stdout.write(`Graph status:\n`); process.stdout.write(` exists: ${result.exists}\n`); process.stdout.write(` nodes: ${result.nodeCount}\n`); process.stdout.write(` edges: ${result.edgeCount}\n`); process.stdout.write(` stale: ${result.stale}\n`); process.stdout.write( ` ageHours: ${result.ageHours !== undefined ? result.ageHours.toFixed(2) : "n/a"}\n`, ); process.stdout.write(` lastBuild: ${result.lastBuild ?? "n/a"}\n`); } } catch (err) { process.stderr.write( formatStructuredError( error("graph status failed", { operation: "graphStatus", file: projectDir, guidance: "Run 'sf graph build' first", cause: err, }), "[sf]", ), ); process.exit(1); } } else if (sub === "query") { const term = cliFlags.messages[2]; if (!term) { process.stderr.write("Usage: sf graph query \n"); process.exit(1); } try { const result = await graphQuery(projectDir, term); if (result.nodes.length === 0) { process.stdout.write(`No nodes found for term: "${term}"\n`); } else { process.stdout.write( `Query results for "${term}" (${result.nodes.length} nodes, ${result.edges.length} edges):\n`, ); for (const node of result.nodes) { process.stdout.write( ` [${node.type}] ${node.label} (${node.confidence})\n`, ); } } } catch (err) { process.stderr.write( formatStructuredError( error("graph query failed", { operation: "graphQuery", file: projectDir, guidance: "Run 'sf graph build' first", cause: err, }), "[sf]", ), ); process.exit(1); } } else if (sub === "diff") { try { const result = await graphDiff(projectDir); process.stdout.write(`Graph diff:\n`); process.stdout.write(` nodes added: ${result.nodes.added.length}\n`); process.stdout.write( ` nodes removed: ${result.nodes.removed.length}\n`, ); process.stdout.write( ` nodes changed: ${result.nodes.changed.length}\n`, ); process.stdout.write(` edges added: ${result.edges.added.length}\n`); process.stdout.write( ` edges removed: ${result.edges.removed.length}\n`, ); } catch (err) { process.stderr.write( formatStructuredError( error("graph diff failed", { operation: "graphDiff", file: projectDir, guidance: "Run 'sf graph build' first", cause: err, }), "[sf]", ), ); process.exit(1); } } else { process.stderr.write(`Unknown graph command: ${sub}\n`); process.stderr.write("Commands: build, status, query , diff\n"); process.exit(1); } process.exit(0); } exitIfManagedResourcesAreNewer(agentDir); // Early TTY check — must come before heavy initialization to avoid dangling // handles that prevent process.exit() from completing promptly. const hasSubcommand = cliFlags.messages.length > 0; if ( !process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels && !cliFlags.web ) { printNonTtyErrorAndExit(undefined, false); } const packageCommand = await runPackageCommand({ appName: "sf", args: process.argv.slice(2), cwd: process.cwd(), agentDir, stdout: process.stdout, stderr: process.stderr, allowedCommands: new Set(["install", "remove", "list"]), }); if (packageCommand.handled) { process.exit(packageCommand.exitCode); } // `sf logs tail|follow` — merged live stream from notifications, sessions, activity, and audit logs if (cliFlags.messages[0] === "logs") { const { runLogsCli } = await import("./cli-logs.js"); const exitCode = await runLogsCli(process.argv.slice(2), { basePath: process.cwd(), }); process.exit(exitCode); } // `sf status [--live] [--watch]` / `sf dash` — printable aggregate project view if (cliFlags.messages[0] === "status" || cliFlags.messages[0] === "dash") { initResources(agentDir); const { runStatusCli } = await import("./cli-status.js"); const exitCode = await runStatusCli(process.argv.slice(2), { basePath: process.cwd(), }); process.exit(exitCode); } // `sf stats models` — model outcome summary from .sf/sf.db if (cliFlags.messages[0] === "stats") { const { runStatsCli } = await import("./cli-stats.js"); const exitCode = await runStatsCli(process.argv.slice(2), { basePath: process.cwd(), }); process.exit(exitCode); } // `sf config` — replay the setup wizard and exit if (cliFlags.messages[0] === "config") { const authStorage = AuthStorage.create(authFilePath); loadStoredEnvKeys(authStorage); await runOnboarding(authStorage); process.exit(0); } // `sf web stop [path|all]` — stop web server before anything else if (cliFlags.messages[0] === "web" && cliFlags.messages[1] === "stop") { const webBranch = await runWebCliBranch(cliFlags, { stopWebMode, stderr: process.stderr, baseSessionsDir: sessionsDir, agentDir, }); if (webBranch.handled) { process.exit(webBranch.exitCode); } } // `sf --web [path]` or `sf web [start] [path]` — launch browser-only web mode if ( cliFlags.web || (cliFlags.messages[0] === "web" && cliFlags.messages[1] !== "stop") ) { await ensureRtkBootstrap(); const webBranch = await runWebCliBranch(cliFlags, { stderr: process.stderr, baseSessionsDir: sessionsDir, agentDir, }); if (webBranch.handled) { process.exit(webBranch.exitCode); } } // `sf sessions` — list past sessions and pick one to resume if (cliFlags.messages[0] === "sessions") { const cwd = process.cwd(); let sessions; if (cliFlags.allSessions) { process.stderr.write( chalk.dim("Loading all sessions across all projects...\n"), ); sessions = await SessionManager.listAll(); } else { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const projectSessionsDir = join(sessionsDir, safePath); process.stderr.write(chalk.dim(`Loading sessions for ${cwd}...\n`)); sessions = await SessionManager.list(cwd, projectSessionsDir); } if (sessions.length === 0) { process.stderr.write(chalk.yellow("No sessions found.\n")); process.exit(0); } const label = cliFlags.allSessions ? "all projects" : cwd; process.stderr.write( chalk.bold(`\n Sessions (${sessions.length}) for ${label}:\n\n`), ); const maxShow = 20; const toShow = sessions.slice(0, maxShow); for (let i = 0; i < toShow.length; i++) { const s = toShow[i]; const date = s.modified.toLocaleString(); const msgs = s.messageCount; const name = s.name ? ` ${chalk.cyan(s.name)}` : ""; const preview = s.firstMessage ? s.firstMessage.replace(/\n/g, " ").substring(0, 80) : chalk.dim("(empty)"); const num = String(i + 1).padStart(3); const projectLabel = cliFlags.allSessions && s.cwd ? ` ${chalk.yellow(`[${s.cwd}]`)}` : ""; process.stderr.write( ` ${chalk.bold(num)}. ${chalk.green(date)} ${chalk.dim(`(${msgs} msgs)`)}${name}${projectLabel}\n`, ); process.stderr.write(` ${chalk.dim(preview)}\n\n`); } if (sessions.length > maxShow) { process.stderr.write( chalk.dim(` ... and ${sessions.length - maxShow} more\n\n`), ); } // Interactive selection const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stderr, }); const answer = await new Promise((resolve) => { rl.question( chalk.bold(" Enter session number to resume (or q to quit): "), resolve, ); }); rl.close(); // Clean up stdin state left by readline.createInterface(). // Without this, downstream TUI initialization gets corrupted listeners and exhibits // duplicate terminal I/O. Match the pattern used after onboarding cleanup. process.stdin.removeAllListeners("data"); process.stdin.removeAllListeners("keypress"); if (process.stdin.setRawMode) process.stdin.setRawMode(false); process.stdin.pause(); const choice = parseInt(answer, 10); if (Number.isNaN(choice) || choice < 1 || choice > toShow.length) { process.stderr.write(chalk.dim("Cancelled.\n")); process.exit(0); } const selected = toShow[choice - 1]; process.stderr.write( chalk.green( `\nResuming session from ${selected.modified.toLocaleString()}...\n\n`, ), ); // Mark for the interactive session below to open this specific session cliFlags.continue = true; cliFlags._selectedSessionPath = selected.path; } // `sf headless ...` — machine surface for direct SF commands if (cliFlags.messages[0] === "headless") { await ensureRtkBootstrap(); // Sync bundled resources before headless runs (#3471). Without this, // headless-query loads from src/resources/ while auto/interactive load // from ~/.sf/agent/extensions/ — different extension copies diverge. initResources(agentDir); const { runHeadless, parseHeadlessArgs } = await import("./headless.js"); await runHeadless(parseHeadlessArgs(process.argv)); process.exit(0); } /** * Run a headless command by invoking the headless entrypoint with a synthetic * argv. Shared by the `autonomous` shorthand (#2732) and the piped-stdout * redirect so they use the same bootstrap + dynamic-import dance. */ async function runHeadlessFromAutonomous( headlessArgs: string[], ): Promise { await ensureRtkBootstrap(); const { runHeadless, parseHeadlessArgs } = await import("./headless.js"); const argv = [process.argv[0], process.argv[1], "headless", ...headlessArgs]; await runHeadless(parseHeadlessArgs(argv)); process.exit(0); } function rawArgsAfterSubcommand(subcommand: string): string[] { const args = process.argv.slice(2); const index = args.indexOf(subcommand); return index >= 0 ? args.slice(index + 1) : []; } // `sf autonomous [args...]` — shorthand for autonomous mode via the machine surface (#2732). if (cliFlags.messages[0] === "autonomous") { await runHeadlessFromAutonomous([ "autonomous", ...rawArgsAfterSubcommand("autonomous"), ]); } // `sf schedule ...` — first-class non-interactive schedule CLI. // Keep this before the interactive/TUI path so commands like // `sf schedule list --json | jq` never fall through to the TTY guard. if (cliFlags.messages[0] === "schedule") { const scheduleModulePath = "./resources/extensions/sf/commands-schedule.js"; const { handleSchedule } = await import(scheduleModulePath); const rawScheduleArgs = process.argv.slice(3); const output = (message: string, level = "info") => { const stream = level === "warning" || level === "error" ? process.stderr : process.stdout; stream.write(message.endsWith("\n") ? message : `${message}\n`); }; await handleSchedule(rawScheduleArgs, { ui: { notify: output, }, }); process.exit(0); } // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. ensureManagedTools(join(agentDir, "bin")); markStartup("ensureManagedTools"); const authStorage = AuthStorage.create(authFilePath); markStartup("AuthStorage.create"); loadStoredEnvKeys(authStorage); migratePiCredentials(authStorage); const settingsManager = SettingsManager.create(process.cwd(), agentDir); applySecurityOverrides(settingsManager); markStartup("SettingsManager.create"); // Resolve models.json path with fallback to ~/.pi/agent/models.json const { resolveModelsJsonPath } = await import("./models-resolver.js"); const modelsJsonPath = resolveModelsJsonPath(); const modelRegistry = new ModelRegistry( authStorage, modelsJsonPath, settingsManager, ); markStartup("ModelRegistry"); await warmDiscoveryBackedProviders(modelRegistry); markStartup("ModelRegistry.discovery"); // Run onboarding wizard on first launch (no LLM provider configured) if ( !isPrintMode && shouldRunOnboarding(authStorage, settingsManager.getDefaultProvider()) ) { await runOnboarding(authStorage); // Clean up stdin state left by @clack/prompts. // readline.emitKeypressEvents() adds a permanent data listener and // readline.createInterface() may leave stdin paused. Remove stale // listeners and pause stdin so the TUI can start with a clean slate. process.stdin.removeAllListeners("data"); process.stdin.removeAllListeners("keypress"); if (process.stdin.setRawMode) process.stdin.setRawMode(false); process.stdin.pause(); } // Update check — non-blocking banner check; interactive prompt deferred to avoid // blocking startup. The passive checkForUpdates() prints a banner if an update is // available (using cached data or a background fetch) without blocking the TUI. if (!isPrintMode) { checkForUpdates().catch(() => {}); } // Warn if terminal is too narrow for readable output if (!isPrintMode && process.stdout.columns && process.stdout.columns < 40) { process.stderr.write( chalk.yellow( `[sf] Terminal width is ${process.stdout.columns} columns (minimum recommended: 40). Output may be unreadable.\n`, ), ); } // --list-models: print the static model catalog quickly by default. Load // extensions only when discovery or explicit extension paths are requested, // because syncing/reloading all bundled extensions makes smoke-test and // orchestration diagnostics pay full startup cost just to list models. if (cliFlags.listModels !== undefined) { exitIfManagedResourcesAreNewer(agentDir); const shouldLoadExtensionsForModels = cliFlags.discover || cliFlags.extensions.length > 0; if (shouldLoadExtensionsForModels) { initResources(agentDir); const listModelsLoader = new DefaultResourceLoader({ agentDir, additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined, }); await listModelsLoader.reload(); const listModelsExtensions = listModelsLoader.getExtensions(); for (const { name, config } of listModelsExtensions.runtime .pendingProviderRegistrations) { modelRegistry.registerProvider(name, config); } listModelsExtensions.runtime.pendingProviderRegistrations = []; } const searchPattern = typeof cliFlags.listModels === "string" ? cliFlags.listModels : undefined; // Apply allowed_providers / blocked_providers from SF preferences so the // listing matches what autonomous mode would actually be willing to dispatch. const sfPrefs = loadEffectiveSFPreferences()?.preferences as | { allowed_providers?: string[]; blocked_providers?: string[]; } | undefined; const modelFilter = sfPrefs ? (model: Model) => isProviderAllowedByLists( model.provider, sfPrefs.allowed_providers ?? [], sfPrefs.blocked_providers ?? [], ) : undefined; await listModels(modelRegistry, { searchPattern, discover: cliFlags.discover, modelFilter, }); process.exit(0); } // SF always uses quiet startup — the sf extension renders its own branded header if (!settingsManager.getQuietStartup()) { settingsManager.setQuietStartup(true); } // Collapse changelog by default — avoid wall of text on updates if (!settingsManager.getCollapseChangelog()) { settingsManager.setCollapseChangelog(true); } // --------------------------------------------------------------------------- // Print / subagent mode — single-shot execution, no TTY required // --------------------------------------------------------------------------- if (isPrintMode) { await ensureRtkBootstrap(); const sessionManager = cliFlags.noSession ? SessionManager.inMemory() : SessionManager.create(process.cwd()); // Read --append-system-prompt file content (subagent writes agent system prompts to temp files) let appendSystemPrompt: string | undefined; if (cliFlags.appendSystemPrompt) { try { appendSystemPrompt = readFileSync(cliFlags.appendSystemPrompt, "utf-8"); } catch { // If it's not a file path, treat it as literal text appendSystemPrompt = cliFlags.appendSystemPrompt; } } exitIfManagedResourcesAreNewer(agentDir); initResources(agentDir); markStartup("initResources"); // Route print mode through buildResourceLoader so the SF extension registry // filter (extensionPathsTransform) is applied consistently with TUI mode. // Constructing DefaultResourceLoader directly bypassed the filter and let // disabled bundled extensions (e.g. `ollama` superseded by `@0xkobold/pi-ollama`) // leak through and emit `/ollama` command conflicts on every print invocation. const resourceLoader = buildResourceLoader(agentDir, { additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined, appendSystemPrompt, }); await resourceLoader.reload(); markStartup("resourceLoader.reload"); // Print mode is a one-shot invocation. The --model flag is a transient // override (e.g. verification smoke tests like `sf -p --model longcat/X "reply ok"`) // and MUST NOT mutate the persisted defaultProvider/defaultModel in settings.json (#4251). // We disable persistence at session construction so every downstream path // (setModel override, fallback reapply, validation repair) is gated in one place. const { session, extensionsResult, modelFallbackMessage } = await createAgentSession({ authStorage, modelRegistry, settingsManager, sessionManager, resourceLoader, isClaudeCodeReady: () => modelRegistry.isProviderRequestReady("claude-code"), persistModelChanges: false, }); markStartup("createAgentSession"); // In print mode we still repair a genuinely stale default so the session has a // usable model, BUT when the caller explicitly passed --model we skip validation // entirely — the CLI already said which model to use, and repairing the default // would overwrite settings.json with a fallback the user didn't ask for (#4251). if (!cliFlags.model) { validateConfiguredModel(modelRegistry, settingsManager); await reapplyValidatedModelOnFallback( session, modelRegistry, settingsManager, modelFallbackMessage, ); } printExtensionErrors(extensionsResult.errors); // Apply --model override if specified. persist: false is redundant given // persistModelChanges above, but we pass it explicitly so the intent is // visible at the call site and survives future refactors. if (cliFlags.model) { const available = modelRegistry.getAvailable(); const match = available.find((m) => m.id === cliFlags.model) || available.find((m) => `${m.provider}/${m.id}` === cliFlags.model); if (match) { session.setModel(match, { persist: false }); } } const mode = cliFlags.mode || "text"; if (mode === "rpc") { printStartupTimings(); await runRpcMode(session); process.exit(0); } printStartupTimings(); await runPrintMode(session, { mode: mode as "text" | "json", messages: cliFlags.messages, }); process.exit(0); } // --------------------------------------------------------------------------- // Worktree subcommand — `sf worktree ` // --------------------------------------------------------------------------- if (cliFlags.messages[0] === "worktree" || cliFlags.messages[0] === "wt") { const { handleList, handleMerge, handleClean, handleRemove } = await import( "./worktree-cli.js" ); const sub = cliFlags.messages[1]; const subArgs = cliFlags.messages.slice(2); if (!sub || sub === "list") { await handleList(process.cwd()); } else if (sub === "merge") { await handleMerge(process.cwd(), subArgs); } else if (sub === "clean") { await handleClean(process.cwd()); } else if (sub === "remove" || sub === "rm") { await handleRemove(process.cwd(), subArgs); } else { process.stderr.write(`Unknown worktree command: ${sub}\n`); process.stderr.write( "Commands: list, merge [name], clean, remove \n", ); } process.exit(0); } // --------------------------------------------------------------------------- // Worktree flag (-w) — create/resume a worktree for the interactive session // --------------------------------------------------------------------------- if (cliFlags.worktree) { const { handleWorktreeFlag } = await import("./worktree-cli.js"); await handleWorktreeFlag(cliFlags.worktree); } // --------------------------------------------------------------------------- // Active worktree banner — remind user of unmerged worktrees on normal launch // --------------------------------------------------------------------------- if (!cliFlags.worktree && !isPrintMode) { try { const { handleStatusBanner } = await import("./worktree-cli.js"); await handleStatusBanner(process.cwd()); } catch { /* non-fatal */ } } // --------------------------------------------------------------------------- // Scheduled items banner — remind user of due follow-ups // --------------------------------------------------------------------------- if (!cliFlags.worktree && !isPrintMode) { try { const { showScheduleBanner } = await import( "./resources/extensions/sf/schedule-launch-banner.js" ); await showScheduleBanner(process.cwd()); } catch { /* non-fatal */ } } // --------------------------------------------------------------------------- // Autonomous redirect: autonomous mode with piped stdout -> machine surface (#2732) // When stdout is not a TTY (e.g. `sf autonomous | cat`), // the TUI cannot render and the process hangs. Redirect to the machine surface // which handles non-interactive output gracefully. // --------------------------------------------------------------------------- if (cliFlags.messages[0] === "autonomous" && !process.stdout.isTTY) { process.stderr.write( "[forge] stdout is not a terminal — running autonomous mode through the machine surface.\n", ); await runHeadlessFromAutonomous([ "autonomous", ...rawArgsAfterSubcommand("autonomous"), ]); } // --------------------------------------------------------------------------- // Interactive mode — normal TTY session // --------------------------------------------------------------------------- await ensureRtkBootstrap(); // Per-directory session storage — same encoding as the upstream SDK so that // /resume only shows sessions from the current working directory. const cwd = process.cwd(); const projectSessionsDir = getProjectSessionsDir(cwd); // Migrate legacy flat sessions: before per-directory scoping, all .jsonl session // files lived directly in ~/.sf/sessions/. Move them into the correct per-cwd // subdirectory so /resume can find them. migrateLegacyFlatSessions(sessionsDir, projectSessionsDir); const sessionManager = cliFlags._selectedSessionPath ? SessionManager.open(cliFlags._selectedSessionPath, projectSessionsDir) : cliFlags.continue ? SessionManager.continueRecent(cwd, projectSessionsDir) : SessionManager.create(cwd, projectSessionsDir); if (!process.stdin.isTTY || !process.stdout.isTTY) { const missing = !process.stdin.isTTY && !process.stdout.isTTY ? "stdin and stdout are" : !process.stdin.isTTY ? "stdin is" : "stdout is"; printNonTtyErrorAndExit(missing, true); } const interactiveLock = acquireInteractiveSessionLock( cwd, sessionManager.getSessionFile(), ); if (!interactiveLock.acquired) { process.stderr.write(`${interactiveLock.message}\n`); process.exit(1); } exitIfManagedResourcesAreNewer(agentDir); initResources(agentDir); markStartup("initResources"); // Warm the sift index in the background so it's ready when needed. try { const { ensureSiftIndexWarmup } = await import( "./resources/extensions/sf/code-intelligence.js" ); const { loadEffectiveSFPreferences } = await import( "./resources/extensions/sf/preferences.js" ); ensureSiftIndexWarmup( process.cwd(), (loadEffectiveSFPreferences()?.preferences as any)?.codebase, ); } catch { /* non-fatal — sift warmup is best-effort */ } // Overlap resource loading with session manager setup — both are independent. // resourceLoader.reload() is the most expensive step (jiti compilation), so // starting it early shaves ~50-200ms off interactive startup. const resourceLoader = buildResourceLoader(agentDir); const resourceLoadPromise = resourceLoader.reload(); // While resources load, let session manager finish any async I/O it needs. // Then await the resource promise before creating the agent session. await resourceLoadPromise; markStartup("resourceLoader.reload"); // Interactive mode explicitly opts into persistence so user model picks (via // /model, Ctrl+P, interactive selector) write back to settings.json. The // AgentSessionConfig.persistModelChanges default is false (#4251) so SDK // consumers don't silently mutate user settings; CLI interactive paths opt in. const { session, extensionsResult, modelFallbackMessage: interactiveFallbackMsg, } = await createAgentSession({ authStorage, modelRegistry, settingsManager, sessionManager, resourceLoader, isClaudeCodeReady: () => modelRegistry.isProviderRequestReady("claude-code"), persistModelChanges: true, }); markStartup("createAgentSession"); // Validate configured model AFTER extensions have registered their models (#2626). // Before this, extension-provided models (e.g. claude-code/*) were not yet in the // registry, causing the user's valid choice to be silently overwritten. validateConfiguredModel(modelRegistry, settingsManager); await reapplyValidatedModelOnFallback( session, modelRegistry, settingsManager, interactiveFallbackMsg, ); printExtensionErrors(extensionsResult.errors); // Restore scoped models from settings on startup. // The upstream InteractiveMode reads enabledModels from settings when /scoped-models is opened, // but doesn't apply them to the session at startup — so Ctrl+P cycles all models instead of // just the saved selection until the user re-runs /scoped-models. const enabledModelPatterns = settingsManager.getEnabledModels(); if (enabledModelPatterns && enabledModelPatterns.length > 0) { const availableModels = modelRegistry.getAvailable(); const scopedModels: Array<{ model: (typeof availableModels)[number] }> = []; const seen = new Set(); for (const pattern of enabledModelPatterns) { // Patterns are "provider/modelId" exact strings saved by /scoped-models const slashIdx = pattern.indexOf("/"); if (slashIdx !== -1) { const provider = pattern.substring(0, slashIdx); const modelId = pattern.substring(slashIdx + 1); const model = availableModels.find( (m) => m.provider === provider && m.id === modelId, ); if (model) { const key = `${model.provider}/${model.id}`; if (!seen.has(key)) { seen.add(key); scopedModels.push({ model }); } } } else { // Fallback: match by model id alone const model = availableModels.find((m) => m.id === pattern); if (model) { const key = `${model.provider}/${model.id}`; if (!seen.has(key)) { seen.add(key); scopedModels.push({ model }); } } } } // Only apply if we resolved some models and it's a genuine subset if (scopedModels.length > 0 && scopedModels.length < availableModels.length) { session.setScopedModels(scopedModels); } } // Welcome screen — shown on every fresh interactive session before TUI takes over. // Skip when the first-run banner was already printed in loader.ts (prevents double banner). if (!process.env.SF_FIRST_RUN_BANNER) { const { printWelcomeScreen } = await import("./welcome-screen.js"); let remoteChannel: string | undefined; try { const { resolveRemoteConfig } = await import( "./resources/extensions/remote-questions/config.js" ); const rc = resolveRemoteConfig() as { channel?: string } | null; if (rc) remoteChannel = rc.channel; } catch { /* non-fatal */ } printWelcomeScreen({ version: process.env.SF_VERSION || "0.0.0", modelName: settingsManager.getDefaultModel() || undefined, provider: settingsManager.getDefaultProvider() || undefined, remoteChannel, }); } const interactiveMode = new InteractiveMode(session); markStartup("InteractiveMode"); printStartupTimings(); try { await interactiveMode.run(); } finally { interactiveLock.release(); }