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:
@@ -69,7 +69,10 @@ export const AutomationEditChat = () => {
|
||||
chatSessionId={chatKey > 0 ? null : detail.chatSessionId}
|
||||
isNew={selection.isNew}
|
||||
description={selection.description}
|
||||
onResponseEnd={() => qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })}
|
||||
onResponseEnd={() => {
|
||||
qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] });
|
||||
qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,217 @@ type CapabilityChatProps = {
|
||||
onResponseEnd?: () => void;
|
||||
};
|
||||
|
||||
const buildTaskCreationPrefix = (filePath: string, resourceDir: string) =>
|
||||
`<frontmatter>
|
||||
input file: ${filePath}
|
||||
TASK.md: ${filePath}
|
||||
dir: ${resourceDir}
|
||||
|
||||
Be aware of any extra files alongside the same dir as the task file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
||||
</frontmatter>
|
||||
|
||||
<task-creation-guide>
|
||||
You are helping create a new task. Gather requirements through a short conversation BEFORE writing the TASK.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
||||
|
||||
Conversation flow:
|
||||
1. First, ask what the task should do — its purpose and high-level steps.
|
||||
2. Based on the answer, ask about triggers: should it appear in the file browser context menu for specific file types? For directories? Or only be runnable from the Automation page?
|
||||
3. Then ask if it needs user inputs (parameters) when running, and if so what kind (text, number, yes/no toggle, dropdown).
|
||||
4. If anything is still unclear, ask a follow-up. Otherwise, write the TASK.md.
|
||||
|
||||
Rules:
|
||||
- Never ask all questions at once. Keep it conversational.
|
||||
- Each message should have at most 1-2 questions on the same topic.
|
||||
- Summarize what you understood before writing the file so the user can confirm.
|
||||
|
||||
## TASK.md Format
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: Task Name
|
||||
description: Short description of what the task does.
|
||||
version: 1
|
||||
author: pastilhas
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
skills:
|
||||
- skill-name # optional — skills the agent can use
|
||||
tools:
|
||||
- tool_name # optional — tools the agent can call
|
||||
trigger: # optional — when omitted, only runnable from Automation page
|
||||
- type: file
|
||||
extensions:
|
||||
- mp3
|
||||
- flac
|
||||
- type: directory
|
||||
inputs: # optional — parameters the user fills in before running
|
||||
- name: param_name
|
||||
description: What this parameter is for.
|
||||
type: string # string (default) | number | boolean | select
|
||||
required: true
|
||||
default: some value
|
||||
# select example:
|
||||
- name: country
|
||||
description: Country to use.
|
||||
type: select
|
||||
default: US
|
||||
options:
|
||||
- value: US
|
||||
label: United States
|
||||
- value: PT
|
||||
label: Portugal
|
||||
# number example:
|
||||
- name: limit
|
||||
type: number
|
||||
default: 20
|
||||
min: 1
|
||||
max: 100
|
||||
# boolean example:
|
||||
- name: download
|
||||
type: boolean
|
||||
default: false
|
||||
---
|
||||
|
||||
(Markdown body with detailed instructions for the agent executing the task)
|
||||
\`\`\`
|
||||
|
||||
## Trigger rules
|
||||
- \`type: file\` + \`extensions\` → appears in file browser context menu for those file types
|
||||
- \`type: directory\` → appears on right-click directories
|
||||
- Both can coexist in the same task
|
||||
- No triggers → task is only runnable from the Automation page
|
||||
|
||||
## Notes
|
||||
- Tasks run inside the user's sandboxed container
|
||||
- The markdown body after the frontmatter should contain step-by-step instructions for the agent
|
||||
</task-creation-guide>`;
|
||||
|
||||
const buildSkillCreationPrefix = (filePath: string, resourceDir: string) =>
|
||||
`<frontmatter>
|
||||
input file: ${filePath}
|
||||
SKILL.md: ${filePath}
|
||||
dir: ${resourceDir}
|
||||
|
||||
Be aware of any extra files alongside the same dir as the skill file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
||||
</frontmatter>
|
||||
|
||||
<skill-creation-guide>
|
||||
You are helping create a new skill. A skill is a reference document (knowledge base) that the agent can consult when performing tasks. Gather requirements through a short conversation BEFORE writing the SKILL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
||||
|
||||
Conversation flow:
|
||||
1. First, ask what technology, API, or domain this skill covers — what should the agent know about?
|
||||
2. Ask what key information should be included: API reference, code examples, common patterns, gotchas?
|
||||
3. If it's for a specific library or tool, ask for the version and any project-specific conventions.
|
||||
4. Summarize what you understood before writing the file so the user can confirm.
|
||||
|
||||
Rules:
|
||||
- Never ask all questions at once. Keep it conversational.
|
||||
- Each message should have at most 1-2 questions on the same topic.
|
||||
- Summarize what you understood before writing the file so the user can confirm.
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: When to use this skill — a sentence describing the domain and trigger conditions.
|
||||
---
|
||||
|
||||
(Comprehensive reference documentation in markdown — API docs, code examples, recipes, best practices)
|
||||
\`\`\`
|
||||
|
||||
## Notes
|
||||
- The frontmatter only needs \`name\` and \`description\`
|
||||
- The description should tell the agent WHEN to consult this skill (e.g. "Use when the user wants to process images with sharp")
|
||||
- The markdown body is the actual knowledge — be thorough, include code examples and common recipes
|
||||
- Skills are referenced by name in TASK.md \`skills:\` fields
|
||||
</skill-creation-guide>`;
|
||||
|
||||
const buildToolCreationPrefix = (filePath: string, resourceDir: string) =>
|
||||
`<frontmatter>
|
||||
input file: ${filePath}
|
||||
TOOL.md: ${filePath}
|
||||
dir: ${resourceDir}
|
||||
|
||||
Be aware of any extra files alongside the same dir as the tool file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.
|
||||
</frontmatter>
|
||||
|
||||
<tool-creation-guide>
|
||||
You are helping create a new tool. A tool is an executable function the agent can call. Gather requirements through a short conversation BEFORE writing the TOOL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on.
|
||||
|
||||
Conversation flow:
|
||||
1. First, ask what the tool should do — what action does it perform?
|
||||
2. Ask what inputs (parameters) it needs and their types.
|
||||
3. Ask what language it should be implemented in (TypeScript, Bash, or Python) and whether it needs any external APIs or services.
|
||||
4. If anything is still unclear, ask a follow-up. Otherwise, write the TOOL.md.
|
||||
|
||||
Rules:
|
||||
- Never ask all questions at once. Keep it conversational.
|
||||
- Each message should have at most 1-2 questions on the same topic.
|
||||
- Summarize what you understood before writing the file so the user can confirm.
|
||||
|
||||
## TOOL.md Format
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: tool_name
|
||||
label: Tool Display Name
|
||||
description: What the tool does and when to use it.
|
||||
language: typescript # typescript | bash | python
|
||||
inputs:
|
||||
param_name:
|
||||
type: string # string | number | boolean | enum | object
|
||||
description: What this parameter is for.
|
||||
optional_param:
|
||||
type: string
|
||||
description: An optional parameter.
|
||||
optional: true
|
||||
secret_param:
|
||||
type: string
|
||||
description: A sensitive parameter (e.g. API key).
|
||||
optional: true
|
||||
sensitive: true
|
||||
choice_param:
|
||||
type: enum
|
||||
description: A parameter with fixed options.
|
||||
values:
|
||||
- option_a
|
||||
- option_b
|
||||
---
|
||||
|
||||
(Markdown body with documentation: usage notes, output format, error handling, examples)
|
||||
\`\`\`
|
||||
|
||||
## Input types
|
||||
- \`string\` — free text (default)
|
||||
- \`number\` — numeric value
|
||||
- \`boolean\` — true/false
|
||||
- \`enum\` — fixed set of values (list under \`values:\`)
|
||||
- \`object\` — JSON object
|
||||
|
||||
## Notes
|
||||
- Tools run inside the user's sandboxed container
|
||||
- The \`name\` field uses snake_case (this is the function name the agent calls)
|
||||
- The \`label\` field is the human-readable display name
|
||||
- Mark parameters as \`optional: true\` when they have sensible defaults
|
||||
- Mark credentials/keys as \`sensitive: true\` so they aren't logged
|
||||
- Tools are referenced by name in TASK.md \`tools:\` fields
|
||||
</tool-creation-guide>`;
|
||||
|
||||
const buildCreationPrefix = (kind: string, filePath: string, resourceDir: string) => {
|
||||
switch (kind) {
|
||||
case 'task':
|
||||
return buildTaskCreationPrefix(filePath, resourceDir);
|
||||
case 'skill':
|
||||
return buildSkillCreationPrefix(filePath, resourceDir);
|
||||
case 'tool':
|
||||
return buildToolCreationPrefix(filePath, resourceDir);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const CapabilityChat = ({
|
||||
kind,
|
||||
filePath,
|
||||
@@ -64,7 +275,8 @@ export const CapabilityChat = ({
|
||||
onResponseEnd,
|
||||
}: CapabilityChatProps) => {
|
||||
const seedFile = `${kind.toUpperCase()}.md`;
|
||||
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||
const promptFrontmatter = isNew ? buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix : genericPrefix;
|
||||
const defaultInput = isNew
|
||||
? description ?? `Help me create the content for this new ${kind} file`
|
||||
: `Help me understand and improve this ${kind} file`;
|
||||
|
||||
@@ -217,7 +217,9 @@ export const EmailList = () => {
|
||||
<div className="flex flex-1 items-center justify-center text-sm opacity-40">No emails in this folder</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => (
|
||||
{messages.map((msg: EmailSummary) => {
|
||||
const unread = !msg.read;
|
||||
return (
|
||||
<button
|
||||
key={msg.id}
|
||||
data-email-id={msg.id}
|
||||
@@ -227,10 +229,10 @@ export const EmailList = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{msg.from}</span>
|
||||
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span>
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className="truncate text-sm">{msg.subject}</span>
|
||||
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{!!msg.attachmentCount && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
||||
@@ -241,7 +243,8 @@ export const EmailList = () => {
|
||||
<span className="truncate opacity-50">{msg.snippet}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
@@ -43,6 +43,7 @@ const HtmlBody = ({ html }: { html: string }) => {
|
||||
|
||||
export const EmailReader = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
|
||||
|
||||
@@ -52,6 +53,14 @@ export const EmailReader = () => {
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
// Mark as read when message loads
|
||||
useEffect(() => {
|
||||
if (!message || message.read) return;
|
||||
client.patch(`/email/messages/${message.id}/read`).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||
}).catch(() => {});
|
||||
}, [message?.id]);
|
||||
|
||||
const extractAttachment = async (index: number) => {
|
||||
if (!selectedId) return;
|
||||
const result = await client.post<OpenAttachment>(`/email/messages/${selectedId}/attachments/${index}/extract`);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -9,9 +9,10 @@ type CommandTerminalWrapperProps = {
|
||||
panelId: string;
|
||||
command: string;
|
||||
statePrefix: string;
|
||||
onPanelRefresh?: () => void;
|
||||
};
|
||||
|
||||
export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: CommandTerminalWrapperProps) => {
|
||||
export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelRefresh }: CommandTerminalWrapperProps) => {
|
||||
const { dashboardId, cwd } = useWorkspace();
|
||||
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
@@ -37,8 +38,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: Comman
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const cwdPath = cwd && cwd !== '~' ? `~/${cwd.replace(/^\//, '')}` : null;
|
||||
const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null;
|
||||
const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} onPanelRefresh={onPanelRefresh} />;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TerminalSquare, Monitor, Columns2, PenLine } from 'lucide-react';
|
||||
import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles } from 'lucide-react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTerminalMode } from './useTerminalMode';
|
||||
@@ -61,3 +61,14 @@ export const NvimHeader = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClaudeCodeHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Claude Code</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export type TerminalViewProps = {
|
||||
onExit?: () => void;
|
||||
onCommandDone?: (exitCode: number, output: string) => void;
|
||||
onDisconnect?: () => void;
|
||||
onPanelRefresh?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||
@@ -69,6 +70,7 @@ export const TerminalView = ({
|
||||
onExit,
|
||||
onCommandDone,
|
||||
onDisconnect,
|
||||
onPanelRefresh,
|
||||
}: TerminalViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
@@ -78,6 +80,7 @@ export const TerminalView = ({
|
||||
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
||||
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||
const onPanelRefreshRef = useRef<TerminalViewProps['onPanelRefresh']>(onPanelRefresh);
|
||||
const commandRef = useRef(command);
|
||||
const initialInputRef = useRef(initialInput);
|
||||
|
||||
@@ -85,6 +88,7 @@ export const TerminalView = ({
|
||||
onExitRef.current = onExit;
|
||||
onCommandDoneRef.current = onCommandDone;
|
||||
onDisconnectRef.current = onDisconnect;
|
||||
onPanelRefreshRef.current = onPanelRefresh;
|
||||
commandRef.current = command;
|
||||
initialInputRef.current = initialInput;
|
||||
|
||||
@@ -188,6 +192,8 @@ export const TerminalView = ({
|
||||
onExitRef.current?.();
|
||||
} else if (msg.type === 'detached') {
|
||||
term.write('\r\n[Session taken over]\r\n');
|
||||
} else if (msg.type === 'panel-refresh') {
|
||||
onPanelRefreshRef.current?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { TerminalSquare, Monitor, Columns2, PenLine } from 'lucide-react';
|
||||
import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { TerminalWrapper } from './TerminalWrapper';
|
||||
import { HostTerminalWrapper } from './HostTerminalWrapper';
|
||||
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
||||
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader } from './Headers';
|
||||
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers';
|
||||
|
||||
export { TerminalView, type TerminalViewProps } from './Terminal';
|
||||
|
||||
@@ -15,6 +17,25 @@ const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
||||
);
|
||||
|
||||
const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||
|
||||
const onPanelRefresh = useCallback(() => {
|
||||
setPreviewRefresh(Date.now());
|
||||
setFilesRefresh(Date.now());
|
||||
}, [setPreviewRefresh, setFilesRefresh]);
|
||||
|
||||
return (
|
||||
<CommandTerminalWrapper
|
||||
panelId={panelId}
|
||||
command="claude --dangerously-skip-permissions"
|
||||
statePrefix="claude-code"
|
||||
onPanelRefresh={onPanelRefresh}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/terminal',
|
||||
@@ -45,4 +66,11 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
component: NvimWrapper,
|
||||
header: NvimHeader,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/claude-code',
|
||||
name: 'Claude Code',
|
||||
icon: Sparkles,
|
||||
component: ClaudeCodeWrapper,
|
||||
header: ClaudeCodeHeader,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user