Update files
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env npx tsx
|
#!/usr/bin/env node
|
||||||
import '../src/index.js'
|
import '../dist/index.js'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Executable
+32
@@ -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
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user