From 6a833420130adfda7b8c3d22958733698f0f5f2a Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 19 Feb 2026 18:05:50 +0000 Subject: [PATCH 1/2] pi-mono --- src/apps/officer-web/App.tsx | 5 +- .../Screens/Dashboard/CapabilityPage.tsx | 61 ++- .../Screens/Dashboard/Chat/ChatPanel.tsx | 4 +- .../Screens/Dashboard/Chat/EmbeddableChat.tsx | 4 +- .../Screens/Dashboard/Chat/InputArea.tsx | 4 +- .../Screens/Dashboard/Chat/Settings.tsx | 10 +- .../Screens/Dashboard/Chat/usePiMono.ts | 205 ++++++++ .../Dashboard/ChatHistory/ChatDetailPanel.tsx | 127 ++++- .../Screens/Dashboard/ChatHistory/Screen.tsx | 23 +- .../Screens/Dashboard/ChatHistory/index.tsx | 2 +- .../Files/Screen/TaskRunnerModal.tsx | 47 +- .../Screens/Dashboard/Home/ChatLauncher.tsx | 14 +- .../Dashboard/Layout/Header/UserMenu.tsx | 14 +- .../Settings/ProfileSettings/TaskDefaults.tsx | 27 +- .../ServerSettings/AIHarnessesSection.tsx | 59 ++- .../Settings/ServerSettings/index.tsx | 46 -- .../Dashboard/Settings/SettingsPanel.tsx | 100 ++-- .../{AISettings.tsx => SystemSettings.tsx} | 254 +++++++--- .../Screens/Dashboard/Settings/index.tsx | 3 +- .../Dashboard/Workspaces/app-registry.tsx | 27 +- src/apps/officer-web/locales/en.json | 3 +- src/apps/officer-web/locales/pt.json | 3 +- .../officer-web/state/types/user-settings.ts | 4 +- src/apps/officer-web/state/useChatSessions.ts | 10 +- src/apps/officer-web/state/useModels.ts | 21 + .../officer-web/state/useServerSettings.ts | 1 + src/server.tsx | 7 +- src/servers/api/pi-mono/sessions.ts | 54 +++ src/servers/api/pi-mono/websocket.ts | 448 ++++++++++++++++++ src/servers/api/scrape/scrape.ts | 2 +- src/servers/api/server-settings/pi-mono.ts | 54 +++ .../api/server-settings/server-settings.ts | 2 + src/servers/api/sessions/sessions.ts | 66 ++- src/servers/api/upload/upload.ts | 2 +- src/servers/data-path.ts | 7 +- src/servers/hono.ts | 2 + src/workspaces/apps/Chat/types.ts | 2 +- .../apps/ChatHistory/SessionBar.tsx | 2 +- 38 files changed, 1459 insertions(+), 267 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx rename src/apps/officer-web/Screens/Dashboard/Settings/{AISettings.tsx => SystemSettings.tsx} (62%) create mode 100644 src/servers/api/pi-mono/sessions.ts create mode 100644 src/servers/api/pi-mono/websocket.ts create mode 100644 src/servers/api/server-settings/pi-mono.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 06acdded..ae86480a 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -37,8 +37,7 @@ export function App() { } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -46,6 +45,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index 80b5152b..ce016528 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -8,12 +8,13 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { useClient } from 'hooks/useClient'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; import { useSettings } from '@/state/useSettings'; import { Card } from '@/components/Card'; import type { ChatMessage } from 'apps/Chat'; import { useClaude } from '@/Screens/Dashboard/Chat/useClaude'; import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode'; +import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono'; import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; type CapabilitySummary = { dirName: string; @@ -60,7 +61,7 @@ type CapabilityChatProps = { }; type CapabilityChatInnerProps = CapabilityChatProps & { - onProviderChange: (p: 'claude' | 'opencode') => void; + onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void; }; const CapabilityChatClaude = ({ @@ -174,15 +175,59 @@ const CapabilityChatOpenCode = ({ ); }; +const CapabilityChatPiMono = ({ + kind, + filePath, + resourceDir, + isNew, + description, + onResponseEnd, + onProviderChange, +}: CapabilityChatInnerProps) => { + const piMonoModels = useVisiblePiMonoModels(); + const seedFile = `${kind.toUpperCase()}.md`; + const promptFrontmatter = `\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n`; + const defaultInput = isNew + ? description ?? `Help me create the content for this new ${kind} file` + : `Help me understand and improve this ${kind} file`; + + const piMono = usePiMono(undefined, undefined, { replaceUrl: false }); + + const onResponseEndRef = useRef(onResponseEnd); + onResponseEndRef.current = onResponseEnd; + + const wasGenerating = useRef(false); + useEffect(() => { + if (wasGenerating.current && !piMono.isGenerating) { + onResponseEndRef.current?.(); + } + wasGenerating.current = piMono.isGenerating; + }, [piMono.isGenerating]); + + return ( + + ); +}; + export const CapabilityChat = (props: CapabilityChatProps) => { const { settings } = useSettings(); - const [provider, setProvider] = useState<'claude' | 'opencode'>(settings.chat.defaultProvider); + const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(settings.chat.defaultProvider); - return provider === 'claude' ? ( - - ) : ( - - ); + if (provider === 'claude') { + return ; + } + if (provider === 'opencode') { + return ; + } + return ; }; export const FrontmatterBlock = ({ yaml }: { yaml: string }) => { diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx index 419a97b0..6b4baf85 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx @@ -12,9 +12,9 @@ export type { Attachment }; type ChatPanelProps = { chat: ReturnType; - provider?: 'claude' | 'opencode'; + provider?: 'claude' | 'opencode' | 'pi-mono'; availableModels?: ModelOption[]; - onProviderChange?: (provider: 'claude' | 'opencode') => void; + onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; }; export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onProviderChange }: ChatPanelProps) => { diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx index bb5c9e03..e925226f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx @@ -13,9 +13,9 @@ export type Attachment = type EmbeddableChatProps = { chat: ReturnType; - provider?: 'claude' | 'opencode'; + provider?: 'claude' | 'opencode' | 'pi-mono'; availableModels?: ModelOption[]; - onProviderChange?: (provider: 'claude' | 'opencode') => void; + onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; onBeforeSend?: (text: string) => boolean | Promise; commandFeedback?: string | null; defaultInput?: string; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx index 0b6175ba..a772e8be 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx @@ -61,9 +61,9 @@ type InputAreaProps = { isConnected: boolean; commandFeedback: string | null; textareaRef: RefObject; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; messages: ChatMessage[]; - onProviderChange?: (provider: 'claude' | 'opencode') => void; + onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; availableModels: ModelOption[]; selectedModel: string | null; onModelChange: (modelId: string) => void; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx index 01f4fbdf..375727f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx @@ -5,9 +5,9 @@ import type { ChatMessage } from 'apps/Chat'; import { OpenCodeModelPicker } from './OpenCodeModelPicker'; type SettingsProps = { - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; messages: ChatMessage[]; - onProviderChange?: (provider: 'claude' | 'opencode') => void; + onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; availableModels: ModelOption[]; selectedModel: string | null; onModelChange: (modelId: string) => void; @@ -35,11 +35,11 @@ export const Settings = ({
{messages.length > 0 ? ( - {provider === 'claude' ? 'Claude' : 'OpenCode'} + {provider === 'claude' ? 'Claude' : provider === 'opencode' ? 'OpenCode' : 'Pi'} ) : (
- {(['claude', 'opencode'] as const).map((value) => ( + {(['claude', 'opencode', 'pi-mono'] as const).map((value) => ( ))}
diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts b/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts new file mode 100644 index 00000000..5d8bb7c2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts @@ -0,0 +1,205 @@ +import { useState, useEffect, useRef } from 'react'; +import { useChatWebSocket } from 'hooks/useChatWebSocket'; +import { useChatSessions } from '@/state/useChatSessions'; +import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat'; + +const SAVE_DEBOUNCE_MS = 1000; + +type UsePiMonoOptions = { + replaceUrl?: boolean; + taskInfo?: TaskInfo; +}; + +export const usePiMono = (initialSessionId?: string, initialModel?: string | null, options?: UsePiMonoOptions) => { + const { replaceUrl = true, taskInfo } = options ?? {}; + const [messages, setMessages] = useState([]); + const [streamingText, setStreamingText] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [sessionId, setSessionId] = useState(initialSessionId ?? null); + const [model, setModel] = useState(null); + const [selectedModel, setSelectedModel] = useState(initialModel ?? null); + + const streamingRef = useRef(''); + const rafRef = useRef(null); + const sessionIdRef = useRef(initialSessionId ?? null); + const saveTimerRef = useRef(null); + + const { getMessages, saveMessages } = useChatSessions(); + + const token = localStorage.getItem('BEARER_TOKEN'); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/harness/pi-mono/ws?token=${token}`; + + const flushStreaming = () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setStreamingText(streamingRef.current); + rafRef.current = null; + }); + }; + + const commitStreaming = () => { + if (!streamingRef.current) return; + setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]); + streamingRef.current = ''; + setStreamingText(''); + }; + + const handleMessage = (data: unknown) => { + const msg = data as ServerMessage; + + switch (msg.type) { + case 'session:init': + sessionIdRef.current = msg.sessionId; + setSessionId(msg.sessionId); + setModel(msg.model); + if (replaceUrl) window.history.replaceState(null, '', `/chat/pi-mono/${msg.sessionId}`); + break; + + case 'system:prompt': + setMessages((prev) => [...prev, { role: 'system', text: msg.text }]); + break; + + case 'assistant:partial': + streamingRef.current += msg.text; + flushStreaming(); + break; + + case 'assistant:text': + if (streamingRef.current) { + commitStreaming(); + } else { + setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]); + } + break; + + case 'tool:use': + setMessages((prev) => [ + ...prev, + { role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId }, + ]); + break; + + case 'tool:result': + setMessages((prev) => + prev.map((m) => + m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m, + ), + ); + break; + + case 'result': + commitStreaming(); + setMessages((prev) => [ + ...prev, + { + role: 'result', + costUsd: msg.costUsd, + durationMs: msg.durationMs, + numTurns: msg.numTurns, + isError: msg.isError, + }, + ]); + setIsGenerating(false); + break; + + case 'error': + commitStreaming(); + setMessages((prev) => [...prev, { role: 'error', text: msg.message }]); + setIsGenerating(false); + break; + + case 'stopped': + commitStreaming(); + setIsGenerating(false); + break; + } + }; + + const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage }); + + // Load messages from server on mount when resuming a session + useEffect(() => { + if (!initialSessionId) return; + getMessages('pi-mono', initialSessionId) + .then((data) => { + if (Array.isArray(data) && data.length > 0) setMessages(data); + }) + .catch(() => {}); + }, [initialSessionId]); + + // Debounced save messages to server + useEffect(() => { + if (!sessionIdRef.current || messages.length === 0) return; + + if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); + + const sid = sessionIdRef.current; + const snapshot = messages; + saveTimerRef.current = window.setTimeout(() => { + saveMessages('pi-mono', sid, snapshot).catch(() => {}); + saveTimerRef.current = null; + }, SAVE_DEBOUNCE_MS); + + return () => { + if (saveTimerRef.current !== null) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }; + }, [messages]); + + // Clean up RAF on unmount + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + const sendPrompt = ( + text: string, + attachmentIds?: string[], + images?: { filename: string; dataUrl: string }[], + cwd?: { root?: string; path: string }, + ) => { + setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]); + setIsGenerating(true); + streamingRef.current = ''; + setStreamingText(''); + + const imageData = images + ?.map((img) => { + const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/); + return match ? { mediaType: match[1], data: match[2] } : null; + }) + .filter((x): x is { mediaType: string; data: string } => x !== null); + + send({ + type: 'chat', + prompt: text, + sessionId: sessionIdRef.current, + ...(selectedModel ? { model: selectedModel } : {}), + ...(cwd ? { cwd } : {}), + ...(attachmentIds?.length ? { attachmentIds } : {}), + ...(imageData?.length ? { images: imageData } : {}), + ...(taskInfo ? { taskInfo } : {}), + }); + }; + + const stopGeneration = () => { + send({ type: 'stop' }); + }; + + return { + messages, + streamingText, + isConnected, + isGenerating, + sessionId, + model, + selectedModel, + setSelectedModel, + sendPrompt, + stopGeneration, + }; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx index bebeafa2..b5c8764b 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx @@ -3,14 +3,15 @@ import { useLocation } from 'react-router'; import { Trash2, Archive } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from '@/state/useChatSessions'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; import { useClaude } from '@/Screens/Dashboard/Chat/useClaude'; import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode'; +import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono'; import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; export type SelectedSession = { id: string; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; model?: string | null; } | null; @@ -26,7 +27,7 @@ type ChatLocationState = { } | null; type DetailBarProps = { - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; sessionTitle: string | undefined; isConnected: boolean; isGenerating: boolean; @@ -138,7 +139,33 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => { ); }; -const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => { +const PiMonoInner = ({ sessionId, model }: InnerProps) => { + const chat = usePiMono(sessionId, model, { replaceUrl: false }); + const models = useVisiblePiMonoModels(); + const { sessions, deleteSession } = useChatSessions(); + const [, setSelected] = usePanelChannel(CHANNEL, null); + const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; + + return ( +
+ { + await deleteSession('pi-mono', sessionId); + setSelected(null); + window.history.replaceState(null, '', '/chat'); + }} + /> + +
+ ); +}; + +const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { const location = useLocation(); const locationState = location.state as ChatLocationState; const initialSentRef = useRef(false); @@ -189,7 +216,7 @@ const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | ); }; -const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => { +const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { const location = useLocation(); const locationState = location.state as ChatLocationState; const initialSentRef = useRef(false); @@ -239,8 +266,58 @@ const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' ); }; +const NewPiMonoInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { + const location = useLocation(); + const locationState = location.state as ChatLocationState; + const initialSentRef = useRef(false); + const chat = usePiMono(); + const models = useVisiblePiMonoModels(); + const [, setSelected] = usePanelChannel(CHANNEL, null); + + useEffect(() => { + if (chat.sessionId) { + setSelected({ id: chat.sessionId, provider: 'pi-mono', model: chat.model }); + } + }, [chat.sessionId]); + + useEffect(() => { + if (!locationState || initialSentRef.current || !chat.isConnected) return; + if (locationState.prefillInput) { + initialSentRef.current = true; + window.history.replaceState({}, '', location.pathname); + return; + } + if (!locationState.initialMessage) return; + initialSentRef.current = true; + if (locationState.model) chat.setSelectedModel(locationState.model); + chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images); + window.history.replaceState({}, '', location.pathname); + }, [chat.isConnected, location.state]); + + return ( +
+ + +
+ ); +}; + type NewChatPanelProps = { - initialProvider?: 'claude' | 'opencode'; + initialProvider?: 'claude' | 'opencode' | 'pi-mono'; }; const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => { @@ -248,24 +325,28 @@ const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => { const provider = selected?.provider ?? initialProvider; - const handleProviderChange = (p: 'claude' | 'opencode') => { + const handleProviderChange = (p: 'claude' | 'opencode' | 'pi-mono') => { setSelected({ id: 'new', provider: p }); }; // Once a session is created, the inner component updates selected via the channel if (selected && selected.id !== 'new') { - return selected.provider === 'claude' ? ( - - ) : ( - - ); + if (selected.provider === 'claude') { + return ; + } + if (selected.provider === 'opencode') { + return ; + } + return ; } - return provider === 'claude' ? ( - - ) : ( - - ); + if (provider === 'claude') { + return ; + } + if (provider === 'opencode') { + return ; + } + return ; }; export const ChatDetailPanel = () => { @@ -283,9 +364,11 @@ export const ChatDetailPanel = () => { return ; } - return selected.provider === 'claude' ? ( - - ) : ( - - ); + if (selected.provider === 'claude') { + return ; + } + if (selected.provider === 'opencode') { + return ; + } + return ; }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx index 65fe8602..3b0cfcae 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx @@ -4,7 +4,7 @@ import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from '@/state/useChatSessions'; import type { SelectedSession } from './ChatDetailPanel'; -type Filter = 'all' | 'claude' | 'opencode'; +type Filter = 'all' | 'claude' | 'opencode' | 'pi-mono'; export const SessionList = () => { const [filter, setFilter] = useState('all'); @@ -29,11 +29,16 @@ export const SessionList = () => { const handleSelect = (session: (typeof sessions)[number]) => { setSelected({ id: session.id, provider: session.provider, model: session.model ?? null }); - const path = session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`; + const path = + session.provider === 'claude' + ? `/chat/${session.id}` + : session.provider === 'opencode' + ? `/chat/opencode/${session.id}` + : `/chat/pi-mono/${session.id}`; window.history.replaceState(null, '', path); }; - const handleDelete = async (provider: 'claude' | 'opencode', id: string) => { + const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => { if (selected?.id === id && selected?.provider === provider) { setSelected(null); window.history.replaceState(null, '', '/chat'); @@ -49,7 +54,7 @@ export const SessionList = () => {
{/* Radio filter */}
- {(['all', 'claude', 'opencode'] as const).map((value) => ( + {(['all', 'claude', 'opencode', 'pi-mono'] as const).map((value) => ( ))}
@@ -114,10 +119,14 @@ export const SessionList = () => { })} - {session.provider === 'claude' ? 'Claude' : 'OpenCode'} + {session.provider === 'claude' ? 'Claude' : session.provider === 'opencode' ? 'OpenCode' : 'Pi'} {session.id.slice(0, 8)} diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index c0947ae8..f700589a 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -22,7 +22,7 @@ const layout: LayoutNode = { }; type SessionListPageProps = { - provider?: 'claude' | 'opencode'; + provider?: 'claude' | 'opencode' | 'pi-mono'; isNew?: boolean; }; diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/TaskRunnerModal.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/TaskRunnerModal.tsx index 2311c49b..a76d8d0b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/TaskRunnerModal.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/TaskRunnerModal.tsx @@ -6,8 +6,9 @@ import { cardStyle } from '@/components/Card'; import type { TaskInfo } from 'apps/Chat'; import { useClaude } from '@/Screens/Dashboard/Chat/useClaude'; import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode'; +import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono'; import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; import { useSettings } from '@/state/useSettings'; import type { TaskSummary } from 'apps/FileBrowser'; @@ -39,7 +40,7 @@ type InnerProps = { defaultInput: string; cwd: { root?: string; path: string }; initialModel: string | null; - onProviderChange: (p: 'claude' | 'opencode') => void; + onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void; }; const ClaudeInner = ({ @@ -100,6 +101,35 @@ const OpenCodeInner = ({ ); }; +const PiMonoInner = ({ + defaultInput, + cwd, + initialModel, + onProviderChange, + taskInfo, +}: InnerProps & { taskInfo: TaskInfo }) => { + const chat = usePiMono(undefined, initialModel, { replaceUrl: false, taskInfo }); + const models = useVisiblePiMonoModels(); + + const wasGenerating = useRef(false); + useEffect(() => { + if (wasGenerating.current && !chat.isGenerating) playDing(); + wasGenerating.current = chat.isGenerating; + }, [chat.isGenerating]); + + return ( + + ); +}; + type TaskRunnerModalProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -113,7 +143,7 @@ type TaskRunnerModalProps = { export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => { const { settings } = useSettings(); const taskSettings = settings.tasks; - const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider); + const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(taskSettings.defaultProvider); const defaultInput = promptOverride ?? (entryName && entryType ? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}` @@ -150,7 +180,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType onProviderChange={setProvider} taskInfo={taskInfo} /> - ) : ( + ) : provider === 'opencode' ? ( + ) : ( + )} diff --git a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx index bb04e08f..430b74a3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx @@ -23,7 +23,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useSettings } from '@/state/useSettings'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat'; export const ChatLauncher = () => { @@ -31,9 +31,10 @@ export const ChatLauncher = () => { const { settings } = useSettings(); const claudeModels = useVisibleClaudeModels(); const openCodeModels = useVisibleOpenCodeModels(); + const piMonoModels = useVisiblePiMonoModels(); const client = useClient(); - const [provider, setProvider] = useState<'claude' | 'opencode'>(settings.chat.defaultProvider); + const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(settings.chat.defaultProvider); const [model, setModel] = useState(settings.chat.defaultModel); const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); @@ -47,7 +48,7 @@ export const ChatLauncher = () => { setModel(settings.chat.defaultModel); }, [settings.chat.defaultProvider, settings.chat.defaultModel]); - const models = provider === 'claude' ? claudeModels : openCodeModels; + const models = provider === 'claude' ? claudeModels : provider === 'opencode' ? openCodeModels : piMonoModels; const handleAttachWebpage = async (url: string) => { const idx = attachments.length; @@ -124,7 +125,8 @@ export const ChatLauncher = () => { attachmentIds.push(a.attachmentId); } - const route = provider === 'claude' ? '/chat/new' : '/chat/opencode/new'; + const route = + provider === 'claude' ? '/chat/new' : provider === 'opencode' ? '/chat/opencode/new' : '/chat/pi-mono/new'; navigate(route, { state: { initialMessage: prompt, @@ -257,7 +259,7 @@ export const ChatLauncher = () => {
- {(['claude', 'opencode'] as const).map((value) => ( + {(['claude', 'opencode', 'pi-mono'] as const).map((value) => ( ))}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index dfd647b5..51756f44 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import * as Dropdown from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { User, LogOut, Server, Package, Bot, Sun, Moon } from 'lucide-react'; +import { User, LogOut, Settings, Package, Sun, Moon } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import { useTranslation } from '@/lib/i18n'; import { useColorMode } from '@/components/ui/ThemeProvider'; @@ -42,15 +42,9 @@ export function UserMenu() { - - - {t('header.userMenu.aiSettings')} - - - - - - {t('header.userMenu.serverSettings')} + + + {t('header.userMenu.systemSettings')} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx index 31bce51d..750f0ad5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx @@ -12,12 +12,13 @@ import { SelectValue, } from '@/components/ui/select'; import { useSettings } from '@/state/useSettings'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; export const TaskDefaults = () => { const { settings, saveSettings } = useSettings(); const claudeModels = useVisibleClaudeModels(); const openCodeModels = useVisibleOpenCodeModels(); + const piMonoModels = useVisiblePiMonoModels(); const [isSaving, setIsSaving] = useState(false); const [model, setModel] = useState(settings.tasks.defaultModel); @@ -26,24 +27,28 @@ export const TaskDefaults = () => { setModel(settings.tasks.defaultModel); }, [settings]); - const openCodeGroups = useMemo(() => { + const buildGroups = (models: { id: string; name: string; provider?: string }[], fallback: string) => { const groups: Record = {}; - for (const m of openCodeModels) { - const provider = m.provider ?? 'OpenCode'; + for (const m of models) { + const provider = m.provider ?? fallback; if (!groups[provider]) groups[provider] = []; groups[provider].push({ id: m.id, name: m.name }); } return Object.entries(groups) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) })); - }, [openCodeModels]); + }; + + const openCodeGroups = useMemo(() => buildGroups(openCodeModels, 'OpenCode'), [openCodeModels]); + const piMonoGroups = useMemo(() => buildGroups(piMonoModels, 'Pi'), [piMonoModels]); const handleSave = async () => { if (isSaving) return; setIsSaving(true); try { + const isPiMono = piMonoModels.some((m) => m.id === model); const isOpenCode = openCodeModels.some((m) => m.id === model); - const defaultProvider = isOpenCode ? ('opencode' as const) : ('claude' as const); + const defaultProvider = isPiMono ? ('pi-mono' as const) : isOpenCode ? ('opencode' as const) : ('claude' as const); await saveSettings({ ...settings, tasks: { defaultProvider, defaultModel: model } }); toast.success('Task defaults saved'); } catch { @@ -82,6 +87,16 @@ export const TaskDefaults = () => { ))} ))} + {piMonoGroups.map(({ provider, models }) => ( + + {provider} (Pi) + {models.map((m) => ( + + {m.name} + + ))} + + ))} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index c78d9593..9156f577 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -14,9 +14,10 @@ export const AIHarnessesSection = () => { const client = useClient(); const queryClient = useQueryClient(); const { aiHarnesses, saveSettings } = useServerSettings(); - const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({ + const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean; piMono: boolean }>({ claudeCode: false, opencode: false, + piMono: false, }); const [copied, setCopied] = useState(null); @@ -40,6 +41,16 @@ export const AIHarnessesSection = () => { }, }); + const { data: piMonoVersion, isLoading: piMonoLoading } = useQuery({ + queryKey: ['PI_MONO_VERSION'], + queryFn: () => client.get('/server-settings/pi-mono/version'), + enabled: !!aiHarnesses?.piMono, + refetchInterval: (query) => { + const data = query.state.data; + return data?.version && !data?.globalPath ? 1000 : false; + }, + }); + const { data: opencodeAuth } = useQuery({ queryKey: ['OPENCODE_AUTH'], queryFn: () => client.get('/server-settings/opencode/auth'), @@ -54,7 +65,7 @@ export const AIHarnessesSection = () => { refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false), }); - const toggleHarness = (key: 'claudeCode' | 'opencode', checked: boolean) => { + const toggleHarness = (key: 'claudeCode' | 'opencode' | 'piMono', checked: boolean) => { const updated = { ...aiHarnesses, [key]: checked }; saveSettings({ aiHarnesses: updated }); }; @@ -79,6 +90,16 @@ export const AIHarnessesSection = () => { } }; + const installPiMono = async () => { + setInstalling((prev) => ({ ...prev, piMono: true })); + try { + const result = await client.post('/server-settings/pi-mono/install'); + queryClient.setQueryData(['PI_MONO_VERSION'], result); + } finally { + setInstalling((prev) => ({ ...prev, piMono: false })); + } + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); setCopied(text); @@ -210,6 +231,40 @@ export const AIHarnessesSection = () => {
)}
+ +
+ + {aiHarnesses?.piMono && ( +
+ {piMonoLoading ? ( + 'Checking version...' + ) : piMonoVersion?.version ? ( + <> +
{piMonoVersion.version}
+
{piMonoVersion.path}
+ {!piMonoVersion.globalPath && piMonoVersion.path && ( + + )} + + ) : ( + + )} +
+ )} +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx deleted file mode 100644 index f59f348f..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useMemo } from 'react'; -import { Terminal, Server } from 'lucide-react'; -import type { LayoutNode, PanelComponents } from '@/components/Workspace'; -import { WorkspaceLayout } from '@/components/Workspace'; -import { appRegistry } from '../../Workspaces/app-registry'; -import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; -import { AIHarnessesSection } from './AIHarnessesSection'; - -const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED'; - -const sections: SettingsSection[] = [ - { key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: }, -]; - -const { Sidebar, Content } = createSettingsPanelComponents({ - globalKey: GLOBAL_KEY, - sidebarIcon: Server, - sidebarLabel: 'Server', - sections, -}); - -const layout: LayoutNode = { - type: 'group', - id: 'server-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'server-left', appType: null }, size: 20 }, - { node: { type: 'panel', id: 'server-right', appType: null }, size: 80 }, - ], -}; - -export const ServerSettings = () => { - const panelComponents: PanelComponents = useMemo( - () => ({ - 'server-left': Sidebar, - 'server-right': Content, - }), - [], - ); - - return ( -
- {}} registry={appRegistry} components={panelComponents} /> -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx index 6e2c7f7d..20878be6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx @@ -12,21 +12,52 @@ export type SettingsSection = { content: ReactNode; }; +export type SettingsSectionGroup = { + label: string; + icon: LucideIcon; + sections: SettingsSection[]; +}; + type SettingsSidebarProps = { globalKey: string; icon: LucideIcon; label: string; sections: SettingsSection[]; + groups?: SettingsSectionGroup[]; }; -export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections }: SettingsSidebarProps) => { - const [selectedKey, setSelectedKey] = useGlobal(globalKey, sections[0]?.key ?? null); +const SectionButton = ({ + section, + isActive, + onClick, +}: { + section: SettingsSection; + isActive: boolean; + onClick: () => void; +}) => ( + +); + +export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups }: SettingsSidebarProps) => { + const allSections = groups ? groups.flatMap((g) => g.sections) : sections; + const [selectedKey, setSelectedKey] = useGlobal(globalKey, allSections[0]?.key ?? null); const [search, setSearch] = useState(''); const query = search.toLowerCase(); - const filtered = sections.filter( - (s) => s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query), - ); + const matchesSearch = (s: SettingsSection) => + s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query); return (
@@ -40,24 +71,37 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections }: Sett setSearch(ev.target.value)} className="h-8 text-xs" />
- {filtered.map((s) => { - const isActive = selectedKey === s.key; - return ( - - ); - })} + {groups + ? groups.map((group) => { + const filtered = group.sections.filter(matchesSearch); + if (filtered.length === 0) return null; + return ( +
+
+ + + {group.label} + +
+ {filtered.map((s) => ( + setSelectedKey(s.key)} + /> + ))} +
+ ); + }) + : sections.filter(matchesSearch).map((s) => ( + setSelectedKey(s.key)} + /> + ))}
); @@ -96,13 +140,15 @@ type CreateSettingsPanelParams = { globalKey: string; sidebarIcon: LucideIcon; sidebarLabel: string; - sections: SettingsSection[]; + sections?: SettingsSection[]; + groups?: SettingsSectionGroup[]; }; -export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections }: CreateSettingsPanelParams) => { +export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => { + const allSections = groups ? groups.flatMap((g) => g.sections) : sections; const Sidebar: ComponentType = () => ( - + ); - const Content: ComponentType = () => ; + const Content: ComponentType = () => ; return { Sidebar, Content }; }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx similarity index 62% rename from src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index c3031ef1..1536155a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; -import { Terminal, Eye, Trash2, Bot } from 'lucide-react'; +import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -13,46 +13,62 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; import { appRegistry } from '../Workspaces/app-registry'; -import { createSettingsPanelComponents, type SettingsSection } from './SettingsPanel'; +import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { useSettings } from '@/state/useSettings'; import { useUserState } from '@/state/useUserState'; import { useClaudeModels, useOpenCodeModels, + usePiMonoModels, useVisibleClaudeModels, useVisibleOpenCodeModels, + useVisiblePiMonoModels, } from '@/state/useModels'; import type { UserSettings } from '@/state/types/user-settings'; +import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; +import { PluginsSection } from './ServerSettings/PluginsSection'; -const GLOBAL_KEY = 'AI_SETTINGS_SELECTED'; - -const sections: SettingsSection[] = [ - { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, - { key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: }, +const groups: SettingsSectionGroup[] = [ + { + label: 'Server', + icon: Server, + sections: [ + { key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: }, + { key: 'plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable plugins', content: }, + ], + }, + { + label: 'AI', + icon: Bot, + sections: [ + { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, + { key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: }, + ], + }, ]; const { Sidebar, Content } = createSettingsPanelComponents({ - globalKey: GLOBAL_KEY, - sidebarIcon: Bot, - sidebarLabel: 'AI', - sections, + globalKey: 'SYSTEM_SETTINGS_SELECTED', + sidebarIcon: Settings, + sidebarLabel: 'System', + groups, }); const layout: LayoutNode = { type: 'group', - id: 'ai-root', + id: 'system-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'ai-left', appType: null }, size: 20 }, - { node: { type: 'panel', id: 'ai-right', appType: null }, size: 80 }, + { node: { type: 'panel', id: 'system-left', appType: null }, size: 20 }, + { node: { type: 'panel', id: 'system-right', appType: null }, size: 80 }, ], }; -export const AISettings = () => { +export const SystemSettings = () => { const panelComponents: PanelComponents = useMemo( () => ({ - 'ai-left': Sidebar, - 'ai-right': Content, + 'system-left': Sidebar, + 'system-right': Content, }), [], ); @@ -64,10 +80,13 @@ export const AISettings = () => { ); }; +// --- AI sections --- + function ChatDefaultsSection() { const { settings, saveSettings } = useSettings(); const claudeModels = useVisibleClaudeModels(); const openCodeModels = useVisibleOpenCodeModels(); + const piMonoModels = useVisiblePiMonoModels(); const [isSaving, setIsSaving] = useState(false); const [model, setModel] = useState(settings.chat.defaultModel); @@ -86,16 +105,18 @@ function ChatDefaultsSection() { () => [ ...claudeModels.map((m) => ({ ...m, provider: 'Claude' })), ...openCodeModels.map((m) => ({ ...m, provider: m.provider ?? 'OpenCode' })), + ...piMonoModels.map((m) => ({ ...m, provider: m.provider ?? 'Pi' })), ], - [claudeModels, openCodeModels], + [claudeModels, openCodeModels, piMonoModels], ); const handleSave = async () => { if (isSaving) return; setIsSaving(true); try { + const isPiMono = piMonoModels.some((m) => m.id === model); const isOpenCode = openCodeModels.some((m) => m.id === model); - const defaultProvider = isOpenCode ? ('opencode' as const) : ('claude' as const); + const defaultProvider = isPiMono ? ('pi-mono' as const) : isOpenCode ? ('opencode' as const) : ('claude' as const); const updated: UserSettings = { ...settings, chat: { defaultProvider, defaultModel: model, systemPrompt, temperature, defaultPwd }, @@ -172,7 +193,8 @@ function ModelVisibilitySection() { const { settings, saveSettings } = useSettings(); const claudeModels = useClaudeModels(); const openCodeModels = useOpenCodeModels(); - const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'opencode'); + const piMonoModels = usePiMonoModels(); + const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'claude'); const enabledModels = settings.ai?.enabledModels ?? []; const enabledProviders = settings.ai?.enabledProviders ?? []; @@ -183,9 +205,9 @@ function ModelVisibilitySection() { await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: newEnabled } }); }; - const ocGroups = useMemo(() => { + const buildProviderGroups = (models: { id: string; name: string; provider?: string }[]) => { const groups: Record = {}; - for (const m of openCodeModels) { + for (const m of models) { const provider = m.provider ?? 'Other'; if (!groups[provider]) groups[provider] = []; groups[provider].push({ id: m.id, name: m.name }); @@ -193,11 +215,16 @@ function ModelVisibilitySection() { return Object.entries(groups) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) })); - }, [openCodeModels]); + }; + + const ocGroups = useMemo(() => buildProviderGroups(openCodeModels), [openCodeModels]); + const piGroups = useMemo(() => buildProviderGroups(piMonoModels), [piMonoModels]); const [addingProvider, setAddingProvider] = useState(false); const [selectedNewProvider, setSelectedNewProvider] = useState(''); - const disabledProviders = ocGroups.filter((g) => !enabledProviders.includes(g.provider)); + + const currentGroups = subTab === 'opencode' ? ocGroups : piGroups; + const disabledProviders = currentGroups.filter((g) => !enabledProviders.includes(g.provider)); const handleEnableProvider = async () => { if (!selectedNewProvider) return; @@ -219,11 +246,14 @@ function ModelVisibilitySection() { return ( + + Claude + OpenCode - - Claude + + Pi @@ -242,63 +272,129 @@ function ModelVisibilitySection() { -
- {addingProvider ? ( - <> - - - - ) : ( - - )} -
- {ocGroups.length === 0 ? ( -

No OpenCode models available.

- ) : ( - - )} + +
+ + + !enabledProviders.includes(g.provider)) : disabledProviders} + addingProvider={addingProvider} + selectedNewProvider={selectedNewProvider} + emptyLabel="No Pi models available." + onSetAddingProvider={setAddingProvider} + onSetSelectedNewProvider={setSelectedNewProvider} + onEnableProvider={handleEnableProvider} + onToggleModel={toggleModel} + onRemoveProvider={handleRemoveProvider} + />
); } +// --- Shared components --- + type ProviderGroup = { provider: string; models: { id: string; name: string }[] }; -type OpenCodeProviderListProps = { +type ProviderGroupTabProps = { + groups: ProviderGroup[]; + enabledProviders: string[]; + enabledModels: string[]; + disabledProviders: ProviderGroup[]; + addingProvider: boolean; + selectedNewProvider: string; + emptyLabel: string; + onSetAddingProvider: (v: boolean) => void; + onSetSelectedNewProvider: (v: string) => void; + onEnableProvider: () => void; + onToggleModel: (key: string) => void; + onRemoveProvider: (provider: string) => void; +}; + +const ProviderGroupTab = ({ + groups, + enabledProviders, + enabledModels, + disabledProviders, + addingProvider, + selectedNewProvider, + emptyLabel, + onSetAddingProvider, + onSetSelectedNewProvider, + onEnableProvider, + onToggleModel, + onRemoveProvider, +}: ProviderGroupTabProps) => ( + <> +
+ {addingProvider ? ( + <> + + + + ) : ( + + )} +
+ {groups.length === 0 ? ( +

{emptyLabel}

+ ) : ( + + )} + +); + +type ProviderListProps = { groups: ProviderGroup[]; enabledProviders: string[]; enabledModels: string[]; @@ -306,14 +402,14 @@ type OpenCodeProviderListProps = { onRemoveProvider: (provider: string) => void; }; -const OpenCodeProviderList = ({ +const ProviderList = ({ groups, enabledProviders, enabledModels, onToggleModel, onRemoveProvider, -}: OpenCodeProviderListProps) => { - const [openProvider, setOpenProvider] = useUserState('ai-settings-oc-accordion', ''); +}: ProviderListProps) => { + const [openProvider, setOpenProvider] = useUserState('ai-settings-provider-accordion', ''); const enabled = groups.filter((g) => enabledProviders.includes(g.provider)); if (enabled.length === 0) { diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx index 3261e197..304be307 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx @@ -1,4 +1,3 @@ export * from './ProfileSettings'; -export * from './AISettings'; -export * from './ServerSettings'; +export * from './SystemSettings'; export * from './ResourceSettings'; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index 38fde2a6..291e32ba 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -8,8 +8,9 @@ import { TerminalView } from 'apps/Terminal'; import { useWorkspacesState } from '@/state/useWorkspacesState'; import { useClaude } from '../Chat/useClaude'; import { useOpenCode } from '../Chat/useOpenCode'; +import { usePiMono } from '../Chat/usePiMono'; import { ChatPanel } from '../Chat/ChatPanel'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels'; +import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; import { ChatHistoryApp as ChatHistory } from '../ChatHistory'; import { Files } from '../Files'; import { Catalog } from 'sounds'; @@ -19,26 +20,34 @@ import { widgetRegistry } from 'widgets/widget-registry'; import { WidgetPanel } from 'widgets/WidgetPanel'; const ChatWidget = () => { - const [provider, setProvider] = useState<'claude' | 'opencode'>('claude'); - return provider === 'claude' ? ( - - ) : ( - - ); + const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>('claude'); + if (provider === 'claude') { + return ; + } + if (provider === 'opencode') { + return ; + } + return ; }; -const ClaudeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => { +const ClaudeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { const claude = useClaude(); const models = useVisibleClaudeModels(); return ; }; -const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => { +const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { const opencode = useOpenCode(); const models = useVisibleOpenCodeModels(); return ; }; +const PiMonoChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { + const piMono = usePiMono(); + const models = useVisiblePiMonoModels(); + return ; +}; + const CodeEditorWrapper = () => ; const TerminalWrapper = ({ panelId }: { panelId: string }) => { diff --git a/src/apps/officer-web/locales/en.json b/src/apps/officer-web/locales/en.json index 139e40b9..e75786da 100644 --- a/src/apps/officer-web/locales/en.json +++ b/src/apps/officer-web/locales/en.json @@ -51,8 +51,7 @@ "header": { "userMenu": { "profile": "Profile", - "aiSettings": "AI Settings", - "serverSettings": "Server Settings", + "systemSettings": "System Settings", "resources": "Resources", "signOut": "Sign Out" } diff --git a/src/apps/officer-web/locales/pt.json b/src/apps/officer-web/locales/pt.json index c5e41294..23df6e49 100644 --- a/src/apps/officer-web/locales/pt.json +++ b/src/apps/officer-web/locales/pt.json @@ -51,8 +51,7 @@ "header": { "userMenu": { "profile": "Perfil", - "aiSettings": "Definições de IA", - "serverSettings": "Definições do Servidor", + "systemSettings": "Definições do Sistema", "resources": "Recursos", "signOut": "Terminar Sessão" } diff --git a/src/apps/officer-web/state/types/user-settings.ts b/src/apps/officer-web/state/types/user-settings.ts index ac82af63..abd3da3b 100644 --- a/src/apps/officer-web/state/types/user-settings.ts +++ b/src/apps/officer-web/state/types/user-settings.ts @@ -1,6 +1,6 @@ export type UserSettings = { chat: { - defaultProvider: 'claude' | 'opencode'; + defaultProvider: 'claude' | 'opencode' | 'pi-mono'; defaultModel: string | null; systemPrompt: string; temperature: number; @@ -11,7 +11,7 @@ export type UserSettings = { enabledProviders: string[]; }; tasks: { - defaultProvider: 'claude' | 'opencode'; + defaultProvider: 'claude' | 'opencode' | 'pi-mono'; defaultModel: string | null; }; appearance: { diff --git a/src/apps/officer-web/state/useChatSessions.ts b/src/apps/officer-web/state/useChatSessions.ts index fadeeea4..865edc98 100644 --- a/src/apps/officer-web/state/useChatSessions.ts +++ b/src/apps/officer-web/state/useChatSessions.ts @@ -15,14 +15,14 @@ export const useChatSessions = () => { queryFn: () => client.get('/sessions'), }); - const getMessages = (provider: 'claude' | 'opencode', sessionId: string) => + const getMessages = (provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => client.get(`/sessions/${provider}/${sessionId}/messages`); - const saveMessages = (provider: 'claude' | 'opencode', sessionId: string, messages: ChatMessage[]) => + const saveMessages = (provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string, messages: ChatMessage[]) => client.put(`/sessions/${provider}/${sessionId}/messages`, messages); const renameSession = async ( - provider: 'claude' | 'opencode', + provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string | null, args: string, ): Promise => { @@ -42,12 +42,12 @@ export const useChatSessions = () => { } }; - const archiveSession = async (provider: 'claude' | 'opencode', sessionId: string) => { + const archiveSession = async (provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => { await client.post(`/sessions/${provider}/${sessionId}/archive`); queryClient.setQueryData(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []); }; - const deleteSession = async (provider: 'claude' | 'opencode', sessionId: string) => { + const deleteSession = async (provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => { await client.delete(`/sessions/${provider}/${sessionId}`); queryClient.setQueryData(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []); }; diff --git a/src/apps/officer-web/state/useModels.ts b/src/apps/officer-web/state/useModels.ts index 9eb3e978..7236bf64 100644 --- a/src/apps/officer-web/state/useModels.ts +++ b/src/apps/officer-web/state/useModels.ts @@ -66,3 +66,24 @@ export const useVisibleOpenCodeModels = () => { [models, providers, enabled], ); }; + +export const usePiMonoModels = () => { + const client = useClient(); + const { isAuthenticated } = useAuth(); + + const { data: models = [] } = useQuery({ + queryKey: ['PI_MONO_MODELS'], + enabled: isAuthenticated, + queryFn: () => client.get('/pi-mono/models'), + staleTime: 5 * 60 * 1000, + }); + + return models; +}; + +export const useVisiblePiMonoModels = () => { + const models = usePiMonoModels(); + const { settings } = useSettings(); + const enabled = settings.ai?.enabledModels ?? []; + return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]); +}; diff --git a/src/apps/officer-web/state/useServerSettings.ts b/src/apps/officer-web/state/useServerSettings.ts index de2a983d..924f8387 100644 --- a/src/apps/officer-web/state/useServerSettings.ts +++ b/src/apps/officer-web/state/useServerSettings.ts @@ -5,6 +5,7 @@ import { useClient } from 'hooks/useClient'; type AIHarnesses = { claudeCode: boolean; opencode: boolean; + piMono: boolean; }; type ServerSettings = { diff --git a/src/server.tsx b/src/server.tsx index 714cf3b7..2b0a7da4 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -7,6 +7,7 @@ import { verify } from './servers/jwt'; import { officerdb, TokenBlacklist } from 'officerdb'; import { claudeWebsocket } from './servers/api/claude/websocket'; import { opencodeWebsocket } from './servers/api/opencode/websocket'; +import { piMonoWebsocket } from './servers/api/pi-mono/websocket'; import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket'; import officerWeb from './apps/officer-web/index.html'; @@ -16,7 +17,7 @@ type WSData = { userId: number; email: string; role: string; - provider: 'claude' | 'opencode' | 'terminal'; + provider: 'claude' | 'opencode' | 'pi-mono' | 'terminal'; sandboxed: boolean; sessionId?: string; }; @@ -24,10 +25,11 @@ type WSData = { const handlers: Record = { claude: claudeWebsocket, opencode: opencodeWebsocket, + 'pi-mono': piMonoWebsocket, terminal: terminalWebsocket, }; -async function upgradeWs(req: Request, server: any, provider: 'claude' | 'opencode' | 'terminal') { +async function upgradeWs(req: Request, server: any, provider: 'claude' | 'opencode' | 'pi-mono' | 'terminal') { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); @@ -67,6 +69,7 @@ const server = serve({ }, '/api/harness/claudecode/ws': (req, server) => upgradeWs(req, server, 'claude'), '/api/harness/opencode/ws': (req, server) => upgradeWs(req, server, 'opencode'), + '/api/harness/pi-mono/ws': (req, server) => upgradeWs(req, server, 'pi-mono'), '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), '/': officerWeb, '/*': officerWeb, diff --git a/src/servers/api/pi-mono/sessions.ts b/src/servers/api/pi-mono/sessions.ts new file mode 100644 index 00000000..8d261531 --- /dev/null +++ b/src/servers/api/pi-mono/sessions.ts @@ -0,0 +1,54 @@ +import { Hono } from 'hono'; +import type { HonoVariables } from '@@/create-router'; + +export const piMonoModelsRouter = new Hono<{ Variables: HonoVariables }>(); + +// Hardcoded fallback models — pi supports many providers but these are the most common +const FALLBACK_MODELS = [ + { id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', provider: 'anthropic', providerId: 'anthropic' }, + { id: 'claude-opus-4-20250918', name: 'Claude Opus 4', provider: 'anthropic', providerId: 'anthropic' }, + { id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', providerId: 'openai' }, + { id: 'o3', name: 'o3', provider: 'openai', providerId: 'openai' }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', providerId: 'google' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', providerId: 'google' }, +]; + +piMonoModelsRouter.get('/pi-mono/models', async (ctx) => { + // Spawn a short-lived pi process to query available models + try { + const proc = Bun.spawn(['pi', '--list-models', '--mode', 'json'], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env }, + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (proc.exitCode !== 0) return ctx.json(FALLBACK_MODELS); + + // Parse the output — pi --list-models outputs model info + const lines = output.trim().split('\n').filter(Boolean); + const models: { id: string; name: string; provider: string; providerId: string }[] = []; + + for (const line of lines) { + try { + const data = JSON.parse(line); + if (data.id && data.provider) { + models.push({ + id: data.id, + name: data.name ?? data.id, + provider: data.provider, + providerId: data.provider, + }); + } + } catch { + // skip non-JSON lines + } + } + + return ctx.json(models.length > 0 ? models : FALLBACK_MODELS); + } catch { + return ctx.json(FALLBACK_MODELS); + } +}); diff --git a/src/servers/api/pi-mono/websocket.ts b/src/servers/api/pi-mono/websocket.ts new file mode 100644 index 00000000..cb82f5af --- /dev/null +++ b/src/servers/api/pi-mono/websocket.ts @@ -0,0 +1,448 @@ +import type { ServerWebSocket } from 'bun'; +import type { Subprocess } from 'bun'; +import { mkdir, rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { + getPiMonoSessionDir, + getTmpAttachmentsDir, + getAttachmentsDir, + getHomeDir, + getNativeSkillsDir, + getGlobalSkillsDir, + getUserSkillsDir, +} from '@@/data-path'; +import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills'; +import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types'; +import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger'; + +type WSData = { userId: number; email: string }; + +type ConnectionState = { + piProcess: Subprocess | null; + sessionId: string | null; + pendingTitle: string | null; + selectedModel: string | null; + pendingAttachmentIds: string[]; + cwd: string | null; + resourceChatDir: string | null; + logId: string | null; + fullText: string; + rpcReady: boolean; +}; + +const connections = new Map, ConnectionState>(); + +function send(ws: ServerWebSocket, msg: ServerMessage) { + if (ws.readyState === 1) ws.send(JSON.stringify(msg)); +} + +function resolveRootDir(email: string, root?: string): string { + if (!root || root === 'home') return getHomeDir(email); + if (root === '~') return homedir(); + if (root === 'officer.dev') return join(process.cwd(), '..'); + return getHomeDir(email); +} + +async function buildSkillsPrompt(email: string): Promise { + const nativeSkills = await readSkillDirs(getNativeSkillsDir()); + const globalSkills = await readSkillDirs(getGlobalSkillsDir()); + const userSkills = await readSkillDirs(getUserSkillsDir(email)); + + const merged = new Map(nativeSkills); + for (const [name, path] of globalSkills) merged.set(name, path); + for (const [name, path] of userSkills) merged.set(name, path); + + if (merged.size === 0) return ''; + + const lines = await Promise.all( + Array.from(merged.entries()).map(async ([dirName, filePath]) => { + const raw = await Bun.file(filePath).text(); + const { frontmatter } = parseFrontmatter(raw); + const name = frontmatter.name || dirName; + return `- ${name}: ${frontmatter.description} (read ${filePath} for full instructions)`; + }), + ); + + return `\n\nYou have access to the following skills. When a user's request matches a skill, read its SKILL.md file for detailed instructions before proceeding.\n\nAvailable skills:\n${lines.join('\n')}`; +} + +function writeRpcCommand(proc: Subprocess, command: Record) { + const writer = proc.stdin as WritableStream; + const textEncoder = new TextEncoder(); + const w = writer.getWriter(); + w.write(textEncoder.encode(JSON.stringify(command) + '\n')); + w.releaseLock(); +} + +function spawnPiProcess(ws: ServerWebSocket, state: ConnectionState, workingDir: string) { + const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']; + + if (state.selectedModel) { + args.push('--model', state.selectedModel); + } + + const proc = Bun.spawn(args, { + cwd: workingDir, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env }, + }); + + state.piProcess = proc; + + // Read stdout line-by-line for JSON events + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const readLoop = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + handlePiEvent(ws, state, event); + } catch { + // skip unparseable lines + } + } + } + } catch { + // process ended + } + }; + + readLoop(); + + // Read stderr for debugging + const stderrReader = proc.stderr.getReader(); + const stderrDecoder = new TextDecoder(); + const readStderr = async () => { + try { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + const text = stderrDecoder.decode(value, { stream: true }); + if (text.trim()) console.log('[pi-mono-ws] stderr:', text.trim()); + } + } catch { + // process ended + } + }; + readStderr(); + + // Handle process exit + proc.exited.then((code) => { + console.log(`[pi-mono-ws] pi process exited with code ${code}`); + if (state.piProcess === proc) { + state.piProcess = null; + } + }); +} + +function handlePiEvent(ws: ServerWebSocket, state: ConnectionState, event: Record) { + const type = event.type as string; + + // RPC responses (type === 'response') + if (type === 'response') { + const command = event.command as string; + if (command === 'get_available_models' && event.success) { + // Models are handled by the REST endpoint, not here + } + if (command === 'prompt' && !event.success) { + send(ws, { type: 'error', message: (event.error as string) ?? 'Prompt failed' }); + } + return; + } + + switch (type) { + case 'agent_start': + state.fullText = ''; + break; + + case 'message_update': { + // message_update contains assistantMessageEvent with content deltas + const ame = event.assistantMessageEvent as Record | undefined; + if (!ame) break; + + const ameType = ame.type as string; + if (ameType === 'text_delta') { + const delta = ame.delta as string; + state.fullText += delta; + send(ws, { type: 'assistant:partial', text: delta }); + } + break; + } + + case 'message_end': { + // Full assistant message complete + if (state.fullText) { + send(ws, { type: 'assistant:text', text: state.fullText }); + if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText }); + state.fullText = ''; + } + break; + } + + case 'tool_execution_start': { + const toolCallId = (event.toolCallId as string) ?? ''; + const toolName = (event.toolName as string) ?? 'unknown'; + const args = (event.args as Record) ?? {}; + + // Commit any streaming text before tool use + if (state.fullText) { + send(ws, { type: 'assistant:text', text: state.fullText }); + if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText }); + state.fullText = ''; + } + + send(ws, { type: 'tool:use', toolName, toolInput: args, toolUseId: toolCallId }); + if (state.logId) appendToLog(state.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId }); + break; + } + + case 'tool_execution_end': { + const toolCallId = (event.toolCallId as string) ?? ''; + const result = event.result; + const isError = (event.isError as boolean) ?? false; + const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : ''; + + send(ws, { type: 'tool:result', toolUseId: toolCallId, output, isError }); + if (state.logId) + appendToLog(state.logId, { + role: 'tool', + toolName: '', + toolInput: {}, + toolUseId: toolCallId, + output, + isError, + }); + break; + } + + case 'agent_end': { + // Commit any remaining streaming text + if (state.fullText) { + send(ws, { type: 'assistant:text', text: state.fullText }); + if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText }); + state.fullText = ''; + } + + send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false }); + if (state.logId) { + appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false }); + finalizeLog(state.logId); + state.logId = null; + } + break; + } + + case 'extension_ui_request': { + // Auto-cancel extension UI requests since we don't support them + if (state.piProcess && event.id) { + writeRpcCommand(state.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true }); + } + break; + } + } +} + +type HandleChatParams = { + ws: ServerWebSocket; + prompt: string; + sessionId?: string; + model?: string; + cwd?: { root?: string; path: string }; + attachmentIds?: string[]; + images?: ImageData[]; + resourceChatDir?: string; + taskInfo?: TaskInfo; +}; + +async function handleChat({ + ws, + prompt, + sessionId, + model, + cwd, + attachmentIds, + images, + resourceChatDir, + taskInfo, +}: HandleChatParams) { + const state = connections.get(ws); + if (!state) return; + + if (taskInfo && !state.logId) { + state.logId = createTaskLog(ws.data.email, taskInfo, 'pi-mono', model ?? 'unknown'); + appendToLog(state.logId, { role: 'user', text: prompt }); + } + + if (resourceChatDir) state.resourceChatDir = resourceChatDir; + if (model) state.selectedModel = model; + + if (!sessionId && !state.sessionId) { + // New session — generate our own sessionId for officer tracking + const newSessionId = crypto.randomUUID(); + state.sessionId = newSessionId; + state.pendingTitle = prompt.slice(0, 100); + if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds; + + send(ws, { type: 'session:init', sessionId: newSessionId, model: model ?? 'pi-mono' }); + + if (state.resourceChatDir) { + const chatDir = join(state.resourceChatDir, 'chat'); + const meta = { id: newSessionId, model: model ?? 'pi-mono' }; + mkdir(chatDir, { recursive: true }) + .then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta))) + .catch(() => {}); + } else { + const dir = getPiMonoSessionDir(ws.data.email, newSessionId); + const meta = { + id: newSessionId, + title: state.pendingTitle ?? 'New chat', + createdAt: Date.now(), + model: model ?? 'pi-mono', + }; + mkdir(dir, { recursive: true }) + .then(() => Bun.write(join(dir, 'meta.json'), JSON.stringify(meta))) + .catch(() => {}); + + // Move tmp attachments to session dir + if (state.pendingAttachmentIds.length > 0) { + const tmpDir = getTmpAttachmentsDir(ws.data.email); + const destDir = getAttachmentsDir(ws.data.email, 'pi-mono', newSessionId); + mkdir(destDir, { recursive: true }) + .then(() => + Promise.all( + state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})), + ), + ) + .catch(() => {}); + state.pendingAttachmentIds = []; + } + } + state.pendingTitle = null; + } else if (sessionId && !state.sessionId) { + state.sessionId = sessionId; + } + + // Ensure pi process is running + const homeDir = getHomeDir(ws.data.email); + if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path); + const workingDir = state.cwd ?? homeDir; + + if (!state.piProcess) { + spawnPiProcess(ws, state, workingDir); + // Give pi a moment to initialize + await new Promise((r) => setTimeout(r, 500)); + } + + if (!state.piProcess) { + send(ws, { type: 'error', message: 'Failed to start pi process' }); + return; + } + + // Set model if specified + if (model && model !== state.selectedModel) { + state.selectedModel = model; + // Model is set via CLI args on spawn, would need a new process to change + } + + // Build context and send prompt + const skillsAppend = await buildSkillsPrompt(ws.data.email); + const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend; + send(ws, { type: 'system:prompt', text: contextAppend }); + + const fullPrompt = `${contextAppend}\n\n${prompt}`; + + const rpcCommand: Record = { + type: 'prompt', + id: `req_${Date.now()}`, + message: fullPrompt, + }; + + writeRpcCommand(state.piProcess, rpcCommand); +} + +function handleStop(ws: ServerWebSocket) { + const state = connections.get(ws); + if (!state?.piProcess) return; + + writeRpcCommand(state.piProcess, { type: 'abort', id: `abort_${Date.now()}` }); + send(ws, { type: 'stopped' }); +} + +function killPiProcess(state: ConnectionState) { + if (state.piProcess) { + try { + state.piProcess.kill(); + } catch { + // already dead + } + state.piProcess = null; + } +} + +export const piMonoWebsocket = { + open(ws: ServerWebSocket) { + connections.set(ws, { + piProcess: null, + sessionId: null, + pendingTitle: null, + selectedModel: null, + pendingAttachmentIds: [], + cwd: null, + resourceChatDir: null, + logId: null, + fullText: '', + rpcReady: false, + }); + }, + + message(ws: ServerWebSocket, raw: string | Buffer) { + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage; + } catch { + send(ws, { type: 'error', message: 'Invalid JSON' }); + return; + } + + if (msg.type === 'chat') { + handleChat({ + ws, + prompt: msg.prompt, + sessionId: msg.sessionId, + model: typeof msg.model === 'string' ? msg.model : undefined, + cwd: msg.cwd, + attachmentIds: msg.attachmentIds, + images: msg.images, + resourceChatDir: msg.resourceChatDir, + taskInfo: msg.taskInfo, + }); + } else if (msg.type === 'stop') { + handleStop(ws); + } + }, + + close(ws: ServerWebSocket) { + const state = connections.get(ws); + if (state) { + killPiProcess(state); + } + connections.delete(ws); + }, + + drain() {}, +}; diff --git a/src/servers/api/scrape/scrape.ts b/src/servers/api/scrape/scrape.ts index 2130b2f5..8c8c5201 100644 --- a/src/servers/api/scrape/scrape.ts +++ b/src/servers/api/scrape/scrape.ts @@ -33,7 +33,7 @@ scrapeRouter.post('/', async (ctx) => { const { url, sessionId, provider } = ctx.get('body') as { url: string; sessionId?: string; - provider?: 'claude' | 'opencode'; + provider?: 'claude' | 'opencode' | 'pi-mono'; }; if (!url) return ctx.json({ error: 'url is required' }, 400); diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts new file mode 100644 index 00000000..81bd1ad9 --- /dev/null +++ b/src/servers/api/server-settings/pi-mono.ts @@ -0,0 +1,54 @@ +import { createRouter } from '../../create-router'; + +export const piMonoRouter = createRouter(); + +const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin']; + +const getPaths = async () => { + try { + const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + if (proc.exitCode !== 0) return { path: null, globalPath: null }; + const paths = [...new Set(output.trim().split('\n'))]; + const path = paths[0] ?? null; + const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null; + return { path, globalPath }; + } catch { + return { path: null, globalPath: null }; + } +}; + +piMonoRouter.get('/version', async (ctx) => { + try { + const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null }); + const { path, globalPath } = await getPaths(); + return ctx.json({ version: output.trim(), path, globalPath }); + } catch { + return ctx.json({ version: null, path: null, globalPath: null }); + } +}); + +piMonoRouter.post('/install', async (ctx) => { + try { + const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], { + stdout: 'pipe', + stderr: 'pipe', + }); + await proc.exited; + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500); + } + const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); + const output = await new Response(versionProc.stdout).text(); + await versionProc.exited; + const { path, globalPath } = await getPaths(); + return ctx.json({ version: output.trim(), path, globalPath }); + } catch { + return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500); + } +}); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index c67d9074..921329c5 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { officerdb, count, Users } from 'officerdb'; import { claudeCodeRouter } from './claude-code'; import { opencodeRouter } from './opencode'; +import { piMonoRouter } from './pi-mono'; import { applicationsRouter } from './applications'; import { resourcesRouter } from './resources'; @@ -22,6 +23,7 @@ export const serverSettingsRouter = createRouter(); serverSettingsRouter.route('/claude-code', claudeCodeRouter); serverSettingsRouter.route('/opencode', opencodeRouter); +serverSettingsRouter.route('/pi-mono', piMonoRouter); serverSettingsRouter.route('/applications', applicationsRouter); serverSettingsRouter.route('/resources', resourcesRouter); diff --git a/src/servers/api/sessions/sessions.ts b/src/servers/api/sessions/sessions.ts index e50779e9..adc6b783 100644 --- a/src/servers/api/sessions/sessions.ts +++ b/src/servers/api/sessions/sessions.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { mkdir, readdir, rename, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir } from '@@/data-path'; +import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path'; import type { HonoVariables } from '@@/create-router'; const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006'; @@ -14,9 +14,13 @@ export const sessionsRouter = new Hono<{ Variables: HonoVariables }>(); sessionsRouter.get('/sessions', async (ctx) => { const { email } = ctx.get('user'); - const [claudeSessions, opencodeSessions] = await Promise.all([fetchClaudeSessions(email), fetchOpencodeSessions(email)]); + const [claudeSessions, opencodeSessions, piMonoSessions] = await Promise.all([ + fetchClaudeSessions(email), + fetchOpencodeSessions(email), + fetchPiMonoSessions(email), + ]); - const merged = [...claudeSessions, ...opencodeSessions].sort((a, b) => b.createdAt - a.createdAt); + const merged = [...claudeSessions, ...opencodeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt); return ctx.json(merged); }); @@ -37,6 +41,12 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => { return ctx.json(await fetchOpencodeMessages(id)); } + if (provider === 'pi-mono') { + const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json')); + if (!(await file.exists())) return ctx.json([]); + return ctx.json(await file.json()); + } + return ctx.json({ error: 'invalid provider' }, 400); }); @@ -46,10 +56,10 @@ sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => { const id = ctx.req.param('id'); if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400); - if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400); + if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400); const messages = ctx.get('body'); - const dir = getSessionDir(email, id); + const dir = provider === 'pi-mono' ? getPiMonoSessionDir(email, id) : getSessionDir(email, id); await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages)); return ctx.json({ ok: true }); }); @@ -92,6 +102,16 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => { return ctx.json({ ok: true }); } + if (provider === 'pi-mono') { + const dir = getPiMonoSessionDir(email, id); + const metaFile = Bun.file(join(dir, 'meta.json')); + if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); + const meta = await metaFile.json(); + meta.title = body.title.slice(0, 200); + await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); + return ctx.json({ ok: true }); + } + return ctx.json({ error: 'invalid provider' }, 400); }); @@ -124,6 +144,16 @@ sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => { return ctx.json({ ok: true }); } + if (provider === 'pi-mono') { + const dir = getPiMonoSessionDir(email, id); + try { + await rm(dir, { recursive: true }); + } catch { + // dir may not exist + } + return ctx.json({ ok: true }); + } + return ctx.json({ error: 'invalid provider' }, 400); }); @@ -134,7 +164,7 @@ sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => { const provider = ctx.req.param('provider'); const id = ctx.req.param('id'); - if (provider === 'opencode') return ctx.json({ error: 'opencode sessions cannot be archived' }, 400); + if (provider === 'opencode' || provider === 'pi-mono') return ctx.json({ error: `${provider} sessions cannot be archived` }, 400); if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400); const src = getSessionDir(email, id); @@ -150,7 +180,7 @@ type SessionMeta = { id: string; title: string; createdAt: number; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; model?: string | null; }; @@ -200,6 +230,28 @@ async function fetchOpencodeSessions(email: string): Promise { } } +async function fetchPiMonoSessions(email: string): Promise { + const dir = getPiMonoDir(email); + try { + const entries = await readdir(dir); + const sessions = await Promise.all( + entries.map(async (id) => { + try { + const metaFile = Bun.file(join(dir, id, 'meta.json')); + if (!(await metaFile.exists())) return null; + const meta = await metaFile.json(); + return { ...meta, provider: 'pi-mono' as const }; + } catch { + return null; + } + }), + ); + return sessions.filter((s): s is SessionMeta => s !== null); + } catch { + return []; + } +} + async function fetchOpencodeMessages(id: string) { try { const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`); diff --git a/src/servers/api/upload/upload.ts b/src/servers/api/upload/upload.ts index 9e194903..3a292a1e 100644 --- a/src/servers/api/upload/upload.ts +++ b/src/servers/api/upload/upload.ts @@ -13,7 +13,7 @@ uploadRouter.post('/', async (ctx) => { const file = body.file as File | null; const sessionId = (body.sessionId as string) || null; - const provider = (body.provider as 'claude' | 'opencode') || null; + const provider = (body.provider as 'claude' | 'opencode' | 'pi-mono') || null; if (!file || !(file instanceof File)) { return ctx.json({ error: 'file is required' }, 400); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 1ad373e3..d6948852 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -16,6 +16,11 @@ export const getOpencodeDir = (email: string) => join(DATA_PATH, email, 'chat_se export const getOpencodeSessionDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'chat_sessions', 'opencode', sessionId); +export const getPiMonoDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'pi-mono'); + +export const getPiMonoSessionDir = (email: string, sessionId: string) => + join(DATA_PATH, email, 'chat_sessions', 'pi-mono', sessionId); + export const getArchivedSessionDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'chat_sessions', 'claude', 'archived', sessionId); @@ -53,5 +58,5 @@ export const getTaskLogsDir = (email: string) => join(DATA_PATH, email, 'logs', export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments'); -export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode', sessionId: string) => +export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ba56c56c..0df9a565 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -12,6 +12,7 @@ import { tasksRouter } from './api/tasks/tasks'; import { processesRouter } from './api/processes/processes'; import { claudeModelsRouter } from './api/claude/sessions'; import { opencodeModelsRouter } from './api/opencode/sessions'; +import { piMonoModelsRouter } from './api/pi-mono/sessions'; import { sessionsRouter } from './api/sessions/sessions'; import { scrapeRouter } from './api/scrape/scrape'; import { uploadRouter } from './api/upload/upload'; @@ -53,6 +54,7 @@ protectedRouter.route('/processes', processesRouter); protectedRouter.route('/', sessionsRouter); protectedRouter.route('/', claudeModelsRouter); protectedRouter.route('/', opencodeModelsRouter); +protectedRouter.route('/', piMonoModelsRouter); protectedRouter.route('/scrape', scrapeRouter); protectedRouter.route('/upload', uploadRouter); protectedRouter.route('/user', settingsRouter); diff --git a/src/workspaces/apps/Chat/types.ts b/src/workspaces/apps/Chat/types.ts index e469bbd2..28169974 100644 --- a/src/workspaces/apps/Chat/types.ts +++ b/src/workspaces/apps/Chat/types.ts @@ -2,7 +2,7 @@ export type SessionEntry = { id: string; title: string; createdAt: number; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; model?: string | null; }; diff --git a/src/workspaces/apps/ChatHistory/SessionBar.tsx b/src/workspaces/apps/ChatHistory/SessionBar.tsx index bf12e20f..3a42aa86 100644 --- a/src/workspaces/apps/ChatHistory/SessionBar.tsx +++ b/src/workspaces/apps/ChatHistory/SessionBar.tsx @@ -3,7 +3,7 @@ import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react'; type SessionBarProps = { listPath: string; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'pi-mono'; sessionTitle: string | undefined; isConnected: boolean; isGenerating: boolean; From 7c0b11c5a5bf5a40bda8bebc79e4ec2715fb5c3d Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 19 Feb 2026 21:43:37 +0000 Subject: [PATCH 2/2] sudo commands through ephemeral terminal --- .../ServerSettings/AIHarnessesSection.tsx | 61 ++++++++++++-- .../ServerSettings/run-command-channel.ts | 6 ++ .../Dashboard/Settings/SystemSettings.tsx | 84 ++++++++++++++++++- src/servers/api/terminal/pty-sidecar.mjs | 78 +++++++++-------- src/servers/api/terminal/websocket.ts | 19 +++-- src/workspaces/apps/Terminal/Terminal.tsx | 38 +++++++++ 6 files changed, 240 insertions(+), 46 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index 9156f577..68730c60 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -1,10 +1,22 @@ import { useState } from 'react'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Play } from 'lucide-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; import { useServerSettings } from '@/state/useServerSettings'; +import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel'; type VersionInfo = { version: string | null; path: string | null; globalPath: string | null }; type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string }; @@ -106,15 +118,28 @@ export const AIHarnessesSection = () => { setTimeout(() => setCopied(null), 1500); }; - const CopyCommand = ({ command }: { command: string }) => ( + const [, setRunCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + + const [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null); + + const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
Not globally accessible. Run:
{command} +
+ + !open && setConfirmCommand(null)}> + + + Run with elevated privileges + + You are about to run a command with elevated privileges (sudo). Are you sure? + + + {confirmCommand?.command} + + Cancel + { + if (confirmCommand) setRunCommand(confirmCommand); + setConfirmCommand(null); + }} + > + Run + + + + + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts new file mode 100644 index 00000000..b3174a66 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts @@ -0,0 +1,6 @@ +export type RunCommandState = { + command: string; + refetchKeys: string[]; +} | null; + +export const RUN_COMMAND_CHANNEL = 'system-settings:run-command'; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 1536155a..9dc63f8a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; -import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings } from 'lucide-react'; +import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -10,8 +10,11 @@ import { Switch } from '@/components/ui/switch'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useQueryClient } from '@tanstack/react-query'; import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { TerminalView } from 'apps/Terminal'; import { appRegistry } from '../Workspaces/app-registry'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { useSettings } from '@/state/useSettings'; @@ -27,6 +30,7 @@ import { import type { UserSettings } from '@/state/types/user-settings'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { PluginsSection } from './ServerSettings/PluginsSection'; +import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; const groups: SettingsSectionGroup[] = [ { @@ -54,7 +58,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({ groups, }); -const layout: LayoutNode = { +const baseLayout: LayoutNode = { type: 'group', id: 'system-root', direction: 'horizontal', @@ -64,11 +68,87 @@ const layout: LayoutNode = { ], }; +const splitLayout: LayoutNode = { + type: 'group', + id: 'system-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'system-left', appType: null }, size: 20 }, + { + node: { + type: 'group', + id: 'system-right-group', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'system-right', appType: null }, size: 50 }, + { node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 }, + ], + }, + size: 80, + }, + ], +}; + +const SystemTerminalPanel = () => { + const queryClient = useQueryClient(); + const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + const [session, setSession] = useState<{ id: string; command: string } | null>(null); + + useEffect(() => { + if (state && (!session || session.command !== state.command)) { + setSession({ id: `run-cmd-${Date.now()}`, command: state.command }); + } else if (!state) { + setSession(null); + } + }, [state]); + + const close = () => setState(null); + + const onCommandDone = (exitCode: number, output: string) => { + if (state) { + for (const key of state.refetchKeys) { + queryClient.invalidateQueries({ queryKey: [key] }); + } + } + if (exitCode === 0) { + toast.success('Command completed successfully'); + } else { + toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 }); + } + setState(null); + }; + + if (!state || !session) return null; + + return ( +
+
+ Run Command + +
+ +
+ ); +}; + export const SystemSettings = () => { + const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + + const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]); + const panelComponents: PanelComponents = useMemo( () => ({ 'system-left': Sidebar, 'system-right': Content, + 'system-terminal': SystemTerminalPanel, }), [], ); diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index d1a206b3..3f587997 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/pty-sidecar.mjs @@ -3,9 +3,16 @@ import { existsSync } from 'node:fs'; import { cp, mkdir } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; import { WebSocketServer } from 'ws'; import * as pty from 'node-pty'; +const run = (cmd, args, opts = {}) => + new Promise((resolve) => { + const proc = execFile(cmd, args, { stdio: 'ignore', ...opts }, () => resolve()); + proc.on('error', () => resolve()); + }); + const __dirname = dirname(fileURLToPath(import.meta.url)); const isDocker = existsSync('/opt/terminal-templates/.zshrc'); @@ -60,25 +67,14 @@ const ensureUserFiles = async (homeDir) => { if (ohMyZshSource && existsSync(ohMyZshSource)) { await cp(ohMyZshSource, ohMyZshPath, { recursive: true }); } else { - const proc = Bun.spawn({ - cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath], - stdout: 'ignore', - stderr: 'ignore', - }); - await proc.exited; + await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]); } } if (!isDocker) { const starshipBin = join(homeDir, '.local', 'bin', 'starship'); if (!existsSync(starshipBin)) { - const installProc = Bun.spawn({ - cmd: ['sh', '-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], - env: { ...process.env, HOME: homeDir }, - stdout: 'ignore', - stderr: 'ignore', - }); - await installProc.exited; + await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } }); } } }; @@ -149,15 +145,36 @@ wss.on('connection', (ws) => { const cwd = msg.cwd ?? process.cwd(); const homeDir = msg.homeDir ?? process.cwd(); const userLabel = msg.userLabel ?? 'officer'; - const prompt = `${userLabel} in %~ %# `; - const bashPrompt = `${userLabel} \\w \\$ `; const cols = msg.cols ?? 80; const rows = msg.rows ?? 24; + const isHost = !!msg.host; - try { - await ensureUserFiles(homeDir); - } catch { - // ignore + let ptyEnv; + if (isHost) { + ptyEnv = { ...process.env, TERM: 'xterm-256color' }; + } else { + const prompt = `${userLabel} in %~ %# `; + const bashPrompt = `${userLabel} \\w \\$ `; + + try { + await ensureUserFiles(homeDir); + } catch { + // ignore + } + + ptyEnv = { + ...process.env, + HOME: homeDir, + ZDOTDIR: homeDir, + ZSH: `${homeDir}/.oh-my-zsh`, + SHELL: shell.command, + USER: userLabel, + LOGNAME: userLabel, + OFFICER_TERMINAL_USER: userLabel, + PROMPT: prompt, + PS1: bashPrompt, + TERM: 'xterm-256color', + }; } let term; @@ -167,19 +184,7 @@ wss.on('connection', (ws) => { cols, rows, cwd, - env: { - ...process.env, - HOME: homeDir, - ZDOTDIR: homeDir, - ZSH: `${homeDir}/.oh-my-zsh`, - SHELL: shell.command, - USER: userLabel, - LOGNAME: userLabel, - OFFICER_TERMINAL_USER: userLabel, - PROMPT: prompt, - PS1: bashPrompt, - TERM: 'xterm-256color', - }, + env: ptyEnv, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start terminal'; @@ -205,7 +210,8 @@ wss.on('connection', (ws) => { } }); - term.onExit(() => { + term.onExit(({ exitCode, signal }) => { + console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`); if (session.ws) { sendJson(session.ws, { type: 'exit' }); } @@ -228,7 +234,11 @@ wss.on('connection', (ws) => { if (msg.cols > 0 && msg.rows > 0) { session.cols = msg.cols; session.rows = msg.rows; - session.term.resize(msg.cols, msg.rows); + try { + session.term.resize(msg.cols, msg.rows); + } catch { + // PTY may have already exited + } } break; case 'cwd': diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index cf6b7eb9..3306ffc9 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -236,20 +236,28 @@ const sidecarAlive = async (port: number): Promise => { } }; -const startHostSidecar = async () => { - if (await sidecarAlive(HOST_SIDECAR_PORT)) { - console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`); - return; +const killSidecarOnPort = (port: number) => { + try { + const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); + if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`); + } catch { + // fuser not available or failed } +}; +const startHostSidecar = async () => { if (hostSidecarProcess) { hostSidecarProcess.kill(); await hostSidecarProcess.exited.catch(() => {}); hostSidecarProcess = null; } + + killSidecarOnPort(HOST_SIDECAR_PORT); + await new Promise((resolve) => setTimeout(resolve, 200)); + const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); hostSidecarProcess = Bun.spawn({ - cmd: ['bun', sidecarPath], + cmd: ['node', sidecarPath], env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) }, stdout: 'inherit', stderr: 'inherit', @@ -323,6 +331,7 @@ export const terminalWebsocket = { sidecar.send( JSON.stringify({ type: 'init', + host: true, sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, cwd: resolveCwd(process.env.HOME!, ws.data.cwd), diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx index a04ae801..21003171 100644 --- a/src/workspaces/apps/Terminal/Terminal.tsx +++ b/src/workspaces/apps/Terminal/Terminal.tsx @@ -19,12 +19,14 @@ export type TerminalViewProps = { sessionId?: string; sandboxed?: boolean; cwd?: string; + command?: string; fontSize?: number; fontFamily?: string; theme?: TerminalTheme; autoFocus?: boolean; onReady?: (term: XTerm) => void; onExit?: () => void; + onCommandDone?: (exitCode: number, output: string) => void; onDisconnect?: () => void; }; @@ -53,12 +55,14 @@ export const TerminalView = ({ sessionId, sandboxed = true, cwd, + command, fontSize = 14, fontFamily = 'Menlo, Monaco, "Courier New", monospace', theme, autoFocus = true, onReady, onExit, + onCommandDone, onDisconnect, }: TerminalViewProps) => { const containerRef = useRef(null); @@ -68,11 +72,15 @@ export const TerminalView = ({ const isMounted = useMounted(); const onReadyRef = useRef(onReady); const onExitRef = useRef(onExit); + const onCommandDoneRef = useRef(onCommandDone); const onDisconnectRef = useRef(onDisconnect); + const commandRef = useRef(command); onReadyRef.current = onReady; onExitRef.current = onExit; + onCommandDoneRef.current = onCommandDone; onDisconnectRef.current = onDisconnect; + commandRef.current = command; const background = theme?.background ?? DEFAULT_THEME.background; const foreground = theme?.foreground ?? DEFAULT_THEME.foreground; @@ -118,6 +126,12 @@ export const TerminalView = ({ const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd)); wsRef.current = ws; + let commandSent = false; + let commandDone = false; + let commandOutput = ''; + const EXIT_MARKER = '__OFFICER_EXIT_'; + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); const handleOpen = () => { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); @@ -128,6 +142,30 @@ export const TerminalView = ({ const msg = JSON.parse(ev.data as string); if (msg.type === 'output') { term.write(msg.data); + if (commandRef.current && !commandSent) { + commandSent = true; + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + const wrapped = onCommandDoneRef.current + ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` + : commandRef.current; + ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); + } + }, 100); + } + if (commandSent && !commandDone && onCommandDoneRef.current) { + commandOutput += msg.data as string; + const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/); + if (markerMatch) { + const exitCode = Number(markerMatch[1]); + const raw = stripAnsi(commandOutput).slice(0, markerMatch.index); + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20))); + const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim(); + commandDone = true; + onCommandDoneRef.current(exitCode, output); + } + } } else if (msg.type === 'exit') { term.write('\r\n[Process exited]\r\n'); onExitRef.current?.();