claude-code as channel model, container trust/permissions fixes, terminal cwd fix
- add claude-code as virtual model in channel messaging (telegram/discord/whatsapp) - new send-claude-code.ts: docker exec claude -p with session resumption - route claude-code model in sendAndAwait before Pi pipeline - append claude-code to listPiModels output - fix container .claude mount (rw for sub-mounts), hooks format (matcher-based) - pre-seed hasTrustDialogAccepted and bypassPermissions in container settings - git init in entrypoint to skip workspace trust prompt - fix ~/~ double-tilde in CommandTerminalWrapper cwd resolution - remove --continue from claude-code panel command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,19 @@ emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
emailRouter.patch('/messages/:id/read', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
db.run('UPDATE emails SET read = 1 WHERE id = ? AND read = 0', [id]);
|
||||
return ctx.json({ ok: true });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
emailRouter.delete('/messages/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { ModelInfo } from './types';
|
||||
import { PI_CONFIG_DIR } from '../../data-path';
|
||||
import { logger } from './logger';
|
||||
import type { ModelInfo } from './types';
|
||||
|
||||
const CLAUDE_CODE_MODEL: ModelInfo = {
|
||||
id: 'claude-code',
|
||||
name: 'claude-code',
|
||||
provider: 'claude-code',
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
reasoning: true,
|
||||
images: true,
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -26,7 +36,7 @@ const parseSize = (s?: string): number => {
|
||||
|
||||
export async function listPiModels(): Promise<ModelInfo[]> {
|
||||
if (cachedModels && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||
return cachedModels;
|
||||
return [...cachedModels, CLAUDE_CODE_MODEL];
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -99,9 +109,9 @@ export async function listPiModels(): Promise<ModelInfo[]> {
|
||||
logger.info('pi --list-models returned', { count: models.length });
|
||||
cachedModels = models;
|
||||
cacheTimestamp = Date.now();
|
||||
return models;
|
||||
return [...models, CLAUDE_CODE_MODEL];
|
||||
} catch (err) {
|
||||
logger.error('Failed to run pi --list-models', { error: String(err) });
|
||||
return [];
|
||||
return [CLAUDE_CODE_MODEL];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ export async function spawnPi(
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
|
||||
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
|
||||
'-e', `OFFICER_EMAIL_DB=/officer/data/emails.db`,
|
||||
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
|
||||
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
|
||||
];
|
||||
|
||||
@@ -63,7 +63,7 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --de
|
||||
|
||||
ENV PATH="/usr/local/cargo/bin:${PATH}"
|
||||
|
||||
RUN npm install -g @mariozechner/pi-coding-agent
|
||||
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
|
||||
@@ -60,5 +60,10 @@ chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.local
|
||||
mkdir -p /home/$USERNAME/.pi/agent/sessions
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.pi
|
||||
|
||||
# Init git repo in home dir so Claude Code skips the workspace trust prompt
|
||||
if [ ! -d /home/$USERNAME/.git ]; then
|
||||
gosu "$USER_UID:$USER_GID" git init /home/$USERNAME >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Run sidecar as the user
|
||||
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
|
||||
import { getUsers } from 'officerdb';
|
||||
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
|
||||
|
||||
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
|
||||
|
||||
@@ -114,10 +115,10 @@ const containerHasExpectedMounts = (dockerId: string): boolean => {
|
||||
});
|
||||
if (result.exitCode !== 0) return false;
|
||||
const mounts = result.stdout.toString();
|
||||
return mounts.includes(getGlobalSkillsDir());
|
||||
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('/officer/data') && mounts.includes('.claude');
|
||||
};
|
||||
|
||||
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string): Promise<{ dockerId: string }> => {
|
||||
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string, contextFile?: string, settingsFile?: string): Promise<{ dockerId: string }> => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
@@ -160,6 +161,8 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
||||
`TERMINAL_UID=${uid}`,
|
||||
'-e',
|
||||
`TERMINAL_GID=${gid}`,
|
||||
'-e',
|
||||
`OFFICER_EMAIL=${email}`,
|
||||
'-v', `${homeDir}:${containerHome}`,
|
||||
'-v', `${getGlobalSkillsDir()}:/officer/skills:ro`,
|
||||
'-v', `${getGlobalToolsDir()}:/officer/tools:ro`,
|
||||
@@ -170,7 +173,10 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
||||
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
|
||||
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
|
||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||
'-v', `${join(DATA_PATH, email, 'emails.db')}:/officer/emails.db`,
|
||||
'-v', `${join(DATA_PATH, email)}:/officer/data`,
|
||||
...(existsSync(join(process.env.HOME ?? '', '.claude')) ? ['-v', `${join(process.env.HOME!, '.claude')}:${containerHome}/.claude`] : []),
|
||||
...(contextFile && existsSync(contextFile) ? ['-v', `${contextFile}:${containerHome}/.claude/CLAUDE.md:ro`] : []),
|
||||
...(settingsFile && existsSync(settingsFile) ? ['-v', `${settingsFile}:${containerHome}/.claude/settings.json:ro`] : []),
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
],
|
||||
@@ -240,7 +246,7 @@ const dockerStart = (dockerId: string) => {
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string, contextFile?: string, settingsFile?: string) => {
|
||||
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
|
||||
// Must check BEFORE mkdirSync overwrites them
|
||||
const skillsDir = getUserSkillsDir(email);
|
||||
@@ -257,12 +263,6 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
|
||||
|
||||
// Ensure emails.db exists as a file before mount (Docker creates a directory if missing)
|
||||
const emailsDbPath = join(DATA_PATH, email, 'emails.db');
|
||||
if (!existsSync(emailsDbPath)) {
|
||||
writeFileSync(emailsDbPath, '');
|
||||
}
|
||||
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
|
||||
@@ -288,7 +288,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = await startDockerSidecar(port, homeDir, userId, username, email);
|
||||
const docker = await startDockerSidecar(port, homeDir, userId, username, email, contextFile, settingsFile);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
@@ -354,8 +354,11 @@ export const initTerminalSidecars = async () => {
|
||||
const homeDir = getHomeDir(user.email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const shellUsername = toShellUsername(user.username ?? '', user.email);
|
||||
const contextFile = generateContainerContext(user.email);
|
||||
const settingsFile = generateClaudeSettings(user.email, shellUsername);
|
||||
try {
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, toShellUsername(user.username ?? '', user.email));
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, shellUsername, contextFile, settingsFile);
|
||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||
} catch (err) {
|
||||
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
|
||||
@@ -439,7 +442,7 @@ export const terminalWebsocket = {
|
||||
let sidecar: WebSocket | null = null;
|
||||
let info: ContainerInfo | undefined;
|
||||
try {
|
||||
info = await ensureDockerContainer(email, ws.data.userId, cwd, username);
|
||||
info = await ensureDockerContainer(email, ws.data.userId, cwd, username, generateContainerContext(email), generateClaudeSettings(email, username));
|
||||
sidecar = await connectSidecar(info.port);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
||||
@@ -524,6 +527,15 @@ export const terminalWebsocket = {
|
||||
drain() {},
|
||||
};
|
||||
|
||||
export const broadcastPanelRefresh = (email: string) => {
|
||||
const msg = JSON.stringify({ type: 'panel-refresh' });
|
||||
for (const [ws, session] of sessions) {
|
||||
if (ws.data.email === email && session.sidecar) {
|
||||
try { ws.send(msg); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stopAllContainers = async () => {
|
||||
// Stop host sidecar
|
||||
if (hostSidecarProcess) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as piBridge from '@@/api/pi/pi-bridge';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { logger } from '@@/api/pi/logger';
|
||||
import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code';
|
||||
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
@@ -63,6 +64,7 @@ export function setSessionModel(context: string, userId: number, contextId: stri
|
||||
const sessionId = buildSessionId(context, userId, contextId);
|
||||
// Store override independently of session — survives idle eviction
|
||||
channelModelOverrides.set(sessionId, model);
|
||||
clearClaudeCodeSession(sessionId);
|
||||
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session) {
|
||||
@@ -94,6 +96,20 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndA
|
||||
await existing;
|
||||
|
||||
try {
|
||||
// Resolve model early to check for claude-code routing
|
||||
const override = channelModelOverrides.get(sessionId);
|
||||
const resolvedModel = params.model ?? override ?? (await getUserDefaultModel(params.userId)) ?? DEFAULT_MODEL;
|
||||
|
||||
if (resolvedModel === 'claude-code') {
|
||||
return await sendClaudeCode({
|
||||
userId: params.userId,
|
||||
email: params.email,
|
||||
username: params.username,
|
||||
prompt: params.prompt,
|
||||
sessionKey: sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return await doSend(sessionId, params);
|
||||
} finally {
|
||||
releaseLock!();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ensureDockerContainer } from '@@/api/terminal/websocket';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { logger } from '@@/api/pi/logger';
|
||||
import type { MessageCost } from '@@/api/pi/types';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type ClaudeCodeParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
type ClaudeCodeResult = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
type ClaudeCodeOutput = {
|
||||
result: string;
|
||||
session_id: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
is_error: boolean;
|
||||
};
|
||||
|
||||
// Map channel session key → Claude Code session ID for --resume
|
||||
const claudeCodeSessions = new Map<string, string>();
|
||||
|
||||
export function clearClaudeCodeSession(sessionKey: string): void {
|
||||
claudeCodeSessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
||||
const { userId, email, username, prompt, sessionKey } = params;
|
||||
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 args = [
|
||||
dockerPath, 'exec', '-i',
|
||||
'-u', username,
|
||||
'-w', containerHome,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
containerId,
|
||||
'claude', '-p', prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'json',
|
||||
];
|
||||
|
||||
const existingSession = claudeCodeSessions.get(sessionKey);
|
||||
if (existingSession) {
|
||||
args.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
logger.info('Claude Code exec', { sessionKey, containerId, resume: existingSession ?? null });
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (stderr.trim()) {
|
||||
logger.info('Claude Code stderr', { text: stderr.trim().slice(0, 500) });
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
let output: ClaudeCodeOutput;
|
||||
try {
|
||||
output = JSON.parse(stdout) as ClaudeCodeOutput;
|
||||
} catch {
|
||||
// Non-JSON output — treat raw stdout as result text
|
||||
return {
|
||||
text: stdout.trim() || '(no response)',
|
||||
sessionId: sessionKey,
|
||||
model: 'claude-code',
|
||||
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
if (output.is_error) {
|
||||
throw new Error(output.result || 'Claude Code returned an error');
|
||||
}
|
||||
|
||||
// Store session for --resume on next message
|
||||
if (output.session_id) {
|
||||
claudeCodeSessions.set(sessionKey, output.session_id);
|
||||
}
|
||||
|
||||
const cost: MessageCost = {
|
||||
inputTokens: output.input_tokens ?? 0,
|
||||
outputTokens: output.output_tokens ?? 0,
|
||||
totalUSD: output.cost_usd ?? 0,
|
||||
};
|
||||
|
||||
logger.info('Claude Code result', {
|
||||
sessionKey,
|
||||
sessionId: output.session_id,
|
||||
cost: cost.totalUSD,
|
||||
tokens: cost.inputTokens + cost.outputTokens,
|
||||
});
|
||||
|
||||
return {
|
||||
text: output.result || '(no response)',
|
||||
sessionId: sessionKey,
|
||||
model: 'claude-code',
|
||||
cost,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getGlobalToolsDir, getUserToolsDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalTasksDir, getUserTasksDir, getGlobalResourcesDir, getHomeDir, DATA_PATH } from '@@/data-path';
|
||||
|
||||
type HookEntry = { type: string; command: string };
|
||||
type HookRule = { matcher?: Record<string, unknown>; hooks: HookEntry[] };
|
||||
type ClaudeSettings = Record<string, unknown> & {
|
||||
hooks?: Record<string, HookRule[]>;
|
||||
};
|
||||
|
||||
type FrontmatterEntry = { name: string; description: string };
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1]!.split('\n')) {
|
||||
const m = line.match(/^(\w+):\s*(.+)/);
|
||||
if (m) fields[m[1]!] = m[2]!.trim();
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function scanDir(dir: string, metaFile: string): FrontmatterEntry[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const entries: FrontmatterEntry[] = [];
|
||||
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!name.isDirectory()) continue;
|
||||
const filePath = join(dir, name.name, metaFile);
|
||||
if (!existsSync(filePath)) continue;
|
||||
const fm = parseFrontmatter(readFileSync(filePath, 'utf-8'));
|
||||
if (fm.name || fm.label) {
|
||||
entries.push({ name: fm.name ?? fm.label ?? name.name, description: fm.description ?? '' });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function dedup(entries: FrontmatterEntry[]): FrontmatterEntry[] {
|
||||
const seen = new Map<string, FrontmatterEntry>();
|
||||
for (const e of entries) seen.set(e.name, e);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function formatList(entries: FrontmatterEntry[]): string {
|
||||
if (entries.length === 0) return 'None configured.\n';
|
||||
return entries.map((e) => `- **${e.name}**${e.description ? ` — ${e.description}` : ''}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
export function generateContainerContext(email: string): string {
|
||||
const tools = dedup([...scanDir(getGlobalToolsDir(), 'TOOL.md'), ...scanDir(getUserToolsDir(email), 'TOOL.md')]);
|
||||
const skills = dedup([...scanDir(getGlobalSkillsDir(), 'SKILL.md'), ...scanDir(getUserSkillsDir(email), 'SKILL.md')]);
|
||||
const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]);
|
||||
const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md');
|
||||
|
||||
const content = `# Officer — Container Environment
|
||||
|
||||
This is a sandboxed development container managed by the Officer platform.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| \`~\` | User home directory (read-write) |
|
||||
| \`~/Projects/\` | User projects |
|
||||
| \`~/Downloads/\` | Downloaded files |
|
||||
| \`/officer/tools/\` | Global tools (read-only) |
|
||||
| \`/officer/user/tools/\` | User tools (read-only) |
|
||||
| \`/officer/skills/\` | Reference skills (read-only) |
|
||||
| \`/officer/data/\` | User data (emails.db, attachments, etc.) |
|
||||
|
||||
## Available Tools
|
||||
|
||||
Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`/officer/tools/<name>/TOOL.md\` or \`/officer/user/tools/<name>/TOOL.md\`.
|
||||
|
||||
${formatList(tools)}
|
||||
## Available Skills
|
||||
|
||||
Skills are reference documentation that the AI agent uses to understand APIs and CLIs.
|
||||
|
||||
${formatList(skills)}
|
||||
## Available Tasks
|
||||
|
||||
Tasks are predefined instruction sets the AI agent can execute.
|
||||
|
||||
${formatList(tasks)}
|
||||
## Configured Resources
|
||||
|
||||
Resources are external service integrations (TTS, STT, OCR, etc.) configured in Settings.
|
||||
|
||||
${formatList(resources)}
|
||||
## Creating New Tools
|
||||
|
||||
Create a directory in \`/officer/user/tools/<tool-name>/\` with two files:
|
||||
|
||||
**TOOL.md** — Frontmatter metadata + markdown documentation:
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: my_tool
|
||||
description: What it does and when the agent should use it.
|
||||
version: 1
|
||||
language: typescript
|
||||
inputs:
|
||||
param:
|
||||
type: string
|
||||
description: What this parameter is for.
|
||||
---
|
||||
# My Tool
|
||||
Usage documentation here.
|
||||
\`\`\`
|
||||
|
||||
**index.ts** — Must export an \`execute\` function:
|
||||
\`\`\`typescript
|
||||
type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean };
|
||||
|
||||
export async function execute(_toolCallId: string, params: Record<string, unknown>): Promise<ToolResult> {
|
||||
return { content: [{ type: 'text', text: 'Done' }] };
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Full guide: \`/officer/tools/TOOLS.md\`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`OFFICER_EMAIL_DB\` | Path to email SQLite database |
|
||||
| \`OFFICER_RESOURCES\` | JSON with configured resource integrations |
|
||||
| \`PI_TOOLS_DIRS\` | Tool discovery paths (colon-separated) |
|
||||
| \`PI_SEARXNG_URL\` | Search engine URL |
|
||||
`;
|
||||
|
||||
const contextDir = join(DATA_PATH, email, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
const filePath = join(contextDir, 'CLAUDE.md');
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function generateClaudeSettings(email: string, username?: string): string {
|
||||
const hostSettingsPath = join(process.env.HOME ?? '', '.claude', 'settings.json');
|
||||
let settings: ClaudeSettings = {};
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(hostSettingsPath, 'utf-8')) as ClaudeSettings;
|
||||
} catch {
|
||||
// no host settings
|
||||
}
|
||||
|
||||
const hookCommand = `curl -s -X POST http://localhost:5000/api/hooks/claude-done -H 'Content-Type: application/json' -d '{"email":"${email}"}'`;
|
||||
const hooks = settings.hooks ?? {};
|
||||
const stopRules = hooks.Stop ?? [];
|
||||
const hasOurHook = stopRules.some((rule) => rule.hooks?.some((h) => h.command?.includes('/api/hooks/claude-done')));
|
||||
|
||||
if (!hasOurHook) {
|
||||
stopRules.push({ hooks: [{ type: 'command', command: hookCommand }] });
|
||||
}
|
||||
|
||||
hooks.Stop = stopRules;
|
||||
settings.hooks = hooks;
|
||||
settings.defaultMode = 'bypassPermissions';
|
||||
settings.skipDangerousModePermissionPrompt = true;
|
||||
|
||||
const contextDir = join(DATA_PATH, email, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
const filePath = join(contextDir, 'settings.json');
|
||||
writeFileSync(filePath, JSON.stringify(settings, null, 2));
|
||||
|
||||
// Pre-seed trust and skip-permissions in .claude.json so interactive Claude Code skips all prompts
|
||||
if (username) {
|
||||
const containerHome = `/home/${username}`;
|
||||
const claudeJsonPath = join(getHomeDir(email), '.claude.json');
|
||||
let claudeJson: Record<string, unknown> = {};
|
||||
try {
|
||||
claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// no existing config
|
||||
}
|
||||
const projects = (claudeJson.projects ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const projectKey = containerHome;
|
||||
if (!projects[projectKey]) projects[projectKey] = {};
|
||||
projects[projectKey]!.hasTrustDialogAccepted = true;
|
||||
claudeJson.projects = projects;
|
||||
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { appsRouter, appServeRouter } from './api/apps';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||
|
||||
@@ -56,6 +57,14 @@ honoServer.route('/api/waitlist', waitlistRouter);
|
||||
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
||||
honoServer.route('/api/app-serve', appServeRouter);
|
||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||
honoServer.post('/api/hooks/claude-done', async (ctx) => {
|
||||
const body = await ctx.req.json().catch(() => null);
|
||||
const email = (body as Record<string, unknown> | null)?.email;
|
||||
if (typeof email === 'string' && email.includes('@')) {
|
||||
broadcastPanelRefresh(email);
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => {
|
||||
const { readServerSettings } = await import('officerdb');
|
||||
const settings = await readServerSettings();
|
||||
|
||||
@@ -29,6 +29,8 @@ export type Job = {
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
retryAt?: number;
|
||||
retries?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
notify?: boolean;
|
||||
};
|
||||
@@ -45,9 +47,15 @@ export type JobHandlerStep = {
|
||||
run: (ctx: StepContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export type RetryConfig = {
|
||||
delayMs: number;
|
||||
maxRetries: number;
|
||||
};
|
||||
|
||||
export type JobHandler = {
|
||||
type: string;
|
||||
steps: JobHandlerStep[];
|
||||
retry?: RetryConfig;
|
||||
};
|
||||
|
||||
export type EnqueueParams = {
|
||||
|
||||
Reference in New Issue
Block a user