From 438c551af41120c3bc1e87f9d093ca8ea82fbfad Mon Sep 17 00:00:00 2001 From: sstefdev Date: Mon, 25 May 2026 01:32:20 +0200 Subject: [PATCH] feat: multi-directory workspaces (closes #2) --- src/app/api/agents/[id]/run/route.ts | 16 +- src/app/api/agents/route.ts | 6 +- .../[id]/directories/[dirId]/route.ts | 44 +++++ .../api/workspaces/[id]/directories/route.ts | 61 +++++++ src/app/api/workspaces/route.ts | 61 ++++++- src/app/layout.tsx | 10 +- src/app/page.tsx | 6 +- src/app/workspace/[id]/page.tsx | 37 +++- src/components/AgentPanel.tsx | 31 +++- src/components/CreateAgentForm.tsx | 59 +++++-- src/components/CreateWorkspaceForm.tsx | 117 +++++++++++-- src/components/WorkspaceDirectories.tsx | 164 ++++++++++++++++++ src/lib/claude.ts | 12 +- src/lib/db.ts | 39 +++++ src/lib/directories.ts | 66 +++++++ src/lib/schema.ts | 31 +++- 16 files changed, 710 insertions(+), 50 deletions(-) create mode 100644 src/app/api/workspaces/[id]/directories/[dirId]/route.ts create mode 100644 src/app/api/workspaces/[id]/directories/route.ts create mode 100644 src/components/WorkspaceDirectories.tsx create mode 100644 src/lib/directories.ts diff --git a/src/app/api/agents/[id]/run/route.ts b/src/app/api/agents/[id]/run/route.ts index e112777..1ae0c90 100644 --- a/src/app/api/agents/[id]/run/route.ts +++ b/src/app/api/agents/[id]/run/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { db, schema } from '@/lib/db' import { eq } from 'drizzle-orm' import { runAgent, writeAgentSkillConfig } from '@/lib/claude' +import { resolveAgentCwd } from '@/lib/directories' export const runtime = 'nodejs' // Allow long-running streams; default Vercel limit is short, but we're @@ -53,9 +54,14 @@ export async function POST( ) } - // Pin the agent's skills into the workspace's .claude config (best + // Resolve which working directory this agent should run in. If the + // agent is bound to a directory we use that; otherwise the + // workspace's first/default directory; otherwise legacy workspace.cwd. + const cwd = await resolveAgentCwd(agent, workspace) + + // Pin the agent's skills into the chosen cwd's .claude config (best // effort; failures are non-fatal and surface in stderr instead). - await writeAgentSkillConfig(workspace.cwd, agent.skills ?? []) + await writeAgentSkillConfig(cwd, agent.skills ?? []) // Insert a `running` row so we can update it when the stream ends. const [run] = await db @@ -80,7 +86,11 @@ export async function POST( try { for await (const event of runAgent({ prompt, - cwd: workspace.cwd, + cwd, + // Memory stays at the workspace's default cwd so every + // agent in this workspace gets the same shared context, + // regardless of which directory it runs in. + memoryCwd: workspace.cwd, systemPrompt: agent.systemPrompt ?? '', model: agent.model, skills: agent.skills ?? [], diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index 33b272e..c563dab 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -44,9 +44,13 @@ export async function POST(req: Request) { const skills = Array.isArray(body.skills) ? body.skills.filter((s: unknown): s is string => typeof s === 'string') : [] + const directoryId = + typeof body.directoryId === 'string' && body.directoryId + ? body.directoryId + : null const [row] = await db .insert(schema.agents) - .values({ workspaceId, name, systemPrompt, model, skills }) + .values({ workspaceId, directoryId, name, systemPrompt, model, skills }) .returning() return NextResponse.json({ agent: row }, { status: 201 }) } diff --git a/src/app/api/workspaces/[id]/directories/[dirId]/route.ts b/src/app/api/workspaces/[id]/directories/[dirId]/route.ts new file mode 100644 index 0000000..45a09e5 --- /dev/null +++ b/src/app/api/workspaces/[id]/directories/[dirId]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { db, schema } from '@/lib/db' +import { and, eq } from 'drizzle-orm' +import { listDirectories } from '@/lib/directories' + +export const runtime = 'nodejs' + +/** + * DELETE /api/workspaces/[id]/directories/[dirId] + * + * Refuses to delete the last directory of a workspace. The workspace + * always needs at least one cwd to spawn agents in. Agents bound to + * the deleted directory get their directory_id set to NULL (via the + * schema's ON DELETE SET NULL) and will fall back to the first + * remaining directory at run time. + */ +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string; dirId: string }> }, +) { + const { id, dirId } = await params + const existing = await listDirectories(id) + if (!existing.find((d) => d.id === dirId)) { + return NextResponse.json( + { error: 'directory not found in this workspace' }, + { status: 404 }, + ) + } + if (existing.length <= 1) { + return NextResponse.json( + { error: 'cannot remove the last directory' }, + { status: 400 }, + ) + } + await db + .delete(schema.workspaceDirectories) + .where( + and( + eq(schema.workspaceDirectories.id, dirId), + eq(schema.workspaceDirectories.workspaceId, id), + ), + ) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/workspaces/[id]/directories/route.ts b/src/app/api/workspaces/[id]/directories/route.ts new file mode 100644 index 0000000..02c1b4e --- /dev/null +++ b/src/app/api/workspaces/[id]/directories/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server' +import { db, schema } from '@/lib/db' +import { eq } from 'drizzle-orm' +import { listDirectories } from '@/lib/directories' + +export const runtime = 'nodejs' + +/** + * GET /api/workspaces/[id]/directories + * POST /api/workspaces/[id]/directories { path, label? } + * + * The path is not validated against the filesystem here. The user is + * trusted to point at a real folder, same trust model as the workspace + * cwd. We do require an absolute-ish path: rejecting empty / pure + * whitespace, that's all. + */ + +async function loadWorkspace(id: string) { + const [row] = await db + .select() + .from(schema.workspaces) + .where(eq(schema.workspaces.id, id)) + return row ?? null +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const workspace = await loadWorkspace(id) + if (!workspace) { + return NextResponse.json({ error: 'workspace not found' }, { status: 404 }) + } + const directories = await listDirectories(workspace.id) + return NextResponse.json({ directories }) +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const workspace = await loadWorkspace(id) + if (!workspace) { + return NextResponse.json({ error: 'workspace not found' }, { status: 404 }) + } + const body = await req.json().catch(() => null) + const path = + body && typeof body.path === 'string' ? body.path.trim() : '' + const label = + body && typeof body.label === 'string' ? body.label.trim() : '' + if (!path) { + return NextResponse.json({ error: 'path is required' }, { status: 400 }) + } + const [row] = await db + .insert(schema.workspaceDirectories) + .values({ workspaceId: workspace.id, path, label }) + .returning() + return NextResponse.json({ directory: row }, { status: 201 }) +} diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 4a6e227..9fd3c30 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { db, schema } from '@/lib/db' import { desc } from 'drizzle-orm' +import { ensureDefaultDirectory } from '@/lib/directories' export const runtime = 'nodejs' @@ -12,22 +13,74 @@ export async function GET() { return NextResponse.json({ workspaces: rows }) } +/** + * POST /api/workspaces { name, cwd?, directories?: [{ path, label? }] } + * + * The body accepts either: + * - legacy `cwd` (single path) for back-compat + * - `directories` (array; first entry is the default cwd) + * - both (cwd is used if directories is omitted/empty) + * + * After creating the workspace row, every directory is inserted into + * workspace_directories. The first directory's path is mirrored into + * workspaces.cwd so the legacy column always reflects the default. + */ export async function POST(req: Request) { const body = await req.json().catch(() => null) if (!body || typeof body !== 'object') { return NextResponse.json({ error: 'invalid body' }, { status: 400 }) } const name = typeof body.name === 'string' ? body.name.trim() : '' - const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : '' - if (!name || !cwd) { + + // Normalize the directories input. `directories` takes precedence if + // it's a non-empty array; otherwise fall back to `cwd` as a single + // entry. + type DirInput = { path: string; label: string } + let directories: DirInput[] = [] + if (Array.isArray(body.directories)) { + const raw: unknown[] = body.directories + for (const d of raw) { + if (!d || typeof d !== 'object') continue + const obj = d as Record + const path = + typeof obj.path === 'string' ? (obj.path as string).trim() : '' + const label = + typeof obj.label === 'string' ? (obj.label as string).trim() : '' + if (path) directories.push({ path, label }) + } + } + if (directories.length === 0) { + const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : '' + if (cwd) directories = [{ path: cwd, label: '' }] + } + + if (!name || directories.length === 0) { return NextResponse.json( - { error: 'name and cwd are required' }, + { error: 'name and at least one directory are required' }, { status: 400 }, ) } + + const defaultPath = directories[0].path const [row] = await db .insert(schema.workspaces) - .values({ name, cwd }) + .values({ name, cwd: defaultPath }) .returning() + + // Insert every directory in order so the first one is also the + // oldest (resolveAgentCwd treats oldest as default). Labels default + // to "default" for the first row when blank, otherwise empty. + for (let i = 0; i < directories.length; i++) { + const d = directories[i] + await db.insert(schema.workspaceDirectories).values({ + workspaceId: row.id, + path: d.path, + label: d.label || (i === 0 ? 'default' : ''), + }) + } + // Safety net: ensureDefaultDirectory is a no-op if any row exists + // and a backstop if the loop above somehow skipped the default. + await ensureDefaultDirectory(row.id, defaultPath) + return NextResponse.json({ workspace: row }, { status: 201 }) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c44d1c2..a9c0cd3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,12 +37,18 @@ export default function RootLayout({ lang="en" className={`${stackSans.variable} ${geistMono.variable} h-full antialiased`} > - + {/* Apply Stack Sans Notch directly on via next/font's + .className (sets font-family on the element, no var + indirection). The header inherits from this, so the navbar + wordmark renders in Stack Sans like the rest of the UI. */} +
diff --git a/src/app/page.tsx b/src/app/page.tsx index 956008e..45d6b2f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,9 +16,9 @@ export default async function Home() {

Workspaces

- A workspace points at a folder on disk. Agents in that workspace - inherit the folder as their cwd. Skills, settings, and hooks come - from the folder's .claude/ config. + A workspace spans one or more folders on disk. Each agent picks + which folder to run in. Skills, settings, and hooks come from + that folder's .claude/ config.

diff --git a/src/app/workspace/[id]/page.tsx b/src/app/workspace/[id]/page.tsx index 548e5fc..2058935 100644 --- a/src/app/workspace/[id]/page.tsx +++ b/src/app/workspace/[id]/page.tsx @@ -6,7 +6,9 @@ import { CreateAgentForm } from '@/components/CreateAgentForm' import { AgentPanel } from '@/components/AgentPanel' import { SkillsGrid } from '@/components/SkillsGrid' import { SharedMemoryForm } from '@/components/SharedMemoryForm' +import { WorkspaceDirectories } from '@/components/WorkspaceDirectories' import { readWorkspaceMemory } from '@/lib/claude' +import { listDirectories } from '@/lib/directories' export const dynamic = 'force-dynamic' @@ -33,6 +35,11 @@ export default async function WorkspacePage({ // string, handled silently by readWorkspaceMemory. const memoryContent = await readWorkspaceMemory(workspace.cwd) + // Pre-load the directory list so the Directories section, the + // CreateAgentForm dropdown, and the per-agent chip render with no + // client-side fetch waterfall. + const directories = await listDirectories(workspace.id) + return (
@@ -46,7 +53,24 @@ export default async function WorkspacePage({ {workspace.name}
- {workspace.cwd} + {directories.length} {directories.length === 1 ? 'directory' : 'directories'} +
+
+ +
+

+ Directories +

+

+ Working directories this workspace spans. Each agent picks one + as its cwd. The first one is the default; new agents that + don't pick a directory run there. +

+
+
@@ -88,7 +112,11 @@ export default async function WorkspacePage({
)} {agents.map((agent) => ( - + ))}
@@ -98,7 +126,10 @@ export default async function WorkspacePage({ Create agent
- +
diff --git a/src/components/AgentPanel.tsx b/src/components/AgentPanel.tsx index b71cf00..6eed0ca 100644 --- a/src/components/AgentPanel.tsx +++ b/src/components/AgentPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useRef } from 'react' -import type { Agent } from '@/lib/schema' +import type { Agent, WorkspaceDirectory } from '@/lib/schema' import { RunHistory } from './RunHistory' type StreamEvent = @@ -13,7 +13,20 @@ type StreamEvent = | { type: 'raw'; line: string } | Record -export function AgentPanel({ agent }: { agent: Agent }) { +export function AgentPanel({ + agent, + directories, +}: { + agent: Agent + directories: WorkspaceDirectory[] +}) { + // Resolve which directory chip to show: the bound one, or "default" + // when nothing is bound (matches resolveAgentCwd's runtime behavior). + const boundDir = agent.directoryId + ? directories.find((d) => d.id === agent.directoryId) + : null + const effectiveDir = boundDir ?? directories[0] ?? null + const isDefault = !boundDir const [prompt, setPrompt] = useState('') const [events, setEvents] = useState([]) const [running, setRunning] = useState(false) @@ -105,6 +118,20 @@ export function AgentPanel({ agent }: { agent: Agent }) { {agent.model} )} + {effectiveDir && ( + + {isDefault + ? `${effectiveDir.label || 'default'} (default)` + : effectiveDir.label || effectiveDir.path} + + )} {(agent.skills ?? []).map((s) => ( ([]) const [skillsError, setSkillsError] = useState(null) + // Empty string => "use the workspace's default directory" (null on + // the wire). Only meaningful when there is more than one directory. + const [directoryId, setDirectoryId] = useState('') const [error, setError] = useState(null) const [pending, startTransition] = useTransition() const router = useRouter() @@ -71,6 +81,7 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { systemPrompt: systemPrompt.trim(), model: model.trim() || null, skills: Array.from(selectedSkills), + directoryId: directoryId || null, }), }) if (!res.ok) { @@ -82,6 +93,7 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { setSystemPrompt('') setModel('') setSelectedSkills(new Set()) + setDirectoryId('') startTransition(() => router.refresh()) } @@ -111,18 +123,39 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none" /> - +
+ + {directories.length > 1 && ( + + )} +
diff --git a/src/components/CreateWorkspaceForm.tsx b/src/components/CreateWorkspaceForm.tsx index be967ef..57d4eca 100644 --- a/src/components/CreateWorkspaceForm.tsx +++ b/src/components/CreateWorkspaceForm.tsx @@ -3,24 +3,55 @@ import { useState, useTransition } from 'react' import { useRouter } from 'next/navigation' +type DirRow = { path: string; label: string } + +const EMPTY_ROW: DirRow = { path: '', label: '' } + export function CreateWorkspaceForm() { const [name, setName] = useState('') - const [cwd, setCwd] = useState('') + // Start with one empty row. The user can add more before submitting. + // The first row's path also becomes workspaces.cwd (the default). + const [dirs, setDirs] = useState([{ ...EMPTY_ROW }]) const [error, setError] = useState(null) const [pending, startTransition] = useTransition() const router = useRouter() + function updateRow(i: number, patch: Partial) { + setDirs((cur) => + cur.map((row, idx) => (idx === i ? { ...row, ...patch } : row)), + ) + } + function addRow() { + setDirs((cur) => [...cur, { ...EMPTY_ROW }]) + } + function removeRow(i: number) { + setDirs((cur) => (cur.length <= 1 ? cur : cur.filter((_, idx) => idx !== i))) + } + async function submit(e: React.FormEvent) { e.preventDefault() setError(null) - if (!name.trim() || !cwd.trim()) { - setError('name and cwd are required') + if (!name.trim()) { + setError('name is required') + return + } + const cleaned = dirs + .map((d) => ({ path: d.path.trim(), label: d.label.trim() })) + .filter((d) => d.path) + if (cleaned.length === 0) { + setError('at least one directory is required') return } const res = await fetch('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name.trim(), cwd: cwd.trim() }), + body: JSON.stringify({ + name: name.trim(), + // First directory is the default; mirror to legacy cwd so + // existing API consumers keep working. + cwd: cleaned[0].path, + directories: cleaned, + }), }) if (!res.ok) { const body = await res.json().catch(() => ({})) @@ -28,7 +59,7 @@ export function CreateWorkspaceForm() { return } setName('') - setCwd('') + setDirs([{ ...EMPTY_ROW }]) startTransition(() => router.refresh()) } @@ -46,18 +77,70 @@ export function CreateWorkspaceForm() { className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none" /> - + +
+ + Directories (absolute paths) + +
+ {dirs.map((dir, i) => ( +
+ + {i === 0 ? 'default' : i + 1} + + updateRow(i, { path: e.target.value })} + placeholder="/Users/you/code/my-project" + className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm focus:border-neutral-600 focus:outline-none" + /> + updateRow(i, { label: e.target.value })} + placeholder="label (optional)" + className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none" + /> + +
+ ))} +
+ +

+ the first one is the default cwd. agents that don't pick a + specific directory run there. +

+
+ {error &&
{error}
} + + ))} + + +
+ + + +
+ + {error &&
{error}
} + + ) +} diff --git a/src/lib/claude.ts b/src/lib/claude.ts index e93307a..1ca1ea8 100644 --- a/src/lib/claude.ts +++ b/src/lib/claude.ts @@ -21,6 +21,13 @@ import path from 'node:path' export type AgentRunOptions = { prompt: string cwd: string + /** + * Directory to read shared MEMORY.md from. Defaults to `cwd` if not + * set. Used by workspaces with multiple directories: the run cwd may + * be a non-default directory, but memory stays anchored to the + * workspace's default cwd so it's the same for every agent. + */ + memoryCwd?: string systemPrompt?: string model?: string | null skills?: string[] @@ -45,7 +52,10 @@ export async function* runAgent(opts: AgentRunOptions) { // Compose the effective system prompt: workspace shared memory // (if any) first, then the agent's own system prompt. Wrapped in // labeled fences so the agent can tell where each layer ends. - const memory = await readWorkspaceMemory(opts.cwd) + // memoryCwd lets a workspace with multiple directories keep one + // shared memory at the default directory regardless of where the + // agent actually runs. + const memory = await readWorkspaceMemory(opts.memoryCwd ?? opts.cwd) const own = (opts.systemPrompt ?? '').trim() const parts: string[] = [] if (memory) { diff --git a/src/lib/db.ts b/src/lib/db.ts index 27fc6e8..ed98903 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -67,9 +67,17 @@ export function ensureMigrated() { cwd TEXT NOT NULL, created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS workspace_directories ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + path TEXT NOT NULL, + label TEXT DEFAULT '', + created_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + directory_id TEXT REFERENCES workspace_directories(id) ON DELETE SET NULL, name TEXT NOT NULL, system_prompt TEXT DEFAULT '', model TEXT, @@ -87,9 +95,40 @@ export function ensureMigrated() { ended_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_agents_workspace ON agents(workspace_id); + CREATE INDEX IF NOT EXISTS idx_directories_workspace ON workspace_directories(workspace_id); CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id); CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC); `) + + // For databases that predate the multi-directory schema, add the + // agents.directory_id column. SQLite errors on duplicate columns, + // which we swallow as the post-condition we wanted is satisfied. + try { + sqlite.exec( + `ALTER TABLE agents ADD COLUMN directory_id TEXT REFERENCES workspace_directories(id) ON DELETE SET NULL`, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : '' + if (!/duplicate column/i.test(msg)) { + // eslint-disable-next-line no-console + console.warn('argus migrate: ALTER agents add directory_id:', msg) + } + } + + // Backfill: any workspace without a directory row gets one + // mirrored from its legacy cwd, labeled "default". Idempotent. + sqlite.exec(` + INSERT INTO workspace_directories (id, workspace_id, path, label, created_at) + SELECT + lower(hex(randomblob(6))), + w.id, + w.cwd, + 'default', + w.created_at + FROM workspaces w + LEFT JOIN workspace_directories d ON d.workspace_id = w.id + WHERE d.id IS NULL + `) } catch (err) { const code = err && typeof err === 'object' && 'code' in err diff --git a/src/lib/directories.ts b/src/lib/directories.ts new file mode 100644 index 0000000..fe5c1ef --- /dev/null +++ b/src/lib/directories.ts @@ -0,0 +1,66 @@ +import { asc, eq } from 'drizzle-orm' +import { db, schema } from './db' +import type { Agent, Workspace, WorkspaceDirectory } from './schema' + +/** + * Helpers around the workspace_directories table. Centralized here so + * routes and the run pipeline agree on what "the workspace's directories" + * means: ordered by created_at ascending, oldest-first (mirrors the + * order the user added them). + */ + +export async function listDirectories( + workspaceId: string, +): Promise { + return db + .select() + .from(schema.workspaceDirectories) + .where(eq(schema.workspaceDirectories.workspaceId, workspaceId)) + .orderBy(asc(schema.workspaceDirectories.createdAt)) +} + +/** + * Resolve the cwd a given agent will actually run in. + * + * Order of preference: + * 1. The directory the agent is bound to via directoryId. + * 2. The workspace's first (oldest) directory row. + * 3. The legacy workspaces.cwd column as a last-resort fallback. + * + * Always returns a string. If everything is missing we return + * `workspace.cwd` rather than throwing so the run can still happen. + */ +export async function resolveAgentCwd( + agent: Agent, + workspace: Workspace, +): Promise { + if (agent.directoryId) { + const [dir] = await db + .select() + .from(schema.workspaceDirectories) + .where(eq(schema.workspaceDirectories.id, agent.directoryId)) + if (dir) return dir.path + } + const dirs = await listDirectories(workspace.id) + if (dirs.length > 0) return dirs[0].path + return workspace.cwd +} + +/** + * Mirror a new workspace's `cwd` into a directory row labeled + * "default". Called from the workspace POST handler so freshly created + * workspaces have a directory row from the start. Backfill in + * ensureMigrated() handles pre-existing rows. + */ +export async function ensureDefaultDirectory( + workspaceId: string, + cwd: string, +): Promise { + const existing = await listDirectories(workspaceId) + if (existing.length > 0) return + await db.insert(schema.workspaceDirectories).values({ + workspaceId, + path: cwd, + label: 'default', + }) +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index eb592f9..ab01636 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -18,12 +18,36 @@ export const workspaces = sqliteTable('workspaces', { .$defaultFn(() => new Date()), }) +/** + * Workspace directories: a workspace can point at multiple working + * directories on disk. Each agent picks one as its cwd (see + * agents.directoryId below). The legacy workspaces.cwd column stays + * as the "default" directory and gets mirrored into a row here when + * the workspace is created (and backfilled on boot for older rows). + */ +export const workspaceDirectories = sqliteTable('workspace_directories', { + id: text('id') + .primaryKey() + .$defaultFn(() => nanoid(12)), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspaces.id, { onDelete: 'cascade' }), + path: text('path').notNull(), + label: text('label').default(''), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), +}) + /** * Agents: per-workspace claude-code persona. Has a name, a system * prompt that gets prepended to every run, optional model override, * and an optional list of skills (skill names that should be active - * for this agent — written to .claude/settings.local.json when the + * for this agent, written to .claude/settings.local.json when the * agent runs). + * + * directoryId binds the agent to one of the workspace's directories. + * Null means "use the workspace's first directory at run time". */ export const agents = sqliteTable('agents', { id: text('id') @@ -32,6 +56,10 @@ export const agents = sqliteTable('agents', { workspaceId: text('workspace_id') .notNull() .references(() => workspaces.id, { onDelete: 'cascade' }), + directoryId: text('directory_id').references( + () => workspaceDirectories.id, + { onDelete: 'set null' }, + ), name: text('name').notNull(), systemPrompt: text('system_prompt').default(''), model: text('model'), // null = use claude default @@ -68,5 +96,6 @@ export const runs = sqliteTable('runs', { }) export type Workspace = typeof workspaces.$inferSelect +export type WorkspaceDirectory = typeof workspaceDirectories.$inferSelect export type Agent = typeof agents.$inferSelect export type Run = typeof runs.$inferSelect