From b0d06211b14e1a9e8e6a2330ee9e68935ff0bad6 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:12:22 +0000 Subject: [PATCH] Refactor: use compiled perry binary for container session discovery - Add perry worker subcommand with sessions list/messages commands - Compile perry to standalone binary during build (bun build --compile) - Copy perry binary to containers during sync (/usr/local/bin/perry) - Remove shell script approach (perry-session-reader.sh) - Share opencode-storage.ts between host and worker modes - Update AGENTS.md with manual testing instructions for port 7391 - Fix OpenCode sessions not showing up in web UI This replaces the npm-based perry-worker package with a compiled binary that gets synced to containers, eliminating the need to publish a separate package and ensuring the container always has the latest code. --- AGENTS.md | 28 ++++ DESIGN.md | 69 +++++++++ bun.lock | 4 + package.json | 3 +- perry/Dockerfile | 1 - perry/scripts/perry-session-reader.sh | 132 ---------------- src/agent/router.ts | 120 +++++--------- src/index.ts | 30 ++++ src/sessions/agents/opencode-storage.ts | 198 ++++++++++++++++++++++++ src/sessions/agents/opencode.ts | 22 ++- src/workspace/manager.ts | 24 +++ web/src/components/Chat.tsx | 4 +- web/src/pages/WorkspaceDetail.tsx | 3 +- 13 files changed, 410 insertions(+), 228 deletions(-) delete mode 100755 perry/scripts/perry-session-reader.sh create mode 100644 src/sessions/agents/opencode-storage.ts diff --git a/AGENTS.md b/AGENTS.md index b282fb47..55701122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,34 @@ Perry creates isolated Docker-in-Docker development environments. Distributed ar If modifying Dockerfile/init scripts, run `perry build` first. +### Manual Agent Testing + +When manually testing agent changes, use a dedicated test port to avoid conflicts with any running production agent: + +```bash +# Kill any existing agents and start test agent on port 7391 +pkill -f "perry agent" 2>/dev/null +perry agent run --port 7391 & + +# Configure CLI to use test agent +perry config worker localhost:7391 + +# Test your changes +perry list +perry sync + +# Verify in container (example: testing perry worker binary) +docker exec -u workspace workspace- perry --version +docker exec -u workspace workspace- perry worker sessions list + +# Test API directly +curl -s -X POST "http://localhost:7391/rpc/sessions/list" \ + -H "Content-Type: application/json" \ + -d '{"json":{"workspaceName":""}}' +``` + +**Important**: Always kill the test agent when done, or it will conflict with automated tests. + ### UI Testing **Critical**: UI (Web, mobile) MUST have e2e tests. Unit/integration tests miss rendering bugs, event binding issues, and framework regressions. diff --git a/DESIGN.md b/DESIGN.md index f689324b..f6f3fbdf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -819,6 +819,75 @@ Currently none (Tailscale-trusted). Future options if needed: --- +## Appendix: perry worker Pattern + +### Overview + +The `perry worker` subcommand runs inside containers to handle container-specific operations like session discovery. It's designed to be updated independently of the Docker image - the compiled binary is copied during `perry sync`, avoiding the need to rebuild images for bug fixes. + +### Architecture + +``` +src/ +├── index.ts # Main CLI with 'worker' subcommand +├── sessions/agents/ +│ ├── opencode-storage.ts # Shared session reading logic +│ └── opencode.ts # Container provider (calls perry worker) +└── workspace/ + └── manager.ts # copyPerryWorker() syncs binary to container + +dist/ +├── index.js # Main perry CLI +└── perry-worker # Compiled standalone binary (bun build --compile) +``` + +### Key Principle: No Bifurcation + +The same code (`opencode-storage.ts`) handles OpenCode session reading everywhere: +- **Host**: Imported directly in `router.ts` +- **Container**: Called via `perry worker sessions list/messages` + +This eliminates duplicate implementations and ensures consistent behavior. + +### Build & Sync + +The worker binary is compiled during build and copied during sync: + +```bash +# Build (in package.json scripts) +bun build src/index.ts --compile --outfile dist/perry-worker --target=bun + +# Sync copies binary to container (in WorkspaceManager.copyPerryWorker) +docker cp dist/perry-worker container:/usr/local/bin/perry +``` + +### Usage + +```bash +# List all OpenCode sessions (JSON output) +perry worker sessions list + +# Get messages for a session (JSON output) +perry worker sessions messages +``` + +### Why This Pattern? + +1. **Independent Updates**: Fix bugs in session reading, rebuild, sync - no Docker image rebuild needed +2. **Single Source of Truth**: Same TypeScript code used on host and in container +3. **Instant Propagation**: `perry sync` updates all workspaces immediately +4. **No External Dependencies**: Self-contained binary, no npm install in container + +### Adding New Commands + +To add new functionality: + +1. Add shared logic to `src/sessions/agents/` or appropriate module +2. Add subcommand to `perry worker` in `src/index.ts` +3. Import shared module in both host code and worker command + +--- + ## Appendix: Port Selection Default port: **7391** diff --git a/bun.lock b/bun.lock index 4733f5a1..9fd3334b 100644 --- a/bun.lock +++ b/bun.lock @@ -330,8 +330,12 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } } diff --git a/package.json b/package.json index 58e5fc7b..6f323452 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "dist" ], "scripts": { - "build": "rm -rf ./dist && bun run build:ts && bun run build:web && bun link", + "build": "rm -rf ./dist && bun run build:ts && bun run build:worker && bun run build:web && bun link", "build:ts": "tsc && chmod +x dist/index.js", + "build:worker": "bun build src/index.ts --compile --outfile dist/perry-worker --target=bun", "build:web": "cp src/shared/client-types.ts web/src/lib/types.ts && cd web && bun run build && cp -r dist ../dist/agent/web", "test": "vitest run", "test:web": "playwright test", diff --git a/perry/Dockerfile b/perry/Dockerfile index 74099514..ee32249b 100644 --- a/perry/Dockerfile +++ b/perry/Dockerfile @@ -193,7 +193,6 @@ RUN cd /opt/workspace/internal && bun install --production COPY internal /opt/workspace/internal RUN chmod +x /opt/workspace/scripts/dockerd-entrypoint.sh \ && install -m 0755 /opt/workspace/scripts/dockerd-entrypoint.sh /usr/local/bin/dockerd-entrypoint.sh \ - && install -m 0755 /opt/workspace/scripts/perry-session-reader.sh /usr/local/bin/perry-session-reader \ && chmod +x /opt/workspace/internal/src/index.ts \ && printf '#!/bin/sh\nexec bun /opt/workspace/internal/src/index.ts "$@"\n' > /usr/local/bin/workspace-internal \ && chmod +x /usr/local/bin/workspace-internal diff --git a/perry/scripts/perry-session-reader.sh b/perry/scripts/perry-session-reader.sh deleted file mode 100755 index d1a249ea..00000000 --- a/perry/scripts/perry-session-reader.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/bin/bash -set -e - -STORAGE_BASE="${HOME}/.local/share/opencode/storage" -SESSION_DIR="${STORAGE_BASE}/session" -MESSAGE_DIR="${STORAGE_BASE}/message" -PART_DIR="${STORAGE_BASE}/part" - -usage() { - echo "Usage: $0 [args]" - echo "Commands:" - echo " list List all sessions (JSON array)" - echo " messages Get all messages for a session (JSON)" - exit 1 -} - -list_sessions() { - local sessions="[]" - - for project_dir in "${SESSION_DIR}"/*; do - [ -d "$project_dir" ] || continue - - for session_file in "${project_dir}"/ses_*.json; do - [ -f "$session_file" ] || continue - - local mtime=$(stat -c %Y "$session_file" 2>/dev/null || stat -f %m "$session_file" 2>/dev/null) - local data=$(cat "$session_file") - local id=$(echo "$data" | jq -r '.id // empty') - local title=$(echo "$data" | jq -r '.title // empty') - local directory=$(echo "$data" | jq -r '.directory // empty') - local updated=$(echo "$data" | jq -r '.time.updated // empty') - - [ -z "$id" ] && continue - - local session_json=$(jq -n \ - --arg id "$id" \ - --arg title "$title" \ - --arg directory "$directory" \ - --arg mtime "${updated:-$mtime}" \ - --arg file "$session_file" \ - '{id: $id, title: $title, directory: $directory, mtime: ($mtime | tonumber), file: $file}') - - sessions=$(echo "$sessions" | jq --argjson s "$session_json" '. + [$s]') - done - done - - echo "$sessions" -} - -get_messages() { - local session_id="$1" - [ -z "$session_id" ] && { echo '{"error": "session_id required"}'; exit 1; } - - local session_file=$(find "$SESSION_DIR" -name "${session_id}.json" -type f 2>/dev/null | head -1) - [ -z "$session_file" ] && { echo '{"error": "session not found"}'; exit 1; } - - local internal_id=$(jq -r '.id // empty' "$session_file") - [ -z "$internal_id" ] && { echo '{"error": "invalid session file"}'; exit 1; } - - local msg_dir="${MESSAGE_DIR}/${internal_id}" - [ -d "$msg_dir" ] || { echo '{"id": "'"$session_id"'", "messages": []}'; exit 0; } - - local messages="[]" - - for msg_file in $(ls -1 "${msg_dir}"/msg_*.json 2>/dev/null | sort); do - [ -f "$msg_file" ] || continue - - local msg=$(cat "$msg_file") - local msg_id=$(echo "$msg" | jq -r '.id // empty') - local role=$(echo "$msg" | jq -r '.role // empty') - local created=$(echo "$msg" | jq -r '.time.created // empty') - - [ -z "$msg_id" ] && continue - [[ "$role" != "user" && "$role" != "assistant" ]] && continue - - local part_msg_dir="${PART_DIR}/${msg_id}" - [ -d "$part_msg_dir" ] || continue - - for part_file in $(ls -1 "${part_msg_dir}"/prt_*.json 2>/dev/null | sort); do - [ -f "$part_file" ] || continue - - local part=$(cat "$part_file") - local part_type=$(echo "$part" | jq -r '.type // empty') - - if [ "$part_type" = "text" ]; then - local text=$(echo "$part" | jq -r '.text // empty') - [ -z "$text" ] && continue - - local entry=$(jq -n \ - --arg type "$role" \ - --arg content "$text" \ - --arg ts "$created" \ - '{type: $type, content: $content, timestamp: (if $ts != "" then ($ts | tonumber / 1000 | todate) else null end)}') - messages=$(echo "$messages" | jq --argjson e "$entry" '. + [$e]') - - elif [ "$part_type" = "tool" ]; then - local tool=$(echo "$part" | jq -r '.tool // empty') - local call_id=$(echo "$part" | jq -r '.callID // .id // empty') - local title=$(echo "$part" | jq -r '.state.title // empty') - local input=$(echo "$part" | jq '.state.input // null') - local output=$(echo "$part" | jq -r '.state.output // empty') - - local tool_use=$(jq -n \ - --arg type "tool_use" \ - --arg toolName "${title:-$tool}" \ - --arg toolId "$call_id" \ - --argjson toolInput "$input" \ - --arg ts "$created" \ - '{type: $type, toolName: $toolName, toolId: $toolId, toolInput: ($toolInput | tostring), timestamp: (if $ts != "" then ($ts | tonumber / 1000 | todate) else null end)}') - messages=$(echo "$messages" | jq --argjson e "$tool_use" '. + [$e]') - - if [ -n "$output" ]; then - local tool_result=$(jq -n \ - --arg type "tool_result" \ - --arg content "$output" \ - --arg toolId "$call_id" \ - --arg ts "$created" \ - '{type: $type, content: $content, toolId: $toolId, timestamp: (if $ts != "" then ($ts | tonumber / 1000 | todate) else null end)}') - messages=$(echo "$messages" | jq --argjson e "$tool_result" '. + [$e]') - fi - fi - done - done - - jq -n --arg id "$session_id" --argjson messages "$messages" '{id: $id, messages: $messages}' -} - -case "${1:-}" in - list) list_sessions ;; - messages) get_messages "$2" ;; - *) usage ;; -esac diff --git a/src/agent/router.ts b/src/agent/router.ts index 181e824c..25ffb94b 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -29,6 +29,10 @@ import { discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery'; +import { + listOpencodeSessions, + getOpencodeSessionMessages, +} from '../sessions/agents/opencode-storage'; const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']); @@ -383,30 +387,16 @@ export function createRouter(ctx: RouterContext) { } if (!input.agentType || input.agentType === 'opencode') { - const opencodeDir = path.join(homeDir, '.opencode', 'sessions'); - try { - const sessions = await fs.readdir(opencodeDir); - for (const sessionDir of sessions) { - const sessionPath = path.join(opencodeDir, sessionDir); - const stat = await fs.stat(sessionPath); - if (!stat.isDirectory()) continue; - - const sessionFile = path.join(sessionPath, 'session.json'); - try { - const sessionStat = await fs.stat(sessionFile); - rawSessions.push({ - id: sessionDir, - agentType: 'opencode', - projectPath: homeDir, - mtime: sessionStat.mtimeMs, - filePath: sessionFile, - }); - } catch { - // session.json doesn't exist - } - } - } catch { - // Directory doesn't exist + const opencodeSessions = await listOpencodeSessions(); + for (const session of opencodeSessions) { + rawSessions.push({ + id: session.id, + agentType: 'opencode', + projectPath: session.directory || homeDir, + mtime: session.mtime, + filePath: session.file, + name: session.title || undefined, + }); } } @@ -447,15 +437,18 @@ export function createRouter(ctx: RouterContext) { // Can't read file } } else if (raw.agentType === 'opencode') { - try { - const sessionContent = await fs.readFile(raw.filePath, 'utf-8'); - const sessionData = JSON.parse(sessionContent); - messageCount = sessionData.messages?.length || 0; - if (sessionData.title) { - firstPrompt = sessionData.title; + const sessionMessages = await getOpencodeSessionMessages(raw.id); + const userAssistantMessages = sessionMessages.messages.filter( + (m) => m.type === 'user' || m.type === 'assistant' + ); + messageCount = userAssistantMessages.length; + if (raw.name) { + firstPrompt = raw.name; + } else { + const firstUserMsg = userAssistantMessages.find((m) => m.type === 'user' && m.content); + if (firstUserMsg?.content) { + firstPrompt = firstUserMsg.content.slice(0, 200); } - } catch { - // Can't read file } } @@ -521,58 +514,17 @@ export function createRouter(ctx: RouterContext) { } if (!agentType || agentType === 'opencode') { - const sessionDir = path.join(homeDir, '.opencode', 'sessions', sessionId); - const partsDir = path.join(sessionDir, 'part'); - - try { - const partFiles = await fs.readdir(partsDir); - const sortedParts = partFiles.sort(); - - for (const partFile of sortedParts) { - const partPath = path.join(partsDir, partFile); - try { - const partContent = await fs.readFile(partPath, 'utf-8'); - const part = JSON.parse(partContent); - - if (part.role === 'user' && part.content) { - const textContent = Array.isArray(part.content) - ? part.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text) - .join('\n') - : part.content; - messages.push({ - type: 'user', - content: textContent, - timestamp: part.time || null, - }); - } else if (part.role === 'assistant') { - if (part.content) { - const textContent = Array.isArray(part.content) - ? part.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text) - .join('\n') - : part.content; - if (textContent) { - messages.push({ - type: 'assistant', - content: textContent, - timestamp: part.time || null, - }); - } - } - } - } catch { - // Can't parse part - } - } - - if (messages.length > 0) { - return { id: sessionId, agentType: 'opencode', messages }; - } - } catch { - // Directory doesn't exist + const sessionData = await getOpencodeSessionMessages(sessionId); + if (sessionData.messages.length > 0) { + const opencodeMessages: SessionMessage[] = sessionData.messages.map((m) => ({ + type: m.type as SessionMessage['type'], + content: m.content, + toolName: m.toolName, + toolId: m.toolId, + toolInput: m.toolInput, + timestamp: m.timestamp, + })); + return { id: sessionId, agentType: 'opencode', messages: opencodeMessages }; } } diff --git a/src/index.ts b/src/index.ts index 4cc7ed89..c237a313 100755 --- a/src/index.ts +++ b/src/index.ts @@ -667,6 +667,36 @@ program } }); +const workerCmd = program + .command('worker') + .description('Worker mode commands (for use inside containers)'); + +workerCmd + .command('sessions') + .argument('', 'Subcommand: list or messages') + .argument('[sessionId]', 'Session ID (required for messages)') + .description('Manage OpenCode sessions') + .action(async (subcommand: string, sessionId?: string) => { + const { listOpencodeSessions, getOpencodeSessionMessages } = + await import('./sessions/agents/opencode-storage'); + + if (subcommand === 'list') { + const sessions = await listOpencodeSessions(); + console.log(JSON.stringify(sessions)); + } else if (subcommand === 'messages') { + if (!sessionId) { + console.error('Usage: perry worker sessions messages '); + process.exit(1); + } + const result = await getOpencodeSessionMessages(sessionId); + console.log(JSON.stringify(result)); + } else { + console.error(`Unknown subcommand: ${subcommand}`); + console.error('Available: list, messages'); + process.exit(1); + } + }); + function handleError(err: unknown): never { if (err instanceof ApiClientError) { console.error(`Error: ${err.message}`); diff --git a/src/sessions/agents/opencode-storage.ts b/src/sessions/agents/opencode-storage.ts new file mode 100644 index 00000000..69b2d359 --- /dev/null +++ b/src/sessions/agents/opencode-storage.ts @@ -0,0 +1,198 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +export interface OpencodeSessionInfo { + id: string; + title: string; + directory: string; + mtime: number; + file: string; +} + +export interface OpencodeMessage { + type: string; + content?: string; + toolName?: string; + toolId?: string; + toolInput?: string; + timestamp?: string; +} + +export interface OpencodeSessionMessages { + id: string; + messages: OpencodeMessage[]; +} + +function getStorageBase(homeDir?: string): string { + const home = homeDir || os.homedir(); + return path.join(home, '.local', 'share', 'opencode', 'storage'); +} + +export async function listOpencodeSessions(homeDir?: string): Promise { + const storageBase = getStorageBase(homeDir); + const sessionDir = path.join(storageBase, 'session'); + const sessions: OpencodeSessionInfo[] = []; + + try { + const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true }); + + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue; + + const projectPath = path.join(sessionDir, projectDir.name); + const sessionFiles = await fs.readdir(projectPath); + + for (const sessionFile of sessionFiles) { + if (!sessionFile.startsWith('ses_') || !sessionFile.endsWith('.json')) continue; + + const filePath = path.join(projectPath, sessionFile); + try { + const stat = await fs.stat(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(content); + + if (!data.id) continue; + + sessions.push({ + id: data.id, + title: data.title || '', + directory: data.directory || '', + mtime: data.time?.updated || Math.floor(stat.mtimeMs), + file: filePath, + }); + } catch { + continue; + } + } + } + } catch { + // Storage doesn't exist + } + + return sessions; +} + +export async function getOpencodeSessionMessages( + sessionId: string, + homeDir?: string +): Promise { + const storageBase = getStorageBase(homeDir); + const sessionDir = path.join(storageBase, 'session'); + const messageDir = path.join(storageBase, 'message'); + const partDir = path.join(storageBase, 'part'); + + const sessionFile = await findSessionFile(sessionDir, sessionId); + if (!sessionFile) { + return { id: sessionId, messages: [] }; + } + + let internalId: string; + try { + const content = await fs.readFile(sessionFile, 'utf-8'); + const data = JSON.parse(content); + internalId = data.id; + if (!internalId) { + return { id: sessionId, messages: [] }; + } + } catch { + return { id: sessionId, messages: [] }; + } + + const msgDir = path.join(messageDir, internalId); + const messages: OpencodeMessage[] = []; + + try { + const msgFiles = (await fs.readdir(msgDir)) + .filter((f) => f.startsWith('msg_') && f.endsWith('.json')) + .sort(); + + for (const msgFile of msgFiles) { + const msgPath = path.join(msgDir, msgFile); + try { + const content = await fs.readFile(msgPath, 'utf-8'); + const msg = JSON.parse(content); + + if (!msg.id || (msg.role !== 'user' && msg.role !== 'assistant')) continue; + + const partMsgDir = path.join(partDir, msg.id); + try { + const partFiles = (await fs.readdir(partMsgDir)) + .filter((f) => f.startsWith('prt_') && f.endsWith('.json')) + .sort(); + + for (const partFile of partFiles) { + const partPath = path.join(partMsgDir, partFile); + try { + const partContent = await fs.readFile(partPath, 'utf-8'); + const part = JSON.parse(partContent); + const timestamp = msg.time?.created + ? new Date(msg.time.created).toISOString() + : undefined; + + if (part.type === 'text' && part.text) { + messages.push({ + type: msg.role, + content: part.text, + timestamp, + }); + } else if (part.type === 'tool') { + const toolName = part.state?.title || part.tool || ''; + const callId = part.callID || part.id || ''; + + messages.push({ + type: 'tool_use', + toolName, + toolId: callId, + toolInput: part.state?.input ? JSON.stringify(part.state.input) : '', + timestamp, + }); + + if (part.state?.output) { + messages.push({ + type: 'tool_result', + content: part.state.output, + toolId: callId, + timestamp, + }); + } + } + } catch { + continue; + } + } + } catch { + continue; + } + } catch { + continue; + } + } + } catch { + // No messages + } + + return { id: sessionId, messages }; +} + +async function findSessionFile(sessionDir: string, sessionId: string): Promise { + try { + const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true }); + + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue; + + const filePath = path.join(sessionDir, projectDir.name, `${sessionId}.json`); + try { + await fs.access(filePath); + return filePath; + } catch { + continue; + } + } + } catch { + // Directory doesn't exist + } + + return null; +} diff --git a/src/sessions/agents/opencode.ts b/src/sessions/agents/opencode.ts index 5652f207..96313559 100644 --- a/src/sessions/agents/opencode.ts +++ b/src/sessions/agents/opencode.ts @@ -3,7 +3,7 @@ import type { RawSession, SessionListItem, ExecInContainer, AgentSessionProvider export const opencodeProvider: AgentSessionProvider = { async discoverSessions(containerName: string, exec: ExecInContainer): Promise { - const result = await exec(containerName, ['perry-session-reader', 'list'], { + const result = await exec(containerName, ['perry', 'worker', 'sessions', 'list'], { user: 'workspace', }); @@ -38,9 +38,13 @@ export const opencodeProvider: AgentSessionProvider = { rawSession: RawSession, exec: ExecInContainer ): Promise { - const result = await exec(containerName, ['perry-session-reader', 'messages', rawSession.id], { - user: 'workspace', - }); + const result = await exec( + containerName, + ['perry', 'worker', 'sessions', 'messages', rawSession.id], + { + user: 'workspace', + } + ); if (result.exitCode !== 0) { return null; @@ -83,9 +87,13 @@ export const opencodeProvider: AgentSessionProvider = { sessionId: string, exec: ExecInContainer ): Promise<{ id: string; messages: SessionMessage[] } | null> { - const result = await exec(containerName, ['perry-session-reader', 'messages', sessionId], { - user: 'workspace', - }); + const result = await exec( + containerName, + ['perry', 'worker', 'sessions', 'messages', sessionId], + { + user: 'workspace', + } + ); if (result.exitCode !== 0) { return null; diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 21a2ef0c..57008e4a 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -384,11 +384,35 @@ export class WorkspaceManager { await this.setupClaudeCodeConfig(containerName); await this.copyCodexCredentials(containerName); await this.setupOpencodeConfig(containerName); + await this.copyPerryWorker(containerName); if (workspaceName) { await this.setupSSHKeys(containerName, workspaceName); } } + private async copyPerryWorker(containerName: string): Promise { + const distDir = path.dirname(new URL(import.meta.url).pathname); + const workerBinaryPath = path.join(distDir, '..', 'perry-worker'); + + try { + await fs.access(workerBinaryPath); + } catch { + console.warn( + `[sync] perry-worker binary not found at ${workerBinaryPath}, session discovery may not work` + ); + return; + } + + const destPath = '/usr/local/bin/perry'; + await docker.copyToContainer(containerName, workerBinaryPath, destPath); + await docker.execInContainer(containerName, ['chown', 'root:root', destPath], { + user: 'root', + }); + await docker.execInContainer(containerName, ['chmod', '755', destPath], { + user: 'root', + }); + } + private async runPostStartScript(containerName: string): Promise { const scriptPath = this.config.scripts.post_start; if (!scriptPath) { diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx index cca619a1..cfaa3038 100644 --- a/web/src/components/Chat.tsx +++ b/web/src/components/Chat.tsx @@ -538,9 +538,9 @@ export function Chat({ workspaceName, sessionId: initialSessionId, onSessionId, if (msg.type === 'system') { if (msg.content.startsWith('Session started')) { - const match = msg.content.match(/Session (\S+)/) + const match = msg.content.match(/Session started:?\s+(\S+)/) if (match) { - const newSessionId = match[1] + const newSessionId = match[1].replace(/\.+$/, '') setSessionId(newSessionId) onSessionIdRef.current?.(newSessionId) } diff --git a/web/src/pages/WorkspaceDetail.tsx b/web/src/pages/WorkspaceDetail.tsx index 7d137f0f..932f208f 100644 --- a/web/src/pages/WorkspaceDetail.tsx +++ b/web/src/pages/WorkspaceDetail.tsx @@ -227,9 +227,10 @@ export function WorkspaceDetail() { const handleSessionId = useCallback((sessionId: string) => { if (name && chatMode?.type === 'chat' && chatMode.agentType) { api.recordSessionAccess(name, sessionId, chatMode.agentType).catch(() => {}) + queryClient.invalidateQueries({ queryKey: ['sessions', name] }) } setChatMode((prev) => prev?.type === 'chat' ? { ...prev, sessionId } : prev) - }, [name, chatMode]) + }, [name, chatMode, queryClient]) const { data: hostInfo, isLoading: hostLoading } = useQuery({ queryKey: ['hostInfo'],