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:
@@ -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 });
|
||||
});
|
||||
@@ -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() {},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user