diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 066f4f10b..2df180613 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -189,7 +189,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { accessibilityLabel="Select thinking option" testID="agent-thinking-selector" > - + {displayThinking} diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 8d34d7990..d3308c411 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -12,9 +12,15 @@ interface DiffViewerProps { diffLines: DiffLine[]; maxHeight?: number; emptyLabel?: string; + fillAvailableHeight?: boolean; } -export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) { +export function DiffViewer({ + diffLines, + maxHeight, + emptyLabel = "No changes to display", + fillAvailableHeight = false, +}: DiffViewerProps) { const [scrollViewWidth, setScrollViewWidth] = React.useState(0); if (!diffLines.length) { @@ -27,7 +33,11 @@ export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No change return ( { return { verticalScroll: {}, + fillHeight: { + flex: 1, + minHeight: 0, + }, verticalContent: { flexGrow: 1, paddingBottom: insets.extraBottom, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 613082c81..fa27e6538 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -223,6 +223,9 @@ const DiffFileHeader = memo(function DiffFileHeader({ testID, }: DiffFileSectionProps) { const expandStartRef = useRef(null); + const layoutYRef = useRef(null); + const pressHandledRef = useRef(false); + const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); const { hunkCount, lineCount, tokenCount } = useMemo(() => { let totalLines = 0; @@ -247,6 +250,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD; const toggleExpanded = useCallback(() => { + pressHandledRef.current = true; if (isPerfLoggingEnabled() && shouldLogFileMetrics) { expandStartRef.current = getNowMs(); perfLog(DIFF_FILE_LOG_TAG, { @@ -295,6 +299,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ !isExpanded && styles.fileSectionBorder, ]} onLayout={(event) => { + layoutYRef.current = event.nativeEvent.layout.y; onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} @@ -305,6 +310,34 @@ const DiffFileHeader = memo(function DiffFileHeader({ styles.fileHeader, pressed && styles.fileHeaderPressed, ]} + // Android: prevent parent pan/scroll gestures from canceling the tap release. + cancelable={false} + onPressIn={(event) => { + pressHandledRef.current = false; + pressInRef.current = { + ts: Date.now(), + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }} + onPressOut={(event) => { + if ( + Platform.OS !== "web" && + !pressHandledRef.current && + layoutYRef.current === 0 && + pressInRef.current + ) { + const durationMs = Date.now() - pressInRef.current.ts; + const dx = event.nativeEvent.pageX - pressInRef.current.pageX; + const dy = event.nativeEvent.pageY - pressInRef.current.pageY; + const distance = Math.hypot(dx, dy); + // Sticky headers on Android can emit pressIn/pressOut without onPress. + // Treat short, low-movement interactions as taps. + if (durationMs <= 500 && distance <= 12) { + toggleExpanded(); + } + } + }} onPress={toggleExpanded} > @@ -562,8 +595,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { for (let i = 0; i < files.length; i++) { const file = files[i]; const isExpanded = expandedByPath[file.path] ?? false; - stickyIndices.push(items.length); items.push({ type: "header", file, fileIndex: i, isExpanded }); + if (isExpanded) { + stickyIndices.push(items.length - 1); + } if (isExpanded) { items.push({ type: "body", file, fileIndex: i }); } @@ -607,6 +642,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const handleToggleExpanded = useCallback( (path: string) => { const isCurrentlyExpanded = expandedByPath[path] ?? false; + const nextExpanded = !isCurrentlyExpanded; const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; // Anchor to the clicked header before collapsing so visual context is preserved. @@ -619,7 +655,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { setExpandedByPath((prev) => ({ ...prev, - [path]: !prev[path], + // Use a deterministic target value (instead of toggling from prev) so duplicate + // onPress events from sticky headers on Android can't flip back immediately. + [path]: nextExpanded, })); }, [computeHeaderOffset, expandedByPath] diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 75a78baed..4d257a7a9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -401,7 +401,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderWidth: theme.borderWidth[1], borderTopWidth: 0, borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, padding: 0, gap: 0, flexShrink: 1, diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index ca5ebddf6..7e832606c 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -19,13 +19,17 @@ interface ToolCallDetailsContentProps { detail?: ToolCallDetail; errorText?: string; maxHeight?: number; + fillAvailableHeight?: boolean; } export function ToolCallDetailsContent({ detail, errorText, - maxHeight = 300, + maxHeight, + fillAvailableHeight = false, }: ToolCallDetailsContentProps) { + const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); + // Compute diff lines for edit type const diffLines = useMemo(() => { if (!detail || detail.type !== "edit") return undefined; @@ -39,6 +43,12 @@ export function ToolCallDetailsContent({ const sections: ReactNode[] = []; const isFullBleed = detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; + const shouldFill = + fillAvailableHeight && + (detail?.type === "shell" || + detail?.type === "edit" || + detail?.type === "write" || + detail?.type === "read"); const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; if (detail?.type === "shell") { @@ -46,10 +56,17 @@ export function ToolCallDetailsContent({ const commandOutput = (detail.output ?? "").replace(/^\n+/, ""); const hasOutput = commandOutput.length > 0; sections.push( - - + + + {diffLines ? ( - - + + ) : null} ); } else if (detail?.type === "write") { sections.push( - + {detail.content ? ( ); } else if (detail?.type === "read") { - sections.push( - - {(detail.offset !== undefined || detail.limit !== undefined) ? ( - - {detail.offset !== undefined ? `Offset: ${detail.offset}` : ""} - {detail.offset !== undefined && detail.limit !== undefined ? " • " : ""} - {detail.limit !== undefined ? `Limit: ${detail.limit}` : ""} - - ) : null} - {detail.content ? ( + if (detail.content) { + sections.push( + {detail.content} - ) : null} - - ); + + ); + } } else if (detail?.type === "search") { sections.push( @@ -145,16 +176,8 @@ export function ToolCallDetailsContent({ if (plainInputText !== null) { sections.push( - - - {plainInputText} - + + {plainInputText} ); } else { @@ -225,7 +248,12 @@ export function ToolCallDetailsContent({ } return ( - + {sections} ); @@ -239,7 +267,7 @@ const styles = StyleSheet.create((theme) => { return { paddedContainer: { gap: theme.spacing[4], - padding: theme.spacing[2], + padding: 0, }, fullBleedContainer: { gap: theme.spacing[2], @@ -249,19 +277,26 @@ const styles = StyleSheet.create((theme) => { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingBottom: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, groupHeaderText: { - color: theme.colors.primary, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.bold, - textTransform: "uppercase", - letterSpacing: 1, + fontWeight: theme.fontWeight.normal, }, - section: { + section: { + gap: theme.spacing[2], + }, + fillHeight: { + flex: 1, + minHeight: 0, + }, + plainTextSection: { gap: theme.spacing[2], + padding: theme.spacing[3], }, sectionTitle: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index f7eec4285..917e4217d 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -157,6 +157,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { @@ -208,9 +209,11 @@ const styles = StyleSheet.create((theme) => ({ }, content: { flex: 1, + minHeight: 0, backgroundColor: theme.colors.surface2, }, contentContainer: { padding: 0, + flexGrow: 1, }, })); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 2d6576315..62d56f2b6 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -690,12 +690,6 @@ function AgentScreenContent({ - Loading agent… - {missingAgentState.kind === "error" ? ( - - {missingAgentState.message} - - ) : null} ); @@ -905,7 +899,6 @@ function AgentScreenContent({ {shouldBlockForHistorySync ? ( - Loading agent... ) : ( ({ fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, - loadingSubtext: { - marginTop: theme.spacing[1], - textAlign: "center", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - paddingHorizontal: theme.spacing[6], - }, centerState: { flex: 1, alignItems: "center", diff --git a/packages/cli/bin/paseo b/packages/cli/bin/paseo index 67ea354db..33a61be8e 100755 --- a/packages/cli/bin/paseo +++ b/packages/cli/bin/paseo @@ -1,2 +1,2 @@ -#!/usr/bin/env npx tsx -import '../src/index.js' +#!/usr/bin/env node +import '../dist/index.js' diff --git a/packages/cli/package.json b/packages/cli/package.json index 132907ff8..dda52960f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,15 +3,26 @@ "version": "0.1.0", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", + "files": [ + "bin", + "dist" + ], + "bundleDependencies": [ + "@getpaseo/server", + "@getpaseo/relay" + ], "bin": { "paseo": "./bin/paseo" }, "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", + "prepack": "npm --prefix ../.. run build --workspace=@getpaseo/relay && npm --prefix ../.. run build --workspace=@getpaseo/server && npm run build && bash ./scripts/prepare-bundled-deps.sh", "typecheck": "tsc --noEmit", "test:e2e": "npx zx tests/run-all.ts", "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" }, "dependencies": { + "@getpaseo/relay": "*", "@getpaseo/server": "*", "chalk": "^5.3.0", "commander": "^12.0.0", diff --git a/packages/cli/scripts/prepare-bundled-deps.sh b/packages/cli/scripts/prepare-bundled-deps.sh new file mode 100755 index 000000000..b2280fb1c --- /dev/null +++ b/packages/cli/scripts/prepare-bundled-deps.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$CLI_DIR/../.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paseo-bundle-XXXXXX")" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +# Nested npm calls can inherit dry-run mode from a parent `npm pack --dry-run`. +# Force real pack/install here so bundled deps are materialized. +export npm_config_dry_run=false + +npm --prefix "$REPO_ROOT" pack --silent --workspace=@getpaseo/relay --pack-destination "$TMP_DIR" +npm --prefix "$REPO_ROOT" pack --silent --workspace=@getpaseo/server --pack-destination "$TMP_DIR" + +RELAY_TGZ="$(ls "$TMP_DIR"/getpaseo-relay-*.tgz | head -n 1)" +SERVER_TGZ="$(ls "$TMP_DIR"/getpaseo-server-*.tgz | head -n 1)" + +mkdir -p "$CLI_DIR/node_modules/@getpaseo" +rm -rf "$CLI_DIR/node_modules/@getpaseo/relay" "$CLI_DIR/node_modules/@getpaseo/server" + +npm --prefix "$CLI_DIR" install --no-save --no-package-lock --workspaces=false --silent "$RELAY_TGZ" "$SERVER_TGZ" + +if [ -L "$CLI_DIR/node_modules/@getpaseo/relay" ] || [ -L "$CLI_DIR/node_modules/@getpaseo/server" ]; then + echo "Expected bundled @getpaseo deps to be real directories, not symlinks." >&2 + exit 1 +fi diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3222c7c4..f1c77d3ed 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,9 @@ import { createPermitCommand } from './commands/permit/index.js' import { createProviderCommand } from './commands/provider/index.js' import { createSpeechCommand } from './commands/speech/index.js' import { createWorktreeCommand } from './commands/worktree/index.js' +import { startCommand as daemonStartCommand } from './commands/daemon/start.js' +import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js' +import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js' import { runLsCommand } from './commands/agent/ls.js' import { runRunCommand } from './commands/agent/run.js' import { runLogsCommand } from './commands/agent/logs.js' @@ -123,6 +126,33 @@ export function createCli(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runWaitCommand)) + // Top-level local daemon shortcuts + program.addCommand(daemonStartCommand()) + + program + .command('status') + .description('Show local daemon status (alias for "paseo daemon status")') + .option('--json', 'Output in JSON format') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .action(withOutput(runDaemonStatusCommand)) + + program + .command('restart') + .description('Restart local daemon (alias for "paseo daemon restart")') + .option('--json', 'Output in JSON format') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .option('--timeout ', 'Wait timeout before force step (default: 15)') + .option('--force', 'Send SIGKILL if graceful stop times out') + .option('--listen ', 'Listen target for restarted daemon (host:port, port, or unix socket)') + .option('--port ', 'Port for restarted daemon listen target') + .option('--no-relay', 'Disable relay on restarted daemon') + .option('--no-mcp', 'Disable Agent MCP on restarted daemon') + .option( + '--allowed-hosts ', + 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")' + ) + .action(withOutput(runDaemonRestartCommand)) + // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()) diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 526c7d880..5f207044b 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -14,23 +14,35 @@ export function createDaemonCommand(): Command { daemon .command('status') - .description('Show daemon status') + .description('Show local daemon status') .option('--json', 'Output in JSON format') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') .action(withOutput(runStatusCommand)) daemon .command('stop') - .description('Stop the daemon') + .description('Stop the local daemon') .option('--json', 'Output in JSON format') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .option('--timeout ', 'Wait timeout before failing (default: 15)') + .option('--force', 'Send SIGKILL if graceful stop times out') .action(withOutput(runStopCommand)) daemon .command('restart') - .description('Restart the daemon') + .description('Restart the local daemon') .option('--json', 'Output in JSON format') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .option('--timeout ', 'Wait timeout before force step (default: 15)') + .option('--force', 'Send SIGKILL if graceful stop times out') + .option('--listen ', 'Listen target for restarted daemon (host:port, port, or unix socket)') + .option('--port ', 'Port for restarted daemon listen target') + .option('--no-relay', 'Disable relay on restarted daemon') + .option('--no-mcp', 'Disable Agent MCP on restarted daemon') + .option( + '--allowed-hosts ', + 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")' + ) .action(withOutput(runRestartCommand)) return daemon diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts new file mode 100644 index 000000000..7895e2902 --- /dev/null +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -0,0 +1,411 @@ +import { spawn } from 'node:child_process' +import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { loadConfig, resolvePaseoHome } from '@getpaseo/server' + +export interface DaemonStartOptions { + port?: string + listen?: string + home?: string + foreground?: boolean + relay?: boolean + mcp?: boolean + allowedHosts?: string +} + +export interface LocalDaemonPidInfo { + pid: number + startedAt?: string + hostname?: string + uid?: number + sockPath?: string +} + +export interface LocalDaemonState { + home: string + listen: string + logPath: string + pidPath: string + pidInfo: LocalDaemonPidInfo | null + running: boolean + stalePidFile: boolean +} + +export interface DetachedStartResult { + pid: number | null + logPath: string +} + +export interface StopLocalDaemonOptions { + home?: string + timeoutMs?: number + force?: boolean +} + +export interface StopLocalDaemonResult { + action: 'stopped' | 'not_running' + home: string + pid: number | null + forced: boolean + message: string +} + +type ProcessExitDetails = { + code: number | null + signal: NodeJS.Signals | null + error?: Error +} + +type DetachedStartupResult = + | { exitedEarly: false } + | ({ exitedEarly: true } & ProcessExitDetails) + +const DETACHED_STARTUP_GRACE_MS = 1200 +const PID_POLL_INTERVAL_MS = 100 +const KILL_TIMEOUT_MS = 3000 +const DAEMON_LOG_FILENAME = 'daemon.log' +const DAEMON_PID_FILENAME = 'paseo.pid' + +export const DEFAULT_STOP_TIMEOUT_MS = 15_000 + +const require = createRequire(import.meta.url) + +const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }) + +const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ + exitedEarly: true, + ...details, +}) + +function envWithHome(home?: string): NodeJS.ProcessEnv { + if (!home) { + return process.env + } + + return { ...process.env, PASEO_HOME: home } +} + +function buildRunnerArgs(options: DaemonStartOptions): string[] { + const args: string[] = [] + if (options.relay === false) { + args.push('--no-relay') + } + + if (options.mcp === false) { + args.push('--no-mcp') + } + + return args +} + +function resolveDaemonRunnerEntry(): string { + const serverExportPath = require.resolve('@getpaseo/server') + let currentDir = path.dirname(serverExportPath) + + while (true) { + const packageJsonPath = path.join(currentDir, 'package.json') + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { name?: string } + if (packageJson.name === '@getpaseo/server') { + const distRunner = path.join(currentDir, 'dist', 'scripts', 'daemon-runner.js') + if (existsSync(distRunner)) { + return distRunner + } + return path.join(currentDir, 'scripts', 'daemon-runner.ts') + } + } catch { + // Continue searching up if package.json exists but is invalid. + } + } + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) { + break + } + currentDir = parentDir + } + + throw new Error('Unable to resolve @getpaseo/server package root for daemon runner') +} + +function pidFilePath(paseoHome: string): string { + return path.join(paseoHome, DAEMON_PID_FILENAME) +} + +function readPidFile(pidPath: string): LocalDaemonPidInfo | null { + try { + const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as Record + const pidValue = parsed.pid + if (typeof pidValue !== 'number' || !Number.isInteger(pidValue) || pidValue <= 0) { + return null + } + + return { + pid: pidValue, + startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : undefined, + hostname: typeof parsed.hostname === 'string' ? parsed.hostname : undefined, + uid: typeof parsed.uid === 'number' ? parsed.uid : undefined, + sockPath: typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined, + } + } catch { + return null + } +} + +function tailFile(filePath: string, lines = 30): string | null { + try { + const content = readFileSync(filePath, 'utf-8') + return content.split('\n').filter(Boolean).slice(-lines).join('\n') + } catch { + return null + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined + if (code === 'EPERM') { + return true + } + return false + } +} + +function signalProcess(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal) + return true + } catch (err) { + const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined + if (code === 'ESRCH') { + return false + } + throw err + } +} + +async function waitForExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) { + return true + } + await sleep(PID_POLL_INTERVAL_MS) + } + + return !isProcessRunning(pid) +} + +export function resolveLocalPaseoHome(home?: string): string { + return resolvePaseoHome(envWithHome(home)) +} + +export function resolveTcpHostFromListen(listen: string): string | null { + const normalized = listen.trim() + if (!normalized) { + return null + } + + if (normalized.startsWith('/') || normalized.startsWith('unix://')) { + return null + } + + if (/^\d+$/.test(normalized)) { + return `127.0.0.1:${normalized}` + } + + if (normalized.includes(':')) { + return normalized + } + + return null +} + +export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState { + const env: NodeJS.ProcessEnv = { + ...envWithHome(options.home), + // Status should reflect local persisted config + pid file, not inherited daemon env overrides. + PASEO_LISTEN: undefined, + PASEO_ALLOWED_HOSTS: undefined, + } + const home = resolvePaseoHome(env) + const config = loadConfig(home, { env }) + const pidPath = pidFilePath(home) + const logPath = path.join(home, DAEMON_LOG_FILENAME) + const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null + const running = pidInfo ? isProcessRunning(pidInfo.pid) : false + const listen = pidInfo?.sockPath ?? config.listen + + return { + home, + listen, + logPath, + pidPath, + pidInfo, + running, + stalePidFile: Boolean(pidInfo) && !running, + } +} + +export function tailDaemonLog(home?: string, lines = 30): string | null { + const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME) + return tailFile(logPath, lines) +} + +export async function startLocalDaemonDetached( + options: DaemonStartOptions +): Promise { + if (options.listen && options.port) { + throw new Error('Cannot use --listen and --port together') + } + + const childEnv: NodeJS.ProcessEnv = { ...process.env } + if (options.home) { + childEnv.PASEO_HOME = options.home + } + if (options.listen) { + childEnv.PASEO_LISTEN = options.listen + } else if (options.port) { + childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}` + } + if (options.allowedHosts) { + childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts + } + + const paseoHome = resolvePaseoHome(childEnv) + const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME) + const daemonRunnerEntry = resolveDaemonRunnerEntry() + const logFd = openSync(logPath, 'a') + + try { + const child = spawn( + process.execPath, + [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], + { + detached: true, + env: childEnv, + stdio: ['ignore', logFd, logFd], + } + ) + + child.unref() + + const startup = await new Promise((resolve) => { + let settled = false + + const finish = (value: DetachedStartupResult) => { + if (settled) return + settled = true + resolve(value) + } + + const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS) + + child.once('error', (error) => { + clearTimeout(timer) + finish(startupExited({ code: null, signal: null, error })) + }) + + child.once('exit', (code, signal) => { + clearTimeout(timer) + finish(startupExited({ code, signal })) + }) + }) + + if (startup.exitedEarly) { + const reason = startup.error + ? startup.error.message + : `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}` + const recentLogs = tailFile(logPath) + throw new Error( + [ + `Daemon failed to start in background (${reason}).`, + recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, + ] + .filter(Boolean) + .join('\n\n') + ) + } + + return { + pid: child.pid ?? null, + logPath, + } + } finally { + closeSync(logFd) + } +} + +export async function stopLocalDaemon( + options: StopLocalDaemonOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS + const state = resolveLocalDaemonState({ home: options.home }) + + if (!state.pidInfo || !state.running) { + const staleSuffix = + state.stalePidFile && state.pidInfo + ? ` (stale PID file for ${state.pidInfo.pid})` + : '' + return { + action: 'not_running', + home: state.home, + pid: state.pidInfo?.pid ?? null, + forced: false, + message: `Daemon is not running${staleSuffix}`, + } + } + + const pid = state.pidInfo.pid + const signaled = signalProcess(pid, 'SIGTERM') + if (!signaled) { + return { + action: 'not_running', + home: state.home, + pid, + forced: false, + message: 'Daemon process was already stopped', + } + } + + let forced = false + let stopped = await waitForExit(pid, timeoutMs) + + if (!stopped && options.force) { + forced = true + const killSent = signalProcess(pid, 'SIGKILL') + if (killSent) { + stopped = await waitForExit(pid, KILL_TIMEOUT_MS) + } else { + stopped = true + } + } + + if (!stopped) { + throw new Error( + `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s` + ) + } + + rmSync(state.pidPath, { force: true }) + + return { + action: 'stopped', + home: state.home, + pid, + forced, + message: forced ? 'Daemon was force-stopped' : 'Daemon stopped gracefully', + } +} diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index de95b4bb8..a9e1ef2b6 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -1,85 +1,122 @@ import type { Command } from 'commander' -import { connectToDaemon, getDaemonHost } from '../../utils/client.js' +import { + startLocalDaemonDetached, + stopLocalDaemon, + DEFAULT_STOP_TIMEOUT_MS, + type DaemonStartOptions, +} from './local-daemon.js' import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js' -/** Result of restart command */ interface RestartResult { - action: 'restarted' | 'not_running' - host: string + action: 'restarted' + home: string + pid: string message: string } -/** Schema for restart result */ const restartResultSchema: OutputSchema = { idField: 'action', columns: [ { header: 'STATUS', field: 'action', - color: (value) => (value === 'restarted' ? 'green' : 'red'), + color: () => 'green', }, - { header: 'HOST', field: 'host' }, + { header: 'HOME', field: 'home' }, + { header: 'PID', field: 'pid' }, { header: 'MESSAGE', field: 'message' }, ], } export type RestartCommandResult = SingleResult -export async function runRestartCommand( - options: CommandOptions, - _command: Command -): Promise { - const connectOptions = { host: options.host as string | undefined } - const host = getDaemonHost(connectOptions) +function parseTimeoutMs(raw: unknown): number { + if (typeof raw !== 'string' || raw.trim().length === 0) { + return DEFAULT_STOP_TIMEOUT_MS + } - let client - try { - client = await connectToDaemon(connectOptions) - } catch { - // Daemon not running - cannot restart + const seconds = Number(raw) + if (!Number.isFinite(seconds) || seconds <= 0) { const error: CommandError = { - code: 'DAEMON_NOT_RUNNING', - message: `Daemon is not running (tried to connect to ${host})`, - details: 'Start the daemon with: paseo daemon start', + code: 'INVALID_TIMEOUT', + message: `Invalid timeout value: ${raw}`, + details: 'Timeout must be a positive number of seconds', } throw error } - try { - // Request server restart - await client.restartServer('cli_restart') + return Math.ceil(seconds * 1000) +} - await client.close() +function toStartOptions(options: CommandOptions): DaemonStartOptions { + const startOptions: DaemonStartOptions = { + home: typeof options.home === 'string' ? options.home : undefined, + listen: typeof options.listen === 'string' ? options.listen : undefined, + port: typeof options.port === 'string' ? options.port : undefined, + relay: typeof options.relay === 'boolean' ? options.relay : undefined, + mcp: typeof options.mcp === 'boolean' ? options.mcp : undefined, + allowedHosts: typeof options.allowedHosts === 'string' ? options.allowedHosts : undefined, + } + + if (startOptions.listen && startOptions.port) { + const error: CommandError = { + code: 'INVALID_OPTIONS', + message: 'Cannot use --listen and --port together', + } + throw error + } + + return startOptions +} + +export async function runRestartCommand( + options: CommandOptions, + _command: Command +): Promise { + const timeoutMs = parseTimeoutMs(options.timeout) + const force = options.force === true + const startOptions = toStartOptions(options) + + try { + let stopResult: Awaited> + try { + stopResult = await stopLocalDaemon({ + home: startOptions.home, + timeoutMs, + force, + }) + } catch (err) { + const isTimeout = err instanceof Error && err.message.includes('Timed out waiting for daemon PID') + if (!force && isTimeout) { + stopResult = await stopLocalDaemon({ + home: startOptions.home, + timeoutMs, + force: true, + }) + } else { + throw err + } + } + + const startup = await startLocalDaemonDetached(startOptions) + const before = stopResult.pid === null ? 'not running' : `PID ${stopResult.pid}` + const after = startup.pid === null ? 'unknown PID' : `PID ${startup.pid}` return { type: 'single', data: { action: 'restarted', - host, - message: 'Daemon restart requested', + home: stopResult.home, + pid: startup.pid === null ? '-' : String(startup.pid), + message: `Local daemon restarted (${before} -> ${after})`, }, schema: restartResultSchema, } } catch (err) { - await client.close().catch(() => {}) const message = err instanceof Error ? err.message : String(err) - - // If connection was closed, the daemon is restarting - if (message.includes('closed') || message.includes('disconnected')) { - return { - type: 'single', - data: { - action: 'restarted', - host, - message: 'Daemon is restarting', - }, - schema: restartResultSchema, - } - } - const error: CommandError = { code: 'RESTART_FAILED', - message: `Failed to restart daemon: ${message}`, + message: `Failed to restart local daemon: ${message}`, } throw error } diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 27652ce10..3b6bc230d 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,8 +1,5 @@ import { Command } from 'commander' import chalk from 'chalk' -import { spawn } from 'node:child_process' -import { closeSync, openSync, readFileSync } from 'node:fs' -import path from 'node:path' import { createPaseoDaemon, loadConfig, @@ -11,36 +8,16 @@ import { loadPersistedConfig, } from '@getpaseo/server' import type { CliConfigOverrides } from '@getpaseo/server' +import { + startLocalDaemonDetached, + type DaemonStartOptions as StartOptions, +} from './local-daemon.js' -interface StartOptions { - port?: string - listen?: string - home?: string - foreground?: boolean - relay?: boolean - mcp?: boolean - allowedHosts?: string -} - -interface DetachedStartupReady { - exitedEarly: false -} - -interface DetachedStartupExited { - exitedEarly: true - code: number | null - signal: NodeJS.Signals | null - error?: Error -} - -type DetachedStartupResult = DetachedStartupReady | DetachedStartupExited - -const DETACHED_STARTUP_GRACE_MS = 1200 -const DAEMON_LOG_FILENAME = 'daemon.log' +export type { DaemonStartOptions as StartOptions } from './local-daemon.js' export function startCommand(): Command { return new Command('start') - .description('Start the Paseo daemon') + .description('Start the local Paseo daemon') .option('--listen ', 'Listen target (host:port, port, or unix socket path)') .option('--port ', 'Port to listen on (default: 6767)') .option('--home ', 'Paseo home directory (default: ~/.paseo)') @@ -49,148 +26,14 @@ export function startCommand(): Command { .option('--no-mcp', 'Disable the Agent MCP HTTP endpoint') .option( '--allowed-hosts ', - 'Comma-separated list of allowed Host header values (Vite-style; e.g., "localhost,.example.com" or "true")' + 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")' ) .action(async (options: StartOptions) => { await runStart(options) }) } -function buildForegroundArgs(options: StartOptions): string[] { - const args = ['daemon', 'start', '--foreground'] - - if (options.listen) { - args.push('--listen', options.listen) - } else if (options.port) { - args.push('--port', options.port) - } - - if (options.home) { - args.push('--home', options.home) - } - - if (options.relay === false) { - args.push('--no-relay') - } - - if (options.mcp === false) { - args.push('--no-mcp') - } - - if (options.allowedHosts) { - args.push('--allowed-hosts', options.allowedHosts) - } - - return args -} - -function tailFile(filePath: string, lines = 30): string | null { - try { - const content = readFileSync(filePath, 'utf-8') - return content.split('\n').filter(Boolean).slice(-lines).join('\n') - } catch { - return null - } -} - -async function runDetachedStart(options: StartOptions): Promise { - const childEnv: NodeJS.ProcessEnv = { ...process.env } - if (options.home) { - childEnv.PASEO_HOME = options.home - } - - const paseoHome = resolvePaseoHome(childEnv) - const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME) - - const cliEntry = process.argv[1] - if (!cliEntry) { - throw new Error('Unable to determine CLI entrypoint for detached daemon start') - } - - const logFd = openSync(logPath, 'a') - - try { - const child = spawn( - process.execPath, - [...process.execArgv, cliEntry, ...buildForegroundArgs(options)], - { - detached: true, - env: childEnv, - stdio: ['ignore', logFd, logFd], - } - ) - - child.unref() - - const startup = await new Promise((resolve) => { - let settled = false - - const finish = (value: DetachedStartupResult) => { - if (settled) return - settled = true - resolve(value) - } - - const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS) - - child.once('error', (error) => { - clearTimeout(timer) - finish({ exitedEarly: true, code: null, signal: null, error }) - }) - - child.once('exit', (code, signal) => { - clearTimeout(timer) - finish({ exitedEarly: true, code, signal }) - }) - }) - - if (startup.exitedEarly) { - const reason = startup.error - ? startup.error.message - : `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}` - const recentLogs = tailFile(logPath) - throw new Error( - [ - `Daemon failed to start in background (${reason}).`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join('\n\n') - ) - } - - console.log(chalk.green(`Daemon starting in background (PID ${child.pid ?? 'unknown'}).`)) - console.log(chalk.dim(`Logs: ${logPath}`)) - } finally { - closeSync(logFd) - } -} - -async function runStart(options: StartOptions): Promise { - if (options.listen && options.port) { - console.error(chalk.red('Cannot use --listen and --port together')) - process.exit(1) - } - - if (!options.foreground) { - try { - await runDetachedStart(options) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error(chalk.red(message)) - process.exit(1) - } - return - } - - // Set environment variables based on CLI options - if (options.home) { - process.env.PASEO_HOME = options.home - } - - let paseoHome: string - let logger: ReturnType - let config: ReturnType +function toCliOverrides(options: StartOptions): CliConfigOverrides { const cliOverrides: CliConfigOverrides = {} if (options.listen) { @@ -215,11 +58,41 @@ async function runStart(options: StartOptions): Promise { cliOverrides.mcpEnabled = false } + return cliOverrides +} + +export async function runStart(options: StartOptions): Promise { + if (options.listen && options.port) { + console.error(chalk.red('Cannot use --listen and --port together')) + process.exit(1) + } + + if (!options.foreground) { + try { + const startup = await startLocalDaemonDetached(options) + console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? 'unknown'}).`)) + console.log(chalk.dim(`Logs: ${startup.logPath}`)) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(chalk.red(message)) + process.exit(1) + } + return + } + + if (options.home) { + process.env.PASEO_HOME = options.home + } + + let paseoHome: string + let logger: ReturnType + let config: ReturnType + try { paseoHome = resolvePaseoHome() const persistedConfig = loadPersistedConfig(paseoHome) logger = createRootLogger(persistedConfig) - config = loadConfig(paseoHome, { cli: cliOverrides }) + config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(chalk.red(message)) @@ -235,7 +108,6 @@ async function runStart(options: StartOptions): Promise { process.exit(1) } - // Handle graceful shutdown let shuttingDown = false const handleShutdown = async (signal: string) => { if (shuttingDown) { diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index a173ab26a..2ff172949 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,24 +1,26 @@ import type { Command } from 'commander' -import { resolvePaseoHome } from '@getpaseo/server' -import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js' -import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js' +import { tryConnectToDaemon } from '../../utils/client.js' +import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js' +import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js' -/** Status data for the daemon */ interface DaemonStatus { - status: 'running' | 'stopped' - host: string + status: 'running' | 'stopped' | 'unresponsive' home: string - runningAgents: number - idleAgents: number + listen: string + pid: number | null + startedAt: string | null + owner: string | null + logPath: string + runningAgents: number | null + idleAgents: number | null + note?: string } -/** Key-value row for table display */ interface StatusRow { key: string value: string } -/** Schema for key-value display with custom serialization for JSON/YAML */ function createStatusSchema(status: DaemonStatus): OutputSchema { return { idField: 'key', @@ -28,28 +30,60 @@ function createStatusSchema(status: DaemonStatus): OutputSchema { header: 'VALUE', field: 'value', color: (_, item) => { - if (item.key === 'Status') { - return item.value === 'running' ? 'green' : 'red' + if (item.key !== 'Status') { + return undefined } - return undefined + if (item.value === 'running') { + return 'green' + } + if (item.value === 'unresponsive') { + return 'yellow' + } + return 'red' }, }, ], - // For JSON/YAML, return the structured status object (not key-value rows) - // The serializer receives each item, but we want the whole object - // So we return null for individual items and handle it at the result level - serialize: (_item) => status, + serialize: () => status, } } -/** Convert status to key-value rows for table display */ function toStatusRows(status: DaemonStatus): StatusRow[] { - return [ + const rows: StatusRow[] = [ { key: 'Status', value: status.status }, - { key: 'Host', value: status.host }, { key: 'Home', value: status.home }, - { key: 'Agents', value: `${status.runningAgents} running, ${status.idleAgents} idle` }, + { key: 'Listen', value: status.listen }, + { key: 'PID', value: status.pid === null ? '-' : String(status.pid) }, + { key: 'Started', value: status.startedAt ?? '-' }, + { key: 'Owner', value: status.owner ?? '-' }, + { key: 'Logs', value: status.logPath }, ] + + if (status.runningAgents !== null && status.idleAgents !== null) { + rows.push({ + key: 'Agents', + value: `${status.runningAgents} running, ${status.idleAgents} idle`, + }) + } else { + rows.push({ + key: 'Agents', + value: 'Unavailable (daemon API not reachable)', + }) + } + + if (status.note) { + rows.push({ key: 'Note', value: status.note }) + } + + return rows +} + +function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { + if (uid === undefined && !hostname) { + return null + } + const uidPart = uid === undefined ? '?' : String(uid) + const hostPart = hostname ?? 'unknown-host' + return `${uidPart}@${hostPart}` } export type StatusResult = ListResult @@ -58,49 +92,59 @@ export async function runStatusCommand( options: CommandOptions, _command: Command ): Promise { - const connectOptions = { host: options.host as string | undefined } - const host = getDaemonHost(connectOptions) - const client = await tryConnectToDaemon(connectOptions) + const home = typeof options.home === 'string' ? options.home : undefined + const state = resolveLocalDaemonState({ home }) - if (!client) { - const error: CommandError = { - code: 'DAEMON_NOT_RUNNING', - message: `Daemon is not running (tried to connect to ${host})`, - details: 'Start the daemon with: paseo daemon start', - } - throw error + const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname) + let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped' + let runningAgents: number | null = null + let idleAgents: number | null = null + let note: string | undefined + + if (!state.running && state.stalePidFile && state.pidInfo) { + note = `Stale PID file found for PID ${state.pidInfo.pid}` } - try { - const agents = await client.fetchAgents() - const runningAgents = agents.filter((a) => a.status === 'running') - const idleAgents = agents.filter((a) => a.status === 'idle') - - // Get paseo home for display - const paseoHome = resolvePaseoHome() - - const status: DaemonStatus = { - status: 'running', - host, - home: paseoHome, - runningAgents: runningAgents.length, - idleAgents: idleAgents.length, + if (state.running) { + const host = resolveTcpHostFromListen(state.listen) + if (host) { + const client = await tryConnectToDaemon({ host, timeout: 1500 }) + if (client) { + try { + const agents = await client.fetchAgents() + runningAgents = agents.filter(a => a.status === 'running').length + idleAgents = agents.filter(a => a.status === 'idle').length + } catch { + status = 'unresponsive' + note = `Daemon PID is running but API requests to ${host} failed` + } finally { + await client.close().catch(() => {}) + } + } else { + status = 'unresponsive' + note = `Daemon PID is running but websocket at ${host} is not reachable` + } + } else { + note = 'Daemon is configured for unix socket listen; API probe skipped' } + } - await client.close() + const daemonStatus: DaemonStatus = { + status, + home: state.home, + listen: state.listen, + pid: state.pidInfo?.pid ?? null, + startedAt: state.pidInfo?.startedAt ?? null, + owner, + logPath: state.logPath, + runningAgents, + idleAgents, + note, + } - return { - type: 'list', - data: toStatusRows(status), - schema: createStatusSchema(status), - } - } catch (err) { - await client.close().catch(() => {}) - const message = err instanceof Error ? err.message : String(err) - const error: CommandError = { - code: 'STATUS_FAILED', - message: `Failed to get status: ${message}`, - } - throw error + return { + type: 'list', + data: toStatusRows(daemonStatus), + schema: createStatusSchema(daemonStatus), } } diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index eaaf08808..72b307ad8 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,15 +1,14 @@ import type { Command } from 'commander' -import { connectToDaemon, getDaemonHost } from '../../utils/client.js' +import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from './local-daemon.js' import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js' -/** Result of stop command */ interface StopResult { action: 'stopped' | 'not_running' - host: string + home: string + pid: string message: string } -/** Schema for stop result */ const stopResultSchema: OutputSchema = { idField: 'action', columns: [ @@ -18,75 +17,57 @@ const stopResultSchema: OutputSchema = { field: 'action', color: (value) => (value === 'stopped' ? 'green' : 'yellow'), }, - { header: 'HOST', field: 'host' }, + { header: 'HOME', field: 'home' }, + { header: 'PID', field: 'pid' }, { header: 'MESSAGE', field: 'message' }, ], } export type StopCommandResult = SingleResult +function parseTimeoutMs(raw: unknown): number { + if (typeof raw !== 'string' || raw.trim().length === 0) { + return DEFAULT_STOP_TIMEOUT_MS + } + + const seconds = Number(raw) + if (!Number.isFinite(seconds) || seconds <= 0) { + const error: CommandError = { + code: 'INVALID_TIMEOUT', + message: `Invalid timeout value: ${raw}`, + details: 'Timeout must be a positive number of seconds', + } + throw error + } + + return Math.ceil(seconds * 1000) +} + export async function runStopCommand( options: CommandOptions, _command: Command ): Promise { - const connectOptions = { host: options.host as string | undefined } - const host = getDaemonHost(connectOptions) + const home = typeof options.home === 'string' ? options.home : undefined + const force = options.force === true + const timeoutMs = parseTimeoutMs(options.timeout) - let client try { - client = await connectToDaemon(connectOptions) - } catch { - // Daemon not running - this is a valid outcome + const result = await stopLocalDaemon({ home, force, timeoutMs }) return { type: 'single', data: { - action: 'not_running', - host, - message: 'Daemon was not running', - }, - schema: stopResultSchema, - } - } - - try { - // Request server restart with "shutdown" reason - // This signals the daemon to shut down gracefully - await client.restartServer('cli_shutdown') - - // Give the daemon a moment to acknowledge - await new Promise((resolve) => setTimeout(resolve, 500)) - - await client.close() - - return { - type: 'single', - data: { - action: 'stopped', - host, - message: 'Daemon stop requested - shutting down gracefully', + action: result.action, + home: result.home, + pid: result.pid === null ? '-' : String(result.pid), + message: result.message, }, schema: stopResultSchema, } } catch (err) { - await client.close().catch(() => {}) const message = err instanceof Error ? err.message : String(err) - - // If connection was closed, the daemon is stopping - if (message.includes('closed') || message.includes('disconnected')) { - return { - type: 'single', - data: { - action: 'stopped', - host, - message: 'Daemon is stopping', - }, - schema: stopResultSchema, - } - } - const error: CommandError = { code: 'STOP_FAILED', - message: `Failed to stop daemon: ${message}`, + message: `Failed to stop local daemon: ${message}`, } throw error } diff --git a/packages/relay/package.json b/packages/relay/package.json index f2763da5f..3d772292a 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,14 +1,33 @@ { "name": "@getpaseo/relay", "version": "0.1.0", + "private": true, "description": "Paseo relay for bridging daemon and client connections", "type": "module", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], "exports": { - ".": "./src/index.ts", - "./e2ee": "./src/e2ee.ts", - "./cloudflare": "./src/cloudflare-adapter.ts" + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "default": "./src/index.ts" + }, + "./e2ee": { + "types": "./dist/e2ee.d.ts", + "node": "./dist/e2ee.js", + "default": "./src/e2ee.ts" + }, + "./cloudflare": { + "types": "./dist/cloudflare-adapter.d.ts", + "node": "./dist/cloudflare-adapter.js", + "default": "./src/cloudflare-adapter.ts" + } }, "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", + "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json index 5485126b1..55414c93c 100644 --- a/packages/relay/tsconfig.json +++ b/packages/relay/tsconfig.json @@ -13,6 +13,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "outDir": "./dist", + "rootDir": "./src", "declaration": true, "declarationMap": true, "sourceMap": true diff --git a/packages/server/package.json b/packages/server/package.json index 94c3ccb6c..f578dcb8f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,17 +1,41 @@ { "name": "@getpaseo/server", "version": "0.1.0", + "private": true, "description": "Paseo backend server", "type": "module", + "types": "./dist/server/server/exports.d.ts", + "files": [ + "dist/server", + "dist/scripts", + "README.md", + ".env.example", + "agent-prompt.md" + ], "exports": { - ".": "./src/server/exports.ts", - "./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts" + ".": { + "types": "./dist/server/server/exports.d.ts", + "default": [ + "./dist/server/server/exports.js", + "./src/server/exports.ts" + ] + }, + "./utils/tool-call-parsers": { + "types": "./dist/server/utils/tool-call-parsers.d.ts", + "default": [ + "./dist/server/utils/tool-call-parsers.js", + "./src/utils/tool-call-parsers.ts" + ] + } }, "scripts": { "dev": "NODE_ENV=development tsx scripts/dev-runner.ts", "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", - "build": "tsc -p tsconfig.server.json", - "start": "NODE_ENV=production node dist/server/index.js", + "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", + "build:lib": "tsc -p tsconfig.server.json --incremental false", + "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && mkdir -p dist/scripts && cp scripts/mcp-stdio-socket-bridge-cli.mjs dist/scripts/mcp-stdio-socket-bridge-cli.mjs", + "prepack": "npm run build", + "start": "NODE_ENV=production node dist/server/server/index.js", "typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", "speech:models": "tsx scripts/list-speech-models.ts", @@ -25,7 +49,8 @@ "test:e2e:mobile": "playwright test --project='Mobile Chrome'" }, "dependencies": { - "@ai-sdk/openai": "^2.0.52", + "@getpaseo/relay": "*", + "@ai-sdk/openai": "2.0.52", "@deepgram/sdk": "^3.4.0", "@lezer/common": "^1.5.0", "@lezer/css": "^1.3.0", @@ -39,7 +64,7 @@ "@opencode-ai/sdk": "^1.1.12", "@sctg/sentencepiece-js": "^1.1.0", "@xterm/headless": "^6.0.0", - "ai": "^5.0.76", + "ai": "5.0.78", "ajv": "^8.17.1", "dotenv": "^17.2.3", "express": "^4.18.2", @@ -50,7 +75,6 @@ "openai": "^4.20.0", "pino": "^10.2.0", "pino-pretty": "^13.1.3", - "playwright": "^1.56.1", "qrcode": "^1.5.4", "sherpa-onnx": "^1.12.23", "sherpa-onnx-node": "^1.12.23", @@ -62,8 +86,8 @@ "zod-to-json-schema": "^3.25.1" }, "devDependencies": { - "@getpaseo/relay": "*", "@playwright/test": "^1.56.1", + "playwright": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", diff --git a/packages/server/scripts/daemon-runner.ts b/packages/server/scripts/daemon-runner.ts new file mode 100644 index 000000000..d6c6f186c --- /dev/null +++ b/packages/server/scripts/daemon-runner.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from "url"; +import { existsSync } from "node:fs"; +import { runSupervisor } from "./supervisor.js"; + +function resolveWorkerEntry(): string { + const candidates = [ + fileURLToPath(new URL("../server/server/index.js", import.meta.url)), + fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)), + fileURLToPath(new URL("../src/server/index.ts", import.meta.url)), + fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return candidates[0]; +} + +function resolveWorkerExecArgv(): string[] { + const workerEntry = resolveWorkerEntry(); + return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : []; +} + +runSupervisor({ + name: "DaemonRunner", + startupMessage: "Starting daemon worker (IPC restart enabled)", + resolveWorkerEntry, + workerArgs: process.argv.slice(2), + workerEnv: process.env, + workerExecArgv: resolveWorkerExecArgv(), + restartOnCrash: false, + shutdownReasons: ["cli_shutdown"], +}); diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index 4cbf4ce9b..91a7d1526 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -1,63 +1,36 @@ -import { fork, type ChildProcess } from "child_process"; import { fileURLToPath } from "url"; -import path from "path"; +import { existsSync } from "node:fs"; +import { runSupervisor } from "./supervisor.js"; -const serverEntry = fileURLToPath( - new URL("../src/server/index.ts", import.meta.url) -); +function resolveWorkerEntry(): string { + const candidates = [ + fileURLToPath(new URL("../server/server/index.js", import.meta.url)), + fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)), + fileURLToPath(new URL("../src/server/index.ts", import.meta.url)), + fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)), + ]; -let child: ChildProcess | null = null; -let restarting = false; - -function spawnServer() { - child = fork(serverEntry, process.argv.slice(2), { - stdio: "inherit", - env: process.env, - execArgv: ["--import", "tsx"], - }); - - child.on("message", (msg: any) => { - if (msg?.type === "paseo:restart") { - restartServer(); + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; } - }); - - child.on("exit", (code, signal) => { - const exitDescriptor = - signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); - - // Restart on: explicit restart request, or any non-zero exit (crash) - if (restarting || (code !== 0 && code !== null)) { - restarting = false; - process.stderr.write(`[DevRunner] Server exited (${exitDescriptor}). Restarting...\n`); - spawnServer(); - return; - } - - process.stderr.write(`[DevRunner] Server exited (${exitDescriptor}). Shutting down.\n`); - process.exit(0); - }); -} - -function restartServer() { - if (!child || restarting) { - return; } - restarting = true; - process.stderr.write("[DevRunner] Restart requested. Stopping current server...\n"); - child.kill("SIGTERM"); + return candidates[0]; } -function forwardSignal(signal: NodeJS.Signals) { - if (!child) { - process.exit(0); - } - child.kill(signal); +function resolveWorkerExecArgv(): string[] { + const workerEntry = resolveWorkerEntry(); + return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : []; } -process.on("SIGINT", () => forwardSignal("SIGINT")); -process.on("SIGTERM", () => forwardSignal("SIGTERM")); - -process.stdout.write("[DevRunner] Starting server with tsx (explicit restarts only)\n"); -spawnServer(); +runSupervisor({ + name: "DevRunner", + startupMessage: "Starting server worker (crash restarts enabled)", + resolveWorkerEntry, + workerArgs: process.argv.slice(2), + workerEnv: process.env, + workerExecArgv: resolveWorkerExecArgv(), + restartOnCrash: true, + shutdownReasons: ["cli_shutdown"], +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts new file mode 100644 index 000000000..6c11d76b7 --- /dev/null +++ b/packages/server/scripts/supervisor.ts @@ -0,0 +1,130 @@ +import { fork, type ChildProcess } from "child_process"; + +type RestartMessage = { + type: "paseo:restart"; + reason?: string; +}; + +type SupervisorOptions = { + name: string; + startupMessage: string; + resolveWorkerEntry: () => string; + workerArgs?: string[]; + workerEnv?: NodeJS.ProcessEnv; + workerExecArgv?: string[]; + restartOnCrash?: boolean; + shutdownReasons?: string[]; +}; + +function describeExit(code: number | null, signal: NodeJS.Signals | null): string { + return signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); +} + +function isRestartMessage(msg: unknown): msg is RestartMessage { + return ( + typeof msg === "object" && + msg !== null && + "type" in msg && + (msg as { type?: unknown }).type === "paseo:restart" + ); +} + +export function runSupervisor(options: SupervisorOptions): void { + const shutdownReasons = new Set(options.shutdownReasons ?? ["cli_shutdown"]); + const restartOnCrash = options.restartOnCrash ?? false; + const workerArgs = options.workerArgs ?? process.argv.slice(2); + const workerEnv = options.workerEnv ?? process.env; + const workerExecArgv = options.workerExecArgv ?? ["--import", "tsx"]; + + let child: ChildProcess | null = null; + let restarting = false; + let shuttingDown = false; + + const log = (message: string): void => { + process.stderr.write(`[${options.name}] ${message}\n`); + }; + + const spawnWorker = () => { + let workerEntry: string; + try { + // Resolve at spawn time so restarts pick up current filesystem state. + workerEntry = options.resolveWorkerEntry(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Failed to resolve worker entry: ${message}`); + process.exit(1); + return; + } + + child = fork(workerEntry, workerArgs, { + stdio: "inherit", + env: workerEnv, + execArgv: workerExecArgv, + }); + + child.on("message", (msg: unknown) => { + if (!isRestartMessage(msg)) { + return; + } + + if (msg.reason && shutdownReasons.has(msg.reason)) { + requestShutdown(`Shutdown requested by worker (${msg.reason})`); + return; + } + + requestRestart("Restart requested by worker"); + }); + + child.on("exit", (code, signal) => { + const exitDescriptor = describeExit(code, signal); + + if (shuttingDown) { + log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); + process.exit(0); + } + + if (restarting || (restartOnCrash && code !== 0 && code !== null)) { + restarting = false; + log(`Worker exited (${exitDescriptor}). Restarting worker...`); + spawnWorker(); + return; + } + + log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); + process.exit(typeof code === "number" ? code : 0); + }); + }; + + const requestRestart = (reason: string) => { + if (!child || restarting || shuttingDown) { + return; + } + restarting = true; + log(`${reason}. Stopping worker for restart...`); + child.kill("SIGTERM"); + }; + + const requestShutdown = (reason: string) => { + if (shuttingDown) { + return; + } + shuttingDown = true; + restarting = false; + log(`${reason}. Stopping worker...`); + if (!child) { + process.exit(0); + return; + } + child.kill("SIGTERM"); + }; + + const forwardSignal = (signal: NodeJS.Signals) => { + requestShutdown(`Received ${signal}`); + }; + + process.on("SIGINT", () => forwardSignal("SIGINT")); + process.on("SIGTERM", () => forwardSignal("SIGTERM")); + + process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); + spawnWorker(); +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 8a93092de..aabefe766 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -977,7 +977,6 @@ function mapCodexPatchNotificationToToolCall(params: { stdout?: string | null; stderr?: string | null; success?: boolean | null; - latestUnifiedDiff?: string | null; running: boolean; }): ToolCallTimelineItem { const files = parseCodexPatchChanges(params.changes); @@ -985,7 +984,7 @@ function mapCodexPatchNotificationToToolCall(params: { const firstPatchText = files .map((file) => file.content?.trim()) .find((value): value is string => typeof value === "string" && value.length > 0); - const patchText = params.latestUnifiedDiff?.trim() || firstPatchText; + const patchText = firstPatchText; const patchFields = codexPatchTextFields(patchText); const mapped = mapCodexRolloutToolCall({ callId: params.callId ?? null, @@ -1736,7 +1735,6 @@ class CodexAppServerAgentSession implements AgentSession { private warnedUnknownNotificationMethods = new Set(); private warnedInvalidNotificationPayloads = new Set(); private warnedIncompleteEditToolCallIds = new Set(); - private latestTurnUnifiedDiff: string | null = null; private latestUsage: AgentUsage | undefined; private connected = false; private collaborationModes: Array<{ @@ -2422,7 +2420,6 @@ class CodexAppServerAgentSession implements AgentSession { if (parsed.kind === "turn_started") { this.currentTurnId = parsed.turnId; - this.latestTurnUnifiedDiff = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.pendingCommandOutputDeltas.clear(); @@ -2444,7 +2441,6 @@ class CodexAppServerAgentSession implements AgentSession { } else { this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage }); } - this.latestTurnUnifiedDiff = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.pendingCommandOutputDeltas.clear(); @@ -2470,8 +2466,6 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "diff_updated") { - const trimmedDiff = parsed.diff.trim(); - this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null; // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications // containing a full accumulated unified diff for the *entire turn*. // This is not a concrete file-change tool call; it is progress telemetry. @@ -2557,7 +2551,6 @@ class CodexAppServerAgentSession implements AgentSession { callId: parsed.callId, changes: parsed.changes, cwd: this.config.cwd ?? null, - latestUnifiedDiff: this.latestTurnUnifiedDiff, running: true, }); this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", { @@ -2580,7 +2573,6 @@ class CodexAppServerAgentSession implements AgentSession { stdout: parsed.stdout ?? bufferedOutput, stderr: parsed.stderr, success: parsed.success, - latestUnifiedDiff: this.latestTurnUnifiedDiff, running: false, }); this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 5ce23a1d3..e274f5843 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -463,4 +463,38 @@ describe("codex tool-call mapper", () => { expect(item.detail.newString).toBeUndefined(); } }); + + it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-file-change-path-only", + status: "completed", + changes: [{ path: "/tmp/repo/src/path-only.ts", kind: "modify" }], + }, + { cwd: "/tmp/repo" } + ); + + expect(item?.detail.type).toBe("unknown"); + if (item?.detail.type === "unknown") { + expect(item.detail.input).toEqual({ + files: [{ path: "src/path-only.ts", kind: "modify" }], + }); + } + }); + + it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => { + const item = mapCodexRolloutToolCall({ + callId: "codex-call-apply-path-only", + name: "apply_patch", + input: { path: "/tmp/repo/src/path-only-rollout.ts" }, + output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] }, + cwd: "/tmp/repo", + }); + + expect(item.detail.type).toBe("unknown"); + if (item.detail.type === "unknown") { + expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" }); + } + }); }); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 9e7cdcab6..3b544c5ed 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -309,6 +309,17 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con return { content: text }; } +function hasRenderableEditContent(detail: ToolCallDetail): boolean { + if (detail.type !== "edit") { + return false; + } + return ( + (typeof detail.unifiedDiff === "string" && detail.unifiedDiff.length > 0) || + (typeof detail.newString === "string" && detail.newString.length > 0) || + (typeof detail.oldString === "string" && detail.oldString.length > 0) + ); +} + function pickFirstPatchLikeString(values: unknown[]): string | undefined { for (const value of values) { if (typeof value === "string" && value.length > 0) { @@ -610,12 +621,19 @@ function mapFileChangeItem( const firstFile = files[0]; const firstTextFields = asEditTextFields(firstFile?.diff); + const hasFirstTextFields = Object.keys(firstTextFields).length > 0; const detail = firstFile?.path - ? { + ? hasFirstTextFields + ? { type: "edit" as const, filePath: firstFile.path, ...firstTextFields, } + : { + type: "unknown" as const, + input, + output, + } : { type: "unknown" as const, input, @@ -755,6 +773,13 @@ export function mapCodexRolloutToolCall(params: { detail = fallbackDetail; } } + if (detail.type === "edit" && !hasRenderableEditContent(detail)) { + detail = { + type: "unknown", + input, + output, + }; + } return buildToolCall({ callId, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f52d1b805..99a6da294 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -147,18 +147,22 @@ export async function createPaseoDaemon( ); } - const serverId = getOrCreateServerId(config.paseoHome, { logger }); - const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); - let relayTransport: RelayTransportController | null = null; + // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. + await acquirePidLock(config.paseoHome, config.listen); - const staticDir = config.staticDir; - const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; + try { + const serverId = getOrCreateServerId(config.paseoHome, { logger }); + const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); + let relayTransport: RelayTransportController | null = null; - const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs }); + const staticDir = config.staticDir; + const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; - const listenTarget = parseListenString(config.listen); + const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs }); - const app = express(); + const listenTarget = parseListenString(config.listen); + + const app = express(); // Host allowlist / DNS rebinding protection (vite-like semantics). // For non-TCP (unix sockets), skip host validation. @@ -495,12 +499,9 @@ export async function createPaseoDaemon( } ); - const start = async () => { - // Acquire PID lock - await acquirePidLock(config.paseoHome, config.listen); - - // Start main HTTP server - await new Promise((resolve, reject) => { + const start = async () => { + // Start main HTTP server + await new Promise((resolve, reject) => { const onError = (err: Error) => { httpServer.off("listening", onListening); reject(err); @@ -581,43 +582,47 @@ export async function createPaseoDaemon( } httpServer.listen(listenTarget.path); } - }); - }; + }); + }; - const stop = async () => { - await closeAllAgents(logger, agentManager); - await agentManager.flush().catch(() => undefined); - detachAgentStoragePersistence(); - await agentStorage.flush().catch(() => undefined); - await shutdownProviders(logger); - terminalManager.killAll(); - cleanupSpeechRuntime(); - await relayTransport?.stop().catch(() => undefined); - if (wsServer) { - await wsServer.close(); - } - if (voiceMcpBridgeManager) { - await voiceMcpBridgeManager.stop().catch(() => undefined); - } - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - // Clean up socket files - if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { - unlinkSync(listenTarget.path); - } - // Release PID lock - await releasePidLock(config.paseoHome); - }; + const stop = async () => { + await closeAllAgents(logger, agentManager); + await agentManager.flush().catch(() => undefined); + detachAgentStoragePersistence(); + await agentStorage.flush().catch(() => undefined); + await shutdownProviders(logger); + terminalManager.killAll(); + cleanupSpeechRuntime(); + await relayTransport?.stop().catch(() => undefined); + if (wsServer) { + await wsServer.close(); + } + if (voiceMcpBridgeManager) { + await voiceMcpBridgeManager.stop().catch(() => undefined); + } + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + // Clean up socket files + if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { + unlinkSync(listenTarget.path); + } + // Release PID lock + await releasePidLock(config.paseoHome); + }; - return { - config, - agentManager, - agentStorage, - terminalManager, - start, - stop, - }; + return { + config, + agentManager, + agentStorage, + terminalManager, + start, + stop, + }; + } catch (err) { + await releasePidLock(config.paseoHome).catch(() => undefined); + throw err; + } } async function closeAllAgents( diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index a9fc694f6..f4a6dc66f 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; @@ -157,10 +157,18 @@ export function loadPersistedConfig( ): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); + const defaultConfig = PersistedConfigSchema.parse({}); if (!existsSync(configPath)) { - log?.info(`No config file at ${configPath}, using defaults`); - return PersistedConfigSchema.parse({}); + try { + mkdirSync(path.dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + "\n"); + log?.info(`Initialized config file at ${configPath}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`[Config] Failed to initialize ${configPath}: ${message}`); + } + return defaultConfig; } let raw: string; diff --git a/packages/server/tsconfig.scripts.json b/packages/server/tsconfig.scripts.json new file mode 100644 index 000000000..3717acabd --- /dev/null +++ b/packages/server/tsconfig.scripts.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2020"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "outDir": "./dist", + "rootDir": ".", + "declaration": false, + "sourceMap": true + }, + "include": [ + "scripts/daemon-runner.ts", + "scripts/dev-runner.ts", + "scripts/supervisor.ts" + ], + "exclude": ["node_modules", "dist"] +}