claude-code streaming chat, desktop remote viewer, new-automation route, tiktok task v4, misc fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent 0068244356
commit 40a9768cb3
91 changed files with 17872 additions and 85 deletions
+2
View File
@@ -43,6 +43,7 @@ export function App() {
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
<Route path="/automation" element={<Dashboard.Automation />} />
<Route path="/new-automation" element={<Dashboard.NewAutomation />} />
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
@@ -63,6 +64,7 @@ export function App() {
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/desktop" element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />} />
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
export const DesktopScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/desktop', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,7 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'desktop-screen',
appType: 'officerdev/desktop',
};
@@ -0,0 +1 @@
export * from './DesktopScreen';
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
};
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe } from 'lucide-react';
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
@@ -124,6 +124,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899', role: 'Super Admin' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
];
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
const LeftPanel = () => {
return <div className="h-full w-full p-4" />;
};
const RightPanel = () => {
return <div className="h-full w-full p-4" />;
};
const layout: LayoutNode = {
type: 'group',
id: 'new-automation-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-automation-left', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'new-automation-right', appType: null }, size: 70 },
],
};
export const NewAutomation = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'new-automation-left': LeftPanel,
'new-automation-right': RightPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -12,6 +12,7 @@ type TelegramConnection = {
type TelegramStatus = {
configured: boolean;
running: boolean;
botUsername: string | null;
serverInvite: string | null;
botHandle: string | null;
};
@@ -23,6 +24,7 @@ export const TelegramAccount = () => {
const [botStatus, setBotStatus] = useState<TelegramStatus>({
configured: false,
running: false,
botUsername: null,
serverInvite: null,
botHandle: null,
});
@@ -81,8 +83,23 @@ export const TelegramAccount = () => {
);
}
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
const botHandle = botStatus.botUsername ? `@${botStatus.botUsername}` : botStatus.botHandle;
const botLink = botStatus.botUsername ? `https://t.me/${botStatus.botUsername}` : null;
const connectionInfo = (botHandle || botStatus.serverInvite) && (
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
{botHandle && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
{botLink ? (
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
{botHandle}
</a>
) : (
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botHandle}</code>
)}
</div>
)}
{botStatus.serverInvite && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Group:</span>
@@ -96,12 +113,6 @@ export const TelegramAccount = () => {
</a>
</div>
)}
{botStatus.botHandle && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
</div>
)}
</div>
);
@@ -131,7 +142,7 @@ export const TelegramAccount = () => {
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Link your Telegram account to chat with your PI agent via direct messages.
Link your Telegram account to chat with your agent via direct messages{botHandle ? ` to ${botHandle}` : ''}.
</p>
{connectionInfo}
@@ -153,7 +164,15 @@ export const TelegramAccount = () => {
</Button>
</div>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Send this code as a direct message to the bot on Telegram. Expires in 10 minutes.
Send this code as a direct message to{' '}
{botLink ? (
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
{botHandle}
</a>
) : (
<strong>{botHandle ?? 'the bot'}</strong>
)}{' '}
on Telegram. Expires in 10 minutes.
</p>
<Button
type="button"
@@ -7,6 +7,7 @@ export * from './Processes';
export * from './CapabilityPage';
export * from './Settings';
export * from './Automation';
export * from './NewAutomation';
export * from './Skills';
export * from './TaskLogs';
export * from './Tasks';
@@ -18,3 +19,4 @@ export * from './Projects';
export * from './Terminal';
export * from './Email';
export * from './Browser';
export * from './Desktop';
+13 -2
View File
@@ -8,6 +8,7 @@ import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/
import { piWebsocket } from './servers/api/pi/websocket';
import { cliampWebsocket } from './servers/api/cliamp/websocket';
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
import { desktopWebsocket } from './servers/api/desktop/websocket';
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
import officerWeb from './apps/officer-web/index.html';
import { startBrowserRelay } from './servers/api/browser/relay';
@@ -20,7 +21,7 @@ type WSData = {
email: string;
username: string;
role: string;
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio';
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop';
sandboxed: boolean;
sessionId?: string;
cwd?: string;
@@ -39,6 +40,7 @@ const handlers: Record<string, any> = {
pi: piWebsocket,
cliamp: cliampWebsocket,
'cliamp-audio': cliampAudioWebsocket,
desktop: desktopWebsocket,
};
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
@@ -106,7 +108,7 @@ const devServerWebsocket = {
};
handlers['dev-server'] = devServerWebsocket;
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'cliamp' | 'cliamp-audio') {
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'cliamp' | 'cliamp-audio' | 'desktop') {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
@@ -172,6 +174,14 @@ const server = serve({
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
routes: {
'/og-image.jpg': () => new Response(Bun.file('public/og-image.jpg'), { headers: { 'Content-Type': 'image/jpeg' } }),
'/novnc/*': (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
},
'/vendor/*': (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
},
'/api/dev-server-proxy/*': (req, server) => {
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
return honoServer.fetch(req, server);
@@ -180,6 +190,7 @@ const server = serve({
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
'/': officerWeb,
'/*': officerWeb,
'/api': honoServer.fetch,
+11
View File
@@ -0,0 +1,11 @@
import { createRouter } from '../../create-router';
export const desktopRouter = createRouter();
desktopRouter.get('/vnc-password', (ctx) => {
const password = process.env.VNC_PASSWORD;
if (!password) {
return ctx.json({ error: 'VNC password not configured' }, 500);
}
return ctx.json({ password });
});
+102
View File
@@ -0,0 +1,102 @@
import type { ServerWebSocket } from 'bun';
import type { Socket } from 'bun';
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string };
type VncSession = {
tcpSocket: Socket<{ ws: ServerWebSocket<WSData> }> | null;
pendingMessages: Buffer[];
};
const VNC_PORT = Number(process.env.VNC_PORT || 5901);
const sessions = new Map<ServerWebSocket<WSData>, VncSession>();
export const desktopWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
if (ws.data.role !== 'Super Admin') {
ws.close(4003, 'Forbidden');
return;
}
const session: VncSession = { tcpSocket: null, pendingMessages: [] };
sessions.set(ws, session);
try {
const tcpSocket = await Bun.connect({
hostname: '127.0.0.1',
port: VNC_PORT,
socket: {
data(_socket, data) {
try {
ws.sendBinary(Buffer.from(data));
} catch {
// ws already closed
}
},
close() {
sessions.delete(ws);
try { ws.close(); } catch { /* ignore */ }
},
error(_socket, err) {
console.error('[desktop] TCP error:', err.message);
sessions.delete(ws);
try { ws.close(); } catch { /* ignore */ }
},
connectError(_socket, err) {
console.error('[desktop] TCP connect error:', err.message);
sessions.delete(ws);
try { ws.close(4004, 'VNC server unavailable'); } catch { /* ignore */ }
},
open(socket) {
// Flush any pending messages
for (const msg of session.pendingMessages) {
socket.write(msg);
}
session.pendingMessages = [];
},
},
data: { ws },
});
session.tcpSocket = tcpSocket;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect to VNC server';
console.error('[desktop] VNC connect failed:', message);
sessions.delete(ws);
ws.close(4004, 'VNC server unavailable');
}
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const session = sessions.get(ws);
if (!session) return;
const data = typeof raw === 'string' ? Buffer.from(raw) : Buffer.from(raw);
if (!session.tcpSocket) {
session.pendingMessages.push(data);
return;
}
try {
session.tcpSocket.write(data);
} catch {
// ignore
}
},
close(ws: ServerWebSocket<WSData>) {
const session = sessions.get(ws);
if (session?.tcpSocket) {
try {
session.tcpSocket.end();
} catch {
// ignore
}
}
sessions.delete(ws);
},
drain() {},
};
+20 -6
View File
@@ -29,13 +29,27 @@ async function cleanOldCacheDirs(userDataDir: string) {
}
}
async function syncOnboarding(homeDir: string) {
const target = join(homeDir, 'Onboarding');
if (!existsSync(ONBOARDING_SEED)) return;
await mkdir(target, { recursive: true });
// Copy any files from seed that are missing in the user's dir
const seedEntries = await readdir(ONBOARDING_SEED, { withFileTypes: true });
for (const entry of seedEntries) {
const dest = join(target, entry.name);
if (existsSync(dest)) continue;
await cp(join(ONBOARDING_SEED, entry.name), dest, { recursive: true });
}
}
async function seedHomeDir(homeDir: string) {
for (const dir of DEFAULT_HOME_DIRS) {
const target = join(homeDir, dir);
if (existsSync(target)) continue;
if (dir === 'Onboarding' && existsSync(ONBOARDING_SEED)) {
await cp(ONBOARDING_SEED, target, { recursive: true });
} else {
if (dir === 'Onboarding') {
await syncOnboarding(homeDir);
} else if (!existsSync(target)) {
await mkdir(target, { recursive: true });
}
}
@@ -905,8 +919,8 @@ router.post('/download-video', async (ctx) => {
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const cookiesPath = join(DATA_PATH, '..', 'yt-dlp-cookies.txt');
const args = ['yt-dlp', '--js-runtimes', 'bun', '--cookies', cookiesPath, '-o', '%(title)s.%(ext)s'];
const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`;
const args = [ytdlp, '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--cookies-from-browser', 'brave', '-o', '%(title)s.%(ext)s'];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
+16
View File
@@ -563,3 +563,19 @@ export function killPi(process: Subprocess): void {
// Already dead
}
}
/** Build env vars needed by Officer tools when running on the host. */
export async function buildHostToolEnv(userId: number, email: string): Promise<Record<string, string>> {
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken();
return {
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
OFFICER_USER_ROOT: join(DATA_PATH, email),
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
...browserRelayEnv,
};
}
+2 -2
View File
@@ -23,8 +23,8 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
try {
const models = await listPiModels();
// Build providerNames map for officer-local-* providers
const providerNames: Record<string, string> = {};
// Build providerNames map
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code' };
const localProviders = await readLocalProviders();
for (const lp of localProviders) {
providerNames[`officer-local-${lp.id}`] = lp.name;
+102 -11
View File
@@ -4,6 +4,7 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
import { sessionManager } from './session-manager';
import * as storage from './storage';
import * as piBridge from './pi-bridge';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { getHomeDir } from '../../../servers/data-path';
@@ -11,7 +12,7 @@ import { getUserSettings } from 'officerdb';
import { logger } from './logger';
// Default model when no user preference is set
const DEFAULT_MODEL = 'opencode/big-pickle';
const DEFAULT_MODEL = 'claude-code';
async function getUserDefaultModel(userId: number): Promise<string | null> {
try {
@@ -262,14 +263,18 @@ async function handleChat(
}
}
logger.info('Model selected for chat', {
sessionId,
model,
logger.info('Model selected for chat', {
sessionId,
model,
modelSource,
clientModel: msg.model || null,
userDefault,
});
if (model === 'claude-code') {
return handleClaudeCodeChat(ws, sessionId, model, msg);
}
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
const cwd = sandboxed
@@ -351,6 +356,86 @@ async function handleChat(
piBridge.sendPrompt(session.piProcess, msg.prompt, requestId);
}
async function handleClaudeCodeChat(
ws: ServerWebSocket<WSData>,
sessionId: string,
model: string,
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
// Claude Code always operates on the user's data home (not OS home).
// Resolve cwd relative to data directory, then remap for container if sandboxed.
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
let cwd: string;
if (sandboxed) {
const containerHome = `/home/${username}`;
cwd = dataCwd.startsWith(homeDir)
? `${containerHome}${dataCwd.slice(homeDir.length)}`
: containerHome;
} else {
cwd = dataCwd;
}
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.sandboxed = sandboxed;
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
// Add user message to session
const userMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'user',
text: msg.prompt,
};
session.messages.push(userMsg);
session.meta.messageCount += 1;
session.meta.updatedAt = Date.now();
if (!session.meta.title) {
session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100);
}
session.isGenerating = true;
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
try {
const handle = await sendClaudeCodeStreaming({
userId,
email,
username,
prompt: msg.prompt,
sessionKey: sessionId,
cwd,
sandboxed,
onEvent,
});
// Store proc as piProcess so handleStop can kill it
session.piProcess = handle.proc;
// Null out when process exits so next message spawns a new one
handle.proc.exited.then(() => {
if (session.piProcess === handle.proc) {
session.piProcess = null;
}
});
} catch (err) {
logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Claude Code' });
session.isGenerating = false;
}
}
async function handleResume(
ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
@@ -444,21 +529,27 @@ async function handleResume(
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {
const session = sessionManager.getSession(sessionId);
if (session?.piProcess) {
try {
piBridge.abort(session.piProcess, randomUUID());
logger.info('Sent abort to Pi process', { sessionId });
if (session.model === 'claude-code') {
// Claude Code: kill the docker exec process directly
session.piProcess.kill();
logger.info('Killed Claude Code process', { sessionId });
} else {
piBridge.abort(session.piProcess, randomUUID());
logger.info('Sent abort to Pi process', { sessionId });
}
session.isGenerating = false;
} catch (err) {
logger.error('Failed to abort Pi process', { sessionId, error: String(err) });
logger.error('Failed to stop process', { sessionId, error: String(err) });
}
}
}
sendToClient(ws, { type: 'stopped' });
}
@@ -65,6 +65,10 @@ ENV PATH="/usr/local/cargo/bin:${PATH}"
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
RUN sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' \
/usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js
WORKDIR /tmp
ENV TERMINAL_PTY_PORT=5337
+376 -2
View File
@@ -1,7 +1,13 @@
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { existsSync, symlinkSync, rmdirSync } from 'node:fs';
import { ensureDockerContainer } from '@@/api/terminal/websocket';
import { getHomeDir } from '@@/data-path';
import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
import { logger } from '@@/api/pi/logger';
import type { MessageCost } from '@@/api/pi/types';
import type { MessageCost, PiEvent } from '@@/api/pi/types';
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -36,6 +42,86 @@ export function clearClaudeCodeSession(sessionKey: string): void {
claudeCodeSessions.delete(sessionKey);
}
// ── Build dynamic system prompt from available tools & skills ──
async function buildToolsSystemPrompt(email: string): Promise<string | null> {
const [nativeTools, globalTools, userTools, nativeSkills, globalSkills, userSkills] = await Promise.all([
readToolDirs(getNativeToolsDir()),
readToolDirs(getGlobalToolsDir()),
readToolDirs(getUserToolsDir(email)),
readSkillDirs(getNativeSkillsDir()),
readSkillDirs(getGlobalSkillsDir()),
readSkillDirs(getUserSkillsDir(email)),
]);
// Merge tools (user overrides global overrides native)
const mergedTools = new Map(nativeTools);
for (const [name, path] of globalTools) mergedTools.set(name, path);
for (const [name, path] of userTools) mergedTools.set(name, path);
// Merge skills
const mergedSkills = new Map(nativeSkills);
for (const [name, path] of globalSkills) mergedSkills.set(name, path);
for (const [name, path] of userSkills) mergedSkills.set(name, path);
if (mergedTools.size === 0 && mergedSkills.size === 0) return null;
const sections: string[] = [
'# Officer Automation System',
'',
'You are running inside the Officer platform. Officer has its own automation concepts that are DIFFERENT from your built-in tools. When the user or a task references these, use the definitions below — do NOT map them to your own built-in concepts.',
'',
'- **Task**: A markdown file (TASK.md) with instructions for you to execute. When asked to "run a task", read the TASK.md file and follow its instructions step by step.',
'- **Skill**: A knowledge document (SKILL.md) that describes HOW to do something — APIs, commands, patterns. When a task says "use the X skill", follow the instructions from the matching skill section below.',
'- **Tool**: A capability defined by a TOOL.md and implemented in an index.ts file. Tools are NOT shell commands — do NOT try to call them by name. If the tool has a "Run" line below, execute it using that command, passing inputs as a JSON string argument. Only if there is no Run command should you replicate the behavior manually using the TOOL.md documentation.',
'',
'Skills and tools listed here are AVAILABLE to you. Follow their documentation directly.',
'',
];
if (mergedTools.size > 0) {
const toolLines: string[] = ['# Tools', ''];
const entries = await Promise.all(
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseToolFrontmatter(raw);
const toolDir = dirname(filePath);
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl };
}),
);
const runnerPath = `${getNativeToolsDir()}/run.ts`;
for (const tool of entries) {
toolLines.push(`## ${tool.name}`);
if (tool.hasImpl) toolLines.push(`Run: \`bun run ${runnerPath} ${tool.toolDir} '{"param":"value"}'\``);
if (tool.description) toolLines.push(tool.description);
if (tool.body.trim()) toolLines.push('', tool.body.trim());
toolLines.push('');
}
sections.push(toolLines.join('\n'));
}
if (mergedSkills.size > 0) {
const skillLines: string[] = ['# Skills', ''];
const entries = await Promise.all(
Array.from(mergedSkills.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseSkillFrontmatter(raw);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body };
}),
);
for (const skill of entries) {
skillLines.push(`## ${skill.name}`);
if (skill.description) skillLines.push(skill.description);
if (skill.body.trim()) skillLines.push('', skill.body.trim());
skillLines.push('');
}
sections.push(skillLines.join('\n'));
}
return sections.join('\n\n');
}
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
const { userId, email, username, prompt, sessionKey } = params;
const homeDir = getHomeDir(email);
@@ -137,3 +223,291 @@ export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCo
throw err;
}
}
// ── Streaming variant for Chat Panel WebSocket ──
type ClaudeCodeStreamingParams = {
userId: number;
email: string;
username: string;
prompt: string;
sessionKey: string;
cwd?: string;
sandboxed?: boolean;
onEvent: (event: PiEvent) => void;
};
type ClaudeCodeStreamingHandle = {
proc: ReturnType<typeof Bun.spawn>;
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params;
const claudeArgs = [
'claude', '-p', prompt,
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
'--verbose',
'--include-partial-messages',
];
const existingSession = claudeCodeSessions.get(sessionKey);
if (existingSession) {
claudeArgs.push('--resume', existingSession);
}
// Append dynamic system prompt with available tools & skills
const systemPrompt = await buildToolsSystemPrompt(email);
if (systemPrompt) {
claudeArgs.push('--append-system-prompt', systemPrompt);
}
let proc: ReturnType<typeof Bun.spawn>;
if (sandboxed) {
// Container execution via docker exec
const homeDir = getHomeDir(email);
const container = await ensureDockerContainer(email, userId, homeDir, username);
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${username}`;
const workDir = cwd ?? containerHome;
const args = [
dockerPath, 'exec',
'-u', username,
'-w', workDir,
'-e', `HOME=${containerHome}`,
containerId,
...claudeArgs,
];
logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null });
proc = Bun.spawn(args, {
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
});
} else {
// Host execution — run claude directly
const claudePath = Bun.which('claude') ?? 'claude';
claudeArgs[0] = claudePath;
const workDir = cwd ?? homedir();
logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null });
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const toolEnv = await buildHostToolEnv(userId, email);
// Ensure Claude Code can find ~/.claude credentials in the user's data home.
// Symlink the host's .claude config into the data home if not already there.
const dataHome = toolEnv.HOME!;
const hostClaudeConfig = join(homedir(), '.claude');
const targetClaudeConfig = join(dataHome, '.claude');
const targetCredentials = join(targetClaudeConfig, '.credentials.json');
if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) {
try {
// Remove empty placeholder dir if it exists, then symlink
if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig);
symlinkSync(hostClaudeConfig, targetClaudeConfig);
} catch { /* race, permission, or non-empty dir */ }
}
proc = Bun.spawn(claudeArgs, {
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
env: { ...cleanEnv, ...toolEnv },
});
}
const timeout = setTimeout(() => {
try { proc.kill(); } catch { /* already dead */ }
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
}, SEND_TIMEOUT_MS);
// Process NDJSON stream in background
(async () => {
try {
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
let gotResult = false;
let lineCount = 0;
const processLine = (line: string) => {
if (!line.trim()) return;
lineCount++;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const type = msg.type as string;
if (lineCount <= 5 || type === 'result') {
logger.info('Claude Code NDJSON', { sessionKey, lineCount, type, subtype: msg.subtype ?? null });
}
// stream_event — partial streaming (text deltas)
if (type === 'stream_event') {
const event = msg.event as Record<string, unknown> | undefined;
if (event?.type === 'content_block_delta') {
const delta = event.delta as Record<string, unknown> | undefined;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
}
}
}
// assistant — complete message with text and tool_use blocks
else if (type === 'assistant') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
// Full text block — emit as text event, reset streaming buffer
onEvent({ type: 'text', text: block.text });
textBuffer = '';
} else if (block.type === 'tool_use') {
// Flush any pending streamed text before tool
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
});
}
}
}
}
// user — tool results
else if (type === 'user') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
let output = '';
if (typeof block.content === 'string') {
output = block.content;
} else if (Array.isArray(block.content)) {
output = (block.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.join('\n');
}
onEvent({
type: 'tool:result',
toolCallId: (block.tool_use_id as string) ?? '',
output,
isError: (block.is_error as boolean) ?? false,
});
}
}
}
}
// system init — extract session_id for --resume
else if (type === 'system' && msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
}
// result — final
else if (type === 'result') {
gotResult = true;
clearTimeout(timeout);
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
if (isError) {
logger.info('Claude Code streaming error result', { sessionKey, error: resultText.slice(0, 500) });
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
const usage = msg.usage as Record<string, number> | undefined;
const cost: MessageCost = {
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
totalUSD: (msg.total_cost_usd as number) ?? 0,
};
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
logger.info('Claude Code streaming result', { sessionKey, cost: cost.totalUSD });
onEvent({ type: 'result', cost });
}
} catch {
// Skip malformed JSON lines
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
processLine(line);
}
}
// Process any remaining buffer
if (buffer.trim()) {
processLine(buffer);
}
logger.info('Claude Code stream ended', { sessionKey, totalLines: lineCount, gotResult });
clearTimeout(timeout);
// If process exited without a result event, emit error or synthetic result
if (!gotResult) {
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) });
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
}
if (exitCode !== 0) {
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
} else {
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
}
}
} catch (err) {
clearTimeout(timeout);
onEvent({ type: 'error', message: String(err) });
}
})();
return { proc };
}
+3
View File
@@ -27,6 +27,7 @@ import { queueRouter } from './api/queue/queue';
import { emailRouter } from './api/email/email';
import { channelsRouter } from './channels/routes';
import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest';
import { appsRouter, appServeRouter } from './api/apps';
import { bugReportRouter } from './api/bug-report/bug-report';
import { broadcastPanelRefresh } from './api/terminal/websocket';
@@ -99,6 +100,8 @@ protectedRouter.route('/channels', channelsRouter);
protectedRouter.route('/browser', browserRouter);
protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/bug-report', bugReportRouter);
desktopRouter.use(superAdminMiddleware);
protectedRouter.route('/desktop', desktopRouter);
protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter);
@@ -9,13 +9,14 @@ import { appRegistryMetas as projectMetas } from '../apps/Projects';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as previewMetas } from '../apps/Preview';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas];
export const AppRegistry = () => {
const { registerApp } = useAppRegistry(apps);
@@ -18,6 +18,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
bedrock: 'Amazon Bedrock',
'google-vertex': 'Google Vertex AI',
'azure-openai': 'Azure OpenAI',
'claude-code': 'Claude Code',
};
type ModelSelectorProps = {
@@ -0,0 +1,10 @@
import { MonitorSmartphone } from 'lucide-react';
export const DesktopHeader = () => {
return (
<>
<MonitorSmartphone className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Remote Desktop</span>
</>
);
};
@@ -0,0 +1,140 @@
import type { CSSProperties } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { useMounted } from 'hooks/useMounted';
export type DesktopViewProps = {
className?: string;
style?: CSSProperties;
};
const NOVNC_URL = '/novnc/rfb.js';
const buildWsUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
return `${protocol}//${window.location.host}/api/desktop/ws?token=${encodeURIComponent(token)}`;
};
type RFBInstance = {
resizeSession: boolean;
scaleViewport: boolean;
focusOnClick: boolean;
disconnect: () => void;
sendCredentials: (creds: { password: string }) => void;
addEventListener: (type: string, listener: (ev: CustomEvent) => void) => void;
};
let rfbModulePromise: Promise<{ default: new (target: HTMLElement, url: string, options?: { credentials?: { password?: string } }) => RFBInstance }> | null = null;
const loadRFB = () => {
if (!rfbModulePromise) {
rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as typeof rfbModulePromise;
}
return rfbModulePromise!;
};
export const DesktopView = ({ className, style }: DesktopViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const rfbRef = useRef<RFBInstance | null>(null);
const isMounted = useMounted();
const client = useClient();
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (!isMounted) return;
const container = containerRef.current;
if (!container) return;
let disposed = false;
const connect = async () => {
let password = '';
try {
const res = await client.get<{ password: string }>('/desktop/vnc-password');
password = res.password;
} catch {
if (disposed) return;
setStatus('error');
setErrorMsg('Failed to fetch VNC password');
return;
}
if (disposed) return;
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
try {
const mod = await loadRFB();
RFB = mod.default;
} catch (err) {
console.error('[desktop] Failed to load noVNC:', err);
if (disposed) return;
setStatus('error');
setErrorMsg('Failed to load noVNC library');
return;
}
if (disposed) return;
const wsUrl = buildWsUrl();
const rfb = new RFB(container, wsUrl, {
credentials: { password },
});
rfb.resizeSession = true;
rfb.scaleViewport = true;
rfb.focusOnClick = true;
rfbRef.current = rfb;
rfb.addEventListener('connect', () => {
if (!disposed) setStatus('connected');
});
rfb.addEventListener('disconnect', (ev: CustomEvent) => {
if (disposed) return;
setStatus('disconnected');
if (!ev.detail.clean) {
setErrorMsg('Connection lost');
}
});
rfb.addEventListener('credentialsrequired', () => {
rfb.sendCredentials({ password });
});
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
if (!disposed) {
setStatus('error');
setErrorMsg(ev.detail.reason || 'Security failure');
}
});
};
void connect();
return () => {
disposed = true;
if (rfbRef.current) {
try { rfbRef.current.disconnect(); } catch { /* ignore */ }
rfbRef.current = null;
}
};
}, [isMounted, client]);
return (
<div className={className} style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}>
{status === 'connecting' && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
Connecting to desktop...
</div>
)}
{(status === 'disconnected' || status === 'error') && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{errorMsg || 'Disconnected from desktop'}
</div>
)}
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
};
@@ -0,0 +1,16 @@
import { useAuth } from 'hooks/useAuth';
import { DesktopView } from './DesktopView';
export const DesktopWrapper = () => {
const { user } = useAuth();
if (user?.role !== 'Super Admin') {
return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
Remote Desktop requires Super Admin permissions.
</div>
);
}
return <DesktopView className="h-full w-full" />;
};
@@ -0,0 +1,17 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { MonitorSmartphone } from 'lucide-react';
import { DesktopWrapper } from './DesktopWrapper';
import { DesktopHeader } from './DesktopHeader';
export { DesktopView, type DesktopViewProps } from './DesktopView';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/desktop',
name: 'Remote Desktop',
icon: MonitorSmartphone,
component: DesktopWrapper,
header: DesktopHeader,
availableOnPanel: false,
},
];
@@ -1,4 +1,4 @@
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
@@ -124,10 +124,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
{/* <ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem> */}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { X, Play, Square, CircleCheck } from 'lucide-react';
import { X, Play, Square, CircleCheck, CircleX } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
@@ -54,7 +54,7 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
const seenResultRef = useRef(false);
const [, bump] = useState(0);
// Track tool/result messages from chat.messages (idempotent during render)
// Track tool/result/error messages from chat.messages (idempotent during render)
for (const m of chat.messages) {
if (m.role === 'tool' && 'toolCallId' in m) {
if (!seenToolIdsRef.current.has(m.toolCallId)) {
@@ -72,6 +72,10 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
seenResultRef.current = true;
accRef.current.push(m);
}
if (m.role === 'error') {
const alreadyHas = accRef.current.some((a) => a.role === 'error' && 'text' in a && a.text === m.text);
if (!alreadyHas) accRef.current.push(m);
}
}
// Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
@@ -170,6 +174,11 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
<Square className="h-3.5 w-3.5" />
Stop
</button>
) : accRef.current.some((m) => m.role === 'error') ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<CircleX className="h-4 w-4" />
Task failed
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<CircleCheck className="h-4 w-4" />
+2
View File
@@ -15,6 +15,8 @@ export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, F
export type { FileType } from './apps/FileViewer';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { DesktopView } from './apps/Desktop';
export type { DesktopViewProps } from './apps/Desktop';
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
+2 -1
View File
@@ -60,6 +60,7 @@ export function useVisiblePiModels() {
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
return models.filter((m) => {
if (m.provider === 'claude-code') return true;
const isExplicitlyAllowed = allowedSet.has(modelKey(m));
const isFromNewProvider = !allowedProviderSet.has(m.provider);
return isExplicitlyAllowed || isFromNewProvider;
@@ -75,5 +76,5 @@ export function useEnabledPiModels() {
if (allowed.length === 0) return models;
const allowedSet = new Set(allowed);
return models.filter((m) => allowedSet.has(modelKey(m)));
return models.filter((m) => m.provider === 'claude-code' || allowedSet.has(modelKey(m)));
}
+3 -3
View File
@@ -87,8 +87,8 @@ export type UserState = Record<string, unknown>;
export const DEFAULT_SETTINGS: UserSettings = {
chat: {
defaultProvider: 'pi',
defaultModel: null,
defaultProjectModel: null,
defaultModel: 'claude-code',
defaultProjectModel: 'claude-code',
systemPrompt: '',
temperature: 1,
defaultPwd: '~',
@@ -100,7 +100,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
},
tasks: {
defaultProvider: 'pi',
defaultModel: null,
defaultModel: 'claude-code',
},
appearance: {
colorMode: 'light',