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
+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