checkpoint

This commit is contained in:
Michael Neale
2026-02-05 16:48:56 +11:00
parent 5fc21ce094
commit becdee8a58
14 changed files with 4247 additions and 42 deletions
Generated
+2
View File
@@ -4302,6 +4302,7 @@ dependencies = [
"clap_complete",
"cliclack",
"console 0.16.2",
"dirs 6.0.0",
"dotenvy",
"etcetera 0.11.0",
"futures",
@@ -4314,6 +4315,7 @@ dependencies = [
"open",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rmcp 0.14.0",
"rustyline",
"serde",
+2
View File
@@ -56,6 +56,8 @@ anstream = "0.6.18"
open = "5.3.2"
urlencoding = "2.1"
clap_complete = "4.5.62"
dirs = "6.0.0"
reqwest.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
+3150 -40
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -40,6 +40,7 @@
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.51.6",
"@mcp-ui/client": "^5.17.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
+13 -2
View File
@@ -23,7 +23,7 @@ import { ChatType } from '../types/chat';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { cn } from '../utils';
import { useChatStream } from '../hooks/useChatStream';
import { useAgentChat, type AgentBackend } from '../hooks/useAgentChat';
import { useNavigation } from '../hooks/useNavigation';
import { RecipeHeader } from './RecipeHeader';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
@@ -78,6 +78,16 @@ export default function BaseChat({
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
const [agentBackend, setAgentBackend] = useState<AgentBackend>('goose');
// Load agent backend preference from settings
useEffect(() => {
window.electron.getSettings().then((settings) => {
if (settings.agentBackend) {
setAgentBackend(settings.agentBackend);
}
});
}, []);
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
@@ -106,8 +116,9 @@ export default function BaseChat({
tokenState,
notifications: toolCallNotifications,
onMessageUpdate,
} = useChatStream({
} = useAgentChat({
sessionId,
backend: agentBackend,
onStreamFinish,
});
@@ -13,6 +13,7 @@ import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
import TelemetrySettings from './TelemetrySettings';
import { trackSettingToggled } from '../../../utils/analytics';
import type { AgentBackend } from '../../../utils/settings';
interface AppSettingsSectionProps {
scrollToSection?: string;
@@ -27,6 +28,8 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [showPricing, setShowPricing] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(false);
const [isPiAvailable, setIsPiAvailable] = useState(false);
const [agentBackend, setAgentBackend] = useState<AgentBackend>('goose');
const updateSectionRef = useRef<HTMLDivElement>(null);
// Check if GOOSE_VERSION is set to determine if Updates section should be shown
@@ -62,6 +65,14 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
setShowPricing(stored !== 'false');
}, []);
// Check Pi availability and load agent backend setting
useEffect(() => {
window.electron.pi.isAvailable().then(setIsPiAvailable);
window.electron.getSettings().then((settings) => {
setAgentBackend(settings.agentBackend || 'goose');
});
}, []);
// Handle scrolling to update section
useEffect(() => {
if (scrollToSection === 'update' && updateSectionRef.current) {
@@ -148,6 +159,17 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
window.dispatchEvent(new CustomEvent('storage'));
};
const handleAgentBackendToggle = async (usePi: boolean) => {
const newBackend: AgentBackend = usePi ? 'pi' : 'goose';
const settings = await window.electron.getSettings();
settings.agentBackend = newBackend;
const success = await window.electron.saveSettings(settings);
if (success) {
setAgentBackend(newBackend);
trackSettingToggled('agent_backend_pi', usePi);
}
};
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="rounded-lg">
@@ -271,6 +293,41 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
<TunnelSection />
{/* Experimental: Pi Agent Backend */}
<Card className="rounded-lg border-amber-500/50">
<CardHeader className="pb-0">
<CardTitle className="mb-1 flex items-center gap-2">
<span className="text-amber-500 text-xs font-normal px-2 py-0.5 bg-amber-500/10 rounded">
Experimental
</span>
Agent Backend
</CardTitle>
<CardDescription>
Switch between the default Goose agent and the Pi coding agent
</CardDescription>
</CardHeader>
<CardContent className="pt-4 px-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-default text-xs">Use Pi Agent</h3>
<p className="text-xs text-text-muted max-w-md mt-[2px]">
{isPiAvailable
? 'Pi is an alternative coding agent that runs natively in the app. New sessions will use the selected agent.'
: 'Pi agent is not available. Check logs for loading errors.'}
</p>
</div>
<div className="flex items-center">
<Switch
checked={agentBackend === 'pi'}
onCheckedChange={handleAgentBackendToggle}
disabled={!isPiAvailable}
variant="mono"
/>
</div>
</div>
</CardContent>
</Card>
<TelemetrySettings isWelcome={false} />
<Card className="rounded-lg">
+103
View File
@@ -0,0 +1,103 @@
/**
* Unified agent chat hook that can switch between Goose and Pi backends.
*
* This hook provides a single interface for chat functionality,
* delegating to either useChatStream (Goose/goosed) or usePiChat (Pi)
* based on configuration.
*
* Usage:
* const chat = useAgentChat({ sessionId, backend: 'goose' }); // or 'pi'
*/
import { useChatStream } from './useChatStream';
import { usePiChat } from './usePiChat';
import { Message, Session, TokenState } from '../api';
import { ChatState } from '../types/chatState';
import { NotificationEvent, UserInput } from '../types/message';
export type AgentBackend = 'goose' | 'pi';
interface UseAgentChatProps {
sessionId: string;
backend?: AgentBackend;
onStreamFinish: () => void;
onSessionLoaded?: () => void;
}
interface UseAgentChatReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
setChatState: (state: ChatState) => void;
handleSubmit: (input: UserInput) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
onMessageUpdate: (
messageId: string,
newContent: string,
editType?: 'fork' | 'edit'
) => Promise<void>;
backend: AgentBackend;
}
export function useAgentChat({
sessionId,
backend = 'goose',
onStreamFinish,
onSessionLoaded,
}: UseAgentChatProps): UseAgentChatReturn {
// Use the appropriate hook based on backend selection
const gooseChat = useChatStream({
sessionId,
onStreamFinish,
onSessionLoaded,
});
const piChat = usePiChat({
sessionId,
onStreamFinish,
onSessionLoaded,
});
if (backend === 'pi') {
return {
session: piChat.session,
messages: piChat.messages,
chatState: piChat.chatState,
setChatState: piChat.setChatState,
handleSubmit: piChat.handleSubmit,
submitElicitationResponse: piChat.submitElicitationResponse,
setRecipeUserParams: piChat.setRecipeUserParams,
stopStreaming: piChat.stopStreaming,
sessionLoadError: piChat.sessionLoadError,
tokenState: piChat.tokenState,
notifications: piChat.notifications,
onMessageUpdate: piChat.onMessageUpdate,
backend: 'pi',
};
}
// Default to Goose backend
return {
session: gooseChat.session,
messages: gooseChat.messages,
chatState: gooseChat.chatState,
setChatState: gooseChat.setChatState,
handleSubmit: gooseChat.handleSubmit,
submitElicitationResponse: gooseChat.submitElicitationResponse,
setRecipeUserParams: gooseChat.setRecipeUserParams,
stopStreaming: gooseChat.stopStreaming,
sessionLoadError: gooseChat.sessionLoadError,
tokenState: gooseChat.tokenState,
notifications: gooseChat.notifications,
onMessageUpdate: gooseChat.onMessageUpdate,
backend: 'goose',
};
}
+313
View File
@@ -0,0 +1,313 @@
/**
* React hook for Pi agent integration.
*
* This hook provides the same interface as useChatStream but uses Pi
* running in the Electron main process instead of goosed.
*
* Architecture:
* - Renderer calls window.electron.pi.* IPC methods
* - Main process manages Pi session and streams events back
* - Events are translated to Goose-compatible Message format
*/
import { useCallback, useEffect, useReducer, useRef } from 'react';
import { ChatState } from '../types/chatState';
import { Message, Session, TokenState } from '../api';
import { createUserMessage, NotificationEvent, UserInput } from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
// Debug logging to file
const piLog = (msg: string, ...args: unknown[]) => {
const line = `[${new Date().toISOString()}] ${msg} ${args.map(a => JSON.stringify(a)).join(' ')}\n`;
console.log('[usePiChat]', msg, ...args);
// Write to localStorage for debugging (renderer can't write files directly)
const existing = localStorage.getItem('pi-debug-log') || '';
localStorage.setItem('pi-debug-log', existing + line);
};
interface UsePiChatProps {
sessionId: string;
onStreamFinish: () => void;
onSessionLoaded?: () => void;
}
interface UsePiChatReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
setChatState: (state: ChatState) => void;
handleSubmit: (input: UserInput) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
onMessageUpdate: (
messageId: string,
newContent: string,
editType?: 'fork' | 'edit'
) => Promise<void>;
}
interface PiChatState {
messages: Message[];
session: Session | undefined;
chatState: ChatState;
sessionLoadError: string | undefined;
tokenState: TokenState;
isPiAvailable: boolean;
}
type PiChatAction =
| { type: 'SET_MESSAGES'; payload: Message[] }
| { type: 'SET_SESSION'; payload: Session | undefined }
| { type: 'SET_CHAT_STATE'; payload: ChatState }
| { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined }
| { type: 'SET_TOKEN_STATE'; payload: TokenState }
| { type: 'SET_PI_AVAILABLE'; payload: boolean }
| { type: 'ADD_MESSAGE'; payload: Message }
| { type: 'UPDATE_LAST_MESSAGE'; payload: Message }
| { type: 'START_STREAMING' }
| { type: 'STREAM_FINISH'; payload?: string }
| { type: 'SESSION_STARTED' };
const initialTokenState: TokenState = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
};
const initialState: PiChatState = {
messages: [],
session: undefined,
chatState: ChatState.Idle,
sessionLoadError: undefined,
tokenState: initialTokenState,
isPiAvailable: false,
};
function piChatReducer(state: PiChatState, action: PiChatAction): PiChatState {
switch (action.type) {
case 'SET_MESSAGES':
return { ...state, messages: action.payload };
case 'SET_SESSION':
return { ...state, session: action.payload };
case 'SET_CHAT_STATE':
return { ...state, chatState: action.payload };
case 'SET_SESSION_LOAD_ERROR':
return { ...state, sessionLoadError: action.payload };
case 'SET_TOKEN_STATE':
return { ...state, tokenState: action.payload };
case 'SET_PI_AVAILABLE':
return { ...state, isPiAvailable: action.payload };
case 'ADD_MESSAGE':
return { ...state, messages: [...state.messages, action.payload] };
case 'UPDATE_LAST_MESSAGE': {
const messages = [...state.messages];
if (messages.length > 0 && messages[messages.length - 1].role === action.payload.role) {
messages[messages.length - 1] = action.payload;
} else {
messages.push(action.payload);
}
return { ...state, messages };
}
case 'START_STREAMING':
return { ...state, chatState: ChatState.Streaming };
case 'STREAM_FINISH':
return {
...state,
chatState: ChatState.Idle,
sessionLoadError: action.payload,
};
case 'SESSION_STARTED':
return {
...state,
chatState: ChatState.Idle,
session: {
id: `pi-${Date.now()}`,
name: 'Pi Session',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Session,
};
default:
return state;
}
}
export function usePiChat({
sessionId: _sessionId,
onStreamFinish,
onSessionLoaded,
}: UsePiChatProps): UsePiChatReturn {
const [state, dispatch] = useReducer(piChatReducer, initialState);
const stateRef = useRef(state);
stateRef.current = state;
const sessionStartedRef = useRef(false);
// Get working directory from app config
const workingDir = (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || process.cwd();
// Check Pi availability and auto-start session on mount
useEffect(() => {
if (sessionStartedRef.current) return;
(async () => {
try {
console.log('[usePiChat] Checking Pi availability...');
const available = await window.electron.pi.isAvailable();
console.log('[usePiChat] Pi available:', available);
dispatch({ type: 'SET_PI_AVAILABLE', payload: available });
if (available && !sessionStartedRef.current) {
sessionStartedRef.current = true;
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.LoadingConversation });
console.log('[usePiChat] Starting Pi session with workingDir:', workingDir);
const result = await window.electron.pi.startSession({ workingDir });
console.log('[usePiChat] Session start result:', result);
if (result.success) {
dispatch({ type: 'SESSION_STARTED' });
onSessionLoaded?.();
} else {
dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: 'Failed to start Pi session' });
}
}
} catch (err) {
console.error('[usePiChat] Error:', err);
dispatch({ type: 'SET_PI_AVAILABLE', payload: false });
dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: errorMessage(err) });
}
})();
}, [workingDir, onSessionLoaded]);
// Listen for Pi events from main process
useEffect(() => {
const handlePiEvent = (_event: { type: string }) => {
// Raw Pi events are for debugging - we use translated pi:message events
};
const handlePiMessage = (gooseMessage: Message) => {
// Handle translated Goose messages (these have proper metadata)
if (gooseMessage && gooseMessage.metadata) {
dispatch({ type: 'UPDATE_LAST_MESSAGE', payload: gooseMessage });
}
};
const handlePiComplete = () => {
dispatch({ type: 'STREAM_FINISH' });
onStreamFinish();
};
const handlePiError = (data: { error: string }) => {
dispatch({ type: 'STREAM_FINISH', payload: data.error });
onStreamFinish();
};
// Register listeners via IPC
window.electron.on('pi:event', (_e, data) => handlePiEvent(data as { type: string; message?: Message }));
window.electron.on('pi:message', (_e, data) => handlePiMessage(data as Message));
window.electron.on('pi:complete', handlePiComplete);
window.electron.on('pi:error', (_e, data) => handlePiError(data as { error: string }));
return () => {
window.electron.off('pi:event', handlePiEvent as () => void);
window.electron.off('pi:message', handlePiMessage as () => void);
window.electron.off('pi:complete', handlePiComplete);
window.electron.off('pi:error', handlePiError as () => void);
};
}, [onStreamFinish]);
const handleSubmit = useCallback(
async (input: UserInput) => {
console.log('[usePiChat] handleSubmit called with:', input.msg);
const userMessage = createUserMessage(input.msg, input.images);
dispatch({ type: 'ADD_MESSAGE', payload: userMessage });
dispatch({ type: 'START_STREAMING' });
try {
console.log('[usePiChat] Calling pi.prompt...');
const result = await window.electron.pi.prompt(input.msg);
console.log('[usePiChat] prompt result:', result);
if (!result.success && result.error) {
dispatch({ type: 'STREAM_FINISH', payload: result.error });
onStreamFinish();
}
// Success case is handled by IPC event listeners
} catch (error) {
console.error('[usePiChat] prompt error:', error);
dispatch({ type: 'STREAM_FINISH', payload: errorMessage(error) });
onStreamFinish();
}
},
[onStreamFinish]
);
const submitElicitationResponse = useCallback(
async (_elicitationId: string, _userData: Record<string, unknown>) => {
// Pi doesn't support elicitation yet - no-op
console.warn('[usePiChat] Elicitation not supported by Pi agent');
},
[]
);
const setRecipeUserParams = useCallback(async (_values: Record<string, string>) => {
// Pi doesn't support recipe params yet - no-op
console.warn('[usePiChat] Recipe params not supported by Pi agent');
}, []);
const stopStreaming = useCallback(async () => {
try {
await window.electron.pi.stopSession();
} catch {
// Ignore abort errors
}
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
}, []);
const setChatState = useCallback((newState: ChatState) => {
dispatch({ type: 'SET_CHAT_STATE', payload: newState });
}, []);
const onMessageUpdate = useCallback(
async (_messageId: string, _newContent: string, _editType?: 'fork' | 'edit') => {
// Pi doesn't support message editing yet - no-op
console.warn('[usePiChat] Message editing not supported by Pi agent');
},
[]
);
return {
session: state.session,
messages: state.messages,
chatState: state.chatState,
setChatState,
handleSubmit,
submitElicitationResponse,
setRecipeUserParams,
stopStreaming,
sessionLoadError: state.sessionLoadError,
tokenState: state.tokenState,
notifications: new Map(),
onMessageUpdate,
};
}
+11
View File
@@ -24,6 +24,7 @@ import os from 'node:os';
import { spawn } from 'child_process';
import 'dotenv/config';
import { checkServerStatus, startGoosed } from './goosed';
import { initializePi, registerPiIpcHandlers } from './pi/index';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
@@ -1649,6 +1650,16 @@ async function appMain() {
registerUpdateIpcHandlers();
// Initialize Pi agent (optional - loads if available)
// Always register IPC handlers so renderer can check availability
registerPiIpcHandlers();
const piAvailable = await initializePi();
if (piAvailable) {
log.info('[Main] Pi agent initialized');
} else {
log.info('[Main] Pi agent not available (optional dependency)');
}
// Handle microphone permission requests
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
console.log('Permission requested:', permission);
+229
View File
@@ -0,0 +1,229 @@
/**
* Translates Pi agent events to Goose-compatible message format.
*
* This allows the existing UI components to work with Pi as the backend,
* maintaining a consistent user experience.
*/
import type { Message, MessageContent, Role } from '../api';
// Pi event types (from @mariozechner/pi-agent-core)
export interface PiAgentEvent {
type: string;
message?: PiAgentMessage;
[key: string]: unknown;
}
export interface PiMessageContent {
type: 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'image';
text?: string;
thinking?: string;
toolCallId?: string;
toolName?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;
}
export interface PiAgentMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: PiMessageContent[];
createdAt?: string;
}
// Re-export Goose Message type for convenience
export type GooseMessage = Message;
/**
* Translates a Pi message to Goose message format.
*/
export function translatePiMessage(piMessage: PiAgentMessage): GooseMessage {
const gooseContent: MessageContent[] = [];
for (const content of piMessage.content) {
switch (content.type) {
case 'text':
gooseContent.push({
type: 'text',
text: content.text || '',
});
break;
case 'thinking':
gooseContent.push({
type: 'thinking',
thinking: content.thinking || content.text || '',
signature: '', // Pi doesn't use Anthropic-style thinking signatures
});
break;
case 'tool_call':
gooseContent.push({
type: 'toolRequest',
id: content.toolCallId || generateId(),
toolCall: {
name: content.toolName || 'unknown',
arguments: content.args || {},
},
});
break;
case 'tool_result':
gooseContent.push({
type: 'toolResponse',
id: content.toolCallId || generateId(),
toolResult: {
result: content.result,
isError: content.isError || false,
},
});
break;
}
}
const role: Role = piMessage.role === 'tool' ? 'assistant' : piMessage.role;
const created = piMessage.createdAt
? Math.floor(new Date(piMessage.createdAt).getTime() / 1000)
: Math.floor(Date.now() / 1000);
return {
id: piMessage.id,
role,
created,
content: gooseContent,
metadata: {
userVisible: true,
agentVisible: true,
},
};
}
function generateId(): string {
return Math.random().toString(36).substring(2, 10);
}
/**
* State machine for accumulating Pi events into Goose messages.
*/
export class PiEventAccumulator {
private currentMessage: PiAgentMessage | null = null;
private messages: GooseMessage[] = [];
private isStreaming = false;
/**
* Process a Pi event and return any completed Goose messages.
*/
processEvent(event: PiAgentEvent): {
message?: GooseMessage;
isComplete: boolean;
isStreaming: boolean;
} {
switch (event.type) {
case 'agent_start':
this.isStreaming = true;
this.messages = [];
return { isComplete: false, isStreaming: true };
case 'agent_end':
this.isStreaming = false;
return { isComplete: true, isStreaming: false };
case 'message_start': {
const msg = event.message as PiAgentMessage | undefined;
if (!msg) return { isComplete: false, isStreaming: true };
this.currentMessage = msg;
return {
message: translatePiMessage(msg),
isComplete: false,
isStreaming: true,
};
}
case 'message_update': {
const msg = event.message as PiAgentMessage | undefined;
if (!msg) return { isComplete: false, isStreaming: true };
this.currentMessage = msg;
return {
message: translatePiMessage(msg),
isComplete: false,
isStreaming: true,
};
}
case 'message_end': {
const msg = event.message as PiAgentMessage | undefined;
if (!msg) return { isComplete: false, isStreaming: true };
this.currentMessage = null;
const gooseMsg = translatePiMessage(msg);
this.messages.push(gooseMsg);
return {
message: gooseMsg,
isComplete: false,
isStreaming: true,
};
}
case 'tool_execution_start': {
// Tool execution events are already part of the message content
// We can emit a partial update here if needed
return { isComplete: false, isStreaming: true };
}
case 'tool_execution_end': {
// Tool result will be included in the next message
return { isComplete: false, isStreaming: true };
}
default:
// Unknown event type, ignore
return { isComplete: false, isStreaming: this.isStreaming };
}
}
/**
* Get all accumulated messages.
*/
getMessages(): GooseMessage[] {
return this.messages;
}
/**
* Get the current streaming message (if any).
*/
getCurrentMessage(): GooseMessage | null {
return this.currentMessage ? translatePiMessage(this.currentMessage) : null;
}
/**
* Reset the accumulator.
*/
reset(): void {
this.currentMessage = null;
this.messages = [];
this.isStreaming = false;
}
}
/**
* Create SSE-compatible event data from a Pi event.
* This matches the format goosed uses for /reply SSE stream.
*/
export function piEventToSSE(event: PiAgentEvent): string | null {
const result = new PiEventAccumulator().processEvent(event);
if (result.message) {
return JSON.stringify({
type: 'message',
data: result.message,
});
}
if (result.isComplete) {
return JSON.stringify({
type: 'done',
});
}
return null;
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Pi Agent integration for Electron.
*
* This module provides a bridge between the Electron app and Pi,
* running Pi directly in the Node.js main process.
*
* Architecture:
* - Pi runs in the Electron main process
* - IPC handlers expose Pi to the renderer
* - Events are translated to Goose-compatible format
* - MCP extensions configured via goosed are passed to Pi
*/
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import { appendFileSync } from 'node:fs';
import log from '../utils/logger';
import { PiEventAccumulator, type PiAgentEvent, type GooseMessage } from './eventTranslator';
const PI_LOG = '/tmp/pi-debug.log';
const piLog = (msg: string, ...args: unknown[]) => {
const line = `[${new Date().toISOString()}] ${msg} ${args.map(a => JSON.stringify(a)).join(' ')}\n`;
console.log('[Pi]', msg, ...args);
try { appendFileSync(PI_LOG, line); } catch {}
};
// Pi types - these match the actual @mariozechner/pi-coding-agent exports
import type {
AgentSession,
CreateAgentSessionOptions,
CreateAgentSessionResult,
AgentSessionEvent,
} from '@mariozechner/pi-coding-agent';
interface PiModule {
createAgentSession: (options?: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
VERSION: string;
}
// Module state
let piModule: PiModule | null = null;
let currentSession: AgentSession | null = null;
let unsubscribeFn: (() => void) | null = null;
let eventAccumulator = new PiEventAccumulator();
/**
* Initialize Pi module.
* Called once at app startup.
*/
export async function initializePi(): Promise<boolean> {
try {
piLog('Attempting to load Pi module...');
piModule = (await import('@mariozechner/pi-coding-agent')) as PiModule;
piLog('Module loaded successfully, version:', piModule.VERSION);
return true;
} catch (error) {
piLog('Failed to load module:', error);
return false;
}
}
/**
* Check if Pi is available.
*/
export function isPiAvailable(): boolean {
return piModule !== null;
}
/**
* Get Pi version.
*/
export function getPiVersion(): string | null {
return piModule?.VERSION || null;
}
export interface StartSessionOptions {
workingDir?: string;
}
/**
* Start a new Pi session.
*/
export async function startPiSession(options: StartSessionOptions = {}): Promise<void> {
piLog('startPiSession called with options:', options);
if (!piModule) {
piLog('ERROR: Pi module not loaded');
throw new Error('Pi module not loaded. Call initializePi() first.');
}
// Stop existing session if any
if (currentSession) {
await stopPiSession();
}
// Set working directory
if (options.workingDir) {
process.chdir(options.workingDir);
}
const sessionConfig: CreateAgentSessionOptions = {
cwd: options.workingDir || process.cwd(),
};
piLog('Creating agent session with config:', sessionConfig);
const result = await piModule.createAgentSession(sessionConfig);
piLog('Session created, result:', { hasSession: !!result.session });
currentSession = result.session;
eventAccumulator.reset();
piLog('Session started successfully');
}
/**
* Stop the current Pi session.
*/
export async function stopPiSession(): Promise<void> {
if (unsubscribeFn) {
unsubscribeFn();
unsubscribeFn = null;
}
if (currentSession) {
try {
currentSession.abort();
} catch {
// Ignore abort errors
}
currentSession = null;
eventAccumulator.reset();
log.info('[Pi] Session stopped');
}
}
/**
* Send a prompt to Pi and stream events via callback.
*/
export async function promptPi(
message: string,
onEvent: (event: PiAgentEvent, gooseMessage?: GooseMessage) => void,
onComplete: () => void,
onError: (error: Error) => void
): Promise<void> {
piLog('promptPi called with message:', message.substring(0, 100));
if (!currentSession) {
piLog('ERROR: No Pi session');
onError(new Error('No Pi session. Call startPiSession() first.'));
return;
}
eventAccumulator.reset();
// Subscribe to events
if (unsubscribeFn) {
unsubscribeFn();
}
piLog('Subscribing to session events...');
unsubscribeFn = currentSession.subscribe((event: AgentSessionEvent) => {
const piEvent = event as PiAgentEvent;
piLog('Received event:', piEvent.type);
const result = eventAccumulator.processEvent(piEvent);
onEvent(piEvent, result.message);
if (result.isComplete) {
piLog('Event stream complete');
if (unsubscribeFn) {
unsubscribeFn();
unsubscribeFn = null;
}
onComplete();
}
});
try {
piLog('Calling currentSession.prompt()...');
await currentSession.prompt(message);
piLog('prompt() returned');
} catch (error) {
piLog('prompt() threw error:', error);
if (unsubscribeFn) {
unsubscribeFn();
unsubscribeFn = null;
}
onError(error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Abort current Pi operation.
*/
export function abortPi(): void {
if (currentSession) {
currentSession.abort();
}
}
/**
* Get Pi state.
*/
export function getPiState(): {
available: boolean;
version: string | null;
hasSession: boolean;
isStreaming: boolean;
} {
return {
available: isPiAvailable(),
version: getPiVersion(),
hasSession: currentSession !== null,
isStreaming: currentSession?.state.isStreaming || false,
};
}
/**
* Register IPC handlers for Pi.
* Call this from main.ts after app is ready.
*/
export function registerPiIpcHandlers(): void {
// Check if Pi is available
ipcMain.handle('pi:isAvailable', () => {
piLog('IPC pi:isAvailable called, returning:', isPiAvailable());
return isPiAvailable();
});
// Get Pi version
ipcMain.handle('pi:getVersion', () => getPiVersion());
// Get Pi state
ipcMain.handle('pi:getState', () => getPiState());
// Start Pi session
ipcMain.handle('pi:startSession', async (_event: IpcMainInvokeEvent, options: StartSessionOptions) => {
piLog('IPC pi:startSession called with options:', options);
try {
await startPiSession(options);
return { success: true };
} catch (error) {
piLog('IPC pi:startSession error:', error);
return { success: false, error: String(error) };
}
});
// Stop Pi session
ipcMain.handle('pi:stopSession', async () => {
await stopPiSession();
return { success: true };
});
// Abort current operation
ipcMain.handle('pi:abort', () => {
abortPi();
return { success: true };
});
// Send prompt - streams events back via IPC
ipcMain.handle('pi:prompt', async (event: IpcMainInvokeEvent, message: string) => {
const webContents = event.sender;
return new Promise<{ success: boolean; error?: string }>((resolve) => {
promptPi(
message,
// onEvent
(piEvent, gooseMessage) => {
// Send raw Pi event
webContents.send('pi:event', piEvent);
// Send translated Goose message if available
if (gooseMessage) {
webContents.send('pi:message', gooseMessage);
}
},
// onComplete
() => {
webContents.send('pi:complete');
resolve({ success: true });
},
// onError
(error) => {
webContents.send('pi:error', { error: error.message });
resolve({ success: false, error: error.message });
}
);
});
});
log.info('[Pi] IPC handlers registered');
}
// Re-export types and translator
export * from './eventTranslator';
+44
View File
@@ -51,6 +51,24 @@ interface UpdaterEvent {
data?: unknown;
}
// Pi Agent types
interface PiSessionOptions {
workingDir?: string;
provider?: string;
model?: string;
systemPrompt?: string;
}
interface PiState {
available: boolean;
hasSession: boolean;
}
interface PiAgentEvent {
type: string;
[key: string]: unknown;
}
// Define the API types in a single place
type ElectronAPI = {
platform: string;
@@ -130,6 +148,16 @@ type ElectronAPI = {
refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>;
// Pi Agent API
pi: {
isAvailable: () => Promise<boolean>;
getState: () => Promise<PiState>;
startSession: (options: PiSessionOptions) => Promise<{ success: boolean }>;
stopSession: () => Promise<{ success: boolean }>;
prompt: (message: string) => Promise<{ success: boolean; error?: string }>;
onEvent: (callback: (event: PiAgentEvent) => void) => void;
offEvent: (callback: (event: PiAgentEvent) => void) => void;
};
};
type AppConfigAPI = {
@@ -263,6 +291,22 @@ const electronAPI: ElectronAPI = {
refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app),
closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName),
addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir),
// Pi Agent API
pi: {
isAvailable: () => ipcRenderer.invoke('pi:isAvailable'),
getState: () => ipcRenderer.invoke('pi:getState'),
startSession: (options: PiSessionOptions) => ipcRenderer.invoke('pi:startSession', options),
stopSession: () => ipcRenderer.invoke('pi:stopSession'),
prompt: (message: string) => ipcRenderer.invoke('pi:prompt', message),
onEvent: (callback: (event: PiAgentEvent) => void) => {
const wrappedCallback = (_event: Electron.IpcRendererEvent, data: PiAgentEvent) =>
callback(data);
ipcRenderer.on('pi:event', wrappedCallback);
},
offEvent: (callback: (event: PiAgentEvent) => void) => {
ipcRenderer.removeListener('pi:event', callback as unknown as (...args: unknown[]) => void);
},
},
};
const appConfigAPI: AppConfigAPI = {
+3
View File
@@ -21,6 +21,8 @@ export type DefaultKeyboardShortcuts = {
[K in keyof KeyboardShortcuts]: string;
};
export type AgentBackend = 'goose' | 'pi';
export interface Settings {
showMenuBarIcon: boolean;
showDockIcon: boolean;
@@ -29,6 +31,7 @@ export interface Settings {
externalGoosed?: ExternalGoosedConfig;
globalShortcut?: string | null;
keyboardShortcuts?: KeyboardShortcuts;
agentBackend?: AgentBackend;
}
export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = {
+32
View File
@@ -1,9 +1,41 @@
import { defineConfig } from 'vite';
import { builtinModules } from 'node:module';
// Pi and all its transitive deps that have native/ws issues
const piExternals = [
'@mariozechner/pi-coding-agent',
'@mariozechner/pi-agent-core',
'@mariozechner/pi-ai',
'@mariozechner/pi-tui',
'@mariozechner/jiti',
'@mariozechner/clipboard',
'@google/genai',
'openai',
'ws',
'bufferutil',
'utf-8-validate',
];
// https://vitejs.dev/config
export default defineConfig({
define: {
'process.env.GITHUB_OWNER': JSON.stringify(process.env.GITHUB_OWNER || 'block'),
'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'),
// Disable ws native module lookups - they break Vite bundling
'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('1'),
'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('1'),
},
build: {
rollupOptions: {
external: (id) => {
// Node builtins
if (builtinModules.includes(id) || id.startsWith('node:')) return true;
// Pi and its problematic transitive deps
for (const ext of piExternals) {
if (id === ext || id.startsWith(ext + '/')) return true;
}
return false;
},
},
},
});