singularity-forge/src/cli.ts
Andrew 815be0a698 feat: managed RTK integration with opt-in preference and web UI toggle (#2620)
* feat: integrate managed RTK across shell workflows

* fix(rtk): unify managed fallback and live savings wiring

* fix(rtk): improve TUI status visibility

* fix(tests): make portability tests independent of pi-coding-agent dist build

The CI portability test runs don't guarantee that
packages/pi-coding-agent has been compiled. Any test that
imported files pulling in @gsd/pi-coding-agent (resource-loader,
preferences-skills, async-bash-tool, etc.) crashed with
ERR_MODULE_NOT_FOUND pointing at dist/index.js.

Two changes to dist-redirect.mjs (the Node ESM loader hook used by
all unit tests):
- Redirect the bare @gsd/pi-coding-agent specifier to the workspace
  source entrypoint (src/index.ts) so no dist/ artifact is needed.
- Extend the load() hook to transpile *.ts files under
  packages/pi-coding-agent/src/ through TypeScript's transpileModule.
  Node's --experimental-strip-types can't handle parameter properties
  and similar syntax present in that package's source; full transpilation
  avoids the ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX crash.

Also fix the dashboard.tsx responsive grid:
- xl:grid-cols-5 → xl:grid-cols-4 2xl:grid-cols-5
  (5 metric cards no longer fit at xl without overflow; test contract
  expected xl:grid-cols-4)
- Keep loading-skeletons.tsx in sync with the same breakpoints.

Add src/tests/resolve-ts-loader.test.ts to guard the loader behaviour:
- bare @gsd/pi-coding-agent redirect points to workspace source
- direct source-entry rewrite (.js → .ts)
- transpilation removes TS parameter property syntax that strip-only
  mode cannot parse

* fix(tests): redirect all workspace package imports to source in portability tests

The previous fix only redirected @gsd/pi-coding-agent to its
source entrypoint. In CI, pi-coding-agent/src itself imports
@gsd/pi-ai (and other workspace packages) which were still pointing
at dist/. Since no workspace dist is built during the portability
test run, any transitive resolution hit the same ERR_MODULE_NOT_FOUND.

Changes to dist-redirect.mjs:
- Redirect @gsd/pi-ai, @gsd/pi-ai/oauth, @gsd/pi-agent-core, and
  @gsd/pi-tui bare imports to their workspace src/ entrypoints.
- Broaden the load() transpilation condition from
  '/packages/pi-coding-agent/src/' to '/packages/*/src/' so that
  all workspace source files are run through TypeScript's
  transpileModule, handling parameter properties and other syntax
  that Node's strip-only mode rejects.

Verified by hiding all four workspace dist/ directories locally and
running the failing test set — 96/96 pass.

* fix(tests): redirect @gsd/native sub-paths; fix Windows .cmd spawnSync

Two more portability failures after the previous fix:

1. @gsd/native sub-path imports (@gsd/native/fd, @gsd/native/text, etc.)
   were not redirected — the loader only handled the bare specifier.
   Added a prefix-match redirect for @gsd/native/* → packages/native/src/<sub>/index.ts.

2. Windows RTK tests failed because createFakeRtk produces a .cmd wrapper
   on Windows, and spawnSync(binaryPath, [...]) without shell:true silently
   returns non-zero when the binary is a .cmd file.
   Added shell: /\.(cmd|bat)$/i.test(binaryPath) to the spawnSync calls in:
   - src/resources/extensions/shared/rtk.ts (rewriteCommandWithRtk)
   - src/resources/extensions/shared/rtk-session-stats.ts (readCurrentRtkGainSummary)
   - packages/pi-coding-agent/src/utils/rtk.ts (rewriteCommandForGsd)
   Production use of rtk.exe is unaffected; the shell flag is only true for
   .cmd/.bat paths.

Verified: all 93 portability tests pass with all workspace dist/ directories
removed (simulating CI portability environment).

* fix(tests): Windows portability fixes — HOME env, managed RTK path, perf threshold

Four Windows-specific failures fixed:

1. app-smoke.test.ts: process.env.HOME is undefined on Windows (uses
   USERPROFILE instead). Changed to homedir() from node:os which works
   cross-platform.

2. Managed RTK path tests on Windows: tests placed a fake RTK as rtk.exe
   (by copying a .cmd script into a .exe filename), which Windows cannot
   execute. Two-part fix:
   - resolveRtkBinaryPath() in both rtk.ts files now falls back to rtk.cmd
     in the managed dir on Windows when rtk.exe is absent.
   - withManagedFakeRtk and equivalent patterns in rtk.test.ts,
     rtk-session-stats.test.ts, rtk-execution-seams.test.ts changed to
     place the fake at rtk.cmd instead of rtk.exe on Windows.

3. bg_shell RTK test on Windows: requires bash (for shell sessions), which
   is not available on the blacksmith-4vcpu-windows-2025 runner without
   Git Bash installed. Test now skips on win32.

4. derive-state-db perf assertion: 10ms threshold was too tight for Windows
   CI runners (measured 12ms under load). Raised to 25ms — still catches
   real regressions (baseline is 3ms locally and ~12ms on stressed runners).

* fix(tests): fix managed RTK path fallback on Windows in src/rtk.ts + fix copyable fake

Two remaining Windows failures:

1. src/rtk.ts was never patched with the rtk.cmd managed-dir fallback
   (only the shared/rtk.ts and pi-coding-agent/src/utils/rtk.ts were updated).
   Added the same rtk.cmd fallback and shell:.cmd detection to src/rtk.ts,
   which is what rtk.test.ts imports from.

2. createFakeRtk on Windows wrote '%~dp0\fake-rtk.js' in the .cmd content —
   this resolves relative to the .cmd file's own directory. When the test
   copies rtk.cmd to a different managed dir, %~dp0 resolves to the copy
   destination where fake-rtk.js does not exist. Fixed by embedding the
   absolute path to fake-rtk.js directly in the .cmd content so the fake
   works correctly regardless of where the .cmd is copied.

* feat(experimental): add RTK opt-in preference with web UI toggle

- Add `experimental` category to GSDPreferences with `rtk: boolean` (default: false)
- RTK is now opt-in: disabled by default for all projects unless explicitly enabled
- Validate experimental.* keys; unknown experimental keys produce warnings

Web UI:
- Add ExperimentalPanel component with animated toggle switch per flag
- Add /api/experimental route (GET/PATCH) to read/write flags in preferences.md
- Add 'Experimental' tab to settings dialog sidebar nav (FlaskConical icon)
- Include ExperimentalPanel at bottom of gsd-prefs mega-scroll
- Fix toggle disabled state: trigger loadSettingsData for 'experimental' section
  and self-fetch on mount when data is absent

Dashboard:
- Gate RTK Saved metric card on rtkEnabled from live auto state (web)
- Gate TUI dashboard RTK savings row on rtkEnabled
- Gate TUI footer RTK status updates on experimental.rtk preference
- Propagate rtkEnabled through AutoDashboardData → bridge-service → store

Build:
- Add scripts/build-if-stale.cjs: incremental build driver that skips each
  step (packages, root tsc, copy-resources, web) when output is newer than
  source; replaces full rebuild chain in gsd:web
- Add scripts/web-stop.cjs: robust stop with registry + legacy PID + orphan
  sweep via pgrep; handles crash/restart orphaned next-server processes
- gsd:web now uses build-if-stale.cjs (fast cold starts, instant when unchanged)
- gsd:web:stop / gsd:web:stop:all use web-stop.cjs directly

Fix: correct import path in rtk-status.ts (./preferences.js not ../preferences.js)

* fix: restore em-dash encoding in package.json to match upstream

* refactor(rtk): move command rewrite out of pi-coding-agent into GSD extension

Per review feedback from igouss: pi-coding-agent should not be modified to add
GSD-specific logic. Instead, add a proper extension point and wire RTK through it.

Changes to packages/pi-coding-agent (extension API only — no RTK logic):
- Add BashTransformEvent + BashTransformEventResult types to extension API
- Add on('bash_transform') overload to ExtensionAPI interface
- Add emitBashTransform() to ExtensionRunner (chains all handlers in order)
- Call emitBashTransform() in wrapToolWithExtensions before bash tool execution
- Export new types from extensions/index.ts and package index.ts
- Revert all RTK-specific changes from bash-executor.ts, tools/bash.ts
- Remove packages/pi-coding-agent/src/utils/rtk.ts entirely

Changes to GSD extension:
- Register bash_transform handler in register-hooks.ts that calls
  rewriteCommandWithRtk() from the existing shared/rtk.ts module
- Handler is a no-op when RTK is disabled or not installed

* fix: correct import path for shared/rtk.js in register-hooks

* fix(tests): remove deleted pi-coding-agent/utils/rtk imports from execution seams test

The RTK rewrite logic was moved out of pi-coding-agent into the GSD
extension (bash_transform hook). Tests that directly imported the
deleted utils/rtk.ts are removed; remaining tests verify the shared
RTK module and GSD-layer surfaces that still call rewriteCommandWithRtk.
2026-03-26 09:33:07 -06:00

691 lines
26 KiB
TypeScript

import {
AuthStorage,
DefaultResourceLoader,
ModelRegistry,
runPackageCommand,
SettingsManager,
SessionManager,
createAgentSession,
InteractiveMode,
runPrintMode,
runRpcMode,
} from '@gsd/pi-coding-agent'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js'
import { ensureManagedTools } from './tool-bootstrap.js'
import { loadStoredEnvKeys } from './wizard.js'
import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js'
import { shouldRunOnboarding, runOnboarding } from './onboarding.js'
import chalk from 'chalk'
import { checkForUpdates } from './update-check.js'
import { printHelp, printSubcommandHelp } from './help-text.js'
import {
parseCliArgs as parseWebCliArgs,
runWebCliBranch,
migrateLegacyFlatSessions,
} from './cli-web-branch.js'
import { stopWebMode } from './web-mode.js'
import { getProjectSessionsDir } from './project-sessions.js'
import { markStartup, printStartupTimings } from './startup-timings.js'
import { bootstrapRtk, GSD_RTK_DISABLED_ENV } from './rtk.js'
import { loadEffectiveGSDPreferences } from './resources/extensions/gsd/preferences.js'
// ---------------------------------------------------------------------------
// V8 compile cache — Node 22+ 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) >= 22) {
process.env.NODE_COMPILE_CACHE ??= join(agentDir, '.compile-cache')
}
// ---------------------------------------------------------------------------
// Minimal CLI arg parser — detects print/subagent mode flags
// ---------------------------------------------------------------------------
interface CliFlags {
mode?: 'text' | 'json' | 'rpc' | 'mcp'
print?: boolean
continue?: boolean
noSession?: boolean
worktree?: boolean | string
model?: string
listModels?: string | true
extensions: string[]
appendSystemPrompt?: string
tools?: string[]
messages: string[]
web?: boolean
webPath?: string
/** Set by `gsd sessions` when the user picks a specific session to resume */
_selectedSessionPath?: string
}
function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
const currentVersion = process.env.GSD_VERSION || '0.0.0'
const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion)
if (!managedVersion) {
return
}
process.stderr.write(
`[gsd] ${chalk.yellow('Version mismatch detected')}\n` +
`[gsd] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`gsd\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` +
`[gsd] Run ${chalk.bold('npm install -g gsd-pi@latest')} or ${chalk.bold('gsd update')}, then try again.\n`,
)
process.exit(1)
}
function parseCliArgs(argv: string[]): CliFlags {
const flags: CliFlags = { extensions: [], messages: [] }
const args = argv.slice(2) // skip node + script
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--mode' && i + 1 < args.length) {
const m = args[++i]
if (m === 'text' || m === 'json' || m === 'rpc' || m === 'mcp') flags.mode = m
} else if (arg === '--print' || arg === '-p') {
flags.print = true
} else if (arg === '--continue' || arg === '-c') {
flags.continue = true
} else if (arg === '--no-session') {
flags.noSession = true
} else if (arg === '--model' && i + 1 < args.length) {
flags.model = args[++i]
} else if (arg === '--extension' && i + 1 < args.length) {
flags.extensions.push(args[++i])
} else if (arg === '--append-system-prompt' && i + 1 < args.length) {
flags.appendSystemPrompt = args[++i]
} else if (arg === '--tools' && i + 1 < args.length) {
flags.tools = args[++i].split(',')
} else if (arg === '--list-models') {
flags.listModels = (i + 1 < args.length && !args[i + 1].startsWith('-')) ? args[++i] : true
} else if (arg === '--version' || arg === '-v') {
process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n')
process.exit(0)
} else if (arg === '--worktree' || arg === '-w') {
// -w with no value → auto-generate name; -w <name> → use that name
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
flags.worktree = args[++i]
} else {
flags.worktree = true
}
} else if (arg === '--help' || arg === '-h') {
printHelp(process.env.GSD_VERSION || '0.0.0')
process.exit(0)
} else if (arg === '--web') {
flags.web = true
// Capture optional project path after --web (not a flag)
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
flags.webPath = args[++i]
}
} else if (!arg.startsWith('--') && !arg.startsWith('-')) {
flags.messages.push(arg)
}
}
return flags
}
const cliFlags = parseCliArgs(process.argv)
const isPrintMode = cliFlags.print || cliFlags.mode !== undefined
// Early resource-skew check — must run before TTY gate so version mismatch
// errors surface even in non-TTY environments.
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) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
process.exit(1)
}
async function ensureRtkBootstrap(): Promise<void> {
if ((ensureRtkBootstrap as { _done?: boolean })._done) return
// RTK is opt-in via experimental.rtk preference. Default: disabled.
// Honor GSD_RTK_DISABLED if already explicitly set in the environment
// (env var takes precedence over preferences for manual override).
if (!process.env[GSD_RTK_DISABLED_ENV]) {
const prefs = loadEffectiveGSDPreferences();
const rtkEnabled = prefs?.preferences.experimental?.rtk === true;
if (!rtkEnabled) {
process.env[GSD_RTK_DISABLED_ENV] = "1";
}
}
const rtkStatus = await bootstrapRtk()
;(ensureRtkBootstrap as { _done?: boolean })._done = true
markStartup('bootstrapRtk')
if (!rtkStatus.available && rtkStatus.supported && rtkStatus.enabled && rtkStatus.reason) {
process.stderr.write(`[gsd] Warning: RTK unavailable — continuing without shell-command compression (${rtkStatus.reason}).\n`)
}
}
// `gsd <subcommand> --help` — show subcommand-specific help
const subcommand = cliFlags.messages[0]
if (subcommand && process.argv.includes('--help')) {
if (printSubcommandHelp(subcommand, process.env.GSD_VERSION || '0.0.0')) {
process.exit(0)
}
}
const packageCommand = await runPackageCommand({
appName: 'gsd',
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)
}
// `gsd 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)
}
// `gsd 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)
}
// `gsd web stop [path|all]` — stop web server before anything else
if (cliFlags.messages[0] === 'web' && cliFlags.messages[1] === 'stop') {
const webFlags = parseWebCliArgs(process.argv)
const webBranch = await runWebCliBranch(webFlags, {
stopWebMode,
stderr: process.stderr,
baseSessionsDir: sessionsDir,
agentDir,
})
if (webBranch.handled) {
process.exit(webBranch.exitCode)
}
}
// `gsd --web [path]` or `gsd web [start] [path]` — launch browser-only web mode
if (cliFlags.web || (cliFlags.messages[0] === 'web' && cliFlags.messages[1] !== 'stop')) {
await ensureRtkBootstrap()
const webFlags = parseWebCliArgs(process.argv)
const webBranch = await runWebCliBranch(webFlags, {
stderr: process.stderr,
baseSessionsDir: sessionsDir,
agentDir,
})
if (webBranch.handled) {
process.exit(webBranch.exitCode)
}
}
// `gsd sessions` — list past sessions and pick one to resume
if (cliFlags.messages[0] === 'sessions') {
const cwd = process.cwd()
const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`
const projectSessionsDir = join(sessionsDir, safePath)
process.stderr.write(chalk.dim(`Loading sessions for ${cwd}...\n`))
const sessions = await SessionManager.list(cwd, projectSessionsDir)
if (sessions.length === 0) {
process.stderr.write(chalk.yellow('No sessions found for this directory.\n'))
process.exit(0)
}
process.stderr.write(chalk.bold(`\n Sessions (${sessions.length}):\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)
process.stderr.write(` ${chalk.bold(num)}. ${chalk.green(date)} ${chalk.dim(`(${msgs} msgs)`)}${name}\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()
const choice = parseInt(answer, 10)
if (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
}
// `gsd headless` — run auto-mode without TUI
if (cliFlags.messages[0] === 'headless') {
await ensureRtkBootstrap()
const { runHeadless, parseHeadlessArgs } = await import('./headless.js')
await runHeadless(parseHeadlessArgs(process.argv))
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)
// 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)
markStartup('ModelRegistry')
const settingsManager = SettingsManager.create(agentDir)
markStartup('SettingsManager.create')
// 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(`[gsd] Terminal width is ${process.stdout.columns} columns (minimum recommended: 40). Output may be unreadable.\n`),
)
}
// --list-models: print available models and exit (no TTY needed)
if (cliFlags.listModels !== undefined) {
const models = modelRegistry.getAvailable()
if (models.length === 0) {
console.log('No models available. Set API keys in environment variables.')
process.exit(0)
}
const searchPattern = typeof cliFlags.listModels === 'string' ? cliFlags.listModels : undefined
let filtered = models
if (searchPattern) {
const q = searchPattern.toLowerCase()
filtered = models.filter((m) => `${m.provider} ${m.id} ${m.name}`.toLowerCase().includes(q))
}
// Sort by name descending (newest first), then provider, then id
filtered.sort((a, b) => {
const nameCmp = b.name.localeCompare(a.name)
if (nameCmp !== 0) return nameCmp
const provCmp = a.provider.localeCompare(b.provider)
if (provCmp !== 0) return provCmp
return a.id.localeCompare(b.id)
})
const fmt = (n: number) => n >= 1_000_000 ? `${n / 1_000_000}M` : n >= 1_000 ? `${n / 1_000}K` : `${n}`
const rows = filtered.map((m) => [
m.provider,
m.id,
m.name,
fmt(m.contextWindow),
fmt(m.maxTokens),
m.reasoning ? 'yes' : 'no',
])
const hdrs = ['provider', 'model', 'name', 'context', 'max-out', 'thinking']
const widths = hdrs.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
const pad = (s: string, w: number) => s.padEnd(w)
console.log(hdrs.map((h, i) => pad(h, widths[i])).join(' '))
for (const row of rows) {
console.log(row.map((c, i) => pad(c, widths[i])).join(' '))
}
process.exit(0)
}
// Validate configured model on startup — catches stale settings from prior installs
// (e.g. grok-2 which no longer exists) and fresh installs with no settings.
// Only resets the default when the configured model no longer exists in the registry;
// never overwrites a valid user choice.
const configuredProvider = settingsManager.getDefaultProvider()
const configuredModel = settingsManager.getDefaultModel()
const allModels = modelRegistry.getAll()
const availableModels = modelRegistry.getAvailable()
const configuredExists = configuredProvider && configuredModel &&
allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
const configuredAvailable = configuredProvider && configuredModel &&
availableModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
if (!configuredModel || !configuredExists) {
// Model not configured at all, or removed from registry — pick a fallback.
// Only fires when the model is genuinely unknown (not just temporarily unavailable).
const piDefault = getPiDefaultModelAndProvider()
const preferred =
(piDefault
? availableModels.find((m) => m.provider === piDefault.provider && m.id === piDefault.model)
: undefined) ||
availableModels.find((m) => m.provider === 'openai' && m.id === 'gpt-5.4') ||
availableModels.find((m) => m.provider === 'openai') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
availableModels.find((m) => m.provider === 'anthropic') ||
availableModels[0]
if (preferred) {
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id)
}
}
if (settingsManager.getDefaultThinkingLevel() !== 'off' && !configuredExists) {
settingsManager.setDefaultThinkingLevel('off')
}
// GSD always uses quiet startup — the gsd 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')
const resourceLoader = new DefaultResourceLoader({
agentDir,
additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined,
appendSystemPrompt,
})
await resourceLoader.reload()
markStartup('resourceLoader.reload')
const { session, extensionsResult } = await createAgentSession({
authStorage,
modelRegistry,
settingsManager,
sessionManager,
resourceLoader,
})
markStartup('createAgentSession')
if (extensionsResult.errors.length > 0) {
for (const err of extensionsResult.errors) {
// Downgrade conflicts with built-in tools to warnings (#1347)
const isSuperseded = err.error.includes("supersedes");
const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`)
}
}
// Apply --model override if specified
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)
}
}
const mode = cliFlags.mode || 'text'
if (mode === 'rpc') {
printStartupTimings()
await runRpcMode(session)
process.exit(0)
}
if (mode === 'mcp') {
printStartupTimings()
const { startMcpServer } = await import('./mcp-server.js')
await startMcpServer({
tools: session.agent.state.tools ?? [],
version: process.env.GSD_VERSION || '0.0.0',
})
// MCP server runs until the transport closes; keep alive
await new Promise(() => {})
}
printStartupTimings()
await runPrintMode(session, {
mode: mode as 'text' | 'json',
messages: cliFlags.messages,
})
process.exit(0)
}
// ---------------------------------------------------------------------------
// Worktree subcommand — `gsd 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 */ }
}
// ---------------------------------------------------------------------------
// 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 ~/.gsd/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)
exitIfManagedResourcesAreNewer(agentDir)
initResources(agentDir)
markStartup('initResources')
// 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')
const { session, extensionsResult } = await createAgentSession({
authStorage,
modelRegistry,
settingsManager,
sessionManager,
resourceLoader,
})
markStartup('createAgentSession')
if (extensionsResult.errors.length > 0) {
for (const err of extensionsResult.errors) {
const isSuperseded = err.error.includes("supersedes");
const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`)
}
}
// 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)
}
}
if (!process.stdin.isTTY) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n')
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
process.exit(1)
}
// 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.GSD_FIRST_RUN_BANNER) {
const { printWelcomeScreen } = await import('./welcome-screen.js')
printWelcomeScreen({
version: process.env.GSD_VERSION || '0.0.0',
modelName: settingsManager.getDefaultModel() || undefined,
provider: settingsManager.getDefaultProvider() || undefined,
})
}
const interactiveMode = new InteractiveMode(session)
markStartup('InteractiveMode')
printStartupTimings()
await interactiveMode.run()