Compare commits

...

2 Commits

Author SHA1 Message Date
Gabriel Brown ddce5efb13 Update README.md & fix test
Build and Push Next App / quality (push) Successful in 1m40s
Build and Push Next App / build-next (push) Successful in 4m17s
2026-06-22 10:42:47 -04:00
Gabriel Brown 206b64176b Move to threads based system. 2026-06-22 10:37:26 -04:00
83 changed files with 6260 additions and 2003 deletions
+6 -1
View File
@@ -5,7 +5,9 @@
- `apps/next`: Next.js 16 frontend.
- `apps/agent-worker`: optional server-side coding-agent worker. It polls
Convex for queued jobs and may control Docker/Podman to run ephemeral job
containers.
containers. It also exposes a server-only HTTP API, defaulting to port 3921,
that Next route handlers proxy to for active workspace files, diffs,
messages, commands, and draft PR actions.
- `apps/expo`: Expo scaffold; only work here when explicitly requested.
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
- `packages/ui`: shared shadcn-based UI components.
@@ -37,6 +39,9 @@
UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing
variables from Infisical into the selected Convex deployment. Backend
dev/setup scripts run it before `convex dev`.
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
Keep these server-only; the browser must never receive worker tokens.
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
- CI must provide Convex deployment env for codegen, either
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
+92 -75
View File
@@ -1,70 +1,102 @@
# Spoon
Spoon is a self-hostable fork maintenance dashboard.
Spoon is a self-hostable fork maintenance cockpit.
The product goal is simple: make it practical to fork a project, customize it,
and still stay close to upstream. Spoon tracks managed forks, called
**Spoons**, and lays the foundation for upstream update checks, AI-assisted
change review, and agent-authored merge requests.
Forking a project should not mean supporting it alone. Spoon tracks managed
forks, called **Spoons**, watches upstream for drift, automatically syncs clean
forks when it can, and opens durable **Threads** when upstream changes need
review, context, or code.
This repository is the Spoon application itself, not a generic starter.
## Current scope
## What Spoon Does
- Tracks GitHub-backed managed forks and their upstream projects.
- Shows raw and effective drift, fork-only commits, pull requests, clone URLs,
additional remotes, sync history, and open maintenance work.
- Uses Threads as the product center for upstream reviews, merge conflicts,
ignored commits, user-requested changes, worker logs, and draft PR handoff.
- Auto-syncs clean behind forks when there are no fork-only commits.
- Creates maintenance threads when custom fork work means upstream changes need
a decision.
- Runs optional OpenCode-backed workspaces in isolated agent-job containers.
- Lets users configure encrypted AI provider profiles, Codex/OpenCode auth,
per-Spoon secrets, commands, and agent settings.
- Opens draft PRs for code changes instead of auto-merging custom forks.
## Current Scope
Implemented today:
- Public Spoon landing page in Next.js.
- Authenticated web dashboard routes:
- Public Next.js landing page for Spoon's thread-first maintenance model.
- Authenticated web routes:
- `/dashboard`
- `/spoons`
- `/spoons/new`
- `/updates`
- `/spoons/[spoonId]`
- `/settings`
- Manual and GitHub-created Spoon records stored in Convex.
- `/spoons/[spoonId]/agent/[jobId]`
- `/threads`
- `/threads/[threadId]`
- `/settings/profile`
- `/settings/integrations`
- `/settings/ai-providers`
- Legacy `/updates` and `/agents` routes redirect into Threads.
- GitHub App connection, repository listing, fork creation, drift refresh,
commit/PR cache, and safe manual sync foundation.
- Per-user OpenAI settings for upstream compatibility review.
- Per-Spoon encrypted project secrets and agent runtime settings.
- Optional `apps/agent-worker` service that can claim queued jobs, clone the
GitHub fork, ask OpenAI for bounded file edits, run checks, push a branch, and
open a draft PR.
- Password auth and Authentik OAuth through Convex Auth.
commit/PR cache, and safe sync foundation.
- Thread-first maintenance model with ignored upstream changes and effective
drift.
- Optional `apps/agent-worker` service that claims queued jobs, clones the
current GitHub fork, starts an isolated workspace, exposes file browsing and
edits through server-side Next proxies, runs commands, and opens draft PRs.
- Browser workspace with persisted thread messages, file tree, Monaco editor
with optional Vim mode, diff view, command panel, logs, artifacts, and draft
PR actions.
- Encrypted per-user AI provider profiles and per-Spoon project secrets.
- Password auth and Authentik/GitHub OAuth through Convex Auth.
- Expo companion app shell with password and Authentik sign-in.
- Self-hosted local Convex using Postgres storage.
Not implemented yet:
- Browser IDE/editor.
- Automatic merge.
- Additional Git provider automation beyond preserving provider-neutral fields.
- Automatic merge of custom/diverged forks.
- Git provider automation beyond GitHub.
- Additional remotes as push targets.
- Long-running service-stack orchestration inside agent jobs.
- Direct browser access to worker containers.
- Production mobile build/release setup.
## Architecture
- `apps/next`: Next.js 16 web app and primary product UI.
- `apps/agent-worker`: optional server-side worker for queued coding-agent jobs.
- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and
draft PR jobs.
- `apps/expo`: Expo companion app.
- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and
HTTP routes.
- `packages/ui`: shared shadcn-based UI components.
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
- `docker`: local and production Compose files.
- `scripts`: environment, database, and CI helpers.
- `scripts`: environment, database, codegen, and CI helpers.
The core domain objects are:
Core domain objects:
- `spoons`: managed fork records.
- `gitConnections`: future Git provider connection metadata.
- `syncRuns`: future upstream checks, merge attempts, and AI reviews.
- `agentRequests`: prompt-driven agent work requests.
- `agentJobs`: worker-executed coding-agent jobs and their PR lifecycle.
- `threads`: durable maintenance and work conversations.
- `threadMessages`: persisted thread messages.
- `syncRuns`: upstream checks, sync attempts, and maintenance decisions.
- `ignoredUpstreamChanges`: intentional ignore decisions that affect effective
drift.
- `gitConnections`: Git provider connection metadata.
- `agentJobs`: worker-executed workspace jobs and PR lifecycle.
- `agentJobEvents` and `agentJobArtifacts`: logs and structured job outputs.
- `agentWorkspaceChanges`: recorded file changes from user, agent, or command
activity.
- `spoonSecrets`: encrypted per-Spoon environment variables.
- `spoonAgentSettings`: per-Spoon agent model, branch, and command settings.
- `spoonAgentSettings`: per-Spoon runtime, branch, command, and env-file
settings.
- `aiProviderProfiles`: encrypted provider/auth profiles used by OpenCode.
## Local setup
## Local Setup
Requirements:
@@ -92,8 +124,8 @@ Local services:
Next and Expo run on the host. Local Convex runs in containers with Postgres
storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
Convex, and the dashboard, generates a machine-local Convex admin key in
`.local/dev.generated.env` when needed, deploys functions/schema, and
configures local Convex Auth keys.
`.local/dev.generated.env` when needed, deploys functions/schema, and configures
local Convex Auth keys.
```sh
bun db:down # stop; preserve local data
@@ -112,6 +144,10 @@ Run the optional local agent worker in a separate terminal:
bun dev:agent
```
The worker starts an internal HTTP API, defaulting to `http://localhost:3921`,
for server-side Next route handlers. The browser never receives the worker token
or talks to this API directly.
The Docker Compose local worker service is disabled by default behind the
`agent` profile. Build the job image before using Docker-backed jobs:
@@ -120,10 +156,13 @@ docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest .
docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker
```
## Environment model
The job image includes the OpenCode CLI. Rebuild it after changes to
`docker/agent-job.Dockerfile`.
Local `dev` and `staging` values come from Infisical through `scripts/with-env`.
App commands do not fall back to root `.env` files.
## Environment Model
Local `dev` and `staging` values come from Infisical through
`scripts/with-env`. App commands do not fall back to root `.env` files.
Generated local state belongs in:
@@ -139,17 +178,19 @@ Useful helpers:
sh scripts/with-env dev -- <command>
sh scripts/export-env dev
bun sync:convex
bun sync:convex:staging
```
### Convex deployment env
### Convex Deployment Env
Convex functions and HTTP actions read environment variables from the Convex
deployment environment, not directly from the host process. For OAuth providers,
that means Infisical values must also be present in local Convex env.
deployment environment, not directly from the host process. OAuth providers,
GitHub App credentials, UseSend, encryption keys, worker tokens, and Convex Auth
signing keys must be synced into the selected Convex deployment.
`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
values into the selected Convex deployment first. Run it manually when needed:
values into local Convex first. Run it manually when needed:
```sh
sh scripts/sync-convex-env dev
@@ -157,38 +198,12 @@ sh scripts/sync-convex-env staging
INFISICAL_ENV=staging bun sync:convex
```
The sync includes:
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`,
`SPOON_WORKER_TOKEN`, and related generated values are created automatically if
they are not already present. The generated Convex admin key remains
machine-local in `.local/dev.generated.env`; do not put it in Infisical.
```txt
AUTH_AUTHENTIK_ID
AUTH_AUTHENTIK_SECRET
AUTH_AUTHENTIK_ISSUER
AUTH_GITHUB_ID
AUTH_GITHUB_SECRET
GITHUB_APP_ID
GITHUB_APP_CLIENT_ID
GITHUB_APP_CLIENT_SECRET
GITHUB_APP_PRIVATE_KEY
GITHUB_APP_WEBHOOK_SECRET
GITHUB_APP_SLUG
GITHUB_APP_INSTALLATION_ID
GITHUB_APP_OWNER
SPOON_ENCRYPTION_KEY
SPOON_WORKER_TOKEN
USESEND_API_KEY
USESEND_URL
USESEND_FROM_EMAIL
JWT_PRIVATE_KEY
JWKS
SITE_URL
```
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`, and
`SPOON_WORKER_TOKEN` are generated automatically if they are not already present
in Convex. The generated Convex admin key remains machine-local in
`.local/dev.generated.env`; do not put it in Infisical.
The local OAuth callback URLs are:
Local OAuth callback URLs:
```txt
http://localhost:3211/api/auth/callback/authentik
@@ -204,6 +219,7 @@ sync command.
```sh
bun dev:next
bun dev:expo
bun dev:agent
```
Physical devices cannot resolve their own `localhost`; override the public
@@ -244,9 +260,10 @@ test runner instead of the repo's Turbo/Vitest test script.
## Deployment
Production Compose keeps the self-hosted Convex backend/dashboard and expects
`POSTGRES_URL` to be a database-cluster URL without a database path.
Production Compose runs the Next image, self-hosted Convex backend/dashboard,
and Postgres. The deployed Next image is expected to be named
`spoon-next:latest` in the Gitea registry.
Gitea runs the quality gate first, builds the Next image from a temporary
Gitea-secret env file, then pushes SHA and `latest` tags. CI never installs or
invokes Infisical.
Gitea runs the quality gate first, runs Convex codegen with deployment env,
builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA
and `latest` tags. CI never installs or invokes Infisical.
+1 -2
View File
@@ -17,10 +17,9 @@
"dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@openai/agents": "latest",
"@opencode-ai/sdk": "latest",
"convex": "catalog:convex",
"execa": "latest",
"openai": "^6.44.0",
"zod": "catalog:"
},
"devDependencies": {
-190
View File
@@ -1,190 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { execa } from 'execa';
import OpenAI from 'openai';
const editSchema = {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'string' },
files: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string' },
content: { type: 'string' },
},
required: ['path', 'content'],
},
},
commands: {
type: 'array',
items: { type: 'string' },
},
limitations: {
type: 'array',
items: { type: 'string' },
},
},
required: ['summary', 'files', 'commands', 'limitations'],
} as const;
type AgentEdit = {
summary: string;
files: { path: string; content: string }[];
commands: string[];
limitations: string[];
};
const maxContextFiles = 40;
const maxFileBytes = 12_000;
const safeContextFile = (file: string) =>
!file.includes('node_modules/') &&
!file.includes('.git/') &&
!file.includes('dist/') &&
!file.includes('build/') &&
!file.includes('.next/') &&
!file.endsWith('.lock') &&
!file.endsWith('.png') &&
!file.endsWith('.jpg') &&
!file.endsWith('.jpeg') &&
!file.endsWith('.webp') &&
!file.endsWith('.gif') &&
!file.endsWith('.pdf');
const listFiles = async (repoDir: string) => {
const result = await execa('git', ['ls-files'], {
cwd: repoDir,
all: true,
reject: false,
});
return result.all
.split('\n')
.map((file) => file.trim())
.filter(Boolean)
.filter(safeContextFile);
};
const chooseContextFiles = (files: string[], prompt: string) => {
const promptWords = new Set(
prompt
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 3),
);
const scored = files.map((file) => {
const lower = file.toLowerCase();
const score = [...promptWords].reduce(
(sum, word) => sum + (lower.includes(word) ? 2 : 0),
/(readme|package\.json|auth|env|config|route|provider)/i.exec(file)
? 3
: 0,
);
return { file, score };
});
return scored
.sort((a, b) => b.score - a.score)
.slice(0, maxContextFiles)
.map((item) => item.file);
};
const readContext = async (repoDir: string, files: string[]) => {
const chunks = [];
for (const file of files) {
try {
const content = await readFile(path.join(repoDir, file), 'utf8');
chunks.push({
path: file,
content:
content.length > maxFileBytes
? `${content.slice(0, maxFileBytes)}\n[truncated]`
: content,
});
} catch {
// Ignore files that disappeared while context was being gathered.
}
}
return chunks;
};
const parseEdit = (value: string): AgentEdit => {
const parsed = JSON.parse(value) as AgentEdit;
if (!Array.isArray(parsed.files)) {
throw new Error('OpenAI returned an edit without a files array.');
}
return parsed;
};
const safePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
if (!resolved.startsWith(path.resolve(repoDir))) {
throw new Error(`Refusing to write outside the repository: ${filePath}`);
}
return resolved;
};
export const runOpenAiEdit = async (args: {
repoDir: string;
apiKey: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
prompt: string;
secretNames: string[];
spoonName: string;
upstreamFullName: string;
forkFullName: string;
}) => {
const files = await listFiles(args.repoDir);
const selectedFiles = chooseContextFiles(files, args.prompt);
const contextFiles = await readContext(args.repoDir, selectedFiles);
const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({
model: args.model,
store: false,
reasoning:
args.reasoningEffort === 'none'
? undefined
: { effort: args.reasoningEffort },
input: [
{
role: 'system',
content:
'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.',
},
{
role: 'user',
content: JSON.stringify(
{
task: args.prompt,
spoon: args.spoonName,
upstream: args.upstreamFullName,
fork: args.forkFullName,
availableSecretNames: args.secretNames,
repositoryFiles: files.slice(0, 500),
contextFiles,
},
null,
2,
),
},
],
text: {
format: {
type: 'json_schema',
name: 'spoon_agent_file_edits',
strict: true,
schema: editSchema,
},
},
});
const edit = parseEdit(response.output_text);
for (const file of edit.files) {
const target = safePath(args.repoDir, file.path);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
}
return edit;
};
+5
View File
@@ -24,6 +24,11 @@ export const env = {
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
network: process.env.SPOON_AGENT_NETWORK?.trim(),
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
internalToken:
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'),
+2
View File
@@ -1,3 +1,5 @@
import { startWorkerServer } from './server';
import { startWorker } from './worker';
startWorkerServer();
await startWorker();
+142
View File
@@ -0,0 +1,142 @@
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { env } from './env';
import {
getWorkspaceDiff,
listWorkspaceTree,
openWorkspacePullRequest,
readWorkspaceFile,
runWorkspaceCommand,
sendWorkspaceMessage,
stopWorkspace,
writeWorkspaceFile,
} from './worker';
const sendJson = (response: ServerResponse, status: number, body: unknown) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
};
const readBody = async (request: IncomingMessage) =>
await new Promise<string>((resolve, reject) => {
let body = '';
request.on('data', (chunk: Buffer) => {
body += chunk.toString('utf8');
});
request.on('end', () => resolve(body));
request.on('error', reject);
});
const parseJson = async <T>(request: IncomingMessage) => {
const body = await readBody(request);
if (!body.trim()) return {} as T;
return JSON.parse(body) as T;
};
const requireAuth = (request: IncomingMessage) => {
const header = request.headers.authorization;
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (!env.internalToken || token !== env.internalToken) {
throw new Error('Unauthorized');
}
};
const jobRoute = (pathname: string) => {
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
if (!match?.[1] || !match[2]) return null;
return { jobId: decodeURIComponent(match[1]), action: match[2] };
};
export const startWorkerServer = () => {
const server = createServer((request, response) => {
void (async () => {
try {
requireAuth(request);
const url = new URL(
request.url ?? '/',
`http://localhost:${env.httpPort}`,
);
if (url.pathname === '/health') {
sendJson(response, 200, { ok: true, workerId: env.workerId });
return;
}
const route = jobRoute(url.pathname);
if (!route) {
sendJson(response, 404, { error: 'Not found' });
return;
}
if (request.method === 'GET' && route.action === 'tree') {
sendJson(response, 200, {
tree: await listWorkspaceTree(route.jobId),
});
return;
}
if (request.method === 'GET' && route.action === 'file') {
const filePath = url.searchParams.get('path') ?? '';
sendJson(response, 200, {
path: filePath,
content: await readWorkspaceFile(route.jobId, filePath),
});
return;
}
if (request.method === 'PUT' && route.action === 'file') {
const body = await parseJson<{ path?: string; content?: string }>(
request,
);
sendJson(
response,
200,
await writeWorkspaceFile(
route.jobId,
body.path ?? '',
body.content ?? '',
),
);
return;
}
if (request.method === 'GET' && route.action === 'diff') {
sendJson(response, 200, {
diff: await getWorkspaceDiff(route.jobId),
});
return;
}
if (request.method === 'POST' && route.action === 'message') {
const body = await parseJson<{ content?: string }>(request);
await sendWorkspaceMessage(route.jobId, body.content ?? '');
sendJson(response, 200, { success: true });
return;
}
if (request.method === 'POST' && route.action === 'run-command') {
const body = await parseJson<{ command?: string }>(request);
sendJson(
response,
200,
await runWorkspaceCommand(route.jobId, body.command ?? ''),
);
return;
}
if (request.method === 'POST' && route.action === 'open-pr') {
sendJson(response, 200, await openWorkspacePullRequest(route.jobId));
return;
}
if (request.method === 'POST' && route.action === 'stop') {
sendJson(response, 200, await stopWorkspace(route.jobId));
return;
}
sendJson(response, 404, { error: 'Not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
error: message,
});
}
})();
});
server.listen(env.httpPort, () => {
console.log(
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
);
});
};
+675 -113
View File
@@ -1,11 +1,18 @@
import { access, readFile, rm } from 'node:fs/promises';
import {
access,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { runOpenAiEdit } from './agent';
import { env } from './env';
import {
cloneRepository,
@@ -22,6 +29,10 @@ type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
runtime?: 'openai_direct' | 'opencode';
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
envFilePath?: string;
materializeEnvFile?: boolean;
baseBranch: string;
workBranch: string;
forkOwner: string;
@@ -31,7 +42,26 @@ type Claim = {
};
spoon: { name: string };
openai: {
apiKey: string;
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' | 'none';
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
@@ -40,13 +70,30 @@ type Claim = {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
autoDetectCommands?: boolean;
} | null;
secrets: { name: string; value: string }[];
};
type ActiveWorkspace = {
claim: Claim;
workdir: string;
repoDir: string;
githubToken: string;
redact: (value: string) => string;
};
type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>();
const appendEvent = async (
jobId: Id<'agentJobs'>,
@@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: {
...args,
});
const applyMaintenanceDecision = async (
jobId: Id<'agentJobs'>,
decision: MaintenanceDecision,
) =>
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
...decision,
});
const markWorkspaceActive = async (args: {
jobId: Id<'agentJobs'>;
opencodeSessionId?: string;
containerId?: string;
workspaceUrl?: string;
}) =>
await client.mutation(api.agentJobs.markWorkspaceActive, {
workerToken: env.workerToken,
workerId: env.workerId,
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
...args,
});
const markWorkspaceStopped = async (
jobId: Id<'agentJobs'>,
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
) =>
await client.mutation(api.agentJobs.markWorkspaceStopped, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
workspaceStatus,
});
const appendMessage = async (args: {
jobId: Id<'agentJobs'>;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
status: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.appendMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>;
path: string;
source: 'user' | 'agent' | 'command';
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
diff?: string;
}) =>
await client.mutation(api.agentJobs.recordWorkspaceChange, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const commandToShell = (command: string) => ['bash', '-lc', command];
const providerEnvironment = (claim: Claim): Record<string, string> => {
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(
'OpenCode login profiles are saved but need auth-file injection before execution.',
);
};
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}`;
};
const systemPromptForJob = (claim: Claim) => {
const base = [
`Spoon: ${claim.spoon.name}`,
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
].join('\n');
if (claim.job.jobType === 'maintenance_review') {
return `${base}
You are reviewing upstream changes for a maintained fork.
Determine whether the upstream commits can be safely applied.
If the fork has no relevant customizations, recommend sync.
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
Do not claim tests passed unless commands were run.
End with a JSON maintenance decision in this exact shape:
{
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
"risk": "low" | "medium" | "high" | "unknown",
"summary": "string",
"ignoredCommitShas": ["string"],
"ignoredReason": "string",
"recommendedAction": "string",
"requiresUserApproval": true
}
User/system request:
${claim.job.prompt}`;
}
if (claim.job.jobType === 'conflict_resolution') {
return `${base}
You are resolving upstream merge conflicts in a maintained fork.
Preserve fork customizations unless the user explicitly removed that upstream behavior.
Prefer small, reviewable changes.
Produce a draft PR rather than committing to main.
Request:
${claim.job.prompt}`;
}
return `${base}
You are working on a maintained fork managed by Spoon.
Make the requested change only.
Preserve fork-specific customizations.
Do not commit secrets.
Use selected environment variables only for running/building/testing.
Open a draft PR only when instructed by Spoon.
Request:
${claim.job.prompt}`;
};
type MaintenanceDecision = {
decision:
| 'sync'
| 'ignore'
| 'open_review_pr'
| 'manual_review'
| 'conflict_resolution'
| 'unknown';
risk: 'low' | 'medium' | 'high' | 'unknown';
summary: string;
ignoredCommitShas: string[];
ignoredReason: string;
recommendedAction: string;
requiresUserApproval: boolean;
};
const parseMaintenanceDecision = (
output: string,
): MaintenanceDecision | null => {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
Boolean,
) as string[];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(
candidate.trim(),
) as Partial<MaintenanceDecision>;
if (
parsed.decision &&
parsed.risk &&
typeof parsed.summary === 'string'
) {
return {
decision: parsed.decision,
risk: parsed.risk,
summary: parsed.summary,
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
? parsed.ignoredCommitShas.filter(
(sha): sha is string => typeof sha === 'string',
)
: [],
ignoredReason:
typeof parsed.ignoredReason === 'string'
? parsed.ignoredReason
: '',
recommendedAction:
typeof parsed.recommendedAction === 'string'
? parsed.recommendedAction
: parsed.summary,
requiresUserApproval: parsed.requiresUserApproval ?? true,
};
}
} catch {
// Try the next candidate.
}
}
return null;
};
const fileExists = async (filePath: string) => {
try {
await access(filePath);
@@ -180,28 +451,91 @@ const runProjectCommand = async (args: {
}
};
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const resolveWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) {
throw new Error('Agent workspace is not active on this worker.');
}
return workspace;
};
const safeWorkspacePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
const root = path.resolve(repoDir);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to access path outside repository: ${filePath}`);
}
return resolved;
};
const fileChangedType = async (repoDir: string, filePath: string) => {
const status = await run('git', ['status', '--short', '--', filePath], {
cwd: repoDir,
redact: (value) => value,
timeoutMs: 60_000,
});
const code = status.output.trim().slice(0, 2);
if (code.includes('D')) return 'deleted' as const;
if (code.includes('A') || code.includes('?')) return 'added' as const;
if (code.includes('R')) return 'renamed' as const;
return 'modified' as const;
};
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
const { claim, repoDir } = workspace;
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
await mkdir(path.dirname(envPath), { recursive: true });
const content = `${claim.secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
.join('\n')}\n`;
await writeFile(envPath, content);
await appendEvent(
claim.job._id,
'info',
'clone',
`Materialized selected secrets into ${claim.job.envFilePath}.`,
);
};
const detectPackageCommands = async (
repoDir: string,
): Promise<{ install?: string; check?: string; test?: string }> => {
): Promise<{
packageManager?: string;
scripts?: string[];
install?: string;
check?: string;
test?: string;
build?: string;
}> => {
const packageJsonPath = path.join(repoDir, 'package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
packageManager?: string;
scripts?: Record<string, string>;
};
const scripts = packageJson.scripts ?? {};
const packageManager = (await fileExists(path.join(repoDir, 'bun.lock')))
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
const detectedPackageManager = (await fileExists(
path.join(repoDir, 'bun.lock'),
))
? 'bun'
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
? 'pnpm'
: (await fileExists(path.join(repoDir, 'yarn.lock')))
? 'yarn'
: 'npm';
const packageManager = declaredPackageManager ?? detectedPackageManager;
const runScript = (script: string) =>
packageManager === 'npm'
? `npm run ${script}`
: `${packageManager} run ${script}`;
return {
packageManager,
scripts: Object.keys(scripts).sort(),
install: `${packageManager} install`,
check: scripts.typecheck
? runScript('typecheck')
@@ -213,6 +547,7 @@ const detectPackageCommands = async (
? 'npm test'
: `${packageManager} test`
: undefined,
build: scripts.build ? runScript('build') : undefined,
};
} catch {
return {};
@@ -250,13 +585,28 @@ ${
Generated by Spoon.`;
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
const status = await run('git', ['status', '--short'], {
cwd: workspace.repoDir,
redact: workspace.redact,
timeoutMs: 60_000,
});
const envLine = status.output
.split('\n')
.find((line) => /\s\.env(?:$|[./-])/.test(line));
if (envLine) {
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
}
};
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const workdir = path.resolve(env.workdir, jobId);
const secretValues = [
claim.openai.apiKey,
claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '',
...claim.secrets.map((secret) => secret.value),
];
].filter(Boolean);
const redact = createRedactor(secretValues);
try {
await updateStatus(jobId, 'preparing');
@@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => {
redact,
timeoutMs: env.jobTimeoutMs,
});
await updateStatus(jobId, 'running');
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
const edit = await runOpenAiEdit({
const workspace: ActiveWorkspace = {
claim,
workdir,
repoDir,
apiKey: claim.openai.apiKey,
model: claim.openai.model,
reasoningEffort: claim.openai.reasoningEffort,
prompt: claim.job.prompt,
secretNames: claim.secrets.map((secret) => secret.name),
spoonName: claim.spoon.name,
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
});
await addArtifact({
jobId,
kind: 'plan',
title: 'Agent plan',
content: edit.summary,
contentType: 'text/markdown',
});
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes produced by the agent.');
}
const diff = await getWorktreeDiff(repoDir, redact);
await addArtifact({
jobId,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await updateStatus(jobId, 'checks_running');
githubToken,
redact,
};
await materializeEnvFile(workspace);
const detected = await detectPackageCommands(repoDir);
const settings = claim.agentSettings;
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
if (installCommand) {
await runProjectCommand({
command: installCommand,
phase: 'install',
claim,
workdir,
repoDir,
redact,
});
}
if (checkCommand) {
await runProjectCommand({
command: checkCommand,
phase: 'check',
claim,
workdir,
repoDir,
redact,
});
}
if (testCommand) {
await runProjectCommand({
command: testCommand,
phase: 'test',
claim,
workdir,
repoDir,
redact,
});
}
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
redact,
timeoutMs: env.jobTimeoutMs,
});
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: edit.summary,
commands: [
installCommand,
checkCommand,
testCommand,
...edit.commands,
].filter((command): command is string => Boolean(command)),
limitations: edit.limitations,
});
await addArtifact({
jobId,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
kind: 'summary',
title: 'Detected project commands',
content: JSON.stringify(detected, null, 2),
contentType: 'application/json',
});
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
activeWorkspaces.set(jobId, workspace);
await markWorkspaceActive({ jobId });
await updateStatus(jobId, 'running', {
summary: 'Workspace is active.',
});
await completeWithDraftPr({
await appendMessage({
jobId,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: edit.summary,
role: 'system',
status: 'completed',
content:
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
});
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
@@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => {
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
{ error: truncate(redact(message), 10_000) },
);
} finally {
await rm(workdir, { recursive: true, force: true });
await markWorkspaceStopped(
jobId,
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
).catch((stopError: unknown) => {
console.error(stopError);
});
}
};
export const listWorkspaceTree = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const buildNode = async (
absolutePath: string,
relativePath: string,
): Promise<FileTreeNode | null> => {
const basename = path.basename(absolutePath);
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
return null;
}
const stats = await stat(absolutePath);
if (stats.isDirectory()) {
const entries = await readdir(absolutePath);
const children = (
await Promise.all(
entries
.sort((a, b) => a.localeCompare(b))
.map((entry) =>
buildNode(
path.join(absolutePath, entry),
path.join(relativePath, entry),
),
),
)
).filter((node): node is FileTreeNode => Boolean(node));
return {
name: relativePath ? basename : workspace.claim.job.forkRepo,
path: relativePath,
type: 'directory',
children,
};
}
return { name: basename, path: relativePath, type: 'file' };
};
return await buildNode(workspace.repoDir, '');
};
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
return await readFile(target, 'utf8');
};
export const writeWorkspaceFile = async (
jobId: string,
filePath: string,
content: string,
) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: filePath,
source: 'user',
changeType: await fileChangedType(workspace.repoDir, filePath),
diff: truncate(diff.output, 50_000),
});
await appendEvent(
workspace.claim.job._id,
'info',
'edit',
`Saved ${filePath}.`,
);
return { success: true };
};
export const getWorkspaceDiff = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
return diff.output;
};
export const runWorkspaceCommand = async (jobId: string, command: string) => {
const workspace = resolveWorkspace(jobId);
await updateStatus(workspace.claim.job._id, 'checks_running');
await runProjectCommand({
command,
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
workdir: workspace.workdir,
repoDir: workspace.repoDir,
redact: workspace.redact,
});
await updateStatus(workspace.claim.job._id, 'running');
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: '.',
source: 'command',
changeType: 'modified',
diff: truncate(
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
50_000,
),
});
return { success: true };
};
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact, workdir } = workspace;
await appendMessage({
jobId: claim.job._id,
role: 'user',
status: 'completed',
content: prompt,
});
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
const model = opencodeModel(claim);
const aiEnv = providerEnvironment(claim);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir,
command: commandToShell(
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
),
environment: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
})
: await run(
'bash',
[
'-lc',
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
],
{
cwd: repoDir,
env: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
},
);
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: result.exitCode === 0 ? 'completed' : 'failed',
content: truncate(result.output, 40_000),
});
if (result.exitCode !== 0) {
throw new Error(`opencode failed:\n${result.output}`);
}
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(result.output);
if (decision) {
await addArtifact({
jobId: claim.job._id,
kind: 'summary',
title: 'Maintenance decision',
content: JSON.stringify(decision, null, 2),
contentType: 'application/json',
});
await applyMaintenanceDecision(claim.job._id, decision);
} else {
await updateStatus(claim.job._id, 'changes_ready', {
summary:
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
});
}
}
const diff = await getWorktreeDiff(repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await recordWorkspaceChange({
jobId: claim.job._id,
path: '.',
source: 'agent',
changeType: 'modified',
diff: truncate(diff.output, 50_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
claim.job._id,
'error',
'cleanup',
truncate(redact(message), 20_000),
);
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'failed',
content: truncate(redact(message), 40_000),
});
throw error;
}
};
export const openWorkspacePullRequest = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact } = workspace;
await ensureNoEnvFilesStaged(workspace);
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes are ready for a draft PR.');
}
const diff = await getWorktreeDiff(repoDir, redact);
const settings = claim.agentSettings;
const detected = await detectPackageCommands(repoDir);
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: 'Interactive Spoon agent workspace changes.',
commands: [installCommand, checkCommand, testCommand].filter(
(command): command is string => Boolean(command),
),
limitations: ['Review the draft PR before merging.'],
});
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Final Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await addArtifact({
jobId: claim.job._id,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
});
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
redact,
timeoutMs: env.jobTimeoutMs,
});
if (!claim.github.installationId) {
throw new Error('GitHub installation ID is missing.');
}
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
});
await completeWithDraftPr({
jobId: claim.job._id,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: 'Draft PR opened from interactive workspace.',
});
await markWorkspaceStopped(claim.job._id);
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
};
};
export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id);
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return { success: true };
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {
+5 -7
View File
@@ -29,11 +29,9 @@ const Index = () => {
api.syncRuns.listRecent,
isAuthenticated ? { limit: 5 } : 'skip',
) ?? [];
const agentRequests =
useQuery(
api.agentRequests.listRecent,
isAuthenticated ? { limit: 5 } : 'skip',
) ?? [];
const threads =
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
[];
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
@@ -101,8 +99,8 @@ const Index = () => {
</View>
<View className='flex-row gap-3'>
<Stat label='Spoons' value={spoons.length} />
<Stat label='Updates' value={syncRuns.length} />
<Stat label='Agents' value={agentRequests.length} />
<Stat label='Checks' value={syncRuns.length} />
<Stat label='Threads' value={threads.length} />
</View>
<View className='border-border bg-card rounded-lg border p-4'>
<Text className='text-foreground font-semibold'>
+3
View File
@@ -21,11 +21,14 @@
},
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@monaco-editor/react": "latest",
"@sentry/nextjs": "^10.46.0",
"@spoon/backend": "workspace:*",
"@spoon/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.11",
"convex": "catalog:convex",
"monaco-editor": "latest",
"monaco-vim": "latest",
"next": "^16.2.1",
"next-plausible": "^3.12.5",
"react": "catalog:react19",
+4 -143
View File
@@ -1,146 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/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 {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@spoon/ui';
const AgentsPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
const createRequest = useMutation(api.agentRequests.create);
const [spoonId, setSpoonId] = useState('');
const [targetBranch, setTargetBranch] = useState('');
const [prompt, setPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId) {
toast.error('Choose a Spoon first.');
return;
}
setSubmitting(true);
try {
await createRequest({
spoonId: spoonId as Id<'spoons'>,
prompt,
targetBranch: targetBranch || undefined,
});
setPrompt('');
setTargetBranch('');
toast.success('Agent request queued.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent request.');
} finally {
setSubmitting(false);
}
const AgentsRedirectPage = () => {
redirect('/threads?source=user_request');
};
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
<p className='text-muted-foreground mt-2'>
Queue prompt-driven work for future AI merge request automation.
</p>
</div>
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Request work</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='targetBranch'>Target branch</Label>
<Input
id='targetBranch'
value={targetBranch}
placeholder='feature/my-change'
onChange={(event) => setTargetBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='prompt'>Prompt</Label>
<Textarea
id='prompt'
value={prompt}
required
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<Button type='submit' disabled={submitting || !spoons.length}>
{submitting ? 'Queueing...' : 'Queue request'}
</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent requests</CardTitle>
</CardHeader>
<CardContent>
{requests.length ? (
<div className='space-y-3'>
{requests.map((request) => (
<div key={request._id} className='border-border border p-4'>
<p className='font-medium'>{request.prompt}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{request.status.replaceAll('_', ' ')} ·{' '}
{(request.requestType ?? 'future_code_change').replaceAll(
'_',
' ',
)}{' '}
· {request.source ?? 'user'}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Agent requests will appear here after you create a Spoon and
queue work.
</p>
)}
</CardContent>
</Card>
</div>
</main>
);
};
export default AgentsPage;
export default AgentsRedirectPage;
+26 -21
View File
@@ -3,9 +3,9 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-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';
@@ -13,9 +13,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active',
).length;
@@ -34,7 +32,8 @@ const DashboardPage = () => {
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
<p className='text-muted-foreground mt-2'>
Monitor managed forks, upstream activity, and queued agent work.
Monitor managed forks, upstream activity, and open maintenance
threads.
</p>
</div>
<Button asChild>
@@ -56,10 +55,17 @@ const DashboardPage = () => {
icon={RefreshCw}
/>
<MetricCard
label='Agent requests'
value={agentRequests.length}
note='Queued and recent'
icon={Bot}
label='Open threads'
value={
threads.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
).length
}
note='Across all Spoons'
icon={MessageSquare}
/>
<MetricCard
label='Upstream commits'
@@ -71,7 +77,7 @@ const DashboardPage = () => {
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
<MaintenanceQueue threads={threads} />
</section>
<div className='grid gap-6 xl:grid-cols-2'>
@@ -126,29 +132,28 @@ const DashboardPage = () => {
</Card>
<Card className='mt-4 shadow-none'>
<CardHeader>
<CardTitle className='text-base'>AI reviews</CardTitle>
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{aiReviews.length ? (
{threads.length ? (
<div className='space-y-3'>
{aiReviews.map((review) => (
{threads.slice(0, 5).map((thread) => (
<div
key={review._id}
key={thread._id}
className='border-border border p-3 text-sm'
>
<p className='font-medium capitalize'>
{review.risk} risk
</p>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground'>
{review.outputSummary ?? review.inputSummary}
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
OpenAI compatibility reviews will appear here after you run
them on a Spoon.
Threads appear when you request work or upstream changes need
review.
</p>
)}
</CardContent>
@@ -0,0 +1,16 @@
import { AiProviderProfilesPanel } from '@/components/integrations/ai-provider-profiles-panel';
const AiProvidersPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI providers</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure encrypted API-key profiles and OpenCode auth profiles for
agent workspaces.
</p>
</div>
<AiProviderProfilesPanel />
</section>
);
export default AiProvidersPage;
+2 -13
View File
@@ -1,16 +1,5 @@
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
import { redirect } from 'next/navigation';
const AiSettingsPage = () => (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>AI</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Configure the OpenAI key, review model, and thinking level used for
compatibility reviews.
</p>
</div>
<OpenAiStatusPanel />
</section>
);
const AiSettingsPage = () => redirect('/settings/ai-providers');
export default AiSettingsPage;
+1 -1
View File
@@ -10,7 +10,7 @@ import { cn } from '@spoon/ui';
const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai', label: 'AI', icon: Brain },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,27 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { ArrowLeft } from 'lucide-react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
const AgentWorkspacePage = () => {
const params = useParams<{ spoonId: string; jobId: string }>();
return (
<main className='space-y-4'>
<Button asChild variant='ghost' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>
<ArrowLeft className='size-4' />
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
</main>
);
};
export default AgentWorkspacePage;
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentJobList } from '@/components/agents/agent-job-list';
import { AgentRequestForm } from '@/components/agents/agent-request-form';
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
@@ -46,12 +46,10 @@ const SpoonDetailPage = () => {
}) ?? [];
const pullRequests =
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
const reviews =
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
const syncRuns =
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
const threads =
useQuery(api.threads.listForSpoon, { spoonId, limit: 25 }) ?? [];
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
spoonId,
});
@@ -68,7 +66,7 @@ const SpoonDetailPage = () => {
<SpoonMetrics
spoon={details.spoon}
state={details.state}
latestReview={details.latestReview}
latestThread={threads[0]}
/>
{details.spoon.lastError ? (
<Card className='border-destructive shadow-none'>
@@ -95,11 +93,8 @@ const SpoonDetailPage = () => {
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
Pull requests
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
AI review
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
Agent work
<TabsTrigger className='h-9 flex-none px-3' value='threads'>
Threads
</TabsTrigger>
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
Activity
@@ -125,6 +120,17 @@ const SpoonDetailPage = () => {
'unknown'
).replaceAll('_', ' ')}
</p>
{details.effectiveUpstreamAheadBy === 0 &&
(details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy ??
0) > 0 ? (
<p className='text-muted-foreground mt-1 text-xs'>
Up to date after ignored upstream changes. Raw upstream
ahead:{' '}
{details.state?.upstreamAheadBy ??
details.spoon.upstreamAheadBy}
</p>
) : null}
</div>
<div>
<p className='text-muted-foreground'>Default branches</p>
@@ -155,37 +161,34 @@ const SpoonDetailPage = () => {
</Card>
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Latest AI review</CardTitle>
<CardTitle className='text-base'>Latest thread</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
{details.latestReview ? (
{threads[0] ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div>
<p className='text-muted-foreground'>Risk</p>
<p className='text-muted-foreground'>Status</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.risk}
{threads[0].status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground'>Action</p>
<p className='text-muted-foreground'>Source</p>
<p className='mt-1 font-semibold capitalize'>
{details.latestReview.recommendedAction.replaceAll(
'_',
' ',
)}
{threads[0].source.replaceAll('_', ' ')}
</p>
</div>
</div>
<p className='text-muted-foreground'>
{details.latestReview.outputSummary ??
details.latestReview.inputSummary}
{threads[0].summary ??
'Open the thread to continue maintenance work.'}
</p>
</>
) : (
<p className='text-muted-foreground'>
Run a refresh and AI review to get a compatibility summary
for upstream changes.
Refresh GitHub state or create a thread to start maintenance
work for this Spoon.
</p>
)}
</CardContent>
@@ -239,26 +242,45 @@ const SpoonDetailPage = () => {
<SpoonPrList pullRequests={pullRequests} />
</TabsContent>
<TabsContent value='ai' className='space-y-4'>
<SpoonAiReviewPanel
latestReview={details.latestReview}
reviews={reviews}
/>
</TabsContent>
<TabsContent value='agent' className='space-y-4'>
<TabsContent value='threads' className='space-y-4'>
<AgentRequestForm
spoon={details.spoon}
agentSettings={agentSettings}
/>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Spoon threads</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
>
<p className='font-medium'>{thread.title}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{thread.status.replaceAll('_', ' ')} ·{' '}
{thread.source.replaceAll('_', ' ')}
</p>
</Link>
))
) : (
<p className='text-muted-foreground text-sm'>
No threads exist for this Spoon yet.
</p>
)}
</CardContent>
</Card>
<AgentJobList jobs={agentJobs} />
</TabsContent>
<TabsContent value='activity'>
<SpoonActivityTimeline
syncRuns={syncRuns}
reviews={reviews}
requests={agentRequests}
threads={threads}
jobs={agentJobs}
/>
</TabsContent>
+166 -8
View File
@@ -1,33 +1,185 @@
'use client';
import Link from 'next/link';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
GitBranch,
MessageSquare,
RefreshCw,
} from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
import {
Badge,
Button,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
0,
);
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
<p className='text-muted-foreground mt-2'>
Managed forks you want to keep close to their upstream projects.
Managed forks, upstream drift, active maintenance threads, and fork
metadata in one place.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>New Spoon</Link>
</Button>
</div>
{spoons.length ? (
<div className='grid gap-4 xl:grid-cols-2'>
{spoons.map((spoon) => (
<SpoonCard key={spoon._id} spoon={spoon} />
))}
<div className='grid gap-3 md:grid-cols-3'>
<Card className='shadow-none'>
<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>
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
<p className='text-2xl font-semibold'>{active}</p>
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
</Card>
<Card className='shadow-none'>
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
<p className='text-2xl font-semibold'>{needsReview}</p>
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='pl-4'>Spoon</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fork</TableHead>
<TableHead>Drift</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Last checked</TableHead>
<TableHead className='pr-4 text-right'>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spoons.map((spoon) => {
const href = `/spoons/${spoon._id}`;
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);
}
}}
>
<TableCell className='pl-4'>
<Link
href={href}
className='group inline-flex min-w-0 flex-col'
onClick={(event) => event.stopPropagation()}
>
<span className='group-hover:text-primary font-medium transition-colors'>
{spoon.name}
</span>
<span className='text-muted-foreground text-xs'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</span>
</Link>
</TableCell>
<TableCell>
<SpoonStatusBadge
status={spoon.syncStatus ?? spoon.status}
/>
</TableCell>
<TableCell>
{spoon.forkOwner && spoon.forkRepo ? (
<span className='font-medium'>
{spoon.forkOwner}/{spoon.forkRepo}
</span>
) : (
<Badge variant='outline'>Missing metadata</Badge>
)}
</TableCell>
<TableCell>
<div className='text-sm'>
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
<p className='text-muted-foreground'>
{spoon.forkAheadBy ?? 0} fork-only
</p>
</div>
</TableCell>
<TableCell className='capitalize'>
{spoon.syncCadence}
</TableCell>
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
<TableCell className='pr-4 text-right'>
<Button size='sm' variant='outline' asChild>
<Link
href={href}
onClick={(event) => event.stopPropagation()}
>
Open
<ArrowUpRight className='size-3' />
</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card className='shadow-none'>
<CardContent className='p-8'>
@@ -42,6 +194,12 @@ const SpoonsPage = () => {
</CardContent>
</Card>
)}
{spoons.length ? (
<p className='text-muted-foreground text-sm'>
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
</p>
) : null}
</main>
);
};
@@ -0,0 +1,188 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, XCircle } 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,
Textarea,
} from '@spoon/ui';
const ThreadDetailPage = () => {
const params = useParams<{ threadId: string }>();
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 markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel);
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
const { thread, spoon, latestJob } = details;
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = form.get('message');
const content = typeof value === 'string' ? value : '';
try {
await appendMessage({ threadId, content });
event.currentTarget.reset();
toast.success('Message added.');
} catch (error) {
console.error(error);
toast.error('Could not add message.');
}
};
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<h1 className='text-3xl font-semibold tracking-normal'>
{thread.title}
</h1>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-2 max-w-3xl'>
{thread.summary ?? 'No summary has been recorded yet.'}
</p>
{spoon ? (
<Button variant='link' className='mt-2 h-auto p-0' asChild>
<Link href={`/spoons/${spoon._id}`}>
{spoon.name}
<ArrowUpRight className='size-3' />
</Link>
</Button>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
{latestJob ? (
<Button variant='outline' asChild>
<Link
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
>
Open workspace
</Link>
</Button>
) : null}
{latestJob?.pullRequestUrl ? (
<Button asChild>
<a
href={latestJob.pullRequestUrl}
target='_blank'
rel='noreferrer'
>
Open PR
</a>
</Button>
) : null}
<Button
variant='outline'
onClick={() =>
markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
)
}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() =>
cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
)
}
>
<XCircle className='size-4' />
Cancel
</Button>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{messages.map((message) => (
<div
key={message._id}
className='border-border rounded-md border p-3'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<Badge variant='outline'>{message.role}</Badge>
<span className='text-muted-foreground text-xs'>
{new Date(message.createdAt).toLocaleString()}
</span>
</div>
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
</div>
))}
<form onSubmit={submit} className='space-y-3'>
<Textarea
name='message'
required
minLength={2}
placeholder='Add context or instructions for this thread.'
/>
<Button type='submit'>Add message</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Thread state</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<div>
<p className='text-muted-foreground'>Priority</p>
<p className='font-medium capitalize'>{thread.priority}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream range</p>
<p className='font-mono text-xs break-all'>
{thread.upstreamFrom ?? 'unknown'} {' '}
{thread.upstreamTo ?? 'unknown'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Latest job</p>
<p className='font-medium'>
{latestJob?.status.replaceAll('_', ' ') ?? 'No job queued'}
</p>
</div>
</CardContent>
</Card>
</div>
</main>
);
};
export default ThreadDetailPage;
+128
View File
@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => {
const params = useSearchParams();
const source = params.get('source') ?? 'all';
const threads =
useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
| 'upstream_update'
| 'merge_conflict'
| 'manual_review'
| 'system',
limit: 100,
}) ?? [];
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Threads</h1>
<p className='text-muted-foreground mt-2'>
Maintenance reviews, upstream decisions, and user-requested fork
work across all Spoons.
</p>
</div>
<Button asChild>
<Link href='/spoons'>
<Plus className='size-4' />
New thread from Spoon
</Link>
</Button>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select
value={source}
onValueChange={(value) => {
window.location.href =
value === 'all' ? '/threads' : `/threads?source=${value}`;
}}
>
<SelectTrigger className='w-full md:w-56'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All sources</SelectItem>
<SelectItem value='user_request'>User requests</SelectItem>
<SelectItem value='upstream_update'>Upstream updates</SelectItem>
<SelectItem value='merge_conflict'>Merge conflicts</SelectItem>
<SelectItem value='manual_review'>Manual review</SelectItem>
<SelectItem value='system'>System</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
key={thread._id}
href={`/threads/${thread._id}`}
className='block'
>
<Card className='hover:border-primary/50 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>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
</div>
</CardContent>
</Card>
</Link>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground flex items-center gap-3 p-6 text-sm'>
<MessageSquare className='size-4' />
Threads appear when you ask Spoon to change a fork or when
upstream changes need review.
</CardContent>
</Card>
)}
</div>
</main>
);
};
export default ThreadsPage;
+4 -85
View File
@@ -1,88 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const UpdatesPage = () => {
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
<p className='text-muted-foreground mt-2'>
Upstream checks, merge attempts, and AI reviews will appear here.
</p>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-48'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='needs_review'>Needs review</SelectItem>
<SelectItem value='conflict'>Conflict</SelectItem>
<SelectItem value='clean'>Clean</SelectItem>
</SelectContent>
</Select>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-64'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent sync runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length ? (
<div className='space-y-3'>
{runs.map((run) => (
<div key={run._id} className='border-border border p-4'>
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
<p className='text-muted-foreground text-sm'>
{run.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Scheduled upstream checks will appear here once provider
connections and workers are added.
</p>
)}
</CardContent>
</Card>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue spoons={spoons} />
</section>
</main>
);
const UpdatesRedirectPage = () => {
redirect('/threads?source=upstream_update');
};
export default UpdatesPage;
export default UpdatesRedirectPage;
@@ -0,0 +1,14 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'run-command', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'diff', { method: 'GET' }),
);
@@ -0,0 +1,28 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(context, async (jobId) => {
const url = new URL(request.url);
return await proxyWorker(
jobId,
'file',
{ method: 'GET' },
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
);
});
export const PUT = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'file', {
method: 'PUT',
body: await request.text(),
}),
);
@@ -0,0 +1,14 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'message', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'open-pr', { method: 'POST' }),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'stop', { method: 'POST' }),
);
@@ -0,0 +1,10 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) => await proxyWorker(jobId, 'tree', { method: 'GET' }),
);
+6 -2
View File
@@ -1,10 +1,12 @@
import {
Agents,
CTA,
Features,
Hero,
MaintenanceDecisions,
Security,
ThreadedWork,
Workflow,
WorkspaceShowcase,
} from '@/components/landing';
const Home = () => (
@@ -12,7 +14,9 @@ const Home = () => (
<Hero />
<Workflow />
<Features />
<Agents />
<MaintenanceDecisions />
<ThreadedWork />
<WorkspaceShowcase />
<Security />
<CTA />
</main>
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
disabled,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
disabled: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const send = async () => {
if (!content.trim()) return;
setSending(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error(await response.text());
setContent('');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages persist with this workspace.
</p>
</div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<span className='text-muted-foreground text-xs capitalize'>
{message.status}
</span>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
</article>
))}
</div>
<div className='border-border space-y-2 border-t p-3'>
<Textarea
value={content}
placeholder='Ask the agent to inspect, explain, or change this fork.'
disabled={disabled || sending}
onChange={(event) => setContent(event.target.value)}
/>
<Button
type='button'
className='w-full'
disabled={disabled || sending || !content.trim()}
onClick={send}
>
<Send className='size-4' />
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,153 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [diff, setDiff] = useState('');
const workspaceDisabled =
!job ||
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
job.status,
) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const loadTree = useCallback(async () => {
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 };
setTree(data.tree);
}, [jobId]);
const loadDiff = useCallback(async () => {
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;
setDiff(data.diff);
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setSelectedPath(data.path);
setFileContent(data.content);
},
[jobId],
);
useEffect(() => {
if (!job) return;
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
});
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
const saveFile = async (content: string) => {
if (!selectedPath) return;
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
throw new Error(await response.text());
}
setFileContent(content);
await loadDiff();
toast.success('File saved.');
};
return (
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
<aside className='border-border bg-background min-h-[260px] border-r'>
<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>
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
/>
</aside>
<section className='bg-background min-w-0'>
<Tabs defaultValue='editor' className='h-full'>
<TabsList
variant='line'
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
>
<TabsTrigger value='editor'>Editor</TabsTrigger>
<TabsTrigger value='diff'>Diff</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0'>
<CodeEditor
path={selectedPath}
content={fileContent}
readOnly={workspaceDisabled}
onSave={saveFile}
/>
</TabsContent>
<TabsContent value='diff' className='m-0'>
<DiffViewer diff={diff} onRefresh={loadDiff} />
</TabsContent>
</Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section>
<aside className='border-border bg-muted/20 min-w-0 border-l'>
<AgentThread
jobId={jobId}
messages={messages}
disabled={workspaceDisabled}
/>
</aside>
</div>
</main>
);
};
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
type MonacoEditorInstance = {
getModel?: () => unknown;
};
type VimMode = {
dispose: () => void;
};
export const CodeEditor = ({
path,
content,
readOnly,
onSave,
}: {
path?: string;
content: string;
readOnly: boolean;
onSave: (content: string) => Promise<void>;
}) => {
const [value, setValue] = useState(content);
const [saving, setSaving] = useState(false);
const [vimEnabled, setVimEnabled] = useState(false);
const [dirty, setDirty] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setValue(content);
setDirty(false);
}, [content, path]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
vimRef.current?.dispose();
vimRef.current = null;
if (!vimEnabled) return;
void import('monaco-vim').then((module) => {
const initVimMode = module.initVimMode as unknown as (
editor: MonacoEditorInstance,
statusNode?: HTMLElement | null,
) => VimMode;
vimRef.current = initVimMode(editor, statusRef.current);
});
return () => {
vimRef.current?.dispose();
vimRef.current = null;
};
}, [vimEnabled, path]);
if (!path) {
return (
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
Select a file to inspect or edit.
</div>
);
}
const save = async () => {
setSaving(true);
try {
await onSave(value);
setDirty(false);
} finally {
setSaving(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
</label>
<Button
type='button'
size='sm'
disabled={readOnly || saving || !dirty}
onClick={save}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1'>
<MonacoEditor
height='520px'
path={path}
value={value}
theme='vs-dark'
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
}}
/>
</div>
<div
ref={statusRef}
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
/>
</div>
);
};
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Input } from '@spoon/ui';
export const CommandPanel = ({
jobId,
disabled,
}: {
jobId: string;
disabled: boolean;
}) => {
const [command, setCommand] = useState('');
const [running, setRunning] = useState(false);
const run = async () => {
setRunning(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
method: 'POST',
body: JSON.stringify({ command }),
});
if (!response.ok) throw new Error(await response.text());
toast.success('Command completed.');
setCommand('');
} catch (error) {
console.error(error);
toast.error('Command failed.');
} finally {
setRunning(false);
}
};
return (
<div className='border-border flex items-center gap-2 border-t p-3'>
<Terminal className='text-muted-foreground size-4' />
<Input
value={command}
placeholder='bun test, pnpm lint, npm run typecheck...'
disabled={disabled || running}
onChange={(event) => setCommand(event.target.value)}
/>
<Button
type='button'
variant='outline'
disabled={disabled || running || !command.trim()}
onClick={run}
>
{running ? 'Running...' : 'Run'}
</Button>
</div>
);
};
@@ -0,0 +1,47 @@
'use client';
import dynamic from 'next/dynamic';
import { Button } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground text-xs'>Current git diff</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
{diff.trim() ? (
<MonacoEditor
height='520px'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
@@ -0,0 +1,83 @@
'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react';
import { Button } from '@spoon/ui';
import type { FileTreeNode } from './types';
const TreeNode = ({
node,
selectedPath,
onSelect,
depth = 0,
}: {
node: FileTreeNode;
selectedPath?: string;
onSelect: (path: string) => void;
depth?: number;
}) => {
if (node.type === 'directory') {
return (
<div>
{node.path ? (
<div
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }}
>
<ChevronRight className='size-3' />
<Folder className='size-3' />
<span className='truncate'>{node.name}</span>
</div>
) : null}
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
onSelect={onSelect}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
</div>
);
}
return (
<Button
type='button'
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onSelect(node.path)}
>
<FileCode className='size-3 flex-none' />
<span className='truncate'>{node.name}</span>
</Button>
);
};
export const FileTree = ({
tree,
selectedPath,
onSelect,
}: {
tree: FileTreeNode | null;
selectedPath?: string;
onSelect: (path: string) => void;
}) => {
if (!tree) {
return (
<p className='text-muted-foreground p-3 text-sm'>
Workspace files are not available yet.
</p>
);
}
return (
<div className='overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
</div>
);
};
@@ -0,0 +1,24 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '@spoon/ui';
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
<div className='min-w-0'>
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
<p className='text-muted-foreground truncate font-mono text-xs'>
{job.baseBranch} {'->'} {job.workBranch}
</p>
</div>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
<Badge variant='secondary' className='capitalize'>
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
</Badge>
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
</div>
</div>
);
@@ -0,0 +1,15 @@
export type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
export type FileResponse = {
path: string;
content: string;
};
export type DiffResponse = {
diff: string;
};
@@ -0,0 +1,68 @@
'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const WorkspaceActions = ({
job,
disabled,
}: {
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const openPr = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Draft PR opened.');
} catch (error) {
console.error(error);
toast.error('Could not open draft PR.');
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Workspace stopped.');
} catch (error) {
console.error(error);
toast.error('Could not stop workspace.');
}
};
return (
<div className='flex flex-wrap items-center gap-2'>
{job.pullRequestUrl ? (
<Button asChild variant='outline' size='sm'>
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
<ExternalLink className='size-4' />
Open PR
</a>
</Button>
) : null}
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
<GitPullRequestDraft className='size-4' />
Open draft PR
</Button>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled}
onClick={stop}
>
<Square className='size-4' />
Stop
</Button>
</div>
);
};
@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, XCircle } from 'lucide-react';
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -101,6 +102,14 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
Cancel job
</Button>
) : null}
<Button asChild>
<Link
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
>
<MonitorUp className='size-4' />
Open workspace
</Link>
</Button>
<AgentJobDetail job={selectedJob} />
</div>
) : null}
@@ -15,15 +15,24 @@ import {
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
import { SecretSelector } from './secret-selector';
type AgentSettings = {
defaultBaseBranch?: string;
runtime?: 'opencode' | 'openai_direct';
agentModel: string;
reasoningEffort: string;
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
export const AgentRequestForm = ({
@@ -33,11 +42,16 @@ export const AgentRequestForm = ({
spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null;
}) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
const secrets =
useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
});
const createRequest = useMutation(api.agentRequests.create);
const createJob = useMutation(api.agentJobs.createFromRequest);
}) ?? [];
const profiles =
useQuery(api.aiProviderProfiles.listMine, {})?.filter(
(profile) => profile.enabled && profile.configured,
) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const createThread = useMutation(api.threads.createUserThread);
const [prompt, setPrompt] = useState('');
const [baseBranch, setBaseBranch] = useState(
agentSettings?.defaultBaseBranch ??
@@ -45,30 +59,52 @@ export const AgentRequestForm = ({
spoon.upstreamDefaultBranch,
);
const [requestedBranchName, setRequestedBranchName] = useState('');
const [selectedSecretIds, setSelectedSecretIds] = useState<
Id<'spoonSecrets'>[]
>([]);
const [materializeEnvFile, setMaterializeEnvFile] = useState(
agentSettings?.materializeEnvFileByDefault ?? false,
);
const [envFilePath, setEnvFilePath] = useState(
agentSettings?.envFilePath === 'custom'
? (agentSettings.customEnvFilePath ?? '.env.local')
: (agentSettings?.envFilePath ?? '.env.local'),
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
agentSettings?.aiProviderProfileId ?? '__settings',
);
const [submitting, setSubmitting] = useState(false);
const effectiveProviderProfileId =
aiProviderProfileId === '__settings'
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: aiProviderProfileId;
const hasProvider = Boolean(
effectiveProviderProfileId &&
profiles.some((profile) => profile._id === effectiveProviderProfileId),
);
const selectedProfile = profiles.find((profile) =>
aiProviderProfileId === '__settings'
? profile._id ===
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: profile._id === aiProviderProfileId,
);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
const requestId = await createRequest({
await createThread({
spoonId: spoon._id,
prompt,
targetBranch: baseBranch,
});
await createJob({
requestId,
selectedSecretIds,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
materializeEnvFile,
envFilePath: materializeEnvFile ? envFilePath : undefined,
aiProviderProfileId:
aiProviderProfileId === '__settings'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
});
setPrompt('');
setRequestedBranchName('');
setSelectedSecretIds([]);
toast.success('Agent job queued.');
toast.success('Thread created.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent job.');
@@ -99,6 +135,32 @@ export const AgentRequestForm = ({
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Workspace runtime</Label>
<Input value='OpenCode workspace' disabled />
</div>
<div className='grid gap-2'>
<Label>AI provider</Label>
<Select
value={aiProviderProfileId}
onValueChange={setAiProviderProfileId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__settings'>
Use default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{profiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='baseBranch'>Base branch</Label>
<Input
@@ -117,26 +179,45 @@ export const AgentRequestForm = ({
/>
</div>
</div>
<div className='grid gap-2'>
<Label>Secrets exposed to this job</Label>
<SecretSelector
secrets={secrets ?? []}
selectedSecretIds={selectedSecretIds}
onChange={setSelectedSecretIds}
<div className='grid gap-3 md:grid-cols-[1fr_1fr]'>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Write Spoon secrets to env file</Label>
<p className='text-muted-foreground text-xs'>
All {secrets.length} Spoon secret(s) are available as process
env. When enabled, Spoon also writes them to this file and
refuses to commit .env files.
</p>
</div>
<Switch
checked={materializeEnvFile}
onCheckedChange={setMaterializeEnvFile}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='envFilePath'>Env file path</Label>
<Input
id='envFilePath'
value={envFilePath}
disabled={!materializeEnvFile}
onChange={(event) => setEnvFilePath(event.target.value)}
/>
</div>
</div>
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
<span>
Model:{' '}
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
<strong>
{selectedProfile?.defaultModel ?? 'Configure an AI provider'}
</strong>
</span>
<span>
Reasoning:{' '}
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
</span>
</div>
<Button type='submit' disabled={submitting}>
{submitting ? 'Queueing...' : 'Queue agent job'}
<Button type='submit' disabled={submitting || !hasProvider}>
{submitting ? 'Creating...' : 'Create thread'}
</Button>
</form>
</CardContent>
@@ -1,60 +0,0 @@
'use client';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Checkbox, Label } from '@spoon/ui';
type Secret = {
_id: Id<'spoonSecrets'>;
name: string;
valuePreview?: string;
description?: string;
};
export const SecretSelector = ({
secrets,
selectedSecretIds,
onChange,
}: {
secrets: Secret[];
selectedSecretIds: Id<'spoonSecrets'>[];
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
}) => {
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
onChange(
checked
? [...selectedSecretIds, secretId]
: selectedSecretIds.filter((id) => id !== secretId),
);
};
if (!secrets.length) {
return (
<p className='text-muted-foreground text-sm'>
No Spoon secrets saved. Add project secrets in Settings when a job needs
environment variables.
</p>
);
}
return (
<div className='grid gap-2'>
{secrets.map((secret) => (
<label
key={secret._id}
className='border-border flex items-start gap-3 rounded-md border p-3'
>
<Checkbox
checked={selectedSecretIds.includes(secret._id)}
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
/>
<span className='grid gap-1'>
<Label className='font-mono text-xs'>{secret.name}</Label>
<span className='text-muted-foreground text-xs'>
{secret.description ?? secret.valuePreview ?? 'Configured'}
</span>
</span>
</label>
))}
</div>
);
};
@@ -0,0 +1,451 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } 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 {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
type Provider =
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveProfileRef = makeFunctionReference<
'action',
{
profileId?: Id<'aiProviderProfiles'>;
name: string;
provider: Provider;
authType: AuthType;
secret?: string;
baseUrl?: string;
defaultModel: string;
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
Id<'aiProviderProfiles'>
>('aiProviderProfilesNode:save');
const setDefaultProfileRef = makeFunctionReference<
'mutation',
{ profileId: Id<'aiProviderProfiles'> },
{ success: true }
>('aiProviderProfiles:setDefault');
const providerOptions: {
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' },
{
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
value: 'opencode_openai_login',
label: 'OpenCode OpenAI login',
authType: 'opencode_auth_json',
},
];
const reasoningOptions: ReasoningEffort[] = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
];
export const AiProviderProfilesPanel = () => {
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const saveProfile = useAction(saveProfileRef);
const setDefaultProfile = useMutation(setDefaultProfileRef);
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [provider, setProvider] = useState<Provider>('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState('');
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('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 reset = () => {
setProfileId(undefined);
setProvider('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
};
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
try {
await saveProfile({
profileId,
name,
provider,
authType: selectedProvider.authType,
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
reasoningEffort,
enabled,
});
toast.success('AI provider saved.');
reset();
} catch (error) {
console.error(error);
toast.error('Could not save AI provider.');
} finally {
setSaving(false);
}
};
const selectedProfile = profileId
? profiles.find((profile) => profile._id === profileId)
: undefined;
const hasCredential =
selectedProvider.authType === 'none' ||
Boolean(secret.trim()) ||
Boolean(selectedProfile?.configured);
const canSelectModel = hasCredential && modelOptions.length > 0;
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
return (
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' />
Provider profiles
</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{configuredProfiles.length > 1 ? (
<div className='grid gap-2 rounded-md border p-3'>
<Label>Default provider</Label>
<Select
value={defaultProfile?._id ?? ''}
onValueChange={async (value) => {
await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>,
});
toast.success('Default AI provider updated.');
}}
>
<SelectTrigger>
<SelectValue placeholder='Choose default provider' />
</SelectTrigger>
<SelectContent>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.defaultModel}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Spoons using account default will use this provider.
</p>
</div>
) : null}
{profiles.length ? (
profiles.map((profile) => (
<div
key={profile._id}
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
>
<button
type='button'
className='min-w-0 text-left'
onClick={() => edit(profile)}
>
<p className='truncate text-sm font-medium'>{profile.name}</p>
<p className='text-muted-foreground text-xs'>
{profile.provider.replaceAll('_', ' ')} ·{' '}
{profile.secretPreview ?? 'not configured'} ·{' '}
{profile.defaultModel}
{profile.isDefault ? ' · default' : ''}
</p>
</button>
<Button
type='button'
variant='ghost'
size='icon'
aria-label='Remove provider'
onClick={async () => {
await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.');
}}
>
<Trash2 className='size-4' />
</Button>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>
Add API-key providers for OpenCode, or store an OpenCode OpenAI
login profile for the next auth-file injection pass.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>
{profileId ? 'Edit provider' : 'Add provider'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={save} className='space-y-4'>
<div className='grid gap-2'>
<Label>Name</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={provider}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'OpenCode auth JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full auth.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Codex auth file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.codex/auth.json
</code>
. It is stored encrypted and should be treated like a
password.
</p>
</>
) : (
<Input
type='password'
value={secret}
placeholder={
profileId ? 'Leave blank to keep current secret' : 'sk-...'
}
onChange={(event) => setSecret(event.target.value)}
/>
)}
</div>
<div className='grid gap-2'>
<Label>Base URL</Label>
<Input
value={baseUrl}
placeholder='Optional for LiteLLM, Requesty, custom providers'
onChange={(event) => setBaseUrl(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Default model</Label>
<Select
value={defaultModelValue}
onValueChange={setDefaultModelValue}
disabled={!canSelectModel}
>
<SelectTrigger>
<SelectValue
placeholder={
hasCredential
? 'Choose a model'
: 'Add credentials first'
}
/>
</SelectTrigger>
<SelectContent>
{modelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Models are loaded from Models.dev, the catalog OpenCode uses
for provider/model metadata.
</p>
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Enabled</Label>
<p className='text-muted-foreground text-xs'>
Disabled profiles cannot be selected for new jobs.
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='submit'
disabled={saving || !hasCredential || !defaultModelValue}
>
{saving ? 'Saving...' : 'Save provider'}
</Button>
{profileId ? (
<Button type='button' variant='outline' onClick={reset}>
New provider
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
</div>
);
};
@@ -1,197 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveOpenAiSettingsRef = makeFunctionReference<
'action',
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
{ success: true }
>('aiSettingsNode:saveOpenAiSettings');
const modelOptions = [
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
];
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' },
];
export const OpenAiStatusPanel = () => {
const status = useQuery(api.integrations.getStatus, {});
const settings = useQuery(api.aiSettings.getMine, {});
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('gpt-5.5');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!settings) return;
setModel(settings.model);
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
}, [settings]);
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
if (apiKey.trim()) {
await saveOpenAiSettings({
apiKey,
model,
reasoningEffort,
});
setApiKey('');
} else {
await updatePreferences({
model,
reasoningEffort,
});
}
toast.success('OpenAI settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save OpenAI settings.');
} finally {
setSubmitting(false);
}
};
const remove = async () => {
try {
await removeOpenAiKey({});
toast.success('OpenAI API key removed.');
} catch (error) {
console.error(error);
toast.error('Could not remove OpenAI API key.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Brain className='size-4' />
OpenAI reviews
</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<p className='text-muted-foreground'>
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
key before storing it and only shows a short preview.
</p>
<div>
<p className='text-muted-foreground'>Encryption</p>
<p className='font-medium'>
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
</p>
</div>
<div>
<p className='text-muted-foreground'>OpenAI API key</p>
<p className='font-medium'>
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
</p>
</div>
<form onSubmit={save} className='space-y-4 pt-2'>
<div className='grid gap-2'>
<Label htmlFor='openai-api-key'>API key</Label>
<Input
id='openai-api-key'
type='password'
value={apiKey}
placeholder={
settings?.configured
? 'Leave blank to keep current key'
: 'sk-...'
}
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className='grid gap-2 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Review model</Label>
<Select value={model} onValueChange={(value) => setModel(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(value as ReasoningEffort)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button type='submit' disabled={submitting}>
{submitting ? 'Saving...' : 'Save OpenAI settings'}
</Button>
<Button
type='button'
variant='outline'
onClick={remove}
disabled={!settings?.configured}
>
Remove key
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
+5 -4
View File
@@ -15,16 +15,17 @@ export const CTA = () => {
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
<div>
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
Keep the fork. Lose the maintenance dread.
Fork the project. Keep the relationship.
</h2>
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
Create your first Spoon, connect GitHub, and make upstream drift
something you can see, review, and act on.
Create your first Spoon, connect GitHub, and let upstream
maintenance become a visible thread instead of a lonely recurring
chore.
</p>
</div>
<Button variant='secondary' size='lg' asChild>
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
{isAuthenticated ? 'Create a Spoon' : 'Start with Spoon'}
<ArrowRight className='size-4' />
</Link>
</Button>
+207 -127
View File
@@ -4,107 +4,135 @@ import {
GitBranch,
GitCompare,
GitPullRequest,
History,
KeyRound,
LockKeyhole,
MessagesSquare,
RefreshCw,
ServerCog,
Sparkles,
ShieldCheck,
} from 'lucide-react';
import { Badge } from '@spoon/ui';
const workflow = [
{
title: 'Connect GitHub',
title: 'Create the Spoon',
description:
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
'Register the upstream project, your GitHub fork, default branches, clone URLs, and any extra remotes you want visible.',
},
{
title: 'Create a Spoon',
title: 'Watch upstream',
description:
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
'Spoon intermittently checks the upstream default branch and compares it against the current fork state.',
},
{
title: 'Watch drift',
title: 'Auto-sync clean drift',
description:
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
'If the fork has no custom commits and upstream moved, Spoon can fast-forward the fork without turning it into a chore.',
},
{
title: 'Review safely',
title: 'Open a thread when it matters',
description:
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
'If your fork has custom commits, Spoon creates a durable maintenance thread instead of guessing.',
},
{
title: 'Ship through PRs',
title: 'Resolve with context',
description:
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
'Review commits, changed files, pull requests, fork-only work, ignored upstream changes, and workspace output together.',
},
{
title: 'Ship through draft PRs',
description:
'When code is needed, OpenCode works in an isolated workspace and hands changes back as a draft PR.',
},
];
const features = [
{
title: 'Project dashboards',
title: 'Spoon dashboards',
description:
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
'See drift, fork-only commits, pull requests, clone URLs, extra remotes, threads, activity, and settings for each managed fork.',
icon: GitCompare,
},
{
title: 'Upstream maintenance queue',
title: 'Thread-first maintenance',
description:
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
'Every review, conflict, ignore decision, and requested code change has a durable conversation attached to it.',
icon: MessagesSquare,
},
{
title: 'Effective drift',
description:
'Spoon shows raw upstream state and the effective maintenance state after intentional ignore decisions.',
icon: RefreshCw,
},
{
title: 'Pull request visibility',
title: 'OpenCode workspaces',
description:
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
icon: GitPullRequest,
},
{
title: 'AI compatibility review',
description:
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
icon: Sparkles,
},
{
title: 'Per-user AI settings',
description:
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
icon: KeyRound,
},
{
title: 'Agent job foundation',
description:
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
icon: Bot,
},
];
const builtFor = [
{
title: 'Self-hosted by design',
description:
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
icon: ServerCog,
},
{
title: 'Secrets stay deliberate',
description:
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
icon: LockKeyhole,
},
{
title: 'Outside work is expected',
description:
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
'Open a file tree, browser editor, diff view, job logs, command panel, and thread context when a fork needs code.',
icon: Code2,
},
{
title: 'History stays inspectable',
title: 'Provider-owned AI',
description:
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
icon: History,
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
icon: KeyRound,
},
{
title: 'Draft PR handoff',
description:
'Agent work becomes a branch and draft pull request. Spoon does not auto-merge custom forks behind your back.',
icon: GitPullRequest,
},
];
const decisions = [
{
condition: 'No fork-only commits + upstream ahead',
action: 'Auto-sync',
explanation: 'The fork is still close enough to fast-forward.',
},
{
condition: 'Fork-only commits + upstream ahead',
action: 'Create thread',
explanation: 'Spoon reviews whether upstream affects custom work.',
},
{
condition: 'Merge conflicts',
action: 'Open workspace',
explanation: 'Resolve in an isolated worker and ship a draft PR.',
},
{
condition: 'Irrelevant upstream changes',
action: 'Ignore intentionally',
explanation: 'Record why those commits no longer matter to this fork.',
},
];
const ownership = [
{
title: 'Your GitHub App',
description:
'GitHub remains the active source of truth for forks, branches, compares, and draft PRs.',
icon: GitBranch,
},
{
title: 'Your providers',
description:
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
icon: ShieldCheck,
},
{
title: 'Your secrets',
description:
'Project secrets are per Spoon, redacted in logs, and refused from commits when materialized.',
icon: LockKeyhole,
},
{
title: 'Your workflow',
description:
'Local commits, Gitea mirrors, CI changes, and direct GitHub edits are expected parts of the loop.',
icon: ServerCog,
},
];
@@ -116,44 +144,42 @@ export const Workflow = () => (
Workflow
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Forking should not mean drifting alone.
Forking should start a relationship, not a support burden.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Spoon treats a fork as an ongoing relationship with upstream. The
product keeps the original project, your custom work, and future
automation visible in one place.
Spoon keeps watching the upstream project after the fork. When
upstream moves, it decides whether your fork can fast-forward, needs a
maintenance thread, or should ignore changes that no longer matter to
your version. Forking a project should not mean supporting it alone.
</p>
</div>
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
<div className='grid gap-8 lg:grid-cols-[0.75fr_1.25fr]'>
<div className='border-border bg-background rounded-lg border p-6'>
<GitBranch className='text-primary size-6' />
<MessagesSquare className='text-primary size-6' />
<h3 className='mt-5 text-xl font-semibold'>
A Spoon is a managed fork
Spoon keeps the conversation going
</h3>
<p className='text-muted-foreground mt-3 leading-7'>
It knows where upstream lives, where your fork lives, which branch
matters, what extra remotes you care about, and what rules should
govern updates. That gives maintenance a durable home instead of a
pile of one-off Git commands.
A fork is not a one-time split. Spoon keeps the fork and upstream in
conversation by turning maintenance into visible, reviewable threads
instead of surprise drift.
</p>
</div>
<ol className='grid gap-3'>
<ol className='grid gap-3 md:grid-cols-2'>
{workflow.map((step, index) => (
<li
key={step.title}
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
className='border-border bg-background rounded-lg border p-5'
>
<span className='text-primary text-sm font-semibold'>
{String(index + 1).padStart(2, '0')}
</span>
<div>
<h3 className='font-semibold'>{step.title}</h3>
<p className='text-muted-foreground mt-1 text-sm leading-6'>
<h3 className='mt-3 font-semibold'>{step.title}</h3>
<p className='text-muted-foreground mt-2 text-sm leading-6'>
{step.description}
</p>
</div>
</li>
))}
</ol>
@@ -170,12 +196,12 @@ export const Features = () => (
Product surface
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Everything important about a fork, without opening six tabs.
The maintenance cockpit for forks you actually care about.
</h2>
</div>
<p className='text-muted-foreground max-w-xl leading-7'>
Spoon is not trying to replace GitHub. It is the layer that explains how
your fork relates to upstream and what should happen next.
Custom work should not mean permanent drift. Spoon keeps the operational
picture clear from the first upstream check to the final draft PR.
</p>
</div>
@@ -195,59 +221,113 @@ export const Features = () => (
</section>
);
export const Agents = () => (
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
<div>
export const MaintenanceDecisions = () => (
<section id='maintenance' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mb-10 max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Agent work
Maintenance decisions
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
The agent belongs inside the fork dashboard.
Spoon knows when to sync, when to thread, and when to stay out of the
way.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
The goal is simple: ask for a change, let a worker clone the current
fork, expose only the secrets you selected, run checks, push a branch,
and open a draft pull request. The first pieces are already modeled:
encrypted Spoon secrets, agent settings, queued jobs, logs, and
artifacts.
</p>
</div>
<div className='border-border bg-background rounded-lg border'>
<div className='border-border border-b p-5'>
<div className='flex items-center gap-3'>
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
<Bot className='size-4' />
</span>
<div>
<p className='font-medium'>Draft PR agent flow</p>
<p className='text-muted-foreground text-sm'>
Built for review, not automatic merge.
</p>
</div>
</div>
</div>
<div className='divide-border divide-y'>
{[
['Clone', 'Start from the current GitHub fork state.'],
['Branch', 'Create a short-lived agent branch.'],
['Edit', 'Apply focused changes with selected project context.'],
[
'Check',
'Run configured install, lint, typecheck, or test steps.',
],
['Review', 'Open a draft pull request with logs and artifacts.'],
].map(([phase, detail]) => (
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
<p className='text-sm font-semibold'>{phase}</p>
<div className='overflow-hidden rounded-lg border'>
{decisions.map((decision) => (
<div
key={decision.condition}
className='bg-background border-border grid gap-4 border-b p-5 last:border-b-0 lg:grid-cols-[1fr_12rem_1.3fr]'
>
<p className='font-medium'>{decision.condition}</p>
<Badge
variant='outline'
className='bg-primary/10 text-primary w-fit'
>
{decision.action}
</Badge>
<p className='text-muted-foreground text-sm leading-6'>
{detail}
{decision.explanation}
</p>
</div>
))}
</div>
</div>
</section>
);
export const ThreadedWork = () => (
<section id='threads' className='container mx-auto px-4 py-24'>
<div className='grid gap-10 lg:grid-cols-[0.9fr_1.1fr]'>
<div>
<Badge variant='outline' className='mb-4'>
Threads
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Threads keep the whole maintenance conversation in one place.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Upstream changed, a fork drifted, a conflict appeared, or you asked
for a code change. Spoon puts the reasoning, messages, workspace,
artifacts, and draft PR handoff in the same thread.
</p>
</div>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b p-5'>
<div>
<p className='font-medium'>Thread: Upstream auth changes landed</p>
<p className='text-muted-foreground text-sm'>
Source: upstream update
</p>
</div>
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
Waiting for review
</Badge>
</div>
<div className='bg-border grid gap-px lg:grid-cols-[1.35fr_0.65fr]'>
<div className='bg-background space-y-3 p-5'>
{[
['system', 'Spoon found 3 upstream commits after 8f3a2c1.'],
[
'assistant',
'These touch auth callback handling and package scripts. Your fork has Authentik-only changes in the same area.',
],
[
'user',
'Open a review PR and preserve Authentik as the only provider.',
],
].map(([role, message]) => (
<div key={role} className='rounded-md border p-3 text-sm'>
<p className='text-muted-foreground text-xs'>{role}</p>
<p className='mt-1 leading-6'>{message}</p>
</div>
))}
</div>
<div className='bg-background space-y-4 p-5 text-sm'>
<div>
<p className='text-muted-foreground text-xs'>Latest job</p>
<p className='mt-1 font-medium'>OpenCode workspace active</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Model/provider</p>
<p className='mt-1 font-medium'>Codex profile</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>PR target</p>
<p className='mt-1 font-medium'>fork:main</p>
</div>
<div className='bg-muted/60 rounded-md p-3'>
<Bot className='text-primary mb-2 size-4' />
<p className='text-muted-foreground leading-6'>
Workspace logs, diffs, checks, and the final PR stay attached to
the thread.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
);
@@ -260,18 +340,18 @@ export const Security = () => (
Ownership
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Useful because it respects how forks are really maintained.
Self-hosted because the fork is yours.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
A fork can have local experiments, CI changes, private deployment
settings, and emergency upstream fixes all happening at once. Spoon
keeps those threads visible without pretending every change must come
through the app.
Your fork can have local commits, private deploy settings, Gitea
mirrors, CI experiments, and emergency GitHub edits. Spoon keeps that
relationship with upstream visible without taking ownership away from
you.
</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
{builtFor.map(({ title, description, icon: Icon }) => (
{ownership.map(({ title, description, icon: Icon }) => (
<div key={title} className='border-border rounded-lg border p-5'>
<div className='flex items-center gap-3'>
<Icon className='text-primary size-5 shrink-0' />
+14 -108
View File
@@ -2,42 +2,11 @@
import Link from 'next/link';
import { useConvexAuth } from 'convex/react';
import {
ArrowRight,
Bot,
CheckCircle2,
CircleDot,
GitBranch,
GitPullRequest,
KeyRound,
ShieldCheck,
} from 'lucide-react';
import { ArrowRight, CircleDot, ShieldCheck } from 'lucide-react';
import { Badge, Button } from '@spoon/ui';
const previewRows = [
{
name: 'gibsend',
upstream: 'usesend/usesend',
status: '3 upstream commits',
icon: CheckCircle2,
tone: 'text-emerald-600',
},
{
name: 'internal-docs',
upstream: 'platform/docs',
status: 'AI review ready',
icon: Bot,
tone: 'text-teal-600',
},
{
name: 'ops-console',
upstream: 'console/main',
status: 'fork-only changes',
icon: GitPullRequest,
tone: 'text-amber-600',
},
];
import { ProductStoryDemo } from './product-story-demo';
export const Hero = () => {
const { isAuthenticated } = useConvexAuth();
@@ -47,17 +16,17 @@ export const Hero = () => {
<div className='max-w-3xl'>
<Badge variant='outline' className='mb-5 gap-2'>
<ShieldCheck className='size-3.5 text-emerald-600' />
Self-hostable fork maintenance cockpit
Self-hostable fork maintenance with Threads
</Badge>
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
Make your forks <em className='text-primary'>intimately</em> close
to upstream.
Fork freely & keep them all{' '}
<em className='text-primary'>intimately</em> close to upstream.
</h1>
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
Spoon gives every important fork a living maintenance dashboard.
Track upstream drift, preserve your custom commits, review pull
requests, and queue AI-assisted work without losing sight of the
project you forked from.
Spoon is a self-hostable maintenance cockpit for forks. It watches
upstream, understands your fork-only changes, opens threads when
decisions are needed, and helps keep your managed forks close
without asking you to support them alone.
</p>
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
<Button size='lg' asChild>
@@ -67,13 +36,14 @@ export const Hero = () => {
</Link>
</Button>
<Button size='lg' variant='outline' asChild>
<Link href='#workflow'>See how it works</Link>
<Link href='#demo'>Watch the flow</Link>
</Button>
</div>
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
<div className='text-muted-foreground mt-8 grid max-w-2xl gap-3 text-sm sm:grid-cols-2'>
{[
'GitHub App backed',
'OpenAI key per user',
'Thread-first maintenance',
'OpenCode workspaces',
'Draft PR workflow',
].map((item) => (
<span key={item} className='flex items-center gap-2'>
@@ -84,71 +54,7 @@ export const Hero = () => {
</div>
</div>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>Fork health</p>
<p className='text-muted-foreground text-xs'>
Current state across managed Spoons
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
Live GitHub sync
</Badge>
</div>
<div className='grid gap-4 p-5 md:grid-cols-3'>
{[
['Behind', '3', 'upstream commits'],
['Fork-only', '12', 'custom changes'],
['AI risk', 'Low', 'reviewed'],
].map(([label, value, note]) => (
<div
key={label}
className='border-border bg-background rounded-md border p-4'
>
<p className='text-muted-foreground text-xs'>{label}</p>
<p className='mt-2 text-2xl font-semibold'>{value}</p>
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
</div>
))}
</div>
<div className='space-y-3 px-5 pb-5'>
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
<div
key={name}
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
>
<div className='flex items-center gap-3'>
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
<GitBranch className='size-4' />
</span>
<div>
<p className='text-sm font-medium'>{name}</p>
<p className='text-muted-foreground text-xs'>{upstream}</p>
</div>
</div>
<span className='flex items-center gap-2 text-sm'>
<Icon className={`size-4 ${tone}`} />
{status}
</span>
</div>
))}
</div>
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
<div className='flex items-start gap-3'>
<KeyRound className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
User-owned OpenAI keys stay encrypted and selectable.
</p>
</div>
<div className='flex items-start gap-3'>
<GitPullRequest className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
Agent jobs are shaped around draft pull requests.
</p>
</div>
</div>
</div>
<ProductStoryDemo />
</div>
</section>
);
+9 -1
View File
@@ -1,3 +1,11 @@
export { Hero } from './hero';
export { Agents, Features, Security, Workflow } from './features';
export {
Features,
MaintenanceDecisions,
Security,
ThreadedWork,
Workflow,
} from './features';
export { ProductStoryDemo } from './product-story-demo';
export { WorkspaceShowcase } from './workspace-showcase';
export { CTA } from './cta';
@@ -0,0 +1,387 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import {
AlertTriangle,
CheckCircle2,
Code2,
GitBranch,
GitPullRequest,
MessagesSquare,
RefreshCw,
} from 'lucide-react';
import { Badge, Button } from '@spoon/ui';
type DemoStep = 'check' | 'thread' | 'workspace' | 'decision' | 'pr';
type DemoStepConfig = {
id: DemoStep;
label: string;
title: string;
description: string;
};
const defaultStep: DemoStepConfig = {
id: 'check',
label: 'Check',
title: 'Upstream moved',
description: 'Spoon checks default branches and compares the fork network.',
};
const steps: DemoStepConfig[] = [
defaultStep,
{
id: 'thread',
label: 'Thread',
title: 'Custom work needs context',
description:
'Fork-only commits turn an upstream update into a durable thread.',
},
{
id: 'workspace',
label: 'Workspace',
title: 'OpenCode gets a sandbox',
description:
'The worker opens a repo workspace with files, thread context, and checks.',
},
{
id: 'decision',
label: 'Decision',
title: 'Review the maintenance call',
description:
'Spoon records risk, conflicts, and the recommended next step.',
},
{
id: 'pr',
label: 'Draft PR',
title: 'Ship as reviewable work',
description:
'Code changes leave the workspace as a branch and draft pull request.',
},
];
const getReducedMotionPreference = () => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};
const usePrefersReducedMotion = () => {
const [reducedMotion, setReducedMotion] = useState(
getReducedMotionPreference,
);
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return;
}
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setReducedMotion(query.matches);
update();
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return reducedMotion;
};
const Metric = ({
label,
value,
tone = 'default',
}: {
label: string;
value: string;
tone?: 'default' | 'warning' | 'good';
}) => (
<div className='border-border bg-background rounded-md border p-3'>
<p className='text-muted-foreground text-xs'>{label}</p>
<p
className={
tone === 'warning'
? 'mt-1 text-lg font-semibold text-amber-600'
: tone === 'good'
? 'mt-1 text-lg font-semibold text-emerald-600'
: 'mt-1 text-lg font-semibold'
}
>
{value}
</p>
</div>
);
const CheckPreview = () => (
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-3'>
<Metric label='Raw upstream ahead' value='3 commits' tone='warning' />
<Metric label='Fork-only work' value='5 commits' />
<Metric label='Status' value='Needs thread' tone='warning' />
</div>
<div className='border-border bg-background rounded-md border p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='min-w-0'>
<p className='font-medium'>usesend-authentik</p>
<p className='text-muted-foreground text-xs'>
usesend/usesend {'->'} gibbyb/usesend
</p>
</div>
<Badge variant='outline'>daily check</Badge>
</div>
<div className='mt-4 grid gap-2 text-sm'>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<RefreshCw className='text-primary size-4' />
Compare upstream main
</span>
<span className='text-muted-foreground'>complete</span>
</div>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
Detect fork-only commits
</span>
<span className='text-muted-foreground'>custom auth work</span>
</div>
</div>
</div>
</div>
);
const ThreadPreview = () => (
<div className='space-y-4'>
<div className='border-border bg-background rounded-md border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<MessagesSquare className='text-primary size-4' />
<p className='font-medium'>
Upstream changed: auth and webhook updates
</p>
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
Review required
</Badge>
</div>
<div className='mt-4 space-y-3 text-sm'>
<div className='bg-muted/60 rounded-md p-3'>
<p className='text-muted-foreground text-xs'>system</p>
<p className='mt-1'>
Spoon found upstream commits that touch auth-adjacent files. Fork
has custom Authentik work.
</p>
</div>
<div className='rounded-md border p-3'>
<p className='text-muted-foreground text-xs'>assistant</p>
<p className='mt-1'>
These updates are probably valuable, but they overlap with fork-only
provider changes.
</p>
</div>
</div>
</div>
</div>
);
const WorkspacePreview = () => (
<div className='grid min-h-[19rem] gap-3 lg:grid-cols-[0.8fr_1.4fr_0.9fr]'>
<div className='border-border bg-background rounded-md border p-3 text-xs'>
<p className='mb-3 font-medium'>Files</p>
{['packages/auth/providers.ts', '.env.example', 'apps/web/auth.ts'].map(
(file) => (
<div
key={file}
className='text-muted-foreground hover:text-foreground rounded px-2 py-1.5'
>
{file}
</div>
),
)}
</div>
<div className='border-border rounded-md border bg-zinc-950 p-3 font-mono text-xs text-zinc-100'>
<div className='mb-3 flex items-center justify-between text-zinc-400'>
<span>providers.ts</span>
<span>vim mode</span>
</div>
<pre className='overflow-hidden leading-6'>
<code>{`export const providers = [
Authentik({
issuer: env.AUTHENTIK_ISSUER,
clientId: env.AUTHENTIK_CLIENT_ID,
}),
];
// GitHub provider removed in fork`}</code>
</pre>
</div>
<div className='border-border bg-background rounded-md border p-3 text-xs'>
<p className='mb-3 font-medium'>Thread</p>
<div className='space-y-2'>
<p className='bg-muted/60 rounded-md p-2'>
Preserve Authentik-only auth.
</p>
<p className='rounded-md border p-2'>
Running typecheck after provider update.
</p>
<p className='text-muted-foreground bg-muted/40 rounded-md p-2'>
Secrets available as process env.
</p>
</div>
</div>
</div>
);
const DecisionPreview = () => (
<div className='space-y-3'>
{[
['Risk', 'Medium', 'Auth provider wiring overlaps custom fork changes.'],
[
'Recommended action',
'Open review PR',
'Keep the fork branch reviewable.',
],
[
'Conflict signals',
'2 files',
'OAuth callback copy and package scripts.',
],
].map(([label, value, detail]) => (
<div
key={label}
className='border-border bg-background rounded-md border p-4'
>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-sm'>{label}</p>
<Badge variant='outline'>{value}</Badge>
</div>
<p className='mt-2 text-sm'>{detail}</p>
</div>
))}
</div>
);
const DraftPrPreview = () => (
<div className='border-border bg-background rounded-md border p-5'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='font-medium'>Draft PR opened</p>
<p className='text-muted-foreground text-xs'>
spoon/thread/authentik-upstream
</p>
</div>
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
ready for review
</Badge>
</div>
<div className='mt-5 grid gap-2 text-sm'>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<CheckCircle2 className='size-4 text-emerald-600' />
lint
</span>
<span>passed</span>
</div>
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
<span className='flex items-center gap-2'>
<AlertTriangle className='size-4 text-amber-600' />
typecheck
</span>
<span>queued</span>
</div>
</div>
<Button className='mt-5 w-full' variant='outline'>
Review PR
<GitPullRequest className='size-4' />
</Button>
</div>
);
const renderPreview = (step: DemoStep) => {
if (step === 'check') return <CheckPreview />;
if (step === 'thread') return <ThreadPreview />;
if (step === 'workspace') return <WorkspacePreview />;
if (step === 'decision') return <DecisionPreview />;
return <DraftPrPreview />;
};
export const ProductStoryDemo = () => {
const [activeStep, setActiveStep] = useState<DemoStep>('check');
const [paused, setPaused] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const active = useMemo(
() => steps.find((step) => step.id === activeStep) ?? defaultStep,
[activeStep],
);
useEffect(() => {
if (paused || reducedMotion) return;
const interval = window.setInterval(() => {
setActiveStep((current) => {
const index = steps.findIndex((step) => step.id === current);
return steps[(index + 1) % steps.length]?.id ?? 'check';
});
}, 3500);
return () => window.clearInterval(interval);
}, [paused, reducedMotion]);
return (
<div
id='demo'
aria-label='Animated Spoon maintenance flow demo'
className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className='border-border flex items-start justify-between gap-4 border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>{active.title}</p>
<p className='text-muted-foreground mt-1 text-xs'>
{active.description}
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
Live flow
</Badge>
</div>
<div className='border-border flex gap-2 overflow-x-auto border-b p-2'>
{steps.map((step) => (
<button
key={step.id}
type='button'
aria-current={step.id === activeStep ? 'step' : undefined}
onClick={() => setActiveStep(step.id)}
className={
step.id === activeStep
? 'bg-primary text-primary-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
}
>
{step.label}
</button>
))}
</div>
<div className='min-h-[24rem] p-4 sm:p-5'>
{renderPreview(activeStep)}
</div>
<div className='border-border bg-muted/30 grid gap-3 border-t p-4 text-sm sm:grid-cols-3'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
fork stays source of truth
</span>
<span className='flex items-center gap-2'>
<MessagesSquare className='text-primary size-4' />
decisions stay threaded
</span>
<span className='flex items-center gap-2'>
<Code2 className='text-primary size-4' />
workspace opens when needed
</span>
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import {
CheckCircle2,
Code2,
FileCode2,
GitBranch,
MessagesSquare,
Terminal,
} from 'lucide-react';
import { Badge } from '@spoon/ui';
const files = [
'apps/web/auth.ts',
'packages/auth/providers.ts',
'packages/auth/env.ts',
'.env.example',
];
const codeLines = [
'export const authProviders = [',
' Authentik({',
' issuer: env.AUTHENTIK_ISSUER,',
' clientId: env.AUTHENTIK_CLIENT_ID,',
' }),',
'];',
];
export const WorkspaceShowcase = () => (
<section id='workspace' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mb-10 max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Workspace
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
When a thread needs code, open a real workspace.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Spoon can expose project secrets as process env, optionally
materialize an env file, run configured checks, and refuse to commit
`.env*` files. The result is reviewable code, not a mystery patch.
</p>
</div>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4'>
<div className='flex flex-wrap items-center gap-3 text-sm'>
<span className='flex items-center gap-2'>
<GitBranch className='text-primary size-4' />
spoon/thread/authentik-upstream
</span>
<span className='text-muted-foreground hidden sm:inline'>/</span>
<span className='flex items-center gap-2'>
<Code2 className='text-primary size-4' />
OpenCode
</span>
</div>
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
Workspace active
</Badge>
</div>
<div className='bg-border grid min-h-[30rem] gap-px lg:grid-cols-[0.8fr_1.5fr_0.9fr]'>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<FileCode2 className='text-primary size-4' />
Files
</p>
<div className='space-y-1 text-sm'>
{files.map((file, index) => (
<div
key={file}
className={
index === 1
? 'bg-primary/10 text-primary rounded-md px-3 py-2 font-medium'
: 'text-muted-foreground rounded-md px-3 py-2'
}
>
{file}
</div>
))}
</div>
</div>
<div className='bg-zinc-950 p-4 text-zinc-100'>
<div className='mb-4 flex items-center justify-between text-xs text-zinc-400'>
<span>packages/auth/providers.ts</span>
<span>vim mode on</span>
</div>
<pre className='overflow-x-auto rounded-md bg-zinc-900 p-4 text-xs leading-7'>
<code>
{codeLines.map((line, index) => (
<span key={line} className='block'>
<span className='mr-4 text-zinc-600'>
{String(index + 1).padStart(2, '0')}
</span>
{line}
</span>
))}
</code>
</pre>
<div className='mt-4 rounded-md border border-zinc-800 bg-zinc-900 p-4 text-xs'>
<p className='text-emerald-400'>+ Authentik provider</p>
<p className='text-red-300'>- GitHub provider fallback</p>
<p className='mt-2 text-zinc-400'>
Diff stays attached to the thread before the draft PR opens.
</p>
</div>
</div>
<div className='bg-background bg-border grid gap-px'>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<MessagesSquare className='text-primary size-4' />
Thread
</p>
<div className='space-y-3 text-sm'>
<p className='bg-muted/60 rounded-md p-3'>
Preserve Authentik as the only provider.
</p>
<p className='rounded-md border p-3'>
I found the provider wiring and updated the env example.
</p>
</div>
</div>
<div className='bg-background p-4'>
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
<Terminal className='text-primary size-4' />
Checks
</p>
<div className='space-y-2 text-sm'>
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
<span>lint</span>
<span className='flex items-center gap-1 text-emerald-600'>
<CheckCircle2 className='size-4' />
passed
</span>
</div>
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
<span>typecheck</span>
<span className='text-muted-foreground'>queued</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
@@ -9,9 +9,8 @@ export default function Footer() {
<div className='md:col-span-2'>
<SpoonLogo className='mb-4' />
<p className='text-muted-foreground max-w-md text-sm'>
Spoon is a self-hostable fork maintenance dashboard for teams who
want to customize upstream projects without drifting away from
security fixes, product updates, and merge history.
Spoon is a self-hostable fork maintenance cockpit for keeping
important forks close to upstream without supporting them alone.
</p>
</div>
@@ -36,10 +35,10 @@ export default function Footer() {
</li>
<li>
<Link
href='/updates'
href='/threads'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Updates
Threads
</Link>
</li>
</ul>
@@ -50,18 +49,18 @@ export default function Footer() {
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/agents'
href='/settings/ai-providers'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Agents
AI providers
</Link>
</li>
<li>
<Link
href='/profile'
href='/settings/integrations'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Profile
Integrations
</Link>
</li>
<li>
@@ -80,7 +79,7 @@ export default function Footer() {
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
<p>
Self-hostable fork maintenance for teams that stay close to
Self-hostable fork maintenance for projects that stay close to
upstream.
</p>
</div>
@@ -7,10 +7,9 @@ import { useConvexAuth } from 'convex/react';
import {
GitBranch,
LayoutDashboard,
RefreshCw,
MessagesSquare,
Settings,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { Button } from '@spoon/ui';
@@ -31,12 +30,12 @@ const Header = (headerProps: ComponentProps<'header'>) => {
{
href: '/spoons',
icon: GitBranch,
label: 'My Spoons',
label: 'Spoons',
},
{
href: '/updates',
icon: RefreshCw,
label: 'Updates',
href: '/threads',
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/settings/profile',
@@ -51,9 +50,9 @@ const Header = (headerProps: ComponentProps<'header'>) => {
label: 'Workflow',
},
{
href: '/#features',
icon: Sparkles,
label: 'Features',
href: '/#threads',
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/#security',
@@ -9,12 +9,12 @@ const formatDate = (value: number) =>
export const SpoonActivityTimeline = ({
syncRuns,
reviews,
requests,
threads,
jobs,
}: {
syncRuns: Doc<'syncRuns'>[];
reviews: Doc<'aiReviews'>[];
requests: Doc<'agentRequests'>[];
threads: Doc<'threads'>[];
jobs: Doc<'agentJobs'>[];
}) => {
const items = [
...syncRuns.map((item) => ({
@@ -24,18 +24,18 @@ export const SpoonActivityTimeline = ({
summary: item.summary ?? item.error ?? 'Sync run recorded.',
time: item.createdAt,
})),
...reviews.map((item) => ({
...threads.map((item) => ({
id: item._id,
kind: 'AI review',
kind: item.source.replaceAll('_', ' '),
status: item.status,
summary: item.outputSummary ?? item.inputSummary,
summary: item.summary ?? item.title,
time: item.createdAt,
})),
...requests.map((item) => ({
...jobs.map((item) => ({
id: item._id,
kind: 'Agent request',
kind: item.jobType?.replaceAll('_', ' ') ?? 'workspace job',
status: item.status,
summary: item.prompt,
summary: item.summary ?? item.prompt,
time: item.createdAt,
})),
].sort((a, b) => b.time - a.time);
@@ -62,7 +62,7 @@ export const SpoonActivityTimeline = ({
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Refreshes, AI reviews, and queued requests will build this timeline.
Refreshes, threads, and workspace jobs will build this timeline.
</CardContent>
</Card>
)}
@@ -1,11 +1,13 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
@@ -24,17 +26,9 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
const modelOptions = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
] as const;
type AgentModel = (typeof modelOptions)[number]['value'];
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
@@ -42,13 +36,14 @@ type AgentSettings = {
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
autoDetectCommands?: boolean;
allowUserFileEditing?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
const toAgentModel = (value?: string): AgentModel =>
modelOptions.some((option) => option.value === value)
? (value as AgentModel)
: 'gpt-5.1-codex';
export const SpoonAgentSettingsForm = ({
spoon,
settings,
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
settings?: AgentSettings | null;
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState<AgentModel>(
toAgentModel(settings?.agentModel),
const [envFilePath, setEnvFilePath] = useState(
settings?.envFilePath ?? '.env.local',
);
const [customEnvFilePath, setCustomEnvFilePath] = useState(
settings?.customEnvFilePath ?? '',
);
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
useState(settings?.materializeEnvFileByDefault ?? false);
const [autoDetectCommands, setAutoDetectCommands] = useState(
settings?.autoDetectCommands ?? true,
);
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
settings?.allowUserFileEditing ?? true,
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
settings?.aiProviderProfileId ?? '__default',
);
const selectedProfile = profiles.find(
(profile) =>
profile._id ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
[],
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
settings?.reasoningEffort === 'none'
!settings?.aiProviderProfileId
? 'medium'
: settings.reasoningEffort === 'none'
? 'minimal'
: (settings?.reasoningEffort ?? 'high'),
: 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 save = async () => {
try {
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel,
agentModel: agentModel.trim()
? agentModel
: (selectableModels[0]?.id ?? undefined),
reasoningEffort,
envFilePath: envFilePath as
| '.env'
| '.env.local'
| '.env.production'
| '.env.production.local'
| 'custom',
customEnvFilePath: customEnvFilePath || undefined,
materializeEnvFileByDefault,
autoDetectCommands,
allowUserFileEditing,
aiProviderProfileId:
aiProviderProfileId === '__default'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
clearAiProviderProfile: aiProviderProfileId === '__default',
});
toast.success('Agent settings saved.');
} catch (error) {
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Runtime</Label>
<Input value='OpenCode workspace' disabled />
</div>
<div className='grid gap-2'>
<Label>AI provider profile</Label>
<Select
value={aiProviderProfileId}
onValueChange={(value) => {
setAiProviderProfileId(value);
const nextProfile = profiles.find(
(profile) =>
profile._id ===
(value === '__default' ? defaultProfile?._id : value),
);
if (!nextProfile?.configured) setAgentModel('');
if (nextProfile?.configured) {
setReasoningEffort(
nextProfile.reasoningEffort === 'none'
? 'minimal'
: nextProfile.reasoningEffort,
);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__default'>
Use account default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
<Label htmlFor='agentModel'>Model</Label>
<Select
value={agentModel}
onValueChange={(value) => setAgentModel(value as AgentModel)}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
<SelectTrigger id='agentModel'>
<SelectValue />
<SelectValue placeholder='Choose a configured model' />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{selectableModels.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!selectableModels.length ? (
<p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before
choosing a model.
</p>
) : null}
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
Leave blank to run the detected test script when one exists.
</p>
</div>
<div className='grid gap-2'>
<Label>Env file path</Label>
<Select value={envFilePath} onValueChange={setEnvFilePath}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='.env'>.env</SelectItem>
<SelectItem value='.env.local'>.env.local</SelectItem>
<SelectItem value='.env.production'>.env.production</SelectItem>
<SelectItem value='.env.production.local'>
.env.production.local
</SelectItem>
<SelectItem value='custom'>Custom path</SelectItem>
</SelectContent>
</Select>
</div>
<Button type='button' onClick={save}>
{envFilePath === 'custom' ? (
<div className='grid gap-2'>
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
<Input
id='customEnvFilePath'
value={customEnvFilePath}
placeholder='.env.spoon'
onChange={(event) => setCustomEnvFilePath(event.target.value)}
/>
</div>
) : null}
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Materialize env file by default</Label>
<p className='text-muted-foreground text-xs'>
Write all Spoon secrets into the chosen .env file for new
workspaces.
</p>
</div>
<Switch
checked={materializeEnvFileByDefault}
onCheckedChange={setMaterializeEnvFileByDefault}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Auto-detect commands</Label>
<p className='text-muted-foreground text-xs'>
Inspect package files after cloning.
</p>
</div>
<Switch
checked={autoDetectCommands}
onCheckedChange={setAutoDetectCommands}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Allow browser file editing</Label>
<p className='text-muted-foreground text-xs'>
Let users edit workspace files manually.
</p>
</div>
<Switch
checked={allowUserFileEditing}
onCheckedChange={setAllowUserFileEditing}
/>
</div>
</div>
<Button
type='button'
onClick={save}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel)
}
>
Save agent settings
</Button>
</CardContent>
@@ -1,75 +0,0 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
export const SpoonAiReviewPanel = ({
latestReview,
reviews,
}: {
latestReview?: Doc<'aiReviews'> | null;
reviews: Doc<'aiReviews'>[];
}) => (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
</CardHeader>
<CardContent>
{latestReview ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<Badge>{latestReview.risk}</Badge>
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
{latestReview.requiresHumanReview ? (
<Badge variant='secondary'>Human review required</Badge>
) : null}
</div>
<p className='text-sm'>
{latestReview.outputSummary ?? latestReview.inputSummary}
</p>
{latestReview.reasoningSummary ? (
<p className='text-muted-foreground text-sm'>
{latestReview.reasoningSummary}
</p>
) : null}
{latestReview.potentialConflicts?.length ? (
<div>
<p className='text-sm font-medium'>Potential conflicts</p>
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
{latestReview.potentialConflicts.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Run an AI review after a GitHub refresh to get compatibility notes.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Review history</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{reviews.length ? (
reviews.map((review) => (
<div key={review._id} className='border-border border p-3 text-sm'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline'>{review.status}</Badge>
<Badge variant='secondary'>{review.risk}</Badge>
</div>
<p className='mt-2'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
)}
</CardContent>
</Card>
</div>
);
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useAction } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
import { RefreshCw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -27,12 +27,6 @@ const syncRef = makeFunctionReference<
unknown
>('githubSync:syncForkWithUpstream');
const reviewRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
>('aiReviewActions:reviewLatestUpstreamChanges');
export const SpoonDetailHeader = ({
spoon,
state,
@@ -42,7 +36,6 @@ export const SpoonDetailHeader = ({
}) => {
const refresh = useAction(refreshRef);
const sync = useAction(syncRef);
const review = useAction(reviewRef);
const [busy, setBusy] = useState<string | null>(null);
const canSync =
spoon.provider === 'github' &&
@@ -110,14 +103,6 @@ export const SpoonDetailHeader = ({
<RefreshCw className='size-4' />
Refresh
</Button>
<Button
variant='outline'
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<Brain className='size-4' />
Review with AI
</Button>
<Button
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
disabled={Boolean(busy) || !canSync}
@@ -2,7 +2,7 @@ import {
Clock,
GitCommit,
GitPullRequest,
ShieldCheck,
MessagesSquare,
TrendingUp,
} from 'lucide-react';
@@ -17,11 +17,11 @@ const formatDate = (value?: number) =>
export const SpoonMetrics = ({
spoon,
state,
latestReview,
latestThread,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
latestReview?: Doc<'aiReviews'> | null;
latestThread?: Doc<'threads'> | null;
}) => {
const metrics = [
{
@@ -42,9 +42,9 @@ export const SpoonMetrics = ({
icon: GitPullRequest,
},
{
label: 'Latest AI risk',
value: latestReview?.risk ?? 'unknown',
icon: ShieldCheck,
label: 'Latest thread',
value: latestThread?.status.replaceAll('_', ' ') ?? 'none',
icon: MessagesSquare,
},
{
label: 'Last check',
@@ -93,7 +93,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoRefreshEnabled,
},
{
label: 'Auto AI review',
label: 'Auto maintenance threads',
value: autoReviewEnabled,
onChange: setAutoReviewEnabled,
},
@@ -103,7 +103,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoSyncEnabled,
},
{
label: 'Require low AI risk for sync',
label: 'Require low-risk thread decision for sync',
value: requireAiLowRiskForSync,
onChange: setRequireAiLowRiskForSync,
},
@@ -0,0 +1,56 @@
import Link from 'next/link';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Card, CardContent } from '@spoon/ui';
export const MaintenanceQueue = ({
threads,
}: {
threads: Doc<'threads'>[];
}) => {
const queued = threads.filter(
(thread) =>
['upstream_update', 'merge_conflict'].includes(thread.source) &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
);
return (
<div className='space-y-3'>
{queued.length ? (
queued.map((thread) => (
<Card key={thread._id} className='shadow-none'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{thread.title}</p>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{thread.summary ?? 'Maintenance thread waiting for review.'}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{new Date(thread.updatedAt).toLocaleString()}
</p>
</div>
<Button variant='outline' size='sm' asChild>
<Link href={`/threads/${thread._id}`}>Open thread</Link>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
No Spoons currently need review. Refresh GitHub state to populate
this queue.
</CardContent>
</Card>
)}
</div>
);
};
@@ -1,52 +0,0 @@
import Link from 'next/link';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Card, CardContent } from '@spoon/ui';
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
const queued = spoons
.filter((spoon) =>
['behind', 'diverged', 'conflict', 'error'].includes(
spoon.syncStatus ?? '',
),
)
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
return (
<div className='space-y-3'>
{queued.length ? (
queued.map((spoon) => (
<Card key={spoon._id} className='shadow-none'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{spoon.name}</p>
<SpoonStatusBadge status={spoon.syncStatus} />
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo} {' '}
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
{spoon.forkAheadBy ?? 0} fork-only commit(s)
</p>
</div>
<Button variant='outline' size='sm' asChild>
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
No Spoons currently need review. Refresh GitHub state to populate
this queue.
</CardContent>
</Card>
)}
</div>
);
};
+7
View File
@@ -19,6 +19,9 @@ export const env = createEnv({
GITHUB_APP_SLUG: z.string().optional(),
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
GITHUB_APP_OWNER: z.string().optional(),
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
SPOON_WORKER_TOKEN: z.string().optional(),
},
/**
@@ -52,6 +55,10 @@ export const env = createEnv({
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
SPOON_AGENT_WORKER_URL: process.env.SPOON_AGENT_WORKER_URL,
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
+86
View File
@@ -0,0 +1,86 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { env } from '@/env';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
type RouteContext = {
params: Promise<{ jobId: string }> | { jobId: string };
};
export const routeJobId = async (context: RouteContext) => {
const params = await context.params;
return params.jobId as Id<'agentJobs'>;
};
const workerToken = () =>
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN;
export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
await fetchQuery(api.agentJobs.assertOwned, { jobId }, { token });
return { ok: true as const };
};
export const proxyWorker = async (
jobId: Id<'agentJobs'>,
action: string,
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(
`/jobs/${encodeURIComponent(jobId)}/${action}`,
env.SPOON_AGENT_WORKER_URL,
);
if (search) {
for (const [key, value] of search) url.searchParams.set(key, value);
}
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',
},
});
};
export const withOwnedJob = async (
context: RouteContext,
handler: (jobId: Id<'agentJobs'>) => Promise<Response>,
) => {
try {
const jobId = await routeJobId(context);
const owned = await requireOwnedJob(jobId);
if (!owned.ok) return owned.response;
return await handler(jobId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
+56
View File
@@ -0,0 +1,56 @@
type ModelsDevModel = {
id?: string;
name?: string;
tool_call?: boolean;
reasoning?: boolean;
limit?: { context?: number };
};
type ModelsDevProvider = {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
};
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<string, ModelsDevProvider>;
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));
};
+1
View File
@@ -10,6 +10,7 @@ const isProtectedRoute = createRouteMatcher([
'/spoons(.*)',
'/updates(.*)',
'/agents(.*)',
'/threads(.*)',
'/github(.*)',
'/settings(.*)',
'/profile(.*)',
+1 -1
View File
@@ -25,7 +25,7 @@ describe('component test harness', () => {
render(<Hero />);
expect(
screen.getByRole('heading', {
name: /make your forks intimately close to upstream\./i,
name: /fork freely & keep them all intimately close to upstream\./i,
}),
).toBeInTheDocument();
});
+21 -108
View File
@@ -21,10 +21,9 @@
"dependencies": {
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@openai/agents": "latest",
"@opencode-ai/sdk": "latest",
"convex": "catalog:convex",
"execa": "latest",
"openai": "^6.44.0",
"zod": "catalog:",
},
"devDependencies": {
@@ -94,11 +93,14 @@
"version": "0.1.0",
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@monaco-editor/react": "latest",
"@sentry/nextjs": "^10.46.0",
"@spoon/backend": "workspace:*",
"@spoon/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.11",
"convex": "catalog:convex",
"monaco-editor": "latest",
"monaco-vim": "latest",
"next": "^16.2.1",
"next-plausible": "^3.12.5",
"react": "catalog:react19",
@@ -137,7 +139,6 @@
"@react-email/components": "1.0.10",
"@react-email/render": "^2.0.4",
"convex": "catalog:convex",
"openai": "^6.44.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"usesend-js": "^1.6.3",
@@ -700,8 +701,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -830,7 +829,9 @@
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
@@ -956,13 +957,7 @@
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@openai/agents": ["@openai/agents@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@openai/agents-openai": "0.11.8", "@openai/agents-realtime": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-D4XHF2g+Ub/L9fRJT/xpuiCqHyxiKzZbi0BqQxnso42t+J049O/OSvVzFBcRskF4uPFAvs0TOOB7KBbanCwaYQ=="],
"@openai/agents-core": ["@openai/agents-core@0.11.8", "", { "dependencies": { "debug": "^4.4.0", "openai": "^6.35.0" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.26.0" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-TrE34RXXPoWYv2PjXf5hq3Eq+uvRJMNiY+Q5WBgEPjAg60yt2hya8cS2I8qkO6i25MjNJl37a25X0vL/gs5Wdg=="],
"@openai/agents-openai": ["@openai/agents-openai@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-XjHCnJPGapgZBlh8y5oxU7zV0hrAQTF5im6HpUwaPcH5CeRFLtc06VXLso0vJ5G3g9e/J5gIh3S1iAxiJqEAVQ=="],
"@openai/agents-realtime": ["@openai/agents-realtime@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-i1qEGUE8GTW0neWgAc1aj/3wZFtstz8bVG2BvVbU/BzQbyhZV8j3CvndkMJGFfgeobvVmn2qGTV5Ry6ibfuxeQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1552,6 +1547,8 @@
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
@@ -1760,8 +1757,6 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
@@ -1864,8 +1859,6 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -1876,8 +1869,6 @@
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
@@ -1980,6 +1971,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
@@ -2094,10 +2087,6 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
@@ -2158,10 +2147,6 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -2206,8 +2191,6 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
@@ -2290,8 +2273,6 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="],
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
@@ -2346,10 +2327,6 @@
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
@@ -2398,8 +2375,6 @@
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -2566,7 +2541,7 @@
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -2574,16 +2549,12 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="],
"memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -2642,6 +2613,10 @@
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
"monaco-vim": ["monaco-vim@0.4.4", "", { "peerDependencies": { "monaco-editor": "*" } }, "sha512-LNChAb//WEm/W+eyeHG/0+pdVEHotk2hLTN+M3sQZx5E8cAlSWSgqcxpcRuQnxDybSln7pfHF9i63HmbIQvrWw=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
@@ -2722,8 +2697,6 @@
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"openai": ["openai@6.44.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@@ -2778,8 +2751,6 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
@@ -2828,16 +2799,12 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
@@ -2850,8 +2817,6 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -2964,8 +2929,6 @@
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -3074,6 +3037,8 @@
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
@@ -3208,8 +3173,6 @@
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -3360,8 +3323,6 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -3518,12 +3479,6 @@
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"@modelcontextprotocol/sdk/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@node-rs/argon2-wasm32-wasi/@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="],
@@ -3534,8 +3489,6 @@
"@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="],
"@openai/agents-realtime/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
@@ -3688,6 +3641,8 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@react-email/tailwind/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"@react-native/babel-plugin-codegen/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -3842,8 +3797,6 @@
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
"chrome-launcher/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
@@ -3930,22 +3883,6 @@
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"express/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
@@ -4068,8 +4005,6 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
@@ -4104,8 +4039,6 @@
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -4158,8 +4091,6 @@
"tsx/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
"usesend-js/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="],
@@ -4314,8 +4245,6 @@
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@opentelemetry/sql-common/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
"@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="],
@@ -4554,8 +4483,6 @@
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -4742,18 +4669,6 @@
"expo-router/@react-navigation/native/@react-navigation/core": ["@react-navigation/core@7.16.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw=="],
"express/accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"express/finalhandler/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"express/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"express/send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"express/send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -4802,8 +4717,6 @@
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+1 -1
View File
@@ -14,7 +14,7 @@ RUN apt-get update \
python3 \
ripgrep \
&& corepack enable \
&& npm install -g bun@1.3.10 \
&& npm install -g bun@1.3.10 opencode-ai@latest \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash agent
+771 -51
View File
@@ -18,6 +18,51 @@ const jobStatus = v.union(
v.literal('timed_out'),
);
const runtime = v.literal('opencode');
const jobType = v.union(
v.literal('user_change'),
v.literal('maintenance_review'),
v.literal('conflict_resolution'),
);
const workspaceStatus = v.union(
v.literal('not_started'),
v.literal('starting'),
v.literal('active'),
v.literal('idle'),
v.literal('stopped'),
v.literal('expired'),
v.literal('failed'),
);
const messageRole = v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
);
const messageStatus = v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
);
const changeSource = v.union(
v.literal('user'),
v.literal('agent'),
v.literal('command'),
);
const changeType = v.union(
v.literal('added'),
v.literal('modified'),
v.literal('deleted'),
v.literal('renamed'),
);
const eventLevel = v.union(
v.literal('debug'),
v.literal('info'),
@@ -55,13 +100,34 @@ const artifactContentType = v.union(
v.literal('text/x-diff'),
);
const maintenanceDecision = v.union(
v.literal('sync'),
v.literal('ignore'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('conflict_resolution'),
v.literal('unknown'),
);
const maintenanceRisk = v.union(
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('unknown'),
);
const defaultAgentSettings = {
enabled: true,
runtime: 'opencode' as const,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
agentModel: '',
reasoningEffort: 'medium' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
envFilePath: '.env.local',
materializeEnvFileByDefault: false,
autoDetectCommands: true,
allowUserFileEditing: true,
};
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
@@ -94,6 +160,18 @@ const buildBranch = (
)}`;
};
const normalizeEnvFilePath = (value?: string) => {
const trimmed = optionalText(value);
if (!trimmed) return undefined;
if (trimmed.startsWith('/') || trimmed.includes('..')) {
throw new ConvexError('Env file path must stay inside the repository.');
}
if (!/^\.env(?:[./-][A-Za-z0-9_.-]+)?$/.test(trimmed)) {
throw new ConvexError('Env file path must be a .env-style path.');
}
return trimmed;
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
@@ -120,12 +198,207 @@ const assertSecretOwnership = async (
}
};
const getJobProfile = async (
ctx: MutationCtx,
ownerId: Id<'users'>,
profileId?: Id<'aiProviderProfiles'>,
) => {
const profile = profileId
? await ctx.db.get(profileId)
: await getDefaultJobProfile(ctx, ownerId);
if (profile?.ownerId !== ownerId || !profile.enabled) {
throw new ConvexError('AI provider profile not found.');
}
if (profile.authType !== 'none' && !profile.encryptedSecret) {
throw new ConvexError('Selected AI provider is missing credentials.');
}
return profile;
};
const getDefaultJobProfile = async (ctx: MutationCtx, ownerId: Id<'users'>) => {
const profiles = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const configuredProfiles = profiles.filter(
(profile) =>
profile.enabled &&
(profile.authType === 'none' || Boolean(profile.encryptedSecret)),
);
const explicitDefault = configuredProfiles.find(
(profile) =>
(profile as Doc<'aiProviderProfiles'> & { isDefault?: boolean })
.isDefault,
);
const profile =
explicitDefault ??
(configuredProfiles.length === 1 ? configuredProfiles[0] : undefined);
if (!profile) {
throw new ConvexError(
'Choose a default AI provider before queueing agent work.',
);
}
return profile;
};
const listSpoonSecretIds = async (
ctx: MutationCtx,
spoonId: Id<'spoons'>,
ownerId: Id<'users'>,
) => {
const secrets = await ctx.db
.query('spoonSecrets')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.collect();
return secrets
.filter((secret) => secret.ownerId === ownerId)
.map((secret) => secret._id);
};
const insertJob = async (
ctx: MutationCtx,
{
ownerId,
spoon,
requestId,
prompt,
settings,
threadId,
requestedJobType,
baseBranch,
requestedBranchName,
requestedRuntime,
materializeEnvFile,
requestedEnvFilePath,
requestedProfileId,
}: {
ownerId: Id<'users'>;
spoon: Doc<'spoons'>;
requestId: Id<'agentRequests'>;
prompt: string;
settings: Awaited<ReturnType<typeof getAgentSettings>>;
threadId?: Id<'threads'>;
requestedJobType:
| 'user_change'
| 'maintenance_review'
| 'conflict_resolution';
baseBranch?: string;
requestedBranchName?: string;
requestedRuntime?: 'opencode';
materializeEnvFile?: boolean;
requestedEnvFilePath?: string;
requestedProfileId?: Id<'aiProviderProfiles'>;
},
) => {
if (spoon.provider !== 'github') {
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
}
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
throw new ConvexError(
'Add fork repository metadata before queueing a job.',
);
}
if (!settings.enabled) {
throw new ConvexError('Agent jobs are disabled for this Spoon.');
}
const aiProviderProfileId =
requestedProfileId ?? settings.aiProviderProfileId;
const profile = await getJobProfile(ctx, ownerId, aiProviderProfileId);
const selectedSecretIds = await listSpoonSecretIds(ctx, spoon._id, ownerId);
const now = Date.now();
const resolvedBaseBranch =
optionalText(baseBranch) ?? settings.defaultBaseBranch;
const jobRuntime = requestedRuntime ?? 'opencode';
const shouldMaterializeEnvFile =
materializeEnvFile ?? settings.materializeEnvFileByDefault;
const envFilePath =
normalizeEnvFilePath(requestedEnvFilePath) ??
normalizeEnvFilePath(
settings.envFilePath === 'custom'
? settings.customEnvFilePath
: settings.envFilePath,
);
const workBranch = buildBranch(
requestId,
prompt,
settings.branchPrefix,
requestedBranchName,
);
const jobId = await ctx.db.insert('agentJobs', {
spoonId: spoon._id,
ownerId,
agentRequestId: requestId,
threadId,
jobType: requestedJobType,
status: 'queued',
prompt,
runtime: jobRuntime,
workspaceStatus: 'not_started',
baseBranch: resolvedBaseBranch,
workBranch,
envFilePath,
materializeEnvFile: shouldMaterializeEnvFile,
githubInstallationId: spoon.githubInstallationId,
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkUrl: spoon.forkUrl,
upstreamOwner: spoon.upstreamOwner,
upstreamRepo: spoon.upstreamRepo,
selectedSecretIds,
aiProviderProfileId: profile._id,
model: profile.defaultModel,
reasoningEffort: profile.reasoningEffort,
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(requestId, {
agentJobId: jobId,
selectedSecretIds,
baseBranch: resolvedBaseBranch,
requestedBranchName: optionalText(requestedBranchName),
status: 'queued',
updatedAt: now,
});
if (threadId) {
await ctx.db.patch(threadId, {
latestAgentJobId: jobId,
relatedAgentRequestId: requestId,
status: 'queued',
updatedAt: now,
});
}
await ctx.db.insert('agentJobEvents', {
jobId,
spoonId: spoon._id,
ownerId,
level: 'info',
phase: 'queued',
message: 'OpenCode job queued.',
createdAt: now,
});
await ctx.db.insert('agentJobMessages', {
jobId,
spoonId: spoon._id,
ownerId,
role: 'user',
content: prompt,
status: 'completed',
createdAt: now,
updatedAt: now,
});
return jobId;
};
export const createFromRequest = mutation({
args: {
requestId: v.id('agentRequests'),
selectedSecretIds: v.array(v.id('spoonSecrets')),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
runtime: v.optional(runtime),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
@@ -137,74 +410,158 @@ export const createFromRequest = mutation({
throw new ConvexError('This request already has an agent job.');
}
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
if (spoon.provider !== 'github') {
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
}
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
throw new ConvexError(
'Add fork repository metadata before queueing a job.',
);
}
const settings = await getAgentSettings(ctx, spoon);
if (!settings.enabled) {
throw new ConvexError('Agent jobs are disabled for this Spoon.');
}
await assertSecretOwnership(
ctx,
spoon._id,
ownerId,
args.selectedSecretIds,
);
const now = Date.now();
const baseBranch =
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
const workBranch = buildBranch(
request._id,
request.prompt,
settings.branchPrefix,
args.requestedBranchName,
);
const jobId = await ctx.db.insert('agentJobs', {
spoonId: spoon._id,
return await insertJob(ctx, {
ownerId,
agentRequestId: request._id,
status: 'queued',
spoon,
requestId: request._id,
prompt: request.prompt,
baseBranch,
workBranch,
githubInstallationId: spoon.githubInstallationId,
forkOwner: spoon.forkOwner,
forkRepo: spoon.forkRepo,
forkUrl: spoon.forkUrl,
upstreamOwner: spoon.upstreamOwner,
upstreamRepo: spoon.upstreamRepo,
selectedSecretIds: args.selectedSecretIds,
model: settings.agentModel,
reasoningEffort: settings.reasoningEffort,
createdAt: now,
updatedAt: now,
settings,
requestedJobType: 'user_change',
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
});
await ctx.db.patch(request._id, {
agentJobId: jobId,
selectedSecretIds: args.selectedSecretIds,
baseBranch,
requestedBranchName: optionalText(args.requestedBranchName),
status: 'queued',
updatedAt: now,
},
});
await ctx.db.insert('agentJobEvents', {
jobId,
export const createForThread = mutation({
args: {
threadId: v.id('threads'),
jobType,
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(args.threadId);
if (thread?.ownerId !== ownerId || !thread.spoonId) {
throw new ConvexError('Thread not found.');
}
if (thread.latestAgentJobId) {
throw new ConvexError('This thread already has an agent job.');
}
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
const promptMessage = await ctx.db
.query('threadMessages')
.withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
.order('desc')
.first();
const prompt =
promptMessage?.content ??
thread.summary ??
`Work on thread: ${thread.title}`;
const now = Date.now();
const requestId = await ctx.db.insert('agentRequests', {
spoonId: spoon._id,
ownerId,
level: 'info',
phase: 'queued',
message: 'Agent job queued.',
prompt,
status: 'queued',
requestType:
args.jobType === 'user_change'
? 'future_code_change'
: 'upstream_review',
priority: thread.priority,
source: thread.source === 'user_request' ? 'user' : 'system',
targetBranch: optionalText(args.baseBranch),
createdAt: now,
updatedAt: now,
});
const settings = await getAgentSettings(ctx, spoon);
const jobId = await insertJob(ctx, {
ownerId,
spoon,
requestId,
prompt,
settings,
threadId: args.threadId,
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
});
return jobId;
},
});
export const createForThreadInternal = internalMutation({
args: {
threadId: v.id('threads'),
ownerId: v.id('users'),
jobType,
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
},
handler: async (ctx, args) => {
const thread = await ctx.db.get(args.threadId);
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
throw new ConvexError('Thread not found.');
}
if (thread.latestAgentJobId) return thread.latestAgentJobId;
const spoon = await ctx.db.get(thread.spoonId);
if (spoon?.ownerId !== args.ownerId) {
throw new ConvexError('Spoon not found.');
}
const promptMessage = await ctx.db
.query('threadMessages')
.withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
.order('desc')
.first();
const prompt =
promptMessage?.content ??
thread.summary ??
`Review maintenance thread: ${thread.title}`;
const now = Date.now();
const requestId = await ctx.db.insert('agentRequests', {
spoonId: spoon._id,
ownerId: args.ownerId,
prompt,
status: 'queued',
requestType:
args.jobType === 'user_change'
? 'future_code_change'
: 'upstream_review',
priority: thread.priority,
source: thread.source === 'user_request' ? 'user' : 'system',
targetBranch: optionalText(args.baseBranch),
createdAt: now,
updatedAt: now,
});
const settings = await getAgentSettings(ctx, spoon);
return await insertJob(ctx, {
ownerId: args.ownerId,
spoon,
requestId,
prompt,
settings,
threadId: args.threadId,
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
});
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
@@ -228,6 +585,66 @@ export const get = query({
},
});
export const assertOwned = query({
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.');
return { job, ownerId };
},
});
export const listMessages = query({
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
handler: async (ctx, { jobId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('asc')
.take(limit ?? 200);
},
});
export const appendUserMessage = mutation({
args: { jobId: v.id('agentJobs'), content: v.string() },
handler: async (ctx, { jobId, content }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
const trimmed = optionalText(content);
if (!trimmed) throw new ConvexError('Message is required.');
const now = Date.now();
return await ctx.db.insert('agentJobMessages', {
jobId,
spoonId: job.spoonId,
ownerId,
role: 'user',
content: trimmed,
status: 'queued',
createdAt: now,
updatedAt: now,
});
},
});
export const listWorkspaceChanges = query({
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
handler: async (ctx, { jobId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
return await ctx.db
.query('agentWorkspaceChanges')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.order('desc')
.take(limit ?? 100);
},
});
export const listEvents = query({
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
handler: async (ctx, { jobId, limit }) => {
@@ -312,6 +729,9 @@ export const claimNextInternal = internalMutation({
.query('spoonAgentSettings')
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
.first();
const aiProviderProfile = job.aiProviderProfileId
? await ctx.db.get(job.aiProviderProfileId)
: null;
const secrets = [];
for (const secretId of job.selectedSecretIds) {
const secret = await ctx.db.get(secretId);
@@ -343,6 +763,8 @@ export const claimNextInternal = internalMutation({
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
spoon,
aiSettings,
aiProviderProfile:
aiProviderProfile?.ownerId === job.ownerId ? aiProviderProfile : null,
agentSettings,
secrets,
};
@@ -380,6 +802,110 @@ export const updateStatus = mutation({
if (args.error !== undefined) patch.error = args.error;
if (args.summary !== undefined) patch.summary = args.summary;
await ctx.db.patch(args.jobId, patch);
if (job.threadId) {
const threadStatus =
args.status === 'queued' || args.status === 'claimed'
? 'queued'
: args.status === 'running' || args.status === 'checks_running'
? 'running'
: args.status === 'changes_ready'
? 'changes_ready'
: args.status === 'draft_pr_opened'
? 'draft_pr_opened'
: args.status === 'failed' || args.status === 'timed_out'
? 'failed'
: args.status === 'cancelled'
? 'cancelled'
: undefined;
if (threadStatus) {
const threadPatch: Partial<Doc<'threads'>> = {
status: threadStatus,
summary: args.summary ?? job.summary,
updatedAt: now,
};
if (
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
args.status,
)
) {
threadPatch.resolvedAt = now;
}
await ctx.db.patch(job.threadId, threadPatch);
}
}
return { success: true };
},
});
export const markWorkspaceActive = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
opencodeSessionId: v.optional(v.string()),
containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
await ctx.db.patch(args.jobId, {
workspaceStatus: 'active',
opencodeSessionId: optionalText(args.opencodeSessionId),
containerId: optionalText(args.containerId),
workspaceUrl: optionalText(args.workspaceUrl),
workspaceExpiresAt: args.workspaceExpiresAt,
lastHeartbeatAt: now,
updatedAt: now,
});
return { success: true };
},
});
export const markWorkspaceStopped = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
workspaceStatus: v.optional(workspaceStatus),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
await ctx.db.patch(args.jobId, {
workspaceStatus: args.workspaceStatus ?? 'stopped',
updatedAt: now,
});
return { success: true };
},
});
export const heartbeatWorkspace = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
await ctx.db.patch(args.jobId, {
workspaceStatus: 'active',
lastHeartbeatAt: Date.now(),
updatedAt: Date.now(),
});
return { success: true };
},
});
@@ -416,6 +942,98 @@ export const completeWithDraftPr = mutation({
summary: args.summary,
updatedAt: now,
});
if (job.threadId) {
await ctx.db.patch(job.threadId, {
status: 'draft_pr_opened',
summary: args.summary,
updatedAt: now,
resolvedAt: now,
});
}
return { success: true };
},
});
export const applyMaintenanceDecision = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
decision: maintenanceDecision,
risk: maintenanceRisk,
summary: v.string(),
ignoredCommitShas: v.array(v.string()),
ignoredReason: v.optional(v.string()),
recommendedAction: v.string(),
requiresUserApproval: v.boolean(),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
if (!job.threadId) return { success: true };
const now = Date.now();
const outcome =
args.decision === 'sync'
? 'sync_recommended'
: args.decision === 'ignore'
? 'ignored'
: args.decision === 'open_review_pr'
? 'review_pr_recommended'
: args.decision === 'conflict_resolution'
? 'conflict_resolution_required'
: args.decision === 'manual_review'
? 'manual_review_required'
: 'unknown';
const status =
args.decision === 'ignore'
? 'ignored'
: args.decision === 'sync' && !args.requiresUserApproval
? 'resolved'
: 'waiting_for_user';
const threadPatch: Partial<Doc<'threads'>> = {
status,
maintenanceOutcome: outcome,
summary: args.summary,
ignoredCommitShas: args.ignoredCommitShas,
ignoredReason: args.ignoredReason,
updatedAt: now,
};
if (status === 'ignored' || status === 'resolved') {
threadPatch.resolvedAt = now;
}
await ctx.db.patch(job.threadId, threadPatch);
if (args.decision === 'ignore' && args.ignoredCommitShas.length > 0) {
const thread = await ctx.db.get(job.threadId);
await ctx.db.insert('ignoredUpstreamChanges', {
spoonId: job.spoonId,
ownerId: job.ownerId,
upstreamFrom: thread?.upstreamFrom,
upstreamTo: thread?.upstreamTo ?? job.upstreamRepo,
commitShas: args.ignoredCommitShas,
reason: args.ignoredReason ?? args.summary,
decidedBy: 'agent',
threadId: job.threadId,
createdAt: now,
});
}
await ctx.db.insert('threadMessages', {
threadId: job.threadId,
ownerId: job.ownerId,
spoonId: job.spoonId,
role: 'assistant',
content: args.summary,
status: 'completed',
metadata: JSON.stringify({
decision: args.decision,
risk: args.risk,
recommendedAction: args.recommendedAction,
}),
createdAt: now,
updatedAt: now,
});
return { success: true };
},
});
@@ -449,6 +1067,108 @@ export const appendEvent = mutation({
},
});
export const appendMessage = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
role: messageRole,
content: v.string(),
status: messageStatus,
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
const messageId = await ctx.db.insert('agentJobMessages', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
role: args.role,
content: args.content,
status: args.status,
metadata: args.metadata,
createdAt: now,
updatedAt: now,
});
if (job.threadId) {
await ctx.db.insert('threadMessages', {
threadId: job.threadId,
spoonId: job.spoonId,
ownerId: job.ownerId,
role: args.role,
content: args.content,
status: args.status,
metadata: args.metadata,
createdAt: now,
updatedAt: now,
});
}
return messageId;
},
});
export const updateMessage = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
messageId: v.id('agentJobMessages'),
content: v.optional(v.string()),
status: v.optional(messageStatus),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const message = await ctx.db.get(args.messageId);
if (!message) throw new ConvexError('Agent message not found.');
const job = await ctx.db.get(message.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const patch: Partial<Doc<'agentJobMessages'>> = {
updatedAt: Date.now(),
};
if (args.content !== undefined) patch.content = args.content;
if (args.status !== undefined) patch.status = args.status;
if (args.metadata !== undefined) patch.metadata = args.metadata;
await ctx.db.patch(args.messageId, patch);
return { success: true };
},
});
export const recordWorkspaceChange = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
path: v.string(),
source: changeSource,
changeType,
diff: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
return await ctx.db.insert('agentWorkspaceChanges', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
path: args.path,
source: args.source,
changeType: args.changeType,
diff: args.diff,
createdAt: Date.now(),
});
},
});
export const addArtifact = mutation({
args: {
workerToken: v.string(),
+35 -6
View File
@@ -11,6 +11,7 @@ type ClaimedJob = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'> | null;
aiSettings: Doc<'userAiSettings'> | null;
aiProviderProfile: Doc<'aiProviderProfiles'> | null;
agentSettings: Doc<'spoonAgentSettings'> | null;
secrets: Doc<'spoonSecrets'>[];
};
@@ -19,10 +20,20 @@ type WorkerClaim = {
job: Doc<'agentJobs'>;
spoon: Doc<'spoons'>;
openai: {
apiKey: string;
apiKey?: string;
model: string;
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
};
aiProviderProfile?: {
id: string;
name: string;
provider: Doc<'aiProviderProfiles'>['provider'];
authType: Doc<'aiProviderProfiles'>['authType'];
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: Doc<'aiProviderProfiles'>['reasoningEffort'];
};
agentSettings: Doc<'spoonAgentSettings'> | null;
github: {
installationId?: string;
@@ -53,18 +64,36 @@ export const claimNextForWorker = action({
if (!claimed.spoon) {
throw new ConvexError('Claimed job points at a missing Spoon.');
}
if (!claimed.aiSettings?.encryptedApiKey) {
if (!claimed.aiProviderProfile) {
throw new ConvexError(
'OpenAI is not configured for this user. Add an OpenAI API key in settings.',
'AI is not configured for this user. Add an AI provider in settings.',
);
}
if (
claimed.aiProviderProfile.authType !== 'none' &&
!claimed.aiProviderProfile.encryptedSecret
) {
throw new ConvexError('Selected AI provider is missing credentials.');
}
const profile = claimed.aiProviderProfile;
return {
job: claimed.job,
spoon: claimed.spoon,
openai: {
apiKey: decryptSecret(claimed.aiSettings.encryptedApiKey),
model: claimed.job.model,
reasoningEffort: claimed.job.reasoningEffort,
model: profile.defaultModel,
reasoningEffort: profile.reasoningEffort,
},
aiProviderProfile: {
id: profile._id,
name: profile.name,
provider: profile.provider,
authType: profile.authType,
secret: profile.encryptedSecret
? decryptSecret(profile.encryptedSecret)
: undefined,
baseUrl: profile.baseUrl,
model: profile.defaultModel,
reasoningEffort: profile.reasoningEffort,
},
agentSettings: claimed.agentSettings,
github: {
@@ -0,0 +1,273 @@
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getRequiredUserId, optionalText } from './model';
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
isDefault?: boolean;
};
const provider = v.union(
v.literal('openai'),
v.literal('anthropic'),
v.literal('google'),
v.literal('openrouter'),
v.literal('requesty'),
v.literal('litellm'),
v.literal('cloudflare_ai_gateway'),
v.literal('custom_openai_compatible'),
v.literal('opencode_openai_login'),
);
const authType = v.union(
v.literal('api_key'),
v.literal('opencode_auth_json'),
v.literal('none'),
);
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const isConfigured = (profile: Doc<'aiProviderProfiles'>) =>
profile.authType === 'none' || Boolean(profile.encryptedSecret);
const defaultPatch = (isDefault: boolean) =>
({ isDefault }) as Partial<Doc<'aiProviderProfiles'>>;
const publicProfile = (
profile: AiProviderProfileWithDefault,
defaultProfileId?: Id<'aiProviderProfiles'>,
) => ({
_id: profile._id,
_creationTime: profile._creationTime,
name: profile.name,
provider: profile.provider,
authType: profile.authType,
secretPreview: profile.secretPreview,
baseUrl: profile.baseUrl,
defaultModel: profile.defaultModel,
modelOptions: profile.modelOptions,
reasoningEffort: profile.reasoningEffort,
enabled: profile.enabled,
configured: isConfigured(profile),
isDefault: profile._id === defaultProfileId,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
});
const requireOwnedProfile = async (
ctx: MutationCtx,
profileId: Id<'aiProviderProfiles'>,
ownerId: Id<'users'>,
) => {
const profile = await ctx.db.get(profileId);
if (profile?.ownerId !== ownerId) {
throw new ConvexError('AI provider profile not found.');
}
return profile;
};
export const listMine = 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 && isConfigured(profile),
);
const explicitDefault = configuredProfiles.find(
(profile) => (profile as AiProviderProfileWithDefault).isDefault,
);
const defaultProfileId =
explicitDefault?._id ??
(configuredProfiles.length === 1
? configuredProfiles[0]?._id
: undefined);
return profiles.map((profile) => publicProfile(profile, defaultProfileId));
},
});
export const get = query({
args: { profileId: v.id('aiProviderProfiles') },
handler: async (ctx, { profileId }) => {
const ownerId = await getRequiredUserId(ctx);
const profile = await ctx.db.get(profileId);
if (profile?.ownerId !== ownerId) {
throw new ConvexError('AI provider profile not found.');
}
const profiles = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const configuredProfiles = profiles.filter(
(item) => item.enabled && isConfigured(item),
);
const explicitDefault = configuredProfiles.find(
(item) => (item as AiProviderProfileWithDefault).isDefault,
);
const defaultProfileId =
explicitDefault?._id ??
(configuredProfiles.length === 1
? configuredProfiles[0]?._id
: undefined);
return publicProfile(profile, defaultProfileId);
},
});
export const upsertEncryptedInternal = internalMutation({
args: {
ownerId: v.id('users'),
profileId: v.optional(v.id('aiProviderProfiles')),
name: v.string(),
provider,
authType,
encryptedSecret: v.optional(v.string()),
secretPreview: v.optional(v.string()),
baseUrl: v.optional(v.string()),
defaultModel: v.string(),
modelOptions: v.optional(v.array(v.string())),
reasoningEffort,
enabled: v.boolean(),
},
handler: async (ctx, args) => {
const now = Date.now();
const patch: Partial<Doc<'aiProviderProfiles'>> = {
name: args.name.trim() || 'AI provider',
provider: args.provider,
authType: args.authType,
baseUrl: optionalText(args.baseUrl),
defaultModel: args.defaultModel.trim() || 'gpt-5.5',
modelOptions: args.modelOptions
?.map((model) => model.trim())
.filter(Boolean),
reasoningEffort: args.reasoningEffort,
enabled: args.enabled,
updatedAt: now,
};
if (args.encryptedSecret !== undefined) {
patch.encryptedSecret = args.encryptedSecret;
patch.secretPreview = args.secretPreview;
}
if (args.profileId) {
await requireOwnedProfile(ctx, args.profileId, args.ownerId);
await ctx.db.patch(args.profileId, patch);
return args.profileId;
}
const existingProfiles = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId))
.collect();
const shouldBecomeDefault =
args.enabled &&
(args.authType === 'none' || Boolean(args.encryptedSecret)) &&
existingProfiles.filter(
(profile) => profile.enabled && isConfigured(profile),
).length === 0;
const profileId = await ctx.db.insert('aiProviderProfiles', {
ownerId: args.ownerId,
name: patch.name ?? 'AI provider',
provider: args.provider,
authType: args.authType,
encryptedSecret: args.encryptedSecret,
secretPreview: args.secretPreview,
baseUrl: optionalText(args.baseUrl),
defaultModel: patch.defaultModel ?? 'gpt-5.5',
modelOptions: patch.modelOptions,
reasoningEffort: args.reasoningEffort,
enabled: args.enabled,
createdAt: now,
updatedAt: now,
});
if (shouldBecomeDefault) {
await ctx.db.patch(profileId, defaultPatch(true));
}
return profileId;
},
});
export const updateMetadata = mutation({
args: {
profileId: v.id('aiProviderProfiles'),
name: v.string(),
baseUrl: v.optional(v.string()),
defaultModel: v.string(),
modelOptions: v.optional(v.array(v.string())),
reasoningEffort,
enabled: v.boolean(),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await requireOwnedProfile(ctx, args.profileId, ownerId);
await ctx.db.patch(args.profileId, {
name: args.name.trim() || 'AI provider',
baseUrl: optionalText(args.baseUrl),
defaultModel: args.defaultModel.trim() || 'gpt-5.5',
modelOptions: args.modelOptions
?.map((model) => model.trim())
.filter(Boolean),
reasoningEffort: args.reasoningEffort,
enabled: args.enabled,
updatedAt: Date.now(),
});
return { success: true };
},
});
export const remove = mutation({
args: { profileId: v.id('aiProviderProfiles') },
handler: async (ctx, { profileId }) => {
const ownerId = await getRequiredUserId(ctx);
const profile = (await requireOwnedProfile(
ctx,
profileId,
ownerId,
)) as AiProviderProfileWithDefault;
await ctx.db.delete(profileId);
if (profile.isDefault) {
const remaining = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const nextDefault = remaining.find(
(item) => item.enabled && isConfigured(item),
);
if (nextDefault) {
await ctx.db.patch(nextDefault._id, defaultPatch(true));
}
}
return { success: true };
},
});
export const setDefault = mutation({
args: { profileId: v.id('aiProviderProfiles') },
handler: async (ctx, { profileId }) => {
const ownerId = await getRequiredUserId(ctx);
const target = await requireOwnedProfile(ctx, profileId, ownerId);
if (!target.enabled || !isConfigured(target)) {
throw new ConvexError('Default provider must be enabled and configured.');
}
const profiles = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
await Promise.all(
profiles.map((profile) =>
ctx.db.patch(profile._id, defaultPatch(profile._id === profileId)),
),
);
return { success: true };
},
});
@@ -0,0 +1,90 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { encryptSecret } from './secretCrypto';
const provider = v.union(
v.literal('openai'),
v.literal('anthropic'),
v.literal('google'),
v.literal('openrouter'),
v.literal('requesty'),
v.literal('litellm'),
v.literal('cloudflare_ai_gateway'),
v.literal('custom_openai_compatible'),
v.literal('opencode_openai_login'),
);
const authType = v.union(
v.literal('api_key'),
v.literal('opencode_auth_json'),
v.literal('none'),
);
const reasoningEffort = v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
);
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const previewSecret = (secret: string) => {
const trimmed = secret.trim();
if (!trimmed) return undefined;
if (trimmed.startsWith('{')) return 'auth json configured';
if (trimmed.length <= 10) return 'configured';
return `${trimmed.slice(0, 7)}...${trimmed.slice(-4)}`;
};
export const save = action({
args: {
profileId: v.optional(v.id('aiProviderProfiles')),
name: v.string(),
provider,
authType,
secret: v.optional(v.string()),
baseUrl: v.optional(v.string()),
defaultModel: v.string(),
modelOptions: v.optional(v.array(v.string())),
reasoningEffort,
enabled: v.boolean(),
},
handler: async (ctx, args): Promise<Id<'aiProviderProfiles'>> => {
const ownerId = await getRequiredUserId(ctx);
const secret = args.secret?.trim();
if (!args.profileId && args.authType !== 'none' && !secret) {
throw new ConvexError('A credential is required for this provider.');
}
return await ctx.runMutation(
internal.aiProviderProfiles.upsertEncryptedInternal,
{
ownerId,
profileId: args.profileId,
name: args.name,
provider: args.provider,
authType: args.authType,
encryptedSecret: secret ? encryptSecret(secret) : undefined,
secretPreview: secret ? previewSecret(secret) : undefined,
baseUrl: args.baseUrl,
defaultModel: args.defaultModel,
modelOptions: args.modelOptions,
reasoningEffort: args.reasoningEffort,
enabled: args.enabled,
},
);
},
});
-201
View File
@@ -1,201 +0,0 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { reviewUpstreamCompatibility } from './openaiClient';
import { decryptSecret } from './secretCrypto';
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const reviewLatestUpstreamChanges = action({
args: { spoonId: v.id('spoons') },
handler: async (
ctx,
{ spoonId },
): Promise<{
reviewId: Id<'aiReviews'>;
risk: 'low' | 'medium' | 'high';
recommendedAction:
| 'sync'
| 'open_review_pr'
| 'manual_review'
| 'do_not_sync';
}> => {
const ownerId = await getRequiredUserId(ctx);
const spoon: Doc<'spoons'> = await ctx.runQuery(
internal.spoons.getOwnedForAction,
{
spoonId,
ownerId,
},
);
const [state, settings, upstreamCommits, forkCommits]: [
Doc<'spoonRepositoryStates'> | null,
Doc<'spoonSettings'> | null,
Doc<'spoonCommits'>[],
Doc<'spoonCommits'>[],
] = await Promise.all([
ctx.runQuery(internal.spoonState.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonSettings.getInternal, { spoonId, ownerId }),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'upstream',
limit: 80,
}),
ctx.runQuery(internal.spoonCommits.listInternal, {
spoonId,
ownerId,
side: 'fork',
limit: 80,
}),
]);
const aiSettings: Doc<'userAiSettings'> | null = await ctx.runQuery(
internal.aiSettings.getForUserInternal,
{ userId: ownerId },
);
if (!aiSettings?.encryptedApiKey) {
throw new ConvexError(
'Add your OpenAI API key in Settings before running AI review.',
);
}
const model = aiSettings.model;
const syncRunId: Id<'syncRuns'> = await ctx.runMutation(
internal.syncRuns.createInternal,
{
spoonId,
ownerId,
kind: 'ai_review',
status: 'running',
summary: 'Reviewing upstream changes with OpenAI.',
},
);
const reviewId: Id<'aiReviews'> = await ctx.runMutation(
internal.aiReviews.createInternal,
{
spoonId,
ownerId,
syncRunId,
model,
status: 'running',
reviewType: 'upstream_update',
inputSummary: `${upstreamCommits.length} upstream commit(s), ${forkCommits.length} fork-only commit(s).`,
},
);
try {
if (upstreamCommits.length === 0) {
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: 'The fork is already up to date with upstream.',
risk: 'low',
compatible: true,
requiresHumanReview: false,
recommendedAction: 'sync',
potentialConflicts: [],
importantFiles: [],
reasoningSummary:
'No upstream-only commits are cached for this Spoon, so there is nothing to review.',
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'clean',
aiAssessment: 'No upstream-only commits are waiting.',
}),
]);
return { reviewId, risk: 'low', recommendedAction: 'sync' };
}
const review = await reviewUpstreamCompatibility(
{
spoonName: spoon.name,
upstreamFullName:
state?.upstreamFullName ??
`${spoon.upstreamOwner}/${spoon.upstreamRepo}`,
forkFullName:
state?.forkFullName ??
`${spoon.forkOwner ?? 'unknown'}/${spoon.forkRepo ?? 'unknown'}`,
status: state?.status ?? spoon.syncStatus ?? 'unknown',
upstreamAheadBy: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
upstreamCommits: upstreamCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
forkCommits: forkCommits.map((commit) => ({
sha: commit.sha,
message: commit.message,
authorName: commit.authorName,
committedAt: commit.committedAt,
})),
importantFilePatterns: settings?.importantFilePatterns,
ignoredFilePatterns: settings?.ignoredFilePatterns,
},
{
apiKey: decryptSecret(aiSettings.encryptedApiKey),
model,
reasoningEffort: aiSettings.reasoningEffort,
},
);
await ctx.runMutation(internal.aiReviews.completeInternal, {
reviewId,
outputSummary: review.summary,
risk: review.risk,
compatible: review.compatible,
requiresHumanReview: review.requiresHumanReview,
recommendedAction: review.recommendedAction,
potentialConflicts: review.potentialConflicts,
importantFiles: review.importantFiles,
reasoningSummary: review.reasoningSummary,
});
await Promise.all([
ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
lastAiReviewId: reviewId,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status:
review.compatible && review.risk === 'low'
? 'clean'
: 'needs_review',
aiAssessment: review.summary,
}),
]);
return {
reviewId,
risk: review.risk,
recommendedAction: review.recommendedAction,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await Promise.all([
ctx.runMutation(internal.aiReviews.failInternal, {
reviewId,
error: message,
}),
ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: message,
}),
]);
throw new ConvexError(message);
}
},
});
+108
View File
@@ -50,6 +50,7 @@ const refreshOwnedSpoon = async (
ownerId: Id<'users'>,
spoonId: Id<'spoons'>,
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
allowAutoSync = true,
): Promise<{
success: boolean;
status: ReturnType<typeof toStatus>;
@@ -200,6 +201,87 @@ const refreshOwnedSpoon = async (
status: status === 'diverged' ? 'needs_review' : 'clean',
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
});
if (status === 'behind' && forkCompare.aheadBy === 0 && allowAutoSync) {
try {
await syncForkBranch(octokit, {
forkOwner,
forkRepo,
branch: resolvedForkBranch,
});
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'merged',
decision: 'auto_synced',
summary:
'Fork had no custom commits, so Spoon synced it with upstream automatically.',
});
return await refreshOwnedSpoon(ctx, ownerId, spoonId, kind, false);
} catch (syncError) {
const message =
syncError instanceof Error ? syncError.message : String(syncError);
const threadId = await ctx.runMutation(
internal.threads.createMaintenanceThread,
{
spoonId,
ownerId,
source: 'merge_conflict',
title: `Resolve upstream sync conflict for ${spoon.name}`,
summary: `GitHub refused the automatic upstream sync: ${message}`,
upstreamFrom: upstreamCompare.mergeBaseSha,
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
forkHeadAtCreation: forkCompare.headSha,
mergeBaseAtCreation:
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
relatedSyncRunId: syncRunId,
jobType: 'conflict_resolution',
},
);
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
threadId,
status: 'conflict',
decision: 'thread_created',
error: message,
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'conflict',
lastError: message,
});
return {
success: true,
status: 'unknown' as const,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
};
}
}
if (status === 'diverged') {
const threadId = await ctx.runMutation(
internal.threads.createMaintenanceThread,
{
spoonId,
ownerId,
source: 'upstream_update',
title: `Review upstream changes for ${spoon.name}`,
summary: `Upstream has ${upstreamCompare.aheadBy} commit(s) and the fork has ${forkCompare.aheadBy} custom commit(s). Review whether upstream should be merged, ignored, or resolved in a draft PR.`,
upstreamFrom: upstreamCompare.mergeBaseSha,
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
forkHeadAtCreation: forkCompare.headSha,
mergeBaseAtCreation:
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
relatedSyncRunId: syncRunId,
jobType: 'maintenance_review',
},
);
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
threadId,
decision: 'thread_created',
});
}
return {
success: true,
status,
@@ -301,6 +383,32 @@ export const syncForkWithUpstream = action({
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const conflict = message.toLowerCase().includes('conflict');
if (conflict) {
const threadId = await ctx.runMutation(
internal.threads.createMaintenanceThread,
{
spoonId,
ownerId,
source: 'merge_conflict',
title: `Resolve upstream sync conflict for ${spoon.name}`,
summary: `GitHub reported a conflict while syncing upstream into this fork: ${message}`,
upstreamTo:
state.upstreamHeadSha ??
spoon.lastUpstreamCommit ??
`${Date.now()}`,
forkHeadAtCreation: state.forkHeadSha ?? spoon.lastForkCommit,
mergeBaseAtCreation:
state.mergeBaseSha ?? spoon.lastMergeBaseCommit,
relatedSyncRunId: syncRunId,
jobType: 'conflict_resolution',
},
);
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
threadId,
decision: 'thread_created',
});
}
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: conflict ? 'conflict' : 'failed',
-160
View File
@@ -1,160 +0,0 @@
import { ConvexError } from 'convex/values';
import OpenAI from 'openai';
type ReviewRisk = 'low' | 'medium' | 'high';
type ReviewAction = 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync';
export type AiCompatibilityReview = {
summary: string;
risk: ReviewRisk;
compatible: boolean;
requiresHumanReview: boolean;
recommendedAction: ReviewAction;
potentialConflicts: string[];
importantFiles: string[];
reasoningSummary: string;
};
export type ReviewInput = {
spoonName: string;
upstreamFullName: string;
forkFullName: string;
status: string;
upstreamAheadBy: number;
forkAheadBy: number;
upstreamCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
forkCommits: {
sha: string;
message: string;
authorName?: string;
committedAt?: number;
}[];
importantFilePatterns?: string[];
ignoredFilePatterns?: string[];
};
export type ReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
export type OpenAiReviewSettings = {
apiKey: string;
model: string;
reasoningEffort: ReasoningEffort;
};
const reviewSchema = {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'string' },
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
compatible: { type: 'boolean' },
requiresHumanReview: { type: 'boolean' },
recommendedAction: {
type: 'string',
enum: ['sync', 'open_review_pr', 'manual_review', 'do_not_sync'],
},
potentialConflicts: { type: 'array', items: { type: 'string' } },
importantFiles: { type: 'array', items: { type: 'string' } },
reasoningSummary: { type: 'string' },
},
required: [
'summary',
'risk',
'compatible',
'requiresHumanReview',
'recommendedAction',
'potentialConflicts',
'importantFiles',
'reasoningSummary',
],
} as const;
const isReviewRisk = (value: unknown): value is ReviewRisk =>
value === 'low' || value === 'medium' || value === 'high';
const isReviewAction = (value: unknown): value is ReviewAction =>
value === 'sync' ||
value === 'open_review_pr' ||
value === 'manual_review' ||
value === 'do_not_sync';
const validateReview = (value: unknown): AiCompatibilityReview => {
if (!value || typeof value !== 'object') {
throw new ConvexError('OpenAI returned an invalid review payload.');
}
const record = value as Record<string, unknown>;
if (
typeof record.summary !== 'string' ||
!isReviewRisk(record.risk) ||
typeof record.compatible !== 'boolean' ||
typeof record.requiresHumanReview !== 'boolean' ||
!isReviewAction(record.recommendedAction) ||
!Array.isArray(record.potentialConflicts) ||
!Array.isArray(record.importantFiles) ||
typeof record.reasoningSummary !== 'string'
) {
throw new ConvexError('OpenAI review did not match the expected schema.');
}
return {
summary: record.summary,
risk: record.risk,
compatible: record.compatible,
requiresHumanReview: record.requiresHumanReview,
recommendedAction: record.recommendedAction,
potentialConflicts: record.potentialConflicts.filter(
(item): item is string => typeof item === 'string',
),
importantFiles: record.importantFiles.filter(
(item): item is string => typeof item === 'string',
),
reasoningSummary: record.reasoningSummary,
};
};
export const reviewUpstreamCompatibility = async (
input: ReviewInput,
settings: OpenAiReviewSettings,
): Promise<AiCompatibilityReview> => {
const response = await new OpenAI({
apiKey: settings.apiKey,
}).responses.create({
model: settings.model,
store: false,
reasoning: {
effort: settings.reasoningEffort,
},
input: [
{
role: 'system',
content:
'You are reviewing whether upstream changes can be safely brought into a maintained fork. You do not execute code. You do not claim tests passed. Treat fork-only commits as user customizations that must be preserved. If changed files overlap with fork-only changes, increase risk. If patch context is incomplete, say so and require human review. Prefer conservative recommendations. Return only the required structured output.',
},
{
role: 'user',
content: JSON.stringify(input, null, 2),
},
],
text: {
format: {
type: 'json_schema',
name: 'spoon_upstream_compatibility_review',
strict: true,
schema: reviewSchema,
},
},
});
const raw = response.output_text;
if (!raw) throw new ConvexError('OpenAI returned an empty review.');
return validateReview(JSON.parse(raw));
};
+232
View File
@@ -219,6 +219,7 @@ const applicationTables = {
syncRuns: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
threadId: v.optional(v.id('threads')),
kind: v.union(
v.literal('scheduled_check'),
v.literal('manual_check'),
@@ -241,6 +242,14 @@ const applicationTables = {
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
decision: v.optional(
v.union(
v.literal('auto_synced'),
v.literal('thread_created'),
v.literal('ignored'),
v.literal('failed'),
),
),
createdAt: v.number(),
updatedAt: v.number(),
})
@@ -339,6 +348,46 @@ const applicationTables = {
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider']),
aiProviderProfiles: defineTable({
ownerId: v.id('users'),
name: v.string(),
provider: v.union(
v.literal('openai'),
v.literal('anthropic'),
v.literal('google'),
v.literal('openrouter'),
v.literal('requesty'),
v.literal('litellm'),
v.literal('cloudflare_ai_gateway'),
v.literal('custom_openai_compatible'),
v.literal('opencode_openai_login'),
),
authType: v.union(
v.literal('api_key'),
v.literal('opencode_auth_json'),
v.literal('none'),
),
encryptedSecret: v.optional(v.string()),
secretPreview: v.optional(v.string()),
baseUrl: v.optional(v.string()),
defaultModel: v.string(),
modelOptions: v.optional(v.array(v.string())),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
enabled: v.boolean(),
isDefault: v.optional(v.boolean()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_owner_provider', ['ownerId', 'provider'])
.index('by_owner_enabled', ['ownerId', 'enabled']),
agentRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
@@ -395,6 +444,9 @@ const applicationTables = {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
enabled: v.boolean(),
runtime: v.optional(
v.union(v.literal('opencode'), v.literal('openai_direct')),
),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.string(),
installCommand: v.optional(v.string()),
@@ -411,6 +463,20 @@ const applicationTables = {
),
maxJobDurationMs: v.number(),
maxOutputBytes: v.number(),
envFilePath: v.optional(
v.union(
v.literal('.env'),
v.literal('.env.local'),
v.literal('.env.production'),
v.literal('.env.production.local'),
v.literal('custom'),
),
),
customEnvFilePath: v.optional(v.string()),
materializeEnvFileByDefault: v.optional(v.boolean()),
autoDetectCommands: v.optional(v.boolean()),
allowUserFileEditing: v.optional(v.boolean()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
createdAt: v.number(),
updatedAt: v.number(),
})
@@ -420,6 +486,14 @@ const applicationTables = {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentRequestId: v.id('agentRequests'),
threadId: v.optional(v.id('threads')),
jobType: v.optional(
v.union(
v.literal('user_change'),
v.literal('maintenance_review'),
v.literal('conflict_resolution'),
),
),
status: v.union(
v.literal('queued'),
v.literal('claimed'),
@@ -433,8 +507,29 @@ const applicationTables = {
v.literal('timed_out'),
),
prompt: v.string(),
runtime: v.optional(
v.union(v.literal('openai_direct'), v.literal('opencode')),
),
workspaceStatus: v.optional(
v.union(
v.literal('not_started'),
v.literal('starting'),
v.literal('active'),
v.literal('idle'),
v.literal('stopped'),
v.literal('expired'),
v.literal('failed'),
),
),
baseBranch: v.string(),
workBranch: v.string(),
opencodeSessionId: v.optional(v.string()),
containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()),
lastHeartbeatAt: v.optional(v.number()),
envFilePath: v.optional(v.string()),
materializeEnvFile: v.optional(v.boolean()),
githubInstallationId: v.optional(v.string()),
forkOwner: v.string(),
forkRepo: v.string(),
@@ -442,6 +537,7 @@ const applicationTables = {
upstreamOwner: v.string(),
upstreamRepo: v.string(),
selectedSecretIds: v.array(v.id('spoonSecrets')),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
@@ -468,6 +564,50 @@ const applicationTables = {
.index('by_request', ['agentRequestId'])
.index('by_status', ['status'])
.index('by_claim', ['status', 'createdAt']),
agentJobMessages: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
role: v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
),
content: v.string(),
status: v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentWorkspaceChanges: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
path: v.string(),
source: v.union(
v.literal('user'),
v.literal('agent'),
v.literal('command'),
),
changeType: v.union(
v.literal('added'),
v.literal('modified'),
v.literal('deleted'),
v.literal('renamed'),
),
diff: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_path', ['jobId', 'path']),
agentJobEvents: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
@@ -523,6 +663,98 @@ const applicationTables = {
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
threads: defineTable({
ownerId: v.id('users'),
spoonId: v.optional(v.id('spoons')),
title: v.string(),
summary: v.optional(v.string()),
source: v.union(
v.literal('user_request'),
v.literal('upstream_update'),
v.literal('merge_conflict'),
v.literal('manual_review'),
v.literal('system'),
),
status: v.union(
v.literal('open'),
v.literal('queued'),
v.literal('running'),
v.literal('waiting_for_user'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('resolved'),
v.literal('ignored'),
v.literal('failed'),
v.literal('cancelled'),
),
priority: v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
forkHeadAtCreation: v.optional(v.string()),
mergeBaseAtCreation: v.optional(v.string()),
relatedSyncRunId: v.optional(v.id('syncRuns')),
relatedAgentRequestId: v.optional(v.id('agentRequests')),
latestAgentJobId: v.optional(v.id('agentJobs')),
maintenanceOutcome: v.optional(
v.union(
v.literal('auto_synced'),
v.literal('sync_recommended'),
v.literal('ignored'),
v.literal('review_pr_recommended'),
v.literal('manual_review_required'),
v.literal('conflict_resolution_required'),
v.literal('failed'),
v.literal('unknown'),
),
),
ignoredCommitShas: v.optional(v.array(v.string())),
ignoredReason: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
resolvedAt: v.optional(v.number()),
})
.index('by_owner', ['ownerId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_spoon', ['spoonId'])
.index('by_source', ['ownerId', 'source'])
.index('by_created', ['createdAt']),
threadMessages: defineTable({
threadId: v.id('threads'),
ownerId: v.id('users'),
spoonId: v.optional(v.id('spoons')),
role: v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
),
content: v.string(),
status: v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_thread', ['threadId'])
.index('by_owner', ['ownerId']),
ignoredUpstreamChanges: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.string(),
commitShas: v.array(v.string()),
reason: v.string(),
decidedBy: v.union(v.literal('agent'), v.literal('user')),
threadId: v.optional(v.id('threads')),
createdAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_upstream_to', ['spoonId', 'upstreamTo']),
};
export default defineSchema({
+52 -9
View File
@@ -13,21 +13,28 @@ const reasoningEffort = v.union(
v.literal('xhigh'),
);
const agentModel = v.union(
v.literal('gpt-5.1-codex'),
v.literal('gpt-5.5'),
v.literal('gpt-5.5-pro'),
v.literal('gpt-5.4'),
v.literal('gpt-5.4-mini'),
const runtime = v.literal('opencode');
const envFilePath = v.union(
v.literal('.env'),
v.literal('.env.local'),
v.literal('.env.production'),
v.literal('.env.production.local'),
v.literal('custom'),
);
const defaults = {
enabled: true,
runtime: 'opencode' as const,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high' as const,
agentModel: '',
reasoningEffort: 'medium' as const,
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
envFilePath: '.env.local' as const,
materializeEnvFileByDefault: false,
autoDetectCommands: true,
allowUserFileEditing: true,
};
export const getForSpoon = query({
@@ -60,10 +67,18 @@ export const update = mutation({
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.optional(agentModel),
runtime: v.optional(runtime),
agentModel: v.optional(v.string()),
reasoningEffort: v.optional(reasoningEffort),
maxJobDurationMs: v.optional(v.number()),
maxOutputBytes: v.optional(v.number()),
envFilePath: v.optional(envFilePath),
customEnvFilePath: v.optional(v.string()),
materializeEnvFileByDefault: v.optional(v.boolean()),
autoDetectCommands: v.optional(v.boolean()),
allowUserFileEditing: v.optional(v.boolean()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
clearAiProviderProfile: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
@@ -107,6 +122,9 @@ export const update = mutation({
if (args.testCommand !== undefined) {
patch.testCommand = optionalText(args.testCommand);
}
if (args.runtime !== undefined) {
patch.runtime = 'opencode';
}
if (args.agentModel !== undefined) {
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
}
@@ -119,6 +137,31 @@ export const update = mutation({
if (args.maxOutputBytes !== undefined) {
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
}
if (args.envFilePath !== undefined) {
patch.envFilePath = args.envFilePath;
}
if (args.customEnvFilePath !== undefined) {
patch.customEnvFilePath = optionalText(args.customEnvFilePath);
}
if (args.materializeEnvFileByDefault !== undefined) {
patch.materializeEnvFileByDefault = args.materializeEnvFileByDefault;
}
if (args.autoDetectCommands !== undefined) {
patch.autoDetectCommands = args.autoDetectCommands;
}
if (args.allowUserFileEditing !== undefined) {
patch.allowUserFileEditing = args.allowUserFileEditing;
}
if (args.aiProviderProfileId !== undefined) {
const profile = await ctx.db.get(args.aiProviderProfileId);
if (profile?.ownerId !== ownerId) {
throw new Error('AI provider profile not found.');
}
patch.aiProviderProfileId = args.aiProviderProfileId;
}
if (args.clearAiProviderProfile) {
patch.aiProviderProfileId = undefined;
}
await ctx.db.patch(settings._id, patch);
return { success: true };
+37 -5
View File
@@ -100,8 +100,14 @@ export const getDetails = query({
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
const [state, settings, latestReview, recentRuns, agentRequests] =
await Promise.all([
const [
state,
settings,
latestReview,
recentRuns,
agentRequests,
ignoredChanges,
] = await Promise.all([
ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
@@ -125,8 +131,28 @@ export const getDetails = query({
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(10),
ctx.db
.query('ignoredUpstreamChanges')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.collect(),
]);
return { spoon, state, settings, latestReview, recentRuns, agentRequests };
const ignoredShas = new Set(
ignoredChanges.flatMap((change) => change.commitShas),
);
const effectiveUpstreamAheadBy = Math.max(
0,
(state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0) - ignoredShas.size,
);
return {
spoon,
state,
settings,
latestReview,
recentRuns,
agentRequests,
ignoredChanges,
effectiveUpstreamAheadBy,
};
},
});
@@ -210,12 +236,18 @@ export const createManual = mutation({
spoonId,
ownerId,
enabled: true,
runtime: 'opencode',
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
branchPrefix: 'spoon/agent',
agentModel: 'gpt-5.1-codex',
reasoningEffort: 'high',
agentModel: '',
reasoningEffort: 'medium',
maxJobDurationMs: 1_800_000,
maxOutputBytes: 200_000,
envFilePath: '.env.local',
materializeEnvFileByDefault: false,
autoDetectCommands: true,
allowUserFileEditing: true,
aiProviderProfileId: undefined,
createdAt: now,
updatedAt: now,
});
+13
View File
@@ -22,6 +22,13 @@ const syncStatus = v.union(
v.literal('merged'),
);
const syncDecision = v.union(
v.literal('auto_synced'),
v.literal('thread_created'),
v.literal('ignored'),
v.literal('failed'),
);
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
@@ -52,6 +59,7 @@ export const createInternal = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
threadId: v.optional(v.id('threads')),
kind: syncKind,
status: syncStatus,
upstreamFrom: v.optional(v.string()),
@@ -60,6 +68,7 @@ export const createInternal = internalMutation({
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
decision: v.optional(syncDecision),
},
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
const now = Date.now();
@@ -74,6 +83,7 @@ export const createInternal = internalMutation({
export const patchInternal = internalMutation({
args: {
syncRunId: v.id('syncRuns'),
threadId: v.optional(v.id('threads')),
status: v.optional(syncStatus),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
@@ -81,9 +91,11 @@ export const patchInternal = internalMutation({
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
decision: v.optional(syncDecision),
},
handler: async (ctx, args) => {
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
if (args.threadId !== undefined) patch.threadId = args.threadId;
if (args.status !== undefined) patch.status = args.status;
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
@@ -95,6 +107,7 @@ export const patchInternal = internalMutation({
patch.mergeRequestUrl = args.mergeRequestUrl;
}
if (args.error !== undefined) patch.error = args.error;
if (args.decision !== undefined) patch.decision = args.decision;
await ctx.db.patch(args.syncRunId, patch);
return { success: true };
},
+458
View File
@@ -0,0 +1,458 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { internal } from './_generated/api';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
const threadSource = v.union(
v.literal('user_request'),
v.literal('upstream_update'),
v.literal('merge_conflict'),
v.literal('manual_review'),
v.literal('system'),
);
const threadStatus = v.union(
v.literal('open'),
v.literal('queued'),
v.literal('running'),
v.literal('waiting_for_user'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('resolved'),
v.literal('ignored'),
v.literal('failed'),
v.literal('cancelled'),
);
const maintenanceOutcome = v.union(
v.literal('auto_synced'),
v.literal('sync_recommended'),
v.literal('ignored'),
v.literal('review_pr_recommended'),
v.literal('manual_review_required'),
v.literal('conflict_resolution_required'),
v.literal('failed'),
v.literal('unknown'),
);
const messageRole = v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
);
const messageStatus = v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
);
const titleFromPrompt = (prompt: string) => {
const firstLine = prompt.trim().split('\n')[0] ?? 'Thread';
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
};
const publicThread = (thread: Doc<'threads'>) => thread;
export const listMine = query({
args: {
status: v.optional(v.union(threadStatus, v.literal('all'))),
source: v.optional(v.union(threadSource, v.literal('all'))),
spoonId: v.optional(v.id('spoons')),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const threads = await ctx.db
.query('threads')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(args.limit ?? 50);
return threads.filter((thread) => {
if (
args.status &&
args.status !== 'all' &&
thread.status !== args.status
) {
return false;
}
if (
args.source &&
args.source !== 'all' &&
thread.source !== args.source
) {
return false;
}
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
return true;
});
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('threads')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
export const get = query({
args: { threadId: v.id('threads') },
handler: async (ctx, { threadId }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
const spoon = thread.spoonId ? await ctx.db.get(thread.spoonId) : null;
const job = thread.latestAgentJobId
? await ctx.db.get(thread.latestAgentJobId)
: null;
return {
thread: publicThread(thread),
spoon: spoon?.ownerId === ownerId ? spoon : null,
latestJob: job?.ownerId === ownerId ? job : null,
};
},
});
export const listMessages = query({
args: { threadId: v.id('threads'), limit: v.optional(v.number()) },
handler: async (ctx, { threadId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
return await ctx.db
.query('threadMessages')
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
.order('asc')
.take(limit ?? 200);
},
});
export const createUserThread = mutation({
args: {
spoonId: v.id('spoons'),
title: v.optional(v.string()),
prompt: v.string(),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const prompt = requireText(args.prompt, 'Prompt');
const now = Date.now();
const threadId = await ctx.db.insert('threads', {
ownerId,
spoonId: args.spoonId,
title: optionalText(args.title) ?? titleFromPrompt(prompt),
summary: prompt,
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('threadMessages', {
threadId,
ownerId,
spoonId: args.spoonId,
role: 'user',
content: prompt,
status: 'completed',
createdAt: now,
updatedAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.agentJobs.createForThreadInternal,
{
threadId,
ownerId,
jobType: 'user_change',
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
materializeEnvFile: args.materializeEnvFile,
envFilePath: args.envFilePath,
aiProviderProfileId: args.aiProviderProfileId,
},
);
return threadId;
},
});
export const appendUserMessage = mutation({
args: { threadId: v.id('threads'), content: v.string() },
handler: async (ctx, { threadId, content }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
const now = Date.now();
return await ctx.db.insert('threadMessages', {
threadId,
ownerId,
spoonId: thread.spoonId,
role: 'user',
content: requireText(content, 'Message'),
status: 'queued',
createdAt: now,
updatedAt: now,
});
},
});
export const cancel = mutation({
args: { threadId: v.id('threads') },
handler: async (ctx, { threadId }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
await ctx.db.patch(threadId, {
status: 'cancelled',
updatedAt: Date.now(),
resolvedAt: Date.now(),
});
return { success: true };
},
});
export const markResolved = mutation({
args: { threadId: v.id('threads') },
handler: async (ctx, { threadId }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
await ctx.db.patch(threadId, {
status: 'resolved',
updatedAt: Date.now(),
resolvedAt: Date.now(),
});
return { success: true };
},
});
export const findOpenMaintenanceThread = internalQuery({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamTo: v.string(),
},
handler: async (ctx, { spoonId, ownerId, upstreamTo }) => {
const threads = await ctx.db
.query('threads')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.collect();
return (
threads.find(
(thread) =>
thread.ownerId === ownerId &&
thread.upstreamTo === upstreamTo &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
) ?? null
);
},
});
export const createMaintenanceThread = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
source: v.union(v.literal('upstream_update'), v.literal('merge_conflict')),
title: v.string(),
summary: v.string(),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.string(),
forkHeadAtCreation: v.optional(v.string()),
mergeBaseAtCreation: v.optional(v.string()),
relatedSyncRunId: v.optional(v.id('syncRuns')),
jobType: v.union(
v.literal('maintenance_review'),
v.literal('conflict_resolution'),
),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query('threads')
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
.order('desc')
.collect()
.then((threads) =>
threads.find(
(thread) =>
thread.ownerId === args.ownerId &&
thread.upstreamTo === args.upstreamTo &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
),
);
if (existing) {
await ctx.db.insert('threadMessages', {
threadId: existing._id,
ownerId: args.ownerId,
spoonId: args.spoonId,
role: 'system',
content: args.summary,
status: 'completed',
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(existing._id, {
relatedSyncRunId: args.relatedSyncRunId,
updatedAt: now,
});
return existing._id;
}
const threadId = await ctx.db.insert('threads', {
ownerId: args.ownerId,
spoonId: args.spoonId,
title: args.title,
summary: args.summary,
source: args.source,
status: 'open',
priority: args.source === 'merge_conflict' ? 'high' : 'normal',
upstreamFrom: args.upstreamFrom,
upstreamTo: args.upstreamTo,
forkHeadAtCreation: args.forkHeadAtCreation,
mergeBaseAtCreation: args.mergeBaseAtCreation,
relatedSyncRunId: args.relatedSyncRunId,
maintenanceOutcome: 'unknown',
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('threadMessages', {
threadId,
ownerId: args.ownerId,
spoonId: args.spoonId,
role: 'system',
content: args.summary,
status: 'completed',
createdAt: now,
updatedAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.agentJobs.createForThreadInternal,
{
threadId,
ownerId: args.ownerId,
jobType: args.jobType,
},
);
return threadId;
},
});
export const patchThreadInternal = internalMutation({
args: {
threadId: v.id('threads'),
status: v.optional(threadStatus),
summary: v.optional(v.string()),
maintenanceOutcome: v.optional(maintenanceOutcome),
ignoredCommitShas: v.optional(v.array(v.string())),
ignoredReason: v.optional(v.string()),
latestAgentJobId: v.optional(v.id('agentJobs')),
},
handler: async (ctx, args) => {
const thread = await ctx.db.get(args.threadId);
if (!thread) throw new ConvexError('Thread not found.');
const patch: Partial<Doc<'threads'>> = { updatedAt: Date.now() };
if (args.status !== undefined) patch.status = args.status;
if (args.summary !== undefined) patch.summary = optionalText(args.summary);
if (args.maintenanceOutcome !== undefined) {
patch.maintenanceOutcome = args.maintenanceOutcome;
}
if (args.ignoredCommitShas !== undefined) {
patch.ignoredCommitShas = args.ignoredCommitShas;
}
if (args.ignoredReason !== undefined) {
patch.ignoredReason = optionalText(args.ignoredReason);
}
if (args.latestAgentJobId !== undefined) {
patch.latestAgentJobId = args.latestAgentJobId;
}
if (
args.status &&
['resolved', 'ignored', 'failed', 'cancelled'].includes(args.status)
) {
patch.resolvedAt = Date.now();
}
await ctx.db.patch(args.threadId, patch);
return { success: true };
},
});
export const appendMessageInternal = internalMutation({
args: {
threadId: v.id('threads'),
ownerId: v.id('users'),
role: messageRole,
content: v.string(),
status: v.optional(messageStatus),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
const thread = await ctx.db.get(args.threadId);
if (thread?.ownerId !== args.ownerId) {
throw new ConvexError('Thread not found.');
}
const now = Date.now();
return await ctx.db.insert('threadMessages', {
threadId: args.threadId,
ownerId: args.ownerId,
spoonId: thread.spoonId,
role: args.role,
content: args.content,
status: args.status ?? 'completed',
metadata: optionalText(args.metadata),
createdAt: now,
updatedAt: now,
});
},
});
export const recordIgnoredUpstreamChange = internalMutation({
args: {
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.string(),
commitShas: v.array(v.string()),
reason: v.string(),
decidedBy: v.union(v.literal('agent'), v.literal('user')),
threadId: v.optional(v.id('threads')),
},
handler: async (ctx, args) => {
return await ctx.db.insert('ignoredUpstreamChanges', {
...args,
createdAt: Date.now(),
});
},
});
-1
View File
@@ -35,7 +35,6 @@
"@react-email/components": "1.0.10",
"@react-email/render": "^2.0.4",
"convex": "catalog:convex",
"openai": "^6.44.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"usesend-js": "^1.6.3",
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = ({
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
className,
)}
{...props}
+1 -1
View File
@@ -24,7 +24,7 @@ const RadioGroupItem = ({
<RadioGroupPrimitive.Item
data-slot='radio-group-item'
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'border-input text-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
+2 -2
View File
@@ -16,14 +16,14 @@ const Switch = ({
data-slot='switch'
data-size={size}
className={cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]',
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-[state=unchecked]:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className='bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0'
className='bg-background dark:group-data-[state=unchecked]/switch:bg-foreground dark:group-data-[state=checked]/switch:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:group-data-[state=checked]/switch:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:group-data-[state=checked]/switch:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:group-data-[state=unchecked]/switch:translate-x-0 group-data-[size=sm]/switch:group-data-[state=unchecked]/switch:translate-x-0'
/>
</SwitchPrimitive.Root>
);
+3
View File
@@ -43,6 +43,9 @@
"SPOON_AGENT_WORKDIR",
"SPOON_AGENT_NETWORK",
"SPOON_AGENT_POLL_MS",
"SPOON_AGENT_WORKER_URL",
"SPOON_AGENT_WORKER_HTTP_PORT",
"SPOON_AGENT_WORKER_INTERNAL_TOKEN",
"SKIP_E2E",
"BASE_URL",
"NETWORK",