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:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent 192ae63c0e
commit 0068244356
20 changed files with 1168 additions and 73 deletions
+13
View File
@@ -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');
+14 -4
View File
@@ -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];
}
}
+1 -1
View File
@@ -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
+5
View File
@@ -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
+25 -13
View File
@@ -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) {