fix: support print/JSON mode in cli.js so subagents don't hang

cli.ts unconditionally entered InteractiveMode, ignoring --mode, -p,
--no-session and other flags the subagent extension passes to child
processes. The child would wait for TTY input that never arrives
(stdin is "ignore"), causing the parent to hang forever on "working".

Parse CLI args to detect print/subagent mode and route to runPrintMode()
with proper session, model, extension, and system prompt handling.

Closes #45

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-11 11:21:12 -06:00
parent aea1f8a51b
commit d4a46beef7

View file

@ -1,17 +1,64 @@
import {
AuthStorage,
DefaultResourceLoader,
ModelRegistry,
SettingsManager,
SessionManager,
createAgentSession,
InteractiveMode,
runPrintMode,
} from '@mariozechner/pi-coding-agent'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
import { buildResourceLoader, initResources } from './resource-loader.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.
@ -19,7 +66,11 @@ ensureManagedTools(join(agentDir, 'bin'))
const authStorage = AuthStorage.create(authFilePath)
loadStoredEnvKeys(authStorage)
await runWizardIfNeeded(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)
@ -60,6 +111,70 @@ 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()
@ -68,7 +183,7 @@ const projectSessionsDir = join(sessionsDir, safePath)
const sessionManager = SessionManager.create(cwd, projectSessionsDir)
initResources(agentDir)
const resourceLoader = buildResourceLoader(agentDir)
const resourceLoader = new DefaultResourceLoader({ agentDir })
await resourceLoader.reload()
const { session, extensionsResult } = await createAgentSession({