- Install @logtape/logtape, @logtape/pretty, @logtape/file, @logtape/redaction
- Create src/logger.ts with configureLogger() and getLogger() exports
- Dev mode: pretty console output with debug level
- Autonomous mode: JSON console + rotating file sink in .sf/logs/{sessionId}/
- PII redaction for API keys (sk-*, key-*, Bearer *) and home directory paths
- Category hierarchy: sf.core, sf.uok, sf.autonomous, sf.extension, sf.web
- Comprehensive tests in src/tests/logger.test.ts (10 tests)
- Wire configureLogger() into src/cli.ts startup path
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1112 lines
37 KiB
TypeScript
1112 lines
37 KiB
TypeScript
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<void> {
|
|
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<unknown>;
|
|
},
|
|
modelRegistry: ModelRegistry,
|
|
settingsManager: SettingsManager,
|
|
fallbackMessage: string | undefined,
|
|
): Promise<void> {
|
|
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<void> | undefined;
|
|
async function doRtkBootstrap(): Promise<void> {
|
|
// 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<void> {
|
|
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 <term>\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 <term>, 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<string>((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<never> {
|
|
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<Api>) =>
|
|
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 <list|merge|clean|remove>`
|
|
// ---------------------------------------------------------------------------
|
|
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 <name>\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<string>();
|
|
|
|
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();
|
|
}
|