ensureSliceBranch() now auto-commits dirty files before git checkout, preventing "would be overwritten" errors when doctor/STATE.md rebuild leaves uncommitted changes between slice dispatches. (closes #63) On startup, migrate any .jsonl session files from the flat ~/.gsd/sessions/ directory into the per-cwd subdirectory so /resume can find sessions created before per-directory scoping was added. (closes #64) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
9.6 KiB
TypeScript
271 lines
9.6 KiB
TypeScript
import {
|
|
AuthStorage,
|
|
DefaultResourceLoader,
|
|
ModelRegistry,
|
|
SettingsManager,
|
|
SessionManager,
|
|
createAgentSession,
|
|
InteractiveMode,
|
|
runPrintMode,
|
|
} from '@mariozechner/pi-coding-agent'
|
|
import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
|
|
import { initResources } from './resource-loader.js'
|
|
import { ensureManagedTools } from './tool-bootstrap.js'
|
|
import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal CLI arg parser — detects print/subagent mode flags
|
|
// ---------------------------------------------------------------------------
|
|
interface CliFlags {
|
|
mode?: 'text' | 'json' | 'rpc'
|
|
print?: boolean
|
|
noSession?: boolean
|
|
model?: string
|
|
extensions: string[]
|
|
appendSystemPrompt?: string
|
|
tools?: string[]
|
|
messages: string[]
|
|
}
|
|
|
|
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') flags.mode = m
|
|
} else if (arg === '--print' || arg === '-p') {
|
|
flags.print = 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.startsWith('--') && !arg.startsWith('-')) {
|
|
flags.messages.push(arg)
|
|
}
|
|
}
|
|
return flags
|
|
}
|
|
|
|
const cliFlags = parseCliArgs(process.argv)
|
|
const isPrintMode = cliFlags.print || cliFlags.mode !== undefined
|
|
|
|
// 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'))
|
|
|
|
const authStorage = AuthStorage.create(authFilePath)
|
|
loadStoredEnvKeys(authStorage)
|
|
|
|
// Skip the setup wizard in print mode — it requires TTY interaction
|
|
if (!isPrintMode) {
|
|
await runWizardIfNeeded(authStorage)
|
|
}
|
|
|
|
const modelRegistry = new ModelRegistry(authStorage)
|
|
const settingsManager = SettingsManager.create(agentDir)
|
|
|
|
// 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 configuredExists = configuredProvider && configuredModel &&
|
|
allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
|
|
|
|
if (!configuredModel || !configuredExists) {
|
|
// Fallback: pick the best available Anthropic model
|
|
const preferred =
|
|
allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
|
|
allModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
|
|
allModels.find((m) => m.provider === 'anthropic')
|
|
if (preferred) {
|
|
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id)
|
|
}
|
|
}
|
|
|
|
// Default thinking level: off (always reset if not explicitly set)
|
|
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) {
|
|
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
|
|
}
|
|
}
|
|
|
|
initResources(agentDir)
|
|
const resourceLoader = new DefaultResourceLoader({
|
|
agentDir,
|
|
additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined,
|
|
appendSystemPrompt,
|
|
})
|
|
await resourceLoader.reload()
|
|
|
|
const { session, extensionsResult } = await createAgentSession({
|
|
authStorage,
|
|
modelRegistry,
|
|
settingsManager,
|
|
sessionManager,
|
|
resourceLoader,
|
|
})
|
|
|
|
if (extensionsResult.errors.length > 0) {
|
|
for (const err of extensionsResult.errors) {
|
|
process.stderr.write(`[gsd] Extension load error: ${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'
|
|
await runPrintMode(session, {
|
|
mode: mode === 'rpc' ? 'json' : mode,
|
|
messages: cliFlags.messages,
|
|
})
|
|
process.exit(0)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interactive mode — normal TTY session
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 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 safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`
|
|
const projectSessionsDir = join(sessionsDir, safePath)
|
|
|
|
// 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.
|
|
if (existsSync(sessionsDir)) {
|
|
try {
|
|
const entries = readdirSync(sessionsDir)
|
|
const flatJsonl = entries.filter(f => f.endsWith('.jsonl'))
|
|
if (flatJsonl.length > 0) {
|
|
const { mkdirSync } = await import('node:fs')
|
|
mkdirSync(projectSessionsDir, { recursive: true })
|
|
for (const file of flatJsonl) {
|
|
const src = join(sessionsDir, file)
|
|
const dst = join(projectSessionsDir, file)
|
|
if (!existsSync(dst)) {
|
|
renameSync(src, dst)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-fatal — don't block startup if migration fails
|
|
}
|
|
}
|
|
|
|
const sessionManager = SessionManager.create(cwd, projectSessionsDir)
|
|
|
|
initResources(agentDir)
|
|
const resourceLoader = new DefaultResourceLoader({ agentDir })
|
|
await resourceLoader.reload()
|
|
|
|
const { session, extensionsResult } = await createAgentSession({
|
|
authStorage,
|
|
modelRegistry,
|
|
settingsManager,
|
|
sessionManager,
|
|
resourceLoader,
|
|
})
|
|
|
|
if (extensionsResult.errors.length > 0) {
|
|
for (const err of extensionsResult.errors) {
|
|
process.stderr.write(`[gsd] Extension load error: ${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)
|
|
}
|
|
}
|
|
|
|
const interactiveMode = new InteractiveMode(session)
|
|
await interactiveMode.run()
|