Merge codex/spoon-stabilize-unify-build: 5-phase stabilize/unify/build (65 tasks)

Phases 1-5 complete: stabilization criticals, runtime unification (codex/opencode/claude
in per-user box), dev-box /machine surface, GitHub sync correctness + webhooks,
notifications + polish. Final whole-branch review passed; all packages green.
This commit is contained in:
Gabriel Brown
2026-07-12 02:53:40 -04:00
174 changed files with 13795 additions and 2144 deletions
+189 -59
View File
@@ -10,7 +10,8 @@
<p align="center">
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
durable maintenance threads, and OpenCode-powered workspaces.
durable maintenance threads, and a persistent per-user dev box that agents,
terminals, and project commands all run inside.
</p>
<p align="center">
@@ -61,14 +62,38 @@ in the data model, but GitHub is the active automation surface today.
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
changes so irrelevant commits do not keep a fork permanently actionable.
- **OpenCode workspaces**
Agent work happens in an isolated workspace with a file tree, browser editor,
diff viewer, command panel, logs, artifacts, and draft PR actions.
- **One persistent per-user box**
Every user owns a single long-running Fedora container `spoon-box-{username}`
with a persistent home. Every thread's agent turn, terminal session, and
project command `docker exec`s into that same box — there is no per-job
throwaway container.
- **Three agent runtimes**
Codex, OpenCode, and Claude Code all run inside the box behind one adapter
interface, selected per thread and validated at queue time against the AI
provider profile.
- **Workspaces**
Agent work happens in a workspace with a file tree, browser editor, diff
viewer, command panel, logs, artifacts, an interactive terminal, and draft PR
actions.
- **My Machine**
A `/machine` surface to start/stop/restart the box, open a `~`-rooted
terminal, and browse the persistent home.
- **GitHub webhooks**
A signature-verified webhook keeps drift fresh on `push` and flips connection
status on `installation` changes; the hourly cron is a fallback.
- **Notifications**
An in-app bell plus preference-gated transactional email for maintenance
threads, agent turns, needed input, sync failures, and connection re-auth.
- **User-owned providers and secrets**
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
encrypted. Secrets are redacted from logs and refused from commits when
materialized into env files.
AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
secrets are encrypted. Secrets are written `0600`, redacted from logs, and
refused from commits when materialized into env files.
- **Draft PR handoff**
Code changes become branches and draft pull requests. Spoon does not
@@ -138,12 +163,23 @@ custom work exists.
</details>
<details>
<summary><strong>OpenCode workspaces</strong></summary>
<summary><strong>The box and workspaces</strong></summary>
Spoon's optional agent worker is designed to run outside Convex actions. The
worker claims queued jobs, clones the current GitHub fork, creates a branch,
starts an isolated workspace, and exposes workspace operations to the Next app
through server-only API proxies.
worker claims queued jobs, clones the current GitHub fork into the owner's
persistent home, creates a branch, and exposes workspace operations to the Next
app through server-only API proxies.
Everything runs inside **one long-running container per user**,
`spoon-box-{username}`. The box is a Fedora image (`docker/agent-job.Dockerfile`)
started with `sleep infinity` and a `--memory 4g` / `--cpus 2` cap. Each
thread's agent turn, terminal, and project commands `docker exec` into that same
box; the box's `/home/{username}` is a bind-mounted persistent home so dotfiles,
installed tools, shell history, and thread checkouts under `~/Code/{spoon}/{branch}`
survive across sessions. The box is reference-counted by the worker and reaped
after `SPOON_AGENT_BOX_IDLE_MS` idle (default 30m). There is no per-job
`docker run --rm` container and no separate `opencode serve` container — that
path was removed.
Workspace capabilities:
@@ -154,16 +190,18 @@ Workspace capabilities:
- inspect diffs
- send thread messages to the agent
- run configured commands
- use an interactive terminal (xterm.js → box PTY; see
[docs/agent-terminal.md](docs/agent-terminal.md))
- store logs and artifacts
- push a branch
- open a draft PR
The browser never receives worker tokens and never talks directly to the worker
or job container.
or the box.
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
workspace records and ask the active worker to remove orphaned job containers
and inactive work directories.
Worker cleanup is available in `Settings -> Worker`. It can delete stale
workspace records and ask the active worker to remove orphaned containers and
inactive work directories (persistent per-user homes are preserved).
Local worker development:
@@ -174,20 +212,53 @@ bun dev:next:worker
bun dev:next:worker:staging
```
Local host-run worker commands still load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
and publishes the OpenCode server on a localhost port so the host worker can
reach the job container. Override with:
Local host-run worker commands load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available and falls back to
Docker. Override the container CLI with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
SPOON_AGENT_CONTAINER_ACCESS=host_port
```
</details>
<details>
<summary><strong>Production agent runtime images</strong></summary>
<summary><strong>Agent runtimes</strong></summary>
Three agent CLIs run inside the box behind one `AgentRuntime` adapter interface
(`apps/agent-worker/src/runtime/*-adapter.ts`), selected by `job.runtime`:
| Runtime | CLI | Selected for |
| ---------- | ----------------------------------- | -------------------------------------------------- |
| `codex` | `@openai/codex` | OpenAI providers and Codex ChatGPT-login snapshots |
| `opencode` | `opencode-ai` | OpenAI-compatible API-key providers (default) |
| `claude` | `@anthropic-ai/claude-code@2.1.207` | Anthropic providers |
The runtime is validated at queue time (`runtimeSupport.ts` +
`agentJobs.insertJob`): a job is rejected unless the resolved runtime is one the
AI provider profile supports. `runtimesForProfile` maps profiles to runtimes —
ChatGPT-login snapshots → `codex` only; `openai` API keys → `opencode`/`codex`;
Anthropic API keys → `claude`/`opencode`; Anthropic `anthropic_oauth_json`
credential snapshots → `claude` only; every other OpenAI-compatible API key →
`opencode`.
Auth is materialized into the box before a turn:
- **Codex ChatGPT-login** profiles get the encrypted `auth.json` written to
`CODEX_HOME/.codex/auth.json`.
- **Anthropic API-key** profiles authenticate via `ANTHROPIC_API_KEY` in the
environment (no file needed).
- **Anthropic `anthropic_oauth_json`** credential-snapshot profiles get the
encrypted OAuth blob written to `~/.claude/.credentials.json`.
- **API-key** profiles run through OpenCode.
All materialized auth files are written `0600`. Treat those saved auth files
like a password and only use them on trusted workers.
</details>
<details>
<summary><strong>Production runtime images</strong></summary>
Gitea CI builds and pushes three production images:
@@ -197,31 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker:latest
git.gbrown.org/gib/spoon-agent-job:latest
```
The worker image is the long-running service that polls Convex. The job image is
the isolated workbench that the worker launches for each agent job. For the MVP,
production should use the repo-provided JS/TS workbench image:
The worker image is the long-running service that polls Convex. The
`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
worker `exec`s into (it is not launched fresh per job). Point the worker at it
with:
```env
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
```
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
project's production runtime; it is the agent execution environment.
The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
zoxide, gh, gum, oh-my-posh) and the pinned agent CLIs OpenCode, Codex, and
Claude Code. It is not the forked project's production runtime; it is the agent
execution environment.
Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service.
- The worker needs `/var/run/docker.sock` mounted so it can launch job
containers.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
`SPOON_AGENT_CONTAINER_ACCESS=network`.
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
can pull the private `spoon-agent-job` image.
- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
into per-user boxes on the host daemon.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
`SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
the host daemon can bind-mount per-user homes under `${WORKDIR}/homes/{username}`.
- The production Docker host must be logged into `git.gbrown.org` so the worker
can pull the private `spoon-agent-job` box image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
file, diff, message, command, and draft PR actions.
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
box file, diff, message, command, terminal, and draft PR actions.
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
If the private key is stored in a single-line dotenv value, encode newlines as
literal `\n` characters so the worker can restore the PEM before using it.
@@ -240,22 +316,57 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
Deployment readiness checklist:
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
GitHub App env, and Convex Auth signing keys.
GitHub App env (including `GITHUB_APP_WEBHOOK_SECRET`), and Convex Auth
signing keys.
2. Compose env has `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
App private key.
App private key; the `spoon-next` image is built with
`NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL`.
3. The production Docker host can pull private images from `git.gbrown.org`.
4. `Settings -> Worker` reports the expected job image, runtime, network, and
active workspace count.
4. `Settings -> Worker` reports the expected box image, runtime, network, and
active box count.
5. The first test thread uses a configured API-key provider or a trusted Codex
login profile.
6. If a worker restart leaves stale workspace state, use the workspace recovery
panel or `Settings -> Worker` cleanup.
6. If a worker restart leaves stale state, use the workspace recovery panel or
`Settings -> Worker` cleanup.
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
Treat that saved auth file like a password and only use it on trusted workers.
</details>
<details>
<summary><strong>GitHub webhooks</strong></summary>
Spoon exposes a signature-verified webhook as a Convex `httpAction` at
`POST /webhooks/github` (`packages/backend/convex/githubWebhooks.ts`,
`http.ts`). Configure the GitHub App webhook URL as
`<CONVEX_SITE_URL>/webhooks/github` and set `GITHUB_APP_WEBHOOK_SECRET`; the
endpoint fails closed (HTTP 503) when no secret is configured and rejects any
payload whose `x-hub-signature-256` does not verify.
- `push` → a targeted drift refresh for every Spoon tracking that repository.
- `installation` / `installation_repositories` → the matching connection's
status flips to `revoked` (deleted/suspended), `active` (created/unsuspended),
or otherwise `needs_reauth`, surfaced in `Settings -> Integrations`.
The hourly `refreshDueSpoons` cron remains a fallback, so drift still refreshes
even if a webhook is missed.
</details>
<details>
<summary><strong>Notifications</strong></summary>
Spoon delivers an in-app bell plus preference-gated transactional email
(`packages/backend/convex/notifications.ts`). Five kinds are emitted:
`maintenance_thread`, `agent_turn_finished`, `agent_needs_input`, `sync_failed`,
and `connection_needs_reauth`.
- Every event inserts an in-app notification (bell badge via `unreadCount`,
list via `listMine`, `markRead` / `markAllRead`).
- Email is sent through UseSend (`USESEND_*`) only when the user has an email
and the matching per-kind preference is not disabled. Unset preferences
default to enabled.
- Per-kind email toggles live in `Settings -> Notifications`. Web push is out of
scope.
</details>
@@ -285,7 +396,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
<summary><strong>Core tables</strong></summary>
| Table | Purpose |
| ------------------------ | --------------------------------------------------------- |
| ------------------------- | ----------------------------------------------------------- |
| `spoons` | Managed fork records |
| `threads` | Durable maintenance and work conversations |
| `threadMessages` | Messages inside threads |
@@ -297,11 +408,15 @@ Treat that saved auth file like a password and only use it on trusted workers.
| `spoonPullRequests` | Cached fork/upstream pull requests |
| `spoonSecrets` | Encrypted per-Spoon environment variables |
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
| `agentJobEvents` | Append-only worker event log |
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
| `userDotfiles` | Encrypted per-user dotfiles overlay + repo/setup config |
| `userEnvironment` | Per-user box/home environment config |
| `notifications` | In-app notification rows (bell) |
| `notificationPreferences` | Per-user email notification toggles |
</details>
@@ -309,7 +424,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
<summary><strong>Important routes</strong></summary>
| Route | Purpose |
| --------------------------------- | --------------------------------------- |
| --------------------------------- | ----------------------------------------------- |
| `/` | Public product landing page |
| `/dashboard` | Maintenance overview |
| `/spoons` | Managed fork list |
@@ -318,11 +433,16 @@ Treat that saved auth file like a password and only use it on trusted workers.
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
| `/threads` | Global thread queue |
| `/threads/[threadId]` | Thread detail |
| `/machine` | My Machine: box status, terminal, home |
| `/settings/profile` | User profile settings |
| `/settings/integrations` | GitHub and service integration settings |
| `/settings/ai-providers` | AI/OpenCode provider profiles |
| `/settings/ai-providers` | AI provider profiles (Codex/OpenCode/Anthropic) |
| `/settings/dotfiles` | Per-user dotfiles overlay + repo |
| `/settings/notifications` | Email notification preferences |
Legacy `/updates` and `/agents` routes redirect into `/threads`.
The Convex `httpAction` `POST /webhooks/github` (mounted at
`<CONVEX_SITE_URL>/webhooks/github`) handles GitHub App webhooks. Legacy
`/updates` and `/agents` routes redirect into `/threads`.
</details>
@@ -424,9 +544,10 @@ not call Infisical.
<summary><strong>Public Next variables</strong></summary>
| Variable | Used for |
| --------------------------------- | ------------------------------------------- |
| --------------------------------------- | ------------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
@@ -487,15 +608,16 @@ not call Infisical.
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
| `SPOON_AGENT_TERMINAL_SECRET` | HMAC secret for terminal/box tokens (falls back to the internal/worker token) |
| `SPOON_AGENT_JOB_IMAGE` | Per-user box (Fedora) image the worker `exec`s into |
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
| `SPOON_AGENT_BOX_IDLE_MS` | Idle time before a per-user box is reaped (default `1800000`) |
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Agent turn timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory; per-user homes live under `homes/{username}` |
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
| `SPOON_AGENT_NETWORK` | Optional job container network |
| `SPOON_AGENT_NETWORK` | Optional box container network |
</details>
@@ -523,12 +645,20 @@ not call Infisical.
- GitHub drift refresh, commit cache, PR cache, and sync-run history
- Effective drift and ignored upstream change records
- Global Threads page and Spoon-scoped Threads tab
- OpenCode/Codex-oriented agent worker and browser workspace foundation
- Persistent per-user box the worker `exec`s into for agent turns, terminal, and
commands
- Three agent runtimes (Codex, OpenCode, Claude Code) behind one adapter, with
queue-time runtime validation
- My Machine surface (box status/start/stop/restart, terminal, home browser)
- Interactive workspace terminal (xterm.js → box PTY)
- Per-user dotfiles overlay + optional dotfiles repo
- Signature-verified GitHub webhook with hourly cron fallback
- In-app + email notifications with per-user preferences
- Monaco editor with optional Vim mode
- Diff viewer, command panel, worker logs, and artifacts
- Encrypted Spoon secrets and bulk `.env` import
- Encrypted AI provider profiles, including Codex auth JSON and API-key
provider support
- Encrypted AI provider profiles, including Codex/Anthropic auth JSON and
API-key provider support
- Authentik, GitHub, and password auth through Convex Auth
- Self-hosted Convex/Postgres deployment model
+161
View File
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
return events;
};
// Claude Code `--output-format stream-json` emits one JSON object per line:
// `system:init` announces the session id, `assistant` messages carry text and
// tool_use blocks, `user` messages carry tool_result blocks, and `result`
// terminates the turn (success with final text, or an `error_*` subtype).
export const normalizeClaudeJsonLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const event = asRecord(parsed);
if (!event) return [{ kind: 'status', status: line }];
const type = stringify(event.type);
const subtype = stringify(event.subtype);
const events: NormalizedAgentEvent[] = [];
if (type === 'system') {
const sessionId = stringify(event.session_id);
if (subtype === 'init' && sessionId) {
events.push({ kind: 'session', sessionId });
}
if (events.length === 0) {
events.push({ kind: 'status', status: subtype || 'system' });
}
return events;
}
if (type === 'assistant') {
const message = asRecord(event.message);
const content = message?.content;
let text = '';
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record) continue;
const blockType = stringify(record.type);
if (blockType === 'text') {
text += textFromPart(record);
} else if (blockType === 'tool_use') {
events.push({
kind: 'tool_started',
name: stringify(record.name ?? 'tool'),
input: JSON.stringify(record.input ?? {}),
});
}
}
}
if (text) events.unshift({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: 'assistant' });
}
return events;
}
if (type === 'content_block_delta') {
const delta = asRecord(event.delta);
const text = delta ? textFromPart(delta) : '';
if (text) events.push({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: type });
}
return events;
}
if (type === 'user') {
const message = asRecord(event.message);
const content = message?.content;
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record || stringify(record.type) !== 'tool_result') continue;
events.push({
kind: 'tool_completed',
name: 'tool',
output: toolOutputFromRecord(record),
});
}
}
if (events.length === 0) {
events.push({ kind: 'status', status: 'user' });
}
return events;
}
if (type === 'result') {
const sessionId = stringify(event.session_id);
if (sessionId) events.push({ kind: 'session', sessionId });
if (subtype === 'success') {
events.push({
kind: 'assistant_completed',
content: stringify(event.result),
});
} else {
events.push({
kind: 'error',
message: stringify(event.result ?? event.error ?? subtype),
});
}
return events;
}
return [{ kind: 'status', status: type || line }];
};
export const normalizeOpenCodeEvent = (
input: unknown,
): NormalizedAgentEvent[] => {
@@ -464,3 +572,56 @@ export const normalizeOpenCodeEvent = (
}
return events;
};
// `opencode run --format json` emits newline-delimited JSON: streamed events share
// the server's `{ type, properties }` SSE envelope, plus a final result object that
// carries the session id and the assistant's completed text.
export const normalizeOpenCodeRunLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const record = asRecord(parsed);
if (!record) return [{ kind: 'status', status: line }];
if ('type' in record && 'properties' in record) {
return normalizeOpenCodeEvent(record);
}
const events: NormalizedAgentEvent[] = [];
const sessionId = record.sessionID ?? record.sessionId;
if (typeof sessionId === 'string') {
events.push({ kind: 'session', sessionId });
}
const parts = record.parts;
let text = '';
if (Array.isArray(parts)) {
text = parts
.map((part) => {
const record = asRecord(part);
return record ? textFromPart(record) : '';
})
.join('');
}
if (!text) {
const message = asRecord(record.message);
text =
textFromPart(record) ||
(message ? stringify(message.content ?? message.text) : '');
}
if (text) {
const externalMessageId = stringify(record.messageID ?? record.id);
events.push({
kind: 'assistant_completed',
content: text,
...(externalMessageId ? { externalMessageId } : {}),
});
}
if (events.length === 0) {
events.push({ kind: 'status', status: line });
}
return events;
};
+153
View File
@@ -0,0 +1,153 @@
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { env } from './env';
import { assertContainedRealPath } from './path-containment';
import {
ensureUserContainer,
inspectUserBoxStatus,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
import { resetBox } from './user-container';
// Matches FileTreeNode in apps/next/src/components/agent-workspace/types.ts.
type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
// Per-user box home layout, shared by the HTTP routes and the terminal WS bridge
// so path derivation lives in exactly one place (must match worker.ts).
export const boxHomePaths = (username: string) => ({
homeDir: path.resolve(env.workdir, 'homes', username),
containerHome: path.posix.join('/home', username),
});
export type BoxStatus = {
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
containerName: string;
};
export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
const status = await inspectUserBoxStatus(username);
return { ...status, containerName: userContainerName(username) };
};
export const startBox = async (username: string): Promise<void> => {
const { homeDir, containerHome } = boxHomePaths(username);
await ensureUserContainer({ username, workdir: homeDir, containerHome });
};
export const stopBox = async (username: string): Promise<void> => {
await stopWorkspaceContainer(userContainerName(username));
resetBox(username);
};
export const restartBox = async (username: string): Promise<void> => {
await stopBox(username);
await startBox(username);
};
// Directories never surfaced in the box file tree — VCS internals, package
// caches and build output that would bloat (or hang) the walk.
const BOX_TREE_IGNORE = new Set([
'.git',
'node_modules',
'.cache',
'.npm',
'.bun',
'.cargo',
'dist',
'build',
'.next',
]);
// Home-relative paths (POSIX) that are always skipped regardless of depth.
const BOX_TREE_IGNORE_PATHS = new Set(['.local/share/Trash']);
// Guard against a huge home hanging the request; stop descending past this.
const BOX_TREE_MAX_NODES = 5000;
// Realpath-checked absolute path inside the user's home. Rejects lexical `..`
// escapes and symlinks that resolve outside home (reuses the Phase-1
// containment helper); for writes, also rejects a symlinked final target.
const safeHomePath = (
homeDir: string,
relPath: string,
opts: { forWrite?: boolean } = {},
): Promise<string> =>
assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' });
export const listBoxTree = async (username: string): Promise<FileTreeNode> => {
const { homeDir } = boxHomePaths(username);
let count = 0;
const buildChildren = async (
absoluteDir: string,
relativeDir: string,
): Promise<FileTreeNode[]> => {
const entries = await readdir(absoluteDir);
const nodes = await Promise.all(
entries
.sort((a, b) => a.localeCompare(b))
.map(async (entry): Promise<FileTreeNode | null> => {
const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
if (
BOX_TREE_IGNORE.has(entry) ||
BOX_TREE_IGNORE_PATHS.has(relativePath)
) {
return null;
}
if (count >= BOX_TREE_MAX_NODES) return null;
count += 1;
const absolutePath = path.join(absoluteDir, entry);
const stats = await stat(absolutePath).catch(() => null);
if (!stats) return null;
if (stats.isDirectory()) {
return {
name: entry,
path: relativePath,
type: 'directory',
children: await buildChildren(absolutePath, relativePath),
};
}
return { name: entry, path: relativePath, type: 'file' };
}),
);
return nodes.filter((node): node is FileTreeNode => Boolean(node));
};
return {
name: '~',
path: '',
type: 'directory',
children: await buildChildren(homeDir, ''),
};
};
export const readBoxFile = async (
username: string,
relPath: string,
): Promise<string> => {
const { homeDir } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath);
return await readFile(target, 'utf8');
};
export const writeBoxFile = async (
username: string,
relPath: string,
content: string,
): Promise<{ success: true }> => {
const { homeDir } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
return { success: true };
};
+88
View File
@@ -0,0 +1,88 @@
type CommandResult = { exitCode: number; output: string };
const launchScript = `
import os
import sys
pid_file, job_id, *command = sys.argv[1:]
if os.getpgrp() != os.getpid():
os.setsid()
os.environ["SPOON_CODEX_JOB_ID"] = job_id
with open(pid_file, "w", encoding="utf-8") as handle:
handle.write(str(os.getpid()))
handle.flush()
os.fsync(handle.fileno())
os.execvpe(command[0], command, os.environ)
`;
const abortScript = `
import os
import signal
import sys
import time
pid_file, job_id = sys.argv[1:]
pid = None
for _ in range(20):
try:
with open(pid_file, encoding="utf-8") as handle:
pid = int(handle.read().strip())
break
except FileNotFoundError:
time.sleep(0.1)
if pid is None:
sys.exit(0)
try:
with open(f"/proc/{pid}/environ", "rb") as handle:
environment = handle.read().split(b"\\0")
marker = f"SPOON_CODEX_JOB_ID={job_id}".encode()
if marker not in environment or os.getpgid(pid) != pid:
raise RuntimeError("Codex process identity did not match the requested job")
os.killpg(pid, signal.SIGTERM)
for _ in range(20):
time.sleep(0.1)
try:
os.killpg(pid, 0)
except ProcessLookupError:
break
else:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
finally:
try:
os.unlink(pid_file)
except FileNotFoundError:
pass
`;
export const managedCodexCommand = (args: {
jobId: string;
pidFile: string;
command: string[];
}) => [
'python3',
'-c',
launchScript,
args.pidFile,
args.jobId,
...args.command,
];
export const abortManagedCodexProcess = async (args: {
jobId: string;
pidFile: string;
execute: (command: string[]) => Promise<CommandResult>;
}) => {
const result = await args.execute([
'python3',
'-c',
abortScript,
args.pidFile,
args.jobId,
]);
if (result.exitCode !== 0) {
throw new Error(`Failed to abort Codex process: ${result.output}`);
}
};
+1 -13
View File
@@ -27,25 +27,14 @@ export const env = {
'docker',
containerVolumeOptions:
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
: 'network',
jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
// Interactive terminal: image for the persistent shell container (defaults to
// the job image), the secret shared with the Next app for verifying terminal
// tokens, and how long an idle terminal container survives before cleanup.
terminalImage:
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
'spoon-agent-job:latest',
// Secret shared with the Next app for verifying interactive terminal tokens.
terminalSecret:
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
// How long a per-user box container survives with no active jobs/terminals.
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
@@ -60,7 +49,6 @@ export const env = {
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
githubAppId: requiredEnv('GITHUB_APP_ID'),
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
@@ -0,0 +1,42 @@
export const createHeartbeatController = (args: {
intervalMs: number;
heartbeat: (jobId: string) => Promise<{ cancelRequested: boolean }>;
onCancel: (jobId: string) => Promise<void>;
onError: (error: unknown) => void;
}) => {
const timers = new Map<string, ReturnType<typeof setInterval>>();
const inFlight = new Set<string>();
const stop = (jobId: string) => {
const timer = timers.get(jobId);
if (timer) clearInterval(timer);
timers.delete(jobId);
};
const tick = async (jobId: string) => {
if (inFlight.has(jobId) || !timers.has(jobId)) return;
inFlight.add(jobId);
try {
const result = await args.heartbeat(jobId);
if (result.cancelRequested) {
stop(jobId);
await args.onCancel(jobId);
}
} catch (error) {
args.onError(error);
} finally {
inFlight.delete(jobId);
}
};
return {
start: (jobId: string) => {
stop(jobId);
const timer = setInterval(() => void tick(jobId), args.intervalMs);
timer.unref();
timers.set(jobId, timer);
},
stop,
has: (jobId: string) => timers.has(jobId),
};
};
+2
View File
@@ -1,5 +1,6 @@
import { env } from './env';
import { startWorkerServer } from './server';
import { reconcileExistingBoxes } from './user-container';
import { startWorker } from './worker';
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
@@ -26,4 +27,5 @@ if (env.devWatchdog) {
}
startWorkerServer();
await reconcileExistingBoxes();
await startWorker();
+38
View File
@@ -0,0 +1,38 @@
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { assertContainedRealPath } from './path-containment';
export type MaterializedSecret = { name: string; value: string };
// Format matches the historical `NAME="value"` env-file layout the agent CLIs
// source; values are JSON-quoted so newlines/quotes survive.
export const formatMaterializedEnv = (secrets: MaterializedSecret[]) =>
`${secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
.join('\n')}\n`;
// Resolves the contained env-file path inside the checkout and writes it 0600
// so plaintext secrets never sit world/group-readable in the persistent home.
export const writeMaterializedEnv = async (
repoDir: string,
envFilePath: string,
secrets: MaterializedSecret[],
): Promise<string> => {
const envPath = await assertContainedRealPath(repoDir, envFilePath, {
forWrite: true,
});
await mkdir(path.dirname(envPath), { recursive: true });
await writeFile(envPath, formatMaterializedEnv(secrets), { mode: 0o600 });
// writeFile's mode only applies on create; chmod unconditionally tightens an
// already-existing (possibly 0644) file so plaintext secrets never leak.
await chmod(envPath, 0o600);
return envPath;
};
// Removes a previously materialized env file; no-ops when the path is unset or
// already gone so it is safe on every teardown path.
export const removeMaterializedEnv = async (envPath: string | undefined) => {
if (!envPath) return;
await rm(envPath, { force: true });
};
-128
View File
@@ -1,128 +0,0 @@
import type { OpencodeClient } from '@opencode-ai/sdk';
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events';
export type OpenCodeSession = {
client: OpencodeClient;
sessionId: string;
close: () => void;
};
const basicAuth = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const modelParts = (model: string) => {
const [rawProviderId, ...rest] = model.split('/');
const providerID =
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
const modelID = rest.length > 0 ? rest.join('/') : model;
return {
providerID,
modelID,
};
};
export const createOpenCodeSession = async (args: {
baseUrl: string;
password: string;
directory: string;
title: string;
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
}) => {
const abortController = new AbortController();
const client = createOpencodeClient({
baseUrl: args.baseUrl,
directory: args.directory,
headers: {
authorization: basicAuth('opencode', args.password),
},
});
const created = await client.session.create({
query: { directory: args.directory },
body: { title: args.title },
});
if (!created.data) {
throw new Error('OpenCode session could not be created.');
}
const sessionId = created.data.id;
void (async () => {
const events = await client.event.subscribe({
signal: abortController.signal,
query: { directory: args.directory },
onSseEvent: (event) => {
for (const normalized of normalizeOpenCodeEvent(event.data)) {
void args.onEvent(normalized);
}
},
onSseError: (error) => {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
},
});
for await (const event of events.stream) {
for (const normalized of normalizeOpenCodeEvent(event)) {
await args.onEvent(normalized);
}
}
})().catch((error: unknown) => {
if (!abortController.signal.aborted) {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
});
return {
client,
sessionId,
close: () => abortController.abort(),
} satisfies OpenCodeSession;
};
export const promptOpenCodeSession = async (args: {
session: OpenCodeSession;
prompt: string;
model: string;
directory: string;
}) => {
const model = modelParts(args.model);
const result = await args.session.client.session.promptAsync({
path: { id: args.session.sessionId },
query: { directory: args.directory },
body: {
model,
parts: [{ type: 'text', text: args.prompt }],
},
});
if (result.error) {
throw new Error('OpenCode prompt was rejected.');
}
};
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
await session.client.session.abort({
path: { id: session.sessionId },
});
};
export const replyOpenCodePermission = async (args: {
session: OpenCodeSession;
permissionId: string;
response: 'once' | 'always' | 'reject';
directory: string;
}) => {
const result = await args.session.client.postSessionIdPermissionsPermissionId(
{
path: { id: args.session.sessionId, permissionID: args.permissionId },
query: { directory: args.directory },
body: { response: args.response },
},
);
if (result.error) {
throw new Error('OpenCode permission response was rejected.');
}
};
+41
View File
@@ -0,0 +1,41 @@
import { lstat, realpath } from 'node:fs/promises';
import path from 'node:path';
const realpathOrDeepestExisting = async (target: string): Promise<string> => {
let current = target;
const tail: string[] = [];
for (;;) {
try {
const real = await realpath(current);
return path.join(real, ...tail.reverse());
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') throw error;
const parent = path.dirname(current);
if (parent === current) throw error;
tail.push(path.basename(current));
current = parent;
}
}
};
export const assertContainedRealPath = async (
root: string,
requested: string,
opts: { forWrite?: boolean; label?: string } = {},
): Promise<string> => {
const lexical = path.resolve(root, requested);
const realRoot = await realpath(root);
const resolved = await realpathOrDeepestExisting(lexical);
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) {
throw new Error(
`Refusing to access path outside ${opts.label ?? 'root'}: ${requested}`,
);
}
if (opts.forWrite) {
const info = await lstat(lexical).catch(() => null);
if (info?.isSymbolicLink()) {
throw new Error(`Refusing to write through a symlink: ${requested}`);
}
}
return resolved;
};
+10 -7
View File
@@ -1,8 +1,11 @@
const secretPatterns = [
/ghs_[A-Za-z0-9_]+/g,
/github_pat_[A-Za-z0-9_]+/g,
/sk-[A-Za-z0-9_-]+/g,
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
const secretPatterns: { pattern: RegExp; replacement: string }[] = [
{ pattern: /ghs_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
{ pattern: /github_pat_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
{ pattern: /sk-[A-Za-z0-9_-]+/g, replacement: '[redacted]' },
{
pattern: /(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
replacement: '$1=[redacted]',
},
];
export const createRedactor = (values: string[]) => {
@@ -12,8 +15,8 @@ export const createRedactor = (values: string[]) => {
for (const secret of secrets) {
output = output.split(secret).join('[redacted]');
}
for (const pattern of secretPatterns) {
output = output.replace(pattern, '$1=[redacted]');
for (const { pattern, replacement } of secretPatterns) {
output = output.replace(pattern, replacement);
}
return output;
};
@@ -0,0 +1,49 @@
import type { NormalizedAgentEvent } from '../agent-events';
import type { streamExecInContainer } from './docker';
import type { AdapterWorkspace } from './provider';
export type AgentRuntimeName = 'codex' | 'opencode' | 'claude';
export type ExecStreamFn = typeof streamExecInContainer;
export type TurnResult = {
// Assistant text the adapter captured out-of-band (Codex --output-last-message
// file, Claude `result` event). Empty/undefined when everything streamed via onEvent.
finalMessage?: string;
// Runtime session id to persist for continuity (resume/--session/--resume).
sessionId?: string;
// Non-empty when the runtime reported a hard failure the caller must surface.
error?: string;
};
export type AgentRuntime = {
readonly name: AgentRuntimeName;
prepareAuth(workspace: AdapterWorkspace): Promise<void>;
runTurn(
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult>;
abort(workspace: AdapterWorkspace): Promise<void>;
};
export type AdapterFactory = (deps?: {
execStream?: ExecStreamFn;
}) => AgentRuntime;
const factories = new Map<AgentRuntimeName, AdapterFactory>();
export const registerAdapter = (
name: AgentRuntimeName,
factory: AdapterFactory,
): void => {
factories.set(name, factory);
};
export const getAdapter = (name: AgentRuntimeName): AgentRuntime => {
const factory = factories.get(name);
if (!factory) {
throw new Error(`No agent runtime adapter registered for "${name}".`);
}
return factory();
};
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
// permissions. Shared by the credential-writing adapters (Claude, and available
// to Codex/OpenCode). `label` names the source in the parse-error message.
export const writeJsonFile = async (
filePath: string,
content: string,
label = 'auth',
) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error(`${label} JSON is not valid JSON.`);
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
@@ -0,0 +1,127 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { writeJsonFile } from './auth';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
// environment; only OAuth-json profiles need `.credentials.json` on disk.
if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Claude auth profile is missing credentials.json contents.');
}
const credentialsPath = path.join(
workspace.homeDir,
'.claude',
'.credentials.json',
);
await writeJsonFile(credentialsPath, secret, 'Claude auth');
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'claude';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// Flags verified against `claude --help` (@anthropic-ai/claude-code@2.1.207):
// `-p`, `--output-format stream-json`, `--verbose`, `--dangerously-skip-permissions`,
// `--model`, and `--resume <sessionId>` (continuity) all exist.
const argv = [
'claude',
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--model',
claudeModel(workspace.claim),
...(workspace.claudeSessionId
? ['--resume', workspace.claudeSessionId]
: []),
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeClaudeJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'claude', prepareAuth, runTurn, abort };
};
@@ -0,0 +1,193 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import { normalizeCodexJsonLine } from '../agent-events';
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import {
codexModelArgs,
isCodexLoginProfile,
providerEnvironment,
} from './provider';
// Reused verbatim from the worker: normalize + pretty-print an auth JSON blob
// before writing it with owner-only permissions.
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error('Codex auth JSON is not valid JSON.');
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
const isNoise = (line: string) => {
const trimmed = line.trim();
return (
!trimmed ||
trimmed === 'Reading additional input from stdin...' ||
trimmed.includes('`[features].codex_hooks` is deprecated')
);
};
export const createCodexAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles (e.g. OpenAI api_key) authenticate purely via
// OPENAI_API_KEY from providerEnvironment; only ChatGPT-login/JSON-snapshot
// profiles need a Codex `auth.json` on disk. Mirror the Claude adapter,
// which early-returns for non-OAuth profiles.
if (!isCodexLoginProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Codex auth profile is missing auth.json contents.');
}
await prepareCodexWorkspaceFiles({
workdir: workspace.workdir,
repoDir: workspace.repoDir,
});
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await writeJsonFile(codexAuthPath, secret);
// Also seed OpenCode's auth location with the saved JSON for forward
// compatibility if this profile later runs through OpenCode directly.
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await writeJsonFile(openCodeAuthPath, secret);
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'codex';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
const outputFileHostPath = path.join(
workspace.workdir,
'.codex',
outputFileName,
);
const outputFileContainerPath = path.posix.join(
workspace.containerHome,
'.codex',
outputFileName,
);
const codexArgv = workspace.codexSessionId
? [
'codex',
'exec',
'resume',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
workspace.codexSessionId,
prompt,
]
: [
'codex',
'exec',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
'--cd',
workspace.containerRepo,
prompt,
];
const command = buildMarkedCommand(marker, codexArgv);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and
// clear it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeCodexJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (isNoise(line)) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
let finalMessage: string | undefined;
try {
const lastMessage = await readFile(outputFileHostPath, 'utf8');
if (lastMessage.trim()) finalMessage = lastMessage.trim();
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'codex', prepareAuth, runTurn, abort };
};
+130 -188
View File
@@ -1,6 +1,7 @@
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'node:stream';
import Docker from 'dockerode';
import { execa } from 'execa';
import { env } from '../env';
@@ -10,6 +11,13 @@ type CommandResult = {
output: string;
};
let dockerClient: Docker | undefined;
export const getDockerClient = () => {
dockerClient ??= new Docker();
return dockerClient;
};
const environmentArgs = (environment: Record<string, string>) =>
Object.entries(environment).flatMap(([name, value]) => [
'-e',
@@ -95,138 +103,6 @@ export const jobWorkspaceVolumeSpec = (
: `${source}:${containerHome}`;
};
export const runInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const result = await execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return normalizeRunResult(result, result.all, args.redact);
};
export const startWorkspaceContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
publishTcpPort?: number;
}) => {
await ensureJobImagePulled();
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
reject: false,
});
const result = await execa(
containerRuntime(),
[
'run',
'-d',
'--name',
args.containerName,
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...(args.publishTcpPort
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
: []),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
{ all: true, stdin: 'ignore' },
);
return {
containerId: result.stdout.trim(),
containerName: args.containerName,
hostPort: args.publishTcpPort
? await getPublishedPort(args.containerName, args.publishTcpPort)
: undefined,
};
};
const getPublishedPort = async (
containerName: string,
containerPort: number,
) => {
const result = await execa(
containerRuntime(),
['port', containerName, `${containerPort}/tcp`],
{ all: true, reject: false, stdin: 'ignore' },
);
const output = result.all.trim();
const match = /:(\d+)\s*$/.exec(output);
if (!match?.[1]) {
throw new Error(
`Could not determine published port for ${containerName}:${containerPort}.`,
);
}
return match[1];
};
export const execInWorkspaceContainer = async (args: {
containerName: string;
command: string[];
environment?: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
const result = await execa(
containerRuntime(),
[
'exec',
...(args.environment ? environmentArgs(args.environment) : []),
args.containerName,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
// Shared line-streaming + result normalization for a started subprocess
// (used by both `docker run` and `docker exec` paths).
type StreamingSubprocess = {
@@ -288,51 +164,6 @@ const streamSubprocess = async (
return normalizeRunResult(result, output.join(''), redact);
};
export const streamInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const subprocess = execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return streamSubprocess(
subprocess,
args.redact,
args.onStdoutLine,
args.onStderrLine,
);
};
// Per-user persistent "box" container that all of a user's threads exec into
// (Phase 2). Started once, reused; the home volume persists state across stops.
export const userContainerName = (username: string) =>
@@ -362,6 +193,7 @@ export const ensureUserContainer = async (args: {
[
'run',
'-d',
'--init',
'--name',
name,
'--memory',
@@ -382,6 +214,127 @@ export const ensureUserContainer = async (args: {
return name;
};
// Inspect the per-user box in one shot. Non-zero exit (no such container) yields
// an all-null/false status. `--memory 0` means unlimited, which the Go template
// prints as `0`; we normalize that (and any unparseable value) to null.
export const inspectUserBoxStatus = async (
username: string,
): Promise<{
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
}> => {
const result = await execa(
containerRuntime(),
[
'inspect',
'--format',
'{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}',
userContainerName(username),
],
{ reject: false, stdin: 'ignore' },
);
if (result.exitCode !== 0) {
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
}
const [runningField, imageField, startedAtField, memoryField] = result.stdout
.trim()
.split('|');
const running = runningField === 'true';
return {
running,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "no image"
image: imageField || null,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "not started"
startedAt: running ? startedAtField || null : null,
memoryLimitBytes: Number(memoryField) || null,
};
};
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// The marker is embedded literally into a `# comment` line and into a `pgrep`
// pattern, so it must not contain shell metacharacters or a newline (either could
// break out of the comment or the pattern). Callers generate it, but validate
// defensively so a malformed marker fails loudly instead of silently corrupting
// the script.
const MARKER_PATTERN = /^[A-Za-z0-9_-]+$/;
const assertMarker = (marker: string) => {
if (!MARKER_PATTERN.test(marker)) {
throw new Error(
`Invalid process marker ${JSON.stringify(marker)}: must match ${String(MARKER_PATTERN)}.`,
);
}
};
// Wraps a CLI argv so the process runs as a new session/process-group leader whose
// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT
// `exec` the CLI: bash tail-exec's a final simple command, which would replace the
// marker-carrying bash with the CLI and make `pgrep -f <marker>` miss the running
// turn. Keeping a trailing `rc=$?; exit "$rc"` after the CLI means the CLI is no
// longer the script's final statement, so bash survives as the group leader while
// still propagating the CLI's exit code (downstream `normalizeRunResult` relies on
// it — a bare trailing `wait` would mask a non-zero CLI failure as 0). The CLI runs
// in the same process group as this bash, so the kill helper's group signal reaches
// it. stdout/stderr fds are inherited by the CLI so streaming still works.
export const buildMarkedCommand = (
marker: string,
command: string[],
): string[] => {
assertMarker(marker);
const script = [
`# ${marker}`,
'exec 0</dev/null',
command.map(shellQuote).join(' '),
'rc=$?',
'exit "$rc"',
].join('\n');
return ['setsid', 'bash', '-lc', script];
};
// Kills every process group whose bash parent matches the marker, TERM then KILL.
// The negative pid (`kill -TERM -"$pgid"`) targets the whole process group, so the
// CLI and any children it spawned die together.
//
// pgrep self-match: this kill script runs via `bash -lc <script>`, whose OWN argv
// contains the marker literal, so `pgrep -f <marker>` matches the kill script (and
// its command-substitution subshells) too. Left unguarded it would `kill -TERM` its
// own process group and die on iteration one, leaking the real target. So we compute
// this script's pgid up front and skip any match sharing it, killing only the
// target's process group.
export const buildKillScript = (marker: string): string => {
assertMarker(marker);
return [
`self_pgid=$(ps -o pgid= -p $$ | tr -d ' ')`,
`pids=$(pgrep -f ${shellQuote(marker)} || true)`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -TERM -"$pgid" 2>/dev/null || true`,
`done`,
`sleep 2`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -KILL -"$pgid" 2>/dev/null || true`,
`done`,
].join('\n');
};
export const killBoxProcessesByMarker = async (args: {
containerName: string;
marker: string;
}): Promise<void> => {
await execa(
containerRuntime(),
['exec', args.containerName, 'bash', '-lc', buildKillScript(args.marker)],
{ reject: false, stdin: 'ignore' },
);
};
export const streamExecInContainer = async (args: {
containerName: string;
command: string[];
@@ -441,17 +394,6 @@ export const stopWorkspaceContainer = async (containerName: string) => {
});
};
export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa(containerRuntime(), ['inspect', containerName], {
all: true,
reject: false,
});
return {
exists: result.exitCode === 0,
output: result.all,
};
};
export const listWorkspaceContainerNames = async (prefix: string) => {
const result = await execa(
containerRuntime(),
@@ -0,0 +1,140 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import { normalizeOpenCodeRunLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { opencodeModel, providerEnvironment } from './provider';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
// permissions (mirrors the CodexAdapter helper).
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error('OpenCode auth JSON is not valid JSON.');
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
export const createOpenCodeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY
// in the environment; only `opencode_auth_json` profiles need auth.json on disk.
const profile = workspace.claim.aiProviderProfile;
if (profile?.authType !== 'opencode_auth_json') return;
if (!profile.secret) {
throw new Error('OpenCode auth profile is missing auth.json contents.');
}
const authPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await writeJsonFile(authPath, profile.secret);
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'opencode';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// Flags verified against `opencode run --help` (opencode-ai@1.17.9):
// `--format json`, `--model`, and `--session <id>` all exist.
const argv = [
'opencode',
'run',
'--format',
'json',
'--model',
opencodeModel(workspace.claim),
...(workspace.opencodeSessionId
? ['--session', workspace.opencodeSessionId]
: []),
'--',
prompt,
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeOpenCodeRunLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'opencode', prepareAuth, runTurn, abort };
};
+199
View File
@@ -0,0 +1,199 @@
import path from 'node:path';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import type { AgentRuntimeName } from './agent-runtime';
export type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
runtime?: 'codex' | 'opencode' | 'claude';
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
envFilePath?: string;
materializeEnvFile?: boolean;
baseBranch: string;
workBranch: string;
forkOwner: string;
forkRepo: string;
upstreamOwner: string;
upstreamRepo: string;
};
spoon: { name: string };
openai: {
apiKey?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
aiProviderProfile?: {
id: string;
name: string;
provider:
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none';
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
github: { installationId?: string };
agentSettings?: {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
autoDetectCommands?: boolean;
} | null;
secrets: { name: string; value: string }[];
};
// The subset of the worker's active workspace that agent adapters read.
export type AdapterWorkspace = {
claim: Claim;
workdir: string;
homeDir: string;
username: string;
containerHome: string;
containerRepo: string;
repoDir: string;
boxName: string;
redact: (value: string) => string;
runtime: AgentRuntimeName;
codexSessionId?: string;
opencodeSessionId?: string;
claudeSessionId?: string;
turnMarker?: string;
};
export const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
export const collectJsonStringValues = (value?: string): string[] => {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
const values: string[] = [];
const visit = (item: unknown) => {
if (typeof item === 'string') {
if (item.length >= 12) values.push(item);
return;
}
if (Array.isArray(item)) {
item.forEach(visit);
return;
}
if (item && typeof item === 'object') {
Object.values(item).forEach(visit);
}
};
visit(parsed);
return values;
} catch {
return [];
}
};
export const providerEnvironment = (
claim: Claim,
workspaceRoot?: string,
): Record<string, string> => {
if (isCodexLoginProfile(claim)) {
if (!workspaceRoot) {
throw new Error('Codex auth profiles require a prepared workspace.');
}
return {
CODEX_HOME: path.join(workspaceRoot, '.codex'),
HOME: workspaceRoot,
XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'),
XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'),
};
}
const profile = claim.aiProviderProfile;
const secret = profile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No AI provider credential is configured for this job.');
}
const baseUrl: Record<string, string> = profile?.baseUrl
? { OPENAI_BASE_URL: profile.baseUrl }
: {};
if (!profile || profile.provider === 'openai') {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
if (profile.provider === 'openrouter') {
return { OPENROUTER_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'requesty') {
return { REQUESTY_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'cloudflare_ai_gateway') {
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
}
if (
profile.provider === 'litellm' ||
profile.provider === 'custom_openai_compatible'
) {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
throw new Error('Unsupported AI provider profile.');
};
export const opencodeModel = (claim: Claim) => {
const profile = claim.aiProviderProfile;
const model = profile?.model ?? claim.openai.model;
if (model.includes('/')) return model;
if (!profile) return `openai/${model}`;
if (
profile.provider === 'custom_openai_compatible' ||
profile.provider === 'cloudflare_ai_gateway'
) {
return model;
}
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
return `${profile.provider}/${model}`;
};
export const codexModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
export const codexModelArgs = (claim: Claim) =>
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
// Claude Code OAuth profiles store their credentials as a JSON blob written to
// `~/.claude/.credentials.json`; API-key profiles authenticate via
// ANTHROPIC_API_KEY. Keyed on the Anthropic OAuth snapshot type.
export const isClaudeOAuthProfile = (claim: Claim) =>
claim.aiProviderProfile?.authType === 'anthropic_oauth_json';
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
// so strip any `provider/` prefix like codexModel does.
export const claudeModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
// Environment for a `claude -p` turn. OAuth-json profiles authenticate from the
// on-disk credentials file (only HOME is needed); API-key profiles export
// ANTHROPIC_API_KEY.
export const claudeEnv = (
claim: Claim,
containerHome: string,
): Record<string, string> => {
if (isClaudeOAuthProfile(claim)) return { HOME: containerHome };
const secret = claim.aiProviderProfile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No Anthropic API key is configured for this Claude job.');
}
return { ANTHROPIC_API_KEY: secret, HOME: containerHome };
};
@@ -0,0 +1,8 @@
import { registerAdapter } from './agent-runtime';
import { createClaudeAdapter } from './claude-adapter';
import { createCodexAdapter } from './codex-adapter';
import { createOpenCodeAdapter } from './opencode-adapter';
registerAdapter('codex', createCodexAdapter);
registerAdapter('opencode', createOpenCodeAdapter);
registerAdapter('claude', createClaudeAdapter);
@@ -0,0 +1,60 @@
import type { AgentRuntimeName } from './agent-runtime';
// Pure, side-effect-free decision for how a completed runtime turn should be
// surfaced. Kept out of worker.ts so it is unit-testable without loading the
// worker's Convex/Docker dependencies.
//
// Inputs:
// - assistantText: text already streamed to the user (may be partial).
// - recoveredText: text recovered from `TurnResult.finalMessage` (already
// redacted+truncated by the caller), used only when nothing streamed.
// - error: `TurnResult.error` — non-empty when the runtime reported a hard
// failure (nonzero exit / failure event).
// - runtime: runtime name, for the failure message prefix.
//
// Output:
// - text: the assistant text to persist (streamed text, else recovered text).
// - failure: when set, the caller must surface the turn as FAILED (throw). The
// partial text in `text` is still preserved so the user can see it.
export const resolveTurnOutcome = (args: {
assistantText: string;
recoveredText?: string;
error?: string;
runtime: AgentRuntimeName;
}): { text: string; failure?: string } => {
const { assistantText, recoveredText, error, runtime } = args;
let text = assistantText;
if (!text.trim() && recoveredText) {
text = recoveredText;
}
if (!text.trim()) {
return {
text,
failure: error
? `${runtime} failed:\n${error}`
: 'Codex completed without producing an assistant response.',
};
}
// A hard failure that streamed partial text must still be surfaced as failed;
// the streamed text is preserved above so the user sees the truncated answer.
if (error) {
return { text, failure: `${runtime} failed:\n${error}` };
}
return { text };
};
// Pure decision for whether the `content` on an `assistant_completed` event
// should be folded into the accumulated assistant text.
//
// Some runtimes (Claude `-p --output-format stream-json`, OpenCode) emit the
// final answer TWICE: once as streamed `assistant_delta` chunks and again as
// the `content` on the terminal `assistant_completed`/`result` event. Appending
// both yields a doubled "<answer><answer>". So only fold in the completed
// content when nothing has streamed yet (a runtime that emits only a final
// result). When deltas already carried the answer, skip it. The truly-empty
// case is still covered by resolveTurnOutcome's recoveredText/finalMessage
// fallback, so nothing is lost.
export const shouldAppendCompletedContent = (
currentText: string,
completedContent?: string,
): boolean => Boolean(completedContent) && !currentText.trim();
+69
View File
@@ -3,6 +3,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import {
getBoxStatus,
listBoxTree,
readBoxFile,
restartBox,
startBox,
stopBox,
writeBoxFile,
} from './box';
import { env } from './env';
import { attachTerminalServer } from './terminal';
import {
@@ -56,6 +65,11 @@ const jobRoute = (pathname: string) => {
return { jobId: decodeURIComponent(match[1]), action: match[2] };
};
export const boxRoute = (pathname: string) => {
const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname);
return match?.[1] ? { action: match[1] } : null;
};
export const startWorkerServer = () => {
const server = createServer((request, response) => {
void (async () => {
@@ -73,6 +87,59 @@ export const startWorkerServer = () => {
sendJson(response, 200, await cleanupOrphanedWorkspaces());
return;
}
const box = boxRoute(url.pathname);
if (box) {
const user = url.searchParams.get('user') ?? '';
if (!user) {
sendJson(response, 400, { error: 'Missing user' });
return;
}
if (request.method === 'GET' && box.action === 'status') {
sendJson(response, 200, { status: await getBoxStatus(user) });
return;
}
if (request.method === 'POST' && box.action === 'lifecycle') {
const body = await parseJson<{ action?: string }>(request);
if (body.action === 'start') {
await startBox(user);
} else if (body.action === 'stop') {
await stopBox(user);
} else if (body.action === 'restart') {
await restartBox(user);
} else {
sendJson(response, 400, { error: 'Unknown action' });
return;
}
sendJson(response, 200, { status: await getBoxStatus(user) });
return;
}
if (request.method === 'GET' && box.action === 'tree') {
sendJson(response, 200, { tree: await listBoxTree(user) });
return;
}
if (request.method === 'GET' && box.action === 'file') {
const filePath = url.searchParams.get('path') ?? '';
sendJson(response, 200, {
path: filePath,
content: await readBoxFile(user, filePath),
});
return;
}
if (request.method === 'PUT' && box.action === 'file') {
const body = await parseJson<{ path?: string; content?: string }>(
request,
);
sendJson(
response,
200,
await writeBoxFile(user, body.path ?? '', body.content ?? ''),
);
return;
}
sendJson(response, 404, { error: 'Not found' });
return;
}
const route = jobRoute(url.pathname);
if (!route) {
sendJson(response, 404, { error: 'Not found' });
@@ -175,6 +242,8 @@ export const startWorkerServer = () => {
const status =
message === 'Unauthorized'
? 401
: message.startsWith('Refusing to')
? 400
: message.includes('not supported')
? 409
: 500;
+38
View File
@@ -27,3 +27,41 @@ export const verifyTerminalToken = (
timingSafeEqual(providedBuf, expectedBuf)
);
};
// Extract the username a box token authorizes without verifying its signature.
// The token itself authorizes the connection, so the upgrade handler reads the
// username from it (rather than a query param) and then verifies the whole
// token against that username. Returns null unless the format matches
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`.
export const parseBoxTokenUsername = (token: string): string | null => {
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return null;
const username = parts[2];
if (!username) return null;
return username;
};
// User-scoped variant authorizing a terminal connection to a user's box.
// Embeds a literal `box` segment so its four parts never collide with the
// three-part job token above. Format:
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
export const verifyBoxTerminalToken = (
token: string,
username: string,
secret: string,
): boolean => {
if (!token || !secret) return false;
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return false;
const [expRaw, , tokenUsername, provided] = parts;
if (tokenUsername !== username) return false;
const exp = Number.parseInt(expRaw ?? '', 10);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = signature(`${expRaw}.box.${tokenUsername}`, secret);
const providedBuf = Buffer.from(provided ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
return (
providedBuf.length === expectedBuf.length &&
timingSafeEqual(providedBuf, expectedBuf)
);
};
+174 -95
View File
@@ -1,12 +1,19 @@
import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { BoxHandle } from './user-container';
import { boxHomePaths } from './box';
import { env } from './env';
import { verifyTerminalToken } from './terminal-token';
import { acquireUserBox, releaseUserBox } from './user-container';
import { getDockerClient } from './runtime/docker';
import {
parseBoxTokenUsername,
verifyBoxTerminalToken,
verifyTerminalToken,
} from './terminal-token';
import { acquireUserBox } from './user-container';
import { ensureBashProfile } from './user-environment';
import { getTerminalWorkspace } from './worker';
const clampDimension = (value: unknown) => {
@@ -15,55 +22,79 @@ const clampDimension = (value: unknown) => {
return Math.min(Math.max(n, 1), 1000);
};
// Single-quote a string for a POSIX shell.
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
ws.close(1011, 'Workspace is not active.');
return;
}
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
// *inside* the container with `script`, bridging the plain pipes to the socket.
//
// Register the message handler immediately and buffer input/size until the exec
// is ready (acquiring the box can take seconds on first connect), so the initial
// resize and early keystrokes aren't dropped.
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
const pendingInput: Buffer[] = [];
let cols = 80;
let rows = 24;
ws.on('message', (data: Buffer, isBinary: boolean) => {
if (!isBinary) {
// Text frames are control messages (resize); anything else is raw input.
export const parseResizeMessage = (
data: Buffer,
isBinary: boolean,
): { cols: number; rows: number } | null => {
if (isBinary) return null;
try {
const message = JSON.parse(data.toString('utf8')) as {
type?: string;
cols?: number;
rows?: number;
};
if (message.type === 'resize') {
const c = clampDimension(message.cols);
const r = clampDimension(message.rows);
if (c && r) {
cols = c;
rows = r;
if (message.type !== 'resize') return null;
const cols = clampDimension(message.cols);
const rows = clampDimension(message.rows);
if (!cols || !rows) return null;
return { cols, rows };
} catch {
return null;
}
};
// The login-shell command shared by the job and box terminals: prefer a
// resumable tmux session, else fall back to a plain interactive login shell.
const SHELL_CMD = [
'/bin/bash',
'-lc',
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi',
];
type ShellBridgeOptions = {
// Acquire the per-user box (reference-counted); its handle is released on
// cleanup. Called after the input handler is wired so early keystrokes buffer.
acquire: () => Promise<BoxHandle>;
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
prepare?: () => Promise<void>;
// Directory the login shell starts in (container path).
cwd: string;
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
envFlags: string[];
};
/**
* Shared PTY bridge: wire a WebSocket to an interactive login shell running via
* a real TTY (dockerode `exec` with `Tty:true` + `exec.resize`) inside the
* user's box. Registers the input handler immediately and buffers input/size
* until the exec is ready (acquiring the box can take seconds on first connect),
* so the initial resize and early keystrokes aren't dropped. Used by both the
* job terminal (`bridge`) and the box terminal (`bridgeBox`); only the box to
* acquire, the working directory, and the env differ.
*/
const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
const execHolder: {
current?: { exec: import('dockerode').Exec; stream: Duplex };
} = {};
const pendingInput: Buffer[] = [];
let cols = 80;
let rows = 24;
ws.on('message', (data: Buffer, isBinary: boolean) => {
const resize = parseResizeMessage(data, isBinary);
if (resize) {
cols = resize.cols;
rows = resize.rows;
void execHolder.current?.exec
.resize({ h: rows, w: cols })
.catch(() => undefined);
return;
}
} catch {
// fall through: treat as raw input
}
}
if (procHolder.current) procHolder.current.stdin.write(data);
if (execHolder.current) execHolder.current.stream.write(data);
else pendingInput.push(data);
});
let acquired = false;
const handleHolder: { current?: BoxHandle } = {};
let released = false;
// Read through a function so TS doesn't narrow `released` to a constant — the
// cleanup handler flips it asynchronously when the socket closes.
@@ -71,8 +102,9 @@ const bridge = async (ws: WebSocket, jobId: string) => {
const cleanup = () => {
if (released) return;
released = true;
procHolder.current?.kill();
if (acquired) releaseUserBox(workspace.username);
execHolder.current?.stream.end();
execHolder.current?.stream.destroy();
handleHolder.current?.release();
};
ws.on('close', cleanup);
ws.on('error', cleanup);
@@ -81,12 +113,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
// the terminal share the exact same container (Phase 2).
let boxName: string;
try {
boxName = await acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
});
acquired = true;
if (opts.prepare) await opts.prepare();
const handle = await opts.acquire();
handleHolder.current = handle;
boxName = handle.boxName;
} catch (error) {
ws.close(
1011,
@@ -95,64 +125,90 @@ const bridge = async (ws: WebSocket, jobId: string) => {
return;
}
if (isReleased()) return; // client disconnected during startup; cleanup ran
// Cleanup may have run before the awaited handle existed.
if (isReleased()) {
handleHolder.current.release();
return;
}
// Reattach a persistent tmux session across reconnects when available, else a
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
const launcher =
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
// Reattach a persistent tmux session when tmux is present; otherwise fall back
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
// non-interactive shell exit before the `||`, so the fallback never runs.
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
'else exec bash -il; fi';
const envFlags = [
'-e',
'TERM=xterm-256color',
'-e',
`HOME=${workspace.containerHome}`,
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
];
const docker = getDockerClient();
const container = docker.getContainer(boxName);
const exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: SHELL_CMD,
Env: ['TERM=xterm-256color', ...opts.envFlags],
WorkingDir: opts.cwd,
});
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
execHolder.current = { exec, stream };
const proc = spawn(
env.containerRuntime,
[
'exec',
'-i',
...envFlags,
'-w',
workspace.containerRepo,
boxName,
'/bin/bash',
'-lc',
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
],
{ stdio: ['pipe', 'pipe', 'pipe'] },
);
procHolder.current = proc;
if (isReleased()) {
stream.end();
stream.destroy();
return;
}
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
// Replay any keystrokes the client sent before the process was ready.
for (const buffered of pendingInput) proc.stdin.write(buffered);
for (const buffered of pendingInput) stream.write(buffered);
pendingInput.length = 0;
const forward = (chunk: Buffer) => {
stream.on('data', (chunk: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
};
proc.stdout.on('data', forward);
proc.stderr.on('data', forward);
proc.on('exit', () => {
});
stream.on('end', () => {
if (ws.readyState === ws.OPEN) ws.close();
});
proc.on('error', () => {
stream.on('error', () => {
if (ws.readyState === ws.OPEN) ws.close();
});
};
// Job terminal: opens the shell at the repo checkout, with the job's selected
// secrets in the environment (it is scoped to that job's active workspace).
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
ws.close(1011, 'Workspace is not active.');
return;
}
await runShellBridge(ws, {
acquire: () =>
acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
}),
cwd: workspace.containerRepo,
envFlags: [
`HOME=${workspace.containerHome}`,
...workspace.secrets.map((secret) => `${secret.name}=${secret.value}`),
],
});
};
// Box terminal: opens a login shell rooted at the user's home (`~`), with no
// per-job secrets — it is user-scoped, not tied to any job's workspace.
const bridgeBox = async (ws: WebSocket, username: string) => {
const { homeDir, containerHome } = boxHomePaths(username);
await runShellBridge(ws, {
prepare: () => ensureBashProfile(homeDir),
acquire: () =>
acquireUserBox({ username, workdir: homeDir, containerHome }),
cwd: containerHome,
envFlags: [`HOME=${containerHome}`],
});
};
/**
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
* token minted by the Next app (which has already verified job ownership).
* Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP
* server. Two routes, each authorized by a short-lived token minted by the Next
* app: `/jobs/:jobId/terminal?token=…` (job-scoped, after ownership check) and
* `/box/terminal?token=…` (user-scoped, username read from the token itself).
*/
export const attachTerminalServer = (server: Server) => {
if (env.runtime !== 'docker') return;
@@ -160,20 +216,43 @@ export const attachTerminalServer = (server: Server) => {
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
const token = url.searchParams.get('token') ?? '';
// User-scoped box terminal: the token authorizes the connection, so the
// username is read FROM the token, then verified against the whole token.
if (url.pathname === '/box/terminal') {
const username = parseBoxTokenUsername(token);
if (
!username ||
!verifyBoxTerminalToken(token, username, env.terminalSecret)
) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridgeBox(ws, username).catch(() => {
if (ws.readyState === ws.OPEN) ws.close();
});
});
return;
}
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
if (!match?.[1]) {
socket.destroy();
return;
}
const jobId = decodeURIComponent(match[1]);
const token = url.searchParams.get('token') ?? '';
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridge(ws, jobId);
void bridge(ws, jobId).catch(() => {
if (ws.readyState === ws.OPEN) ws.close();
});
});
});
+114 -19
View File
@@ -1,40 +1,135 @@
import { env } from './env';
import {
ensureUserContainer,
listWorkspaceContainerNames,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
// Phase 2: one persistent "box" container per user that all of their threads
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
// up while any thread workspace is active or a terminal is connected, and is
// reaped after an idle period once nothing holds it.
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
const boxes = new Map<string, Box>();
// Phase 2: one persistent "box" container per user. Reference-counted by opaque
// handles (not a shared integer), serialized per-username with an async mutex,
// and idle-reaped once no handle is held.
export type BoxHandle = { boxName: string; release: () => void };
export const acquireUserBox = async (args: {
type Box = {
name: string;
initialized: boolean;
refs: Set<symbol>;
idleTimer?: ReturnType<typeof setTimeout>;
};
const boxes = new Map<string, Box>();
const locks = new Map<string, Promise<unknown>>();
// Per-username async mutex: chain each operation after the previous one.
const withLock = <T>(username: string, fn: () => Promise<T>): Promise<T> => {
const previous = locks.get(username) ?? Promise.resolve();
const next = previous.then(fn, fn);
locks.set(
username,
next.then(
() => undefined,
() => undefined,
),
);
return next;
};
const scheduleReapIfIdle = (username: string) => {
const box = boxes.get(username);
if (!box || box.refs.size > 0) return;
if (box.idleTimer) clearTimeout(box.idleTimer);
box.idleTimer = setTimeout(() => {
void withLock(username, async () => {
const current = boxes.get(username);
if (current !== box || current.refs.size > 0) return;
await stopWorkspaceContainer(current.name);
if (boxes.get(username) === current && current.refs.size === 0) {
boxes.delete(username);
}
});
}, env.boxIdleMs);
};
const makeHandle = (username: string, box: Box): BoxHandle => {
const token = Symbol('box-ref');
box.refs.add(token);
let released = false;
return {
boxName: box.name,
release: () => {
if (released) return;
released = true;
box.refs.delete(token);
scheduleReapIfIdle(username);
},
};
};
export const acquireUserBox = (args: {
username: string;
workdir: string;
containerHome: string;
}): Promise<string> => {
const name = await ensureUserContainer(args);
const box = boxes.get(args.username) ?? { refs: 0 };
if (box.idleTimer) {
}): Promise<BoxHandle> =>
withLock(args.username, async () => {
let box = boxes.get(args.username);
if (box?.idleTimer) {
clearTimeout(box.idleTimer);
box.idleTimer = undefined;
}
box.refs += 1;
if (!box) {
box = {
name: userContainerName(args.username),
initialized: false,
refs: new Set(),
};
boxes.set(args.username, box);
return name;
}
// Register the ref before the slow Docker call so a failed acquire can
// release it without leaking registry state.
const handle = makeHandle(args.username, box);
try {
if (!box.initialized) {
box.name = await ensureUserContainer(args);
box.initialized = true;
}
return handle;
} catch (error) {
handle.release();
if (boxes.get(args.username) === box && box.refs.size === 0) {
if (box.idleTimer) clearTimeout(box.idleTimer);
boxes.delete(args.username);
}
throw error;
}
});
// Adopt boxes left by a prior worker process so the idle reaper cleans them up.
export const reconcileExistingBoxes = async (): Promise<void> => {
const names = await listWorkspaceContainerNames('spoon-box-');
for (const name of names) {
const username = name.replace(/^spoon-box-/, '');
if (boxes.has(username)) continue;
const box: Box = { name, initialized: false, refs: new Set() };
boxes.set(username, box);
scheduleReapIfIdle(username);
}
};
export const releaseUserBox = (username: string) => {
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
// Drop a box from the in-memory registry (used after an explicit stop so a
// stopped box isn't treated as "held" by the idle reaper or reconcile logic).
export const resetBox = (username: string): void => {
const box = boxes.get(username);
if (!box) return;
box.refs = Math.max(0, box.refs - 1);
if (box.refs > 0) return;
box.idleTimer = setTimeout(() => {
void stopWorkspaceContainer(userContainerName(username));
if (box.idleTimer) clearTimeout(box.idleTimer);
boxes.delete(username);
}, env.boxIdleMs);
};
export const _resetBoxRegistryForTests = () => {
for (const box of boxes.values()) {
if (box.idleTimer) clearTimeout(box.idleTimer);
}
boxes.clear();
locks.clear();
};
+20 -19
View File
@@ -7,6 +7,7 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
import { assertContainedRealPath } from './path-containment';
import { runExecInContainer } from './runtime/docker';
const client = new ConvexHttpClient(env.convexUrl);
@@ -31,14 +32,21 @@ export const fetchUserEnvironment = async (
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// Keep a written path inside the home directory.
const safeHomeJoin = (homeDir: string, relPath: string) => {
const target = path.resolve(homeDir, relPath);
const root = path.resolve(homeDir);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
}
return target;
/**
* A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc.
* Write a minimal `.bash_profile` (only if absent) so an interactive login
* shell — the agent's, or the user's box terminal — loads their environment.
* Shared by `materializeUserHome` and the box terminal bridge.
*/
export const ensureBashProfile = async (homeDir: string): Promise<void> => {
await mkdir(homeDir, { recursive: true });
const bashProfile = path.join(homeDir, '.bash_profile');
await readFile(bashProfile, 'utf8').catch(async () => {
await writeFile(
bashProfile,
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
);
});
};
/**
@@ -56,16 +64,7 @@ export const materializeUserHome = async (args: {
redact: (value: string) => string;
}): Promise<void> => {
const { homeDir, containerHome, boxName, userEnv, redact } = args;
await mkdir(homeDir, { recursive: true });
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
const bashProfile = path.join(homeDir, '.bash_profile');
await readFile(bashProfile, 'utf8').catch(async () => {
await writeFile(
bashProfile,
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
);
});
await ensureBashProfile(homeDir);
if (!userEnv.enabled) return;
@@ -111,7 +110,9 @@ export const materializeUserHome = async (args: {
// Editable overlay tree (wins over the repo/setup output).
for (const file of userEnv.files) {
const target = safeHomeJoin(homeDir, file.path);
const target = await assertContainedRealPath(homeDir, file.path, {
forWrite: true,
});
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
if (file.isExecutable) await chmod(target, 0o755);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
type TeardownStep = () => unknown;
export const teardownWorkspace = async (steps: {
stopHeartbeat: TeardownStep;
removeActive: TeardownStep;
appendEvent?: TeardownStep;
abortAgent?: TeardownStep;
removeMaterializedEnv?: TeardownStep;
markStopped?: TeardownStep;
closeAgent?: TeardownStep;
stopContainer?: TeardownStep;
release: TeardownStep;
}) => {
const errors: unknown[] = [];
const call = (step: TeardownStep | undefined) => {
if (!step) return Promise.resolve();
try {
return Promise.resolve(step()).catch((error: unknown) => {
errors.push(error);
});
} catch (error) {
errors.push(error);
return Promise.resolve();
}
};
const immediate = [call(steps.stopHeartbeat), call(steps.removeActive)];
await Promise.all(immediate);
await call(steps.appendEvent);
await call(steps.abortAgent);
await call(steps.removeMaterializedEnv);
await call(steps.markStopped);
await call(steps.closeAgent);
await call(steps.stopContainer);
await call(steps.release);
return { errors };
};
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import {
normalizeClaudeJsonLine,
normalizeCodexJsonLine,
normalizeOpenCodeEvent,
normalizeOpenCodeRunLine,
} from '../../src/agent-events';
describe('agent event normalization', () => {
@@ -291,4 +293,110 @@ describe('agent event normalization', () => {
externalMessageId: 'message-2',
});
});
test('normalizes opencode run --format json output lines', () => {
expect(
normalizeOpenCodeRunLine(
JSON.stringify({
type: 'message.part.delta',
properties: { part: { text: 'hi' }, messageID: 'm1' },
}),
),
).toContainEqual({
kind: 'assistant_delta',
content: 'hi',
externalMessageId: 'm1',
});
expect(
normalizeOpenCodeRunLine(
JSON.stringify({
sessionID: 'ses_abc',
parts: [{ text: 'final answer' }],
}),
),
).toEqual(
expect.arrayContaining([
{ kind: 'session', sessionId: 'ses_abc' },
expect.objectContaining({
kind: 'assistant_completed',
content: 'final answer',
}),
]),
);
expect(normalizeOpenCodeRunLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
test('normalizes Claude Code stream-json output lines', () => {
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
},
}),
),
).toContainEqual({ kind: 'assistant_delta', content: 'hello world' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'Bash',
input: JSON.stringify({ command: 'ls' }),
});
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'assistant_completed', content: 'final text' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
result: 'hit the turn limit',
}),
),
).toContainEqual({ kind: 'error', message: 'hit the turn limit' });
expect(normalizeClaudeJsonLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import type { AgentRuntime } from '../../src/runtime/agent-runtime';
import type { Claim } from '../../src/runtime/provider';
import { getAdapter, registerAdapter } from '../../src/runtime/agent-runtime';
import { isCodexLoginProfile, opencodeModel } from '../../src/runtime/provider';
describe('agent runtime registry', () => {
test('registerAdapter/getAdapter resolves a registered runtime', () => {
const fake: AgentRuntime = {
name: 'codex',
prepareAuth: () => Promise.resolve(),
runTurn: () => Promise.resolve({}),
abort: () => Promise.resolve(),
};
registerAdapter('codex', () => fake);
expect(getAdapter('codex').name).toBe('codex');
});
test('getAdapter throws for an unregistered runtime', () => {
expect(() => getAdapter('opencode')).toThrow(/No agent runtime adapter/);
});
});
describe('shared provider helpers re-exported from runtime/provider', () => {
test('isCodexLoginProfile detects opencode login profiles', () => {
expect(
isCodexLoginProfile({
aiProviderProfile: { provider: 'opencode_openai_login' },
} as Claim),
).toBe(true);
expect(isCodexLoginProfile({} as Claim)).toBe(false);
});
test('opencodeModel prefixes the provider', () => {
expect(
opencodeModel({
aiProviderProfile: { provider: 'anthropic', model: 'claude-x' },
} as Claim),
).toBe('anthropic/claude-x');
});
});
@@ -0,0 +1,84 @@
import {
mkdir,
mkdtemp,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
const tempDirs: string[] = [];
// Point boxHomePaths('t').homeDir at a real tmp home, then import box fresh so
// env.workdir (captured at import) reflects the tmp workdir for this test.
const loadWithHome = async () => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-box-'));
tempDirs.push(workdir);
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = workdir;
const box = await import('../../src/box');
const homeDir = box.boxHomePaths('t').homeDir;
await mkdir(homeDir, { recursive: true });
return { box, homeDir, workdir };
};
describe('box file helpers', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
vi.resetModules();
});
test('writeBoxFile then readBoxFile round-trips', async () => {
const { box } = await loadWithHome();
await expect(
box.writeBoxFile('t', 'notes/todo.txt', 'hello box'),
).resolves.toEqual({ success: true });
await expect(box.readBoxFile('t', 'notes/todo.txt')).resolves.toBe(
'hello box',
);
});
test('readBoxFile rejects a lexical .. escape', async () => {
const { box } = await loadWithHome();
await expect(
box.readBoxFile('t', '../../etc/passwd'),
).rejects.toThrow(/outside home/);
});
test('rejects a symlink inside home that points outside on read and write', async () => {
const { box, homeDir, workdir } = await loadWithHome();
const outside = path.join(workdir, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(path.join(outside, 'secret'), 'S');
await symlink(path.join(outside, 'secret'), path.join(homeDir, 'link'));
await expect(box.readBoxFile('t', 'link')).rejects.toThrow(/outside home/);
await expect(
box.writeBoxFile('t', 'link', 'nope'),
).rejects.toThrow();
});
test('listBoxTree includes a created file and excludes node_modules', async () => {
const { box, homeDir } = await loadWithHome();
await writeFile(path.join(homeDir, 'readme.md'), '# hi');
await mkdir(path.join(homeDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(homeDir, 'node_modules', 'pkg', 'x.js'), '1');
const tree = await box.listBoxTree('t');
expect(tree.name).toBe('~');
expect(tree.path).toBe('');
expect(tree.type).toBe('directory');
const names = (tree.children ?? []).map((child) => child.name);
expect(names).toContain('readme.md');
expect(names).not.toContain('node_modules');
});
});
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = '/work';
return await import('../../src/box');
};
describe('boxHomePaths', () => {
afterEach(() => vi.resetModules());
test('derives the per-user home dir and container home', async () => {
const { boxHomePaths } = await load();
const paths = boxHomePaths('gib');
expect(paths.homeDir.endsWith('homes/gib')).toBe(true);
expect(paths.containerHome).toBe('/home/gib');
});
test('distinct usernames produce distinct home dirs', async () => {
const { boxHomePaths } = await load();
expect(boxHomePaths('gib').homeDir).not.toBe(
boxHomePaths('other').homeDir,
);
});
});
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'vitest';
const loadDocker = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/docker');
};
describe('in-box process-group marker + kill helpers', () => {
test('buildMarkedCommand wraps argv as a setsid bash session carrying the marker', async () => {
const { buildMarkedCommand } = await loadDocker();
const argv = buildMarkedCommand('spoon-turn-abc', [
'codex',
'exec',
'--json',
'hi',
]);
expect(argv[0]).toBe('setsid');
expect(argv[1]).toBe('bash');
expect(argv[2]).toBe('-lc');
const script = argv[3] ?? '';
expect(script).toContain('# spoon-turn-abc');
expect(script).toContain("'codex' 'exec' '--json' 'hi'");
});
test('buildMarkedCommand keeps a trailing exit after the CLI so bash survives tail-exec while propagating the exit code (C1 guard)', async () => {
const { buildMarkedCommand } = await loadDocker();
const script = buildMarkedCommand('spoon-turn-abc', ['codex', 'run'])[3] ?? '';
// The CLI must NOT be the final simple command (bash would tail-exec it,
// replacing the marker-carrying bash). The exit code must still propagate.
expect(script).toContain("'codex' 'run'\nrc=$?\nexit \"$rc\"");
expect(script.split('\n').at(-1)).toBe('exit "$rc"');
expect(script.split('\n').at(-1)).not.toBe("'codex' 'run'");
});
test('buildMarkedCommand rejects a marker that could break out of the comment', async () => {
const { buildMarkedCommand } = await loadDocker();
expect(() => buildMarkedCommand('bad\nmarker', ['codex'])).toThrow();
});
test('buildKillScript TERM-then-KILLs every group matching the marker', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
expect(script).toContain("pgrep -f 'spoon-turn-abc'");
expect(script).toContain('kill -TERM -"$pgid"');
expect(script).toContain('kill -KILL -"$pgid"');
});
test('buildKillScript excludes its own process group so it does not kill itself (C2 guard)', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
// Computes its own pgid and skips any match sharing it (the kill script's
// own bash + its command-substitution subshells all carry the marker).
expect(script).toContain('self_pgid=$(ps -o pgid= -p $$ | tr -d \' \')');
expect(script).toContain('[ "$pgid" = "$self_pgid" ] && continue');
});
test('buildKillScript escalates TERM -> KILL with a sleep 2 in between (order matters)', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
const termIdx = script.indexOf('kill -TERM -"$pgid"');
const sleepIdx = script.indexOf('\nsleep 2\n');
const killIdx = script.indexOf('kill -KILL -"$pgid"');
expect(termIdx).toBeGreaterThanOrEqual(0);
expect(sleepIdx).toBeGreaterThan(termIdx);
expect(killIdx).toBeGreaterThan(sleepIdx);
});
test('buildKillScript rejects a marker that could break out of the pgrep pattern', async () => {
const { buildKillScript } = await loadDocker();
expect(() => buildKillScript('bad\nmarker')).toThrow();
});
});
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = '/work';
return await import('../../src/server');
};
describe('boxRoute', () => {
afterEach(() => vi.resetModules());
test('matches the four box actions', async () => {
const { boxRoute } = await load();
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
});
test('rejects unknown, nested, and trailing paths', async () => {
const { boxRoute } = await load();
expect(boxRoute('/box/other')).toBeNull();
expect(boxRoute('/box/file/extra')).toBeNull();
expect(boxRoute('/box/status/')).toBeNull();
expect(boxRoute('/box')).toBeNull();
expect(boxRoute('/jobs/x/file')).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { createHmac } from 'node:crypto';
import { describe, expect, test } from 'vitest';
import {
parseBoxTokenUsername,
verifyBoxTerminalToken,
} from '../../src/terminal-token';
const mintBox = (username: string, expiresAt: number, secret: string) => {
const payload = `${expiresAt}.box.${username}`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
return `${payload}.${sig}`;
};
describe('verifyBoxTerminalToken', () => {
const secret = 'test-secret';
test('accepts a valid, unexpired, username-matched token', () => {
const token = mintBox('alice', Date.now() + 60_000, secret);
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(true);
});
test('rejects an expired token', () => {
const token = mintBox('alice', Date.now() - 1, secret);
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
});
test('rejects a token minted for another username', () => {
const token = mintBox('alice', Date.now() + 60_000, secret);
expect(verifyBoxTerminalToken(token, 'bob', secret)).toBe(false);
});
test('rejects a token signed with a different secret', () => {
const token = mintBox('alice', Date.now() + 60_000, 'other-secret');
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
});
test('rejects malformed input and an empty secret', () => {
expect(verifyBoxTerminalToken('garbage', 'alice', secret)).toBe(false);
expect(verifyBoxTerminalToken('', 'alice', secret)).toBe(false);
expect(
verifyBoxTerminalToken(mintBox('alice', Date.now() + 1000, ''), 'alice', ''),
).toBe(false);
});
test('rejects a 3-part job token (cross-scheme confusion guard)', () => {
const payload = `${Date.now() + 60_000}.alice`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
const jobToken = `${payload}.${sig}`;
expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false);
});
});
describe('parseBoxTokenUsername', () => {
test('extracts the username from a well-formed box token', () => {
const token = mintBox('alice', Date.now() + 60_000, 'test-secret');
expect(parseBoxTokenUsername(token)).toBe('alice');
});
test('returns null for a 3-part job token', () => {
expect(parseBoxTokenUsername('123.alice.sig')).toBeNull();
});
test('returns null when the literal box segment is missing', () => {
expect(parseBoxTokenUsername('123.notbox.alice.sig')).toBeNull();
});
test('returns null for an empty username segment', () => {
expect(parseBoxTokenUsername('123.box..sig')).toBeNull();
});
test('returns null for garbage input', () => {
expect(parseBoxTokenUsername('garbage')).toBeNull();
expect(parseBoxTokenUsername('')).toBeNull();
});
});
@@ -0,0 +1,177 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/claude-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'claude-sonnet-4', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType: 'api_key',
secret: 'sk-ant-test',
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-claude-adapter-test-missing',
homeDir: '/tmp/spoon-claude-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
const makeOAuthWorkspace = (
homeDir: string,
authType: 'anthropic_oauth_json' | 'opencode_auth_json',
): AdapterWorkspace => ({
claim: {
...claim,
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType,
secret: JSON.stringify({ access_token: 'oauth-token' }),
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
} as unknown as Claim,
workdir: homeDir,
homeDir,
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: path.join(homeDir, 'Code/spoon'),
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
const tempDirs: string[] = [];
afterEach(async () => {
while (tempDirs.length) {
const dir = tempDirs.pop();
if (dir) await rm(dir, { recursive: true, force: true });
}
});
describe('ClaudeCodeAdapter prepareAuth', () => {
test('writes credentials.json for an anthropic_oauth_json profile', async () => {
const { createClaudeAdapter } = await loadAdapter();
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
tempDirs.push(homeDir);
const adapter = createClaudeAdapter();
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'));
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
const contents = await readFile(credentialsPath, 'utf8');
expect(contents).toContain('oauth-token');
});
test('does not write credentials.json for an opencode_auth_json profile', async () => {
const { createClaudeAdapter } = await loadAdapter();
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
tempDirs.push(homeDir);
const adapter = createClaudeAdapter();
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json'));
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
await expect(stat(credentialsPath)).rejects.toThrow();
});
});
describe('ClaudeCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createClaudeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'hi there' }] },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi there',
});
expect(result.finalMessage).toBe('final text');
expect(result.sessionId).toBe('abc');
expect(result.error).toBeUndefined();
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// `claude -p` argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'claude' '-p'");
expect(command[3]).toContain("'--output-format' 'stream-json'");
});
});
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
return await import('../../src/worker');
};
describe('planWorkdirCleanup', () => {
afterEach(() => vi.resetModules());
test('never removes the homes/ root or any home directory', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: { alice: [{ name: 'Code', isDirectory: true }] },
codeLeaves: {},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).not.toContain('/work/homes');
expect(plan.removeDirs).not.toContain('/work/homes/alice');
});
test('removes legacy top-level job dirs not in the active set', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [
{ name: 'homes', isDirectory: true },
{ name: 'legacy-job-123', isDirectory: true },
{ name: 'dev', isDirectory: true },
],
homesEntries: {},
codeLeaves: {},
activeWorkdirs: new Set(['/work/dev']),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).toContain('/work/legacy-job-123');
expect(plan.removeDirs).not.toContain('/work/dev');
expect(plan.removeDirs).not.toContain('/work/homes');
});
test('removes per-thread checkouts only for users with no running box', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: {
alice: [{ name: 'Code', isDirectory: true }],
bob: [{ name: 'Code', isDirectory: true }],
},
codeLeaves: {
alice: ['/work/homes/alice/Code/spoon-a/branch-x'],
bob: ['/work/homes/bob/Code/spoon-b/branch-y'],
},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(['bob']),
});
expect(plan.removeDirs).toContain(
'/work/homes/alice/Code/spoon-a/branch-x',
);
expect(plan.removeDirs).not.toContain(
'/work/homes/bob/Code/spoon-b/branch-y',
);
expect(plan.removeDirs).not.toContain('/work/homes/alice');
expect(plan.removeDirs).not.toContain('/work/homes/alice/Code');
});
});
@@ -0,0 +1,207 @@
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const tempDirs: string[] = [];
const pathExists = async (filePath: string): Promise<boolean> => {
try {
await stat(filePath);
return true;
} catch {
return false;
}
};
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/codex-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
// A non-existent workdir so the --output-last-message read is a clean ENOENT.
workdir: '/tmp/spoon-codex-adapter-test-missing',
homeDir: '/tmp/spoon-codex-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-codex-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'codex',
});
const makeAuthWorkspace = async (
profile: Record<string, unknown>,
): Promise<AdapterWorkspace> => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-auth-'));
tempDirs.push(workdir);
const repoDir = path.join(workdir, 'Code', 'spoon');
await mkdir(repoDir, { recursive: true });
return {
...makeWorkspace(),
claim: { ...claim, aiProviderProfile: profile } as unknown as Claim,
workdir,
homeDir: workdir,
repoDir,
};
};
describe('CodexAdapter', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('prepareAuth skips auth.json for an api_key profile', async () => {
const { createCodexAdapter } = await loadAdapter();
const adapter = createCodexAdapter();
const workspace = await makeAuthWorkspace({
id: 'p',
name: 'OpenAI',
provider: 'openai',
authType: 'api_key',
model: 'gpt-5',
// A raw API key, which is deliberately NOT valid JSON.
secret: 'sk-test',
});
await expect(adapter.prepareAuth?.(workspace)).resolves.toBeUndefined();
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await expect(pathExists(codexAuthPath)).resolves.toBe(false);
await expect(pathExists(openCodeAuthPath)).resolves.toBe(false);
});
test('prepareAuth writes auth.json for a ChatGPT-login profile', async () => {
const { createCodexAdapter } = await loadAdapter();
const adapter = createCodexAdapter();
const secret = JSON.stringify({ tokens: { access: 'abc' } });
const workspace = await makeAuthWorkspace({
id: 'p',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
secret,
});
await adapter.prepareAuth?.(workspace);
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await expect(readFile(codexAuthPath, 'utf8')).resolves.toBe(
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
);
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await expect(readFile(openCodeAuthPath, 'utf8')).resolves.toBe(
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
);
});
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createCodexAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'item.completed',
item: { id: 'item-1', type: 'agent_message', text: 'done' },
}),
);
await args.onStdoutLine?.(JSON.stringify({ type: 'turn.completed' }));
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createCodexAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'done\n\n',
externalMessageId: 'item-1',
});
expect(events).toContainEqual({ kind: 'assistant_completed' });
expect(result).toBeTypeOf('object');
// The turn ran through buildMarkedCommand (setsid bash session).
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
});
test('surfaces a turn.failed error while exiting zero', async () => {
const { createCodexAdapter } = await loadAdapter();
const execStream = vi.fn(async (args) => {
await args.onStdoutLine?.(
JSON.stringify({
type: 'turn.failed',
error: { message: 'boom' },
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createCodexAdapter({ execStream });
const result = await adapter.runTurn(makeWorkspace(), 'do it', () =>
Promise.resolve(),
);
expect(result.error).toContain('boom');
});
});
@@ -0,0 +1,37 @@
import { describe, expect, test, vi } from 'vitest';
import {
abortManagedCodexProcess,
managedCodexCommand,
} from '../../src/codex-process';
describe('managed Codex process', () => {
test('launches Codex in a job-specific process session', () => {
const command = managedCodexCommand({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
command: ['codex', 'exec', '--json', 'fix it'],
});
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
expect(command).toContain('/home/alice/.codex/job-1.pid');
expect(command).toContain('job-1');
expect(command.slice(-4)).toEqual(['codex', 'exec', '--json', 'fix it']);
});
test('invokes an isolated in-box kill for the matching job', async () => {
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
await abortManagedCodexProcess({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
execute,
});
expect(execute).toHaveBeenCalledOnce();
const [command] = execute.mock.calls[0] as [string[]];
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
expect(command).toContain('/home/alice/.codex/job-1.pid');
expect(command).toContain('job-1');
});
});
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { createHeartbeatController } from '../../src/heartbeat-controller';
describe('heartbeat controller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('does not overlap ticks while a heartbeat is in flight', async () => {
let resolveHeartbeat!: (value: { cancelRequested: boolean }) => void;
const heartbeat = vi.fn(
() =>
new Promise<{ cancelRequested: boolean }>((resolve) => {
resolveHeartbeat = resolve;
}),
);
const controller = createHeartbeatController({
intervalMs: 30_000,
heartbeat,
onCancel: vi.fn(),
onError: vi.fn(),
});
controller.start('job-1');
await vi.advanceTimersByTimeAsync(90_000);
expect(heartbeat).toHaveBeenCalledOnce();
resolveHeartbeat({ cancelRequested: false });
await Promise.resolve();
await vi.advanceTimersByTimeAsync(30_000);
expect(heartbeat).toHaveBeenCalledTimes(2);
controller.stop('job-1');
});
test('removes the timer before awaiting cancellation teardown', async () => {
let finishCancel!: () => void;
const onCancel = vi.fn(
() =>
new Promise<void>((resolve) => {
finishCancel = resolve;
}),
);
const heartbeat = vi.fn().mockResolvedValue({ cancelRequested: true });
const controller = createHeartbeatController({
intervalMs: 30_000,
heartbeat,
onCancel,
onError: vi.fn(),
});
controller.start('job-1');
await vi.advanceTimersByTimeAsync(30_000);
expect(onCancel).toHaveBeenCalledOnce();
expect(controller.has('job-1')).toBe(false);
await vi.advanceTimersByTimeAsync(90_000);
expect(heartbeat).toHaveBeenCalledOnce();
finishCancel();
});
});
@@ -0,0 +1,160 @@
import { getFunctionName } from 'convex/server';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
const mocks = vi.hoisted(() => ({
getWorktreeDiff: vi.fn(() =>
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
),
mutation: vi.fn(),
streamExecInContainer: vi.fn(),
}));
vi.mock('convex/browser', () => ({
ConvexHttpClient: vi.fn(
class ConvexHttpClient {
mutation = mocks.mutation;
},
),
}));
vi.mock('../../src/env', () => ({
env: {
buildCreatedAt: 'test',
buildSha: 'test',
convexUrl: 'http://convex.test',
jobTimeoutMs: 1000,
runtime: 'docker',
workerId: 'worker-1',
workerToken: 'token',
},
}));
vi.mock('../../src/git', () => ({
cloneRepository: vi.fn(),
commitAndPush: vi.fn(),
getStatus: vi.fn(),
getWorktreeDiff: mocks.getWorktreeDiff,
run: vi.fn(),
}));
vi.mock('../../src/github', () => ({
getInstallationToken: vi.fn(),
openDraftPullRequest: vi.fn(),
}));
vi.mock('../../src/runtime/docker', () => ({
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
runExecInContainer: vi.fn(),
streamExecInContainer: mocks.streamExecInContainer,
}));
vi.mock('../../src/user-container', () => ({
acquireUserBox: vi.fn(),
runningBoxUsernames: vi.fn(() => new Set()),
}));
vi.mock('../../src/user-environment', () => ({
fetchUserEnvironment: vi.fn(),
materializeUserHome: vi.fn(),
}));
const decision = {
decision: 'sync',
risk: 'low',
summary: 'Safe to sync.',
ignoredCommitShas: [],
ignoredReason: '',
recommendedAction: 'Sync upstream.',
requiresUserApproval: false,
};
const load = async () => await import('../../src/worker');
afterEach(async () => {
const worker = await load();
worker._resetActiveWorkspacesForTests();
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('maintenance workspace cleanup', () => {
test('releases and removes the workspace when status teardown fails', async () => {
const worker = await load();
const release = vi.fn();
const jobId = 'maintenance-mark';
mocks.mutation.mockImplementation((reference) => {
if (getFunctionName(reference) === 'agentJobs:markWorkspaceStopped') {
return Promise.reject(new Error('status teardown failed'));
}
return Promise.resolve('message-1');
});
mocks.streamExecInContainer.mockImplementation(async (args) => {
await args.onStdoutLine(
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify(decision),
},
}),
);
return { exitCode: 0, output: '' };
});
worker._setActiveWorkspaceForTests(jobId, {
claim: {
job: {
_id: jobId as Id<'agentJobs'>,
prompt: 'Review maintenance changes',
jobType: 'maintenance_review',
baseBranch: 'main',
workBranch: 'spoon/maintenance',
forkOwner: 'team',
forkRepo: 'spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'spoon',
},
spoon: { name: 'Spoon' },
openai: {
model: 'gpt-5',
reasoningEffort: 'medium',
},
aiProviderProfile: {
id: 'profile-1',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
},
workdir: '/tmp/workspace',
homeDir: '/tmp/workspace',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/workspace/Code/spoon',
boxName: 'spoon-box-tester',
boxHandle: { boxName: 'spoon-box-tester', release },
githubToken: 'github-token',
redact: (value: string) => value,
runtime: 'codex',
});
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
expect(
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
).toBe(false);
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
'Agent workspace is not active on this worker.',
);
expect(consoleError).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,86 @@
import {
access,
chmod,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import {
removeMaterializedEnv,
writeMaterializedEnv,
} from '../../src/materialize-env';
const tempDirs: string[] = [];
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
const exists = async (filePath: string) =>
access(filePath).then(
() => true,
() => false,
);
describe('materialize env', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('writes the env file 0600 at the returned path with quoted values', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
await expect(exists(envPath)).resolves.toBe(true);
await expect(mode(envPath)).resolves.toBe(0o600);
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
});
test('tightens perms to 0600 and overwrites content when the env file pre-exists 0644', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const preExisting = path.join(repoDir, '.env.local');
// Pre-create the target with loose perms. umask may mask the writeFile
// mode, so chmod explicitly and assert the precondition really is 0644.
await writeFile(preExisting, 'OLD', { mode: 0o644 });
await chmod(preExisting, 0o644);
await expect(mode(preExisting)).resolves.toBe(0o644);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
// Overwrote the plaintext content AND tightened perms unconditionally.
await expect(mode(envPath)).resolves.toBe(0o600);
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
});
test('removeMaterializedEnv deletes an existing file and no-ops when absent', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
await expect(exists(envPath)).resolves.toBe(true);
await removeMaterializedEnv(envPath);
await expect(exists(envPath)).resolves.toBe(false);
// No-ops when the file is already gone or the path is undefined.
await expect(removeMaterializedEnv(envPath)).resolves.toBeUndefined();
await expect(removeMaterializedEnv(undefined)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,100 @@
import { describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/opencode-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'OpenAI',
provider: 'openai',
authType: 'api_key',
secret: 'sk-test',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-opencode-adapter-test-missing',
homeDir: '/tmp/spoon-opencode-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-opencode-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'opencode',
});
describe('OpenCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createOpenCodeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'message.part.delta',
properties: { part: { text: 'hi' }, messageID: 'm1' },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
sessionID: 'ses_abc',
parts: [{ text: 'final answer' }],
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createOpenCodeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi',
externalMessageId: 'm1',
});
expect(result).toBeTypeOf('object');
expect(result.sessionId).toBe('ses_abc');
expect(result.finalMessage).toBe('final answer');
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// opencode run argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'opencode' 'run' '--format' 'json'");
});
});
@@ -0,0 +1,54 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import { assertContainedRealPath } from '../../src/path-containment';
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
describe('assertContainedRealPath', () => {
test('allows a normal file inside the root', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await writeFile(path.join(root, 'a.txt'), 'hi');
await expect(assertContainedRealPath(root, 'a.txt')).resolves.toBe(
path.join(root, 'a.txt'),
);
});
test('rejects a lexical .. escape', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await expect(assertContainedRealPath(root, '../secret')).rejects.toThrow();
});
test('rejects reading through a symlink that escapes the root', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
const outside = path.join(dir, 'outside');
await mkdir(root, { recursive: true });
await mkdir(outside, { recursive: true });
await writeFile(path.join(outside, 'secret'), 'S');
await symlink(outside, path.join(root, 'link'));
await expect(
assertContainedRealPath(root, 'link/secret'),
).rejects.toThrow();
});
test('rejects a write whose final target is a symlink', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await symlink(path.join(dir, 'evil'), path.join(root, 'out'));
await expect(
assertContainedRealPath(root, 'out', { forWrite: true }),
).rejects.toThrow();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { createRedactor } from '../../src/redact';
describe('createRedactor', () => {
test('redacts group-less token patterns without emitting a literal $1', () => {
const redact = createRedactor([]);
const output = redact('token ghs_ABC123 and sk-xyz789 here');
expect(output).toContain('[redacted]');
expect(output).not.toContain('$1');
expect(output).not.toContain('ghs_ABC123');
expect(output).not.toContain('sk-xyz789');
});
test('redacts github_pat_ tokens to [redacted]', () => {
const redact = createRedactor([]);
const output = redact('github_pat_ABCDEF123456');
expect(output).toBe('[redacted]');
expect(output).not.toContain('$1');
});
test('keeps the key= prefix for key=value secrets', () => {
const redact = createRedactor([]);
expect(redact('api_key=secret')).toBe('api_key=[redacted]');
expect(redact('password=hunter2')).toBe('password=[redacted]');
});
test('redacts caller-supplied secret values verbatim', () => {
const redact = createRedactor(['super-secret-value']);
expect(redact('the value is super-secret-value')).toBe(
'the value is [redacted]',
);
});
});
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
vi.doMock('../../src/worker', () => ({ getTerminalWorkspace: () => null }));
vi.doMock('../../src/user-container', () => ({
acquireUserBox: vi.fn(),
}));
return await import('../../src/terminal');
};
describe('parseResizeMessage', () => {
afterEach(() => vi.resetModules());
test('parses and clamps a resize control frame', async () => {
const { parseResizeMessage } = await load();
const msg = Buffer.from(
JSON.stringify({ type: 'resize', cols: 120, rows: 40 }),
);
expect(parseResizeMessage(msg, false)).toEqual({ cols: 120, rows: 40 });
});
test('returns null for binary input (raw keystrokes)', async () => {
const { parseResizeMessage } = await load();
expect(parseResizeMessage(Buffer.from([1, 2, 3]), true)).toBeNull();
});
test('returns null for non-resize JSON', async () => {
const { parseResizeMessage } = await load();
expect(
parseResizeMessage(Buffer.from('{"type":"other"}'), false),
).toBeNull();
});
test('clamps out-of-range dimensions into [1,1000]', async () => {
const { parseResizeMessage } = await load();
const msg = Buffer.from(
JSON.stringify({ type: 'resize', cols: 99999, rows: 0 }),
);
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
});
});
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'vitest';
import {
resolveTurnOutcome,
shouldAppendCompletedContent,
} from '../../src/runtime/turn-outcome';
describe('shouldAppendCompletedContent', () => {
test('does not re-append when deltas already streamed the answer', () => {
// Claude/OpenCode emit the final answer as assistant_delta AND as the
// content-bearing assistant_completed event. Folding the completed
// content in again would produce "<answer><answer>".
expect(shouldAppendCompletedContent('the answer', 'the answer')).toBe(
false,
);
});
test('appends completed content when nothing has streamed yet', () => {
expect(shouldAppendCompletedContent('', 'the final result')).toBe(true);
});
test('treats whitespace-only streamed text as empty', () => {
expect(shouldAppendCompletedContent(' \n', 'the final result')).toBe(
true,
);
});
test('does not append when the completed event carries no content', () => {
expect(shouldAppendCompletedContent('the answer', undefined)).toBe(false);
expect(shouldAppendCompletedContent('', undefined)).toBe(false);
expect(shouldAppendCompletedContent('', '')).toBe(false);
});
});
describe('resolveTurnOutcome', () => {
test('partial streamed text + error is surfaced as failed and text preserved', () => {
const outcome = resolveTurnOutcome({
assistantText: 'partial answer before crash',
error: 'exit code 1: rate limited',
runtime: 'codex',
});
expect(outcome.text).toBe('partial answer before crash');
expect(outcome.failure).toBe('codex failed:\nexit code 1: rate limited');
});
test('successful turn with streamed text and no error has no failure', () => {
const outcome = resolveTurnOutcome({
assistantText: 'complete answer',
runtime: 'codex',
});
expect(outcome.text).toBe('complete answer');
expect(outcome.failure).toBeUndefined();
});
test('empty turn with error is surfaced as failed', () => {
const outcome = resolveTurnOutcome({
assistantText: ' ',
error: 'exit code 137',
runtime: 'claude',
});
expect(outcome.failure).toBe('claude failed:\nexit code 137');
});
test('empty turn with no error reports the no-response failure', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
runtime: 'codex',
});
expect(outcome.failure).toBe(
'Codex completed without producing an assistant response.',
);
});
test('recovers finalMessage text when nothing streamed and no error', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
recoveredText: 'recovered from last-message file',
runtime: 'codex',
});
expect(outcome.text).toBe('recovered from last-message file');
expect(outcome.failure).toBeUndefined();
});
test('recovered finalMessage text + error is still surfaced as failed', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
recoveredText: 'recovered partial',
error: 'network drop',
runtime: 'codex',
});
expect(outcome.text).toBe('recovered partial');
expect(outcome.failure).toBe('codex failed:\nnetwork drop');
});
});
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('../../src/env', () => ({ env: { boxIdleMs: 1000 } }));
vi.mock('../../src/runtime/docker', () => ({
ensureUserContainer: vi.fn((args: { username: string }) =>
Promise.resolve(`spoon-box-${args.username}`),
),
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
userContainerName: (username: string) => `spoon-box-${username}`,
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
}));
const load = async () => await import('../../src/user-container');
describe('box registry', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(async () => {
const module = await load();
module._resetBoxRegistryForTests();
vi.useRealTimers();
vi.resetModules();
vi.clearAllMocks();
});
test('serializes concurrent acquire into a single docker run', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const [a, b] = await Promise.all([
module.acquireUserBox({
username: 'alice',
workdir: '/w',
containerHome: '/home/alice',
}),
module.acquireUserBox({
username: 'alice',
workdir: '/w',
containerHome: '/home/alice',
}),
]);
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(a.boxName).toBe('spoon-box-alice');
a.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
b.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
});
test('double-release of one handle does not reap a box held elsewhere', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const a = await module.acquireUserBox({
username: 'bob',
workdir: '/w',
containerHome: '/home/bob',
});
const b = await module.acquireUserBox({
username: 'bob',
workdir: '/w',
containerHome: '/home/bob',
});
a.release();
a.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
b.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
});
test('reconcileExistingBoxes adopts orphans with zero refs and reaps them', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
(
docker.listWorkspaceContainerNames as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(['spoon-box-carol']);
await module.reconcileExistingBoxes();
expect(module.runningBoxUsernames().has('carol')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledWith(
'spoon-box-carol',
);
});
test('ensures an adopted box once before returning acquire handles', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
'spoon-box-casey',
]);
await module.reconcileExistingBoxes();
const first = await module.acquireUserBox({
username: 'casey',
workdir: '/w',
containerHome: '/home/casey',
});
const second = await module.acquireUserBox({
username: 'casey',
workdir: '/w',
containerHome: '/home/casey',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(first.boxName).toBe('spoon-box-casey');
expect(second.boxName).toBe('spoon-box-casey');
});
test('retries ensure after the first acquire fails', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const ensure = vi.mocked(docker.ensureUserContainer);
ensure
.mockRejectedValueOnce(new Error('docker failed'))
.mockResolvedValueOnce('spoon-box-dana');
await expect(
module.acquireUserBox({
username: 'dana',
workdir: '/w',
containerHome: '/home/dana',
}),
).rejects.toThrow('docker failed');
const handle = await module.acquireUserBox({
username: 'dana',
workdir: '/w',
containerHome: '/home/dana',
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(handle.boxName).toBe('spoon-box-dana');
});
test('finishes an old idle reap before a new acquire can ensure the box', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const ensure = vi.mocked(docker.ensureUserContainer);
const stop = vi.mocked(docker.stopWorkspaceContainer);
const events: string[] = [];
let finishStop!: () => void;
const stopDone = new Promise<void>((resolve) => {
finishStop = resolve;
});
ensure.mockReset().mockImplementation((args: { username: string }) => {
events.push('ensure');
return Promise.resolve(`spoon-box-${args.username}`);
});
stop.mockReset().mockImplementationOnce(() => {
events.push('stop:start');
return stopDone.then(() => {
events.push('stop:end');
});
});
const first = await module.acquireUserBox({
username: 'erin',
workdir: '/w',
containerHome: '/home/erin',
});
first.release();
await vi.advanceTimersByTimeAsync(1000);
const secondPromise = module.acquireUserBox({
username: 'erin',
workdir: '/w',
containerHome: '/home/erin',
});
await Promise.resolve();
await Promise.resolve();
expect(events).toEqual(['ensure', 'stop:start']);
finishStop();
const second = await secondPromise;
expect(second.boxName).toBe('spoon-box-erin');
expect(events).toEqual(['ensure', 'stop:start', 'stop:end', 'ensure']);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, test, vi } from 'vitest';
import { abortManagedCodexProcess } from '../../src/codex-process';
import { teardownWorkspace } from '../../src/workspace-teardown';
describe('workspace teardown', () => {
test('event failure does not skip abort, stop, removal, or release', async () => {
const calls: string[] = [];
const record = (name: string) => vi.fn(() => calls.push(name));
const result = await teardownWorkspace({
stopHeartbeat: record('heartbeat'),
removeActive: record('remove'),
appendEvent: vi.fn().mockRejectedValue(new Error('event failed')),
abortAgent: record('abort'),
markStopped: vi.fn().mockRejectedValue(new Error('mutation failed')),
closeAgent: record('close'),
stopContainer: vi.fn().mockRejectedValue(new Error('container failed')),
release: record('release'),
});
expect(calls).toEqual(['heartbeat', 'remove', 'abort', 'close', 'release']);
expect(result.errors).toHaveLength(3);
});
test('removes active state before asynchronous cancellation work', async () => {
const active = new Set(['job-1']);
let activeDuringAbort = true;
await teardownWorkspace({
stopHeartbeat: vi.fn(),
removeActive: () => active.delete('job-1'),
abortAgent: () => {
activeDuringAbort = active.has('job-1');
},
release: vi.fn(),
});
expect(activeDuringAbort).toBe(false);
expect(active.has('job-1')).toBe(false);
});
test('Codex cancellation invokes isolated kill and leaves no active workspace', async () => {
const active = new Set(['job-1']);
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
await teardownWorkspace({
stopHeartbeat: vi.fn(),
removeActive: () => active.delete('job-1'),
abortAgent: async () =>
await abortManagedCodexProcess({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
execute,
}),
release: vi.fn(),
});
expect(execute).toHaveBeenCalledOnce();
expect(active.has('job-1')).toBe(false);
});
});
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
const AgentsRedirectPage = () => {
redirect('/threads?source=user_request');
};
export default AgentsRedirectPage;
+47 -13
View File
@@ -3,27 +3,38 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
const spoons = useQuery(api.spoons.listMineWithState, {});
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
const threads = useQuery(api.threads.listMine, { limit: 25 });
const spoonList = spoons ?? [];
const threadList = threads ?? [];
const metricsLoading = spoons === undefined || threads === undefined;
const activeSpoons = spoonList.filter(
(spoon) => spoon.status === 'active',
).length;
const behind = spoons.filter(
const behind = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
).length;
const diverged = spoons.filter(
const diverged = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length;
const openPullRequests = spoons.reduce(
const openPullRequests = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -43,10 +54,22 @@ const DashboardPage = () => {
</Button>
</div>
{metricsLoading ? (
<div
role='status'
aria-label='Loading'
className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'
>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-32 w-full' />
))}
<span className='sr-only'>Loading</span>
</div>
) : (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Spoons'
value={spoons.length}
value={spoonList.length}
note={`${activeSpoons} active`}
icon={GitBranch}
/>
@@ -59,7 +82,7 @@ const DashboardPage = () => {
<MetricCard
label='Open threads'
value={
threads.filter(
threadList.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
@@ -76,16 +99,23 @@ const DashboardPage = () => {
icon={ShieldCheck}
/>
</div>
)}
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
{threads === undefined ? (
<ListSkeleton rows={2} />
) : (
<MaintenanceQueue threads={threads} />
)}
</section>
<div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={3} />
) : spoons.length ? (
spoons
.slice(0, 3)
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
@@ -108,7 +138,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Upstream checks</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length ? (
{syncRuns === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : syncRuns.length ? (
<div className='space-y-3'>
{syncRuns.map((run) => (
<div
@@ -137,7 +169,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{threads.length ? (
{threads === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : threads.length ? (
<div className='space-y-3'>
{threads.slice(0, 5).map((thread) => (
<div
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import { Button } from '@spoon/ui';
const AppError = ({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Something went wrong</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This page hit an unexpected error. It has been reported.
</p>
<Button onClick={() => reset()}>Try again</Button>
</main>
);
};
export default AppError;
+5
View File
@@ -0,0 +1,5 @@
import { MachineShell } from '@/components/machine/machine-shell';
const MachinePage = () => <MachineShell />;
export default MachinePage;
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Button } from '@spoon/ui';
const NotFound = () => (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Not found</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This item does not exist, or you do not have access to it.
</p>
<Button asChild>
<Link href='/dashboard'>Back to dashboard</Link>
</Button>
</main>
);
export default NotFound;
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
const AiSettingsPage = () => redirect('/settings/ai-providers');
export default AiSettingsPage;
+10 -1
View File
@@ -3,7 +3,15 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
import {
Bell,
Brain,
FileCog,
Github,
ServerCog,
Shield,
User,
} from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -12,6 +20,7 @@ const settingsItems = [
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,19 @@
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
const SettingsNotificationsPage = () => {
return (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Notifications</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Choose which notifications also reach you by email. In-app
notifications always show in the bell; these toggles only control the
email copies.
</p>
</div>
<NotificationPreferencesPanel />
</section>
);
};
export default SettingsNotificationsPage;
@@ -1,5 +1,3 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
@@ -21,7 +21,24 @@ const AgentWorkspacePage = () => {
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
}, [job?.threadId, router]);
if (job?.threadId) {
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
if (job === null) {
return (
<main className='space-y-4 p-6'>
<p className='text-muted-foreground'>This workspace was not found.</p>
<Button asChild variant='outline' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>Back to Spoon</Link>
</Button>
</main>
);
}
if (job.threadId) {
return (
<main className='text-muted-foreground p-6'>
Opening thread workspace...
@@ -37,7 +54,7 @@ const AgentWorkspacePage = () => {
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={jobId} />
<AgentWorkspaceShell key={jobId} jobId={jobId} />
</main>
);
};
+31 -26
View File
@@ -1,8 +1,8 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
@@ -17,6 +17,7 @@ import {
Button,
Card,
CardContent,
Skeleton,
Table,
TableBody,
TableCell,
@@ -31,16 +32,19 @@ const formatDate = (value?: number) =>
: 'Never';
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
const spoons = useQuery(api.spoons.listMineWithState, {});
const threads = useQuery(api.threads.listMine, { limit: 100 });
const spoonList = spoons ?? [];
const threadList = threads ?? [];
const spoonsLoading = spoons === undefined;
const metricsLoading = spoons === undefined || threads === undefined;
const active = spoonList.filter((spoon) => spoon.status === 'active').length;
const needsReview = threadList.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
const upstreamWaiting = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -65,7 +69,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{spoonList.length}</p>
)}
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
@@ -74,7 +82,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{active}</p>
)}
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
@@ -83,14 +95,20 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
{metricsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{needsReview}</p>
)}
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
) : spoons.length ? (
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
@@ -111,22 +129,12 @@ const SpoonsPage = () => {
return (
<TableRow
key={spoon._id}
role='link'
tabIndex={0}
className='hover:bg-muted/50 cursor-pointer'
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(href);
}
}}
className='hover:bg-muted/50 relative cursor-pointer'
>
<TableCell className='pl-4'>
<Link
href={href}
className='group inline-flex min-w-0 flex-col'
onClick={(event) => event.stopPropagation()}
className='group inline-flex min-w-0 flex-col after:absolute after:inset-0 after:content-[""]'
>
<span className='group-hover:text-primary font-medium transition-colors'>
{spoon.name}
@@ -170,10 +178,7 @@ const SpoonsPage = () => {
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
<TableCell className='pr-4 text-right'>
<Button size='sm' variant='outline' asChild>
<Link
href={href}
onClick={(event) => event.stopPropagation()}
>
<Link href={href} className='relative z-10'>
Open
<ArrowUpRight className='size-3' />
</Link>
@@ -201,7 +206,7 @@ const SpoonsPage = () => {
</Card>
)}
{spoons.length ? (
{spoonList.length ? (
<p className='text-muted-foreground text-sm'>
Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
@@ -43,6 +43,14 @@ const ThreadDetailPage = () => {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
if (details === null) {
return (
<main className='text-muted-foreground p-6'>
This thread was not found.
</main>
);
}
const { thread, spoon, latestJob } = details;
if (latestJob && spoon) {
return (
@@ -53,7 +61,7 @@ const ThreadDetailPage = () => {
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={latestJob._id} />
<AgentWorkspaceShell key={latestJob._id} jobId={latestJob._id} />
</main>
);
}
+19 -24
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const threads =
useQuery(api.threads.listMine, {
const threads = useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
@@ -70,8 +70,9 @@ const ThreadsPage = () => {
| 'failed'
| 'cancelled',
limit: 100,
}) ?? [];
const visibleThreads = threads.filter((thread) => {
});
const threadsLoading = threads === undefined;
const visibleThreads = (threads ?? []).filter((thread) => {
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
return false;
@@ -312,25 +313,25 @@ const ThreadsPage = () => {
</div>
<div className='space-y-3'>
{visibleThreads.length ? (
{threadsLoading ? (
<ListSkeleton rows={4} />
) : visibleThreads.length ? (
visibleThreads.map((thread) => (
<Card
key={thread._id}
role='link'
tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(threadTarget(thread))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(threadTarget(thread));
}
}}
className='hover:border-primary/50 relative cursor-pointer shadow-none transition-colors'
>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
<h2 className='truncate font-medium'>
<Link
href={threadTarget(thread)}
className='after:absolute after:inset-0 after:content-[""]'
>
{thread.title}
</Link>
</h2>
{thread.spoonName ? (
<Badge variant='outline'>{thread.spoonName}</Badge>
) : null}
@@ -361,15 +362,10 @@ const ThreadsPage = () => {
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
</p>
) : null}
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
<div className='relative z-10 mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild>
<Link
href={threadTarget(thread)}
onClick={(event) => event.stopPropagation()}
>
Open workspace
</Link>
<Link href={threadTarget(thread)}>Open workspace</Link>
</Button>
) : null}
{thread.latestJobPullRequestUrl ? (
@@ -378,7 +374,6 @@ const ThreadsPage = () => {
href={thread.latestJobPullRequestUrl}
target='_blank'
rel='noreferrer'
onClick={(event) => event.stopPropagation()}
>
PR
</a>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
const UpdatesRedirectPage = () => {
redirect('/threads?source=upstream_update');
};
export default UpdatesRedirectPage;
@@ -100,12 +100,14 @@ const ForgotPassword = () => {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
// Only clear the form once the request succeeded; a failed request
// keeps the typed email so the user can retry without re-entering it.
forgotPasswordForm.reset();
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
@@ -121,12 +123,14 @@ const ForgotPassword = () => {
setLoading(true);
try {
await signIn('password', formData);
// Only clear on success (navigation follows); a failed submit keeps the
// entered password so the user can correct and retry.
resetVerificationForm.reset();
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
@@ -230,7 +234,7 @@ const ForgotPassword = () => {
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
email.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
@@ -265,7 +269,7 @@ const ForgotPassword = () => {
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
Confirm Password
</FormLabel>
<FormControl>
<Input
@@ -1,25 +0,0 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
};
const ProfileLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
return <>{children}</>;
};
export default ProfileLayout;
@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation';
const Profile = () => {
redirect('/settings/profile');
};
export default Profile;
+11 -7
View File
@@ -132,12 +132,14 @@ const SignIn = () => {
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
await signIn('password', formData).then(() => {
signInForm.reset();
router.push('/dashboard');
});
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
@@ -153,6 +155,7 @@ const SignIn = () => {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
signUpForm.reset();
setEmail(values.email);
setFlow('email-verification');
});
@@ -160,7 +163,6 @@ const SignIn = () => {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
@@ -174,12 +176,14 @@ const SignIn = () => {
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
await signIn('password', formData).then(() => {
verifyEmailForm.reset();
router.push('/dashboard');
});
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
@@ -232,7 +236,7 @@ const SignIn = () => {
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
pendingText='Verifying...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Verify Email
@@ -423,7 +427,7 @@ const SignIn = () => {
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
Confirm Password
</FormLabel>
<FormControl>
<Input
+21
View File
@@ -0,0 +1,21 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async (request: Request) =>
await withBox(async (username) => {
const url = new URL(request.url);
return await proxyBox(
username,
'file',
{ method: 'GET' },
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
);
});
export const PUT = async (request: Request) =>
await withBox(
async (username) =>
await proxyBox(username, 'file', {
method: 'PUT',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const POST = async (request: Request) =>
await withBox(
async (username) =>
await proxyBox(username, 'lifecycle', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,6 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async () =>
await withBox(
async (username) => await proxyBox(username, 'status', { method: 'GET' }),
);
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { mintBoxTerminalToken, resolveBoxUsername } from '@/lib/agent-worker-proxy';
export const GET = async () => {
const resolved = await resolveBoxUsername();
if (!resolved.ok) return resolved.response;
const minted = mintBoxTerminalToken(resolved.username);
return minted
? NextResponse.json(minted)
: NextResponse.json(
{ error: 'Terminal is not configured on this deployment.' },
{ status: 503 },
);
};
+6
View File
@@ -0,0 +1,6 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async () =>
await withBox(
async (username) => await proxyBox(username, 'tree', { method: 'GET' }),
);
@@ -35,7 +35,14 @@ export const POST = async (
{ status: 400 },
);
}
const details = await fetchQuery(api.threads.get, { threadId }, { token });
const details = (await fetchQuery(
api.threads.get,
{ threadId },
{ token },
));
if (details === null) {
return NextResponse.json({ error: 'Thread not found.' }, { status: 404 });
}
const latestJob = details.latestJob;
const canSendToWorker =
latestJob &&
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown,
Ban,
FilePenLine,
MessagesSquare,
@@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui';
import { DiffFileView, useDiffTheme } from './diff-file-view';
import { parseDiffFileForPath } from './diff-utils';
import { isNearBottom } from './workspace-helpers';
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
@@ -70,6 +72,7 @@ export const AgentThread = ({
const [filter, setFilter] = useState<ActivityFilter>('all');
const diffTheme = useDiffTheme();
const scrollRef = useRef<HTMLDivElement>(null);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const chatMessages = useMemo(
() =>
messages.filter((message) => {
@@ -111,20 +114,35 @@ export const AgentThread = ({
? []
: workspaceChanges;
useEffect(() => {
const scrollToLatest = () => {
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 160 || agentTurnActive) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
setShowJumpToLatest(false);
};
// Only auto-scroll to the newest content when the user is already near the
// bottom. If they have scrolled up to read, don't yank them — surface a
// "jump to latest" affordance instead. (The agent streaming no longer forces
// a scroll; that was fighting the reader.)
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
if (isNearBottom(node)) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
setShowJumpToLatest(false);
} else {
setShowJumpToLatest(true);
}
}, [
agentTurnActive,
events.length,
interactions.length,
messages.length,
@@ -227,8 +245,13 @@ export const AgentThread = ({
</Button>
))}
</div>
<div className='relative flex min-h-0 flex-1 flex-col'>
<div
ref={scrollRef}
onScroll={() => {
const node = scrollRef.current;
if (node && isNearBottom(node)) setShowJumpToLatest(false);
}}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
>
{(filter === 'all' || filter === 'chat') && interactions.length > 0
@@ -430,6 +453,19 @@ export const AgentThread = ({
yet.
</p>
) : null}
</div>
{showJumpToLatest ? (
<Button
type='button'
size='sm'
variant='secondary'
className='absolute bottom-3 left-1/2 z-10 -translate-x-1/2 shadow-md'
onClick={scrollToLatest}
>
<ArrowDown className='size-3' />
Jump to latest
</Button>
) : null}
</div>
<div className='border-border flex-none space-y-2 border-t p-3'>
<Textarea
@@ -1,13 +1,14 @@
'use client';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import {
FileCode,
GitCompare,
Loader2,
MessagesSquare,
PanelLeft,
SquareTerminal,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -25,6 +26,11 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
Button,
cn,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
Tabs,
TabsContent,
TabsList,
@@ -41,6 +47,7 @@ import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
import { WorkspaceTerminal } from './workspace-terminal';
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
@@ -51,6 +58,9 @@ type OpenFileState = {
loading: boolean;
saving: boolean;
error?: string;
// Set when the agent edited this file on disk while the user had unsaved
// local edits, so the UI can warn instead of silently stomping either side.
conflicted?: boolean;
};
type PendingOverwrite = {
@@ -61,17 +71,27 @@ type PendingOverwrite = {
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
useQuery(
api.agentJobs.listMessages,
job ? { jobId, limit: 200 } : 'skip',
) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ??
[];
const workspaceChanges =
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
useQuery(
api.agentJobs.listWorkspaceChanges,
job ? { jobId, limit: 200 } : 'skip',
) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
useQuery(
api.agentJobs.listInteractionRequests,
job ? { jobId, status: 'all' } : 'skip',
) ?? [];
const uiState = useQuery(
api.agentJobs.getWorkspaceUiState,
job ? { jobId } : 'skip',
);
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
@@ -94,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useState<WorkspaceTab>('editor');
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
const [pendingClosePath, setPendingClosePath] = useState<string>();
const [fileTreeOpen, setFileTreeOpen] = useState(false);
// Per-loader sequence guards: only the latest response for each loader is
// applied, so a slower earlier fetch can't overwrite newer tree/diff data.
const treeSequencerRef = useRef<
ReturnType<typeof createRequestSequencer> | undefined
>(undefined);
treeSequencerRef.current ??= createRequestSequencer();
const treeSequencer = treeSequencerRef.current;
const diffSequencerRef = useRef<
ReturnType<typeof createRequestSequencer> | undefined
>(undefined);
diffSequencerRef.current ??= createRequestSequencer();
const diffSequencer = diffSequencerRef.current;
// Count consecutive workspace status/health failures. A single transient
// 'workspace is not active' blip (worker restart) must not flash the big red
// recovery panel — only surface it after RECOVERY_THRESHOLD in a row.
const consecutiveFailuresRef = useRef(0);
const processedChangeSignatureRef = useRef(0);
const conflictToastPathsRef = useRef<Set<string>>(new Set());
const recordWorkspaceSuccess = useCallback(() => {
consecutiveFailuresRef.current = 0;
setWorkspaceError(undefined);
}, []);
const recordWorkspaceFailure = useCallback((message: string) => {
consecutiveFailuresRef.current += 1;
if (shouldShowRecovery(consecutiveFailuresRef.current)) {
setWorkspaceError(message);
}
}, []);
const workspaceDisabled =
!job ||
@@ -122,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
const loadTree = useCallback(async () => {
const token = treeSequencer.next();
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
if (!treeSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setTree(data.tree);
}, [jobId]);
}, [jobId, recordWorkspaceSuccess, treeSequencer]);
const loadDiff = useCallback(async () => {
const token = diffSequencer.next();
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
if (!diffSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setDiff(data.diff);
}, [jobId]);
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
@@ -143,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
setAgentTurnActive(false);
const body = await response.text();
if (body.includes('workspace is not active')) {
setWorkspaceError(body);
recordWorkspaceFailure(body);
}
return;
}
const data = (await response.json()) as { active?: boolean };
setWorkspaceError(undefined);
recordWorkspaceSuccess();
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
const loadFile = useCallback(
async (path: string) => {
@@ -183,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
[jobId],
);
// Re-fetch an already-open file and replace its editor buffer. Used when the
// agent edits a file that is currently open: if the local buffer is clean we
// adopt the new on-disk content; if the user has unsaved edits we flag a
// conflict instead of stomping their work.
const refreshFileBuffer = useCallback(
async (path: string) => {
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) return;
const data = (await response.json()) as FileResponse;
setFiles((current) => {
const existing = current[path];
if (!existing) return current;
if (existing.content !== existing.savedContent) {
return { ...current, [path]: { ...existing, conflicted: true } };
}
conflictToastPathsRef.current.delete(path);
return {
...current,
[path]: {
path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
conflicted: false,
},
};
});
},
[jobId],
);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
@@ -209,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useEffect(() => {
if (!workspaceReady) return;
// Recovery is driven solely by the recurring status heartbeat
// (loadAgentStatus) so a one-off tree/diff hiccup can't trip the panel.
const handleError = (error: unknown) => {
console.error(error);
setWorkspaceError(error instanceof Error ? error.message : String(error));
};
const timeout = window.setTimeout(() => {
void loadTree().catch(handleError);
@@ -249,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
(latest, change) => Math.max(latest, change._creationTime),
0,
);
// Read the (freshly-allocated-each-render) changes array via a ref inside the
// signature-gated effect below, so the effect's deps stay on the stable
// numeric signature instead of the array identity.
const workspaceChangesRef = useRef(workspaceChanges);
workspaceChangesRef.current = workspaceChanges;
useEffect(() => {
if (!workspaceReady) return;
const timeout = window.setTimeout(() => {
@@ -264,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
loadTree,
]);
// When the agent records a workspace change that touches a currently OPEN
// file, refresh that file's editor buffer (clean buffers adopt the new
// content; dirty buffers get a conflict flag + one-time toast) so the editor
// doesn't keep showing pre-edit content.
useEffect(() => {
if (!workspaceReady) return;
if (workspaceChangeSignature <= processedChangeSignatureRef.current) return;
const previousSignature = processedChangeSignatureRef.current;
processedChangeSignatureRef.current = workspaceChangeSignature;
const changedPaths = new Set(
workspaceChangesRef.current
.filter((change) => change._creationTime > previousSignature)
.map((change) => change.path),
);
for (const path of openFilePaths) {
if (!changedPaths.has(path)) continue;
const file = files[path];
if (!file) continue;
if (file.content !== file.savedContent) {
setFiles((current) =>
current[path]
? { ...current, [path]: { ...current[path], conflicted: true } }
: current,
);
if (!conflictToastPathsRef.current.has(path)) {
conflictToastPathsRef.current.add(path);
toast.warning(`${path} changed on disk`);
}
} else {
void refreshFileBuffer(path).catch(() => undefined);
}
}
}, [
files,
openFilePaths,
refreshFileBuffer,
workspaceChangeSignature,
workspaceReady,
]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
const timeout = window.setTimeout(() => {
@@ -323,6 +460,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
}
if (job === null) {
return (
<main className='text-muted-foreground p-6'>
This workspace was not found.
</main>
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
@@ -374,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}));
throw new Error(await response.text());
}
conflictToastPathsRef.current.delete(path);
setFiles((current) => ({
...current,
[path]: {
@@ -384,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
content,
savedContent: content,
saving: false,
conflicted: false,
},
}));
await loadDiff();
@@ -473,7 +620,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
};
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<main className='border-border bg-muted/20 flex h-[calc(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
<JobStatusBar job={job} />
{workspacePending && !workspaceError ? (
<div className='border-border bg-background border-b p-4'>
@@ -555,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<div className='border-border bg-background flex items-center justify-between gap-2 border-b px-4 py-2'>
<Button
type='button'
variant='outline'
size='sm'
className='lg:hidden'
onClick={() => setFileTreeOpen(true)}
>
<PanelLeft className='size-4' />
Files
</Button>
<div className='flex flex-1 items-center justify-end'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
</div>
<Sheet open={fileTreeOpen} onOpenChange={setFileTreeOpen}>
<SheetContent side='left' className='w-[300px] p-0 lg:hidden'>
<SheetHeader className='border-border border-b'>
<SheetTitle>Files</SheetTitle>
</SheetHeader>
<div className='min-h-0 flex-1 overflow-y-auto'>
<FileTree
tree={tree}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={(path) => {
openFile(path);
setFileTreeOpen(false);
}}
onToggleDirectory={toggleDirectory}
/>
</div>
</SheetContent>
</Sheet>
<div
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
style={
@@ -566,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} as CSSProperties
}
>
<aside className='border-border bg-background min-h-0 border-r'>
<aside className='border-border bg-background hidden min-h-0 border-r lg:block'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2>
<p className='text-muted-foreground text-xs'>Current workspace</p>
@@ -638,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
conflicted={activeFile?.conflicted}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
@@ -659,11 +838,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</TabsContent>
<TabsContent
value='terminal'
className='m-0 min-h-0 flex-1 overflow-hidden'
forceMount
className={cn(
'm-0 min-h-0 flex-1 overflow-hidden',
activeWorkspaceTab !== 'terminal' && 'hidden',
)}
>
<WorkspaceTerminal
jobId={jobId}
active={activeWorkspaceTab === 'terminal' && workspaceReady}
active={workspaceReady}
visible={activeWorkspaceTab === 'terminal'}
waitingLabel={
workspacePending ? 'Waiting for workspace…' : undefined
}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -24,6 +24,7 @@ const EDITOR_FONT_FAMILY =
type MonacoEditorInstance = {
getModel?: () => unknown;
dispose?: () => void;
};
type VimMode = {
@@ -36,6 +37,7 @@ export const CodeEditor = ({
savedContent,
readOnly,
vimEnabled,
conflicted,
onSave,
onChange,
onVimEnabledChange,
@@ -45,6 +47,7 @@ export const CodeEditor = ({
savedContent: string;
readOnly: boolean;
vimEnabled: boolean;
conflicted?: boolean;
onSave: (content: string) => Promise<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => void;
@@ -56,6 +59,18 @@ export const CodeEditor = ({
const { resolvedTheme } = useTheme();
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
// Dispose the Monaco instance and clear the ref on unmount so a remount
// (e.g. the Phase-1 key={jobId} swap) doesn't leave a stale editor/vim
// binding leaked behind the new mount.
useEffect(() => {
return () => {
vimRef.current?.dispose();
vimRef.current = null;
editorRef.current?.dispose?.();
editorRef.current = null;
};
}, []);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -102,7 +117,11 @@ export const CodeEditor = ({
Editor
</p>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
{conflicted ? (
<p className='text-amber-500 text-xs'>
This file changed on disk your unsaved edits may conflict.
</p>
) : dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
@@ -0,0 +1,28 @@
export const scheduleTerminalFits = (deps: {
fit: () => void;
sendResize: () => void;
fontsReady: Promise<unknown>;
loadNerdFont: () => Promise<unknown>;
raf: (cb: () => void) => void;
isAborted: () => boolean;
}): void => {
const refit = () => {
if (deps.isAborted()) return;
deps.fit();
deps.sendResize();
};
const initialFit = new Promise<void>((resolve) => {
deps.raf(() => {
refit();
resolve();
});
});
void Promise.all([deps.fontsReady, initialFit])
.then(refit)
.catch(() => undefined);
void Promise.all([deps.loadNerdFont(), initialFit])
.then(refit)
.catch(() => undefined);
};
@@ -0,0 +1,54 @@
/**
* Small pure helpers extracted from the agent workspace so the tricky UX
* decisions (out-of-order response guarding, recovery thresholding, and
* near-bottom auto-scroll) can be unit tested in isolation.
*/
/**
* A monotonically increasing request sequencer. Each loader call takes a fresh
* token via {@link RequestSequencer.next} before firing its fetch, then checks
* {@link RequestSequencer.isLatest} once the response resolves. A slower earlier
* request whose token is no longer the latest is dropped so it can't overwrite
* newer data (the diff/tree load race).
*/
export type RequestSequencer = {
next: () => number;
isLatest: (token: number) => boolean;
};
export const createRequestSequencer = (): RequestSequencer => {
let current = 0;
return {
next: () => {
current += 1;
return current;
},
isLatest: (token: number) => token === current,
};
};
/**
* Number of consecutive workspace status/health failures required before the
* "needs recovery" panel is shown. A single transient
* `workspace is not active` blip (e.g. during a worker restart) must not trip
* it.
*/
export const RECOVERY_THRESHOLD = 3;
export const shouldShowRecovery = (
consecutiveFailures: number,
threshold: number = RECOVERY_THRESHOLD,
): boolean => consecutiveFailures >= threshold;
/**
* Distance from the bottom (px) within which the thread is considered "near the
* bottom" and safe to auto-scroll on new content. Outside this band the user
* has scrolled up to read and must not be yanked.
*/
export const NEAR_BOTTOM_THRESHOLD = 80;
export const isNearBottom = (
metrics: { scrollHeight: number; scrollTop: number; clientHeight: number },
threshold: number = NEAR_BOTTOM_THRESHOLD,
): boolean =>
metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight < threshold;
@@ -1,254 +1,23 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
import { XtermSession } from './xterm-session';
export const WorkspaceTerminal = ({
jobId,
active,
visible = true,
waitingLabel,
}: {
jobId: string;
active: boolean;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<Status>('connecting');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
visible?: boolean;
waitingLabel?: string;
}) => (
<XtermSession
active={active}
visible={visible}
waitingLabel={waitingLabel}
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
sessionKey={jobId}
/>
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
signal,
});
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
fit.fit();
termRef.current = term;
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
// repaint once ready so powerline/oh-my-posh/eza icons render.
void document.fonts
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
.then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
})
.catch(() => undefined);
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, jobId, reconnectKey]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? 'Connected · workspace shell'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -0,0 +1,379 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import { scheduleTerminalFits } from './terminal-fit';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
// Persisted user preference for copy-on-select. A stored value takes precedence
// over the `copyOnSelect` prop default so the toggle sticks across sessions.
const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect';
const readStoredCopyOnSelect = (): boolean | undefined => {
try {
const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY);
if (stored === 'true') return true;
if (stored === 'false') return false;
} catch {
// localStorage may be unavailable (private mode, SSR); fall back to prop.
}
return undefined;
};
export type XtermStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'error'
| 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const XtermSession = ({
active,
visible = true,
tokenUrl,
sessionKey,
waitingLabel,
copyOnSelect,
label = 'workspace shell',
}: {
active: boolean;
visible?: boolean;
tokenUrl: string;
sessionKey: string;
waitingLabel?: string;
copyOnSelect?: boolean;
label?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<{ fit: () => void } | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('idle');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// localStorage overrides the prop default; the prop is only the initial value
// when nothing has been persisted yet.
const [copyEnabled, setCopyEnabled] = useState<boolean>(
() => readStoredCopyOnSelect() ?? copyOnSelect ?? false,
);
// The connect effect registers onSelectionChange once; it reads the current
// toggle through this ref so live flips take effect without a reconnect.
const copyEnabledRef = useRef(copyEnabled);
useEffect(() => {
copyEnabledRef.current = copyEnabled;
}, [copyEnabled]);
const toggleCopyOnSelect = () => {
setCopyEnabled((prev) => {
const next = !prev;
try {
window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next));
} catch {
// Persistence is best-effort; ignore storage failures.
}
return next;
});
};
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(tokenUrl, { signal });
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
fitRef.current = fit;
// Copy-on-select: when enabled, mirror the selection to the clipboard.
// Registered once; the ref lets live toggle flips apply without a
// reconnect. Clipboard access is guarded — it may be unavailable or
// denied, and empty selections are skipped.
term.onSelectionChange(() => {
if (!copyEnabledRef.current) return;
const selection = term.getSelection();
if (!selection) return;
try {
void navigator.clipboard.writeText(selection).catch(() => {
// clipboard write denied/unavailable; ignore
});
} catch {
// navigator.clipboard missing entirely; ignore
}
});
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
scheduleTerminalFits({
fit: () => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
},
sendResize,
fontsReady: document.fonts.ready,
loadNerdFont: () =>
document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
}),
raf: (cb) => requestAnimationFrame(cb),
isAborted,
});
ws = new WebSocket(url);
wsRef.current = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]);
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
// tab is inactive. When the terminal becomes visible again, refit inside a
// frame (so layout has settled) and push the new dimensions to the PTY. The
// WebSocket stays open across visibility changes — this never reconnects.
useEffect(() => {
if (!visible || !active) return;
const frame = requestAnimationFrame(() => {
try {
fitRef.current?.fit();
} catch {
// ignore transient layout errors
}
const ws = wsRef.current;
const term = termRef.current;
if (ws?.readyState === WebSocket.OPEN && term) {
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
}
});
return () => cancelAnimationFrame(frame);
}, [visible, active]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? `Connected · ${label}`
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
<div className='flex items-center gap-3'>
<label className='text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none'>
<input
type='checkbox'
className='accent-primary size-3.5'
checked={copyEnabled}
onChange={toggleCopyOnSelect}
/>
Copy on select
</label>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : !active && waitingLabel ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
{waitingLabel}
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -42,7 +42,11 @@ type Provider =
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type AuthType =
| 'api_key'
| 'opencode_auth_json'
| 'anthropic_oauth_json'
| 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveProfileRef = makeFunctionReference<
@@ -69,33 +73,75 @@ const setDefaultProfileRef = makeFunctionReference<
>('aiProviderProfiles:setDefault');
const providerOptions: {
key: string;
value: Provider;
label: string;
authType: AuthType;
}[] = [
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
},
{
key: 'anthropic',
value: 'anthropic',
label: 'Anthropic API key',
authType: 'api_key',
},
{
key: 'google',
value: 'google',
label: 'Google Gemini API key',
authType: 'api_key',
},
{
key: 'openrouter',
value: 'openrouter',
label: 'OpenRouter',
authType: 'api_key',
},
{
key: 'requesty',
value: 'requesty',
label: 'Requesty',
authType: 'api_key',
},
{ key: 'litellm', value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
key: 'cloudflare_ai_gateway',
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
key: 'custom_openai_compatible',
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
key: 'opencode_openai_login',
value: 'opencode_openai_login',
label: 'Codex ChatGPT login',
authType: 'opencode_auth_json',
},
{
key: 'anthropic_oauth_json',
value: 'anthropic',
label: 'Anthropic OAuth (paste credentials)',
authType: 'anthropic_oauth_json',
},
];
const defaultProviderOption: (typeof providerOptions)[number] = {
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
};
const reasoningOptions: ReasoningEffort[] = [
'none',
'minimal',
@@ -112,17 +158,14 @@ export const AiProviderProfilesPanel = () => {
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [provider, setProvider] = useState<Provider>('openai');
const [providerKey, setProviderKey] = useState('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
providerOptions.find((option) => option.key === providerKey) ??
defaultProviderOption,
[providerKey],
);
const provider = selectedProvider.value;
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState(
@@ -146,7 +189,7 @@ export const AiProviderProfilesPanel = () => {
const reset = () => {
setProfileId(undefined);
setProvider('openai');
setProviderKey('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
@@ -160,7 +203,16 @@ export const AiProviderProfilesPanel = () => {
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
setProviderKey(
providerOptions.find(
(option) =>
option.value === profile.provider &&
option.authType === profile.authType,
)?.key ??
providerOptions.find((option) => option.value === profile.provider)
?.key ??
'openai',
);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
@@ -233,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
<Select
value={defaultProfile?._id ?? ''}
onValueChange={async (value) => {
try {
await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>,
});
toast.success('Default AI provider updated.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not update default AI provider.',
);
}
}}
>
<SelectTrigger>
@@ -280,8 +340,16 @@ export const AiProviderProfilesPanel = () => {
size='icon'
aria-label='Remove provider'
onClick={async () => {
try {
await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove AI provider.',
);
}
}}
>
<Trash2 className='size-4' />
@@ -314,16 +382,14 @@ export const AiProviderProfilesPanel = () => {
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={provider}
value={providerKey}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
const nextOption =
providerOptions.find((option) => option.key === value) ??
defaultProviderOption;
setProviderKey(nextOption.key);
resetModelOptions(nextOption.value);
setName(nextOption.label.replace(' API key', ''));
}}
>
<SelectTrigger>
@@ -331,7 +397,7 @@ export const AiProviderProfilesPanel = () => {
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
@@ -342,6 +408,8 @@ export const AiProviderProfilesPanel = () => {
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'Codex auth JSON'
: selectedProvider.authType === 'anthropic_oauth_json'
? 'Anthropic credentials JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
@@ -361,6 +429,23 @@ export const AiProviderProfilesPanel = () => {
be treated like a password.
</p>
</>
) : selectedProvider.authType === 'anthropic_oauth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full .credentials.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Claude credentials file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.claude/.credentials.json
</code>
. Spoon writes it into the isolated job workspace as
Claude&apos;s auth cache. It is stored encrypted and should
be treated like a password.
</p>
</>
) : (
<Input
type='password'
@@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => {
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{connection && connection.status !== 'active' ? (
<div
className={
connection.status === 'revoked'
? 'rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-600'
: 'rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-600'
}
>
<p className='font-medium'>
{connection.status === 'revoked'
? 'GitHub App installation removed'
: 'GitHub access needs re-authorization'}
</p>
<p className='mt-1'>
{connection.status === 'revoked'
? 'Reinstall the app to resume syncing.'
: 'Reconnect the app to resume syncing.'}
</p>
{installUrl ? (
<a
className='mt-2 inline-block underline'
href={installUrl}
target='_blank'
rel='noreferrer'
>
Reconnect GitHub App
</a>
) : null}
</div>
) : null}
{connection ? (
<div className='grid gap-2 text-sm'>
<div>
@@ -1,75 +0,0 @@
const techStack = [
{
category: 'Frontend',
technologies: [
{ name: 'Next.js 16', description: 'React framework with App Router' },
{ name: 'Expo 54', description: 'React Native framework' },
{ name: 'React 19', description: 'Latest React with Server Components' },
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager and runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
];
export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</section>
);
@@ -29,6 +29,10 @@ export const AvatarDropdown = () => {
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
);
const avatarUrl = remoteImageUrl ?? currentImageUrl;
// Empty/whitespace names must fall through to the email, so intentionally
// treat a blank string as "no name" (nullish coalescing would keep it).
const trimmedName = user?.name?.trim();
const displayName = trimmedName?.length ? trimmedName : user?.email?.trim();
if (isLoading) {
return (
@@ -61,16 +65,16 @@ export const AvatarDropdown = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && (
{displayName && (
<>
<DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()}
{displayName}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'>
<Link href='/settings/profile' className='w-full cursor-pointer'>
Edit Profile
</Link>
</DropdownMenuItem>
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
import { ThemeToggle } from '@spoon/ui';
import { AvatarDropdown } from './AvatarDropdown';
import { NotificationBell } from './notification-bell';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
...themeToggleProps?.buttonProps,
}}
/>
<NotificationBell />
<AvatarDropdown />
</div>
);
@@ -0,0 +1,149 @@
'use client';
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { toast } from 'sonner';
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@spoon/ui';
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 60 * 60 * 1000],
['month', 30 * 24 * 60 * 60 * 1000],
['day', 24 * 60 * 60 * 1000],
['hour', 60 * 60 * 1000],
['minute', 60 * 1000],
];
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
numeric: 'auto',
});
const formatRelativeTime = (timestamp: number): string => {
const delta = timestamp - Date.now();
const magnitude = Math.abs(delta);
for (const [unit, ms] of RELATIVE_UNITS) {
if (magnitude >= ms) {
return relativeFormatter.format(Math.round(delta / ms), unit);
}
}
return 'just now';
};
export const NotificationBell = () => {
const { isAuthenticated } = useConvexAuth();
const unreadCount = useQuery(
api.notifications.unreadCount,
isAuthenticated ? {} : 'skip',
);
const notifications = useQuery(
api.notifications.listMine,
isAuthenticated ? { limit: 10 } : 'skip',
);
const markRead = useMutation(api.notifications.markRead);
const markAllRead = useMutation(api.notifications.markAllRead);
if (!isAuthenticated) {
return null;
}
const count = unreadCount ?? 0;
const badgeLabel = count > 9 ? '9+' : String(count);
const items = notifications ?? [];
// Fire-and-forget: never block navigation on the read write, but surface a
// toast if it fails so we don't silently assume success.
const handleMarkRead = async (notificationId: Id<'notifications'>) => {
try {
await markRead({ notificationId });
} catch {
toast.error('Could not mark the notification as read.');
}
};
const handleMarkAllRead = async () => {
try {
await markAllRead({});
} catch {
toast.error('Could not mark notifications as read.');
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='relative'
aria-label='Notifications'
>
<Bell className='h-5 w-5' />
{count > 0 && (
<span className='bg-primary text-primary-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold'>
{badgeLabel}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-80'>
<div className='flex items-center justify-between gap-2 px-2 py-1.5'>
<DropdownMenuLabel className='p-0'>Notifications</DropdownMenuLabel>
{count > 0 && (
<button
type='button'
onClick={() => void handleMarkAllRead()}
className='text-muted-foreground hover:text-foreground cursor-pointer text-xs'
>
Mark all read
</button>
)}
</div>
<DropdownMenuSeparator />
{items.length === 0 ? (
<p className='text-muted-foreground px-2 py-6 text-center text-sm'>
No notifications yet.
</p>
) : (
items.map((notification) => (
<DropdownMenuItem key={notification._id} asChild>
<Link
href={notification.link ?? '#'}
onClick={() => void handleMarkRead(notification._id)}
className='flex w-full cursor-pointer flex-col items-start gap-0.5'
>
<span className='flex w-full items-center gap-2'>
<span className='flex-1 truncate text-sm font-medium'>
{notification.title}
</span>
{notification.readAt === undefined && (
<span
className='bg-primary size-2 shrink-0 rounded-full'
aria-hidden
/>
)}
</span>
<span className='text-muted-foreground line-clamp-2 text-xs'>
{notification.body}
</span>
<span className='text-muted-foreground text-[11px]'>
{formatRelativeTime(notification.createdAt)}
</span>
</Link>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -8,6 +8,7 @@ import {
GitBranch,
LayoutDashboard,
MessagesSquare,
Server,
Settings,
ShieldCheck,
} from 'lucide-react';
@@ -37,6 +38,11 @@ const Header = (headerProps: ComponentProps<'header'>) => {
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/machine',
icon: Server,
label: 'Machine',
},
{
href: '/settings/profile',
icon: Settings,
@@ -0,0 +1,201 @@
'use client';
import type { ReactNode } from 'react';
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@spoon/ui';
export type BoxStatus = {
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
containerName: string;
};
export type LifecycleAction = 'start' | 'stop' | 'restart';
const formatUptime = (startedAt: string | null): string => {
if (!startedAt) return '—';
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) return '—';
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
};
const formatMemory = (bytes: number | null): string => {
if (bytes === null) return 'Unlimited';
const gib = bytes / 1024 ** 3;
return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`;
};
const Detail = ({ label, value }: { label: string; value: string }) => (
<div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
{label}
</p>
<p className='truncate font-mono text-sm'>{value}</p>
</div>
);
// Stopping or restarting the box tears down the SAME per-user container that a
// running agent thread job executes in, so both actions are destructive and get
// a confirmation step. Start is safe and stays a plain button.
const ConfirmLifecycleButton = ({
icon,
label,
busy,
title,
description,
confirmLabel,
onConfirm,
}: {
icon: ReactNode;
label: string;
busy: boolean;
title: string;
description: string;
confirmLabel: string;
onConfirm: () => void;
}) => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type='button' variant='outline' size='sm' disabled={busy}>
{icon}
{label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant='destructive' onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
export const BoxStatusCard = ({
status,
pendingAction,
onAction,
}: {
status: BoxStatus | null | undefined;
pendingAction: LifecycleAction | null;
onAction: (action: LifecycleAction) => void;
}) => {
const loading = status === undefined;
const running = Boolean(status?.running);
const busy = pendingAction !== null;
return (
<Card className='shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4 space-y-0'>
<CardTitle className='flex items-center gap-2 text-base'>
<Server className='size-4' />
My Machine
</CardTitle>
{loading ? (
<Badge variant='outline' className='gap-1'>
<Loader2 className='size-3 animate-spin' />
Checking
</Badge>
) : running ? (
<Badge className='gap-1'>Running</Badge>
) : (
<Badge variant='secondary'>Stopped</Badge>
)}
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3'>
<Detail label='Image' value={status?.image ?? '—'} />
<Detail
label='Uptime'
value={running ? formatUptime(status?.startedAt ?? null) : '—'}
/>
<Detail
label='Memory'
value={formatMemory(status?.memoryLimitBytes ?? null)}
/>
</div>
<div className='flex flex-wrap gap-2'>
{running ? (
<>
<ConfirmLifecycleButton
label='Stop'
busy={busy}
icon={
pendingAction === 'stop' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Square className='size-4' />
)
}
title='Stop this machine?'
description='Stopping the machine shuts down the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
confirmLabel='Stop machine'
onConfirm={() => onAction('stop')}
/>
<ConfirmLifecycleButton
label='Restart'
busy={busy}
icon={
pendingAction === 'restart' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<RotateCw className='size-4' />
)
}
title='Restart this machine?'
description='Restarting the machine recreates the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
confirmLabel='Restart machine'
onConfirm={() => onAction('restart')}
/>
</>
) : (
<Button
type='button'
size='sm'
disabled={busy || loading}
onClick={() => onAction('start')}
>
{pendingAction === 'start' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Play className='size-4' />
)}
Start
</Button>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,359 @@
'use client';
import type {
FileResponse,
FileTreeNode,
} from '@/components/agent-workspace/types';
import { useCallback, useEffect, useState } from 'react';
import { CodeEditor } from '@/components/agent-workspace/code-editor';
import { FileTabs } from '@/components/agent-workspace/file-tabs';
import { FileTree } from '@/components/agent-workspace/file-tree';
import { XtermSession } from '@/components/agent-workspace/xterm-session';
import { useQuery } from 'convex/react';
import { FolderTree, SquareTerminal } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { BoxStatus, LifecycleAction } from './box-status-card';
import { BoxStatusCard } from './box-status-card';
const STATUS_POLL_MS = 10_000;
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
};
export const MachineShell = () => {
const environment = useQuery(api.userEnvironment.getMine, {});
const username = environment?.username ?? '';
const firstName = environment?.firstName;
const [status, setStatus] = useState<BoxStatus | null | undefined>(undefined);
const [pendingAction, setPendingAction] = useState<LifecycleAction | null>(
null,
);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [activeTab, setActiveTab] = useState<'files' | 'terminal'>('files');
const refreshStatus = useCallback(async () => {
try {
const response = await fetch('/api/box/status');
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { status: BoxStatus };
setStatus(data.status);
} catch (error) {
console.error(error);
setStatus((current) => current ?? null);
}
}, []);
const loadTree = useCallback(async () => {
try {
const response = await fetch('/api/box/tree');
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setTree(data.tree);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
void refreshStatus();
void loadTree();
const interval = window.setInterval(() => {
void refreshStatus();
}, STATUS_POLL_MS);
return () => window.clearInterval(interval);
}, [refreshStatus, loadTree]);
const runLifecycle = useCallback(
(action: LifecycleAction) => {
if (pendingAction) return;
setPendingAction(action);
void (async () => {
try {
const response = await fetch('/api/box/lifecycle', {
method: 'POST',
body: JSON.stringify({ action }),
});
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { status: BoxStatus };
setStatus(data.status);
toast.success(
action === 'stop'
? 'Machine stopped.'
: action === 'restart'
? 'Machine restarted.'
: 'Machine started.',
);
void loadTree();
} catch (error) {
console.error(error);
toast.error(`Could not ${action} your machine.`);
void refreshStatus();
} finally {
setPendingAction(null);
}
})();
},
[pendingAction, loadTree, refreshStatus],
);
const loadFile = useCallback(async (path: string) => {
setFiles((current) => ({
...current,
[path]: current[path] ?? {
path,
content: '',
savedContent: '',
loading: true,
saving: false,
},
}));
const response = await fetch(
`/api/box/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setFiles((current) => ({
...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
},
}));
}, []);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
current.includes(path) ? current : [...current, path],
);
setActiveFilePath(path);
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
setOpenFilePaths((current) =>
current.filter((filePath) => filePath !== path),
);
toast.error('Could not load file.');
});
}
},
[files, loadFile],
);
const writeFileContent = async (path: string, content: string) => {
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? { path, savedContent: '', loading: false }),
content,
saving: true,
},
}));
const response = await fetch('/api/box/file', {
method: 'PUT',
body: JSON.stringify({ path, content }),
});
if (!response.ok) {
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? {
path,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
toast.error('Could not save file.');
throw new Error(await response.text());
}
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? { path, loading: false }),
content,
savedContent: content,
saving: false,
},
}));
toast.success('File saved.');
};
const saveFile = async (content: string) => {
if (!activeFilePath) return;
await writeFileContent(activeFilePath, content);
};
const closeFile = (path: string) => {
const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
if (activeFilePath === path) {
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
}
};
const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const running = Boolean(status?.running);
const starting = pendingAction === 'start' || pendingAction === 'restart';
// The terminal only shows this while the box is not running. Distinguish a box
// that is starting/restarting from one that is simply stopped so the label is
// accurate instead of always claiming "Starting…".
const terminalWaitingLabel = starting
? 'Starting your machine…'
: 'Machine is stopped — start it to open a terminal.';
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>
{firstName ? `${firstName}'s Machine` : 'My Machine'}
</h1>
<p className='text-muted-foreground mt-2'>
Your always-available dev box a persistent home, a terminal, and a
file browser rooted at <code className='font-mono'>~</code>.
</p>
</div>
<BoxStatusCard
status={status}
pendingAction={pendingAction}
onAction={runLifecycle}
/>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[600px] flex-col overflow-hidden rounded-md border'
>
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
<TabsTrigger
value='files'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<FolderTree className='size-4' />
Files
</TabsTrigger>
<TabsTrigger
value='terminal'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<SquareTerminal className='size-4' />
Terminal
</TabsTrigger>
</TabsList>
<TabsContent
value='files'
className='m-0 grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]'
>
<aside className='border-border bg-background min-h-0 border-r'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Home</h2>
<p className='text-muted-foreground text-xs'>
Files in your persistent home
</p>
</div>
<FileTree
tree={tree}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={openFile}
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
<FileTabs
tabs={openFilePaths.map((path) => ({
path,
dirty: files[path]
? files[path].content !== files[path].savedContent
: false,
}))}
activePath={activeFilePath}
onActivate={setActiveFilePath}
onClose={closeFile}
/>
<CodeEditor
path={activeFilePath}
content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={false}
vimEnabled={vimEnabled}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
saving: false,
}),
content,
},
}));
}}
/>
</section>
</TabsContent>
<TabsContent
value='terminal'
forceMount
className='m-0 min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden'
>
<XtermSession
active={running}
visible={activeTab === 'terminal'}
tokenUrl='/api/box/terminal-token'
sessionKey={username}
waitingLabel={terminalWaitingLabel}
label='your machine'
/>
</TabsContent>
</Tabs>
</main>
);
};
@@ -160,6 +160,7 @@ export const DotfilesManager = () => {
const saveSelected = async (next: string) => {
if (!selected) return;
try {
await putFile({
path: selected.path,
content: next,
@@ -167,6 +168,9 @@ export const DotfilesManager = () => {
});
setSavedContent(next);
toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
};
const importAll = async (incoming: UploadFile[]) => {
@@ -231,11 +235,15 @@ export const DotfilesManager = () => {
const deleteSelected = async () => {
if (!selected) return;
try {
await removeFile({ fileId: selected._id as never });
setSelected(undefined);
setContent('');
setSavedContent('');
toast.success('Deleted.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Delete failed.');
}
};
return (
@@ -345,8 +353,12 @@ export const DotfilesManager = () => {
<RepoPanel
settings={settings}
onSave={async (values) => {
try {
await updateEnv(values);
toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
}}
/>
@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Label, Switch } from '@spoon/ui';
type PreferenceField =
| 'emailEnabled'
| 'emailMaintenance'
| 'emailAgentTurnFinished'
| 'emailAgentNeedsInput'
| 'emailSyncFailed'
| 'emailConnectionReauth';
const perKindToggles: {
field: Exclude<PreferenceField, 'emailEnabled'>;
label: string;
description: string;
}[] = [
{
field: 'emailMaintenance',
label: 'Maintenance threads',
description: 'A maintenance review thread opens for one of your Spoons.',
},
{
field: 'emailAgentTurnFinished',
label: 'Agent finished a turn',
description: 'An agent finishes a turn and changes are ready to review.',
},
{
field: 'emailAgentNeedsInput',
label: 'Agent needs your input',
description: 'An agent pauses and needs your input to continue.',
},
{
field: 'emailSyncFailed',
label: 'A sync fails',
description: 'A scheduled sync with upstream fails for one of your Spoons.',
},
{
field: 'emailConnectionReauth',
label: 'A connection needs re-authentication',
description: 'A git connection is revoked and needs to be reconnected.',
},
];
export const NotificationPreferencesPanel = () => {
// Render directly from the reactive query: after a save resolves, the query
// re-emits the stored value, so there is no local copy to fall out of sync.
const prefs = useQuery(api.notifications.getPreferences);
const updatePreferences = useMutation(api.notifications.updatePreferences);
const [saving, setSaving] = useState(false);
const onToggle = async (field: PreferenceField, next: boolean) => {
setSaving(true);
try {
await updatePreferences({ [field]: next });
toast.success('Preferences saved.');
} catch {
toast.error('Could not save preferences.');
} finally {
setSaving(false);
}
};
if (prefs === undefined) {
return (
<Card className='p-4 shadow-none'>
<p className='text-muted-foreground text-sm'>Loading preferences</p>
</Card>
);
}
const perKindDisabled = saving || !prefs.emailEnabled;
return (
<Card className='divide-border divide-y p-0 shadow-none'>
<div className='flex items-center justify-between gap-4 p-4'>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>Email notifications</Label>
<p className='text-muted-foreground text-sm'>
Send email for your notifications. Turn this off to silence every
kind below.
</p>
</div>
<Switch
aria-label='Email notifications'
checked={prefs.emailEnabled}
disabled={saving}
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
/>
</div>
{perKindToggles.map(({ field, label, description }) => (
<div
key={field}
className='flex items-center justify-between gap-4 p-4'
>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>{label}</Label>
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
<Switch
aria-label={label}
checked={prefs.emailEnabled && prefs[field]}
disabled={perKindDisabled}
onCheckedChange={(next) => void onToggle(field, next)}
/>
</div>
))}
</Card>
);
};
@@ -31,18 +31,17 @@ type WorkerHealth = {
convexUrl: string;
runtime: string;
containerRuntime: string;
containerAccess: string;
jobImage: string;
workdir: string;
network?: string;
httpPort: number;
activeWorkspaceCount: number;
workspaceContainers: string[];
boxContainers: string[];
};
type CleanupResult = {
removedContainers: string[];
removedWorkdirs: string[];
boxContainers: string[];
};
export const WorkerHealthPanel = () => {
@@ -103,9 +102,7 @@ export const WorkerHealthPanel = () => {
});
if (!response.ok) throw new Error(await response.text());
const result = (await response.json()) as CleanupResult;
toast.success(
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
);
toast.success(`Cleaned ${result.removedWorkdirs.length} workdirs.`);
await refreshHealth();
} catch (error) {
console.error(error);
@@ -165,9 +162,7 @@ export const WorkerHealthPanel = () => {
{health.ok ? 'healthy' : 'unhealthy'}
</Badge>
<Badge variant='outline'>{health.workerId}</Badge>
<Badge variant='outline'>
{health.containerRuntime} / {health.containerAccess}
</Badge>
<Badge variant='outline'>{health.containerRuntime}</Badge>
</div>
<dl className='grid gap-3 text-sm md:grid-cols-2'>
<div>
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
</div>
</dl>
<div>
<p className='text-muted-foreground text-sm'>
Workspace containers
</p>
<p className='text-muted-foreground text-sm'>Box containers</p>
<p className='mt-1 font-mono text-sm'>
{health.workspaceContainers.length
? health.workspaceContainers.join(', ')
{health.boxContainers.length
? health.boxContainers.join(', ')
: 'none'}
</p>
</div>
@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {
<div>
<p className='text-sm font-medium'>Orphaned worker resources</p>
<p className='text-muted-foreground text-sm'>
Remove inactive Spoon job containers and inactive directories
under the configured worker workdir.
Remove inactive directories under the configured worker workdir.
</p>
</div>
<Button
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
@@ -24,9 +24,17 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type RuntimeName = 'codex' | 'opencode' | 'claude';
const runtimeLabels: Record<RuntimeName, string> = {
codex: 'Codex',
opencode: 'OpenCode',
claude: 'Claude',
};
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
runtime?: RuntimeName | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
@@ -91,6 +99,7 @@ export const SpoonAgentSettingsForm = ({
const [aiProviderProfileId, setAiProviderProfileId] = useState(
settings?.aiProviderProfileId ?? '__default',
);
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
const selectedProfile = profiles.find(
(profile) =>
profile._id ===
@@ -118,6 +127,59 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort,
);
// The parent gates rendering on a different query than `settings`, so this
// form can mount before the agent settings record resolves. Re-seed every
// field once the real record arrives (and again if the parent swaps to a
// different spoon) so a Save cannot persist the initial defaults over the
// user's saved configuration.
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
useEffect(() => {
if (!settings || hydratedSpoonId.current === spoon._id) return;
const timeout = window.setTimeout(() => {
setEnabled(settings.enabled);
setDefaultBaseBranch(
settings.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
setBranchPrefix(settings.branchPrefix);
setInstallCommand(settings.installCommand ?? '');
setCheckCommand(settings.checkCommand ?? '');
setTestCommand(settings.testCommand ?? '');
setEnvFilePath(settings.envFilePath ?? '.env.local');
setCustomEnvFilePath(settings.customEnvFilePath ?? '');
setMaterializeEnvFileByDefault(
settings.materializeEnvFileByDefault ?? false,
);
setAutoDetectCommands(settings.autoDetectCommands ?? true);
setAllowUserFileEditing(settings.allowUserFileEditing ?? true);
setAiProviderProfileId(settings.aiProviderProfileId ?? '__default');
setAgentModel(settings.aiProviderProfileId ? settings.agentModel : '');
setReasoningEffort(
!settings.aiProviderProfileId
? 'medium'
: settings.reasoningEffort === 'none'
? 'minimal'
: settings.reasoningEffort,
);
hydratedSpoonId.current = spoon._id;
}, 0);
return () => window.clearTimeout(timeout);
}, [settings, spoon]);
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
[]) as RuntimeName[];
const settingsRuntime = settings?.runtime as RuntimeName | undefined;
const effectiveRuntime =
runtime && supportedRuntimes.includes(runtime)
? runtime
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
? settingsRuntime
: supportedRuntimes[0];
const runtimeValid = Boolean(
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
);
const selectableModels = selectedModelProfile?.configured
? selectedModelProfile.models
: [];
@@ -135,7 +197,7 @@ export const SpoonAgentSettingsForm = ({
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
runtime: effectiveRuntime,
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
@@ -186,7 +248,25 @@ export const SpoonAgentSettingsForm = ({
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Runtime</Label>
<Input value='OpenCode workspace' disabled />
<Select
value={effectiveRuntime ?? ''}
onValueChange={(value) => setRuntime(value as RuntimeName)}
disabled={!supportedRuntimes.length}
>
<SelectTrigger>
<SelectValue placeholder='Select a provider profile first' />
</SelectTrigger>
<SelectContent>
{supportedRuntimes.map((name) => (
<SelectItem key={name} value={name}>
{runtimeLabels[name]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Runtimes available for the selected provider profile.
</p>
</div>
<div className='grid gap-2'>
<Label>AI provider profile</Label>
@@ -400,6 +480,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save}
disabled={
!selectedProfile?.configured ||
!runtimeValid ||
!selectableModels.some((model) => model.id === selectedAgentModel)
}
>
@@ -223,8 +223,16 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
remoteName={remote.remoteName}
url={remote.url}
onRemove={async () => {
try {
await removeRemote({ remoteId: remote._id });
toast.success('Remote removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove remote.',
);
}
}}
/>
))}
@@ -130,24 +130,35 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
return;
}
setBulkSaving(true);
try {
let imported = 0;
const failed: string[] = [];
for (const secret of parsedBulk.secrets) {
try {
await createSecret({
spoonId,
name: secret.name,
value: secret.value,
description: 'Imported from .env',
});
imported += 1;
} catch (error) {
console.error(error);
failed.push(secret.name);
}
}
setBulkSaving(false);
if (imported > 0) {
toast.success(`${imported} secret${imported === 1 ? '' : 's'} imported.`);
}
if (failed.length) {
toast.error(
`Could not import ${failed.length} secret${failed.length === 1 ? '' : 's'}: ${failed.join(', ')}`,
);
return;
}
setBulkText('');
setBulkDialogOpen(false);
toast.success(`${parsedBulk.secrets.length} secrets imported.`);
} catch (error) {
console.error(error);
toast.error('Could not import secrets.');
} finally {
setBulkSaving(false);
}
};
const readEnvFile = async (file?: File) => {
@@ -310,8 +321,16 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
size='icon'
aria-label={`Remove ${secret.name}`}
onClick={async () => {
try {
await removeSecret({ secretId: secret._id });
toast.success('Secret removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove secret.',
);
}
}}
>
<Trash2 className='size-4' />
@@ -13,6 +13,7 @@ const labels: Record<string, string> = {
checking: 'Checking',
conflict: 'Conflict',
error: 'Error',
rate_limited: 'Rate limited',
unknown: 'Unknown',
active: 'Active',
draft: 'Draft',
@@ -35,10 +36,16 @@ const variants: Record<
active: 'default',
};
// Warning-tone statuses that need an amber accent the base variants don't
// provide. Matches the app's existing amber warning styling.
const classNames: Record<string, string> = {
rate_limited: 'border-amber-500/40 bg-amber-500/10 text-amber-700',
};
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
const value = status ?? 'unknown';
return (
<Badge variant={variants[value] ?? 'outline'}>
<Badge variant={variants[value] ?? 'outline'} className={classNames[value]}>
{labels[value] ?? value.replaceAll('_', ' ')}
</Badge>
);
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquarePlus } from 'lucide-react';
@@ -25,9 +25,17 @@ import {
Textarea,
} from '@spoon/ui';
type RuntimeName = 'codex' | 'opencode' | 'claude';
const runtimeLabels: Record<RuntimeName, string> = {
codex: 'Codex',
opencode: 'OpenCode',
claude: 'Claude',
};
type AgentSettings = {
defaultBaseBranch?: string;
runtime?: 'opencode' | 'openai_direct';
runtime?: RuntimeName | 'openai_direct';
agentModel: string;
reasoningEffort: string;
envFilePath?: string;
@@ -72,7 +80,35 @@ export const ThreadWorkspaceForm = ({
const [aiProviderProfileId, setAiProviderProfileId] = useState(
agentSettings?.aiProviderProfileId ?? '__settings',
);
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
const [submitting, setSubmitting] = useState(false);
// The parent gates rendering on a different query than `agentSettings`, so
// this form can mount before the agent settings record resolves. Re-seed the
// settings-derived fields once the real record arrives (and again if the
// parent swaps to a different spoon) so the form reflects saved config rather
// than the initial defaults.
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
useEffect(() => {
if (!agentSettings || hydratedSpoonId.current === spoon._id) return;
const timeout = window.setTimeout(() => {
setBaseBranch(
agentSettings.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
setMaterializeEnvFile(agentSettings.materializeEnvFileByDefault ?? false);
setEnvFilePath(
agentSettings.envFilePath === 'custom'
? (agentSettings.customEnvFilePath ?? '.env.local')
: (agentSettings.envFilePath ?? '.env.local'),
);
setAiProviderProfileId(agentSettings.aiProviderProfileId ?? '__settings');
hydratedSpoonId.current = spoon._id;
}, 0);
return () => window.clearTimeout(timeout);
}, [agentSettings, spoon]);
const effectiveProviderProfileId =
aiProviderProfileId === '__settings'
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
@@ -87,6 +123,18 @@ export const ThreadWorkspaceForm = ({
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: profile._id === aiProviderProfileId,
);
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
[]) as RuntimeName[];
const settingsRuntime = agentSettings?.runtime as RuntimeName | undefined;
const effectiveRuntime =
runtime && supportedRuntimes.includes(runtime)
? runtime
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
? settingsRuntime
: supportedRuntimes[0];
const runtimeValid = Boolean(
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -97,6 +145,7 @@ export const ThreadWorkspaceForm = ({
prompt,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
runtime: effectiveRuntime,
materializeEnvFile,
envFilePath: materializeEnvFile ? envFilePath : undefined,
aiProviderProfileId:
@@ -140,7 +189,22 @@ export const ThreadWorkspaceForm = ({
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Workspace runtime</Label>
<Input value='OpenCode workspace' disabled />
<Select
value={effectiveRuntime ?? ''}
onValueChange={(value) => setRuntime(value as RuntimeName)}
disabled={!supportedRuntimes.length}
>
<SelectTrigger>
<SelectValue placeholder='Select a provider first' />
</SelectTrigger>
<SelectContent>
{supportedRuntimes.map((name) => (
<SelectItem key={name} value={name}>
{runtimeLabels[name]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>AI provider</Label>
@@ -219,7 +283,10 @@ export const ThreadWorkspaceForm = ({
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
</span>
</div>
<Button type='submit' disabled={submitting || !hasProvider}>
<Button
type='submit'
disabled={submitting || !hasProvider || !runtimeValid}
>
{submitting ? 'Creating...' : 'Create thread'}
</Button>
</form>
@@ -0,0 +1,23 @@
import { cn, Skeleton } from '@spoon/ui';
export const ListSkeleton = ({
rows = 3,
rowClassName = 'h-20 w-full',
className,
}: {
rows?: number;
rowClassName?: string;
className?: string;
}) => (
<div
role='status'
aria-label='Loading'
data-testid='list-skeleton'
className={cn('space-y-3', className)}
>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className={rowClassName} />
))}
<span className='sr-only'>Loading</span>
</div>
);
+85
View File
@@ -9,6 +9,8 @@ import { fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { buildBoxToken } from './box-terminal-token';
const terminalSecret = () =>
env.SPOON_AGENT_TERMINAL_SECRET ??
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
@@ -29,6 +31,18 @@ export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
return { url, expiresAt };
};
// User-scoped variant of mintTerminalToken for a user's persistent box. The
// username is supplied by resolveBoxUsername (derived from the authed Convex
// user), never from the request. Returns null when the terminal feature is not
// configured.
export const mintBoxTerminalToken = (username: string) =>
buildBoxToken(
username,
terminalSecret(),
env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
Date.now(),
);
type RouteContext = {
params: Promise<{ jobId: string }> | { jobId: string };
};
@@ -144,3 +158,74 @@ export const withOwnedJob = async (
return NextResponse.json({ error: message }, { status: 500 });
}
};
// Resolves the authed caller's own box username from Convex. This is the
// security boundary: the username comes from the authed user's environment,
// never from the request, so a caller can only ever reach their own box.
export const resolveBoxUsername = async (): Promise<
{ ok: true; username: string } | { ok: false; response: NextResponse }
> => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
const mine = await fetchQuery(api.userEnvironment.getMine, {}, { token });
return { ok: true as const, username: mine.username };
};
// Mirrors proxyWorker but targets `/box/${action}` and force-sets the derived
// username as `user` (overwriting any client-supplied value) so the worker only
// ever operates on the caller's own box.
export const proxyBox = async (
username: string,
action: 'status' | 'lifecycle' | 'tree' | 'file',
init?: RequestInit,
search?: URLSearchParams,
) => {
const token = workerToken();
if (!token) {
return NextResponse.json(
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
{ status: 500 },
);
}
const url = new URL(`/box/${action}`, env.SPOON_AGENT_WORKER_URL);
if (search) {
for (const [key, value] of search) url.searchParams.set(key, value);
}
url.searchParams.set('user', username);
const response = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
'content-type':
response.headers.get('content-type') ?? 'application/json',
},
});
};
// Convenience wrapper mirroring withOwnedJob: resolve the caller's own box
// username, then run the handler; 401 when unauthenticated, 500 on throw.
export const withBox = async (
handler: (username: string) => Promise<Response>,
) => {
try {
const resolved = await resolveBoxUsername();
if (!resolved.ok) return resolved.response;
return await handler(resolved.username);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
+21
View File
@@ -0,0 +1,21 @@
import { createHmac } from 'node:crypto';
// Pure token builder for the box terminal connection, extracted so it can be
// unit-tested without pulling in `server-only`/Convex. Mirrors the worker's
// `verifyBoxTerminalToken` format so the worker accepts the minted token:
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
// Returns null when the terminal feature is not configured (secret/WS base).
export const buildBoxToken = (
username: string,
secret: string | undefined,
wsBase: string | undefined,
now: number,
): { url: string; expiresAt: number } | null => {
if (!secret || !wsBase) return null;
const expiresAt = now + 2 * 60 * 1000;
const payload = `${expiresAt}.box.${username}`;
const signature = createHmac('sha256', secret).update(payload).digest('hex');
const token = `${payload}.${signature}`;
const url = `${wsBase.replace(/\/$/, '')}/box/terminal?token=${encodeURIComponent(token)}`;
return { url, expiresAt };
};
-3
View File
@@ -8,12 +8,9 @@ const isAuthRoute = createRouteMatcher(['/sign-in', '/forgot-password']);
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/spoons(.*)',
'/updates(.*)',
'/agents(.*)',
'/threads(.*)',
'/github(.*)',
'/settings(.*)',
'/profile(.*)',
]);
export default convexAuthNextjsMiddleware(

Some files were not shown because too many files have changed in this diff Show More