Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 13:43:07 +07:00
parent ade4fb2e45
commit 7e5537bcc5
29 changed files with 1296 additions and 515 deletions
@@ -189,7 +189,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Select thinking option" accessibilityLabel="Select thinking option"
testID="agent-thinking-selector" testID="agent-thinking-selector"
> >
<Brain size={12} color={theme.colors.foregroundMuted} /> <Brain size={12} color={theme.colors.foregroundMuted} style={{ marginTop: 1 }} />
<Text style={styles.modeBadgeText}>{displayThinking}</Text> <Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} /> <ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
+16 -2
View File
@@ -12,9 +12,15 @@ interface DiffViewerProps {
diffLines: DiffLine[]; diffLines: DiffLine[];
maxHeight?: number; maxHeight?: number;
emptyLabel?: string; 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); const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
if (!diffLines.length) { if (!diffLines.length) {
@@ -27,7 +33,11 @@ export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No change
return ( return (
<ScrollView <ScrollView
style={[styles.verticalScroll, { maxHeight }]} style={[
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
]}
contentContainerStyle={styles.verticalContent} contentContainerStyle={styles.verticalContent}
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator showsVerticalScrollIndicator
@@ -94,6 +104,10 @@ const styles = StyleSheet.create((theme) => {
return { return {
verticalScroll: {}, verticalScroll: {},
fillHeight: {
flex: 1,
minHeight: 0,
},
verticalContent: { verticalContent: {
flexGrow: 1, flexGrow: 1,
paddingBottom: insets.extraBottom, paddingBottom: insets.extraBottom,
+40 -2
View File
@@ -223,6 +223,9 @@ const DiffFileHeader = memo(function DiffFileHeader({
testID, testID,
}: DiffFileSectionProps) { }: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null); const expandStartRef = useRef<number | null>(null);
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const { hunkCount, lineCount, tokenCount } = useMemo(() => { const { hunkCount, lineCount, tokenCount } = useMemo(() => {
let totalLines = 0; let totalLines = 0;
@@ -247,6 +250,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD; tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD;
const toggleExpanded = useCallback(() => { const toggleExpanded = useCallback(() => {
pressHandledRef.current = true;
if (isPerfLoggingEnabled() && shouldLogFileMetrics) { if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
expandStartRef.current = getNowMs(); expandStartRef.current = getNowMs();
perfLog(DIFF_FILE_LOG_TAG, { perfLog(DIFF_FILE_LOG_TAG, {
@@ -295,6 +299,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
!isExpanded && styles.fileSectionBorder, !isExpanded && styles.fileSectionBorder,
]} ]}
onLayout={(event) => { onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}} }}
testID={testID} testID={testID}
@@ -305,6 +310,34 @@ const DiffFileHeader = memo(function DiffFileHeader({
styles.fileHeader, styles.fileHeader,
pressed && styles.fileHeaderPressed, 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} onPress={toggleExpanded}
> >
<View style={styles.fileHeaderLeft}> <View style={styles.fileHeaderLeft}>
@@ -562,8 +595,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const isExpanded = expandedByPath[file.path] ?? false; const isExpanded = expandedByPath[file.path] ?? false;
stickyIndices.push(items.length);
items.push({ type: "header", file, fileIndex: i, isExpanded }); items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) { if (isExpanded) {
items.push({ type: "body", file, fileIndex: i }); items.push({ type: "body", file, fileIndex: i });
} }
@@ -607,6 +642,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const handleToggleExpanded = useCallback( const handleToggleExpanded = useCallback(
(path: string) => { (path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false; const isCurrentlyExpanded = expandedByPath[path] ?? false;
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
// Anchor to the clicked header before collapsing so visual context is preserved. // 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) => ({ setExpandedByPath((prev) => ({
...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] [computeHeaderOffset, expandedByPath]
-1
View File
@@ -401,7 +401,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderTopWidth: 0, borderTopWidth: 0,
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: 0, padding: 0,
gap: 0, gap: 0,
flexShrink: 1, flexShrink: 1,
@@ -19,13 +19,17 @@ interface ToolCallDetailsContentProps {
detail?: ToolCallDetail; detail?: ToolCallDetail;
errorText?: string; errorText?: string;
maxHeight?: number; maxHeight?: number;
fillAvailableHeight?: boolean;
} }
export function ToolCallDetailsContent({ export function ToolCallDetailsContent({
detail, detail,
errorText, errorText,
maxHeight = 300, maxHeight,
fillAvailableHeight = false,
}: ToolCallDetailsContentProps) { }: ToolCallDetailsContentProps) {
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
// Compute diff lines for edit type // Compute diff lines for edit type
const diffLines = useMemo(() => { const diffLines = useMemo(() => {
if (!detail || detail.type !== "edit") return undefined; if (!detail || detail.type !== "edit") return undefined;
@@ -39,6 +43,12 @@ export function ToolCallDetailsContent({
const sections: ReactNode[] = []; const sections: ReactNode[] = [];
const isFullBleed = const isFullBleed =
detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; 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; const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
if (detail?.type === "shell") { if (detail?.type === "shell") {
@@ -46,10 +56,17 @@ export function ToolCallDetailsContent({
const commandOutput = (detail.output ?? "").replace(/^\n+/, ""); const commandOutput = (detail.output ?? "").replace(/^\n+/, "");
const hasOutput = commandOutput.length > 0; const hasOutput = commandOutput.length > 0;
sections.push( sections.push(
<View key="shell" style={styles.section}> <View
<View style={codeBlockStyle}> key="shell"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView <ScrollView
style={[styles.codeVerticalScroll, { maxHeight }]} style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent} contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator showsVerticalScrollIndicator
@@ -74,20 +91,34 @@ export function ToolCallDetailsContent({
); );
} else if (detail?.type === "edit") { } else if (detail?.type === "edit") {
sections.push( sections.push(
<View key="edit" style={styles.section}> <View
key="edit"
style={[styles.section, shouldFill && styles.fillHeight]}
>
{diffLines ? ( {diffLines ? (
<View style={codeBlockStyle}> <View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<DiffViewer diffLines={diffLines} maxHeight={maxHeight} /> <DiffViewer
diffLines={diffLines}
maxHeight={resolvedMaxHeight}
fillAvailableHeight={shouldFill}
/>
</View> </View>
) : null} ) : null}
</View> </View>
); );
} else if (detail?.type === "write") { } else if (detail?.type === "write") {
sections.push( sections.push(
<View key="write" style={styles.section}> <View
key="write"
style={[styles.section, shouldFill && styles.fillHeight]}
>
{detail.content ? ( {detail.content ? (
<ScrollView <ScrollView
style={[styles.scrollArea, { maxHeight }]} style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator={true} showsVerticalScrollIndicator={true}
@@ -104,18 +135,18 @@ export function ToolCallDetailsContent({
</View> </View>
); );
} else if (detail?.type === "read") { } else if (detail?.type === "read") {
sections.push( if (detail.content) {
<View key="read" style={styles.section}> sections.push(
{(detail.offset !== undefined || detail.limit !== undefined) ? ( <View
<Text style={styles.rangeText}> key="read"
{detail.offset !== undefined ? `Offset: ${detail.offset}` : ""} style={[styles.section, shouldFill && styles.fillHeight]}
{detail.offset !== undefined && detail.limit !== undefined ? " • " : ""} >
{detail.limit !== undefined ? `Limit: ${detail.limit}` : ""}
</Text>
) : null}
{detail.content ? (
<ScrollView <ScrollView
style={[styles.scrollArea, { maxHeight }]} style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator={true} showsVerticalScrollIndicator={true}
@@ -128,9 +159,9 @@ export function ToolCallDetailsContent({
<Text selectable style={styles.scrollText}>{detail.content}</Text> <Text selectable style={styles.scrollText}>{detail.content}</Text>
</ScrollView> </ScrollView>
</ScrollView> </ScrollView>
) : null} </View>
</View> );
); }
} else if (detail?.type === "search") { } else if (detail?.type === "search") {
sections.push( sections.push(
<View key="search" style={styles.section}> <View key="search" style={styles.section}>
@@ -145,16 +176,8 @@ export function ToolCallDetailsContent({
if (plainInputText !== null) { if (plainInputText !== null) {
sections.push( sections.push(
<View key="unknown-plain-text" style={styles.section}> <View key="unknown-plain-text" style={styles.plainTextSection}>
<ScrollView <Text selectable style={styles.scrollText}>{plainInputText}</Text>
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
</ScrollView>
</View> </View>
); );
} else { } else {
@@ -225,7 +248,12 @@ export function ToolCallDetailsContent({
} }
return ( return (
<View style={isFullBleed ? styles.fullBleedContainer : styles.paddedContainer}> <View
style={[
isFullBleed ? styles.fullBleedContainer : styles.paddedContainer,
shouldFill && styles.fillHeight,
]}
>
{sections} {sections}
</View> </View>
); );
@@ -239,7 +267,7 @@ const styles = StyleSheet.create((theme) => {
return { return {
paddedContainer: { paddedContainer: {
gap: theme.spacing[4], gap: theme.spacing[4],
padding: theme.spacing[2], padding: 0,
}, },
fullBleedContainer: { fullBleedContainer: {
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -249,19 +277,26 @@ const styles = StyleSheet.create((theme) => {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingBottom: theme.spacing[1], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1], borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
}, },
groupHeaderText: { groupHeaderText: {
color: theme.colors.primary, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.normal,
textTransform: "uppercase",
letterSpacing: 1,
}, },
section: { section: {
gap: theme.spacing[2],
},
fillHeight: {
flex: 1,
minHeight: 0,
},
plainTextSection: {
gap: theme.spacing[2], gap: theme.spacing[2],
padding: theme.spacing[3],
}, },
sectionTitle: { sectionTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -157,6 +157,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
<ToolCallDetailsContent <ToolCallDetailsContent
detail={detail} detail={detail}
errorText={errorText} errorText={errorText}
fillAvailableHeight
/> />
</BottomSheetScrollView> </BottomSheetScrollView>
</View> </View>
@@ -208,9 +209,11 @@ const styles = StyleSheet.create((theme) => ({
}, },
content: { content: {
flex: 1, flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
contentContainer: { contentContainer: {
padding: 0, padding: 0,
flexGrow: 1,
}, },
})); }));
@@ -690,12 +690,6 @@ function AgentScreenContent({
<MenuHeader title="Agent" /> <MenuHeader title="Agent" />
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.loadingText}>Loading agent…</Text>
{missingAgentState.kind === "error" ? (
<Text style={styles.loadingSubtext} numberOfLines={2}>
{missingAgentState.message}
</Text>
) : null}
</View> </View>
</View> </View>
); );
@@ -905,7 +899,6 @@ function AgentScreenContent({
{shouldBlockForHistorySync ? ( {shouldBlockForHistorySync ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} /> <ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.loadingText}>Loading agent...</Text>
</View> </View>
) : ( ) : (
<ReanimatedAnimated.View <ReanimatedAnimated.View
@@ -1061,13 +1054,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
loadingSubtext: {
marginTop: theme.spacing[1],
textAlign: "center",
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[6],
},
centerState: { centerState: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
+2 -2
View File
@@ -1,2 +1,2 @@
#!/usr/bin/env npx tsx #!/usr/bin/env node
import '../src/index.js' import '../dist/index.js'
+11
View File
@@ -3,15 +3,26 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [
"bin",
"dist"
],
"bundleDependencies": [
"@getpaseo/server",
"@getpaseo/relay"
],
"bin": { "bin": {
"paseo": "./bin/paseo" "paseo": "./bin/paseo"
}, },
"scripts": { "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", "typecheck": "tsc --noEmit",
"test:e2e": "npx zx tests/run-all.ts", "test:e2e": "npx zx tests/run-all.ts",
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
}, },
"dependencies": { "dependencies": {
"@getpaseo/relay": "*",
"@getpaseo/server": "*", "@getpaseo/server": "*",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
+32
View File
@@ -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
+30
View File
@@ -5,6 +5,9 @@ import { createPermitCommand } from './commands/permit/index.js'
import { createProviderCommand } from './commands/provider/index.js' import { createProviderCommand } from './commands/provider/index.js'
import { createSpeechCommand } from './commands/speech/index.js' import { createSpeechCommand } from './commands/speech/index.js'
import { createWorktreeCommand } from './commands/worktree/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 { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js' import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.js' import { runLogsCommand } from './commands/agent/logs.js'
@@ -123,6 +126,33 @@ export function createCli(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand)) .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 <path>', '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 <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <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 <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
)
.action(withOutput(runDaemonRestartCommand))
// Advanced agent commands (less common operations) // Advanced agent commands (less common operations)
program.addCommand(createAgentCommand()) program.addCommand(createAgentCommand())
+18 -6
View File
@@ -14,23 +14,35 @@ export function createDaemonCommand(): Command {
daemon daemon
.command('status') .command('status')
.description('Show daemon status') .description('Show local daemon status')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runStatusCommand)) .action(withOutput(runStatusCommand))
daemon daemon
.command('stop') .command('stop')
.description('Stop the daemon') .description('Stop the local daemon')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before failing (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.action(withOutput(runStopCommand)) .action(withOutput(runStopCommand))
daemon daemon
.command('restart') .command('restart')
.description('Restart the daemon') .description('Restart the local daemon')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <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 <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
)
.action(withOutput(runRestartCommand)) .action(withOutput(runRestartCommand))
return daemon return daemon
@@ -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<string, unknown>
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<void> {
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<boolean> {
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<DetachedStartResult> {
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<DetachedStartupResult>((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<StopLocalDaemonResult> {
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',
}
}
+80 -43
View File
@@ -1,85 +1,122 @@
import type { Command } from 'commander' 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' import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result of restart command */
interface RestartResult { interface RestartResult {
action: 'restarted' | 'not_running' action: 'restarted'
host: string home: string
pid: string
message: string message: string
} }
/** Schema for restart result */
const restartResultSchema: OutputSchema<RestartResult> = { const restartResultSchema: OutputSchema<RestartResult> = {
idField: 'action', idField: 'action',
columns: [ columns: [
{ {
header: 'STATUS', header: 'STATUS',
field: 'action', 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' }, { header: 'MESSAGE', field: 'message' },
], ],
} }
export type RestartCommandResult = SingleResult<RestartResult> export type RestartCommandResult = SingleResult<RestartResult>
export async function runRestartCommand( function parseTimeoutMs(raw: unknown): number {
options: CommandOptions, if (typeof raw !== 'string' || raw.trim().length === 0) {
_command: Command return DEFAULT_STOP_TIMEOUT_MS
): Promise<RestartCommandResult> { }
const connectOptions = { host: options.host as string | undefined }
const host = getDaemonHost(connectOptions)
let client const seconds = Number(raw)
try { if (!Number.isFinite(seconds) || seconds <= 0) {
client = await connectToDaemon(connectOptions)
} catch {
// Daemon not running - cannot restart
const error: CommandError = { const error: CommandError = {
code: 'DAEMON_NOT_RUNNING', code: 'INVALID_TIMEOUT',
message: `Daemon is not running (tried to connect to ${host})`, message: `Invalid timeout value: ${raw}`,
details: 'Start the daemon with: paseo daemon start', details: 'Timeout must be a positive number of seconds',
} }
throw error throw error
} }
try { return Math.ceil(seconds * 1000)
// Request server restart }
await client.restartServer('cli_restart')
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<RestartCommandResult> {
const timeoutMs = parseTimeoutMs(options.timeout)
const force = options.force === true
const startOptions = toStartOptions(options)
try {
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>
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 { return {
type: 'single', type: 'single',
data: { data: {
action: 'restarted', action: 'restarted',
host, home: stopResult.home,
message: 'Daemon restart requested', pid: startup.pid === null ? '-' : String(startup.pid),
message: `Local daemon restarted (${before} -> ${after})`,
}, },
schema: restartResultSchema, schema: restartResultSchema,
} }
} catch (err) { } catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err) 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 = { const error: CommandError = {
code: 'RESTART_FAILED', code: 'RESTART_FAILED',
message: `Failed to restart daemon: ${message}`, message: `Failed to restart local daemon: ${message}`,
} }
throw error throw error
} }
+39 -167
View File
@@ -1,8 +1,5 @@
import { Command } from 'commander' import { Command } from 'commander'
import chalk from 'chalk' import chalk from 'chalk'
import { spawn } from 'node:child_process'
import { closeSync, openSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { import {
createPaseoDaemon, createPaseoDaemon,
loadConfig, loadConfig,
@@ -11,36 +8,16 @@ import {
loadPersistedConfig, loadPersistedConfig,
} from '@getpaseo/server' } from '@getpaseo/server'
import type { CliConfigOverrides } from '@getpaseo/server' import type { CliConfigOverrides } from '@getpaseo/server'
import {
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from './local-daemon.js'
interface StartOptions { export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
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 function startCommand(): Command { export function startCommand(): Command {
return new Command('start') return new Command('start')
.description('Start the Paseo daemon') .description('Start the local Paseo daemon')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)') .option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)') .option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)') .option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
@@ -49,148 +26,14 @@ export function startCommand(): Command {
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint') .option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
.option( .option(
'--allowed-hosts <hosts>', '--allowed-hosts <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) => { .action(async (options: StartOptions) => {
await runStart(options) await runStart(options)
}) })
} }
function buildForegroundArgs(options: StartOptions): string[] { function toCliOverrides(options: StartOptions): CliConfigOverrides {
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<void> {
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<DetachedStartupResult>((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<void> {
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<typeof createRootLogger>
let config: ReturnType<typeof loadConfig>
const cliOverrides: CliConfigOverrides = {} const cliOverrides: CliConfigOverrides = {}
if (options.listen) { if (options.listen) {
@@ -215,11 +58,41 @@ async function runStart(options: StartOptions): Promise<void> {
cliOverrides.mcpEnabled = false cliOverrides.mcpEnabled = false
} }
return cliOverrides
}
export async function runStart(options: StartOptions): Promise<void> {
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<typeof createRootLogger>
let config: ReturnType<typeof loadConfig>
try { try {
paseoHome = resolvePaseoHome() paseoHome = resolvePaseoHome()
const persistedConfig = loadPersistedConfig(paseoHome) const persistedConfig = loadPersistedConfig(paseoHome)
logger = createRootLogger(persistedConfig) logger = createRootLogger(persistedConfig)
config = loadConfig(paseoHome, { cli: cliOverrides }) config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(message)) console.error(chalk.red(message))
@@ -235,7 +108,6 @@ async function runStart(options: StartOptions): Promise<void> {
process.exit(1) process.exit(1)
} }
// Handle graceful shutdown
let shuttingDown = false let shuttingDown = false
const handleShutdown = async (signal: string) => { const handleShutdown = async (signal: string) => {
if (shuttingDown) { if (shuttingDown) {
+103 -59
View File
@@ -1,24 +1,26 @@
import type { Command } from 'commander' import type { Command } from 'commander'
import { resolvePaseoHome } from '@getpaseo/server' import { tryConnectToDaemon } from '../../utils/client.js'
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js' import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js' import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
/** Status data for the daemon */
interface DaemonStatus { interface DaemonStatus {
status: 'running' | 'stopped' status: 'running' | 'stopped' | 'unresponsive'
host: string
home: string home: string
runningAgents: number listen: string
idleAgents: number 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 { interface StatusRow {
key: string key: string
value: string value: string
} }
/** Schema for key-value display with custom serialization for JSON/YAML */
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> { function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return { return {
idField: 'key', idField: 'key',
@@ -28,28 +30,60 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: 'VALUE', header: 'VALUE',
field: 'value', field: 'value',
color: (_, item) => { color: (_, item) => {
if (item.key === 'Status') { if (item.key !== 'Status') {
return item.value === 'running' ? 'green' : 'red' 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) serialize: () => status,
// 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,
} }
} }
/** Convert status to key-value rows for table display */
function toStatusRows(status: DaemonStatus): StatusRow[] { function toStatusRows(status: DaemonStatus): StatusRow[] {
return [ const rows: StatusRow[] = [
{ key: 'Status', value: status.status }, { key: 'Status', value: status.status },
{ key: 'Host', value: status.host },
{ key: 'Home', value: status.home }, { 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<StatusRow> export type StatusResult = ListResult<StatusRow>
@@ -58,49 +92,59 @@ export async function runStatusCommand(
options: CommandOptions, options: CommandOptions,
_command: Command _command: Command
): Promise<StatusResult> { ): Promise<StatusResult> {
const connectOptions = { host: options.host as string | undefined } const home = typeof options.home === 'string' ? options.home : undefined
const host = getDaemonHost(connectOptions) const state = resolveLocalDaemonState({ home })
const client = await tryConnectToDaemon(connectOptions)
if (!client) { const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
const error: CommandError = { let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
code: 'DAEMON_NOT_RUNNING', let runningAgents: number | null = null
message: `Daemon is not running (tried to connect to ${host})`, let idleAgents: number | null = null
details: 'Start the daemon with: paseo daemon start', let note: string | undefined
}
throw error if (!state.running && state.stalePidFile && state.pidInfo) {
note = `Stale PID file found for PID ${state.pidInfo.pid}`
} }
try { if (state.running) {
const agents = await client.fetchAgents() const host = resolveTcpHostFromListen(state.listen)
const runningAgents = agents.filter((a) => a.status === 'running') if (host) {
const idleAgents = agents.filter((a) => a.status === 'idle') const client = await tryConnectToDaemon({ host, timeout: 1500 })
if (client) {
// Get paseo home for display try {
const paseoHome = resolvePaseoHome() const agents = await client.fetchAgents()
runningAgents = agents.filter(a => a.status === 'running').length
const status: DaemonStatus = { idleAgents = agents.filter(a => a.status === 'idle').length
status: 'running', } catch {
host, status = 'unresponsive'
home: paseoHome, note = `Daemon PID is running but API requests to ${host} failed`
runningAgents: runningAgents.length, } finally {
idleAgents: idleAgents.length, 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 { return {
type: 'list', type: 'list',
data: toStatusRows(status), data: toStatusRows(daemonStatus),
schema: createStatusSchema(status), schema: createStatusSchema(daemonStatus),
}
} 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
} }
} }
+32 -51
View File
@@ -1,15 +1,14 @@
import type { Command } from 'commander' 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' import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result of stop command */
interface StopResult { interface StopResult {
action: 'stopped' | 'not_running' action: 'stopped' | 'not_running'
host: string home: string
pid: string
message: string message: string
} }
/** Schema for stop result */
const stopResultSchema: OutputSchema<StopResult> = { const stopResultSchema: OutputSchema<StopResult> = {
idField: 'action', idField: 'action',
columns: [ columns: [
@@ -18,75 +17,57 @@ const stopResultSchema: OutputSchema<StopResult> = {
field: 'action', field: 'action',
color: (value) => (value === 'stopped' ? 'green' : 'yellow'), color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
}, },
{ header: 'HOST', field: 'host' }, { header: 'HOME', field: 'home' },
{ header: 'PID', field: 'pid' },
{ header: 'MESSAGE', field: 'message' }, { header: 'MESSAGE', field: 'message' },
], ],
} }
export type StopCommandResult = SingleResult<StopResult> export type StopCommandResult = SingleResult<StopResult>
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( export async function runStopCommand(
options: CommandOptions, options: CommandOptions,
_command: Command _command: Command
): Promise<StopCommandResult> { ): Promise<StopCommandResult> {
const connectOptions = { host: options.host as string | undefined } const home = typeof options.home === 'string' ? options.home : undefined
const host = getDaemonHost(connectOptions) const force = options.force === true
const timeoutMs = parseTimeoutMs(options.timeout)
let client
try { try {
client = await connectToDaemon(connectOptions) const result = await stopLocalDaemon({ home, force, timeoutMs })
} catch {
// Daemon not running - this is a valid outcome
return { return {
type: 'single', type: 'single',
data: { data: {
action: 'not_running', action: result.action,
host, home: result.home,
message: 'Daemon was not running', pid: result.pid === null ? '-' : String(result.pid),
}, message: result.message,
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',
}, },
schema: stopResultSchema, schema: stopResultSchema,
} }
} catch (err) { } catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err) 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 = { const error: CommandError = {
code: 'STOP_FAILED', code: 'STOP_FAILED',
message: `Failed to stop daemon: ${message}`, message: `Failed to stop local daemon: ${message}`,
} }
throw error throw error
} }
+22 -3
View File
@@ -1,14 +1,33 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.0", "version": "0.1.0",
"private": true,
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": { "exports": {
".": "./src/index.ts", ".": {
"./e2ee": "./src/e2ee.ts", "types": "./dist/index.d.ts",
"./cloudflare": "./src/cloudflare-adapter.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": { "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", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
+2
View File
@@ -13,6 +13,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true "sourceMap": true
+32 -8
View File
@@ -1,17 +1,41 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.0", "version": "0.1.0",
"private": true,
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"types": "./dist/server/server/exports.d.ts",
"files": [
"dist/server",
"dist/scripts",
"README.md",
".env.example",
"agent-prompt.md"
],
"exports": { "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": { "scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts", "dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "tsc -p tsconfig.server.json", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"start": "NODE_ENV=production node dist/server/index.js", "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", "typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts", "generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts", "speech:models": "tsx scripts/list-speech-models.ts",
@@ -25,7 +49,8 @@
"test:e2e:mobile": "playwright test --project='Mobile Chrome'" "test:e2e:mobile": "playwright test --project='Mobile Chrome'"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.52", "@getpaseo/relay": "*",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0", "@lezer/css": "^1.3.0",
@@ -39,7 +64,7 @@
"@opencode-ai/sdk": "^1.1.12", "@opencode-ai/sdk": "^1.1.12",
"@sctg/sentencepiece-js": "^1.1.0", "@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "^5.0.76", "ai": "5.0.78",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
@@ -50,7 +75,6 @@
"openai": "^4.20.0", "openai": "^4.20.0",
"pino": "^10.2.0", "pino": "^10.2.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"playwright": "^1.56.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sherpa-onnx": "^1.12.23", "sherpa-onnx": "^1.12.23",
"sherpa-onnx-node": "^1.12.23", "sherpa-onnx-node": "^1.12.23",
@@ -62,8 +86,8 @@
"zod-to-json-schema": "^3.25.1" "zod-to-json-schema": "^3.25.1"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/relay": "*",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"playwright": "^1.56.1",
"@types/express": "^4.17.20", "@types/express": "^4.17.20",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
+36
View File
@@ -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"],
});
+26 -53
View File
@@ -1,63 +1,36 @@
import { fork, type ChildProcess } from "child_process";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import path from "path"; import { existsSync } from "node:fs";
import { runSupervisor } from "./supervisor.js";
const serverEntry = fileURLToPath( function resolveWorkerEntry(): string {
new URL("../src/server/index.ts", import.meta.url) 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; for (const candidate of candidates) {
let restarting = false; if (existsSync(candidate)) {
return candidate;
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();
} }
});
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; return candidates[0];
process.stderr.write("[DevRunner] Restart requested. Stopping current server...\n");
child.kill("SIGTERM");
} }
function forwardSignal(signal: NodeJS.Signals) { function resolveWorkerExecArgv(): string[] {
if (!child) { const workerEntry = resolveWorkerEntry();
process.exit(0); return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
child.kill(signal);
} }
process.on("SIGINT", () => forwardSignal("SIGINT")); runSupervisor({
process.on("SIGTERM", () => forwardSignal("SIGTERM")); name: "DevRunner",
startupMessage: "Starting server worker (crash restarts enabled)",
process.stdout.write("[DevRunner] Starting server with tsx (explicit restarts only)\n"); resolveWorkerEntry,
spawnServer(); workerArgs: process.argv.slice(2),
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(),
restartOnCrash: true,
shutdownReasons: ["cli_shutdown"],
});
+130
View File
@@ -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();
}
@@ -977,7 +977,6 @@ function mapCodexPatchNotificationToToolCall(params: {
stdout?: string | null; stdout?: string | null;
stderr?: string | null; stderr?: string | null;
success?: boolean | null; success?: boolean | null;
latestUnifiedDiff?: string | null;
running: boolean; running: boolean;
}): ToolCallTimelineItem { }): ToolCallTimelineItem {
const files = parseCodexPatchChanges(params.changes); const files = parseCodexPatchChanges(params.changes);
@@ -985,7 +984,7 @@ function mapCodexPatchNotificationToToolCall(params: {
const firstPatchText = files const firstPatchText = files
.map((file) => file.content?.trim()) .map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0); .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 patchFields = codexPatchTextFields(patchText);
const mapped = mapCodexRolloutToolCall({ const mapped = mapCodexRolloutToolCall({
callId: params.callId ?? null, callId: params.callId ?? null,
@@ -1736,7 +1735,6 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedUnknownNotificationMethods = new Set<string>(); private warnedUnknownNotificationMethods = new Set<string>();
private warnedInvalidNotificationPayloads = new Set<string>(); private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>(); private warnedIncompleteEditToolCallIds = new Set<string>();
private latestTurnUnifiedDiff: string | null = null;
private latestUsage: AgentUsage | undefined; private latestUsage: AgentUsage | undefined;
private connected = false; private connected = false;
private collaborationModes: Array<{ private collaborationModes: Array<{
@@ -2422,7 +2420,6 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") { if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId; this.currentTurnId = parsed.turnId;
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear(); this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear(); this.emittedItemCompletedIds.clear();
this.pendingCommandOutputDeltas.clear(); this.pendingCommandOutputDeltas.clear();
@@ -2444,7 +2441,6 @@ class CodexAppServerAgentSession implements AgentSession {
} else { } else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage }); this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
} }
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear(); this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear(); this.emittedItemCompletedIds.clear();
this.pendingCommandOutputDeltas.clear(); this.pendingCommandOutputDeltas.clear();
@@ -2470,8 +2466,6 @@ class CodexAppServerAgentSession implements AgentSession {
} }
if (parsed.kind === "diff_updated") { 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 // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*. // containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry. // This is not a concrete file-change tool call; it is progress telemetry.
@@ -2557,7 +2551,6 @@ class CodexAppServerAgentSession implements AgentSession {
callId: parsed.callId, callId: parsed.callId,
changes: parsed.changes, changes: parsed.changes,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: true, running: true,
}); });
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", { this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
@@ -2580,7 +2573,6 @@ class CodexAppServerAgentSession implements AgentSession {
stdout: parsed.stdout ?? bufferedOutput, stdout: parsed.stdout ?? bufferedOutput,
stderr: parsed.stderr, stderr: parsed.stderr,
success: parsed.success, success: parsed.success,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: false, running: false,
}); });
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", { this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
@@ -463,4 +463,38 @@ describe("codex tool-call mapper", () => {
expect(item.detail.newString).toBeUndefined(); 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" });
}
});
}); });
@@ -309,6 +309,17 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con
return { content: text }; 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 { function pickFirstPatchLikeString(values: unknown[]): string | undefined {
for (const value of values) { for (const value of values) {
if (typeof value === "string" && value.length > 0) { if (typeof value === "string" && value.length > 0) {
@@ -610,12 +621,19 @@ function mapFileChangeItem(
const firstFile = files[0]; const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff); const firstTextFields = asEditTextFields(firstFile?.diff);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const detail = firstFile?.path const detail = firstFile?.path
? { ? hasFirstTextFields
? {
type: "edit" as const, type: "edit" as const,
filePath: firstFile.path, filePath: firstFile.path,
...firstTextFields, ...firstTextFields,
} }
: {
type: "unknown" as const,
input,
output,
}
: { : {
type: "unknown" as const, type: "unknown" as const,
input, input,
@@ -755,6 +773,13 @@ export function mapCodexRolloutToolCall(params: {
detail = fallbackDetail; detail = fallbackDetail;
} }
} }
if (detail.type === "edit" && !hasRenderableEditContent(detail)) {
detail = {
type: "unknown",
input,
output,
};
}
return buildToolCall({ return buildToolCall({
callId, callId,
+54 -49
View File
@@ -147,18 +147,22 @@ export async function createPaseoDaemon(
); );
} }
const serverId = getOrCreateServerId(config.paseoHome, { logger }); // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); await acquirePidLock(config.paseoHome, config.listen);
let relayTransport: RelayTransportController | null = null;
const staticDir = config.staticDir; try {
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; 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). // Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation. // For non-TCP (unix sockets), skip host validation.
@@ -495,12 +499,9 @@ export async function createPaseoDaemon(
} }
); );
const start = async () => { const start = async () => {
// Acquire PID lock // Start main HTTP server
await acquirePidLock(config.paseoHome, config.listen); await new Promise<void>((resolve, reject) => {
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => { const onError = (err: Error) => {
httpServer.off("listening", onListening); httpServer.off("listening", onListening);
reject(err); reject(err);
@@ -581,43 +582,47 @@ export async function createPaseoDaemon(
} }
httpServer.listen(listenTarget.path); httpServer.listen(listenTarget.path);
} }
}); });
}; };
const stop = async () => { const stop = async () => {
await closeAllAgents(logger, agentManager); await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined); await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence(); detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined); await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger); await shutdownProviders(logger);
terminalManager.killAll(); terminalManager.killAll();
cleanupSpeechRuntime(); cleanupSpeechRuntime();
await relayTransport?.stop().catch(() => undefined); await relayTransport?.stop().catch(() => undefined);
if (wsServer) { if (wsServer) {
await wsServer.close(); await wsServer.close();
} }
if (voiceMcpBridgeManager) { if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined); await voiceMcpBridgeManager.stop().catch(() => undefined);
} }
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
httpServer.close(() => resolve()); httpServer.close(() => resolve());
}); });
// Clean up socket files // Clean up socket files
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path); unlinkSync(listenTarget.path);
} }
// Release PID lock // Release PID lock
await releasePidLock(config.paseoHome); await releasePidLock(config.paseoHome);
}; };
return { return {
config, config,
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
start, start,
stop, stop,
}; };
} catch (err) {
await releasePidLock(config.paseoHome).catch(() => undefined);
throw err;
}
} }
async function closeAllAgents( async function closeAllAgents(
+11 -3
View File
@@ -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 path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
@@ -157,10 +157,18 @@ export function loadPersistedConfig(
): PersistedConfig { ): PersistedConfig {
const log = getLogger(logger); const log = getLogger(logger);
const configPath = getConfigPath(paseoHome); const configPath = getConfigPath(paseoHome);
const defaultConfig = PersistedConfigSchema.parse({});
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
log?.info(`No config file at ${configPath}, using defaults`); try {
return PersistedConfigSchema.parse({}); 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; let raw: string;
+28
View File
@@ -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"]
}