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:
+464
-41
@@ -1,23 +1,96 @@
|
||||
# Tools
|
||||
|
||||
A tool is a callable capability that agents can use during task execution. Each tool lives in its own directory under `tools/` and is defined by a `TOOL.md` file and an `index.ts` (or `index.js`) entry point.
|
||||
A tool is a callable capability that agents can use during task execution. Each tool lives in its own directory and is defined by a `TOOL.md` file and an `index.ts` (or `index.js`) entry point.
|
||||
|
||||
Tools are loaded by the `tool-loader` extension at startup and registered as callable functions. They appear in the agent's system prompt and can be invoked by name.
|
||||
Tools are loaded by the `tool-loader` extension at startup. They appear in the agent's system prompt and can be invoked by name.
|
||||
|
||||
## Quick Start — Minimal Tool
|
||||
|
||||
Create a directory with two files:
|
||||
|
||||
```
|
||||
tools/
|
||||
hello/
|
||||
TOOL.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
**TOOL.md:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: hello
|
||||
label: Hello
|
||||
description: Says hello to the user. Use this when the user wants a greeting.
|
||||
version: 1
|
||||
language: typescript
|
||||
inputs:
|
||||
name:
|
||||
type: string
|
||||
description: Who to greet.
|
||||
---
|
||||
|
||||
# Hello Tool
|
||||
|
||||
Greets the user by name.
|
||||
|
||||
## Usage
|
||||
|
||||
Call with a `name` parameter to get a personalized greeting.
|
||||
```
|
||||
|
||||
**index.ts:**
|
||||
|
||||
```typescript
|
||||
type ToolResult = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function execute(
|
||||
_toolCallId: string,
|
||||
params: Params,
|
||||
): Promise<ToolResult> {
|
||||
return { content: [{ type: 'text', text: `Hello, ${params.name}!` }] };
|
||||
}
|
||||
```
|
||||
|
||||
That's it. The tool-loader finds it, registers it, and the agent can call it.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tools/
|
||||
<tool-name>/
|
||||
TOOL.md # Metadata and documentation
|
||||
TOOL.md # Metadata + documentation (required)
|
||||
index.ts # Implementation (required)
|
||||
bin/ # Optional helper scripts
|
||||
```
|
||||
|
||||
Both files are required. The loader skips directories missing either `TOOL.md` or an entry file.
|
||||
Both `TOOL.md` and an entry file (`index.ts` or `index.js`) are required. The loader skips directories missing either.
|
||||
|
||||
Additional files (scripts, configs, READMEs) are allowed but not loaded — only `TOOL.md` and the entry file matter.
|
||||
|
||||
## Tool Locations
|
||||
|
||||
Tools live in two directories:
|
||||
|
||||
| Location | Purpose | Managed by |
|
||||
|----------|---------|------------|
|
||||
| `DATA_PATH/tools/` | Global tools (synced from seed) | `sync-tools.ts` at startup |
|
||||
| `DATA_PATH/<email>/tools/` | User-created tools | Manual (user creates them) |
|
||||
|
||||
Both are mounted into containers and discovered via the `PI_TOOLS_DIRS` environment variable. If a user tool has the same `name` as a global tool, the user tool overrides it (last-writer-wins).
|
||||
|
||||
To create a user tool, make a new directory in `DATA_PATH/<email>/tools/<tool-name>/` with `TOOL.md` and `index.ts`. It will be available after the next agent session starts.
|
||||
|
||||
## TOOL.md Format
|
||||
|
||||
A tool file has two parts: **frontmatter** (YAML metadata) and **body** (Markdown documentation).
|
||||
Two parts: **frontmatter** (YAML metadata) and **body** (Markdown documentation).
|
||||
|
||||
### Frontmatter
|
||||
|
||||
@@ -54,34 +127,47 @@ inputs:
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | Identifier for the tool (used in tool calls). Use `snake_case`. |
|
||||
| `label` | string | no | Human-readable display name. Defaults to `name` if omitted. |
|
||||
| `description` | string | yes | What the tool does. This appears in the agent's system prompt — make it specific enough for the agent to know when to use it. |
|
||||
| `version` | integer | no | Version number. Used by `sync-tools` to detect updates — bump when changing the tool. |
|
||||
| `language` | string | no | Implementation language: `typescript`, `bash`, or `python`. Defaults to `typescript`. |
|
||||
| `description` | string | yes | What the tool does. This appears in the agent's system prompt — make it specific enough for the agent to know **when** to use it. |
|
||||
| `version` | integer | **yes** | Version number. Used by `sync-tools` to detect updates. **Always include and bump when changing the tool.** If omitted, defaults to 0, causing unpredictable sync behavior. |
|
||||
| `language` | string | no | `typescript`, `bash`, or `python`. Defaults to `typescript`. |
|
||||
| `inputs` | object | no | Input parameters the tool accepts. Keys are parameter names. |
|
||||
|
||||
#### Input Fields
|
||||
#### Input Types
|
||||
|
||||
Each input is a key under `inputs:` with these properties:
|
||||
| Type | Schema | Notes |
|
||||
|------|--------|-------|
|
||||
| `string` | `Type.String()` | Default if type is unrecognized |
|
||||
| `number` | `Type.Number()` | |
|
||||
| `boolean` | `Type.Boolean()` | |
|
||||
| `enum` | `Type.Union(literals)` | Requires `values` field (comma-separated or array) |
|
||||
|
||||
**Note:** `object` and `array` types are not supported by the schema builder. If you need complex inputs, accept a JSON string and parse it in the execute function.
|
||||
|
||||
#### Input Properties
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | yes | Parameter type: `string`, `number`, `boolean`, or `enum`. |
|
||||
| `type` | string | yes | `string`, `number`, `boolean`, or `enum`. |
|
||||
| `description` | string | yes | What this parameter is for. Shown to the agent. |
|
||||
| `optional` | boolean | no | Whether the parameter is optional. Defaults to required. |
|
||||
| `sensitive` | boolean | no | Mark sensitive values (tokens, passwords). Prevents logging. |
|
||||
| `values` | string | no | Comma-separated allowed values when `type` is `enum`. |
|
||||
| `sensitive` | boolean | no | Mark sensitive values (tokens, passwords). Prevents logging in UI. |
|
||||
| `values` | string | no | Comma-separated allowed values for `enum` type. |
|
||||
| `default` | any | no | Default value if not provided. |
|
||||
|
||||
### Body
|
||||
|
||||
The body is Markdown documentation that the agent sees when the tool is loaded. Include:
|
||||
The body is Markdown that the agent sees when the tool is loaded. This is your main documentation — the agent reads it to understand how to use the tool.
|
||||
|
||||
Include:
|
||||
|
||||
- **Title** — `# Tool Name`
|
||||
- **Authentication** — How credentials are resolved (env vars, integrations, etc.)
|
||||
- **Usage** — How to call the tool and what parameters to pass.
|
||||
- **Examples** — Common usage patterns.
|
||||
- **Usage** — How to call the tool, what parameters to pass, and what to expect back.
|
||||
- **Examples** — Common usage patterns with example parameter values.
|
||||
- **Authentication** — How credentials are resolved (env vars, integrations, etc.) if applicable.
|
||||
- **Error Handling** — What errors can occur and what they mean.
|
||||
- **Notes** — Limits, billing, external links.
|
||||
- **Notes** — Limits, external dependencies, related links.
|
||||
|
||||
Write the body as instructions for the agent. The agent decides when and how to call the tool based on this documentation.
|
||||
|
||||
## index.ts Format
|
||||
|
||||
@@ -98,7 +184,7 @@ type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) =>
|
||||
export async function execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: OnUpdate,
|
||||
): Promise<ToolResult> {
|
||||
// Implementation here
|
||||
@@ -109,61 +195,397 @@ export async function execute(
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `toolCallId` | Unique ID for this tool call. |
|
||||
| `params` | Input values from the agent, matching the `inputs` defined in TOOL.md. |
|
||||
| `signal` | AbortSignal for cancellation. |
|
||||
| `onUpdate` | Callback for streaming progress updates to the agent during long operations. |
|
||||
| `toolCallId` | Unique ID for this tool call. Usually unused — prefix with `_`. |
|
||||
| `params` | Input values from the agent, matching `inputs` in TOOL.md. |
|
||||
| `signal` | AbortSignal for cancellation. Not widely used yet — accept and ignore. |
|
||||
| `onUpdate` | Callback for streaming progress to the agent during long operations. |
|
||||
|
||||
### Return Value
|
||||
|
||||
Return a `ToolResult` object:
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
return { content: [{ type: 'text', text: 'Done! Created 5 files.' }] };
|
||||
|
||||
// Error — agent sees the error and can react
|
||||
return { content: [{ type: 'text', text: 'API key not found.' }], isError: true };
|
||||
```
|
||||
|
||||
- `content` — Array of content blocks. Usually one `{ type: 'text', text: '...' }`.
|
||||
- `isError` — Set `true` to indicate failure. The agent sees the error and can react.
|
||||
- `isError` — Set `true` to indicate failure.
|
||||
|
||||
### Recommended Helpers
|
||||
|
||||
Define `ok()` and `err()` helpers to keep return statements clean:
|
||||
|
||||
```typescript
|
||||
function ok(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function err(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
```
|
||||
|
||||
### Typed Parameters
|
||||
|
||||
Define a `Params` type matching your TOOL.md inputs instead of using `Record<string, unknown>`:
|
||||
|
||||
```typescript
|
||||
type Params = {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
export async function execute(
|
||||
_toolCallId: string,
|
||||
params: Params,
|
||||
_signal?: AbortSignal,
|
||||
onUpdate?: OnUpdate,
|
||||
): Promise<ToolResult> {
|
||||
const { query, max_results = 10 } = params;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Updates
|
||||
|
||||
Use `onUpdate` to stream status during long-running operations:
|
||||
Use `onUpdate` to stream status during long-running operations. The agent sees each update in real time:
|
||||
|
||||
```typescript
|
||||
onUpdate?.({ content: [{ type: 'text', text: 'Processing step 2 of 5...' }] });
|
||||
onUpdate?.({ content: [{ type: 'text', text: 'Phase 1: Downloading data...' }] });
|
||||
// ... do work ...
|
||||
onUpdate?.({ content: [{ type: 'text', text: 'Phase 2: Processing 500 files...' }] });
|
||||
// ... do work ...
|
||||
return ok('Done! Processed 500 files.');
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Wrap the entire execute body in try/catch. Return errors as `ToolResult` with `isError: true` — never throw from execute:
|
||||
|
||||
```typescript
|
||||
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
|
||||
try {
|
||||
// ... implementation ...
|
||||
return ok('Success');
|
||||
} catch (e) {
|
||||
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For missing configuration, return a helpful error that tells the agent what to do:
|
||||
|
||||
```typescript
|
||||
const token = params.api_token ?? process.env.OFFICER_APIFY_TOKEN;
|
||||
if (!token) {
|
||||
return err('Apify API token not configured. Ask the user to add it in Settings → Integrations.');
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Tools should resolve credentials internally, not require the agent to pass them. Pattern:
|
||||
Tools should resolve credentials internally. Pattern:
|
||||
|
||||
1. Check for an explicit parameter override (e.g., `params.api_token`)
|
||||
2. Fall back to an environment variable (e.g., `process.env.OFFICER_APIFY_TOKEN`)
|
||||
3. Return a helpful error if neither is available
|
||||
|
||||
Environment variables are set by `pi-bridge.ts` from the integration config stored in the database (Settings → Integrations).
|
||||
|
||||
### Runtime Environment
|
||||
|
||||
Tools run inside sandboxed containers using **Node.js** (not Bun). Do not use Bun-specific APIs like `Bun.sleep`, `Bun.file`, etc. Use Node.js equivalents:
|
||||
|
||||
- `setTimeout` / `setInterval` for delays
|
||||
- `fs.readFileSync` / `fs.writeFileSync` for file I/O
|
||||
- `fetch` (available in Node 18+) for HTTP requests
|
||||
Environment variables are set by `pi-bridge.ts` from the integration config in the database (Settings → Integrations).
|
||||
|
||||
### Large Output
|
||||
|
||||
If a tool may return large data (e.g., API responses with many items), provide an `output_path` parameter. When set, save the data to the file and return a summary instead:
|
||||
If a tool may return large data, provide an `output_path` parameter. When set, save data to the file and return a summary:
|
||||
|
||||
```typescript
|
||||
if (params.output_path) {
|
||||
writeFileSync(params.output_path, JSON.stringify(items, null, 2));
|
||||
return { content: [{ type: 'text', text: `${items.length} items saved to ${params.output_path}` }] };
|
||||
return ok(`${items.length} items saved to ${params.output_path}`);
|
||||
}
|
||||
```
|
||||
|
||||
This prevents flooding the agent's context window with raw data.
|
||||
This prevents flooding the agent's context window.
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
Tools run inside **sandboxed Docker containers** using **Node.js** (not Bun).
|
||||
|
||||
### APIs
|
||||
|
||||
Use Node.js standard library only:
|
||||
|
||||
| Need | Use | Don't use |
|
||||
|------|-----|-----------|
|
||||
| File I/O | `fs.readFileSync`, `fs.writeFileSync` | `Bun.file`, `Bun.write` |
|
||||
| Delays | `setTimeout`, `setInterval` | `Bun.sleep` |
|
||||
| HTTP | `fetch` (Node 18+) | Bun-specific fetch options |
|
||||
| Child processes | `child_process.execFileSync`, `spawn` | `Bun.spawn` |
|
||||
| Paths | `path.join`, `path.dirname` | |
|
||||
|
||||
### Available Environment Variables
|
||||
|
||||
These are set by `pi-bridge.ts` and available in all tool containers:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HOME` | Container home directory |
|
||||
| `OFFICER_USER_HOME` | Same as HOME |
|
||||
| `OFFICER_USER_ROOT` | User data root (`/officer/user`) |
|
||||
| `PI_TOOLS_DIRS` | Tool discovery paths (colon-separated) |
|
||||
| `OFFICER_EMAIL_DB` | Path to email SQLite database |
|
||||
| `OFFICER_RESOURCES` | JSON object with all configured resources/integrations |
|
||||
| `PI_SEARXNG_URL` | SearXNG search engine URL |
|
||||
| `OFFICER_APIFY_TOKEN` | Apify API token (if configured) |
|
||||
| `OFFICER_BROWSER_RELAY_PORT` | Browser relay port (if configured) |
|
||||
| `OFFICER_BROWSER_RELAY_TOKEN` | Browser relay auth token (if configured) |
|
||||
|
||||
**`OFFICER_RESOURCES`** is a JSON string containing all resource configs from Settings → Integrations:
|
||||
|
||||
```typescript
|
||||
const resources = JSON.parse(process.env.OFFICER_RESOURCES ?? '{}');
|
||||
const ocrConfig = resources['optical-character-recognition'];
|
||||
```
|
||||
|
||||
### Container Filesystem
|
||||
|
||||
| Mount | Path in container | Access |
|
||||
|-------|-------------------|--------|
|
||||
| Global tools | `/officer/tools/` | Read-only |
|
||||
| User tools | `/officer/user/tools/` | Read-only |
|
||||
| User data | `/officer/user/` | Read-write |
|
||||
| Email database | `/officer/data/emails.db` | Read-write |
|
||||
|
||||
### Referencing Local Files
|
||||
|
||||
If your tool includes helper scripts (e.g., Python/bash in a `bin/` directory), resolve them relative to the entry file:
|
||||
|
||||
```typescript
|
||||
// Works in both ESM and CJS contexts
|
||||
const TOOL_DIR = typeof __dirname !== 'undefined'
|
||||
? __dirname
|
||||
: dirname(fileURLToPath(import.meta.url));
|
||||
const BIN_DIR = join(TOOL_DIR, 'bin');
|
||||
|
||||
// Then call scripts:
|
||||
execFileSync('python3', [join(BIN_DIR, 'process.py'), inputPath]);
|
||||
```
|
||||
|
||||
### External Dependencies
|
||||
|
||||
If your tool requires system binaries (e.g., `python3`, `tesseract`, `ffmpeg`), check for them early and return a helpful error:
|
||||
|
||||
```typescript
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
function hasCommand(cmd: string): boolean {
|
||||
try {
|
||||
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// In execute():
|
||||
if (!hasCommand('python3')) {
|
||||
return err('python3 is required but not installed in the container.');
|
||||
}
|
||||
```
|
||||
|
||||
## Sync and Discovery
|
||||
|
||||
Tools are synced from `seed/tools/` to `DATA_PATH/tools/` at server startup by `sync-tools.ts`. The sync is version-based — it only overwrites when the seed version is higher than the target version. Always bump `version` in the frontmatter when updating a tool.
|
||||
### How Sync Works
|
||||
|
||||
The `tool-loader` extension discovers tools from directories listed in the `PI_TOOLS_DIRS` environment variable (colon-separated). Only `DATA_PATH/tools/` is mounted into containers — `seed/tools/` is not directly accessible at runtime.
|
||||
At server startup, `sync-tools.ts` copies tools from `seed/tools/` to `DATA_PATH/tools/` (the global tools directory). The sync is **version-based**:
|
||||
|
||||
1. Parse `version` from TOOL.md frontmatter (defaults to `0` if missing)
|
||||
2. Compare seed version vs target version
|
||||
3. **Only copy if seed version > target version** (equal versions are skipped)
|
||||
4. When copying, the entire tool directory is replaced (`rm + cp`)
|
||||
|
||||
This means:
|
||||
- Bumping `version` in seed triggers an update on next restart
|
||||
- User edits to global tools are preserved until seed version exceeds theirs
|
||||
- Tools without `version` default to `0` — always include a version number
|
||||
|
||||
### How Discovery Works
|
||||
|
||||
The `tool-loader` extension reads `PI_TOOLS_DIRS` (colon-separated paths) and scans each directory for tool subdirectories. For each subdirectory:
|
||||
|
||||
1. Look for `TOOL.md` — parse frontmatter for metadata
|
||||
2. Look for `index.ts` or `index.js` — this is the entry file
|
||||
3. Skip if either is missing
|
||||
4. Register the tool with the agent (name, description, parameter schema)
|
||||
5. **Lazy load** the entry file on first call (not at startup)
|
||||
|
||||
If multiple tools share the same `name`, the last one registered wins. Since user tools are loaded after global tools, user tools override global tools with the same name.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### No `execute` export
|
||||
|
||||
The entry file **must** export `execute` as a named export. Class-based patterns, CLI entry points (`process.argv`), and `module.exports` do not work:
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong — class pattern
|
||||
export class MyTool { async run() { ... } }
|
||||
|
||||
// ❌ Wrong — CLI entry point
|
||||
if (require.main === module) { main(); }
|
||||
|
||||
// ❌ Wrong — console.log instead of return
|
||||
export async function execute(_id: string, params: Params) {
|
||||
console.log('result'); // Agent never sees this
|
||||
}
|
||||
|
||||
// ✅ Correct
|
||||
export async function execute(_id: string, params: Params): Promise<ToolResult> {
|
||||
return { content: [{ type: 'text', text: 'result' }] };
|
||||
}
|
||||
```
|
||||
|
||||
### Forgetting to bump version
|
||||
|
||||
After editing a seed tool's code or TOOL.md, bump `version` in the frontmatter. Otherwise `sync-tools` won't copy the update to the global directory and the agent will keep using the old version.
|
||||
|
||||
### Using Bun APIs
|
||||
|
||||
Tools run in Node.js containers. `Bun.file()`, `Bun.write()`, `Bun.sleep()` will throw `ReferenceError`.
|
||||
|
||||
### Throwing instead of returning errors
|
||||
|
||||
Never throw from `execute`. Always catch and return `{ isError: true }`. Unhandled throws produce generic error messages the agent can't act on.
|
||||
|
||||
### Unnecessary files
|
||||
|
||||
Tools don't need `package.json`, `tsconfig.json`, `node_modules`, or test directories. The tool-loader only reads `TOOL.md` and `index.ts`. Extra files are harmless but add clutter.
|
||||
|
||||
## Full Example — Database Query Tool
|
||||
|
||||
A complete tool that queries a SQLite database:
|
||||
|
||||
**TOOL.md:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my_db
|
||||
label: My Database
|
||||
description: Query the application database. Use this to look up records, run aggregations, and search data.
|
||||
version: 1
|
||||
language: typescript
|
||||
inputs:
|
||||
action:
|
||||
type: enum
|
||||
values: query,stats
|
||||
description: "Action to perform: query runs SQL, stats shows database overview."
|
||||
sql:
|
||||
type: string
|
||||
description: SQL SELECT statement to execute (query action only).
|
||||
optional: true
|
||||
limit:
|
||||
type: number
|
||||
description: Maximum number of results to return.
|
||||
optional: true
|
||||
---
|
||||
|
||||
# My Database Tool
|
||||
|
||||
Query the application database using SQL.
|
||||
|
||||
## Usage
|
||||
|
||||
Use `action=stats` for a database overview. Use `action=query` with a `sql` parameter for specific queries.
|
||||
|
||||
## Examples
|
||||
|
||||
Get stats:
|
||||
- action: stats
|
||||
|
||||
Search records:
|
||||
- action: query, sql: "SELECT * FROM users WHERE name LIKE '%john%' LIMIT 10"
|
||||
```
|
||||
|
||||
**index.ts:**
|
||||
|
||||
```typescript
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
type ToolResult = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
action: string;
|
||||
sql?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const DB_PATH = process.env.MY_DB_PATH ?? '/officer/data/my.db';
|
||||
|
||||
function ok(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function err(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
|
||||
function queryJson(sql: string): Record<string, unknown>[] {
|
||||
const output = execFileSync('sqlite3', ['-json', DB_PATH], {
|
||||
input: sql,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
|
||||
if (!existsSync(DB_PATH)) {
|
||||
return err(`Database not found at ${DB_PATH}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (params.action) {
|
||||
case 'query': {
|
||||
if (!params.sql) return err('sql parameter is required for query action.');
|
||||
if (!params.sql.trim().toLowerCase().startsWith('select')) {
|
||||
return err('Only SELECT statements are allowed.');
|
||||
}
|
||||
const limit = params.limit ? ` LIMIT ${params.limit}` : '';
|
||||
const rows = queryJson(`${params.sql}${limit}`);
|
||||
if (rows.length === 0) return ok('No results.');
|
||||
const text = rows.map((r, i) => {
|
||||
const fields = Object.entries(r).map(([k, v]) => `${k}: ${v ?? ''}`).join(' | ');
|
||||
return `${i + 1}. ${fields}`;
|
||||
}).join('\n');
|
||||
return ok(`${rows.length} results:\n\n${text}`);
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
const tables = queryJson("SELECT name FROM sqlite_master WHERE type='table'");
|
||||
const lines = tables.map((t) => {
|
||||
const count = queryJson(`SELECT COUNT(*) as c FROM "${t.name}"`);
|
||||
return `${t.name}: ${(count[0]?.c as number) ?? 0} rows`;
|
||||
});
|
||||
return ok(`Tables:\n${lines.join('\n')}`);
|
||||
}
|
||||
|
||||
default:
|
||||
return err(`Unknown action: "${params.action}". Available: query, stats.`);
|
||||
}
|
||||
} catch (e) {
|
||||
return err(`Database error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Existing Tools
|
||||
|
||||
@@ -178,3 +600,4 @@ The `tool-loader` extension discovers tools from directories listed in the `PI_T
|
||||
| `ffmpeg` | Run ffmpeg/ffprobe commands for any audio/video processing |
|
||||
| `ocr` | Optical character recognition on images |
|
||||
| `email_db` | Query the synced email database |
|
||||
| `pdf_categorizer` | Categorize and organize PDF files (user tool) |
|
||||
|
||||
@@ -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