diff --git a/.github/workflows/auto-opencode.yml b/.github/workflows/auto-opencode.yml
new file mode 100644
index 00000000..1f4ca012
--- /dev/null
+++ b/.github/workflows/auto-opencode.yml
@@ -0,0 +1,29 @@
+name: opencode
+
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ opencode:
+ if: |
+ contains(github.event.comment.body, ' /oc') ||
+ startsWith(github.event.comment.body, '/oc') ||
+ contains(github.event.comment.body, ' /opencode') ||
+ startsWith(github.event.comment.body, '/opencode')
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: write
+ pull-requests: write
+ issues: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run opencode
+ uses: sst/opencode/github@latest
+ env:
+ ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
+ with:
+ model: zai-coding-plan/glm-4.6
\ No newline at end of file
diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml
index 1f4ca012..dd8e46ea 100644
--- a/.github/workflows/opencode.yml
+++ b/.github/workflows/opencode.yml
@@ -1,29 +1,229 @@
-name: opencode
+name: OpenChamber for Actions
on:
- issue_comment:
- types: [created]
+ workflow_dispatch:
+ inputs:
+ tunnel_provider:
+ description: 'Select Tunnel Provider'
+ required: true
+ default: 'cloudflare'
+ type: choice
+ options:
+ - ngrok
+ - cloudflare
+ timeout_minutes:
+ description: 'Auto-shutdown after (minutes)'
+ required: true
+ default: '300'
+ type: string
jobs:
- opencode:
- if: |
- contains(github.event.comment.body, ' /oc') ||
- startsWith(github.event.comment.body, '/oc') ||
- contains(github.event.comment.body, ' /opencode') ||
- startsWith(github.event.comment.body, '/opencode')
+ serve:
runs-on: ubuntu-latest
- permissions:
- id-token: write
- contents: write
- pull-requests: write
- issues: write
+
steps:
- - name: Checkout repository
+ - name: Checkout repo
uses: actions/checkout@v4
- - name: Run opencode
- uses: sst/opencode/github@latest
- env:
- ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
+ # ============================================================
+ # PERSISTENCE: Restore previous session (OAuth, chats, config)
+ # ============================================================
+ - name: Restore Session Data (Artifact)
+ if: ${{ secrets.OPENCODE_SERVER_PASSWORD != '' }}
+ uses: dawidd6/action-download-artifact@v6
+ continue-on-error: true
with:
- model: zai-coding-plan/glm-4.6
\ No newline at end of file
+ name: opencode-session
+ path: /tmp/opencode-restore
+ workflow: opencode.yml
+ workflow_conclusion: success
+ if_no_artifact_found: ignore
+ repo: ${{ github.repository }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Apply Restored Session Data
+ if: ${{ secrets.OPENCODE_SERVER_PASSWORD != '' }}
+ env:
+ OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
+ run: |
+ chmod +x scripts/persistence-restore.sh
+ RESTORE_DIR=/tmp/opencode-restore ./scripts/persistence-restore.sh
+
+ # ============================================================
+ # CACHE: Speed up npm installations
+ # ============================================================
+ - name: Cache npm modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.npm
+ key: npm-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ npm-cache-${{ runner.os }}-
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Install Tools
+ run: |
+ npm install -g opencode-ai @openchamber/web
+ sudo apt update && sudo apt install jq lsof coreutils openssl -y
+
+ # Install ttyd for OpenCode TTY exposure
+ wget -q -O ttyd https://github.com/tsl0922/ttyd/releases/download/1.7.3/ttyd.x86_64
+ chmod +x ttyd
+ sudo mv ttyd /usr/local/bin/
+
+ # ============================================================
+ # CONFIGURATION: Setup OpenCode config (respects artifacts)
+ # ============================================================
+ - name: Configure OpenCode
+ run: |
+ chmod +x scripts/opencode-config.sh
+ RESTORE_DIR=/tmp/opencode-restore ./scripts/opencode-config.sh
+
+ # ============================================================
+ # STARTUP: Launch services and tunnel
+ # ============================================================
+ - name: Start Services
+ env:
+ OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
+ run: |
+ # Setup Password Protection
+ TTYD_ARGS=""
+ if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then
+ echo "Password protection enabled."
+ # Export for subsequent steps (Monitor script) and other services
+ echo "OPENCHAMBER_UI_PASSWORD=$OPENCODE_SERVER_PASSWORD" >> $GITHUB_ENV
+ echo "OPENCODE_UI_PASSWORD=$OPENCODE_SERVER_PASSWORD" >> $GITHUB_ENV
+
+ # Use for current step
+ export OPENCHAMBER_UI_PASSWORD="$OPENCODE_SERVER_PASSWORD"
+ export OPENCODE_UI_PASSWORD="$OPENCODE_SERVER_PASSWORD"
+
+ TTYD_ARGS="-c user:$OPENCODE_SERVER_PASSWORD"
+ else
+ echo "Password protection disabled (OPENCODE_SERVER_PASSWORD not set)."
+ fi
+
+ # Start OpenCode TTY (via ttyd)
+ echo "Starting OpenCode TTY..."
+ nohup stdbuf -oL ttyd $TTYD_ARGS -p 7681 opencode > opencode_tty.log 2>&1 &
+
+ # Start OpenChamber
+ echo "Starting OpenChamber..."
+ if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then
+ nohup stdbuf -oL openchamber --port 9090 --ui-password "$OPENCODE_SERVER_PASSWORD" > openchamber.log 2>&1 &
+ else
+ nohup stdbuf -oL openchamber --port 9090 > openchamber.log 2>&1 &
+ fi
+
+ # Start OpenCode Web
+ echo "Starting OpenCode Web..."
+ if [[ -n "$OPENCODE_SERVER_PASSWORD" ]]; then
+ OPENCODE_SERVER_PASSWORD="$OPENCODE_SERVER_PASSWORD" nohup stdbuf -oL opencode web --port 8080 > opencode_web.log 2>&1 &
+ else
+ nohup stdbuf -oL opencode web --port 8080 > opencode_web.log 2>&1 &
+ fi
+
+ # Wait for services to initialize
+ sleep 20
+
+ echo "Services started. Checking status..."
+ lsof -i :7681 || echo "Warning: OpenCode TTY may not be running"
+ lsof -i :9090 || echo "Warning: OpenChamber may not be running"
+ lsof -i :8080 || echo "Warning: OpenCode Web may not be running"
+
+ - name: Setup Tunnel
+ run: |
+ if [[ "${{ github.event.inputs.tunnel_provider }}" == "ngrok" ]]; then
+ echo "Setting up ngrok tunnel..."
+ curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
+ echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list
+ sudo apt update && sudo apt install ngrok -y
+ ngrok config add-authtoken "${{ secrets.NGROK_AUTH_TOKEN }}"
+
+ # Note: Multiple ngrok tunnels on free plan might be limited or require config file
+ # Attempting to start tunnels blindly
+ nohup ngrok http 127.0.0.1:9090 --log=stdout > tunnel_chamber.log 2>&1 &
+ nohup ngrok http 127.0.0.1:8080 --log=stdout > tunnel_web.log 2>&1 &
+ nohup ngrok http 127.0.0.1:7681 --log=stdout > tunnel_tty.log 2>&1 &
+ sleep 10
+
+ # Extract URLs
+ URL_CHAMBER=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("9090")) | .public_url' || true)
+ URL_WEB=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("8080")) | .public_url' || true)
+ URL_TTY=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | select(.config.addr | contains("7681")) | .public_url' || true)
+
+ echo "URL_CHAMBER=$URL_CHAMBER" >> $GITHUB_ENV
+ echo "URL_WEB=$URL_WEB" >> $GITHUB_ENV
+ echo "URL_TTY=$URL_TTY" >> $GITHUB_ENV
+
+ else
+ echo "Setting up Cloudflare tunnel..."
+ wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
+ sudo dpkg -i cloudflared-linux-amd64.deb
+
+ # Start 3 separate tunnels
+ nohup cloudflared tunnel --url http://127.0.0.1:7681 > tunnel_tty.log 2>&1 &
+ nohup cloudflared tunnel --url http://127.0.0.1:9090 > tunnel_chamber.log 2>&1 &
+ nohup cloudflared tunnel --url http://127.0.0.1:8080 > tunnel_web.log 2>&1 &
+
+ echo "Waiting for Cloudflare Tunnel URLs..."
+
+ # Loop to wait for URLs
+ for i in {1..30}; do
+ URL_TTY=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_tty.log 2>/dev/null | tail -n 1 || true)
+ URL_CHAMBER=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_chamber.log 2>/dev/null | tail -n 1 || true)
+ URL_WEB=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_web.log 2>/dev/null | tail -n 1 || true)
+
+ if [[ -n "$URL_TTY" && -n "$URL_CHAMBER" && -n "$URL_WEB" ]]; then
+ echo "All tunnels established!"
+ break
+ fi
+ echo "Waiting for tunnels... ($i/30)"
+ sleep 2
+ done
+
+ if [[ -z "$URL_TTY" ]]; then echo "Warning: TTY Tunnel URL missing"; fi
+ if [[ -z "$URL_CHAMBER" ]]; then echo "Warning: Chamber Tunnel URL missing"; fi
+ if [[ -z "$URL_WEB" ]]; then echo "Warning: Web Tunnel URL missing"; fi
+
+ echo "URL_TTY=$URL_TTY" >> $GITHUB_ENV
+ echo "URL_CHAMBER=$URL_CHAMBER" >> $GITHUB_ENV
+ echo "URL_WEB=$URL_WEB" >> $GITHUB_ENV
+ fi
+
+ # ============================================================
+ # MONITOR: Self-healing loop with status updates
+ # ============================================================
+ - name: Monitor & Self-Heal
+ env:
+ OPENCHAMBER_UI_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
+ run: |
+ chmod +x scripts/monitor.sh
+ # Pass all 3 URLs to monitor script
+ ./scripts/monitor.sh "${{ github.event.inputs.tunnel_provider }}" "${{ github.event.inputs.timeout_minutes }}" "${{ env.URL_TTY }}" "${{ env.URL_CHAMBER }}" "${{ env.URL_WEB }}"
+
+ # ============================================================
+ # PERSISTENCE: Save all session data (always runs)
+ # ============================================================
+ - name: Prepare Session Data for Save
+ if: ${{ always() && secrets.OPENCODE_SERVER_PASSWORD != '' }}
+ env:
+ OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
+ run: |
+ chmod +x scripts/persistence-save.sh
+ SAVE_DIR=/tmp/opencode-save ./scripts/persistence-save.sh
+
+ - name: Upload Session Data (Artifact)
+ if: ${{ always() && secrets.OPENCODE_SERVER_PASSWORD != '' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: opencode-session
+ path: /tmp/opencode-save/
+ retention-days: 90
+ overwrite: true
+ if-no-files-found: warn
diff --git a/README.md b/README.md
index a6f45fc7..8d698c06 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,16 @@ The whole project was built entirely with AI coding agents under my supervision.
- Editor-integrated file picker and click-to-open from tool output
- In-extension Settings access and theme mapping
+### GitHub Actions (Cloud Usage)
+
+Run OpenChamber remotely using GitHub Actions. No local computer required.
+
+* **Zero Setup:** Runs on GitHub's infrastructure.
+* **Persistence:** Optional; enabled when `OPENCODE_SERVER_PASSWORD` is set (encrypted).
+* **Remote Access:** Access via secure tunnel (Cloudflare/Ngrok).
+
+[**Read the Guide: OpenChamber for Actions**](docs/OPENCHAMBER_FOR_ACTIONS.md)
+
## Installation
### VS Code Extension
@@ -118,7 +128,20 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
## Tech Stack
-React 19, TypeScript, Vite 7, Tailwind CSS v4, Zustand, Radix UI, @opencode-ai/sdk, Express, Tauri (desktop)
+### Frontend
+
+
+
+
+
+### State & UI
+
+
+
+### Backend & Desktop
+
+
+
## Acknowledgments
diff --git a/docs/OPENCHAMBER_FOR_ACTIONS.md b/docs/OPENCHAMBER_FOR_ACTIONS.md
new file mode 100644
index 00000000..6962f249
--- /dev/null
+++ b/docs/OPENCHAMBER_FOR_ACTIONS.md
@@ -0,0 +1,166 @@
+# OpenChamber for Actions
+
+**Version:** `v0.1.0-preview`
+
+OpenChamber for Actions allows you to run a full OpenChamber environment remotely using GitHub Actions infrastructure. It provides access to OpenCode, OpenChamber, and a web-based terminal without requiring any local hardware.
+
+---
+
+## Overview
+
+OpenChamber for Actions is a "computer in the cloud" solution that leverages GitHub Runners to host a temporary development environment. It spins up three key services:
+* **OpenCode TTY:** A web-based terminal for command-line access.
+* **OpenChamber:** The core environment.
+* **OpenCode Web:** The web interface for coding and interaction.
+
+These services are exposed via secure tunnels (Cloudflare or Ngrok), allowing you to access them from any browser. The session can persist data between runs using GitHub Artifacts.
+
+---
+
+## Key Features
+
+* **No local hardware required:** Run OpenChamber on GitHub Actions infrastructure.
+* **OpenCode integration:** Access the OpenCode suite and "Antigravity" model selection (including Gemini 3 and Claude 4.5).
+* **Secure access (optional):** Protect OpenChamber, OpenCode Web, and the TTY with `OPENCODE_SERVER_PASSWORD`.
+* **Persistent sessions (optional):** Save login state, OAuth tokens, and configuration as GitHub Artifacts when a password is set (encrypted).
+* **Self-healing tunnels:** Background monitoring keeps the tunnel stable.
+
+---
+
+## New to this? (Beginners' Guide)
+
+
+Click to expand: What is this and how does it help me?
+
+### 1. What exactly is this?
+Think of this as a "computer in the cloud." Instead of running a local AI environment, GitHub Actions provides the compute for you.
+
+### 2. What do I need to start?
+* A GitHub account.
+* A web browser (Chrome, Edge, Safari, etc.).
+* No special hardware required.
+
+### 3. Why is Cloudflare recommended?
+Cloudflare Quick Tunnels create a secure public URL automatically. You do not need extra accounts or API keys, so it is the simplest option.
+
+### 4. What is persistence?
+When a password is set, the workflow saves your login info and settings as an encrypted GitHub Artifact. The next run restores the session so you can continue without logging in again.
+
+
+---
+
+
+Usage Limits & Quotas
+
+To ensure stability and compliance with GitHub’s Terms of Service, be aware of the following limits:
+
+| Limit Type | Constraint | Explanation |
+| :--- | :--- | :--- |
+| **Time Limit** | **6 Hours Max** | GitHub stops any job after 360 minutes. |
+| **Storage** | **2GB** | Session artifacts cannot exceed 2GB per repository. |
+| **Concurrency** | **1 Run Only** | Run a single OpenChamber instance at a time. |
+| **Hardware** | **2-Core CPU** | Standard Linux runners (~7GB RAM). |
+
+
+
+---
+
+## Installation Guide
+
+### Option A: Easy Mode (Recommended)
+Uses Cloudflare tunnels. No configuration required unless you want password protection.
+
+1. Fork this repository to your GitHub account.
+2. Open the Actions tab in your fork.
+3. Select the **OpenChamber for Actions** workflow.
+4. (Optional) **Set a Password (recommended):**
+ * Go to **Settings** -> **Secrets and variables** -> **Actions**.
+ * Click **New repository secret**.
+ * **Name:** `OPENCODE_SERVER_PASSWORD`
+ * **Secret:** Your desired password.
+ * *Note: If this secret is set, it protects OpenCode TTY, OpenChamber, and OpenCode Web. It also enables encrypted persistence.*
+5. Click **Run workflow**.
+ * **Tunnel Provider:** Choose `cloudflare` (default) or `ngrok`.
+ * **Auto-shutdown after (minutes):** Set the duration (default `300`).
+6. Wait about 30 seconds for setup to finish.
+7. Open the run, then open the `serve` job.
+8. Expand the **Monitor & Self-Heal** step to find the URLs and open them.
+
+> [!TIP]
+> Keep the repository visibility `Private` or use `OPENCODE_SERVER_PASSWORD` in Github Actions Secrets. Otherwise, your privacy can be violated.
+
+---
+
+### Option B: Pro Mode (Ngrok)
+
+
+Click to expand: Step-by-step guide for setting up Ngrok
+
+If you prefer using Ngrok for a static domain, follow these steps to set up your API key.
+
+#### Step 1: Get your authtoken
+1. Log in or sign up at dashboard.ngrok.com.
+2. In the sidebar, click "Your Authtoken."
+3. Copy the token string (for example, `21xYz...`).
+
+#### Step 2: Add the secret to GitHub
+1. Open your forked repository on GitHub.
+2. Open Settings.
+3. In the sidebar, open Secrets and variables and click Actions.
+4. Click New repository secret.
+5. **Name:** `NGROK_AUTH_TOKEN`.
+6. **Secret:** Paste the token.
+7. (Optional) Add `OPENCODE_SERVER_PASSWORD` to enable password protection for all services and encrypted persistence.
+8. Click Add secret.
+
+#### Step 3: Run with Ngrok
+1. Go to the Actions tab.
+2. Select **OpenChamber for Actions**.
+3. Click **Run workflow**.
+4. Select `ngrok` as the Tunnel Provider.
+5. Find the URL in the "Monitor & Self-Heal" step.
+
+
+---
+
+## Frequently Asked Questions (FAQ)
+
+
+Q: Is this completely free?
+GitHub provides 2,000 minutes for private repositories and unlimited minutes for public repositories. This workflow uses those minutes.
+
+
+
+Q: Why did my session stop working after a few hours?
+GitHub Actions has a 6-hour job limit. The environment shuts down automatically. If persistence is enabled, your session data is restored on the next run.
+
+
+
+Q: Can I use this on my phone or tablet?
+Yes. Once you have a public URL (Cloudflare or Ngrok), open it in any mobile browser.
+
+
+
+Q: Is my data private?
+If you fork this as a public repository, your code may be visible depending on how you save it. For maximum privacy, use a private fork. If you want persistence, set `OPENCODE_SERVER_PASSWORD` so the artifact is encrypted.
+
+
+
+Q: I see a "Connection Refused" error. What do I do?
+Wait about 30 seconds and refresh the page. If it persists, check the Monitor logs in the Actions run.
+
+
+---
+
+## Technical Specifications
+
+| Feature | Detail |
+| :--- | :--- |
+| **Version** | `v0.1.0-preview` |
+| **OS** | Ubuntu 22.04 LTS (GitHub Runner) |
+| **Node Version** | v20.x |
+| **Tunnel Protocols** | `cloudflared` (Argon) / `ngrok` (HTTP) |
+| **Artifact Retention** | 90 Days (Default GitHub Policy) |
+
+> [!NOTE]
+> This project is for educational and development purposes. Do not use this workflow for mining crypto or other activities banned by the GitHub Acceptable Use Policy.
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index 5fd5d7d1..a0b832a0 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -352,6 +352,8 @@ export interface FilesAPI {
readFile?(path: string): Promise<{ content: string; path: string }>;
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
+ delete?(path: string): Promise<{ success: boolean }>;
+ rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
}
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index de9f3817..381cf248 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -64,6 +64,33 @@ const normalizeDirectoryPath = (value) => {
return trimmed;
};
+const resolveWorkspacePath = (targetPath, baseDirectory) => {
+ const normalized = normalizeDirectoryPath(targetPath);
+ if (!normalized || typeof normalized !== 'string') {
+ return { ok: false, error: 'Path is required' };
+ }
+
+ const resolved = path.resolve(normalized);
+ const resolvedBase = path.resolve(baseDirectory || os.homedir());
+ const relative = path.relative(resolvedBase, resolved);
+
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
+ return { ok: false, error: 'Path is outside of active workspace' };
+ }
+
+ return { ok: true, base: resolvedBase, resolved };
+};
+
+const resolveWorkspacePathFromContext = async (req, targetPath) => {
+ const resolvedProject = await resolveProjectDirectory(req);
+ if (!resolvedProject.directory) {
+ return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
+ }
+
+ return resolveWorkspacePath(targetPath, resolvedProject.directory);
+};
+
+
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
return relative.split(path.sep).join('/') || targetPath;
@@ -4643,16 +4670,14 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Path is required' });
}
- const expandedPath = normalizeDirectoryPath(dirPath);
- const normalizedPath = path.normalize(expandedPath);
- if (normalizedPath.includes('..')) {
- return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
+ const resolved = await resolveWorkspacePathFromContext(req, dirPath);
+ if (!resolved.ok) {
+ return res.status(400).json({ error: resolved.error });
}
- const resolvedPath = path.resolve(expandedPath);
- await fsPromises.mkdir(resolvedPath, { recursive: true });
+ await fsPromises.mkdir(resolved.resolved, { recursive: true });
- res.json({ success: true, path: resolvedPath });
+ res.json({ success: true, path: resolved.resolved });
} catch (error) {
console.error('Failed to create directory:', error);
res.status(500).json({ error: error.message || 'Failed to create directory' });
@@ -4751,15 +4776,15 @@ async function main(options = {}) {
}
try {
- const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
- if (resolvedPath.includes('..')) {
- return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
+ const resolved = await resolveWorkspacePathFromContext(req, filePath);
+ if (!resolved.ok) {
+ return res.status(400).json({ error: resolved.error });
}
// Ensure parent directory exists
- await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
- await fsPromises.writeFile(resolvedPath, content, 'utf8');
- res.json({ success: true, path: resolvedPath });
+ await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
+ await fsPromises.writeFile(resolved.resolved, content, 'utf8');
+ res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
@@ -4770,6 +4795,75 @@ async function main(options = {}) {
}
});
+ // Delete file or directory
+ app.post('/api/fs/delete', async (req, res) => {
+ const { path: targetPath } = req.body || {};
+ if (!targetPath || typeof targetPath !== 'string') {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ try {
+ const resolved = await resolveWorkspacePathFromContext(req, targetPath);
+ if (!resolved.ok) {
+ return res.status(400).json({ error: resolved.error });
+ }
+
+ await fsPromises.rm(resolved.resolved, { recursive: true, force: true });
+
+ res.json({ success: true, path: resolved.resolved });
+ } catch (error) {
+ const err = error;
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
+ return res.status(404).json({ error: 'File or directory not found' });
+ }
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+ console.error('Failed to delete path:', error);
+ res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
+ }
+ });
+
+ // Rename/Move file or directory
+ app.post('/api/fs/rename', async (req, res) => {
+ const { oldPath, newPath } = req.body || {};
+ if (!oldPath || typeof oldPath !== 'string') {
+ return res.status(400).json({ error: 'oldPath is required' });
+ }
+ if (!newPath || typeof newPath !== 'string') {
+ return res.status(400).json({ error: 'newPath is required' });
+ }
+
+ try {
+ const resolvedOld = await resolveWorkspacePathFromContext(req, oldPath);
+ if (!resolvedOld.ok) {
+ return res.status(400).json({ error: resolvedOld.error });
+ }
+ const resolvedNew = await resolveWorkspacePathFromContext(req, newPath);
+ if (!resolvedNew.ok) {
+ return res.status(400).json({ error: resolvedNew.error });
+ }
+
+ if (resolvedOld.base !== resolvedNew.base) {
+ return res.status(400).json({ error: 'Source and destination must share the same workspace root' });
+ }
+
+ await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved);
+
+ res.json({ success: true, path: resolvedNew.resolved });
+ } catch (error) {
+ const err = error;
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
+ return res.status(404).json({ error: 'Source path not found' });
+ }
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+ console.error('Failed to rename path:', error);
+ res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
+ }
+ });
+
// Execute shell commands in a directory (for worktree setup)
// NOTE: This route supports background execution to avoid tying up browser connections.
const execJobs = new Map();
diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts
index 22a39279..9de32771 100644
--- a/packages/web/src/api/files.ts
+++ b/packages/web/src/api/files.ts
@@ -161,4 +161,40 @@ export const createWebFilesAPI = (): FilesAPI => ({
path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : target,
};
},
+
+ async delete(path: string): Promise<{ success: boolean }> {
+ const target = normalizePath(path);
+ const response = await fetch('/api/fs/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: target }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error((error as { error?: string }).error || 'Failed to delete file');
+ }
+
+ const result = await response.json().catch(() => ({}));
+ return { success: Boolean((result as { success?: boolean }).success) };
+ },
+
+ async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> {
+ const response = await fetch('/api/fs/rename', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ oldPath, newPath }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error((error as { error?: string }).error || 'Failed to rename file');
+ }
+
+ const result = await response.json().catch(() => ({}));
+ return {
+ success: Boolean((result as { success?: boolean }).success),
+ path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : newPath,
+ };
+ },
});
diff --git a/scripts/monitor.sh b/scripts/monitor.sh
new file mode 100755
index 00000000..757d9161
--- /dev/null
+++ b/scripts/monitor.sh
@@ -0,0 +1,292 @@
+#!/bin/bash
+# ==============================================================================
+# OpenCode Monitor & Self-Heal Script
+# ==============================================================================
+# This script monitors OpenCode (TTY), OpenChamber, and OpenCode Web services,
+# automatically restarting them if they crash. It also manages the tunnel connections.
+#
+# Usage: ./monitor.sh
+#
+# Arguments:
+# tunnel_provider: "ngrok" or "cloudflare"
+# timeout_minutes: Auto-shutdown timeout in minutes
+# url_tty: Initial tunnel URL for OpenCode TTY
+# url_chamber: Initial tunnel URL for OpenChamber
+# url_web: Initial tunnel URL for OpenCode Web
+# ==============================================================================
+
+set -uo pipefail
+
+TUNNEL_PROVIDER="${1:-cloudflare}"
+TIMEOUT_MINUTES="${2:-300}"
+URL_TTY="${3:-}"
+URL_CHAMBER="${4:-}"
+URL_WEB="${5:-}"
+
+echo "=============================================="
+echo "OpenCode Monitor & Self-Heal"
+echo "=============================================="
+echo "Tunnel Provider: $TUNNEL_PROVIDER"
+echo "Timeout: $TIMEOUT_MINUTES minute(s)"
+echo "----------------------------------------------"
+echo "OpenCode TTY URL: $URL_TTY"
+echo "OpenChamber URL: $URL_CHAMBER"
+echo "OpenCode Web URL: $URL_WEB"
+echo "=============================================="
+echo ""
+
+# ------------------------------------------------------------------------------
+# Configuration
+# ------------------------------------------------------------------------------
+TTY_PORT=7681
+OPENCHAMBER_PORT=9090
+OPENCODE_WEB_PORT=8080
+
+START_TIME=$(date +%s)
+TIMEOUT_SECONDS=$((TIMEOUT_MINUTES * 60))
+
+# ------------------------------------------------------------------------------
+# Helper Functions
+# ------------------------------------------------------------------------------
+
+log() {
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
+}
+
+check_port() {
+ local port=$1
+ lsof -i :"$port" > /dev/null 2>&1
+}
+
+get_remaining_time() {
+ local elapsed=$(($(date +%s) - START_TIME))
+ local remaining=$((TIMEOUT_SECONDS - elapsed))
+ echo $remaining
+}
+
+format_time() {
+ local seconds=$1
+ local minutes=$((seconds / 60))
+ local hours=$((minutes / 60))
+ minutes=$((minutes % 60))
+
+ if [ $hours -gt 0 ]; then
+ echo "${hours}h ${minutes}m"
+ else
+ echo "${minutes}m"
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Service Management
+# ------------------------------------------------------------------------------
+
+restart_opencode_tty() {
+ log "Restarting OpenCode TTY on port $TTY_PORT..."
+ pkill -f "ttyd" 2>/dev/null || true
+ sleep 2
+
+ if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then
+ log "Restarting TTY with password protection."
+ nohup stdbuf -oL ttyd -c "user:${OPENCHAMBER_UI_PASSWORD}" -p $TTY_PORT bash -c "cd \$HOME && exec opencode" >> opencode_tty.log 2>&1 &
+ else
+ nohup stdbuf -oL ttyd -p $TTY_PORT bash -c "cd \$HOME && exec opencode" >> opencode_tty.log 2>&1 &
+ fi
+ sleep 5
+
+ if check_port $TTY_PORT; then
+ log "OpenCode TTY restarted successfully"
+ return 0
+ else
+ log "ERROR: Failed to restart OpenCode TTY"
+ return 1
+ fi
+}
+
+restart_openchamber() {
+ log "Restarting OpenChamber on port $OPENCHAMBER_PORT..."
+ pkill -f "openchamber" 2>/dev/null || true
+ sleep 2
+ if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then
+ nohup stdbuf -oL openchamber --port $OPENCHAMBER_PORT --ui-password "$OPENCHAMBER_UI_PASSWORD" >> openchamber.log 2>&1 &
+ else
+ nohup stdbuf -oL openchamber --port $OPENCHAMBER_PORT >> openchamber.log 2>&1 &
+ fi
+ sleep 5
+
+ if check_port $OPENCHAMBER_PORT; then
+ log "OpenChamber restarted successfully"
+ return 0
+ else
+ log "ERROR: Failed to restart OpenChamber"
+ return 1
+ fi
+}
+
+restart_opencode_web() {
+ log "Restarting OpenCode Web on port $OPENCODE_WEB_PORT..."
+ # Note: "opencode web" might be matched by "opencode" so we use full command in pkill if possible or handle order
+ pkill -f "opencode web" 2>/dev/null || true
+ sleep 2
+ if [ -n "${OPENCHAMBER_UI_PASSWORD:-}" ]; then
+ OPENCODE_SERVER_PASSWORD="$OPENCHAMBER_UI_PASSWORD" nohup stdbuf -oL opencode web --port $OPENCODE_WEB_PORT >> opencode_web.log 2>&1 &
+ else
+ nohup stdbuf -oL opencode web --port $OPENCODE_WEB_PORT >> opencode_web.log 2>&1 &
+ fi
+ sleep 5
+
+ if check_port $OPENCODE_WEB_PORT; then
+ log "OpenCode Web restarted successfully"
+ return 0
+ else
+ log "ERROR: Failed to restart OpenCode Web"
+ return 1
+ fi
+}
+
+restart_tunnels() {
+ log "Restarting tunnels ($TUNNEL_PROVIDER)..."
+
+ if [ "$TUNNEL_PROVIDER" = "ngrok" ]; then
+ pkill -f "ngrok" 2>/dev/null || true
+ sleep 2
+ # Complex to manage multiple ngrok tunnels without config file.
+ # For now, attempting to restore single tunnel logic or just warn.
+ log "WARNING: Multi-tunnel restart for ngrok not fully supported in this script version."
+ # Attempt to start tunnels again (blindly)
+ nohup ngrok http 127.0.0.1:$OPENCHAMBER_PORT --log=stdout > tunnel_chamber.log 2>&1 &
+ nohup ngrok http 127.0.0.1:$OPENCODE_WEB_PORT --log=stdout > tunnel_web.log 2>&1 &
+ nohup ngrok http 127.0.0.1:$TTY_PORT --log=stdout > tunnel_tty.log 2>&1 &
+ sleep 10
+ else
+ pkill -f "cloudflared" 2>/dev/null || true
+ sleep 2
+ nohup cloudflared tunnel --url http://127.0.0.1:$TTY_PORT > tunnel_tty.log 2>&1 &
+ nohup cloudflared tunnel --url http://127.0.0.1:$OPENCHAMBER_PORT > tunnel_chamber.log 2>&1 &
+ nohup cloudflared tunnel --url http://127.0.0.1:$OPENCODE_WEB_PORT > tunnel_web.log 2>&1 &
+ sleep 15
+
+ # Get new URLs
+ URL_TTY=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_tty.log 2>/dev/null | tail -n 1)
+ URL_CHAMBER=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_chamber.log 2>/dev/null | tail -n 1)
+ URL_WEB=$(grep -o 'https://[-a-z0-9.]*trycloudflare.com' tunnel_web.log 2>/dev/null | tail -n 1)
+ fi
+
+ log "Tunnels restarted."
+ echo ""
+ echo "=============================================="
+ echo "NEW ACCESS URLS:"
+ echo "OpenCode TTY: $URL_TTY"
+ echo "OpenChamber: $URL_CHAMBER"
+ echo "OpenCode Web: $URL_WEB"
+ echo "=============================================="
+ echo ""
+}
+
+check_tunnels() {
+ if [ "$TUNNEL_PROVIDER" = "ngrok" ]; then
+ pgrep -f "ngrok" > /dev/null 2>&1
+ else
+ pgrep -f "cloudflared" > /dev/null 2>&1
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Graceful Shutdown
+# ------------------------------------------------------------------------------
+
+shutdown() {
+ log "Initiating graceful shutdown..."
+
+ # Kill all services
+ pkill -f "ttyd" 2>/dev/null || true
+ pkill -f "opencode" 2>/dev/null || true
+ pkill -f "openchamber" 2>/dev/null || true
+ pkill -f "opencode web" 2>/dev/null || true
+ pkill -f "ngrok" 2>/dev/null || true
+ pkill -f "cloudflared" 2>/dev/null || true
+
+ log "All services stopped"
+ exit 0
+}
+
+# Trap signals for graceful shutdown
+trap shutdown SIGTERM SIGINT
+
+# ==============================================================================
+# Main Monitoring Loop
+# ==============================================================================
+
+log "Starting monitoring loop..."
+echo ""
+
+# Write initial URLs to GitHub Step Summary (if running in GitHub Actions)
+if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
+ {
+ echo "## OpenChamber for Actions"
+ echo ""
+ echo "| Service | URL |"
+ echo "|---------|-----|"
+ echo "| **OpenCode TTY** | $URL_TTY |"
+ echo "| **OpenChamber** | $URL_CHAMBER |"
+ echo "| **OpenCode Web** | $URL_WEB |"
+ echo ""
+ echo "_Timeout: ${TIMEOUT_MINUTES} minutes_"
+ } >> "$GITHUB_STEP_SUMMARY"
+fi
+
+# Status display interval (every 5 minutes = 300 seconds)
+LAST_STATUS_TIME=0
+STATUS_INTERVAL=300
+
+while true; do
+ REMAINING=$(get_remaining_time)
+
+ # Check for timeout
+ if [ "$REMAINING" -le 0 ]; then
+ log "Timeout reached. Initiating graceful shutdown..."
+ shutdown
+ fi
+
+ # Periodic status update (every 5 minutes)
+ CURRENT_TIME=$(date +%s)
+ if [ $((CURRENT_TIME - LAST_STATUS_TIME)) -ge $STATUS_INTERVAL ]; then
+ echo ""
+ echo "=============================================="
+ log "Status Update"
+ echo "Time remaining: $(format_time "$REMAINING")"
+ echo "OpenCode TTY: $URL_TTY"
+ echo "OpenChamber: $URL_CHAMBER"
+ echo "OpenCode Web: $URL_WEB"
+ echo "=============================================="
+ echo ""
+ LAST_STATUS_TIME=$CURRENT_TIME
+ fi
+
+ # Check OpenCode TTY
+ if ! check_port $TTY_PORT; then
+ log "OpenCode TTY not responding on port $TTY_PORT"
+ restart_opencode_tty
+ fi
+
+ # Check OpenChamber
+ if ! check_port $OPENCHAMBER_PORT; then
+ log "OpenChamber not responding on port $OPENCHAMBER_PORT"
+ restart_openchamber
+ fi
+
+ # Check OpenCode Web
+ if ! check_port $OPENCODE_WEB_PORT; then
+ log "OpenCode Web not responding on port $OPENCODE_WEB_PORT"
+ restart_opencode_web
+ fi
+
+ # Check Tunnel Process (Basic check)
+ if ! check_tunnels; then
+ log "Tunnel processes not running"
+ restart_tunnels
+ fi
+
+ # Sleep before next check
+ sleep 5
+done
diff --git a/scripts/opencode-config.sh b/scripts/opencode-config.sh
new file mode 100755
index 00000000..4e319ca0
--- /dev/null
+++ b/scripts/opencode-config.sh
@@ -0,0 +1,167 @@
+#!/bin/bash
+# ==============================================================================
+# OpenCode Configuration Script
+# ==============================================================================
+# This script manages OpenCode configuration with intelligent detection of
+# restored artifacts. If a config file exists from a previous session (restored
+# via artifact), it will be preserved. Otherwise, a default config is generated.
+# ==============================================================================
+
+set -euo pipefail
+
+CONFIG_DIR="$HOME/.config/opencode"
+RESTORE_DIR="${RESTORE_DIR:-/tmp/opencode-restore}"
+
+echo "=== OpenCode Configuration Setup ==="
+echo "Config directory: $CONFIG_DIR"
+echo "Restore directory: $RESTORE_DIR"
+
+# Create config directory if it doesn't exist
+mkdir -p "$CONFIG_DIR"
+
+# ------------------------------------------------------------------------------
+# Check for restored configuration (artifact preference)
+# ------------------------------------------------------------------------------
+check_restored_config() {
+ local restored_config_json="$RESTORE_DIR/config/opencode.json"
+ local restored_config_yml="$RESTORE_DIR/config/opencode.yml"
+ local restored_config_yaml="$RESTORE_DIR/config/opencode.yaml"
+
+ # Priority: .yml > .yaml > .json from artifacts
+ if [ -f "$restored_config_yml" ]; then
+ echo "Found restored opencode.yml from artifact - using it"
+ cp -v "$restored_config_yml" "$CONFIG_DIR/opencode.yml"
+ return 0
+ elif [ -f "$restored_config_yaml" ]; then
+ echo "Found restored opencode.yaml from artifact - using it"
+ cp -v "$restored_config_yaml" "$CONFIG_DIR/opencode.yaml"
+ return 0
+ elif [ -f "$restored_config_json" ]; then
+ echo "Found restored opencode.json from artifact - using it"
+ cp -v "$restored_config_json" "$CONFIG_DIR/opencode.json"
+ return 0
+ fi
+
+ return 1
+}
+
+# ------------------------------------------------------------------------------
+# Check for existing configuration in config directory
+# ------------------------------------------------------------------------------
+check_existing_config() {
+ if [ -f "$CONFIG_DIR/opencode.yml" ] || \
+ [ -f "$CONFIG_DIR/opencode.yaml" ] || \
+ [ -f "$CONFIG_DIR/opencode.json" ]; then
+ echo "Existing configuration found in $CONFIG_DIR - keeping it"
+ ls -la "$CONFIG_DIR"/opencode.* 2>/dev/null || true
+ return 0
+ fi
+ return 1
+}
+
+# ------------------------------------------------------------------------------
+# Generate default configuration
+# ------------------------------------------------------------------------------
+generate_default_config() {
+ echo "Generating default OpenCode configuration..."
+
+ cat << 'EOF' > "$CONFIG_DIR/opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["opencode-antigravity-auth@beta"],
+ "provider": {
+ "google": {
+ "models": {
+ "antigravity-gemini-3-pro": {
+ "name": "Gemini 3 Pro (Antigravity)",
+ "limit": { "context": 1048576, "output": 65535 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
+ "variants": {
+ "low": { "thinkingLevel": "low" },
+ "high": { "thinkingLevel": "high" }
+ }
+ },
+ "antigravity-gemini-3-flash": {
+ "name": "Gemini 3 Flash (Antigravity)",
+ "limit": { "context": 1048576, "output": 65536 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
+ "variants": {
+ "minimal": { "thinkingLevel": "minimal" },
+ "low": { "thinkingLevel": "low" },
+ "medium": { "thinkingLevel": "medium" },
+ "high": { "thinkingLevel": "high" }
+ }
+ },
+ "antigravity-claude-sonnet-4-5": {
+ "name": "Claude Sonnet 4.5 (no thinking) (Antigravity)",
+ "limit": { "context": 200000, "output": 64000 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
+ },
+ "antigravity-claude-sonnet-4-5-thinking": {
+ "name": "Claude Sonnet 4.5 Thinking (Antigravity)",
+ "limit": { "context": 200000, "output": 64000 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
+ "variants": {
+ "low": { "thinkingConfig": { "thinkingBudget": 8192 } },
+ "max": { "thinkingConfig": { "thinkingBudget": 32768 } }
+ }
+ },
+ "antigravity-claude-opus-4-5-thinking": {
+ "name": "Claude Opus 4.5 Thinking (Antigravity)",
+ "limit": { "context": 200000, "output": 64000 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
+ "variants": {
+ "low": { "thinkingConfig": { "thinkingBudget": 8192 } },
+ "max": { "thinkingConfig": { "thinkingBudget": 32768 } }
+ }
+ },
+ "gemini-2.5-flash": {
+ "name": "Gemini 2.5 Flash (Gemini CLI)",
+ "limit": { "context": 1048576, "output": 65536 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
+ },
+ "gemini-2.5-pro": {
+ "name": "Gemini 2.5 Pro (Gemini CLI)",
+ "limit": { "context": 1048576, "output": 65536 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
+ },
+ "gemini-3-flash-preview": {
+ "name": "Gemini 3 Flash Preview (Gemini CLI)",
+ "limit": { "context": 1048576, "output": 65536 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
+ },
+ "gemini-3-pro-preview": {
+ "name": "Gemini 3 Pro Preview (Gemini CLI)",
+ "limit": { "context": 1048576, "output": 65535 },
+ "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
+ }
+ }
+ }
+ }
+}
+EOF
+
+ echo "Default configuration created at $CONFIG_DIR/opencode.json"
+}
+
+# ==============================================================================
+# Main Logic
+# ==============================================================================
+
+# Step 1: Check if config was restored from artifact (highest priority)
+if check_restored_config; then
+ echo "Using restored configuration from artifact"
+# Step 2: Check if config already exists (from previous run or manual setup)
+elif check_existing_config; then
+ echo "Using existing configuration"
+# Step 3: No config found - generate default
+else
+ generate_default_config
+fi
+
+echo ""
+echo "=== Configuration Summary ==="
+echo "Active configuration files:"
+ls -la "$CONFIG_DIR"/*.json "$CONFIG_DIR"/*.yml "$CONFIG_DIR"/*.yaml 2>/dev/null || echo "No config files found"
+echo ""
+echo "Configuration setup complete!"
diff --git a/scripts/persistence-restore.sh b/scripts/persistence-restore.sh
new file mode 100755
index 00000000..5115c5bf
--- /dev/null
+++ b/scripts/persistence-restore.sh
@@ -0,0 +1,182 @@
+#!/bin/bash
+# ==============================================================================
+# OpenCode Persistence Restore Script
+# ==============================================================================
+# This script restores session data from GitHub Actions artifacts.
+# It handles the restoration of:
+# - Configuration files (~/.config/opencode/)
+# - Session data, messages, chats (~/.local/share/opencode/storage/)
+# - Project snapshots (~/.local/share/opencode/snapshot/)
+# - Authentication tokens (~/.local/share/opencode/auth.json)
+#
+# If the artifact is encrypted (session.enc exists), it will be decrypted
+# using OPENCODE_SERVER_PASSWORD.
+# ==============================================================================
+
+set -euo pipefail
+
+RESTORE_DIR="${RESTORE_DIR:-/tmp/opencode-restore}"
+CONFIG_DIR="$HOME/.config/opencode"
+SHARE_DIR="$HOME/.local/share/opencode"
+ENCRYPTION_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}"
+
+echo "=== OpenCode Session Restore ==="
+echo "Restore directory: $RESTORE_DIR"
+echo "Config directory: $CONFIG_DIR"
+echo "Share directory: $SHARE_DIR"
+echo ""
+
+# ------------------------------------------------------------------------------
+# Check for encrypted artifact and decrypt if needed
+# ------------------------------------------------------------------------------
+if [ -f "$RESTORE_DIR/session.enc" ]; then
+ echo "=== Encrypted Artifact Detected ==="
+
+ if [ -z "$ENCRYPTION_PASSWORD" ]; then
+ echo "ERROR: Encrypted artifact found but OPENCODE_SERVER_PASSWORD is not set."
+ echo "Cannot decrypt session data. Starting fresh."
+ rm -rf "$RESTORE_DIR"
+ exit 0
+ fi
+
+ echo "Decrypting session data..."
+ TEMP_ARCHIVE="/tmp/opencode-session-data.tar.gz"
+
+ if openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 \
+ -in "$RESTORE_DIR/session.enc" \
+ -out "$TEMP_ARCHIVE" \
+ -pass pass:"$ENCRYPTION_PASSWORD" 2>/dev/null; then
+
+ rm -rf "$RESTORE_DIR"
+ mkdir -p "$RESTORE_DIR"
+ tar -xzf "$TEMP_ARCHIVE" -C "$RESTORE_DIR"
+ rm -f "$TEMP_ARCHIVE"
+ echo "Decryption successful."
+ else
+ echo "ERROR: Decryption failed. Password may be incorrect."
+ echo "Starting fresh session."
+ rm -rf "$RESTORE_DIR"
+ rm -f "$TEMP_ARCHIVE"
+ exit 0
+ fi
+ echo ""
+fi
+
+# ------------------------------------------------------------------------------
+# Debug: Show what we're working with
+# ------------------------------------------------------------------------------
+echo "=== RESTORE DEBUG ==="
+echo "Contents of $RESTORE_DIR:"
+if [ -d "$RESTORE_DIR" ]; then
+ ls -la "$RESTORE_DIR/" 2>/dev/null || echo "Directory exists but empty"
+ echo ""
+ echo "Full tree of restore directory:"
+ find "$RESTORE_DIR" -type f 2>/dev/null | head -50 || echo "No files found"
+else
+ echo "Restore directory does not exist - fresh start"
+ exit 0
+fi
+echo ""
+
+# ------------------------------------------------------------------------------
+# Create target directories
+# ------------------------------------------------------------------------------
+mkdir -p "$CONFIG_DIR"
+mkdir -p "$SHARE_DIR"
+
+# ------------------------------------------------------------------------------
+# Restore configuration files
+# ------------------------------------------------------------------------------
+restore_config() {
+ local src="$RESTORE_DIR/config"
+
+ if [ -d "$src" ] && [ "$(ls -A "$src" 2>/dev/null)" ]; then
+ echo "Restoring configuration files..."
+
+ # Restore all config files except node_modules (will be reinstalled)
+ find "$src" -maxdepth 1 -type f -exec cp -v {} "$CONFIG_DIR/" \; 2>/dev/null || true
+
+ # Count restored files
+ local count=$(find "$src" -maxdepth 1 -type f 2>/dev/null | wc -l)
+ echo "Restored $count configuration file(s)"
+ else
+ echo "No configuration files to restore"
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Restore share data (sessions, messages, snapshots, etc.)
+# ------------------------------------------------------------------------------
+restore_share() {
+ local src="$RESTORE_DIR/share"
+
+ if [ -d "$src" ] && [ "$(ls -A "$src" 2>/dev/null)" ]; then
+ echo "Restoring share data..."
+
+ # Restore auth.json
+ if [ -f "$src/auth.json" ]; then
+ cp -v "$src/auth.json" "$SHARE_DIR/" 2>/dev/null || true
+ echo "Authentication data restored"
+ fi
+
+ # Restore storage directory (sessions, messages, parts, projects)
+ if [ -d "$src/storage" ]; then
+ mkdir -p "$SHARE_DIR/storage"
+ cp -rv "$src/storage/"* "$SHARE_DIR/storage/" 2>/dev/null || true
+ local storage_count=$(find "$SHARE_DIR/storage" -type f 2>/dev/null | wc -l)
+ echo "Storage data restored: $storage_count file(s)"
+ fi
+
+ # Restore snapshot directory (project snapshots)
+ if [ -d "$src/snapshot" ]; then
+ mkdir -p "$SHARE_DIR/snapshot"
+ cp -rv "$src/snapshot/"* "$SHARE_DIR/snapshot/" 2>/dev/null || true
+ local snapshot_count=$(find "$SHARE_DIR/snapshot" -type f 2>/dev/null | wc -l)
+ echo "Snapshot data restored: $snapshot_count file(s)"
+ fi
+
+ # Restore log directory
+ if [ -d "$src/log" ]; then
+ mkdir -p "$SHARE_DIR/log"
+ cp -rv "$src/log/"* "$SHARE_DIR/log/" 2>/dev/null || true
+ echo "Log data restored"
+ fi
+ else
+ echo "No share data to restore"
+ fi
+}
+
+# ==============================================================================
+# Main Restoration Process
+# ==============================================================================
+
+echo "=== Starting Restoration ==="
+
+restore_config
+echo ""
+
+restore_share
+echo ""
+
+# ------------------------------------------------------------------------------
+# Summary
+# ------------------------------------------------------------------------------
+echo "=== Restoration Summary ==="
+
+echo "Config directory contents:"
+ls -la "$CONFIG_DIR/" 2>/dev/null | head -10 || echo "Empty"
+echo ""
+
+echo "Share directory structure:"
+if [ -d "$SHARE_DIR" ]; then
+ du -sh "$SHARE_DIR"/* 2>/dev/null || echo "Empty"
+fi
+echo ""
+
+# Count total restored items
+config_files=$(find "$CONFIG_DIR" -maxdepth 1 -type f 2>/dev/null | wc -l)
+share_files=$(find "$SHARE_DIR" -type f 2>/dev/null | wc -l)
+
+echo "Total restored: $config_files config files, $share_files share files"
+echo ""
+echo "Session restoration complete!"
diff --git a/scripts/persistence-save.sh b/scripts/persistence-save.sh
new file mode 100755
index 00000000..35a3cb9d
--- /dev/null
+++ b/scripts/persistence-save.sh
@@ -0,0 +1,233 @@
+#!/bin/bash
+# ==============================================================================
+# OpenCode Persistence Save Script
+# ==============================================================================
+# This script prepares all session data for artifact upload.
+# It saves:
+# - Configuration files (~/.config/opencode/)
+# - Session data, messages, chats (~/.local/share/opencode/storage/)
+# - Project snapshots (~/.local/share/opencode/snapshot/)
+# - Authentication tokens (~/.local/share/opencode/auth.json)
+# - Logs (~/.local/share/opencode/log/)
+#
+# Excludes (to keep artifact size manageable):
+# - node_modules/ directories
+# - bin/ directory (will be reinstalled)
+# - tool-output/ (temporary tool outputs)
+#
+# If OPENCODE_SERVER_PASSWORD is set, the artifact will be encrypted.
+# ==============================================================================
+
+set -euo pipefail
+
+SAVE_DIR="${SAVE_DIR:-/tmp/opencode-save}"
+CONFIG_DIR="$HOME/.config/opencode"
+SHARE_DIR="$HOME/.local/share/opencode"
+ENCRYPTION_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}"
+
+echo "=== OpenCode Session Save ==="
+echo "Save directory: $SAVE_DIR"
+echo "Config directory: $CONFIG_DIR"
+echo "Share directory: $SHARE_DIR"
+if [ -n "$ENCRYPTION_PASSWORD" ]; then
+ echo "Encryption: ENABLED"
+else
+ echo "Encryption: DISABLED (set OPENCODE_SERVER_PASSWORD to enable)"
+fi
+echo ""
+
+# ------------------------------------------------------------------------------
+# Clean up and create save directory
+# ------------------------------------------------------------------------------
+rm -rf "$SAVE_DIR"
+mkdir -p "$SAVE_DIR/config"
+mkdir -p "$SAVE_DIR/share"
+
+# ------------------------------------------------------------------------------
+# Save configuration files
+# ------------------------------------------------------------------------------
+save_config() {
+ echo "=== Saving Configuration Files ==="
+
+ if [ -d "$CONFIG_DIR" ] && [ "$(ls -A "$CONFIG_DIR" 2>/dev/null)" ]; then
+ # Save all config files (excluding node_modules)
+ for item in "$CONFIG_DIR"/*; do
+ if [ -e "$item" ]; then
+ local basename=$(basename "$item")
+
+ # Skip node_modules and bun.lock (will be reinstalled)
+ if [ "$basename" = "node_modules" ] || [ "$basename" = "bun.lock" ]; then
+ echo "Skipping: $basename (will be reinstalled)"
+ continue
+ fi
+
+ # Copy file or directory
+ if [ -f "$item" ]; then
+ cp -v "$item" "$SAVE_DIR/config/" 2>/dev/null || true
+ fi
+ fi
+ done
+
+ local count=$(find "$SAVE_DIR/config" -type f 2>/dev/null | wc -l)
+ echo "Config files saved: $count"
+ else
+ echo "No config data to save"
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Save share data (sessions, messages, snapshots, auth)
+# ------------------------------------------------------------------------------
+save_share() {
+ echo ""
+ echo "=== Saving Share Data ==="
+
+ if [ ! -d "$SHARE_DIR" ]; then
+ echo "No share directory found"
+ return
+ fi
+
+ # Save auth.json (authentication tokens - critical!)
+ if [ -f "$SHARE_DIR/auth.json" ]; then
+ cp -v "$SHARE_DIR/auth.json" "$SAVE_DIR/share/" 2>/dev/null || true
+ echo "Auth data saved"
+ fi
+
+ # Save storage directory (sessions, messages, parts, projects)
+ if [ -d "$SHARE_DIR/storage" ] && [ "$(ls -A "$SHARE_DIR/storage" 2>/dev/null)" ]; then
+ echo "Saving storage data (sessions, messages, chats)..."
+ mkdir -p "$SAVE_DIR/share/storage"
+
+ # Copy all storage subdirectories
+ for subdir in message migration part project session session_diff; do
+ if [ -d "$SHARE_DIR/storage/$subdir" ]; then
+ cp -r "$SHARE_DIR/storage/$subdir" "$SAVE_DIR/share/storage/" 2>/dev/null || true
+ fi
+ done
+
+ local storage_count=$(find "$SAVE_DIR/share/storage" -type f 2>/dev/null | wc -l)
+ echo "Storage files saved: $storage_count"
+ fi
+
+ # Save snapshot directory (project snapshots for undo/rollback)
+ if [ -d "$SHARE_DIR/snapshot" ] && [ "$(ls -A "$SHARE_DIR/snapshot" 2>/dev/null)" ]; then
+ echo "Saving snapshot data..."
+ mkdir -p "$SAVE_DIR/share/snapshot"
+ cp -r "$SHARE_DIR/snapshot/"* "$SAVE_DIR/share/snapshot/" 2>/dev/null || true
+
+ local snapshot_count=$(find "$SAVE_DIR/share/snapshot" -type f 2>/dev/null | wc -l)
+ echo "Snapshot files saved: $snapshot_count"
+ fi
+
+ # Save log directory (useful for debugging)
+ if [ -d "$SHARE_DIR/log" ] && [ "$(ls -A "$SHARE_DIR/log" 2>/dev/null)" ]; then
+ echo "Saving log data..."
+ mkdir -p "$SAVE_DIR/share/log"
+ cp -r "$SHARE_DIR/log/"* "$SAVE_DIR/share/log/" 2>/dev/null || true
+ echo "Log files saved"
+ fi
+}
+
+# ------------------------------------------------------------------------------
+# Create manifest with metadata
+# ------------------------------------------------------------------------------
+create_manifest() {
+ echo ""
+ echo "=== Creating Manifest ==="
+
+ local manifest="$SAVE_DIR/manifest.json"
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+ local config_count=$(find "$SAVE_DIR/config" -type f 2>/dev/null | wc -l)
+ local share_count=$(find "$SAVE_DIR/share" -type f 2>/dev/null | wc -l)
+ local total_size=$(du -sh "$SAVE_DIR" 2>/dev/null | cut -f1)
+
+ cat << EOF > "$manifest"
+{
+ "version": "2.0",
+ "timestamp": "$timestamp",
+ "hostname": "$(hostname)",
+ "stats": {
+ "config_files": $config_count,
+ "share_files": $share_count,
+ "total_size": "$total_size"
+ },
+ "contents": {
+ "config": $([ -d "$SAVE_DIR/config" ] && ls "$SAVE_DIR/config" 2>/dev/null | jq -R -s -c 'split("\n") | map(select(length > 0))' || echo '[]'),
+ "share": $([ -d "$SAVE_DIR/share" ] && ls "$SAVE_DIR/share" 2>/dev/null | jq -R -s -c 'split("\n") | map(select(length > 0))' || echo '[]')
+ }
+}
+EOF
+
+ echo "Manifest created: $manifest"
+ cat "$manifest"
+}
+
+# ==============================================================================
+# Main Save Process
+# ==============================================================================
+
+save_config
+save_share
+create_manifest
+
+# ------------------------------------------------------------------------------
+# Final Summary
+# ------------------------------------------------------------------------------
+echo ""
+echo "=== Save Summary ==="
+
+# Calculate totals
+total_files=$(find "$SAVE_DIR" -type f 2>/dev/null | wc -l)
+total_size=$(du -sh "$SAVE_DIR" 2>/dev/null | cut -f1)
+
+echo "Save directory structure:"
+if command -v tree &> /dev/null; then
+ tree -L 2 "$SAVE_DIR" 2>/dev/null || ls -laR "$SAVE_DIR" | head -50
+else
+ ls -laR "$SAVE_DIR" | head -50
+fi
+
+echo ""
+echo "Total files to upload: $total_files"
+echo "Total size: $total_size"
+echo ""
+
+# Verify we have something to save
+if [ "$total_files" -gt 1 ]; then
+ echo "Session data prepared successfully for artifact upload!"
+else
+ echo "Warning: Minimal data to save. Creating placeholder..."
+ echo "{\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"status\": \"empty\"}" > "$SAVE_DIR/placeholder.json"
+fi
+
+# ------------------------------------------------------------------------------
+# Optional Encryption (if OPENCODE_SERVER_PASSWORD is set)
+# ------------------------------------------------------------------------------
+if [ -n "$ENCRYPTION_PASSWORD" ]; then
+ echo ""
+ echo "=== Encrypting Session Data ==="
+
+ TEMP_ARCHIVE="/tmp/opencode-session-data.tar.gz"
+ ENCRYPTED_FILE="$SAVE_DIR.enc"
+
+ tar -czf "$TEMP_ARCHIVE" -C "$SAVE_DIR" .
+
+ openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 \
+ -in "$TEMP_ARCHIVE" \
+ -out "$ENCRYPTED_FILE" \
+ -pass pass:"$ENCRYPTION_PASSWORD"
+
+ rm -f "$TEMP_ARCHIVE"
+ rm -rf "$SAVE_DIR"
+ mkdir -p "$SAVE_DIR"
+ mv "$ENCRYPTED_FILE" "$SAVE_DIR/session.enc"
+
+ echo '{"encrypted": true, "algorithm": "aes-256-cbc", "kdf": "pbkdf2", "iterations": 100000}' > "$SAVE_DIR/manifest.json"
+
+ echo "Encryption complete. Artifact is password-protected."
+ echo "Encrypted file: $SAVE_DIR/session.enc"
+fi
+
+echo ""
+echo "Save location: $SAVE_DIR"
+echo "Ready for artifact upload!"