/** * Project context storage: notes, todos, and plan files. * * The server is the sole writer of `//context.json`. * The sibling `/.json` stays client-owned (worktree * setup, draft starters, project actions) and server-owned only for * `version`/`scheduledTasks`; keeping the two apart is what removes the * cross-process read-modify-write race that a shared file would create. * * Plan bodies live as markdown at `//plans/.md` * and are referenced by base name only, so moving the project storage * directory never invalidates a reference. */ const PROJECT_CONTEXT_VERSION = 2; const PROJECT_NOTE_BODY_MAX_LENGTH = 3000; const PROJECT_NOTE_MAX_ITEMS = 200; const PROJECT_TODO_TEXT_MAX_LENGTH = 120; const PROJECT_PLAN_TITLE_MAX_LENGTH = 160; const PROJECT_PLAN_BODY_MAX_LENGTH = 200_000; const PROJECT_TODO_MAX_ITEMS = 500; const PROJECT_PLAN_MAX_ITEMS = 500; const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/; const PLAN_FILE_PATTERN = /^[a-zA-Z0-9._-]+\.md$/; const asNonEmptyString = (value) => { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const clampLength = (value, maxLength) => { if (typeof value !== 'string') return ''; return value.length > maxLength ? value.slice(0, maxLength) : value; }; const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); const NOTE_SOURCES = new Set(['manual', 'selection', 'agent']); const sanitizeNoteOrigin = (value) => { if (!isObjectRecord(value)) return null; const sessionId = asNonEmptyString(value.sessionId); const messageId = asNonEmptyString(value.messageId); if (!sessionId) return null; return messageId ? { sessionId, messageId } : { sessionId }; }; /** * Notes are a list of entries. * * Version 1 stored a single string. It is converted here rather than in a * separate migration pass so that any read — including one that races another * writer — sees the same shape. */ const sanitizeNotes = (value, now) => { if (typeof value === 'string') { const body = clampLength(value, PROJECT_NOTE_BODY_MAX_LENGTH).trim(); if (!body) return []; return [{ id: `note_legacy_${now}`, body, createdAt: now, updatedAt: now, source: 'manual', pinned: false, }]; } if (!Array.isArray(value)) return []; const result = []; const seen = new Set(); for (const entry of value) { if (result.length >= PROJECT_NOTE_MAX_ITEMS) break; if (!isObjectRecord(entry)) continue; const id = asNonEmptyString(entry.id); const body = clampLength(typeof entry.body === 'string' ? entry.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim(); if (!id || !body || seen.has(id)) continue; seen.add(id); const createdAt = Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now; const origin = sanitizeNoteOrigin(entry.origin); result.push({ id, body, createdAt, updatedAt: Number.isFinite(entry.updatedAt) && entry.updatedAt >= 0 ? entry.updatedAt : createdAt, source: NOTE_SOURCES.has(entry.source) ? entry.source : 'manual', pinned: entry.pinned === true, ...(origin ? { origin } : {}), }); } return result.sort((a, b) => b.createdAt - a.createdAt); }; const sanitizeTodos = (value, now) => { if (!Array.isArray(value)) return []; const result = []; const seen = new Set(); for (const entry of value) { if (result.length >= PROJECT_TODO_MAX_ITEMS) break; if (!isObjectRecord(entry)) continue; const id = asNonEmptyString(entry.id); const text = clampLength(asNonEmptyString(entry.text) || '', PROJECT_TODO_TEXT_MAX_LENGTH); if (!id || !text || seen.has(id)) continue; seen.add(id); result.push({ id, text, completed: entry.completed === true, createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now, }); } return result; }; const sanitizePlanTitle = (value) => clampLength(asNonEmptyString(value) || '', PROJECT_PLAN_TITLE_MAX_LENGTH); export const parsePlanMarkdown = (raw) => { const normalized = (typeof raw === 'string' ? raw : '').replace(/\r\n?/g, '\n'); const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/); if (match) { return { title: sanitizePlanTitle(match[1]) || 'Plan', body: normalized.slice(match[0].length).replace(/^\n+/, ''), }; } const firstLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan'; return { title: sanitizePlanTitle(firstLine.replace(/^#+\s*/, '')) || 'Plan', body: normalized.trim(), }; }; const formatPlanMarkdown = (title, body) => { const normalizedTitle = sanitizePlanTitle(title) || 'Plan'; const normalizedBody = typeof body === 'string' ? body.trim() : ''; return normalizedBody ? `# ${normalizedTitle}\n\n${normalizedBody}` : `# ${normalizedTitle}\n`; }; const slugifyPlanTitle = (value) => { const normalized = value .trim() .toLowerCase() .replace(/[`*_#>[\](){}.!?,:;"']/g, '') .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'plan'; }; const sanitizePlanLinks = (value, now) => { if (!Array.isArray(value)) return []; const result = []; const seenIds = new Set(); const seenFiles = new Set(); for (const entry of value) { if (result.length >= PROJECT_PLAN_MAX_ITEMS) break; if (!isObjectRecord(entry)) continue; const id = asNonEmptyString(entry.id); const file = asNonEmptyString(entry.file); if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue; if (seenIds.has(id) || seenFiles.has(file)) continue; seenIds.add(id); seenFiles.add(file); result.push({ id, file, title: sanitizePlanTitle(entry.title) || 'Plan', createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now, pinned: entry.pinned === true, }); } return result.sort((a, b) => b.createdAt - a.createdAt); }; const createEmptyContext = () => ({ version: PROJECT_CONTEXT_VERSION, notes: [], todos: [], plans: [], }); export const createProjectContextRuntime = (deps) => { const { fsPromises, path, projectsDirPath, createId } = deps; const idFactory = typeof createId === 'function' ? createId : () => (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `plan_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`); const writeLocks = new Map(); const sanitizeProjectId = (projectId) => { const value = asNonEmptyString(projectId); if (!value) { throw new Error('projectId is required'); } if (!PROJECT_ID_PATTERN.test(value)) { throw new Error('projectId contains unsupported characters'); } return value; }; const storageDirFor = (projectId) => path.join(projectsDirPath, sanitizeProjectId(projectId)); const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json'); const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans'); const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${sanitizeProjectId(projectId)}.json`); const readJson = async (filePath) => { let raw; try { raw = await fsPromises.readFile(filePath, 'utf8'); } catch (error) { if (error && error.code === 'ENOENT') return { missing: true, value: null }; throw error; } try { const parsed = JSON.parse(raw); return { missing: false, value: isObjectRecord(parsed) ? parsed : null }; } catch { return { missing: false, value: null }; } }; const writeJsonAtomic = async (filePath, value) => { const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); try { await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8'); await fsPromises.rename(temporaryPath, filePath); } catch (error) { await fsPromises.rm(temporaryPath, { force: true }).catch(() => {}); throw error; } }; const withWriteLock = async (projectId, mutate) => { const key = sanitizeProjectId(projectId); const previous = writeLocks.get(key) || Promise.resolve(); let release; const next = new Promise((resolve) => { release = resolve; }); const chained = previous.finally(() => next); writeLocks.set(key, chained); await previous; try { return await mutate(); } finally { release(); if (writeLocks.get(key) === chained) { writeLocks.delete(key); } } }; /** * One-time migration of `projectNotes` / `projectTodos` / `projectPlanFiles` * out of the client-owned `.json`. * * Plan links carried absolute paths; those are converted to base names. A * referenced file that is not already inside the plans directory is moved * there so a stale absolute path from an earlier project id is recovered * rather than dropped. A link whose file cannot be located at all is kept * out of the result — the markdown is gone, so the link is dead either way. * * The legacy keys are removed only after `context.json` is durably written. * A failure at any point leaves the legacy keys in place, so the migration * simply runs again on the next read. */ const migrateFromLegacyConfig = async (projectId, now) => { const legacyPath = legacyConfigPathFor(projectId); const legacy = await readJson(legacyPath); if (!legacy.value) { return null; } const hasLegacyKeys = legacy.value.projectNotes !== undefined || legacy.value.projectTodos !== undefined || legacy.value.projectPlanFiles !== undefined; if (!hasLegacyKeys) { return null; } const plansDir = plansDirFor(projectId); const links = []; const rawLinks = Array.isArray(legacy.value.projectPlanFiles) ? legacy.value.projectPlanFiles : []; for (const entry of rawLinks) { if (!isObjectRecord(entry)) continue; const id = asNonEmptyString(entry.id); const absolutePath = asNonEmptyString(entry.path); if (!id || !absolutePath) continue; const file = path.basename(absolutePath); if (!PLAN_FILE_PATTERN.test(file)) continue; const targetPath = path.join(plansDir, file); let raw = null; try { raw = await fsPromises.readFile(targetPath, 'utf8'); } catch (error) { if (!error || error.code !== 'ENOENT') throw error; // Not in the plans directory yet — recover it from the recorded path. try { raw = await fsPromises.readFile(absolutePath, 'utf8'); } catch (recoverError) { if (!recoverError || recoverError.code !== 'ENOENT') throw recoverError; continue; } await fsPromises.mkdir(plansDir, { recursive: true }); await fsPromises.writeFile(targetPath, raw, 'utf8'); } links.push({ id, file, title: parsePlanMarkdown(raw).title, createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now, }); } const migrated = { version: PROJECT_CONTEXT_VERSION, notes: sanitizeNotes(legacy.value.projectNotes, now), todos: sanitizeTodos(legacy.value.projectTodos, now), plans: sanitizePlanLinks(links, now), }; await writeJsonAtomic(contextPathFor(projectId), migrated); const remaining = { ...legacy.value }; delete remaining.projectNotes; delete remaining.projectTodos; delete remaining.projectPlanFiles; await writeJsonAtomic(legacyPath, remaining); return migrated; }; /** * Read the stored context. * * Distinguishes the three states the caller must not conflate: a missing * file is authoritative empty, malformed JSON is a failure, and an I/O * error propagates. Never returns an empty context to paper over a read * that did not succeed. * * Deliberately does NOT take the write lock: every mutator calls this while * already holding it, so locking here would deadlock. The legacy migration * it can trigger is safe unlocked — both of its writes are atomic renames * of identical content, so concurrent migrations converge instead of * interleaving. */ const readContext = async (projectId) => { const now = Date.now(); const stored = await readJson(contextPathFor(projectId)); if (!stored.missing && !stored.value) { throw new Error('Stored project context is malformed'); } if (stored.missing) { const migrated = await migrateFromLegacyConfig(projectId, now); if (migrated) { return { version: PROJECT_CONTEXT_VERSION, notes: sanitizeNotes(migrated.notes, now), todos: sanitizeTodos(migrated.todos, now), plans: sanitizePlanLinks(migrated.plans, now), }; } return createEmptyContext(); } return { version: PROJECT_CONTEXT_VERSION, notes: sanitizeNotes(stored.value.notes, now), todos: sanitizeTodos(stored.value.todos, now), plans: sanitizePlanLinks(stored.value.plans, now), }; }; const writeContext = async (projectId, context) => { await writeJsonAtomic(contextPathFor(projectId), { version: PROJECT_CONTEXT_VERSION, notes: context.notes, todos: context.todos, plans: context.plans, }); }; const saveTodos = async (projectId, todos) => { return withWriteLock(projectId, async () => { const now = Date.now(); const current = await readContext(projectId); const next = { ...current, todos: sanitizeTodos(todos, now) }; await writeContext(projectId, next); return next; }); }; /** * Notes are addressed individually. * * Splitting them from todos is what lets the panel stop writing both fields * on every keystroke-driven save: a todo toggle can no longer clobber notes * the user is still typing, and an agent-authored note can no longer lose a * concurrent todo change. */ const createNote = async (projectId, value) => { const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_NOTE_BODY_MAX_LENGTH).trim(); if (!body) { throw new Error('body is required'); } return withWriteLock(projectId, async () => { const now = Date.now(); const current = await readContext(projectId); if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) { throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`); } const note = { id: idFactory(), body, createdAt: now, updatedAt: now, source: NOTE_SOURCES.has(value?.source) ? value.source : 'manual', pinned: false, ...(sanitizeNoteOrigin(value?.origin) ? { origin: sanitizeNoteOrigin(value.origin) } : {}), }; const next = { ...current, notes: [note, ...current.notes] }; await writeContext(projectId, next); return { note, context: next }; }); }; /** * Patch one note. Omitted fields are left alone, so pinning a note cannot * roll back an edit that landed between the two requests. */ const updateNote = async (projectId, noteId, patch) => { const id = asNonEmptyString(noteId); if (!id) { throw new Error('noteId is required'); } const hasBody = typeof patch?.body === 'string'; const hasPinned = typeof patch?.pinned === 'boolean'; if (!hasBody && !hasPinned) { throw new Error('body or pinned is required'); } const body = hasBody ? clampLength(patch.body, PROJECT_NOTE_BODY_MAX_LENGTH).trim() : null; if (hasBody && !body) { throw new Error('body is required'); } return withWriteLock(projectId, async () => { const now = Date.now(); const current = await readContext(projectId); const existing = current.notes.find((note) => note.id === id); if (!existing) { return null; } const note = { ...existing, ...(hasBody ? { body, updatedAt: now } : {}), ...(hasPinned ? { pinned: patch.pinned } : {}), }; const next = { ...current, notes: current.notes.map((entry) => (entry.id === id ? note : entry)) }; await writeContext(projectId, next); return { note, context: next }; }); }; const deleteNote = async (projectId, noteId) => { const id = asNonEmptyString(noteId); if (!id) { throw new Error('noteId is required'); } return withWriteLock(projectId, async () => { const current = await readContext(projectId); if (!current.notes.some((note) => note.id === id)) { return { deleted: false, context: current }; } const next = { ...current, notes: current.notes.filter((note) => note.id !== id) }; await writeContext(projectId, next); return { deleted: true, context: next }; }); }; const readPlan = async (projectId, planId) => { const id = asNonEmptyString(planId); if (!id) { throw new Error('planId is required'); } const context = await readContext(projectId); const link = context.plans.find((entry) => entry.id === id); if (!link) { return null; } let raw; try { raw = await fsPromises.readFile(path.join(plansDirFor(projectId), link.file), 'utf8'); } catch (error) { if (error && error.code === 'ENOENT') return null; throw error; } const parsed = parsePlanMarkdown(raw); return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw }; }; /** * Overwrite a plan's markdown in place. * * Takes the whole raw document, because the editor surface owns the file * verbatim — round-tripping through title + body would rewrite the heading * and silently reformat what the user typed. The manifest title is * re-derived from the saved content so the list never drifts from the file. * * The file name is deliberately not regenerated on a title change: it is the * stable identity behind the link, and renaming it would strand the markdown * if the manifest write failed afterwards. */ const updatePlan = async (projectId, planId, value) => { const id = asNonEmptyString(planId); if (!id) { throw new Error('planId is required'); } if (typeof value?.raw !== 'string') { throw new Error('raw is required'); } const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH); return withWriteLock(projectId, async () => { const current = await readContext(projectId); const link = current.plans.find((entry) => entry.id === id); if (!link) { return null; } const filePath = path.join(plansDirFor(projectId), link.file); // Refuse to recreate a file that was deleted underneath us: the link is // already dead, and writing here would resurrect it with editor content // the user believed was discarded. try { await fsPromises.access(filePath); } catch (error) { if (error && error.code === 'ENOENT') return null; throw error; } await fsPromises.writeFile(filePath, raw, 'utf8'); const parsed = parsePlanMarkdown(raw); const nextLink = { ...link, title: parsed.title }; const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? nextLink : entry)), }; await writeContext(projectId, next); return { plan: nextLink, context: next, title: parsed.title, body: parsed.body, raw }; }); }; /** * Create a plan from title + body. * * The markdown file is written before the manifest entry. A failure after * the file write leaves an unreferenced markdown file rather than a * manifest entry pointing at nothing — the orphan is inert, a dangling * entry would surface as a broken row in the UI. */ const createPlan = async (projectId, value) => { const title = sanitizePlanTitle(value?.title) || 'Plan'; const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH); return withWriteLock(projectId, async () => { const current = await readContext(projectId); const createdAt = Date.now(); const plansDir = plansDirFor(projectId); await fsPromises.mkdir(plansDir, { recursive: true }); const baseName = `${createdAt}-${slugifyPlanTitle(title)}`; let file = `${baseName}.md`; let attempt = 1; while (current.plans.some((entry) => entry.file === file)) { file = `${baseName}-${attempt}.md`; attempt += 1; } await fsPromises.writeFile(path.join(plansDir, file), formatPlanMarkdown(title, body), 'utf8'); const link = { id: idFactory(), file, title, createdAt, pinned: false }; const next = { ...current, plans: [link, ...current.plans] }; await writeContext(projectId, next); return { plan: link, context: next }; }); }; /** * Delete a plan. * * The manifest entry is removed first so a failed file unlink cannot leave * the UI showing a plan that no longer opens. The leftover markdown is * unreferenced and harmless. */ /** Pin state is patched on its own so it cannot roll back a concurrent edit. */ const setPlanPinned = async (projectId, planId, pinned) => { const id = asNonEmptyString(planId); if (!id) { throw new Error('planId is required'); } return withWriteLock(projectId, async () => { const current = await readContext(projectId); const existing = current.plans.find((entry) => entry.id === id); if (!existing) { return null; } const plan = { ...existing, pinned: pinned === true }; const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) }; await writeContext(projectId, next); return { plan, context: next }; }); }; const deletePlan = async (projectId, planId) => { const id = asNonEmptyString(planId); if (!id) { throw new Error('planId is required'); } return withWriteLock(projectId, async () => { const current = await readContext(projectId); const link = current.plans.find((entry) => entry.id === id); if (!link) { return { deleted: false, context: current }; } const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) }; await writeContext(projectId, next); await fsPromises.rm(path.join(plansDirFor(projectId), link.file), { force: true }); return { deleted: true, context: next }; }); }; return { readContext, saveTodos, createNote, updateNote, deleteNote, readPlan, updatePlan, createPlan, setPlanPinned, deletePlan, contextPathFor, plansDirFor, }; };