From d207b8b0b8b34e971eaa20d77367286cfe4c63ee Mon Sep 17 00:00:00 2001
From: Gabriel Brown
Date: Tue, 23 Jun 2026 02:06:58 -0400
Subject: [PATCH] Add features & update project
---
.gitea/workflows/build-next.yml | 2 +-
AGENTS.md | 8 +-
README.md | 24 +-
apps/next/src/app/(app)/dashboard/page.tsx | 12 +-
apps/next/src/app/(app)/spoons/page.tsx | 17 +-
.../src/app/(app)/threads/[threadId]/page.tsx | 150 ++++++--
apps/next/src/app/(app)/threads/page.tsx | 335 ++++++++++++++++--
.../api/threads/[threadId]/message/route.ts | 81 +++++
.../agent-workspace/agent-thread.tsx | 4 +-
.../agent-workspace/agent-workspace-shell.tsx | 83 ++++-
.../ai-provider-profiles-panel.tsx | 101 ++++--
apps/next/src/components/landing/features.tsx | 4 +-
.../src/components/layout/footer/index.tsx | 8 +
.../settings/worker-health-panel.tsx | 27 +-
.../spoons/spoon-agent-settings-form.tsx | 75 ++--
.../next/src/components/spoons/spoon-card.tsx | 19 +-
apps/next/src/lib/models-dev.ts | 56 ---
apps/next/src/lib/provider-model-options.ts | 72 ++++
.../tests/unit/provider-model-options.test.ts | 39 ++
docker/agent-job.Dockerfile | 2 +
packages/backend/convex/agentJobs.ts | 41 ++-
packages/backend/convex/aiProviderModels.ts | 99 ++++++
packages/backend/convex/spoons.ts | 58 +++
packages/backend/convex/threads.ts | 26 +-
packages/backend/tests/unit/harness.test.ts | 142 ++++++++
scripts/smoke-agent-container | 3 +
26 files changed, 1257 insertions(+), 231 deletions(-)
create mode 100644 apps/next/src/app/api/threads/[threadId]/message/route.ts
delete mode 100644 apps/next/src/lib/models-dev.ts
create mode 100644 apps/next/src/lib/provider-model-options.ts
create mode 100644 apps/next/tests/unit/provider-model-options.test.ts
create mode 100644 packages/backend/convex/aiProviderModels.ts
diff --git a/.gitea/workflows/build-next.yml b/.gitea/workflows/build-next.yml
index ca34d4a..43acbf9 100644
--- a/.gitea/workflows/build-next.yml
+++ b/.gitea/workflows/build-next.yml
@@ -53,7 +53,7 @@ jobs:
printf '%s\n' "$DOTENV_PROD" > "$env_file"
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
- name: Build agent images
- run: ./scripts/build-agent-images
+ run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
- name: Tag and push images
run: |
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
diff --git a/AGENTS.md b/AGENTS.md
index 9e95883..a47530d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,8 @@
access to the host Docker socket. API-key provider jobs run through OpenCode;
Codex ChatGPT login profiles run through the Codex CLI with an injected
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
+ The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq,
+ Python, OpenCode, and Codex available.
## Protected and generated files
@@ -55,12 +57,14 @@
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
and expects `spoon-agent-job:latest` to exist locally.
-- `bun smoke:agent-container` checks that the local job image has Node, Bun,
- git, ripgrep, jq, Python, OpenCode, and Codex available.
+- `bun smoke:agent-container` checks that the local job image has Node, npm,
+ Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
containers/workdirs are cleaned through the worker HTTP API, not from the
browser directly.
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
+- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local
+ Podman auto-detection out of CI image tagging/pushing.
- CI must provide Convex deployment env for codegen, either
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
`CONVEX_DEPLOYMENT`.
diff --git a/README.md b/README.md
index d739d1c..943ad52 100644
--- a/README.md
+++ b/README.md
@@ -198,8 +198,8 @@ production should use the repo-provided JS/TS workbench image:
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
```
-The job image includes Node 22, Bun, package managers through Corepack, git,
-ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked
+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.
Production worker runtime requirements:
@@ -216,15 +216,35 @@ Production worker runtime requirements:
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
file, diff, message, command, 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.
Useful production checks:
```sh
+docker login git.gbrown.org
+docker pull git.gbrown.org/gib/spoon-agent-worker:latest
+docker pull git.gbrown.org/gib/spoon-agent-job:latest
docker logs --tail=200 spoon-agent-worker
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
http://spoon-agent-worker:3921/health
```
+Deployment readiness checklist:
+
+1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
+ GitHub App env, 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.
+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.
+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.
+
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.
diff --git a/apps/next/src/app/(app)/dashboard/page.tsx b/apps/next/src/app/(app)/dashboard/page.tsx
index 5faddcc..1edaf22 100644
--- a/apps/next/src/app/(app)/dashboard/page.tsx
+++ b/apps/next/src/app/(app)/dashboard/page.tsx
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
- const spoons = useQuery(api.spoons.listMine, {}) ?? [];
+ 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(
(spoon) => spoon.status === 'active',
).length;
- const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
+ const behind = spoons.filter(
+ (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
+ ).length;
const diverged = spoons.filter(
- (spoon) => spoon.syncStatus === 'diverged',
+ (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length;
const openPullRequests = spoons.reduce(
- (total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
+ (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -70,7 +72,7 @@ const DashboardPage = () => {
diff --git a/apps/next/src/app/(app)/spoons/page.tsx b/apps/next/src/app/(app)/spoons/page.tsx
index aa42035..0358e14 100644
--- a/apps/next/src/app/(app)/spoons/page.tsx
+++ b/apps/next/src/app/(app)/spoons/page.tsx
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
const SpoonsPage = () => {
const router = useRouter();
- const spoons = useQuery(api.spoons.listMine, {}) ?? [];
+ 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(
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
- (total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
+ (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
-
{spoon.upstreamAheadBy ?? 0} upstream
+
{spoon.effectiveUpstreamAheadBy} actionable
- {spoon.forkAheadBy ?? 0} fork-only
+ {spoon.rawUpstreamAheadBy} raw upstream ·{' '}
+ {spoon.forkAheadBy} fork-only
+ {spoon.ignoredUpstreamCount ? (
+
+ {spoon.ignoredUpstreamCount} ignored
+
+ ) : null}
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
{spoons.length ? (
- Raw upstream commits waiting across all Spoons: {upstreamWaiting}
+ Actionable upstream commits waiting across all Spoons:{' '}
+ {upstreamWaiting}
) : null}
diff --git a/apps/next/src/app/(app)/threads/[threadId]/page.tsx b/apps/next/src/app/(app)/threads/[threadId]/page.tsx
index 8404d27..dcd1669 100644
--- a/apps/next/src/app/(app)/threads/[threadId]/page.tsx
+++ b/apps/next/src/app/(app)/threads/[threadId]/page.tsx
@@ -1,9 +1,10 @@
'use client';
+import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
-import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
+import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
- const appendMessage = useMutation(api.threads.appendUserMessage);
+ const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel);
+ const [sending, setSending] = useState(false);
+ const [queueing, setQueueing] = useState(false);
if (details === undefined) {
return Loading thread... ;
}
const { thread, spoon, latestJob } = details;
+ const terminalThread = [
+ 'resolved',
+ 'ignored',
+ 'failed',
+ 'cancelled',
+ ].includes(thread.status);
+ const activeJob =
+ latestJob &&
+ [
+ 'claimed',
+ 'preparing',
+ 'running',
+ 'checks_running',
+ 'changes_ready',
+ ].includes(latestJob.status) &&
+ ['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
+ const canQueueRun =
+ spoon &&
+ (!latestJob ||
+ ['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
+ latestJob.status,
+ ) ||
+ ['stopped', 'expired', 'failed'].includes(
+ latestJob.workspaceStatus ?? '',
+ ));
+ const jobType =
+ thread.source === 'merge_conflict'
+ ? ('conflict_resolution' as const)
+ : thread.source === 'upstream_update'
+ ? ('maintenance_review' as const)
+ : ('user_change' as const);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = form.get('message');
const content = typeof value === 'string' ? value : '';
+ setSending(true);
try {
- await appendMessage({ threadId, content });
+ const response = await fetch(`/api/threads/${threadId}/message`, {
+ method: 'POST',
+ body: JSON.stringify({ content }),
+ });
+ if (!response.ok) {
+ const payload = (await response.json().catch(() => null)) as {
+ error?: string;
+ recoverable?: boolean;
+ } | null;
+ throw new Error(payload?.error ?? (await response.text()));
+ }
event.currentTarget.reset();
- toast.success('Message added.');
+ toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) {
console.error(error);
- toast.error('Could not add message.');
+ toast.error('Could not send message.');
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const startRun = async () => {
+ setQueueing(true);
+ try {
+ const jobId = await createJob({ threadId, jobType });
+ toast.success('Workspace run queued.');
+ window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
+ } catch (error) {
+ console.error(error);
+ toast.error('Could not queue workspace run.');
+ } finally {
+ setQueueing(false);
}
};
@@ -99,28 +160,40 @@ const ThreadDetailPage = () => {
) : null}
-
- markResolved({ threadId }).then(() =>
- toast.success('Thread resolved.'),
- )
- }
- >
-
- Resolve
-
-
- cancel({ threadId }).then(() =>
- toast.success('Thread cancelled.'),
- )
- }
- >
-
- Cancel
-
+ {canQueueRun ? (
+ void startRun()}>
+
+ {latestJob ? 'Rerun' : 'Start workspace run'}
+
+ ) : null}
+ {!terminalThread ? (
+ <>
+ {
+ if (!window.confirm('Mark this thread as resolved?')) return;
+ void markResolved({ threadId }).then(() =>
+ toast.success('Thread resolved.'),
+ );
+ }}
+ >
+
+ Resolve
+
+ {
+ if (!window.confirm('Cancel this thread?')) return;
+ void cancel({ threadId }).then(() =>
+ toast.success('Thread cancelled.'),
+ );
+ }}
+ >
+
+ Cancel
+
+ >
+ ) : null}
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
name='message'
required
minLength={2}
- placeholder='Add context or instructions for this thread.'
+ placeholder={
+ activeJob
+ ? 'Send instructions to the active agent workspace.'
+ : 'Add context or instructions for the next run.'
+ }
+ disabled={sending || terminalThread}
/>
- Add message
+
+
+ {sending
+ ? 'Sending...'
+ : activeJob
+ ? 'Send to agent'
+ : 'Add note'}
+
+ {!activeJob ? (
+
+ No active workspace is attached, so messages are saved as
+ thread notes until a run is started.
+
+ ) : null}
+
diff --git a/apps/next/src/app/(app)/threads/page.tsx b/apps/next/src/app/(app)/threads/page.tsx
index 445c09a..8fb6e4b 100644
--- a/apps/next/src/app/(app)/threads/page.tsx
+++ b/apps/next/src/app/(app)/threads/page.tsx
@@ -1,28 +1,52 @@
'use client';
+import { useState } from 'react';
import Link from 'next/link';
-import { useSearchParams } from 'next/navigation';
-import { useQuery } from 'convex/react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
+import { toast } from 'sonner';
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
+ Switch,
+ Textarea,
} from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => {
+ const router = useRouter();
const params = useSearchParams();
const source = params.get('source') ?? 'all';
+ const status = params.get('status') ?? 'all';
+ const [spoonFilter, setSpoonFilter] = useState('all');
+ const [priorityFilter, setPriorityFilter] = useState('all');
+ const [outcomeFilter, setOutcomeFilter] = useState('all');
+ const [spoonId, setSpoonId] = useState('');
+ const [title, setTitle] = useState('');
+ const [prompt, setPrompt] = useState('');
+ const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
+ const [envFilePath, setEnvFilePath] = useState('.env.local');
+ const [creating, setCreating] = useState(false);
+ const createThread = useMutation(api.threads.createUserThread);
+ 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, {
source: source as
@@ -32,8 +56,62 @@ const ThreadsPage = () => {
| 'merge_conflict'
| 'manual_review'
| 'system',
+ status: status as
+ | 'all'
+ | 'open'
+ | 'queued'
+ | 'running'
+ | 'waiting_for_user'
+ | 'changes_ready'
+ | 'draft_pr_opened'
+ | 'resolved'
+ | 'ignored'
+ | 'failed'
+ | 'cancelled',
limit: 100,
}) ?? [];
+ const visibleThreads = threads.filter((thread) => {
+ if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
+ if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
+ return false;
+ }
+ if (
+ outcomeFilter !== 'all' &&
+ (thread.maintenanceOutcome ?? 'none') !== outcomeFilter
+ ) {
+ return false;
+ }
+ return true;
+ });
+
+ const updateFilter = (key: string, value: string) => {
+ const next = new URLSearchParams(params.toString());
+ if (value === 'all') next.delete(key);
+ else next.set(key, value);
+ router.push(next.size ? `/threads?${next.toString()}` : '/threads');
+ };
+
+ const submitThread = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!spoonId || !prompt.trim()) return;
+ setCreating(true);
+ try {
+ const threadId = await createThread({
+ spoonId: spoonId as Id<'spoons'>,
+ title: title.trim() || undefined,
+ prompt,
+ materializeEnvFile,
+ envFilePath,
+ });
+ toast.success('Thread created.');
+ router.push(`/threads/${threadId}`);
+ } catch (error) {
+ console.error(error);
+ toast.error('Could not create thread.');
+ } finally {
+ setCreating(false);
+ }
+ };
return (
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
-
+
- New thread from Spoon
-
+ New thread
+
-
+
+
+ New thread
+
+
+
+
+
+
+
{
- window.location.href =
- value === 'all' ? '/threads' : `/threads?source=${value}`;
- }}
+ onValueChange={(value) => updateFilter('source', value)}
>
@@ -73,43 +228,145 @@ const ThreadsPage = () => {
System
+ updateFilter('status', value)}
+ >
+
+
+
+
+ All statuses
+ Open
+ Queued
+ Running
+ Waiting
+ Changes ready
+ Draft PR opened
+ Resolved
+ Ignored
+ Failed
+ Cancelled
+
+
+
+
+
+
+
+ All Spoons
+ {spoons.map((spoon) => (
+
+ {spoon.name}
+
+ ))}
+
+
+
+
+
+
+
+ All priorities
+ Low
+ Normal
+ High
+
+
+
+
+
+
+
+ All outcomes
+ No outcome
+ Auto synced
+ Sync recommended
+ Ignored
+ Review PR
+
+ Manual review
+
+
+ Conflict
+
+ Failed
+ Unknown
+
+
- {threads.length ? (
- threads.map((thread) => (
-
(
+
router.push(`/threads/${thread._id}`)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ router.push(`/threads/${thread._id}`);
+ }
+ }}
>
-
-
-
-
-
{thread.title}
-
- {thread.source.replaceAll('_', ' ')}
+
+
+
+
{thread.title}
+ {thread.spoonName ? (
+ {thread.spoonName}
+ ) : null}
+
+ {thread.source.replaceAll('_', ' ')}
+
+ {thread.status.replaceAll('_', ' ')}
+ {thread.maintenanceOutcome ? (
+
+ {thread.maintenanceOutcome.replaceAll('_', ' ')}
- {thread.status.replaceAll('_', ' ')}
- {thread.maintenanceOutcome ? (
-
- {thread.maintenanceOutcome.replaceAll('_', ' ')}
-
- ) : null}
-
-
- {thread.summary ??
- 'No summary has been recorded for this thread yet.'}
-
+ ) : null}
-
-
{formatTime(thread.updatedAt)}
-
{thread.priority} priority
+
+ {thread.summary ??
+ 'No summary has been recorded for this thread yet.'}
+
+
+
+
{formatTime(thread.updatedAt)}
+
{thread.priority} priority
+ {thread.latestJobStatus ? (
+
{thread.latestJobStatus.replaceAll('_', ' ')}
+ ) : null}
+
+ {thread.latestAgentJobId ? (
+
+ event.stopPropagation()}
+ >
+ Workspace
+
+
+ ) : null}
+ {thread.latestJobPullRequestUrl ? (
+
+ event.stopPropagation()}
+ >
+ PR
+
+
+ ) : null}
-
-
-
+
+
+
))
) : (
diff --git a/apps/next/src/app/api/threads/[threadId]/message/route.ts b/apps/next/src/app/api/threads/[threadId]/message/route.ts
new file mode 100644
index 0000000..2b0626d
--- /dev/null
+++ b/apps/next/src/app/api/threads/[threadId]/message/route.ts
@@ -0,0 +1,81 @@
+import { NextResponse } from 'next/server';
+import { proxyWorker } from '@/lib/agent-worker-proxy';
+import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
+import { fetchMutation, fetchQuery } from 'convex/nextjs';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+const activeJobStatuses = new Set([
+ 'claimed',
+ 'preparing',
+ 'running',
+ 'checks_running',
+ 'changes_ready',
+]);
+
+const activeWorkspaceStatuses = new Set(['active', 'idle']);
+
+export const POST = async (
+ request: Request,
+ context: { params: Promise<{ threadId: string }> },
+) => {
+ try {
+ const token = await convexAuthNextjsToken();
+ if (!token) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ const { threadId: rawThreadId } = await context.params;
+ const threadId = rawThreadId as Id<'threads'>;
+ const body = (await request.json()) as { content?: string };
+ const content = body.content?.trim() ?? '';
+ if (!content) {
+ return NextResponse.json(
+ { error: 'Message is required.' },
+ { status: 400 },
+ );
+ }
+ const details = await fetchQuery(api.threads.get, { threadId }, { token });
+ const latestJob = details.latestJob;
+ const canSendToWorker =
+ latestJob &&
+ activeJobStatuses.has(latestJob.status) &&
+ activeWorkspaceStatuses.has(latestJob.workspaceStatus ?? '');
+
+ if (!canSendToWorker) {
+ await fetchMutation(
+ api.threads.appendUserMessage,
+ { threadId, content },
+ { token },
+ );
+ return NextResponse.json({
+ success: true,
+ mode: 'note',
+ message: latestJob
+ ? 'Message was added as a thread note because the latest workspace is not active.'
+ : 'Message was added as a thread note.',
+ });
+ }
+
+ const proxied = await proxyWorker(latestJob._id, 'message', {
+ method: 'POST',
+ body: JSON.stringify({ content }),
+ });
+ if (!proxied.ok) {
+ const text = await proxied.text();
+ return NextResponse.json(
+ {
+ error: text,
+ recoverable:
+ text.includes('workspace is not active') ||
+ text.includes('not active on this worker'),
+ },
+ { status: proxied.status === 500 ? 409 : proxied.status },
+ );
+ }
+ return proxied;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+};
diff --git a/apps/next/src/components/agent-workspace/agent-thread.tsx b/apps/next/src/components/agent-workspace/agent-thread.tsx
index 1be2602..5395894 100644
--- a/apps/next/src/components/agent-workspace/agent-thread.tsx
+++ b/apps/next/src/components/agent-workspace/agent-thread.tsx
@@ -13,12 +13,14 @@ export const AgentThread = ({
events,
interactions,
disabled,
+ agentTurnActive,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
+ agentTurnActive: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
@@ -94,7 +96,7 @@ export const AgentThread = ({
type='button'
variant='outline'
size='sm'
- disabled={disabled}
+ disabled={disabled || !agentTurnActive}
onClick={abort}
>
diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx
index d6ee644..c1d9c76 100644
--- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx
+++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
+import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
+ const createJobForThread = useMutation(api.agentJobs.createForThread);
+ const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
+ const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
const [tree, setTree] = useState(null);
const [files, setFiles] = useState>({});
const [openFilePaths, setOpenFilePaths] = useState([]);
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
+ const [workspaceError, setWorkspaceError] = useState();
+ const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled =
!job ||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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);
setTree(data.tree);
}, [jobId]);
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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);
setDiff(data.diff);
}, [jobId]);
+ const loadAgentStatus = useCallback(async () => {
+ const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
+ if (!response.ok) {
+ setAgentTurnActive(false);
+ return;
+ }
+ const data = (await response.json()) as { active?: boolean };
+ setAgentTurnActive(Boolean(data.active));
+ }, [jobId]);
+
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
+ setWorkspaceError(
+ error instanceof Error ? error.message : String(error),
+ );
});
void loadDiff().catch((error: unknown) => {
console.error(error);
+ setWorkspaceError(
+ error instanceof Error ? error.message : String(error),
+ );
});
+ void loadAgentStatus();
}, 0);
return () => window.clearTimeout(timeout);
- }, [job, loadDiff, loadTree]);
+ }, [job, loadAgentStatus, loadDiff, loadTree]);
+
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ void loadAgentStatus();
+ }, 5_000);
+ return () => window.clearInterval(interval);
+ }, [loadAgentStatus]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
+ const recoverWorkspace = async () => {
+ if (!job.threadId) return;
+ const newJobId = await createJobForThread({
+ threadId: job.threadId,
+ jobType: job.jobType ?? 'user_change',
+ });
+ window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
+ };
+
+ const deleteStaleWorkspace = async () => {
+ if (!window.confirm('Delete this stale workspace record?')) return;
+ await markWorkspaceLost({ jobId });
+ await deleteWorkspace({ jobId });
+ window.location.href = job.threadId
+ ? `/threads/${job.threadId}`
+ : `/spoons/${job.spoonId}`;
+ };
const saveFile = async (content: string) => {
if (!activeFilePath) return;
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return (
+ {workspaceError ? (
+
+
+
Workspace not active on this worker
+
+ {workspaceError}
+
+
+ {job.threadId ? (
+
void recoverWorkspace()}>
+ Recreate workspace run
+
+ ) : null}
+
void deleteStaleWorkspace()}
+ >
+ Delete stale workspace
+
+ {job.threadId ? (
+
+ Open thread
+
+ ) : null}
+
+
+
+ ) : null}
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
+ agentTurnActive={agentTurnActive}
/>
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
+ agentTurnActive={agentTurnActive}
/>
diff --git a/apps/next/src/components/integrations/ai-provider-profiles-panel.tsx b/apps/next/src/components/integrations/ai-provider-profiles-panel.tsx
index 1c7c9ad..16e0ab4 100644
--- a/apps/next/src/components/integrations/ai-provider-profiles-panel.tsx
+++ b/apps/next/src/components/integrations/ai-provider-profiles-panel.tsx
@@ -1,8 +1,12 @@
'use client';
-import type { ProviderModelOption } from '@/lib/models-dev';
-import { useEffect, useMemo, useState } from 'react';
-import { loadModelsDevOptions } from '@/lib/models-dev';
+import type { ProviderModelOption } from '@/lib/provider-model-options';
+import { useMemo, useState } from 'react';
+import {
+ modelOptionsFromIds,
+ suggestedModelOptions,
+ supportsCustomModelOptions,
+} from '@/lib/provider-model-options';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react';
@@ -11,6 +15,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
+ Badge,
Button,
Card,
CardContent,
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
secret?: string;
baseUrl?: string;
defaultModel: string;
+ modelOptions?: string[];
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
- const [defaultModelValue, setDefaultModelValue] = useState('');
- const [modelOptions, setModelOptions] = useState
([]);
+ const [defaultModelValue, setDefaultModelValue] = useState(
+ suggestedModelOptions('openai')[0]?.id ?? '',
+ );
+ const [modelOptions, setModelOptions] = useState(
+ suggestedModelOptions('openai'),
+ );
+ const [customModelId, setCustomModelId] = useState('');
const [reasoningEffort, setReasoningEffort] =
useState('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
- useEffect(() => {
- let cancelled = false;
- loadModelsDevOptions(provider)
- .then((options) => {
- if (cancelled) return;
- setModelOptions(options);
- setDefaultModelValue((current) =>
- current && options.some((option) => option.id === current)
- ? current
- : (options[0]?.id ?? ''),
- );
- })
- .catch((error: unknown) => {
- console.error(error);
- if (!cancelled) setModelOptions([]);
- });
- return () => {
- cancelled = true;
- };
- }, [provider]);
+ const resetModelOptions = (nextProvider: Provider) => {
+ const options = suggestedModelOptions(nextProvider);
+ setModelOptions(options);
+ setDefaultModelValue(options[0]?.id ?? '');
+ setCustomModelId('');
+ };
const reset = () => {
setProfileId(undefined);
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
+ setModelOptions(suggestedModelOptions('openai'));
+ setCustomModelId('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
+ setModelOptions(
+ modelOptionsFromIds(
+ profile.modelOptions?.length
+ ? profile.modelOptions
+ : [profile.defaultModel],
+ ),
+ );
+ setCustomModelId('');
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
+ modelOptions: modelOptions.map((model) => model.id),
reasoningEffort,
enabled,
});
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
+ resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
- Models are loaded from Models.dev, the catalog OpenCode uses
- for provider/model metadata.
+ Saved model options are used by Spoons. Add custom model IDs
+ for compatible provider gateways.
+
+
+ Available model options
+
+
+ {modelOptions.map((model) => (
+
+ {model.id}
+
+ ))}
+
+
+ {supportsCustomModelOptions(provider) ? (
+
+ setCustomModelId(event.target.value)}
+ />
+ {
+ const id = customModelId.trim();
+ if (!id) return;
+ setModelOptions((current) =>
+ current.some((model) => model.id === id)
+ ? current
+ : [...current, ...modelOptionsFromIds([id])],
+ );
+ setDefaultModelValue((current) => current || id);
+ setCustomModelId('');
+ }}
+ >
+ Add
+
+
+ ) : null}
Thinking
diff --git a/apps/next/src/components/landing/features.tsx b/apps/next/src/components/landing/features.tsx
index efc10e5..28ecc61 100644
--- a/apps/next/src/components/landing/features.tsx
+++ b/apps/next/src/components/landing/features.tsx
@@ -75,7 +75,7 @@ const features = [
{
title: 'Provider-owned AI',
description:
- 'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
+ 'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
icon: KeyRound,
},
{
@@ -119,7 +119,7 @@ const ownership = [
{
title: 'Your providers',
description:
- 'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
+ 'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
icon: ShieldCheck,
},
{
diff --git a/apps/next/src/components/layout/footer/index.tsx b/apps/next/src/components/layout/footer/index.tsx
index 592698c..33f00a3 100644
--- a/apps/next/src/components/layout/footer/index.tsx
+++ b/apps/next/src/components/layout/footer/index.tsx
@@ -63,6 +63,14 @@ export default function Footer() {
Integrations
+
+
+ Worker
+
+
{
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
+ const copy = async (value: string) => {
+ await navigator.clipboard.writeText(value);
+ toast.success('Copied.');
+ };
+
+ const DiagnosticValue = ({ value }: { value: string }) => (
+
+ {value}
+ void copy(value)}
+ >
+
+
+
+ );
+
const refreshHealth = async () => {
setLoadingHealth(true);
setHealthError(undefined);
@@ -151,15 +170,15 @@ export const WorkerHealthPanel = () => {
Convex
- {health.convexUrl}
+
Job image
- {health.jobImage}
+
Workdir
- {health.workdir}
+
Network
diff --git a/apps/next/src/components/spoons/spoon-agent-settings-form.tsx b/apps/next/src/components/spoons/spoon-agent-settings-form.tsx
index fcc95c7..0a821cb 100644
--- a/apps/next/src/components/spoons/spoon-agent-settings-form.tsx
+++ b/apps/next/src/components/spoons/spoon-agent-settings-form.tsx
@@ -1,8 +1,6 @@
'use client';
-import type { ProviderModelOption } from '@/lib/models-dev';
-import { useEffect, useState } from 'react';
-import { loadModelsDevOptions } from '@/lib/models-dev';
+import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
+ const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
? defaultProfile?._id
: aiProviderProfileId),
);
- const [availableModels, setAvailableModels] = useState
(
- [],
+ const selectedModelProfile = modelCatalog?.profiles.find(
+ (profile) =>
+ profile.profileId ===
+ (aiProviderProfileId === '__default'
+ ? defaultProfile?._id
+ : aiProviderProfileId),
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort,
);
- useEffect(() => {
- if (!selectedProfile?.configured) {
- return;
- }
- let cancelled = false;
- loadModelsDevOptions(selectedProfile.provider)
- .then((models) => {
- if (cancelled) return;
- setAvailableModels(models);
- setAgentModel((current) =>
- current && models.some((model) => model.id === current)
- ? current
- : models.some((model) => model.id === selectedProfile.defaultModel)
- ? selectedProfile.defaultModel
- : (models[0]?.id ?? ''),
- );
- setReasoningEffort(
- selectedProfile.reasoningEffort === 'none'
- ? 'minimal'
- : selectedProfile.reasoningEffort,
- );
- })
- .catch((error: unknown) => {
- console.error(error);
- if (!cancelled) setAvailableModels([]);
- });
- return () => {
- cancelled = true;
- };
- }, [
- selectedProfile?.configured,
- selectedProfile?.defaultModel,
- selectedProfile?.provider,
- selectedProfile?.reasoningEffort,
- ]);
- const selectableModels = selectedProfile?.configured ? availableModels : [];
+ const selectableModels = selectedModelProfile?.configured
+ ? selectedModelProfile.models
+ : [];
+ const selectedAgentModel =
+ agentModel && selectableModels.some((model) => model.id === agentModel)
+ ? agentModel
+ : selectableModels.some(
+ (model) => model.id === selectedModelProfile?.defaultModel,
+ )
+ ? (selectedModelProfile?.defaultModel ?? '')
+ : (selectableModels[0]?.id ?? '');
const save = async () => {
try {
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
- agentModel: agentModel.trim()
- ? agentModel
- : (selectableModels[0]?.id ?? undefined),
+ agentModel: selectedAgentModel || undefined,
reasoningEffort,
envFilePath: envFilePath as
| '.env'
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
- OpenCode jobs and maintenance review threads use this profile.
+ Workspaces use this profile. Use default resolves to your account
+ default provider.
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
Model
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
{!selectableModels.length ? (
- Configure an enabled AI provider profile in Settings before
- choosing a model.
+ Configure an enabled AI provider profile with saved model
+ options in Settings before choosing a model.
) : null}
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save}
disabled={
!selectedProfile?.configured ||
- !selectableModels.some((model) => model.id === agentModel)
+ !selectableModels.some((model) => model.id === selectedAgentModel)
}
>
Save agent settings
diff --git a/apps/next/src/components/spoons/spoon-card.tsx b/apps/next/src/components/spoons/spoon-card.tsx
index 40bd3fd..6e831cf 100644
--- a/apps/next/src/components/spoons/spoon-card.tsx
+++ b/apps/next/src/components/spoons/spoon-card.tsx
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
-export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
+type SpoonCardData = Doc<'spoons'> & {
+ rawUpstreamAheadBy?: number;
+ effectiveUpstreamAheadBy?: number;
+ ignoredUpstreamCount?: number;
+};
+
+export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
{formatDate(spoon.lastCheckedAt)}
-
Upstream waiting
-
{spoon.upstreamAheadBy ?? 0}
+
Actionable upstream
+
+ {spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
+
+ {spoon.ignoredUpstreamCount ? (
+
+ {spoon.ignoredUpstreamCount} ignored
+
+ ) : null}
Fork-only commits
diff --git a/apps/next/src/lib/models-dev.ts b/apps/next/src/lib/models-dev.ts
deleted file mode 100644
index 45fc5f6..0000000
--- a/apps/next/src/lib/models-dev.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-type ModelsDevModel = {
- id?: string;
- name?: string;
- tool_call?: boolean;
- reasoning?: boolean;
- limit?: { context?: number };
-};
-
-type ModelsDevProvider = {
- id?: string;
- name?: string;
- models?: Record
;
-};
-
-const providerMap = {
- openai: 'openai',
- anthropic: 'anthropic',
- google: 'google',
- openrouter: 'openrouter',
- requesty: 'requesty',
- litellm: 'litellm',
- cloudflare_ai_gateway: 'cloudflare',
- custom_openai_compatible: '',
- opencode_openai_login: 'openai',
-} as const;
-
-export type ProviderModelOption = {
- id: string;
- label: string;
- reasoning: boolean;
- toolCall: boolean;
- context?: number;
-};
-
-export const loadModelsDevOptions = async (provider: string) => {
- const mapped = providerMap[provider as keyof typeof providerMap];
- if (!mapped) return [];
- const response = await fetch('https://models.dev/api.json', {
- cache: 'force-cache',
- });
- if (!response.ok) return [];
- const catalog = (await response.json()) as Record;
- const providerCatalog = catalog[mapped];
- return Object.entries(providerCatalog?.models ?? {})
- .map(
- ([id, model]): ProviderModelOption => ({
- id: model.id ?? id,
- label: model.name ?? model.id ?? id,
- reasoning: Boolean(model.reasoning),
- toolCall: Boolean(model.tool_call),
- context: model.limit?.context,
- }),
- )
- .filter((model) => model.toolCall)
- .sort((a, b) => a.label.localeCompare(b.label));
-};
diff --git a/apps/next/src/lib/provider-model-options.ts b/apps/next/src/lib/provider-model-options.ts
new file mode 100644
index 0000000..3ff83f1
--- /dev/null
+++ b/apps/next/src/lib/provider-model-options.ts
@@ -0,0 +1,72 @@
+export type ProviderModelOption = {
+ id: string;
+ label: string;
+ reasoning: boolean;
+ toolCall: boolean;
+ context?: number;
+};
+
+const options = {
+ openai: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5', 'gpt-5-mini'],
+ opencode_openai_login: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5'],
+ anthropic: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
+ google: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'],
+ openrouter: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
+ requesty: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
+ litellm: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
+ cloudflare_ai_gateway: ['openai/gpt-5.1-codex'],
+ custom_openai_compatible: ['gpt-5.1-codex'],
+} as const;
+
+export type ProviderModelKey = keyof typeof options;
+const modelOptionsByProvider: Record = options;
+
+const labelForModel = (id: string) => {
+ const label = id
+ .split('/')
+ .at(-1)
+ ?.replaceAll('-', ' ')
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+ return label ?? id;
+};
+
+export const suggestedModelOptions = (
+ provider: string,
+): ProviderModelOption[] =>
+ (modelOptionsByProvider[provider] ?? []).map((id) => ({
+ id,
+ label: labelForModel(id),
+ reasoning: true,
+ toolCall: true,
+ }));
+
+export const modelOptionsFromIds = (
+ ids: string[] | undefined,
+): ProviderModelOption[] =>
+ (ids ?? [])
+ .map((id) => id.trim())
+ .filter(Boolean)
+ .filter((id, index, all) => all.indexOf(id) === index)
+ .map((id) => ({
+ id,
+ label: labelForModel(id),
+ reasoning: true,
+ toolCall: true,
+ }));
+
+export const modelIdsForProfile = (profile?: {
+ defaultModel?: string;
+ modelOptions?: string[];
+}) =>
+ [profile?.defaultModel, ...(profile?.modelOptions ?? [])]
+ .filter((model): model is string => Boolean(model?.trim()))
+ .filter((model, index, all) => all.indexOf(model) === index);
+
+export const supportsCustomModelOptions = (provider: string) =>
+ [
+ 'openrouter',
+ 'requesty',
+ 'litellm',
+ 'cloudflare_ai_gateway',
+ 'custom_openai_compatible',
+ ].includes(provider);
diff --git a/apps/next/tests/unit/provider-model-options.test.ts b/apps/next/tests/unit/provider-model-options.test.ts
new file mode 100644
index 0000000..38491f3
--- /dev/null
+++ b/apps/next/tests/unit/provider-model-options.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ modelIdsForProfile,
+ modelOptionsFromIds,
+ suggestedModelOptions,
+ supportsCustomModelOptions,
+} from '../../src/lib/provider-model-options';
+
+describe('provider model options', () => {
+ it('returns stored profile model ids without duplicates', () => {
+ expect(
+ modelIdsForProfile({
+ defaultModel: 'gpt-5.1-codex',
+ modelOptions: ['gpt-5.1-codex', 'gpt-5'],
+ }),
+ ).toEqual(['gpt-5.1-codex', 'gpt-5']);
+ });
+
+ it('provides local suggestions for built-in providers', () => {
+ expect(
+ suggestedModelOptions('openai').some(
+ (model) => model.id === 'gpt-5.1-codex',
+ ),
+ ).toBe(true);
+ });
+
+ it('supports custom model ids only for gateway-style providers', () => {
+ expect(supportsCustomModelOptions('openrouter')).toBe(true);
+ expect(supportsCustomModelOptions('openai')).toBe(false);
+ });
+
+ it('normalizes model ids into select options', () => {
+ expect(modelOptionsFromIds(['openai/gpt-5.1-codex'])[0]).toMatchObject({
+ id: 'openai/gpt-5.1-codex',
+ label: 'Gpt 5.1 Codex',
+ });
+ });
+});
diff --git a/docker/agent-job.Dockerfile b/docker/agent-job.Dockerfile
index 81c768f..b25185a 100644
--- a/docker/agent-job.Dockerfile
+++ b/docker/agent-job.Dockerfile
@@ -15,6 +15,8 @@ RUN apt-get update \
python3 \
ripgrep \
&& corepack enable \
+ && corepack prepare pnpm@latest --activate \
+ && corepack prepare yarn@stable --activate \
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
&& rm -rf /var/lib/apt/lists/*
diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts
index f5d8dfd..17fb1eb 100644
--- a/packages/backend/convex/agentJobs.ts
+++ b/packages/backend/convex/agentJobs.ts
@@ -219,6 +219,11 @@ const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
+const isTerminalJob = (job: Doc<'agentJobs'>) =>
+ ['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
+ job.status,
+ ) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
+
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const messages = await ctx.db
.query('agentJobMessages')
@@ -546,7 +551,10 @@ export const createForThread = mutation({
throw new ConvexError('Thread not found.');
}
if (thread.latestAgentJobId) {
- throw new ConvexError('This thread already has an agent job.');
+ const latestJob = await ctx.db.get(thread.latestAgentJobId);
+ if (latestJob && !isTerminalJob(latestJob)) {
+ throw new ConvexError('This thread already has an active agent job.');
+ }
}
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
const promptMessage = await ctx.db
@@ -609,7 +617,12 @@ export const createForThreadInternal = internalMutation({
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
throw new ConvexError('Thread not found.');
}
- if (thread.latestAgentJobId) return thread.latestAgentJobId;
+ if (thread.latestAgentJobId) {
+ const latestJob = await ctx.db.get(thread.latestAgentJobId);
+ if (latestJob && !isTerminalJob(latestJob)) {
+ return thread.latestAgentJobId;
+ }
+ }
const spoon = await ctx.db.get(thread.spoonId);
if (spoon?.ownerId !== args.ownerId) {
throw new ConvexError('Spoon not found.');
@@ -929,6 +942,30 @@ export const deleteWorkspace = mutation({
},
});
+export const markWorkspaceLost = mutation({
+ args: { jobId: v.id('agentJobs') },
+ handler: async (ctx, { jobId }) => {
+ const ownerId = await getRequiredUserId(ctx);
+ const job = await ctx.db.get(jobId);
+ if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
+ const now = Date.now();
+ await ctx.db.patch(jobId, {
+ status: 'failed',
+ workspaceStatus: 'failed',
+ error: 'Workspace is not active on the configured worker.',
+ completedAt: job.completedAt ?? now,
+ updatedAt: now,
+ });
+ if (job.threadId) {
+ await ctx.db.patch(job.threadId, {
+ status: 'failed',
+ updatedAt: now,
+ });
+ }
+ return { success: true };
+ },
+});
+
export const countOldWorkspaces = query({
args: { olderThanDays: v.optional(v.number()) },
handler: async (ctx, { olderThanDays }) => {
diff --git a/packages/backend/convex/aiProviderModels.ts b/packages/backend/convex/aiProviderModels.ts
new file mode 100644
index 0000000..e342208
--- /dev/null
+++ b/packages/backend/convex/aiProviderModels.ts
@@ -0,0 +1,99 @@
+import type { Doc } from './_generated/dataModel';
+import { query } from './_generated/server';
+import { getRequiredUserId } from './model';
+
+type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
+ isDefault?: boolean;
+};
+
+const labelForModel = (model: string): string => {
+ const parts = model.split('/');
+ const raw = parts[parts.length - 1] ?? model;
+ return raw
+ .replaceAll('-', ' ')
+ .replace(/\b\w/g, (letter: string) => letter.toUpperCase());
+};
+
+const recommendedFor = (model: string) => {
+ const lower = model.toLowerCase();
+ const tags: ('coding' | 'review' | 'fast' | 'large_context')[] = [];
+ if (
+ lower.includes('codex') ||
+ lower.includes('claude') ||
+ lower.includes('sonnet')
+ ) {
+ tags.push('coding');
+ }
+ if (
+ lower.includes('mini') ||
+ lower.includes('haiku') ||
+ lower.includes('flash')
+ ) {
+ tags.push('fast');
+ }
+ if (
+ lower.includes('200k') ||
+ lower.includes('1m') ||
+ lower.includes('large')
+ ) {
+ tags.push('large_context');
+ }
+ if (!tags.length) tags.push('review');
+ return tags;
+};
+
+export const listAvailableForUser = query({
+ args: {},
+ handler: async (ctx) => {
+ const ownerId = await getRequiredUserId(ctx);
+ const profiles = await ctx.db
+ .query('aiProviderProfiles')
+ .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
+ .order('desc')
+ .collect();
+ const configuredProfiles = profiles.filter(
+ (profile) =>
+ profile.enabled &&
+ (profile.authType === 'none' || Boolean(profile.encryptedSecret)),
+ );
+ const explicitDefault = configuredProfiles.find(
+ (profile) => (profile as AiProviderProfileWithDefault).isDefault,
+ );
+ const defaultProfileId =
+ explicitDefault?._id ??
+ (configuredProfiles.length === 1
+ ? configuredProfiles[0]?._id
+ : undefined);
+
+ return {
+ profiles: profiles
+ .filter((profile) => profile.enabled)
+ .map((profile) => {
+ const configured =
+ profile.authType === 'none' || Boolean(profile.encryptedSecret);
+ const modelIds = [
+ profile.defaultModel,
+ ...(profile.modelOptions ?? []),
+ ]
+ .map((model) => model.trim())
+ .filter(Boolean)
+ .filter((model, index, all) => all.indexOf(model) === index);
+ return {
+ profileId: profile._id,
+ profileName: profile.name,
+ provider: profile.provider,
+ configured,
+ enabled: profile.enabled,
+ isDefault: profile._id === defaultProfileId,
+ defaultModel: profile.defaultModel,
+ reasoningEffort: profile.reasoningEffort,
+ models: modelIds.map((id) => ({
+ id,
+ label: labelForModel(id),
+ recommendedFor: recommendedFor(id),
+ })),
+ };
+ }),
+ };
+ },
+});
diff --git a/packages/backend/convex/spoons.ts b/packages/backend/convex/spoons.ts
index 5796bb1..6d1ac74 100644
--- a/packages/backend/convex/spoons.ts
+++ b/packages/backend/convex/spoons.ts
@@ -87,6 +87,64 @@ export const listMine = query({
},
});
+export const listMineWithState = query({
+ args: {},
+ handler: async (ctx) => {
+ const ownerId = await getRequiredUserId(ctx);
+ const spoons = (
+ await ctx.db
+ .query('spoons')
+ .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
+ .order('desc')
+ .collect()
+ ).filter((spoon) => spoon.status !== 'archived');
+
+ return await Promise.all(
+ spoons.map(async (spoon) => {
+ const [state, ignoredChanges, threads] = await Promise.all([
+ ctx.db
+ .query('spoonRepositoryStates')
+ .withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
+ .first(),
+ ctx.db
+ .query('ignoredUpstreamChanges')
+ .withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
+ .collect(),
+ ctx.db
+ .query('threads')
+ .withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
+ .order('desc')
+ .collect(),
+ ]);
+ const ignoredShas = new Set(
+ ignoredChanges.flatMap((change) => change.commitShas),
+ );
+ const rawUpstreamAheadBy =
+ state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
+ const effectiveUpstreamAheadBy = Math.max(
+ 0,
+ rawUpstreamAheadBy - ignoredShas.size,
+ );
+ const openThreads = threads.filter(
+ (thread) =>
+ !['resolved', 'ignored', 'failed', 'cancelled'].includes(
+ thread.status,
+ ),
+ );
+ return {
+ ...spoon,
+ rawUpstreamAheadBy,
+ effectiveUpstreamAheadBy,
+ ignoredUpstreamCount: ignoredShas.size,
+ forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
+ openThreadCount: openThreads.length,
+ latestThreadStatus: threads[0]?.status,
+ };
+ }),
+ );
+ },
+});
+
export const get = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
diff --git a/packages/backend/convex/threads.ts b/packages/backend/convex/threads.ts
index bdb735c..11a6984 100644
--- a/packages/backend/convex/threads.ts
+++ b/packages/backend/convex/threads.ts
@@ -82,7 +82,7 @@ export const listMine = query({
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(args.limit ?? 50);
- return threads.filter((thread) => {
+ const filtered = threads.filter((thread) => {
if (
args.status &&
args.status !== 'all' &&
@@ -100,6 +100,28 @@ export const listMine = query({
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
return true;
});
+ return await Promise.all(
+ filtered.map(async (thread) => {
+ const [spoon, latestJob] = await Promise.all([
+ thread.spoonId ? ctx.db.get(thread.spoonId) : null,
+ thread.latestAgentJobId ? ctx.db.get(thread.latestAgentJobId) : null,
+ ]);
+ return {
+ ...publicThread(thread),
+ spoonName: spoon?.ownerId === ownerId ? spoon.name : undefined,
+ latestJobStatus:
+ latestJob?.ownerId === ownerId ? latestJob.status : undefined,
+ latestJobWorkspaceStatus:
+ latestJob?.ownerId === ownerId
+ ? latestJob.workspaceStatus
+ : undefined,
+ latestJobPullRequestUrl:
+ latestJob?.ownerId === ownerId
+ ? latestJob.pullRequestUrl
+ : undefined,
+ };
+ }),
+ );
},
});
@@ -216,7 +238,7 @@ export const appendUserMessage = mutation({
spoonId: thread.spoonId,
role: 'user',
content: requireText(content, 'Message'),
- status: 'queued',
+ status: 'completed',
createdAt: now,
updatedAt: now,
});
diff --git a/packages/backend/tests/unit/harness.test.ts b/packages/backend/tests/unit/harness.test.ts
index 1ba44c1..d274600 100644
--- a/packages/backend/tests/unit/harness.test.ts
+++ b/packages/backend/tests/unit/harness.test.ts
@@ -34,6 +34,13 @@ const spoonInput = {
productionRefStrategy: 'default_branch' as const,
};
+const githubSpoonInput = {
+ ...spoonInput,
+ provider: 'github' as const,
+ upstreamUrl: 'https://github.com/upstream/editor',
+ forkUrl: 'https://github.com/team/editor-spoon',
+};
+
const createAgentJob = async (
t: ReturnType,
args: {
@@ -114,6 +121,54 @@ describe('convex-test harness', () => {
expect(spoons[0]?.ownerId).toBe(userId);
});
+ test('lists effective drift after ignored upstream changes', async () => {
+ const t = convexTest(schema, modules);
+ const ownerId = await createUser(t, 'owner@example.com');
+ const spoonId = await authed(t, ownerId).mutation(
+ api.spoons.createManual,
+ githubSpoonInput,
+ );
+ await t.mutation(async (ctx) => {
+ const now = Date.now();
+ await ctx.db.insert('spoonRepositoryStates', {
+ spoonId,
+ ownerId: ownerId as Id<'users'>,
+ upstreamFullName: 'upstream/editor',
+ forkFullName: 'team/editor-spoon',
+ upstreamDefaultBranch: 'main',
+ forkDefaultBranch: 'main',
+ upstreamHeadSha: 'upstream-head',
+ forkHeadSha: 'fork-head',
+ upstreamAheadBy: 2,
+ forkAheadBy: 1,
+ status: 'diverged',
+ openForkPullRequestCount: 0,
+ openUpstreamPullRequestCount: 0,
+ refreshedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await ctx.db.insert('ignoredUpstreamChanges', {
+ spoonId,
+ ownerId: ownerId as Id<'users'>,
+ upstreamTo: 'upstream-head',
+ commitShas: ['abc123'],
+ reason: 'irrelevant',
+ decidedBy: 'user',
+ createdAt: now,
+ });
+ });
+
+ const spoons = await authed(t, ownerId).query(
+ api.spoons.listMineWithState,
+ {},
+ );
+
+ expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
+ expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
+ expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
+ });
+
test('does not allow reading another user’s Spoon', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
@@ -128,6 +183,38 @@ describe('convex-test harness', () => {
).rejects.toThrow('Spoon not found.');
});
+ test('thread notes are completed when no workspace handles them', async () => {
+ const t = convexTest(schema, modules);
+ const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
+ const spoonId = await authed(t, ownerId).mutation(
+ api.spoons.createManual,
+ githubSpoonInput,
+ );
+ const threadId = await t.mutation(async (ctx) => {
+ return await ctx.db.insert('threads', {
+ ownerId,
+ spoonId,
+ title: 'Manual note',
+ source: 'user_request',
+ status: 'open',
+ priority: 'normal',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ });
+
+ const messageId = await authed(t, ownerId).mutation(
+ api.threads.appendUserMessage,
+ {
+ threadId,
+ content: 'extra context',
+ },
+ );
+ const message = await t.run(async (ctx) => await ctx.db.get(messageId));
+
+ expect(message?.status).toBe('completed');
+ });
+
test('requires Spoon ownership for agent requests', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
@@ -211,4 +298,59 @@ describe('convex-test harness', () => {
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Agent job not found.');
});
+
+ test('queues a new thread job after the previous job is terminal', async () => {
+ const t = convexTest(schema, modules);
+ const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
+ const spoonId = await authed(t, ownerId).mutation(
+ api.spoons.createManual,
+ githubSpoonInput,
+ );
+ await t.mutation(async (ctx) => {
+ await ctx.db.insert('aiProviderProfiles', {
+ ownerId,
+ name: 'Test provider',
+ provider: 'openai',
+ authType: 'none',
+ defaultModel: 'gpt-5.1-codex',
+ modelOptions: ['gpt-5.1-codex'],
+ reasoningEffort: 'medium',
+ enabled: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ });
+ const threadId = await t.mutation(async (ctx) => {
+ return await ctx.db.insert('threads', {
+ ownerId,
+ spoonId,
+ title: 'Retryable thread',
+ summary: 'try again',
+ source: 'user_request',
+ status: 'failed',
+ priority: 'normal',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ });
+ const failedJobId = await createAgentJob(t, {
+ ownerId,
+ spoonId,
+ status: 'failed',
+ workspaceStatus: 'failed',
+ });
+ await t.mutation(async (ctx) => {
+ await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
+ });
+
+ const newJobId = await authed(t, ownerId).mutation(
+ api.agentJobs.createForThread,
+ {
+ threadId,
+ jobType: 'user_change',
+ },
+ );
+
+ expect(newJobId).not.toBe(failedJobId);
+ });
});
diff --git a/scripts/smoke-agent-container b/scripts/smoke-agent-container
index 5fd0fde..809cd0c 100755
--- a/scripts/smoke-agent-container
+++ b/scripts/smoke-agent-container
@@ -19,6 +19,9 @@ fi
set -euo pipefail
node --version
bun --version
+ pnpm --version
+ yarn --version
+ npm --version
git --version
rg --version >/dev/null
jq --version