diff --git a/.github/workflows/kits.yml b/.github/workflows/kits.yml
new file mode 100644
index 0000000..a72ee6b
--- /dev/null
+++ b/.github/workflows/kits.yml
@@ -0,0 +1,34 @@
+# Build every kit app on every PR.
+#
+# Nothing built these before, and a broken kit only showed up at image bake — where the failure is
+# a docker layer, not a diff. An undefined JSX identifier does not even fail there: it is a
+# runtime error, so it ships. This is the cheapest place to catch both.
+name: kits
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ kit: [slides, sheets]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ # Not `npm ci` at the repo root: install-kits.sh cds into each app and resolves against that
+ # app's own lockfile, so hoisting here would test a tree the image never has.
+ - run: npm ci
+ working-directory: kits/${{ matrix.kit }}/app
+ - run: npm test --if-present
+ working-directory: kits/${{ matrix.kit }}/app
+ - run: npm run build
+ working-directory: kits/${{ matrix.kit }}/app
+ - name: dist exists
+ run: test -d kits/${{ matrix.kit }}/app/dist
diff --git a/kits.json b/kits.json
index e4384e4..782048e 100644
--- a/kits.json
+++ b/kits.json
@@ -13,6 +13,7 @@
"so adding a kit does not mean editing this file beyond one line."
],
"kits": [
- { "name": "slides", "include": true }
+ { "name": "slides", "include": true },
+ { "name": "sheets", "include": true }
]
}
diff --git a/kits/sheets/CREDITS.md b/kits/sheets/CREDITS.md
new file mode 100644
index 0000000..8503bb4
--- /dev/null
+++ b/kits/sheets/CREDITS.md
@@ -0,0 +1,16 @@
+# Credits
+
+## Templates
+
+The starting sheets in `templates/templates.json` are original to this kit.
+
+## Components
+
+The grid, the conversation surface, the dialogs and the HarnessRouter transport come from
+**[reifyui](https://www.npmjs.com/package/reifyui)** (MIT), which is where they live so that a
+second kit does not start a second copy of them.
+
+## Spreadsheet export
+
+`.xlsx` export uses **[SheetJS](https://sheetjs.com/)** (`xlsx`, Apache-2.0), loaded only when
+someone actually exports one.
diff --git a/kits/sheets/README.md b/kits/sheets/README.md
new file mode 100644
index 0000000..b7c20f7
--- /dev/null
+++ b/kits/sheets/README.md
@@ -0,0 +1,56 @@
+# Sheets
+
+A spreadsheet where a column can be an agent. Rows are your data; an **agent column** runs one of
+your other agents once per row, builds its input from the columns to its left, and fills each cell
+with what that agent said and made.
+
+Launch it from **Starter Kits** in the HarnessRouter console. That provisions the Harness this app
+talks to; nothing else is deployed and nothing is configured.
+
+## How it works
+
+- **One session is one sheet.** The sheet list is this Harness's session list, and a sheet's
+ content is `sheet.json` in that session's workspace. There is no database.
+- **The app is served by the HarnessRouter image** at `/kits/sheets`, same-origin with the
+ console, which is why it needs no API key and no login of its own.
+- **Column order is execution order.** An agent column may only read columns earlier in the sheet,
+ which makes the column list a valid running order by construction — there is no graph to sort
+ and no cycle to handle.
+- **The run happens in your browser tab.** There is no workflow engine in this deployment, so the
+ page is the orchestrator: it walks the cells in dependency order with a concurrency limit you
+ choose. Cells already started finish if you leave; nothing new starts, and the page says so
+ before you press Run.
+- **Each cell is its own conversation.** One session per cell, titled for its column and row, so
+ every result is a real conversation you can open in the console and read.
+- **Only the last run is kept.** Starting a run clears the cells it owns. The conversations are
+ never deleted — you drop the reference, not the work.
+
+## What's in the folder
+
+| Path | What |
+|---|---|
+| `kit.json` | The Harness this kit needs, and where its app is served |
+| `skills/sheet-design/` | The `sheet.json` contract and how to design columns — read before anything is written |
+| `templates/templates.json` | Starting points, including one with agent columns |
+| `app/` | The UI |
+
+## Working on the app
+
+```
+cd app
+npm install
+npm test # the document rules and the run planner, no network needed
+npm run build
+```
+
+`src/lib` is deliberately free of React and of the network:
+
+| File | What |
+|---|---|
+| `model.js` | The document: ids, references, interpolation, and every way a sheet can be wrong |
+| `run.js` | `plan()` and the `Runner` — what would run, and the walk, over an injected dispatcher |
+| `cell.js` | The dispatcher: one cell, one turn |
+| `sh.js` | What "sheet" adds on top of `reifyui/harness` |
+
+`model.test.mjs` and `run.test.mjs` cover both. `skills/sheet-design/validate_sheet.py` is the
+agent-side twin of `model.js`'s `validate()`; if the two ever disagree, the app is right.
diff --git a/kits/sheets/app/.gitignore b/kits/sheets/app/.gitignore
new file mode 100644
index 0000000..364fdec
--- /dev/null
+++ b/kits/sheets/app/.gitignore
@@ -0,0 +1 @@
+public/
diff --git a/kits/sheets/app/index.html b/kits/sheets/app/index.html
new file mode 100644
index 0000000..64bd196
--- /dev/null
+++ b/kits/sheets/app/index.html
@@ -0,0 +1,9 @@
+
+
+
+ );
+}
+
+export function CellDrawer({ cell, column, rowIndex, agentName, onClose, onLive }) {
+ // The agent id lives on the column, not on the cell: the cell records which session ran,
+ // and the console's deep link needs both.
+ const harnessId = column?.harness?.harness_id || '';
+ const [turns, setTurns] = useState(null);
+ const sid = cell?.session_id;
+ const runningNow = cell?.status === 'running';
+
+ useEffect(() => {
+ if (!sid) return undefined;
+ let dead = false;
+ const load = () => sessionTurns(sid).then((t) => { if (!dead) setTurns(t); }).catch(() => {});
+ load();
+ if (!runningNow) return () => { dead = true; };
+ // The turn replay serves a running turn too, so one mechanism covers both watching it happen
+ // and reading it afterwards — including in a tab that was reopened and has no stream.
+ const iv = window.setInterval(load, POLL_MS);
+ return () => { dead = true; window.clearInterval(iv); };
+ }, [sid, runningNow]);
+
+ const text = useMemo(() => {
+ const parts = (turns || []).map((t) => t.assistant).filter(Boolean);
+ return parts.join('\n\n');
+ }, [turns]);
+
+ // While this cell runs, its first line is worth showing in the grid — this is the only place
+ // already polling for it, so the grid gets it from here rather than starting its own poll.
+ useEffect(() => {
+ if (!onLive) return;
+ onLive(runningNow ? (text.split('\n').find(Boolean) || '').slice(0, 160) : '');
+ }, [text, runningNow, onLive]);
+
+ const steps = useMemo(
+ () => (turns || []).flatMap((t) => (t.tools || []).map((x) => ({ name: x.name, args: x.arguments, result: x.result }))),
+ [turns],
+ );
+
+ const duration = cell?.started_at && cell?.ended_at
+ ? `${Math.max(0, cell.ended_at - cell.started_at)}s` : '';
+
+ return (
+
+ );
+}
diff --git a/kits/sheets/app/src/components/ChatPanel.jsx b/kits/sheets/app/src/components/ChatPanel.jsx
new file mode 100644
index 0000000..9d4e0f6
--- /dev/null
+++ b/kits/sheets/app/src/components/ChatPanel.jsx
@@ -0,0 +1,247 @@
+// The copilot column — the sheet page's full-height right panel.
+//
+// On mount it replays the conversation so far; the landing prompt's ?seed= fires exactly once,
+// stripped from the URL immediately and skipped when there is already history.
+import { useEffect, useRef, useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { ArrowUp, PanelRight, Sparkles } from 'lucide-react';
+import { ChatMessages, ChatMessagesSkeleton, Composer, withReasoning, withResult, withStep, withText } from 'reifyui';
+import { sessionTurns, turnsToMessages } from 'reifyui/harness';
+import { isPending } from '../lib/sh';
+import { runCopilotTurn } from '../lib/copilot';
+
+const RETRY_MS = 20000;
+
+const STATUS_LABELS = {
+ failed: 'This turn failed. Please try again.',
+ cancelled: 'Stopped',
+ incomplete: 'The turn hit its limit. Send a follow up to continue.',
+};
+
+/** Drop ?seed= from the hash in place so refresh/back/bookmark never resend it. */
+function stripSeedFromHash() {
+ const h = window.location.hash || '';
+ if (!/[?&]seed=/.test(h)) return;
+ const [path, query = ''] = h.split('?');
+ const params = new URLSearchParams(query);
+ params.delete('seed');
+ const rest = params.toString();
+ window.history.replaceState(null, '', window.location.pathname + window.location.search
+ + path + (rest ? `?${rest}` : ''));
+}
+
+const history = (id) => (isPending(id) ? Promise.resolve([]) : sessionTurns(id));
+
+export function ChatColumn({ sheetId, seed, title, agentBusy, onSheetMaybeChanged, onSessionStarted,
+ width, collapsed, onToggle }) {
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [connecting, setConnecting] = useState(false);
+ const [histLoading, setHistLoading] = useState(true);
+ const pendingRef = useRef(null);
+ const bodyRef = useRef(null);
+ const seededRef = useRef(false);
+
+ useEffect(() => {
+ bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
+ }, [messages, connecting]);
+
+ function updateLastAsst(fn) {
+ setMessages((m) => {
+ const out = m.slice();
+ for (let i = out.length - 1; i >= 0; i -= 1) {
+ if (out[i].role === 'assistant') { out[i] = fn({ ...out[i] }); break; }
+ }
+ return out;
+ });
+ }
+
+ function dropRunningAsst() {
+ setMessages((m) => (m.length && m[m.length - 1].role === 'assistant' && m[m.length - 1].status === 'running'
+ ? m.slice(0, -1) : m));
+ }
+
+ async function deliver(text) {
+ setBusy(true);
+ setMessages((m) => [...m, { role: 'assistant', blocks: [], status: 'running' }]);
+ try {
+ const out = await runCopilotTurn(sheetId, text, {
+ // A sheet created from the landing page has no session until this turn makes one.
+ onSession: (sid) => onSessionStarted?.(sid),
+ onReasoningDelta: (d) => updateLastAsst((a) => ({ ...a, blocks: withReasoning(a.blocks, d) })),
+ onToolCall: (name, args, callId) => updateLastAsst((a) => ({ ...a, blocks: withStep(a.blocks, { name, args, callId }) })),
+ onToolResult: (callId, output) => updateLastAsst((a) => ({ ...a, blocks: withResult(a.blocks, callId, output) })),
+ onTextDelta: (d) => updateLastAsst((a) => ({ ...a, blocks: withText(a.blocks, d) })),
+ onDone: (status) => updateLastAsst((a) => ({ ...a, status: status === 'completed' ? 'done' : status })),
+ onError: () => updateLastAsst((a) => ({
+ ...a,
+ blocks: a.blocks.length ? a.blocks : withText([], 'Something went wrong. Please try again.'),
+ status: 'failed',
+ })),
+ });
+ if (out.connecting) {
+ dropRunningAsst();
+ pendingRef.current = text;
+ setConnecting(true);
+ return;
+ }
+ pendingRef.current = null;
+ setConnecting(false);
+ updateLastAsst((a) => (a.status === 'running' ? { ...a, status: 'done' } : a));
+ onSheetMaybeChanged?.();
+ } catch {
+ dropRunningAsst();
+ pendingRef.current = text;
+ setConnecting(true);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function send(text) {
+ const t = text.trim();
+ if (!t || busy || histLoading) return;
+ setMessages((m) => [...m, { role: 'user', text: t }]);
+ setDraft('');
+ deliver(t);
+ }
+
+ // Mount: strip ?seed FIRST, then replay history; the seed fires only when there is none.
+ //
+ // Keyed on sheetId, and a pending sheet CHANGES its id mid-stream: it starts as
+ // "new:" and adopts the real session id the moment the first turn opens one.
+ // Re-running then refetches history and setMessages() over the blocks streaming in — the panel
+ // shows the user bubble and nothing else while the agent is visibly working. It is the same
+ // conversation, so the resolve is not a reason to reload it.
+ const prevId = useRef(sheetId);
+ useEffect(() => {
+ const resolved = isPending(prevId.current) && !isPending(sheetId);
+ prevId.current = sheetId;
+ if (resolved) return undefined;
+ stripSeedFromHash();
+ let dead = false;
+ history(sheetId).then((turns) => {
+ if (dead) return;
+ const hist = turnsToMessages(turns);
+ if (hist.length) setMessages(hist);
+ setHistLoading(false);
+ if (seed && !seededRef.current && hist.length === 0) {
+ seededRef.current = true;
+ setMessages([{ role: 'user', text: seed }]);
+ deliver(seed);
+ }
+ });
+ return () => { dead = true; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sheetId]);
+
+ // Retry a message the gateway was not up to receive.
+ useEffect(() => {
+ if (!connecting) return undefined;
+ const iv = window.setInterval(() => {
+ if (pendingRef.current && !busy) deliver(pendingRef.current);
+ }, RETRY_MS);
+ return () => window.clearInterval(iv);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connecting, busy]);
+
+ // A turn may be running that this tab did not start — reopened mid-turn, or another window.
+ // Poll history while that is true and nothing local is streaming, then reload once when it
+ // ends so the final answer and its edits land here without a refresh.
+ const prevBusyRef = useRef(false);
+ useEffect(() => {
+ let dead = false;
+ let poll;
+ const reload = () => history(sheetId).then((turns) => {
+ if (dead) return;
+ const hist = turnsToMessages(turns);
+ if (hist.length && !busy && !pendingRef.current) setMessages(hist);
+ });
+ if (agentBusy && messages.length === 0 && !busy) poll = window.setInterval(reload, 3000);
+ if (prevBusyRef.current && !agentBusy) {
+ reload();
+ onSheetMaybeChanged?.();
+ }
+ prevBusyRef.current = agentBusy;
+ return () => { dead = true; if (poll) window.clearInterval(poll); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agentBusy, messages.length, busy]);
+
+ if (collapsed) {
+ return (
+
+ );
+ }
+
+ const empty = messages.length === 0;
+
+ return (
+
+ );
+}
diff --git a/kits/sheets/app/src/components/HarnessCell.jsx b/kits/sheets/app/src/components/HarnessCell.jsx
new file mode 100644
index 0000000..69d518f
--- /dev/null
+++ b/kits/sheets/app/src/components/HarnessCell.jsx
@@ -0,0 +1,85 @@
+// What an agent cell looks like in the grid.
+//
+// Mounted through SheetGrid's renderCell slot, inside the cell's own value box, so the row-height
+// line clamp and the cell chrome still apply.
+//
+// Every state here is a state the run genuinely produces. There is no percentage and no estimate:
+// the server exposes no queue — past its concurrency limit turns are accepted and block silently,
+// indistinguishable over the API from working ones — so a progress bar inside one cell would be
+// invented. A spinner is the truth.
+import { FileText, Play, Maximize2 } from 'lucide-react';
+
+function Artifacts({ artifacts }) {
+ if (!artifacts?.length) return null;
+ const shown = artifacts.slice(0, 3);
+ return (
+
+ {shown.map((a) => (
+
+ {a.filename}
+
+ ))}
+ {artifacts.length > shown.length && (
+ +{artifacts.length - shown.length}
+ )}
+
+ );
+}
+
+export function HarnessCell({ cell, live, readOnly, onRun, onOpen }) {
+ const status = cell?.status;
+ const tools = !readOnly && (
+
+ {onRun && (
+
+ )}
+ {onOpen && status && (
+
+ )}
+
+ );
+
+ if (!status) {
+ return {tools};
+ }
+ if (status === 'queued') {
+ return {tools};
+ }
+ if (status === 'running') {
+ return (
+
+
+ {/* Live text only when the drawer is open on this cell — that is the one place a turn
+ replay is already being polled. Elsewhere we do not invent a progress phrase. */}
+ {live ? {live} : null}
+ {tools}
+
+ );
+ }
+ if (status === 'failed') {
+ return (
+
+
+ {cell.error || 'This cell failed.'}
+ {tools}
+
+ );
+ }
+ if (status === 'skipped') {
+ return (
+
+ {cell.error || 'Skipped.'}
+ {tools}
+
+ );
+ }
+ return (
+ { if (onOpen) { e.stopPropagation(); onOpen(); } }}>
+ {cell.value}
+
+ {tools}
+
+ );
+}
diff --git a/kits/sheets/app/src/components/HarnessConfig.jsx b/kits/sheets/app/src/components/HarnessConfig.jsx
new file mode 100644
index 0000000..a96e2a4
--- /dev/null
+++ b/kits/sheets/app/src/components/HarnessConfig.jsx
@@ -0,0 +1,155 @@
+// Configuring an agent column: which agent runs, what it is asked, and what it is given.
+//
+// Rendered inside the grid's own column popover (SheetGrid's renderColumnConfig slot), so it sits
+// under the Type select where a column's settings already live.
+//
+// The picker never lists this sheet's own agent — that exclusion happens in sh.js, at the source
+// of the list, so the recursive choice is not offered rather than offered and then refused.
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { columnByName, derivedDeps, isHarnessColumn, refs } from '../lib/model';
+import { runnableHarnesses } from '../lib/sh';
+
+export function HarnessConfig({ column, columns, applyPatch, close }) {
+ const index = columns.findIndex((c) => c.id === column.id);
+ // Only columns to the LEFT can be referenced. Enforced here so a forward reference cannot be
+ // authored in the first place — the planner re-checks, but a refusal at run time is a worse
+ // place to learn it.
+ const earlier = useMemo(() => columns.slice(0, index < 0 ? columns.length : index), [columns, index]);
+ const attachable = earlier.filter(isHarnessColumn);
+
+ const cfg = column.harness || {};
+ const [harnessId, setHarnessId] = useState(cfg.harness_id || '');
+ const [prompt, setPrompt] = useState(cfg.prompt || '');
+ const [attach, setAttach] = useState(new Set(cfg.attach || []));
+ const [agents, setAgents] = useState(null); // null = still loading
+ const [loadErr, setLoadErr] = useState('');
+ const promptRef = useRef(null);
+
+ useEffect(() => {
+ let dead = false;
+ runnableHarnesses()
+ .then((list) => { if (!dead) setAgents(list); })
+ .catch((e) => { if (!dead) { setAgents([]); setLoadErr(e?.message || 'Could not load your agents.'); } });
+ return () => { dead = true; };
+ }, []);
+
+ const chosen = agents?.find((a) => a.id === harnessId) || null;
+
+ // What this column will actually read, derived from the prompt and the attachments — the same
+ // function the planner uses, so what is shown and what runs cannot drift apart.
+ const reads = derivedDeps({ ...column, type: 'harness', harness: { prompt, attach: [...attach] } }, columns)
+ .map((id) => columns.find((c) => c.id === id)?.name)
+ .filter(Boolean);
+
+ // A reference that names no column, or one that is not to the left. Shown while typing rather
+ // than at run time.
+ const badRefs = refs(prompt).filter((n) => {
+ const hit = columnByName(columns, n);
+ return !hit || columns.indexOf(hit) >= index;
+ });
+
+ const insertRef = (name) => {
+ const el = promptRef.current;
+ const token = `{{${name}}}`;
+ if (!el) { setPrompt((p) => p + token); return; }
+ const { selectionStart: a, selectionEnd: b } = el;
+ setPrompt((p) => p.slice(0, a) + token + p.slice(b));
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(a + token.length, a + token.length);
+ });
+ };
+
+ const toggleAttach = (id) => setAttach((s) => {
+ const n = new Set(s);
+ if (n.has(id)) n.delete(id); else n.add(id);
+ return n;
+ });
+
+ const save = () => applyPatch({
+ harness: {
+ harness_id: harnessId,
+ harness_name: chosen?.name || cfg.harness_name || '',
+ prompt,
+ attach: [...attach].filter((id) => attachable.some((c) => c.id === id)),
+ ...(cfg.timeout_seconds ? { timeout_seconds: cfg.timeout_seconds } : {}),
+ },
+ });
+
+ return (
+
+
+
+
+
+ {earlier.length > 0 && (
+
+ Insert
+ {earlier.map((c) => (
+
+ ))}
+
+ )}
+ {!earlier.length && (
+
+ This is the first column, so there is nothing to its left to reference yet. Every row will
+ get the same question.
+
+ )}
+
+ {badRefs.length > 0 && (
+
+ {badRefs.map((n) => `{{${n}}}`).join(', ')} {badRefs.length > 1 ? 'do not name columns' : 'does not name a column'} to
+ the left of this one, so the run would be refused.
+
+ )}
+
+ {attachable.length > 0 && (
+
Also attach the files from
+
+ {attachable.map((c) => (
+
+ ))}
+
+
+ )}
+
+
+ {reads.length ? <>Reads {reads.join(', ')} — once per row.>
+ : 'Reads nothing from this sheet yet.'}
+
+
+
+
+
+
+
+ );
+}
diff --git a/kits/sheets/app/src/components/PasteRows.jsx b/kits/sheets/app/src/components/PasteRows.jsx
new file mode 100644
index 0000000..604093e
--- /dev/null
+++ b/kits/sheets/app/src/components/PasteRows.jsx
@@ -0,0 +1,127 @@
+// Getting data into a sheet without spending a turn on it.
+//
+// A dialog rather than an in-grid paste, and deliberately: SheetGrid has no selection model at
+// all — no anchor cell, no keyboard navigation — so a paste would have nowhere to land. Here the
+// person sees exactly what was parsed, which columns it maps onto, and what will be created,
+// before anything changes.
+import { useMemo, useRef, useState } from 'react';
+import { Upload } from 'lucide-react';
+import { applyImport, parseDelimited, planImport } from '../lib/paste';
+
+const PREVIEW_ROWS = 6;
+
+export function PasteRows({ sheet, onApply, onClose }) {
+ const [text, setText] = useState('');
+ const [mode, setMode] = useState('append');
+ const [drag, setDrag] = useState(false);
+ const fileRef = useRef(null);
+
+ const parsed = useMemo(() => (text.trim() ? parseDelimited(text) : null), [text]);
+ const [mapping, setMapping] = useState(null);
+ const plan = useMemo(() => (parsed ? planImport(sheet, parsed) : null), [sheet, parsed]);
+ const effective = mapping && mapping.length === (parsed?.header.length || 0) ? mapping : plan?.mapping;
+
+ const readFile = (file) => {
+ if (!file) return;
+ const r = new FileReader();
+ r.onload = () => { setText(String(r.result || '')); setMapping(null); };
+ r.readAsText(file);
+ };
+
+ const setTarget = (i, colId) => {
+ const next = (effective || []).map((m, j) => (j === i
+ ? { ...m, colId: colId || null, newName: colId ? '' : (m.newName || m.header) }
+ : m));
+ setMapping(next);
+ };
+
+ const newCount = (effective || []).filter((m) => !m.colId).length;
+
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+ The existing rows and their results go. The columns and their settings stay.
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/kits/sheets/app/src/components/RunControl.jsx b/kits/sheets/app/src/components/RunControl.jsx
new file mode 100644
index 0000000..5edd60a
--- /dev/null
+++ b/kits/sheets/app/src/components/RunControl.jsx
@@ -0,0 +1,97 @@
+// The Run control: what to run, how many at a time, and what is happening now.
+//
+// Everything it claims is counted from the plan. `7/12 · 1 failed` is the real settled count over
+// the real planned count; there is no ETA and no percentage inside a cell, because the server
+// exposes no queue to measure and a wrong estimate is worse than none.
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown, Play, Square } from 'lucide-react';
+import { CONCURRENCY_CHOICES } from '../lib/run';
+
+export function RunControl({ running, progress, concurrency, onConcurrency, onRun, onStop, disabled, lastRun }) {
+ const [open, setOpen] = useState(false);
+ const wrapRef = useRef(null);
+
+ // mousedown with a containment check, from the first line. A `click` listener attached in the
+ // effect that opens the menu fires during that very click — React 18 flushes it synchronously —
+ // and the menu closes before it paints. That is a whole evening's bug in one line of setup.
+ useEffect(() => {
+ if (!open) return undefined;
+ const onDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
+ window.addEventListener('mousedown', onDown);
+ return () => window.removeEventListener('mousedown', onDown);
+ }, [open]);
+
+ if (running) {
+ const { planned = 0, settled = 0, failed = 0 } = progress || {};
+ const pct = planned ? Math.round((settled / planned) * 100) : 0;
+ return (
+
+ );
+}
+
+// Chat history placeholder — a few message bubbles (assistant/user alternating)
+// shaped like the real .msg rows so history loads without a jump.
+export function SkeletonChatHistory() {
+ const rows = [
+ { who: 'assistant', lines: ['86%', '64%'] },
+ { who: 'user', lines: ['52%'] },
+ { who: 'assistant', lines: ['78%', '90%', '46%'] },
+ ];
+ return (
+
+ {rows.map((r, i) => (
+
+ {r.lines.map((w, j) => )}
+
+ ))}
+
+ );
+}
+
+// A quiet graph-canvas placeholder — a few faint node circles joined by lines,
+// pulsing softly. Used while a graph's metadata loads (before the real explorer
+// mounts and shows its own dual-pane skeleton).
+export function SkeletonGraphCanvas() {
+ return (
+
+
+
+ );
+}
+
+// Table rows shaped like the real .gtable body: thumb + name, last-viewed,
+// access stack, actions. Widths vary per row so it reads as content, not a grid.
+export function SkeletonTableRows({ count = 6 }) {
+ const names = ['62%', '48%', '70%', '54%', '44%', '66%'];
+ return (
+ <>
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/kits/sheets/app/src/components/Topbar.jsx b/kits/sheets/app/src/components/Topbar.jsx
new file mode 100644
index 0000000..06bccba
--- /dev/null
+++ b/kits/sheets/app/src/components/Topbar.jsx
@@ -0,0 +1,82 @@
+// Product topbar: wordmark, account menu.
+//
+// Same bar as the hosted product, minus the two things this build does not have. There is no
+// credits badge because there is no billing, and no Sign in button because the console already
+// signed you in — the account menu shows who that is and hands log-out back to the console.
+import { useEffect, useRef, useState } from 'react';
+import { BookOpen, LogOut } from 'lucide-react';
+import { getSession, logout, SESSION_EVENT } from '../lib/auth';
+import { ShMark } from './ShMark';
+
+export const LINKS = {
+ // The kit's own folder, not the repository root — someone asking this app for help wants the
+ // Slides README and its skill, not a list of every starter kit.
+ docs: 'https://github.com/HarnessRouter/starter-kit/tree/main/kits/sheets',
+};
+
+export function Wordmark({ size = 18 }) {
+ return (
+
+
+ Sheets
+
+ );
+}
+
+export function AvatarMenu() {
+ const [open, setOpen] = useState(false);
+ const [session, setSession] = useState(getSession);
+ const wrapRef = useRef(null);
+
+ useEffect(() => {
+ const sync = () => setSession(getSession());
+ window.addEventListener(SESSION_EVENT, sync);
+ return () => window.removeEventListener(SESSION_EVENT, sync);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ function onDown(e) {
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
+ }
+ window.addEventListener('mousedown', onDown);
+ return () => window.removeEventListener('mousedown', onDown);
+ }, [open]);
+
+ // No session yet (or an ungated instance): show nothing rather than a placeholder identity.
+ const member = session?.member;
+ if (!member) return null;
+ const displayName = member.display_name || member.name || member.email || 'Account';
+ const initial = String(displayName).trim().charAt(0).toUpperCase() || 'U';
+
+ return (
+
+ );
+}
+
+export function Topbar({ crumb }) {
+ return (
+
+
+ {crumb && (
+ <>
+ /
+ {crumb}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/kits/sheets/app/src/lib/auth.js b/kits/sheets/app/src/lib/auth.js
new file mode 100644
index 0000000..6a15282
--- /dev/null
+++ b/kits/sheets/app/src/lib/auth.js
@@ -0,0 +1,52 @@
+// Identity, borrowed rather than built.
+//
+// The hosted product ships its own sign-in, JWT and sliding-refresh. This app has none of that on
+// purpose: it is served by the HarnessRouter console at /kits/sheets, so the console's middleware
+// has already authenticated whoever is looking at it, and the browser carries that session on
+// every same-origin request. There is nothing left for the app to do — no token to hold, no login
+// screen to render, no refresh to schedule.
+//
+// The module still exists because the pages import from it. Each export is the honest local answer
+// to the question the hosted version answers with a token.
+export const SESSION_EVENT = 'sheets:session';
+
+/** Same-origin: the console session rides along, and its proxy attaches the internal key. */
+export function authFetch(url, init = {}) {
+ return fetch(url, { cache: 'no-store', ...init });
+}
+
+let _session = null;
+let _asked = false;
+
+/** Who the console says is signed in. Null until the first load resolves. */
+export function getSession() {
+ if (!_asked) {
+ _asked = true;
+ fetch('/api/selfhost/session', { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((d) => {
+ if (!d?.user) return;
+ _session = { member: { email: d.user, id: d.user }, gated: Boolean(d.gated) };
+ window.dispatchEvent(new CustomEvent(SESSION_EVENT));
+ })
+ .catch(() => {});
+ }
+ return _session;
+}
+
+/** Always: reaching this app at all means the console already let you in. */
+export function isAuthed() {
+ return true;
+}
+
+/** The console owns the session, so signing out is its business, not this app's. */
+export function logout() {
+ fetch('/api/selfhost/logout', { method: 'POST' })
+ .catch(() => {})
+ .finally(() => { window.location.href = '/'; });
+}
+
+/** No token here to refresh. */
+export function refreshToken() {
+ return Promise.resolve();
+}
diff --git a/kits/sheets/app/src/lib/cell.js b/kits/sheets/app/src/lib/cell.js
new file mode 100644
index 0000000..b3f3026
--- /dev/null
+++ b/kits/sheets/app/src/lib/cell.js
@@ -0,0 +1,230 @@
+// Running ONE agent cell: build the input, start the turn, wait for it, read the answer.
+//
+// This is the only file in the kit that knows a cell is a turn. The Runner above it knows the
+// rules; the grid below it knows the pixels. Keeping the seam here is what let the rules be
+// tested without a server.
+//
+// Transport: `background: true` plus a poll, NOT a stream. The session id arrives on the
+// immediate reply, so nothing is learned by streaming; and a tab that was closed and reopened has
+// no stream and needs the polling path anyway — streaming per cell would be a second mechanism
+// for the same job. Live text, where it matters, comes from the turn replay in the cell drawer.
+import { cancelResponse, containerFileUrl, createResponse, getResponse, patchSession } from 'reifyui/harness';
+import { VALUE_MAX, interpolate } from './model.js';
+
+const POLL_MS = 2000;
+/** How long a terminal-but-empty response is given to produce its own content.
+ *
+ * Measured on a live instance: a turn reports status "completed" — and the session reports
+ * turn_status "done" — about two seconds BEFORE its output items are written. Believing either
+ * signal on its own records "the agent finished without answering" over a turn that answered
+ * perfectly well, which is the worst kind of wrong: it looks like a model failure and it costs a
+ * re-run to disprove. There is no third signal to consult; the record IS the answer, so the only
+ * honest rule is to wait for it, bounded. Costs nothing on the normal path — the loop exits the
+ * instant content appears. */
+const SETTLE_MS = 15000;
+/** Refuse an attachment larger than the API accepts rather than truncating a file silently. */
+const FILE_MAX = 25 * 1024 * 1024;
+
+const now = () => Math.floor(Date.now() / 1000);
+
+const MIME = {
+ md: 'text/markdown', txt: 'text/plain', json: 'application/json', csv: 'text/csv',
+ tsv: 'text/tab-separated-values', html: 'text/html', py: 'text/x-python', js: 'text/javascript',
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml',
+ pdf: 'application/pdf',
+};
+const mimeOf = (name) => MIME[String(name).split('.').pop().toLowerCase()] || 'application/octet-stream';
+
+async function fileAsDataUrl(artifact) {
+ const res = await fetch(containerFileUrl(artifact.container_id, artifact.file_id), { cache: 'no-store' });
+ if (!res.ok) throw new Error(`Could not read ${artifact.filename}.`);
+ const buf = await res.arrayBuffer();
+ if (buf.byteLength > FILE_MAX) {
+ throw new Error(`${artifact.filename} is too large to attach (${Math.round(buf.byteLength / 1048576)} MB).`);
+ }
+ let bin = '';
+ const bytes = new Uint8Array(buf);
+ for (let i = 0; i < bytes.length; i += 0x8000) {
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ }
+ return `data:${mimeOf(artifact.filename)};base64,${btoa(bin)}`;
+}
+
+/** A directory per source column, so an agent reading three attachments can tell them apart. */
+const slug = (s) => String(s || 'in').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'in';
+
+/** The assistant text of a finished response: message items, in order. */
+function textOf(response) {
+ const parts = [];
+ for (const item of response?.output || []) {
+ if (item?.type !== 'message') continue;
+ for (const c of item.content || []) if (c?.type === 'output_text' && c.text) parts.push(c.text);
+ }
+ return parts.join('\n').trim();
+}
+
+/** The files this turn produced, from the terminal response's citations. */
+function artifactsOf(response) {
+ const out = [];
+ const seen = new Set();
+ for (const item of response?.output || []) {
+ for (const c of item?.content || []) {
+ for (const a of c?.annotations || []) {
+ if (a?.type !== 'container_file_citation' || !a.file_id) continue;
+ if (seen.has(a.file_id)) continue;
+ seen.add(a.file_id);
+ // container_id is taken from the annotation rather than assumed equal to the session id:
+ // it happens to be in this deployment, and depending on that would be a silent coupling.
+ out.push({ filename: a.filename || 'file', container_id: a.container_id, file_id: a.file_id });
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * Make the dispatcher the Runner injects.
+ *
+ * `sheetId` and `runId` are only here to build the idempotency key, which is the difference
+ * between a retried request replaying its first answer and a second turn starting on a session
+ * that is already running one. Two turns on one session destroy that session's workspace.
+ */
+export function makeCellDispatcher({ sheetId, runId, sheetTitle, columns, onCell }) {
+ return async function dispatchCell(task, { signal }) {
+ const { column, rowId, colId, rowIndex, values, upstream } = task;
+ const cfg = column.harness || {};
+ const started_at = now();
+ const key = `${rowId}:${colId}`;
+
+ const { text, missing } = interpolate(cfg.prompt, columns, values);
+ if (missing.length) {
+ // Precise, per row, and it punishes no other row: the hosted product substituted empty
+ // here, which sends the model a prompt with a hole in it and gets a confident answer about
+ // nothing back.
+ return {
+ status: 'skipped', run_id: runId, started_at, ended_at: now(), value: null, artifacts: [],
+ error: `${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} empty in this row.`,
+ };
+ }
+
+ const content = [];
+ for (const u of upstream || []) {
+ for (const a of u.cell?.artifacts || []) {
+ // eslint-disable-next-line no-await-in-loop
+ const file_data = await fileAsDataUrl(a);
+ content.push({ type: 'input_file', filename: `in/${slug(u.column.name)}/${a.filename}`, file_data });
+ }
+ }
+ content.push({ type: 'input_text', text });
+
+ const body = {
+ input: [{ role: 'user', content }],
+ background: true,
+ metadata: { harness_id: cfg.harness_id },
+ // Framing goes in `instructions`, never in `input`: the gateway captures the user text
+ // before prepending this, so the transcript and the console task list show the person's own
+ // prompt rather than our scaffolding.
+ instructions: [
+ `You are filling one cell of a spreadsheet: column "${column.name}", row ${rowIndex + 1} of ${task.rowsTotal}.`,
+ 'Answer for this row only.',
+ 'Keep the answer short enough to read in a table cell. If the work produces something long,',
+ 'write it to a file in your working directory and summarise it in one or two sentences.',
+ ].join(' '),
+ ...(cfg.timeout_seconds ? { timeout_seconds: cfg.timeout_seconds } : {}),
+ };
+
+ let created;
+ try {
+ created = await createResponse(body, { idempotencyKey: `${sheetId}:${runId}:${rowId}:${colId}` });
+ } catch (e) {
+ return { status: 'failed', run_id: runId, started_at, ended_at: now(), value: null,
+ artifacts: [], error: e?.message || 'The turn could not be started.' };
+ }
+
+ const responseId = created?.id || '';
+ const sessionId = created?.metadata?.session_id || '';
+ // Published before anything else is dispatched: these two ids are what makes a reopened tab
+ // able to find out what happened, rather than showing a cell stuck at "running" forever.
+ const partial = { status: 'running', run_id: runId, response_id: responseId,
+ session_id: sessionId, started_at, value: null, artifacts: [], error: null };
+ onCell?.(key, partial);
+
+ // A cell session titled for its cell makes the console's task list navigable instead of forty
+ // rows named after a truncated prompt. One call, and it sets the title as chosen so the
+ // per-turn write stops regenerating it.
+ if (sessionId) {
+ patchSession(sessionId, { title: `${sheetTitle || 'Sheet'} · ${column.name} · row ${rowIndex + 1}` })
+ .catch(() => { /* cosmetic; never fail a cell over its own title */ });
+ }
+
+ const stop = () => { if (responseId) cancelResponse(responseId).catch(() => {}); };
+ signal.addEventListener('abort', stop, { once: true });
+
+ try {
+ for (;;) {
+ if (signal.aborted) {
+ return { ...partial, status: 'failed', ended_at: now(), error: 'Stopped.' };
+ }
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => setTimeout(r, POLL_MS));
+ let res;
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ res = await getResponse(responseId);
+ } catch {
+ continue; // a transient read failure is not a failed turn; the next poll decides
+ }
+ const st = res?.status;
+ if (st === 'queued' || st === 'in_progress' || st === 'running' || !st) continue;
+
+ let value = textOf(res);
+ let artifacts = artifactsOf(res);
+
+ // Terminal, but empty: the record may simply not be written yet (see SETTLE_MS). Give it
+ // the grace window before concluding anything, and take whatever turns up.
+ if (!value && !artifacts.length) {
+ const deadline = Date.now() + SETTLE_MS;
+ while (Date.now() < deadline && !signal.aborted) {
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => setTimeout(r, 1000));
+ // eslint-disable-next-line no-await-in-loop
+ const again = await getResponse(responseId).catch(() => null);
+ if (!again) continue;
+ value = textOf(again);
+ artifacts = artifactsOf(again);
+ if (value || artifacts.length) { res = again; break; }
+ }
+ }
+
+ const base = { ...partial, ended_at: now(), session_id: res?.metadata?.session_id || sessionId };
+
+ if (st === 'completed') {
+ // "completed" means the agent exited cleanly, which is not the same as answering. A turn
+ // that produced neither text nor a file did not fill this cell, and saying it did would
+ // be a green tick over nothing.
+ if (!value && !artifacts.length) {
+ return { ...base, status: 'failed', error: 'The agent finished without answering.' };
+ }
+ return {
+ ...base,
+ status: 'done',
+ value: value.length > VALUE_MAX ? value.slice(0, VALUE_MAX) : value,
+ value_truncated: value.length > VALUE_MAX,
+ artifacts,
+ error: null,
+ };
+ }
+ if (st === 'cancelled') return { ...base, status: 'failed', error: 'Stopped.' };
+ return {
+ ...base,
+ status: 'failed',
+ artifacts,
+ error: res?.error?.message
+ || (st === 'incomplete' ? 'The turn ended without an answer.' : `The turn ${st}.`),
+ };
+ }
+ } finally {
+ signal.removeEventListener('abort', stop);
+ }
+ };
+}
diff --git a/kits/sheets/app/src/lib/copilot.js b/kits/sheets/app/src/lib/copilot.js
new file mode 100644
index 0000000..8c0cb8a
--- /dev/null
+++ b/kits/sheets/app/src/lib/copilot.js
@@ -0,0 +1,45 @@
+// The copilot's turn — the one place this app streams.
+//
+// Cells poll (see lib/cell.js) because a reopened tab has no stream and needs the polling path
+// anyway. The copilot is the opposite case: one turn, one open panel, and watching the agent
+// think is the point. Both are POST /responses; this is the API's two modes used where each is
+// right, not an inconsistency.
+import { streamTurn as harnessStreamTurn } from 'reifyui/harness';
+import { isPending, pendingTemplate } from './sh';
+import { getTemplate } from './templates';
+import { materialize } from './model';
+
+/**
+ * Run one copilot turn.
+ *
+ * `sheetId` may be a pending id ("new:"), in which case no session exists yet and the
+ * turn creates one; handlers.onSession reports it the moment a frame carries it.
+ *
+ * The template the person picked only exists in the browser, so the FIRST turn is where it
+ * becomes real. It travels in `instructions`, not in the message: the gateway records the raw
+ * user text for the transcript, so the chat keeps showing the sentence they typed instead of a
+ * wall of starting JSON. Scaffolding should be invisible.
+ */
+export async function runCopilotTurn(sheetId, message, handlers) {
+ const pending = isPending(sheetId);
+ let instructions = '';
+ if (pending) {
+ const tpl = await getTemplate(pendingTemplate(sheetId)).catch(() => null);
+ if (tpl) {
+ instructions = [
+ `Start this sheet from the "${tpl.name}" template.`,
+ tpl.brief || '',
+ '',
+ 'Write exactly this into ./sheet.json first, then adapt it to what the person asked for:',
+ JSON.stringify(materialize(tpl.sheet, tpl.name), null, 2),
+ ].filter(Boolean).join('\n');
+ }
+ }
+
+ return harnessStreamTurn({
+ sessionId: pending ? '' : sheetId,
+ input: message,
+ instructions,
+ handlers,
+ });
+}
diff --git a/kits/sheets/app/src/lib/exportSheet.js b/kits/sheets/app/src/lib/exportSheet.js
new file mode 100644
index 0000000..2deb9cd
--- /dev/null
+++ b/kits/sheets/app/src/lib/exportSheet.js
@@ -0,0 +1,59 @@
+// Getting a sheet out of the app.
+//
+// Everything here happens in the browser: there is no export service in this deployment, and the
+// grid already holds the whole document. CSV and TSV are pure; XLSX lazy-loads its writer so a
+// person who never exports one never downloads it.
+import { sheetToAoA, sheetToDelimited } from 'reifyui';
+import { cellText } from './model';
+
+function download(name, blob) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+const safeName = (title) => (String(title || 'sheet').replace(/[^\w\- ]+/g, '').trim() || 'sheet');
+
+export async function exportSheet(kind, sheet) {
+ const title = sheet?.meta?.title;
+ if (kind === 'csv' || kind === 'tsv') {
+ // cellText is passed so an agent cell exports as its answer rather than as the run record
+ // that surrounds it — a spreadsheet export must never be raw JSON.
+ const text = sheetToDelimited(sheet, kind === 'csv' ? ',' : '\t', { cellText });
+ download(`${safeName(title)}.${kind}`, new Blob([text], { type: 'text/plain;charset=utf-8' }));
+ return;
+ }
+ if (kind === 'json') {
+ // The run result, as asked for: rows, columns, every cell's configuration and the session
+ // reference of every agent cell that ran. It is the document itself rather than a second
+ // artifact assembled beside it — a second copy is what goes stale.
+ download(`${safeName(title)}.json`,
+ new Blob([JSON.stringify(sheet, null, 2)], { type: 'application/json' }));
+ return;
+ }
+ if (kind === 'xlsx') {
+ const XLSX = await import('xlsx');
+ const wb = XLSX.utils.book_new();
+ const ws = XLSX.utils.aoa_to_sheet(sheetToAoA(sheet, { cellText: xlsxCell }));
+ ws['!cols'] = (sheet.columns || []).map((c) => ({ wpx: Math.min(400, c.width || 160) }));
+ XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
+ const out = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+ download(`${safeName(title)}.xlsx`,
+ new Blob([out], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
+ return;
+ }
+ throw new Error(`Unknown export format: ${kind}`);
+}
+
+/** Numbers and booleans stay themselves so the person can sum the column they exported. */
+function xlsxCell(cell, col) {
+ const v = cell?.value;
+ if (col?.type === 'number' && typeof v === 'number') return v;
+ if (col?.type === 'checkbox') return Boolean(v);
+ return cellText(cell, col);
+}
diff --git a/kits/sheets/app/src/lib/model.js b/kits/sheets/app/src/lib/model.js
new file mode 100644
index 0000000..3b24666
--- /dev/null
+++ b/kits/sheets/app/src/lib/model.js
@@ -0,0 +1,329 @@
+// The sheet document, as data — no React, no network, no DOM.
+//
+// One file describes a sheet: ./sheet.json in the sheet session's workspace. Both the app and the
+// agent write it, so its rules have to be stated somewhere both can be checked against. They are
+// stated here, and mirrored in skills/sheet-design/validate_sheet.py for the agent. If the two
+// ever disagree, this file is right and the validator is the bug.
+//
+// { meta: { schema: 1, title }
+// columns: [ {id, name, type, width?, options?, harness?} ] ORDER IS DEPENDENCY ORDER
+// rows: [ {id, height?} ]
+// cells: { ":": {...} } sparse
+// run: {...} } the last run, and only the last
+//
+// The one structural rule everything else rests on: an agent column may only read columns
+// EARLIER in `columns`. That makes the array a topological order by construction, which is why
+// there is no sort anywhere in this kit and no cycle to handle.
+
+export const SCHEMA = 1;
+
+export const COLUMN_TYPES = ['text', 'number', 'select', 'tags', 'checkbox', 'date', 'url', 'harness'];
+
+export const TYPE_LABEL = {
+ text: 'Text', number: 'Number', select: 'Select', tags: 'Tags',
+ checkbox: 'Checkbox', date: 'Date', url: 'Link', harness: 'Agent',
+};
+
+/** The five states an agent cell can be in. An unrun cell is an ABSENT key, not a sixth state:
+ * one representation per fact. */
+export const CELL_STATUS = ['queued', 'running', 'done', 'failed', 'skipped'];
+
+/** The agent's answer is capped in the document. The grid deep-clones the whole sheet on every
+ * edit and the workspace write has a hard byte cap, so forty rows of unbounded model output
+ * would eventually refuse to save. Nothing is lost: the untruncated text is one request away and
+ * the cell drawer shows it. */
+export const VALUE_MAX = 4000;
+
+export const uid = (p) => `${p}_${Math.random().toString(36).slice(2, 10)}`;
+export const cellKey = (rowId, colId) => `${rowId}:${colId}`;
+
+export const isHarnessColumn = (col) => col?.type === 'harness';
+
+export function columnByName(columns, name) {
+ const want = String(name || '').trim().toLowerCase();
+ return (columns || []).find((c) => String(c.name || '').trim().toLowerCase() === want) || null;
+}
+
+/** The {{Name}} references in a prompt, in order, de-duplicated.
+ *
+ * The pattern matches the hosted product's exactly, so a prompt written there means the same
+ * thing here. */
+export function refs(prompt) {
+ const out = [];
+ for (const m of String(prompt || '').matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)) {
+ if (!out.includes(m[1])) out.push(m[1]);
+ }
+ return out;
+}
+
+/** Which columns an agent column reads: the union of its prompt references and its attachments.
+ *
+ * Derived, never stored. The hosted product stored a `deps` array, validated it, rendered a
+ * checkbox list for it — and ignored it at execution. Two declarations of one fact, and the one
+ * the person could see was the one that did nothing. The config that feeds the turn is the only
+ * source of truth about what the turn reads. */
+export function derivedDeps(col, columns) {
+ if (!isHarnessColumn(col)) return [];
+ const h = col.harness || {};
+ const ids = [];
+ for (const name of refs(h.prompt)) {
+ const hit = columnByName(columns, name);
+ if (hit && !ids.includes(hit.id)) ids.push(hit.id);
+ }
+ for (const id of h.attach || []) if (!ids.includes(id)) ids.push(id);
+ return ids;
+}
+
+/** What one cell is worth as text — for interpolation, for export, and for a downstream prompt. */
+export function cellText(cell, col) {
+ const v = cell?.value;
+ if (v === undefined || v === null) return '';
+ switch (col?.type) {
+ case 'tags':
+ return Array.isArray(v) ? v.join(', ') : String(v);
+ case 'checkbox':
+ return v ? 'yes' : 'no';
+ default:
+ if (Array.isArray(v)) return v.join(', ');
+ if (typeof v === 'object') return JSON.stringify(v);
+ return String(v);
+ }
+}
+
+/** Substitute {{Name}} for this row's values.
+ *
+ * `values` maps column id -> text. A reference whose column has no value in this row is NOT
+ * silently blanked: it is reported, so the caller can skip that one cell with a reason instead
+ * of sending the model a prompt with a hole in it. The hosted product substituted empty, which
+ * fabricates input and produces a confident answer about nothing. */
+export function interpolate(prompt, columns, values) {
+ const missing = [];
+ const text = String(prompt || '').replace(/\{\{\s*([^}]+?)\s*\}\}/g, (whole, name) => {
+ const col = columnByName(columns, name);
+ if (!col) return whole; // a config error; the planner refuses first
+ const v = values[col.id];
+ if (v === undefined || v === null || v === '') {
+ if (!missing.includes(col.name)) missing.push(col.name); // named once, however often used
+ return '';
+ }
+ return v;
+ });
+ return { text, missing };
+}
+
+export function blankSheet(title = 'Untitled sheet') {
+ const cols = [
+ { id: uid('col'), name: 'Item', type: 'text', width: 220 },
+ { id: uid('col'), name: 'Notes', type: 'text', width: 320 },
+ ];
+ return {
+ meta: { schema: SCHEMA, title },
+ columns: cols,
+ rows: [{ id: uid('row') }, { id: uid('row') }, { id: uid('row') }],
+ cells: {},
+ };
+}
+
+/** A fresh copy of a template, with new ids.
+ *
+ * Templates ship with placeholder ids; materialising them per sheet keeps two sheets made from
+ * one template from sharing a cell key. */
+export function materialize(template, title) {
+ const colId = {};
+ const rowId = {};
+ const columns = (template.columns || []).map((c) => {
+ const id = uid('col');
+ colId[c.id] = id;
+ return { ...c, id };
+ });
+ // attach[] holds column ids, so it has to be remapped with everything else.
+ for (const c of columns) {
+ if (c.harness?.attach) c.harness = { ...c.harness, attach: c.harness.attach.map((a) => colId[a]).filter(Boolean) };
+ }
+ const rows = (template.rows || []).map((r) => {
+ const id = uid('row');
+ rowId[r.id] = id;
+ return { ...r, id };
+ });
+ const cells = {};
+ for (const [k, v] of Object.entries(template.cells || {})) {
+ const [r, c] = k.split(':');
+ if (rowId[r] && colId[c]) cells[cellKey(rowId[r], colId[c])] = v;
+ }
+ return {
+ meta: { schema: SCHEMA, title: title || template.meta?.title || template.name || 'Untitled sheet' },
+ columns, rows, cells,
+ };
+}
+
+const CHRN = /^chrn_[0-9a-f]{32}$/;
+const APP_OWNED = ['status', 'run_id', 'response_id', 'session_id', 'artifacts', 'started_at', 'ended_at'];
+
+/** Every way a sheet can be wrong, with the fix for each.
+ *
+ * Errors are things that make the sheet render or run incorrectly; warnings are things that are
+ * merely suspect. Returns {errors, warnings} of {where, what, fix}. */
+export function validate(sheet) {
+ const errors = [];
+ const warnings = [];
+ const err = (where, what, fix) => errors.push({ where, what, fix });
+ const warn = (where, what, fix) => warnings.push({ where, what, fix });
+
+ if (!sheet || typeof sheet !== 'object') {
+ err('sheet.json', 'the file is not a JSON object', 'Write an object with meta, columns, rows and cells.');
+ return { errors, warnings };
+ }
+ if (sheet.meta?.schema !== SCHEMA) {
+ err('meta.schema', `is ${JSON.stringify(sheet.meta?.schema)}, not ${SCHEMA}`,
+ `Set "schema": ${SCHEMA}. The app refuses anything else rather than guessing.`);
+ }
+
+ const columns = Array.isArray(sheet.columns) ? sheet.columns : [];
+ const rows = Array.isArray(sheet.rows) ? sheet.rows : [];
+ if (!Array.isArray(sheet.columns)) err('columns', 'is missing or not an array', 'Write "columns": [].');
+ if (!Array.isArray(sheet.rows)) err('rows', 'is missing or not an array', 'Write "rows": [].');
+
+ const seenCol = new Set();
+ const seenName = new Set();
+ columns.forEach((c, i) => {
+ const at = `columns[${i}]`;
+ if (!c?.id) err(at, 'has no id', 'Give it a stable id like "col_a1b2c3d4".');
+ else if (seenCol.has(c.id)) err(at, `repeats the id "${c.id}"`, 'Ids must be unique.');
+ else seenCol.add(c.id);
+
+ const name = String(c?.name || '').trim();
+ if (!name) err(at, 'has no name', 'Every column needs a short, human name.');
+ else if (seenName.has(name.toLowerCase())) {
+ err(at, `repeats the name "${name}"`,
+ 'Column names must be unique: agent prompts address columns as {{Name}}, and a duplicate makes that ambiguous.');
+ } else seenName.add(name.toLowerCase());
+
+ if (!COLUMN_TYPES.includes(c?.type)) {
+ err(at, `has type ${JSON.stringify(c?.type)}`, `Use one of: ${COLUMN_TYPES.join(' ')}.`);
+ }
+ if (isHarnessColumn(c) && !c.harness) {
+ err(at, 'is an agent column with no "harness" object',
+ 'Add "harness": { "harness_id": "", "prompt": "…", "attach": [] }.');
+ }
+ if (!isHarnessColumn(c) && c?.harness) {
+ err(at, 'carries a "harness" object but is not an agent column',
+ 'Either set "type": "harness" or delete the harness object.');
+ }
+ if (isHarnessColumn(c) && c.harness) {
+ const hid = c.harness.harness_id;
+ if (hid !== '' && hid !== undefined && !CHRN.test(String(hid))) {
+ err(`${at}.harness.harness_id`, `is ${JSON.stringify(hid)}`,
+ 'Leave it "" unless you were given a real agent id — you cannot see the list of agents.');
+ }
+ const prompt = String(c.harness.prompt || '');
+ if (!prompt.trim()) {
+ err(`${at}.harness.prompt`, 'is empty', 'An agent column needs a prompt; it is what runs on every row.');
+ } else if (!refs(prompt).length) {
+ warn(`${at}.harness.prompt`, 'references no columns',
+ 'Without a {{Column}} reference every row gets the same input, so every row gets the same answer.');
+ }
+ for (const name of refs(prompt)) {
+ const hit = columnByName(columns, name);
+ if (!hit) {
+ err(`${at}.harness.prompt`, `references {{${name}}}, which is not a column`,
+ `Use one of: ${columns.map((x) => x.name).filter(Boolean).join(', ') || '(none)'}.`);
+ } else if (columns.indexOf(hit) >= i) {
+ err(`${at}.harness.prompt`, `references {{${name}}}, which is not to its left`,
+ `Move "${hit.name}" before "${c.name}". A column can only read columns earlier in the sheet.`);
+ }
+ }
+ for (const a of c.harness.attach || []) {
+ const hit = columns.find((x) => x.id === a);
+ if (!hit) err(`${at}.harness.attach`, `names "${a}", which is not a column`, 'Attach only real column ids.');
+ else if (columns.indexOf(hit) >= i) {
+ err(`${at}.harness.attach`, `attaches "${hit.name}", which is not to its left`,
+ 'A column can only attach files from columns earlier in the sheet.');
+ } else if (!isHarnessColumn(hit)) {
+ err(`${at}.harness.attach`, `attaches "${hit.name}", which is not an agent column`,
+ 'Only agent columns produce files. Reference a plain column as {{Name}} instead.');
+ }
+ }
+ }
+ if ((c?.type === 'select' || c?.type === 'tags') && c.options && !Array.isArray(c.options)) {
+ err(`${at}.options`, 'is not an array', 'Write "options": ["Todo", "Doing", "Done"].');
+ }
+ });
+
+ const seenRow = new Set();
+ rows.forEach((r, i) => {
+ if (!r?.id) err(`rows[${i}]`, 'has no id', 'Give it a stable id like "row_a1b2c3d4".');
+ else if (seenRow.has(r.id)) err(`rows[${i}]`, `repeats the id "${r.id}"`, 'Ids must be unique.');
+ else seenRow.add(r.id);
+ });
+
+ const cells = (sheet.cells && typeof sheet.cells === 'object') ? sheet.cells : {};
+ if (sheet.cells && typeof sheet.cells !== 'object') {
+ err('cells', 'is not an object', 'Write "cells": { ":": { "value": … } }.');
+ }
+ for (const [k, cell] of Object.entries(cells)) {
+ const [rowRef, colRef, extra] = k.split(':');
+ if (extra !== undefined || !rowRef || !colRef) {
+ err(`cells["${k}"]`, 'is not a ":" key', 'Join the row id and the column id with one colon.');
+ continue;
+ }
+ if (!seenRow.has(rowRef)) err(`cells["${k}"]`, `names row "${rowRef}", which does not exist`, 'Use an id from rows[].');
+ const col = columns.find((c) => c.id === colRef);
+ if (!col) {
+ err(`cells["${k}"]`, `names column "${colRef}", which does not exist`,
+ 'Use an id from columns[] — a column NAME here renders nothing.');
+ continue;
+ }
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell)) {
+ err(`cells["${k}"]`, 'is not an object', 'Write { "value": … } — never a bare value at the key.');
+ continue;
+ }
+ if (!isHarnessColumn(col)) {
+ const owned = APP_OWNED.filter((f) => f in cell);
+ if (owned.length) {
+ err(`cells["${k}"]`, `carries ${owned.join(', ')} in a plain column`,
+ 'Only agent cells carry run state, and only the app writes it.');
+ }
+ } else {
+ if (cell.status !== undefined && !CELL_STATUS.includes(cell.status)) {
+ err(`cells["${k}"].status`, `is ${JSON.stringify(cell.status)}`, `Use one of: ${CELL_STATUS.join(' ')}.`);
+ }
+ // A cell that was never dispatched belongs to a run without having a session — that is
+ // what `skipped` means, and it is written by the app itself. Only a cell that claims to
+ // have RUN needs both halves of the reference.
+ const ran = ['running', 'done', 'failed'].includes(cell.status);
+ if (ran && Boolean(cell.run_id) !== Boolean(cell.session_id)) {
+ err(`cells["${k}"]`, 'claims to have run but has only one of run_id / session_id',
+ 'Both come from a real run. Delete them, or leave the cell out entirely.');
+ }
+ }
+ if (col.type === 'checkbox' && cell.value !== undefined && typeof cell.value !== 'boolean') {
+ warn(`cells["${k}"]`, 'is a checkbox holding a non-boolean', 'Write true or false.');
+ }
+ if (col.type === 'number' && cell.value !== undefined && cell.value !== null
+ && typeof cell.value !== 'number') {
+ warn(`cells["${k}"]`, 'is a number column holding a non-number', 'Write the value as a number, not a string.');
+ }
+ if ((col.type === 'select' || col.type === 'tags') && Array.isArray(col.options) && col.options.length) {
+ const vals = col.type === 'tags'
+ ? (Array.isArray(cell.value) ? cell.value : [])
+ : (cell.value == null || cell.value === '' ? [] : [cell.value]);
+ for (const v of vals) {
+ if (!col.options.some((o) => (typeof o === 'string' ? o : o?.label) === v)) {
+ warn(`cells["${k}"]`, `holds "${v}", which is not one of the column's options`,
+ 'Add it to options, or use one of the existing ones.');
+ }
+ }
+ }
+ }
+
+ if (sheet.run) {
+ for (const id of sheet.run.columns || []) {
+ if (!columns.some((c) => c.id === id)) {
+ warn('run.columns', `names column "${id}", which no longer exists`,
+ 'Leave it — the run header simply counts one column fewer.');
+ }
+ }
+ }
+
+ return { errors, warnings };
+}
diff --git a/kits/sheets/app/src/lib/model.test.mjs b/kits/sheets/app/src/lib/model.test.mjs
new file mode 100644
index 0000000..587a807
--- /dev/null
+++ b/kits/sheets/app/src/lib/model.test.mjs
@@ -0,0 +1,215 @@
+// The document rules, pinned. `node --test src/lib/`
+//
+// These are the rules an agent gets wrong, so they are the ones worth a test: a reference to a
+// column that is not to the left, two columns with the same name, run state written into a cell
+// nobody ran, a cells key holding a column NAME instead of an id.
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+ cellKey, cellText, columnByName, derivedDeps, interpolate, materialize, refs, validate,
+} from './model.js';
+
+function sheet(columns, rows = [{ id: 'row_1' }], cells = {}) {
+ return { meta: { schema: 1, title: 'T' }, columns, rows, cells };
+}
+const errorsOf = (s) => validate(s).errors.map((e) => `${e.where}: ${e.what}`);
+const col = (id, name, type = 'text', extra = {}) => ({ id, name, type, ...extra });
+const agent = (id, name, prompt, attach = []) =>
+ col(id, name, 'harness', { harness: { harness_id: '', prompt, attach } });
+
+test('refs finds every {{Name}} once, trimmed, in order', () => {
+ assert.deepEqual(refs('Read {{ Site }} about {{Company}} and {{Site}} again'), ['Site', 'Company']);
+ assert.deepEqual(refs(''), []);
+ assert.deepEqual(refs(null), []);
+});
+
+test('columnByName is case- and space-insensitive, because people type headers', () => {
+ const cols = [col('c1', 'Company Name')];
+ assert.equal(columnByName(cols, ' company name ')?.id, 'c1');
+ assert.equal(columnByName(cols, 'Nope'), null);
+});
+
+test('derivedDeps is the union of prompt refs and attachments, in that order', () => {
+ const cols = [col('c1', 'A'), agent('c2', 'B', 'x'), agent('c3', 'C', 'use {{A}} and {{B}}', ['c2'])];
+ assert.deepEqual(derivedDeps(cols[2], cols), ['c1', 'c2']);
+ assert.deepEqual(derivedDeps(cols[0], cols), [], 'a plain column reads nothing');
+});
+
+test('a forward reference is an error, and it names both columns', () => {
+ const s = sheet([agent('c1', 'Fit', 'score {{Brief}}'), agent('c2', 'Brief', 'write about it')]);
+ const e = validate(s).errors.find((x) => x.what.includes('not to its left'));
+ assert.ok(e, 'a forward reference must be refused');
+ assert.match(e.fix, /Move "Brief" before "Fit"/);
+});
+
+test('a reference to a column at the same index is a forward reference too', () => {
+ const s = sheet([agent('c1', 'Self', 'loop {{Self}}')]);
+ assert.ok(validate(s).errors.some((x) => x.what.includes('not to its left')));
+});
+
+test('duplicate column names are refused — {{Name}} would be ambiguous', () => {
+ const s = sheet([col('c1', 'Brief'), col('c2', 'brief')]);
+ const e = validate(s).errors.find((x) => x.what.includes('repeats the name'));
+ assert.ok(e);
+ assert.match(e.fix, /agent prompts address columns as/);
+});
+
+test('an unknown column type is refused and the message lists the real ones', () => {
+ const e = validate(sheet([col('c1', 'A', 'formula')])).errors.find((x) => x.where === 'columns[0]');
+ assert.match(e.what, /has type "formula"/);
+ assert.match(e.fix, /text number select tags checkbox date url harness/);
+});
+
+test('harness config: object present iff the type is harness', () => {
+ assert.ok(errorsOf(sheet([col('c1', 'A', 'harness')])).some((m) => m.includes('no "harness" object')));
+ assert.ok(errorsOf(sheet([col('c1', 'A', 'text', { harness: { prompt: 'x' } })]))
+ .some((m) => m.includes('not an agent column')));
+});
+
+test('an invented agent id is refused; empty is fine', () => {
+ const bad = sheet([col('c1', 'A', 'harness', { harness: { harness_id: 'chrn_abc', prompt: 'do {{A}}' } })]);
+ assert.ok(validate(bad).errors.some((e) => e.where.endsWith('harness_id')));
+ const ok = sheet([col('c1', 'A'), agent('c2', 'B', 'do {{A}}')]);
+ assert.deepEqual(validate(ok).errors, []);
+});
+
+test('attach must name an EARLIER AGENT column', () => {
+ const notAgent = sheet([col('c1', 'A'), agent('c2', 'B', 'do {{A}}', ['c1'])]);
+ assert.ok(validate(notAgent).errors.some((e) => e.what.includes('not an agent column')));
+
+ const later = sheet([agent('c1', 'A', 'x {{B}}', ['c2']), col('c2', 'B')]);
+ assert.ok(validate(later).errors.some((e) => e.where.endsWith('attach')));
+
+ const good = sheet([col('c1', 'A'), agent('c2', 'B', 'do {{A}}'), agent('c3', 'C', 'judge {{B}}', ['c2'])]);
+ assert.deepEqual(validate(good).errors, []);
+});
+
+test('a cells key must be : with both resolving', () => {
+ const cols = [col('c1', 'Company')];
+ // The classic: the key uses the column NAME. It renders nothing, silently.
+ const byName = sheet(cols, [{ id: 'row_1' }], { 'row_1:Company': { value: 'x' } });
+ assert.ok(validate(byName).errors.some((e) => e.what.includes('does not exist')));
+
+ const noRow = sheet(cols, [{ id: 'row_1' }], { 'row_9:c1': { value: 'x' } });
+ assert.ok(validate(noRow).errors.some((e) => e.what.includes('row "row_9"')));
+
+ const malformed = sheet(cols, [{ id: 'row_1' }], { row_1c1: { value: 'x' } });
+ assert.ok(validate(malformed).errors.some((e) => e.what.includes('not a ":" key')));
+});
+
+test('a bare value at a cell key is refused', () => {
+ const s = sheet([col('c1', 'A')], [{ id: 'row_1' }], { 'row_1:c1': 'plain' });
+ assert.ok(validate(s).errors.some((e) => e.what === 'is not an object'));
+});
+
+test('run state in a plain cell is refused — it claims a run that never happened', () => {
+ const s = sheet([col('c1', 'A')], [{ id: 'row_1' }],
+ { 'row_1:c1': { value: 'x', status: 'done', session_id: 'hsess1' } });
+ const e = validate(s).errors.find((x) => x.what.includes('in a plain column'));
+ assert.ok(e);
+ assert.match(e.what, /status/);
+ assert.match(e.what, /session_id/);
+});
+
+test('an agent cell that RAN needs both run_id and session_id; a skipped one does not', () => {
+ const cols = [col('c1', 'A'), agent('c2', 'B', 'do {{A}}')];
+ const half = sheet(cols, [{ id: 'row_1' }], { 'row_1:c2': { run_id: 'run_1', status: 'done' } });
+ assert.ok(validate(half).errors.some((e) => e.what.includes('only one of run_id')));
+
+ // The app writes exactly this for a cell it never dispatched. Flagging it made the sheet page
+ // show the person an error about its own correct output.
+ const skipped = sheet(cols, [{ id: 'row_1' }],
+ { 'row_1:c2': { run_id: 'run_1', status: 'skipped', error: 'A is empty in this row.' } });
+ assert.deepEqual(validate(skipped).errors, []);
+
+ const both = sheet(cols, [{ id: 'row_1' }],
+ { 'row_1:c2': { run_id: 'run_1', session_id: 'hsess1', status: 'done', value: 'v' } });
+ assert.deepEqual(validate(both).errors, []);
+});
+
+test('duplicate ids are refused for both columns and rows', () => {
+ assert.ok(errorsOf(sheet([col('c1', 'A'), col('c1', 'B')])).some((m) => m.includes('repeats the id')));
+ assert.ok(errorsOf(sheet([col('c1', 'A')], [{ id: 'r' }, { id: 'r' }])).some((m) => m.includes('repeats the id')));
+});
+
+test('the wrong schema is refused rather than guessed at', () => {
+ const s = { ...sheet([col('c1', 'A')]), meta: { schema: 2, title: 'T' } };
+ assert.ok(validate(s).errors.some((e) => e.where === 'meta.schema'));
+});
+
+test('warnings do not block: a prompt with no reference, an off-list option, a loose number', () => {
+ const s = sheet(
+ [col('c1', 'Stage', 'select', { options: ['Todo', 'Done'] }),
+ col('c2', 'Count', 'number'),
+ agent('c3', 'Same', 'summarise the market')],
+ [{ id: 'row_1' }],
+ { 'row_1:c1': { value: 'Blocked' }, 'row_1:c2': { value: '7' } },
+ );
+ const { errors, warnings } = validate(s);
+ assert.deepEqual(errors, []);
+ assert.equal(warnings.length, 3);
+ assert.ok(warnings.some((w) => w.what.includes('references no columns')));
+ assert.ok(warnings.some((w) => w.what.includes('not one of the column')));
+ assert.ok(warnings.some((w) => w.what.includes('non-number')));
+});
+
+test('interpolate substitutes by name and reports empties instead of blanking them', () => {
+ const cols = [col('c1', 'Company'), col('c2', 'Site')];
+ const both = interpolate('Read {{Site}} on {{Company}}', cols, { c1: 'Northwind', c2: 'https://x' });
+ assert.equal(both.text, 'Read https://x on Northwind');
+ assert.deepEqual(both.missing, []);
+
+ const half = interpolate('Read {{Site}} on {{Company}}', cols, { c1: 'Northwind', c2: '' });
+ assert.deepEqual(half.missing, ['Site'], 'an empty upstream cell is named, not silently blanked');
+});
+
+test('interpolate survives quotes, newlines and braces in the substituted value', () => {
+ const cols = [col('c1', 'Brief')];
+ const v = 'He said "no".\nThen {{not a ref}} — 100%';
+ const out = interpolate('Judge this:\n\n{{Brief}}', cols, { c1: v });
+ assert.equal(out.text, `Judge this:\n\n${v}`);
+ assert.deepEqual(out.missing, []);
+});
+
+test('an unknown reference is left alone — the planner refuses the run and says which', () => {
+ const out = interpolate('hi {{Ghost}}', [col('c1', 'A')], { c1: 'x' });
+ assert.equal(out.text, 'hi {{Ghost}}');
+});
+
+test('cellText covers every column type', () => {
+ assert.equal(cellText({ value: 'x' }, col('c', 'A', 'text')), 'x');
+ assert.equal(cellText({ value: 42 }, col('c', 'A', 'number')), '42');
+ assert.equal(cellText({ value: 0 }, col('c', 'A', 'number')), '0', 'zero is a value, not an absence');
+ assert.equal(cellText({ value: '2026-01-01' }, col('c', 'A', 'date')), '2026-01-01');
+ assert.equal(cellText({ value: 'https://x' }, col('c', 'A', 'url')), 'https://x');
+ assert.equal(cellText({ value: 'Done' }, col('c', 'A', 'select')), 'Done');
+ assert.equal(cellText({ value: ['a', 'b'] }, col('c', 'A', 'tags')), 'a, b');
+ assert.equal(cellText({ value: true }, col('c', 'A', 'checkbox')), 'yes');
+ assert.equal(cellText({ value: false }, col('c', 'A', 'checkbox')), 'no');
+ assert.equal(cellText({ value: 'summary' }, col('c', 'A', 'harness')), 'summary');
+ assert.equal(cellText(undefined, col('c', 'A', 'text')), '');
+ assert.equal(cellText({ value: null }, col('c', 'A', 'text')), '');
+});
+
+test('materialize gives a template fresh ids and remaps attach and cells with them', () => {
+ const tpl = {
+ meta: { title: 'Scan' },
+ columns: [col('c1', 'A'), agent('c2', 'B', 'do {{A}}'), agent('c3', 'C', 'judge {{B}}', ['c2'])],
+ rows: [{ id: 'r1' }],
+ cells: { 'r1:c1': { value: 'seed' }, 'r9:c1': { value: 'orphan' } },
+ };
+ const s = materialize(tpl, 'My scan');
+ assert.equal(s.meta.title, 'My scan');
+ assert.equal(s.meta.schema, 1);
+ assert.notEqual(s.columns[0].id, 'c1');
+ assert.deepEqual(s.columns[2].harness.attach, [s.columns[1].id], 'attach follows the new ids');
+ assert.deepEqual(Object.keys(s.cells), [cellKey(s.rows[0].id, s.columns[0].id)],
+ 'a cell naming a row the template does not have is dropped, not carried');
+ assert.deepEqual(validate(s).errors, []);
+});
+
+test('a column referenced twice is named once when it is empty', () => {
+ const cols = [col('c1', 'Company')];
+ const out = interpolate('About {{Company}}. Save notes for {{Company}}.', cols, { c1: '' });
+ assert.deepEqual(out.missing, ['Company'], '"Company and Company are empty" is not a sentence');
+});
diff --git a/kits/sheets/app/src/lib/paste.js b/kits/sheets/app/src/lib/paste.js
new file mode 100644
index 0000000..c612dd3
--- /dev/null
+++ b/kits/sheets/app/src/lib/paste.js
@@ -0,0 +1,107 @@
+// Parsing pasted rows.
+//
+// This is how data gets into a sheet without spending a turn on it. Spreadsheets put TSV on the
+// clipboard and CSV in files, so both are handled by the same parser with a guessed separator —
+// guessing is safe here because the person SEES the parsed table before anything is imported.
+import { uid } from './model';
+
+/** The separator that yields the most consistent column count. */
+function guessSep(text) {
+ const lines = text.split(/\r?\n/).filter((l) => l.trim()).slice(0, 20);
+ if (!lines.length) return '\t';
+ let best = '\t';
+ let bestScore = -1;
+ for (const sep of ['\t', ',', ';', '|']) {
+ const counts = lines.map((l) => splitLine(l, sep).length);
+ const max = Math.max(...counts);
+ if (max < 2) continue;
+ const consistent = counts.filter((c) => c === max).length;
+ const score = consistent * 10 + max;
+ if (score > bestScore) { bestScore = score; best = sep; }
+ }
+ return best;
+}
+
+/** One line, honouring "quoted, fields" with doubled quotes inside. */
+function splitLine(line, sep) {
+ const out = [];
+ let cur = '';
+ let quoted = false;
+ for (let i = 0; i < line.length; i += 1) {
+ const ch = line[i];
+ if (quoted) {
+ if (ch === '"') {
+ if (line[i + 1] === '"') { cur += '"'; i += 1; } else quoted = false;
+ } else cur += ch;
+ } else if (ch === '"') quoted = true;
+ else if (ch === sep) { out.push(cur); cur = ''; }
+ else cur += ch;
+ }
+ out.push(cur);
+ return out.map((s) => s.trim());
+}
+
+/** Text -> {header, rows} where header is the first line if it looks like labels. */
+export function parseDelimited(text) {
+ const sep = guessSep(text);
+ const lines = String(text || '').split(/\r?\n/).filter((l) => l.trim() !== '');
+ if (!lines.length) return { sep, header: [], rows: [] };
+ const grid = lines.map((l) => splitLine(l, sep));
+ const width = Math.max(...grid.map((r) => r.length));
+ const padded = grid.map((r) => [...r, ...Array(width - r.length).fill('')]);
+ // A first row of short, non-numeric, distinct strings is a header. Getting this wrong is
+ // visible and reversible in the preview, so it is a guess worth making.
+ const first = padded[0];
+ const looksLikeHeader = padded.length > 1
+ && first.every((c) => c === '' || (c.length <= 40 && Number.isNaN(Number(c))))
+ && new Set(first.map((c) => c.toLowerCase())).size === first.length
+ && first.some(Boolean);
+ return {
+ sep,
+ header: looksLikeHeader ? first : first.map((_, i) => `Column ${i + 1}`),
+ rows: looksLikeHeader ? padded.slice(1) : padded,
+ };
+}
+
+/** Map incoming headers onto existing columns by name; the rest become new text columns. */
+export function planImport(sheet, parsed) {
+ const existing = sheet.columns || [];
+ const mapping = parsed.header.map((h) => {
+ const hit = existing.find((c) => c.name.trim().toLowerCase() === String(h).trim().toLowerCase());
+ return { header: h, colId: hit?.id || null, newName: hit ? '' : String(h).trim() || 'Column' };
+ });
+ return { mapping, newColumns: mapping.filter((m) => !m.colId).length, rows: parsed.rows.length };
+}
+
+/** Apply an import. `mode` is 'append' or 'replace'; returns the next sheet. */
+export function applyImport(sheet, parsed, mapping, mode) {
+ const next = JSON.parse(JSON.stringify(sheet));
+ next.columns = next.columns || [];
+ next.rows = next.rows || [];
+ next.cells = next.cells || {};
+
+ const target = mapping.map((m) => {
+ if (m.colId) return m.colId;
+ const col = { id: uid('col'), name: m.newName || 'Column', type: 'text' };
+ next.columns.push(col);
+ return col.id;
+ });
+
+ if (mode === 'replace') {
+ // Replacing rows drops their cells with them; the columns and their configuration stay,
+ // which is what makes "paste a new batch into the same pipeline" one action.
+ next.rows = [];
+ next.cells = {};
+ }
+
+ for (const row of parsed.rows) {
+ const rowId = uid('row');
+ next.rows.push({ id: rowId });
+ row.forEach((v, i) => {
+ const colId = target[i];
+ if (!colId || v === '') return;
+ next.cells[`${rowId}:${colId}`] = { value: v };
+ });
+ }
+ return next;
+}
diff --git a/kits/sheets/app/src/lib/run.js b/kits/sheets/app/src/lib/run.js
new file mode 100644
index 0000000..db016cd
--- /dev/null
+++ b/kits/sheets/app/src/lib/run.js
@@ -0,0 +1,319 @@
+// Planning and running the agent columns of a sheet.
+//
+// There is no workflow engine in this deployment and no batch endpoint: the open tab IS the
+// orchestrator. That is a real constraint and the UI says so before you press Run, rather than
+// implying a background job that does not exist.
+//
+// Two pieces, both pure of the network:
+// plan() decides what would run, and refuses the whole run if the sheet cannot run correctly
+// Runner walks the plan with a concurrency limit, over an INJECTED dispatcher
+//
+// The dispatcher is injected so this file can be tested without a server — the rules below
+// (a cell waits for its row, one failure costs only its own row, cancel clears the queue) are
+// the feature, and they are worth pinning.
+import { cellKey, cellText, derivedDeps, isHarnessColumn, refs, columnByName } from './model.js';
+
+export const CONCURRENCY_CHOICES = [1, 2, 3, 5, 8];
+export const DEFAULT_CONCURRENCY = 3;
+const CONC_KEY = 'sheets.run.concurrency';
+
+export function savedConcurrency() {
+ const n = Number(window.localStorage?.getItem(CONC_KEY));
+ return CONCURRENCY_CHOICES.includes(n) ? n : DEFAULT_CONCURRENCY;
+}
+export function saveConcurrency(n) {
+ try { window.localStorage.setItem(CONC_KEY, String(n)); } catch { /* private mode */ }
+}
+
+export const runId = () => `run_${Math.random().toString(36).slice(2, 8)}`;
+
+/** Which columns a scope covers. Three scopes, one planner: the Run button, the column header's
+ * ▶ and a single cell's ▶ differ only in what they select, never in how they execute. */
+function scopeColumns(sheet, scope) {
+ const cols = (sheet.columns || []).filter(isHarnessColumn);
+ if (scope?.kind === 'column') return cols.filter((c) => c.id === scope.colId);
+ if (scope?.kind === 'cell') return cols.filter((c) => c.id === scope.colId);
+ return cols;
+}
+
+function scopeRows(sheet, scope) {
+ const rows = sheet.rows || [];
+ if (scope?.kind === 'cell') return rows.filter((r) => r.id === scope.rowId);
+ return rows;
+}
+
+function passesFilter(cell, filter) {
+ if (filter === 'unrun') return !cell || !cell.status || cell.status === 'skipped';
+ if (filter === 'failed') return cell?.status === 'failed';
+ return true;
+}
+
+/**
+ * What this run would do, and every reason it cannot.
+ *
+ * `env` describes the world outside the document: {harnesses: Map id -> {name, unusable}, ownId}.
+ * Refusals stop the whole run before a single turn is dispatched, because every one of them is a
+ * configuration error that is identical for every row — discovering it forty turns in would cost
+ * real money to learn nothing.
+ */
+export function plan(sheet, scope = { kind: 'sheet' }, filter = 'all', env = {}) {
+ const columns = sheet.columns || [];
+ const harnesses = env.harnesses || new Map();
+ const cells = sheet.cells || {};
+ const refusals = [];
+ const cols = scopeColumns(sheet, scope);
+
+ for (const c of cols) {
+ const at = c.name || c.id;
+ const h = c.harness || {};
+ const i = columns.indexOf(c);
+
+ if (!h.harness_id) {
+ refusals.push({ colId: c.id, column: at, reason: 'no agent is chosen yet. Open the column menu and pick one.' });
+ } else if (env.ownId && h.harness_id === env.ownId) {
+ refusals.push({ colId: c.id, column: at, reason: 'is set to this sheet’s own agent, which would run the sheet on itself.' });
+ } else if (!harnesses.has(h.harness_id)) {
+ // An unknown id does not fail loudly at dispatch — the server accepts it and runs an ad-hoc
+ // agent with no skills and no system prompt. A stale id silently running the wrong agent is
+ // worse than a refusal, so it is checked here against the live list.
+ refusals.push({ colId: c.id, column: at, reason: 'points at an agent that no longer exists. Pick another one.' });
+ } else if (harnesses.get(h.harness_id)?.unusable) {
+ refusals.push({ colId: c.id, column: at, reason: harnesses.get(h.harness_id).unusable + '.' });
+ }
+
+ if (!String(h.prompt || '').trim()) {
+ refusals.push({ colId: c.id, column: at, reason: 'has no prompt, so there is nothing to run.' });
+ }
+ for (const name of refs(h.prompt)) {
+ const hit = columnByName(columns, name);
+ if (!hit) refusals.push({ colId: c.id, column: at, reason: `refers to {{${name}}}, which is not a column.` });
+ else if (columns.indexOf(hit) >= i) {
+ refusals.push({ colId: c.id, column: at, reason: `refers to {{${name}}}, which is not to its left.` });
+ }
+ }
+ for (const a of h.attach || []) {
+ const hit = columns.find((x) => x.id === a);
+ if (!hit || columns.indexOf(hit) >= i || !isHarnessColumn(hit)) {
+ refusals.push({ colId: c.id, column: at, reason: 'attaches files from a column that cannot provide them.' });
+ }
+ }
+ }
+ if (refusals.length) return { cells: [], columns: cols.map((c) => c.id), refusals };
+
+ const wanted = new Set(cols.map((c) => c.id));
+ const planned = [];
+ for (const row of scopeRows(sheet, scope)) {
+ for (const c of cols) {
+ if (!passesFilter(cells[cellKey(row.id, c.id)], filter)) continue;
+ // A dep is only waited on when this run owns it. A dep outside the plan is not re-run —
+ // implicit upstream re-runs are surprising, expensive in real model spend, and they hide
+ // that your inputs are stale.
+ const deps = derivedDeps(c, columns)
+ .filter((d) => wanted.has(d))
+ .map((d) => cellKey(row.id, d))
+ .filter((k) => planned.some((p) => cellKey(p.rowId, p.colId) === k));
+ planned.push({ rowId: row.id, colId: c.id, key: cellKey(row.id, c.id), deps });
+ }
+ }
+ return { cells: planned, columns: cols.map((c) => c.id), refusals: [] };
+}
+
+/** Is an upstream cell usable as this cell's input? */
+function upstreamState(sheet, results, rowId, depColId) {
+ const live = results.get(cellKey(rowId, depColId));
+ if (live) return live.status;
+ const col = (sheet.columns || []).find((c) => c.id === depColId);
+ if (!isHarnessColumn(col)) return 'done'; // a plain column is always available; emptiness is
+ // a per-cell matter and the dispatcher names it
+ const cell = (sheet.cells || {})[cellKey(rowId, depColId)];
+ return cell?.status === 'done' ? 'done' : 'missing';
+}
+
+/**
+ * Walks a plan with at most `concurrency` turns in flight.
+ *
+ * dispatch(task, {signal}) -> Promise. The Runner never knows what a turn is; that
+ * is lib/cell.js. It only knows the shape of the answer: {status, value, artifacts, …}.
+ *
+ * onCell(key, record) fires on every state change, so the page can write it into the document
+ * and the person watches the grid fill in.
+ */
+export class Runner {
+ constructor({ sheet, plan: p, concurrency = DEFAULT_CONCURRENCY, dispatch, onCell, onProgress }) {
+ this.sheet = sheet;
+ this.plan = p;
+ this.concurrency = Math.max(1, concurrency);
+ this.dispatch = dispatch;
+ this.onCell = onCell || (() => {});
+ this.onProgress = onProgress || (() => {});
+ this.results = new Map(); // key -> record, this run's own state
+ this.inflight = new Map(); // key -> AbortController
+ this.stopped = false;
+ this.byKey = new Map(this.plan.cells.map((c) => [c.key, c]));
+ }
+
+ progress() {
+ let done = 0; let failed = 0; let skipped = 0; let running = 0;
+ for (const r of this.results.values()) {
+ if (r.status === 'done') done += 1;
+ else if (r.status === 'failed') failed += 1;
+ else if (r.status === 'skipped') skipped += 1;
+ else if (r.status === 'running') running += 1;
+ }
+ return { planned: this.plan.cells.length, done, failed, skipped, running,
+ settled: done + failed + skipped };
+ }
+
+ _set(key, record) {
+ this.results.set(key, record);
+ this.onCell(key, record);
+ this.onProgress(this.progress());
+ }
+
+ /** Everything downstream of `key` in ITS OWN ROW. A sheet run is N independent row-pipelines;
+ * one bad row must not cost the other ninety-nine.
+ *
+ * Skipped upstreams cascade exactly like failed ones. They did not run, so a cell that reads
+ * them has nothing to read — and leaving it with no record at all is worse than saying so: the
+ * run would report six planned and five settled, with the sixth cell simply blank. */
+ _skipDownstream(key, reason) {
+ const start = this.byKey.get(key);
+ if (!start) return;
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const c of this.plan.cells) {
+ if (c.rowId !== start.rowId) continue;
+ if (this.results.get(c.key)) continue;
+ const blocked = c.deps.some((d) => {
+ const r = this.results.get(d);
+ return r && (r.status === 'failed' || r.status === 'skipped');
+ });
+ if (blocked) { this._set(c.key, { status: 'skipped', error: reason }); changed = true; }
+ }
+ }
+ }
+
+ _ready() {
+ const out = [];
+ for (const c of this.plan.cells) {
+ if (this.results.get(c.key)) continue;
+ const states = c.deps.map((d) => this.results.get(d)?.status || 'missing');
+ if (states.every((s) => s === 'done')) out.push(c);
+ }
+ return out;
+ }
+
+ /** Deps this run does NOT own: they must already be usable, or this cell never starts. */
+ _blockedByStale(c) {
+ const col = (this.sheet.columns || []).find((x) => x.id === c.colId);
+ for (const depId of derivedDeps(col, this.sheet.columns || [])) {
+ if (this.byKey.has(cellKey(c.rowId, depId))) continue; // owned by this run; handled above
+ if (upstreamState(this.sheet, this.results, c.rowId, depId) !== 'done') {
+ const dep = (this.sheet.columns || []).find((x) => x.id === depId);
+ return `${dep?.name || 'An earlier column'} has not run in this row.`;
+ }
+ }
+ return '';
+ }
+
+ async run() {
+ if (!this.plan.cells.length) return this.progress();
+ this.onProgress(this.progress());
+ const pending = [];
+ for (;;) {
+ if (this.stopped) break;
+ while (this.inflight.size < this.concurrency) {
+ const next = this._ready().find((c) => !this.inflight.has(c.key));
+ if (!next) break;
+ const stale = this._blockedByStale(next);
+ if (stale) { this._set(next.key, { status: 'skipped', error: stale }); continue; }
+ pending.push(this._start(next));
+ }
+ if (!this.inflight.size) break;
+ // One settle per loop: the walk advances as soon as ANY cell finishes, so row 5's second
+ // column starts while row 12's first is still going. No column barrier.
+ await Promise.race([...this.inflight.values()].map((c) => c.settled));
+ }
+ await Promise.allSettled(pending);
+ // Every planned cell ends with a state. A cell can be left without one when its dependency
+ // never resolved for a reason the cascade did not cover; a run that reports six planned and
+ // five settled, with the sixth silently blank, is a run whose own counts do not add up.
+ if (!this.stopped) {
+ for (const c of this.plan.cells) {
+ if (this.results.get(c.key)) continue;
+ this._set(c.key, { status: 'skipped', error: 'An earlier column in this row did not run.' });
+ }
+ }
+ return this.progress();
+ }
+
+ _start(c) {
+ const ctrl = new AbortController();
+ let resolveSettled;
+ ctrl.settled = new Promise((r) => { resolveSettled = r; });
+ this.inflight.set(c.key, ctrl);
+ this._set(c.key, { status: 'running' });
+
+ const task = {
+ rowId: c.rowId,
+ colId: c.colId,
+ rowIndex: (this.sheet.rows || []).findIndex((r) => r.id === c.rowId),
+ rowsTotal: (this.sheet.rows || []).length,
+ column: (this.sheet.columns || []).find((x) => x.id === c.colId),
+ values: this._rowValues(c.rowId),
+ upstream: this._upstreamRecords(c),
+ };
+
+ const p = Promise.resolve()
+ .then(() => this.dispatch(task, { signal: ctrl.signal }))
+ .then((record) => {
+ this._set(c.key, record);
+ const name = task.column?.name || 'An earlier column';
+ if (record.status === 'failed') this._skipDownstream(c.key, `${name} failed in this row.`);
+ else if (record.status === 'skipped') this._skipDownstream(c.key, `${name} was skipped in this row.`);
+ })
+ .catch((e) => {
+ this._set(c.key, { status: 'failed', error: e?.message || 'This cell could not run.' });
+ this._skipDownstream(c.key, `${task.column?.name || 'An earlier column'} failed in this row.`);
+ })
+ .finally(() => { this.inflight.delete(c.key); resolveSettled(); });
+ return p;
+ }
+
+ /** This row's cell text, by column id — what {{Name}} interpolates against. */
+ _rowValues(rowId) {
+ const out = {};
+ for (const col of this.sheet.columns || []) {
+ const live = this.results.get(cellKey(rowId, col.id));
+ const cell = live && live.status === 'done' ? live : (this.sheet.cells || {})[cellKey(rowId, col.id)];
+ out[col.id] = cellText(cell, col);
+ }
+ return out;
+ }
+
+ /** The finished agent cells this cell attaches files from. */
+ _upstreamRecords(c) {
+ const col = (this.sheet.columns || []).find((x) => x.id === c.colId);
+ const out = [];
+ for (const id of col?.harness?.attach || []) {
+ const dep = (this.sheet.columns || []).find((x) => x.id === id);
+ const rec = this.results.get(cellKey(c.rowId, id)) || (this.sheet.cells || {})[cellKey(c.rowId, id)];
+ if (dep && rec) out.push({ column: dep, cell: rec });
+ }
+ return out;
+ }
+
+ /** Stop dispatching, cancel what is in flight, and clear what never started.
+ *
+ * A queued cell is CLEARED rather than left queued: a permanent "queued" that will never move
+ * is a lie about what the app is doing. */
+ stop() {
+ this.stopped = true;
+ for (const ctrl of this.inflight.values()) ctrl.abort();
+ for (const c of this.plan.cells) {
+ const r = this.results.get(c.key);
+ if (!r) this._set(c.key, { status: null }); // null = absent; the page deletes the key
+ }
+ }
+}
diff --git a/kits/sheets/app/src/lib/run.test.mjs b/kits/sheets/app/src/lib/run.test.mjs
new file mode 100644
index 0000000..cdde24c
--- /dev/null
+++ b/kits/sheets/app/src/lib/run.test.mjs
@@ -0,0 +1,309 @@
+// The orchestration rules, pinned against a fake dispatcher.
+//
+// This is the part of the kit that spends the person's money, so its behaviour under failure
+// matters more than its behaviour when everything works: one bad row must not cost the other
+// ninety-nine, a stopped run must not leave cells claiming to be queued forever, and the
+// concurrency the person chose must actually be the ceiling.
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { cellKey } from './model.js';
+import { plan, Runner } from './run.js';
+
+const HARNESSES = new Map([
+ ['chrn_' + 'a'.repeat(32), { name: 'Researcher', unusable: '' }],
+ ['chrn_' + 'b'.repeat(32), { name: 'Judge', unusable: '' }],
+]);
+const HID_A = 'chrn_' + 'a'.repeat(32);
+const HID_B = 'chrn_' + 'b'.repeat(32);
+const ENV = { harnesses: HARNESSES, ownId: 'chrn_' + 'f'.repeat(32) };
+
+const col = (id, name, type = 'text') => ({ id, name, type });
+const agent = (id, name, prompt, attach = [], harness_id = HID_A) =>
+ ({ id, name, type: 'harness', harness: { harness_id, prompt, attach } });
+
+function sheetOf(columns, rowCount = 2, cells = {}) {
+ return {
+ meta: { schema: 1, title: 'T' },
+ columns,
+ rows: Array.from({ length: rowCount }, (_, i) => ({ id: `row_${i + 1}` })),
+ cells,
+ };
+}
+
+/** A dispatcher that records what it was asked and answers however the test wants. */
+function fakeDispatcher({ answer, gate } = {}) {
+ const seen = [];
+ let peak = 0;
+ let live = 0;
+ const dispatch = async (task, { signal }) => {
+ seen.push(task);
+ live += 1; peak = Math.max(peak, live);
+ try {
+ if (gate) await gate(task, signal);
+ const r = answer ? answer(task) : { status: 'done', value: `${task.column.name}/${task.rowId}` };
+ if (signal.aborted) return { status: 'failed', error: 'Stopped.' };
+ return r;
+ } finally { live -= 1; }
+ };
+ return { dispatch, seen, peak: () => peak };
+}
+
+test('plan refuses the whole run when a column has no agent chosen', () => {
+ const s = sheetOf([col('c1', 'Company'), agent('c2', 'Brief', 'about {{Company}}', [], '')]);
+ const p = plan(s, { kind: 'sheet' }, 'all', ENV);
+ assert.equal(p.cells.length, 0, 'nothing is dispatched');
+ assert.equal(p.refusals.length, 1);
+ assert.match(p.refusals[0].reason, /no agent is chosen/);
+ assert.equal(p.refusals[0].column, 'Brief', 'the refusal names the column');
+});
+
+test('plan refuses this sheet’s own agent — a sheet may not run itself', () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}', [], ENV.ownId)]);
+ assert.match(plan(s, { kind: 'sheet' }, 'all', ENV).refusals[0].reason, /own agent/);
+});
+
+test('plan refuses an agent id the workspace no longer has', () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}', [], 'chrn_' + '9'.repeat(32))]);
+ assert.match(plan(s, { kind: 'sheet' }, 'all', ENV).refusals[0].reason, /no longer exists/);
+});
+
+test('plan refuses an agent that needs request headers, quoting the reason', () => {
+ const harnesses = new Map(HARNESSES);
+ harnesses.set(HID_A, { name: 'Locked', unusable: 'needs request headers this app can’t send' });
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')]);
+ assert.match(plan(s, { kind: 'sheet' }, 'all', { ...ENV, harnesses }).refusals[0].reason, /request headers/);
+});
+
+test('plan refuses a forward reference before anything is dispatched', () => {
+ const s = sheetOf([agent('c1', 'Fit', 'score {{Brief}}'), agent('c2', 'Brief', 'write')]);
+ const p = plan(s, { kind: 'sheet' }, 'all', ENV);
+ assert.equal(p.cells.length, 0);
+ assert.ok(p.refusals.some((r) => /not to its left/.test(r.reason)));
+});
+
+test('plan builds same-row, left-going dependencies and nothing else', () => {
+ const s = sheetOf([col('c1', 'Company'), agent('c2', 'Brief', 'about {{Company}}'),
+ agent('c3', 'Fit', 'judge {{Brief}}', ['c2'])], 2);
+ const p = plan(s, { kind: 'sheet' }, 'all', ENV);
+ assert.equal(p.cells.length, 4, 'two agent columns over two rows');
+ const fit1 = p.cells.find((c) => c.key === cellKey('row_1', 'c3'));
+ assert.deepEqual(fit1.deps, [cellKey('row_1', 'c2')], 'depends on its own row only');
+ const brief1 = p.cells.find((c) => c.key === cellKey('row_1', 'c2'));
+ assert.deepEqual(brief1.deps, [], 'a plain column is not a planned dependency');
+});
+
+test('a column-scoped run covers that column only and does not re-run upstream', () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}'), agent('c3', 'C', 'y {{B}}')], 2);
+ const p = plan(s, { kind: 'column', colId: 'c3' }, 'all', ENV);
+ assert.deepEqual(p.cells.map((c) => c.colId), ['c3', 'c3']);
+ assert.deepEqual(p.cells[0].deps, [], 'the upstream cell is not owned by this run');
+});
+
+test('a cell-scoped run is exactly one cell', () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')], 3);
+ const p = plan(s, { kind: 'cell', rowId: 'row_2', colId: 'c2' }, 'all', ENV);
+ assert.deepEqual(p.cells.map((c) => c.key), [cellKey('row_2', 'c2')]);
+});
+
+test('filter unrun skips cells that already have a result; failed picks only failures', () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')], 3, {
+ 'row_1:c2': { status: 'done', value: 'v' },
+ 'row_2:c2': { status: 'failed', error: 'boom' },
+ });
+ assert.deepEqual(plan(s, { kind: 'sheet' }, 'unrun', ENV).cells.map((c) => c.rowId), ['row_3']);
+ assert.deepEqual(plan(s, { kind: 'sheet' }, 'failed', ENV).cells.map((c) => c.rowId), ['row_2']);
+ assert.equal(plan(s, { kind: 'sheet' }, 'all', ENV).cells.length, 3);
+});
+
+test('a cell waits for its row: Fit never starts before Brief finishes', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 1);
+ const order = [];
+ const f = fakeDispatcher({
+ gate: async (task) => {
+ order.push(`start:${task.column.name}`);
+ await new Promise((r) => setTimeout(r, task.column.name === 'Brief' ? 20 : 0));
+ order.push(`end:${task.column.name}`);
+ },
+ });
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch });
+ await r.run();
+ assert.deepEqual(order, ['start:Brief', 'end:Brief', 'start:Fit', 'end:Fit']);
+});
+
+test('a downstream cell receives its upstream cell’s text as an interpolation value', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 1,
+ { 'row_1:c1': { value: 'seed' } });
+ const f = fakeDispatcher({ answer: (t) => ({ status: 'done', value: `${t.column.name}-out` }) });
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch });
+ await r.run();
+ const fit = f.seen.find((t) => t.column.name === 'Fit');
+ assert.equal(fit.values.c1, 'seed');
+ assert.equal(fit.values.c2, 'Brief-out', 'this run’s own result, not the stale document');
+});
+
+test('rows run in parallel: there is no column barrier', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}'), agent('c3', 'C', 'y {{B}}')], 3);
+ const seenPairs = [];
+ const f = fakeDispatcher({
+ gate: async (task) => {
+ seenPairs.push(`${task.column.name}${task.rowId}`);
+ await new Promise((r) => setTimeout(r, 5));
+ },
+ });
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch, concurrency: 3 });
+ await r.run();
+ // Crow_1 must have started before Brow_3 finished — that is what "no barrier" means.
+ assert.ok(seenPairs.indexOf('Crow_1') < seenPairs.length - 1);
+ assert.equal(f.seen.length, 6);
+});
+
+test('concurrency is a ceiling that is never exceeded', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')], 12);
+ const f = fakeDispatcher({ gate: () => new Promise((r) => setTimeout(r, 5)) });
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch, concurrency: 3 });
+ await r.run();
+ assert.equal(f.seen.length, 12);
+ assert.ok(f.peak() <= 3, `peak was ${f.peak()}`);
+ assert.equal(f.peak(), 3, 'and it is actually used');
+});
+
+test('one failure skips only its own row', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 3);
+ const f = fakeDispatcher({
+ answer: (t) => (t.column.name === 'Brief' && t.rowId === 'row_2'
+ ? { status: 'failed', error: 'the model refused' }
+ : { status: 'done', value: 'ok' }),
+ });
+ const states = new Map();
+ const r = new Runner({
+ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch, concurrency: 4,
+ onCell: (k, rec) => states.set(k, rec),
+ });
+ const prog = await r.run();
+
+ assert.equal(states.get('row_2:c2').status, 'failed');
+ assert.equal(states.get('row_2:c3').status, 'skipped');
+ assert.equal(states.get('row_2:c3').error, 'Brief failed in this row.');
+ assert.equal(states.get('row_1:c3').status, 'done', 'row 1 is untouched');
+ assert.equal(states.get('row_3:c3').status, 'done', 'row 3 is untouched');
+ assert.deepEqual(
+ { planned: prog.planned, done: prog.done, failed: prog.failed, skipped: prog.skipped },
+ { planned: 6, done: 4, failed: 1, skipped: 1 },
+ );
+ assert.ok(!f.seen.some((t) => t.column.name === 'Fit' && t.rowId === 'row_2'),
+ 'the skipped cell was never dispatched, so it cost nothing');
+});
+
+test('a skip cascades the whole way down a row', async () => {
+ const s = sheetOf([agent('c1', 'A', 'go'), agent('c2', 'B', 'x {{A}}'), agent('c3', 'C', 'y {{B}}')], 1);
+ const f = fakeDispatcher({ answer: (t) => (t.column.name === 'A' ? { status: 'failed', error: 'no' } : { status: 'done' }) });
+ const states = new Map();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch,
+ onCell: (k, rec) => states.set(k, rec) });
+ await r.run();
+ assert.equal(states.get('row_1:c2').status, 'skipped');
+ assert.equal(states.get('row_1:c3').status, 'skipped', 'two hops down is still skipped');
+});
+
+test('a cell whose upstream this run does NOT own is skipped when that upstream never ran', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 1);
+ const f = fakeDispatcher();
+ const states = new Map();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'column', colId: 'c3' }, 'all', ENV),
+ dispatch: f.dispatch, onCell: (k, rec) => states.set(k, rec) });
+ await r.run();
+ assert.equal(states.get('row_1:c3').status, 'skipped');
+ assert.match(states.get('row_1:c3').error, /Brief has not run in this row/);
+ assert.equal(f.seen.length, 0, 'and no turn was spent finding that out');
+});
+
+test('a column-scoped run proceeds when the upstream already succeeded', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 1,
+ { 'row_1:c2': { status: 'done', value: 'earlier answer' } });
+ const f = fakeDispatcher();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'column', colId: 'c3' }, 'all', ENV), dispatch: f.dispatch });
+ await r.run();
+ assert.equal(f.seen.length, 1);
+ assert.equal(f.seen[0].values.c2, 'earlier answer');
+});
+
+test('stop cancels what is running and CLEARS what never started', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')], 8);
+ let started = 0;
+ const f = fakeDispatcher({
+ gate: (task, signal) => new Promise((resolve) => {
+ started += 1;
+ signal.addEventListener('abort', resolve, { once: true });
+ setTimeout(resolve, 500);
+ }),
+ });
+ const states = new Map();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch,
+ concurrency: 2, onCell: (k, rec) => states.set(k, rec) });
+ const done = r.run();
+ await new Promise((res) => setTimeout(res, 20));
+ assert.equal(started, 2, 'only the concurrency limit had started');
+ r.stop();
+ await done;
+
+ const cleared = [...states.entries()].filter(([, rec]) => rec.status === null);
+ assert.equal(cleared.length, 6, 'the six that never started are cleared, not left "queued" forever');
+ const stopped = [...states.values()].filter((rec) => rec.error === 'Stopped.');
+ assert.equal(stopped.length, 2, 'the two in flight are marked stopped');
+});
+
+test('attached upstream records reach the dispatcher', async () => {
+ const s = sheetOf([agent('c1', 'Brief', 'go'), agent('c2', 'Fit', 'judge {{Brief}}', ['c1'])], 1);
+ const f = fakeDispatcher({
+ answer: (t) => ({ status: 'done', value: 'v',
+ artifacts: t.column.name === 'Brief' ? [{ filename: 'notes.md', file_id: 'f1', container_id: 'x' }] : [] }),
+ });
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch });
+ await r.run();
+ const fit = f.seen.find((t) => t.column.name === 'Fit');
+ assert.equal(fit.upstream.length, 1);
+ assert.equal(fit.upstream[0].column.name, 'Brief');
+ assert.equal(fit.upstream[0].cell.artifacts[0].filename, 'notes.md');
+});
+
+test('an empty plan settles immediately rather than spinning', async () => {
+ const s = sheetOf([col('c1', 'A')], 3);
+ const f = fakeDispatcher();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch });
+ const prog = await r.run();
+ assert.deepEqual({ planned: prog.planned, settled: prog.settled }, { planned: 0, settled: 0 });
+});
+
+test('a dispatcher that throws fails that cell and nothing else', async () => {
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'B', 'x {{A}}')], 3);
+ const f = fakeDispatcher({
+ gate: (t) => { if (t.rowId === 'row_2') throw new Error('network died'); },
+ });
+ const states = new Map();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch,
+ onCell: (k, rec) => states.set(k, rec) });
+ const prog = await r.run();
+ assert.equal(states.get('row_2:c2').error, 'network died');
+ assert.deepEqual({ done: prog.done, failed: prog.failed }, { done: 2, failed: 1 });
+});
+
+test('a skipped upstream cascades too, and every planned cell ends with a state', async () => {
+ // The empty-input case: the dispatcher itself returns 'skipped' rather than failing. Its
+ // downstream cell has nothing to read either, and must not be left blank in a run that claims
+ // to have planned it.
+ const s = sheetOf([col('c1', 'A'), agent('c2', 'Brief', 'x {{A}}'), agent('c3', 'Fit', 'y {{Brief}}')], 2);
+ const f = fakeDispatcher({
+ answer: (t) => (t.column.name === 'Brief' && t.rowId === 'row_1'
+ ? { status: 'skipped', error: 'A is empty in this row.' }
+ : { status: 'done', value: 'ok' }),
+ });
+ const states = new Map();
+ const r = new Runner({ sheet: s, plan: plan(s, { kind: 'sheet' }, 'all', ENV), dispatch: f.dispatch,
+ onCell: (k, rec) => states.set(k, rec) });
+ const prog = await r.run();
+
+ assert.equal(states.get('row_1:c3').status, 'skipped');
+ assert.equal(states.get('row_1:c3').error, 'Brief was skipped in this row.');
+ assert.equal(states.get('row_2:c3').status, 'done', 'the other row is untouched');
+ assert.equal(prog.settled, prog.planned, 'planned and settled must agree when the run ends');
+});
diff --git a/kits/sheets/app/src/lib/sh.js b/kits/sheets/app/src/lib/sh.js
new file mode 100644
index 0000000..7c88f13
--- /dev/null
+++ b/kits/sheets/app/src/lib/sh.js
@@ -0,0 +1,134 @@
+// The Sheets data layer.
+//
+// Everything about talking to HarnessRouter — routes, session semantics, the 409 on a busy
+// workspace — lives in `reifyui/harness`. This file is only what "sheet" adds on top of it:
+//
+// a sheet = a session on this kit's Harness
+// its content = ./sheet.json in that session's workspace
+// an agent column = a turn on a DIFFERENT harness, one per row
+//
+// There is no database, no per-product auth and no billing. The app is served by the console at
+// /kits/sheets, so it is same-origin with the console's API proxy: the browser sends the console
+// session it already has and the proxy attaches the internal key server-side.
+import {
+ configureKit, kitHarness, listHarnesses, listSessions, sessionDetail, patchSession,
+ deleteSession, readJsonFile, writeFile, sessionTurns, containerFileUrl,
+} from 'reifyui/harness';
+
+configureKit({ kitId: 'sheets' });
+
+export const SHEET_FILE = 'sheet.json';
+
+export { containerFileUrl, sessionTurns };
+
+/** The Harness this kit launched, or null when it was never launched. */
+export const sheetsHarness = kitHarness;
+
+/** Every Harness an agent COLUMN may run.
+ *
+ * Deliberately excludes this kit's own Harness. A sheet whose column runs the sheet's own agent
+ * would have that agent editing sheet.json while the app is driving a run over it — recursion
+ * with a file-write race inside it. Excluded at the source of the list rather than validated at
+ * run time, so the choice is never offered in the first place.
+ *
+ * Also excludes harnesses that require request headers: their turns are refused without those
+ * headers, and this app has nowhere to hold them. They are returned marked rather than dropped,
+ * so the picker can say why instead of silently having fewer entries than the console shows. */
+export async function runnableHarnesses() {
+ const [harnesses, mine] = await Promise.all([listHarnesses(), sheetsHarness()]);
+ return harnesses
+ .filter((h) => h.id !== mine?.id)
+ .map((h) => ({
+ id: h.id,
+ name: h.name,
+ base: h.base,
+ model: h.defaultModel,
+ unusable: Object.keys(h.additionalHeaders || {}).length
+ ? 'needs request headers this app can’t send'
+ : '',
+ }));
+}
+
+// ── sheets (= sessions) ────────────────────────────────────────────────────
+function toSheet(s) {
+ const id = s.id || s.session_id;
+ return {
+ id,
+ name: s.title || s.name || 'Untitled sheet',
+ updated_at: s.updated_at || s.created_at || null,
+ status: s.status || '',
+ };
+}
+
+export async function listSheets() {
+ const { sessions } = await listSessions({ limit: 100 });
+ return sessions.map(toSheet);
+}
+
+/** A pending id: a sheet that has been chosen but not yet created.
+ *
+ * Nothing but a turn creates a session, so a new sheet has no id until its first message opens
+ * one. The id carries the template so the first turn knows what to build, and the page adopts
+ * the real id from the stream. */
+export const PENDING = 'new:';
+export const isPending = (id) => String(id || '').startsWith(PENDING);
+export const pendingTemplate = (id) => (isPending(id) ? String(id).slice(PENDING.length) : '');
+export const newSheetId = (template = 'blank') => `${PENDING}${template || 'blank'}`;
+
+/** Rename. This also rewrites the trace manifest the sheet LIST renders from, and marks the title
+ * as chosen so the next turn stops regenerating it from the latest message. */
+export const renameSheet = (id, name) => patchSession(id, { title: name });
+
+/** Delete the sheet and the conversation underneath it. */
+export const deleteSheet = deleteSession;
+
+/** The sheet document, or null when the agent has not written one yet.
+ *
+ * Read by path, which reads the LIVE workspace, so a sheet appears as soon as it is written —
+ * mid-turn. The file LISTING answers from the checkpoint tarball, which only exists once a turn
+ * ends, and would show a spinner over a file already on disk. */
+export async function getSheet(id) {
+ if (isPending(id)) return null;
+ return readJsonFile(id, SHEET_FILE);
+}
+
+/** Write the document back. Refused 409 while a turn is running: the agent owns the file until
+ * it finishes. The caller must re-arm rather than drop the write — see SheetPage's save queue. */
+export function saveSheet(id, sheet) {
+ return writeFile(id, SHEET_FILE, JSON.stringify(sheet, null, 2));
+}
+
+/** Current turn state, so the grid knows when to lock and when to re-read. */
+export async function sheetStatus(id) {
+ if (isPending(id)) return '';
+ const d = await sessionDetail(id).catch(() => null);
+ return d?.turn_status || d?.status || '';
+}
+
+/** The console's deep link to one conversation — where "open the full conversation" goes. */
+export const consoleSessionUrl = (harnessId, sessionId) =>
+ `/tasks?h=${encodeURIComponent(harnessId || '')}&sid=${encodeURIComponent(sessionId || '')}`;
+
+// ── last viewed (local record) ─────────────────────────────────────────────
+const LV_KEY = 'sheets.lastViewed';
+export function lastViewedMap() {
+ try { return JSON.parse(window.localStorage.getItem(LV_KEY) || '{}'); } catch { return {}; }
+}
+export function markViewed(id) {
+ const map = lastViewedMap();
+ map[id] = Date.now();
+ try { window.localStorage.setItem(LV_KEY, JSON.stringify(map)); } catch { /* private mode */ }
+}
+export function relativeTime(ts) {
+ if (!ts) return null;
+ const t = typeof ts === 'number' ? ts : Date.parse(ts);
+ if (Number.isNaN(t)) return null;
+ const m = Math.floor((Date.now() - t) / 60000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m} min ago`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h} h ago`;
+ const d = Math.floor(h / 24);
+ if (d < 30) return `${d} d ago`;
+ return new Date(t).toLocaleDateString();
+}
diff --git a/kits/sheets/app/src/lib/templates.js b/kits/sheets/app/src/lib/templates.js
new file mode 100644
index 0000000..c47b82b
--- /dev/null
+++ b/kits/sheets/app/src/lib/templates.js
@@ -0,0 +1,22 @@
+// The template library.
+//
+// Kit data, not app data: it lives at kits/sheets/templates/ and is staged into public/ at build
+// time (see vite.config.js), so there is one copy of it and it is reachable at
+// /kits/sheets/templates.json.
+const URL = `${import.meta.env.BASE_URL}templates.json`;
+
+let cache = null;
+
+export async function listTemplates() {
+ if (cache) return cache;
+ const res = await fetch(URL, { cache: 'no-store' });
+ if (!res.ok) return [];
+ const body = await res.json().catch(() => null);
+ cache = Array.isArray(body?.templates) ? body.templates : [];
+ return cache;
+}
+
+export async function getTemplate(id) {
+ if (!id || id === 'blank') return null;
+ return (await listTemplates()).find((t) => t.id === id) || null;
+}
diff --git a/kits/sheets/app/src/main.jsx b/kits/sheets/app/src/main.jsx
new file mode 100644
index 0000000..ee4b1b0
--- /dev/null
+++ b/kits/sheets/app/src/main.jsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App.jsx';
+import 'highlight.js/styles/atom-one-dark.css';
+import 'reifyui/styles/sheet.css';
+import 'reifyui/styles/chat.css';
+import 'reifyui/styles/dialog.css';
+import './styles/tokens.css';
+import './styles/app.css';
+
+createRoot(document.getElementById('root')).render();
diff --git a/kits/sheets/app/src/pages/Landing.jsx b/kits/sheets/app/src/pages/Landing.jsx
new file mode 100644
index 0000000..0bec081
--- /dev/null
+++ b/kits/sheets/app/src/pages/Landing.jsx
@@ -0,0 +1,187 @@
+// Landing: describe a sheet, start from a template, or reopen one you have.
+//
+// A sheet is a session on this kit's Harness, so "My sheets" is that harness's session list and
+// there is nothing else to store. Creating one costs no network call here: the choice becomes
+// real on the first turn, which is the only thing that opens a session.
+import { useEffect, useRef, useState } from 'react';
+import { ArrowUp, MoreHorizontal, Table2 } from 'lucide-react';
+import { useDialog } from 'reifyui';
+import {
+ deleteSheet, lastViewedMap, listSheets, newSheetId, relativeTime, renameSheet, sheetsHarness,
+} from '../lib/sh';
+import { listTemplates } from '../lib/templates';
+import { Topbar } from '../components/Topbar';
+
+function openNew(template, seed) {
+ const params = new URLSearchParams();
+ if (seed) params.set('seed', seed);
+ if (template && template !== 'blank') params.set('tpl', template);
+ const q = params.toString();
+ window.location.hash = `#/s/${newSheetId(template)}${q ? `?${q}` : ''}`;
+}
+
+function TemplateCard({ template, onPick }) {
+ const agentCols = (template.sheet?.columns || []).filter((c) => c.type === 'harness').length;
+ return (
+
+ );
+}
+
+function SheetRow({ sheet, viewed, onRename, onDelete }) {
+ const [menu, setMenu] = useState(false);
+ const wrapRef = useRef(null);
+ useEffect(() => {
+ if (!menu) return undefined;
+ const onDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setMenu(false); };
+ window.addEventListener('mousedown', onDown);
+ return () => window.removeEventListener('mousedown', onDown);
+ }, [menu]);
+ return (
+
+ );
+}
+
+export function LandingPage() {
+ const [prompt, setPrompt] = useState('');
+ const [templates, setTemplates] = useState([]);
+ const [sheets, setSheets] = useState(null); // null = loading
+ const [launched, setLaunched] = useState(null); // null = unknown yet
+ const [viewed, setViewed] = useState({});
+ const dialog = useDialog();
+
+ const reload = () => listSheets().then(setSheets).catch(() => setSheets([]));
+
+ useEffect(() => {
+ setViewed(lastViewedMap());
+ listTemplates().then(setTemplates).catch(() => setTemplates([]));
+ sheetsHarness().then((h) => {
+ setLaunched(!!h);
+ // Not launched is a real answer with a real fix, so it is rendered. Catching it into an
+ // empty list would show "no sheets yet" to someone whose actual problem is that nothing is
+ // running.
+ if (h) reload(); else setSheets([]);
+ }).catch(() => { setLaunched(false); setSheets([]); });
+ }, []);
+
+ const rename = async (sheet) => {
+ const name = await dialog.prompt({ title: 'Rename this sheet', defaultValue: sheet.name, confirmLabel: 'Rename' });
+ if (!name?.trim()) return;
+ await renameSheet(sheet.id, name.trim()).catch(() => {});
+ reload();
+ };
+
+ const remove = async (sheet) => {
+ const ok = await dialog.confirm({
+ title: `Delete “${sheet.name}”?`,
+ message: 'The sheet and its conversation go with it. This cannot be undone.',
+ destructive: true,
+ confirmLabel: 'Delete',
+ });
+ if (!ok) return;
+ await deleteSheet(sheet.id).catch((e) => dialog.alert({ variant: 'error', title: 'Could not delete', message: e.message }));
+ reload();
+ };
+
+ return (
+
+
+
+
+
What do you want to work through?
+
+ Describe the sheet. Columns that run one of your agents on every row are set up here too.
+
+
+
+
+
+ {launched === false && (
+
+ Sheets hasn’t been launched yet — open Starter Kits and launch it.
+
+ )}
+
+ {templates.length > 0 && (
+
+
Start from a template
+
+ {/* A template start is still a first turn — nothing but a turn creates a session, and
+ the starting sheet only becomes real when one runs. The sentence is the person's
+ side of it; the sheet itself rides in `instructions`, out of the transcript. */}
+ {templates.map((t) => (
+ openNew(tpl.id, `Start a sheet from the ${tpl.name} template.`)} />
+ ))}
+
+
+ )}
+
+
+
My sheets
+ {sheets === null ? (
+
Loading…
+ ) : sheets.length === 0 ? (
+
+ {launched === false ? 'Nothing here yet.' : 'No sheets yet — describe one above.'}
+
+ ) : (
+
+
Name
Last opened
+
+ {sheets.map((s) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/kits/sheets/app/src/pages/SheetPage.jsx b/kits/sheets/app/src/pages/SheetPage.jsx
new file mode 100644
index 0000000..ec2566a
--- /dev/null
+++ b/kits/sheets/app/src/pages/SheetPage.jsx
@@ -0,0 +1,657 @@
+// The sheet: the grid, the copilot, and the run.
+//
+// Three things own this page and they are kept apart on purpose:
+// the DOCUMENT — one writer, one save queue (commit / flush below)
+// the AGENT — the copilot's turn, during which the file belongs to it and the grid is read-only
+// the RUN — the Runner walking agent columns, whose results flow back through commit
+//
+// The run happens in this tab. There is no workflow engine and no batch endpoint in this
+// deployment, so the browser is the orchestrator; the UI says that before you press Run and says
+// exactly what happened if you leave.
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Download, HelpCircle, Home, ClipboardPaste } from 'lucide-react';
+import { PaneResizer, useResizablePane, useDialog } from 'reifyui';
+import { SheetGrid } from 'reifyui';
+import { getResponse, lastAssistantText, sessionTurns } from 'reifyui/harness';
+import {
+ getSheet, isPending, markViewed, renameSheet, runnableHarnesses, saveSheet, sheetStatus,
+ sheetsHarness,
+} from '../lib/sh';
+import {
+ TYPE_LABEL, cellKey, derivedDeps, isHarnessColumn, validate,
+} from '../lib/model';
+import { Runner, plan, runId, saveConcurrency, savedConcurrency } from '../lib/run';
+import { makeCellDispatcher } from '../lib/cell';
+import { exportSheet } from '../lib/exportSheet';
+import { ChatColumn } from '../components/ChatPanel';
+import { AvatarMenu, LINKS, Wordmark } from '../components/Topbar';
+import { HarnessCell } from '../components/HarnessCell';
+import { HarnessConfig } from '../components/HarnessConfig';
+import { CellDrawer } from '../components/CellDrawer';
+import { PasteRows } from '../components/PasteRows';
+import { RunControl, lastRunSummary } from '../components/RunControl';
+
+const SAVE_DEBOUNCE_MS = 400;
+const STATUS_POLL_MS = 2000;
+const RELOAD_POLL_MS = 4000;
+const now = () => Math.floor(Date.now() / 1000);
+
+// The grid's column vocabulary. `harness` is a first-class type rather than a discriminator over
+// a generic "computed" kind: the other kinds the hosted product offered (a workflow, an image)
+// have no engine behind them here, and a discriminator with one member is a fallback waiting to
+// accrete.
+const COLUMN_TYPES = [
+ { type: 'text', label: TYPE_LABEL.text },
+ { type: 'number', label: TYPE_LABEL.number },
+ { type: 'select', label: TYPE_LABEL.select },
+ { type: 'tags', label: TYPE_LABEL.tags },
+ { type: 'checkbox', label: TYPE_LABEL.checkbox },
+ { type: 'date', label: TYPE_LABEL.date },
+ { type: 'url', label: TYPE_LABEL.url },
+ {
+ type: 'harness',
+ label: TYPE_LABEL.harness,
+ computed: true,
+ configKey: 'harness',
+ badge: (col) => col.harness?.harness_name || (col.harness?.harness_id ? '' : 'not set up'),
+ configWidth: 380,
+ configHeight: 460,
+ },
+];
+
+const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** The first agent column that reads something to its right, if any. */
+function firstBrokenDep(sheet) {
+ const columns = sheet.columns || [];
+ for (let i = 0; i < columns.length; i += 1) {
+ if (!isHarnessColumn(columns[i])) continue;
+ for (const depId of derivedDeps(columns[i], columns)) {
+ const j = columns.findIndex((c) => c.id === depId);
+ if (j >= i) return { column: columns[i], dep: columns[j] || null };
+ }
+ }
+ return null;
+}
+
+export function SheetPage({ id: routeId, seed, template }) {
+ // The session id lives in state, not in the route. A sheet starts as "new:" and
+ // becomes a session when its first turn opens one — which arrives WHILE the copilot is
+ // streaming. Re-keying this component off the URL at that moment unmounts it mid-stream and
+ // throws the turn away.
+ const [id, setId] = useState(routeId);
+ useEffect(() => { setId(routeId); }, [routeId]);
+
+ const [sheet, setSheet] = useState(null);
+ const [noSheet, setNoSheet] = useState(null);
+ const [err, setErr] = useState('');
+ const [saveState, setSaveState] = useState(''); // '' | saving | saved | waiting | error
+ const [agentBusy, setAgentBusy] = useState(false);
+ const [drawer, setDrawer] = useState(null); // {rowId, colId}
+ const [liveText, setLiveText] = useState({}); // cellKey -> first line, while running
+ const [pasting, setPasting] = useState(false);
+ const [exportOpen, setExportOpen] = useState(false);
+ const [env, setEnv] = useState(null); // {harnesses: Map, ownId}
+ const [running, setRunning] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [notice, setNotice] = useState('');
+ const [chatOpen, setChatOpen] = useState(() => window.innerWidth > 900);
+
+ const dialog = useDialog();
+ const chatPane = useResizablePane({ initial: 380, min: 300, maxFraction: 0.6, fromRight: true, storageKey: 'sheets.chat.w' });
+ const sheetRef = useRef(null);
+ sheetRef.current = sheet;
+ const idRef = useRef(id);
+ idRef.current = id;
+ const runnerRef = useRef(null);
+ const exportRef = useRef(null);
+ const mirrored = useRef(''); // the session title we last asserted
+ const save = useRef({ timer: null, inFlight: false, backoff: 0, wanted: null });
+
+ // ── the one writer ────────────────────────────────────────────────────────
+ const flush = useCallback(async () => {
+ const q = save.current;
+ if (q.inFlight || isPending(idRef.current)) return;
+ const doc = sheetRef.current;
+ if (!doc || doc === q.wanted) return;
+ q.inFlight = true;
+ const attempt = doc;
+ try {
+ await saveSheet(idRef.current, attempt);
+ q.inFlight = false;
+ q.backoff = 0;
+ q.wanted = attempt;
+ if (sheetRef.current !== attempt) { flush(); return; } // it changed while we wrote
+ setSaveState('saved');
+ window.setTimeout(() => setSaveState((v) => (v === 'saved' ? '' : v)), 1800);
+ } catch (e) {
+ q.inFlight = false;
+ // 409 means the agent holds the file. That is the lock, and it is honest — but the write
+ // is not dropped: it is re-armed with backoff and the newest whole document is written
+ // when the turn ends.
+ const busy = e?.status === 409;
+ setSaveState(busy ? 'waiting' : 'error');
+ q.backoff = Math.min(8000, q.backoff ? q.backoff * 2 : 1000);
+ window.clearTimeout(q.timer);
+ q.timer = window.setTimeout(flush, q.backoff);
+ }
+ }, []);
+
+ const commit = useCallback((next) => {
+ setSheet(next);
+ sheetRef.current = next;
+ if (isPending(idRef.current)) return;
+ setSaveState('saving');
+ window.clearTimeout(save.current.timer);
+ save.current.timer = window.setTimeout(flush, SAVE_DEBOUNCE_MS);
+ }, [flush]);
+
+ // ── loading ───────────────────────────────────────────────────────────────
+ // "No sheet" is three different situations and they must not look alike: the file is there;
+ // a turn is still writing it; or a turn finished having written nothing, in which case its own
+ // last words are the only honest explanation we have.
+ const load = useCallback(async () => {
+ try {
+ const doc = await getSheet(idRef.current);
+ if (doc) {
+ setNoSheet(null);
+ // The file is the truth. A copilot turn may have restructured it, and re-merging
+ // remembered run refs onto the agent's document would show a result that no longer
+ // corresponds to the configuration beside it.
+ setSheet(doc);
+ sheetRef.current = doc;
+ save.current.wanted = doc;
+ return doc;
+ }
+ const st = await sheetStatus(idRef.current).catch(() => '');
+ if (['running', 'starting'].includes(st)) {
+ setNoSheet({ working: true, text: 'Setting up your sheet…' });
+ return null;
+ }
+ const turns = isPending(idRef.current) ? [] : await sessionTurns(idRef.current).catch(() => []);
+ const last = turns[turns.length - 1];
+ setNoSheet({
+ working: false,
+ text: last?.status && last.status !== 'completed'
+ ? `The last turn ended as “${last.status}” without writing a sheet.`
+ : 'No sheet has been written for this conversation yet.',
+ detail: lastAssistantText(turns).slice(0, 400),
+ });
+ return null;
+ } catch (e) {
+ setErr(e?.message || 'Could not open this sheet.');
+ return null;
+ }
+ }, []);
+
+ useEffect(() => { markViewed(id); load(); }, [id, load]);
+
+ // Keep looking while the agent works: the sheet is a file it writes DURING the turn, and reads
+ // hit the live workspace, so the grid fills in as it is built. Without this the page shows
+ // "Setting up your sheet…" over a file already on disk — a reloaded tab has no stream.
+ useEffect(() => {
+ if (!noSheet?.working && sheet && !agentBusy) return undefined;
+ const t = window.setInterval(() => { void load(); }, RELOAD_POLL_MS);
+ return () => window.clearInterval(t);
+ }, [noSheet?.working, sheet, agentBusy, load]);
+
+ // ── the agent's lock ──────────────────────────────────────────────────────
+ useEffect(() => {
+ if (isPending(id)) return undefined;
+ let dead = false;
+ const tick = () => sheetStatus(id)
+ .then((st) => { if (!dead) setAgentBusy(['running', 'starting'].includes(st)); })
+ .catch(() => {});
+ tick();
+ const iv = window.setInterval(tick, STATUS_POLL_MS);
+ return () => { dead = true; window.clearInterval(iv); };
+ }, [id]);
+
+ // Once the turn ends, write whatever accumulated while the file was locked.
+ const prevAgentBusy = useRef(false);
+ useEffect(() => {
+ if (prevAgentBusy.current && !agentBusy) {
+ mirrored.current = ''; // the turn just renamed the session; re-assert the real name
+ load().then(() => flush());
+ }
+ prevAgentBusy.current = agentBusy;
+ }, [agentBusy, load, flush]);
+
+ // ── the workspace's agents ────────────────────────────────────────────────
+ useEffect(() => {
+ let dead = false;
+ Promise.all([runnableHarnesses(), sheetsHarness()])
+ .then(([list, mine]) => {
+ if (dead) return;
+ setEnv({ harnesses: new Map(list.map((h) => [h.id, h])), ownId: mine?.id || '' });
+ })
+ .catch(() => { if (!dead) setEnv({ harnesses: new Map(), ownId: '' }); });
+ return () => { dead = true; };
+ }, []);
+
+ const adoptSession = useCallback((sid) => {
+ if (!sid || sid === idRef.current) return;
+ const [, query = ''] = (window.location.hash || '').split('?');
+ // replaceState does not fire hashchange, so App never re-keys and the live stream survives.
+ // The pending id addresses nothing, so it must not become a Back target either.
+ window.history.replaceState({}, '', `#/s/${sid}${query ? `?${query}` : ''}`);
+ setId(sid);
+ idRef.current = sid;
+ }, []);
+
+ // ── reconciling a run this tab did not finish ─────────────────────────────
+ const reconciled = useRef('');
+ useEffect(() => {
+ if (!sheet?.run || sheet.run.status !== 'running' || running) return;
+ if (reconciled.current === sheet.run.id) return;
+ reconciled.current = sheet.run.id;
+ (async () => {
+ const doc = JSON.parse(JSON.stringify(sheetRef.current));
+ let stillRunning = false;
+ for (const [key, cell] of Object.entries(doc.cells || {})) {
+ if (cell?.run_id !== doc.run.id) continue;
+ if (cell.status === 'queued' && !cell.response_id) { delete doc.cells[key]; continue; }
+ if (cell.status !== 'running' || !cell.response_id) continue;
+ // eslint-disable-next-line no-await-in-loop
+ const res = await getResponse(cell.response_id).catch(() => null);
+ const st = res?.status;
+ if (!st || st === 'queued' || st === 'in_progress' || st === 'running') { stillRunning = true; continue; }
+ if (st === 'completed') {
+ const text = (res.output || [])
+ .filter((o) => o.type === 'message')
+ .flatMap((o) => (o.content || []).filter((c) => c.type === 'output_text').map((c) => c.text))
+ .join('\n').trim();
+ doc.cells[key] = { ...cell, status: text ? 'done' : 'failed', ended_at: now(),
+ value: text || null,
+ error: text ? null : 'The agent finished without answering.' };
+ } else {
+ doc.cells[key] = { ...cell, status: 'failed', ended_at: now(),
+ error: res?.error?.message || `The turn ${st}.` };
+ }
+ }
+ if (!stillRunning) {
+ // ended_at is left null: we do not know when the walk stopped and will not invent it.
+ doc.run = { ...doc.run, status: 'abandoned' };
+ }
+ commit(doc);
+ })();
+ }, [sheet?.run, running, commit]);
+
+ // ── the run ───────────────────────────────────────────────────────────────
+ const [concurrency, setConcurrency] = useState(savedConcurrency);
+ const pickConcurrency = (n) => { setConcurrency(n); saveConcurrency(n); };
+
+ const startRun = useCallback(async (filter, scope = { kind: 'sheet' }) => {
+ const doc = sheetRef.current;
+ if (!doc || !env) return;
+ const p = plan(doc, scope, filter, env);
+ if (p.refusals.length) {
+ await dialog.alert({
+ variant: 'warning',
+ title: 'This sheet can’t run yet',
+ message: p.refusals.map((r) => `${r.column} ${r.reason}`).join('\n'),
+ });
+ return;
+ }
+ if (!p.cells.length) {
+ await dialog.alert({ title: 'Nothing to run', message: filter === 'failed'
+ ? 'No cells failed in the last run.'
+ : 'Every cell in this scope already has a result.' });
+ return;
+ }
+ if (scope.kind === 'sheet') {
+ const ok = await dialog.confirm({
+ title: `Run ${p.cells.length} cell${p.cells.length === 1 ? '' : 's'}?`,
+ message: 'Running replaces the results in these columns. The conversations stay in your history.\n\n'
+ + 'The run happens in this tab. If you close it, cells already started will finish; nothing new starts.',
+ confirmLabel: 'Run',
+ });
+ if (!ok) return;
+ }
+
+ const rid = runId();
+ const next = JSON.parse(JSON.stringify(doc));
+ // Only the last run is retained: the cells this run owns are cleared before it starts, so a
+ // half-old, half-new column can never be read as one result.
+ for (const c of p.cells) delete next.cells[c.key];
+ next.run = {
+ id: rid, status: 'running', started_at: now(), ended_at: null,
+ scope, filter, columns: p.columns, concurrency,
+ planned: p.cells.length, done: 0, failed: 0, skipped: 0,
+ };
+ commit(next);
+ setRunning(true);
+ setProgress({ planned: p.cells.length, settled: 0, done: 0, failed: 0, skipped: 0 });
+
+ const applyCell = (key, record) => {
+ const cur = JSON.parse(JSON.stringify(sheetRef.current));
+ cur.cells = cur.cells || {};
+ if (record.status === null) delete cur.cells[key];
+ else cur.cells[key] = { ...(cur.cells[key] || {}), ...record, run_id: rid };
+ // Counters are recomputed from the cells rather than incremented, and written on every
+ // change rather than only at the end: a tab reopened mid-run reads this file, and a run
+ // header that says 0 done over four finished cells is a lie the file told.
+ if (cur.run?.id === rid) {
+ const mine = Object.values(cur.cells).filter((c) => c.run_id === rid);
+ cur.run = {
+ ...cur.run,
+ done: mine.filter((c) => c.status === 'done').length,
+ failed: mine.filter((c) => c.status === 'failed').length,
+ skipped: mine.filter((c) => c.status === 'skipped').length,
+ };
+ }
+ commit(cur);
+ };
+
+ const runner = new Runner({
+ sheet: next,
+ plan: p,
+ concurrency,
+ dispatch: makeCellDispatcher({
+ sheetId: idRef.current, runId: rid, sheetTitle: next.meta?.title,
+ columns: next.columns, onCell: applyCell,
+ }),
+ onCell: applyCell,
+ onProgress: setProgress,
+ });
+ runnerRef.current = runner;
+ const final = await runner.run();
+ runnerRef.current = null;
+ setRunning(false);
+
+ const done = JSON.parse(JSON.stringify(sheetRef.current));
+ if (done.run?.id === rid) {
+ done.run = {
+ ...done.run,
+ status: runner.stopped ? 'cancelled' : 'done',
+ ended_at: now(),
+ done: final.done, failed: final.failed, skipped: final.skipped,
+ };
+ commit(done);
+ }
+ }, [env, concurrency, commit, dialog]);
+
+ const stopRun = useCallback(() => { runnerRef.current?.stop(); }, []);
+
+ // Leaving mid-run stops the walk. Say so with the platform's own guard rather than inventing a
+ // second one — and never with a browser popup of our own.
+ useEffect(() => {
+ if (!running) return undefined;
+ const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ''; };
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
+ }, [running]);
+
+ // ── grid edits ────────────────────────────────────────────────────────────
+ const onGridChange = useCallback((next) => {
+ const prev = sheetRef.current;
+ if (!prev) return;
+
+ // A column rename must follow into the prompts that address it by name; otherwise renaming a
+ // column silently breaks every agent column that reads it, and the break only shows at run
+ // time.
+ const renames = [];
+ for (const c of next.columns || []) {
+ const before = (prev.columns || []).find((x) => x.id === c.id);
+ if (before && before.name !== c.name) renames.push([before.name, c.name]);
+ }
+ if (renames.length) {
+ for (const col of next.columns) {
+ if (!col.harness?.prompt) continue;
+ let p = col.harness.prompt;
+ for (const [from, to] of renames) {
+ p = p.replace(new RegExp(`\\{\\{\\s*${escapeRe(from)}\\s*\\}\\}`, 'g'), `{{${to}}}`);
+ }
+ col.harness = { ...col.harness, prompt: p };
+ }
+ }
+
+ // A reorder that puts an agent column left of something it reads is refused, not accepted and
+ // marked invalid: refusing keeps a reversible drag reversible, where accepting it turns a
+ // drag into a sheet that cannot run and says so minutes later.
+ const order = (next.columns || []).map((c) => c.id).join(',');
+ if (order !== (prev.columns || []).map((c) => c.id).join(',')) {
+ const broken = firstBrokenDep(next);
+ if (broken) {
+ setNotice(`${broken.column.name} reads ${broken.dep?.name || 'a later column'}, `
+ + `so it has to stay to the right of it.`);
+ window.setTimeout(() => setNotice(''), 5000);
+ return;
+ }
+ }
+
+ commit(next);
+ }, [commit]);
+
+ // ── rename ────────────────────────────────────────────────────────────────
+ const rename = useCallback(async () => {
+ const doc = sheetRef.current;
+ const name = await dialog.prompt({
+ title: 'Rename this sheet',
+ defaultValue: doc?.meta?.title || '',
+ confirmLabel: 'Rename',
+ });
+ if (!name || !name.trim() || isPending(idRef.current)) return;
+ // The session title first — it is what the sheet LIST renders, because that list cannot read
+ // the file. Then the document, which is what this page renders.
+ await renameSheet(idRef.current, name.trim()).catch(() => {});
+ commit({ ...doc, meta: { ...doc.meta, title: name.trim() } });
+ }, [dialog, commit]);
+
+ // meta.title is the truth and the session title is its mirror — the sheet LIST renders the
+ // session title, because it cannot read the file.
+ //
+ // The mirror has to be re-asserted after every turn, not just when meta.title changes. A turn
+ // regenerates the session title from the latest user message, so a sheet called "Competitor
+ // scan" appeared in the list as "Add a plain text column called Owner at the end." — the name
+ // was right in the document and wrong everywhere the person looks for it. Re-asserting once per
+ // turn is exactly as often as something can undo it.
+ useEffect(() => {
+ const title = sheet?.meta?.title;
+ if (!title || isPending(id) || mirrored.current === title) return;
+ mirrored.current = title;
+ renameSheet(id, title).catch(() => {});
+ }, [sheet?.meta?.title, id]);
+
+ // ── the export menu ───────────────────────────────────────────────────────
+ useEffect(() => {
+ if (!exportOpen) return undefined;
+ const onDown = (e) => { if (exportRef.current && !exportRef.current.contains(e.target)) setExportOpen(false); };
+ window.addEventListener('mousedown', onDown);
+ return () => window.removeEventListener('mousedown', onDown);
+ }, [exportOpen]);
+
+ const doExport = async (kind) => {
+ setExportOpen(false);
+ try {
+ await exportSheet(kind, sheetRef.current);
+ } catch (e) {
+ dialog.alert({ variant: 'error', title: 'Export didn’t finish', message: e?.message || 'Please try again.' });
+ }
+ };
+
+ // ── rendering ─────────────────────────────────────────────────────────────
+ const readOnly = agentBusy;
+ const columns = sheet?.columns || [];
+ const rows = sheet?.rows || [];
+ const problems = useMemo(() => (sheet ? validate(sheet).errors : []), [sheet]);
+
+ const openCell = drawer && sheet
+ ? {
+ cell: sheet.cells?.[cellKey(drawer.rowId, drawer.colId)] || {},
+ column: columns.find((c) => c.id === drawer.colId),
+ rowIndex: rows.findIndex((r) => r.id === drawer.rowId),
+ }
+ : null;
+
+ const renderCell = useCallback((ctx) => {
+ if (!isHarnessColumn(ctx.column)) return undefined;
+ const key = cellKey(ctx.row.id, ctx.column.id);
+ return (
+ setDrawer({ rowId: ctx.row.id, colId: ctx.column.id })}
+ />
+ );
+ }, [liveText]);
+
+ const renderColumnConfig = useCallback((ctx) => (
+ ctx.type === 'harness' ? : undefined
+ ), []);
+
+ if (err) {
+ return (
+