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"]
+}