diff --git a/packages/framework/README.md b/packages/framework/README.md index 3381de141..778e5f9b1 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -3,9 +3,9 @@ **The Framework** — autonomous AI programming: humans make the important decisions while coding agents run unattended. -You register your repos. From then on, agents work on them in sessions: each session -gets a throwaway copy of the repo, does its work, and hands the result off as a pull -request. Your own checkout is never touched. +You register your repos. From then on, agents work on them: each agent gets a throwaway +checkout of the repo, does its work, and hands the result off as a pull request. Your own +checkout is never touched. ```bash npm i -g framework @@ -18,7 +18,7 @@ the-framework # serves the dashboard at http://127.0.0.1:4200 ``` the-framework Serve the dashboard in the foreground. Ctrl+C closes it and - every session it is running. + every agent it is running. --port Dashboard port (default: 4200). --host Bind address (default: 127.0.0.1). A non-loopback address @@ -31,9 +31,9 @@ the-framework Serve the dashboard in the foreground. Ctrl+C closes it a ``` Everything else is the dashboard. It is the product's user interface, and where a -session's prompt, its options, its agent and its checkout are chosen. The dashboard -spawns each session as its own process, handing it one JSON spec rather than a -command line — so a session's configuration is never also a human-facing flag +agent's prompt, its options, its coding agent and its checkout are chosen. The dashboard +spawns each agent as its own process, handing it one JSON spec rather than a +command line — so an agent's configuration is never also a human-facing flag surface. ## How it works @@ -46,17 +46,17 @@ its own subscription auth and stays swappable behind the driver seam ([`Driver`](../agent-driver/src/types.ts) in the `agent-driver` package; Claude Code and Codex today). Everything the framework learns from a turn, it learns by parsing that turn's final -message: the session name the agent invented (the branch is renamed to match), the +message: the session name the agent gave its work (the branch is renamed to match), the views it wants shown, the ready-for-merge signal, and the questions it stops to ask. - **One daemon per machine.** Running the CLI in any registered repo finds it. It - serves the dashboard, spawns sessions, and runs the background work — the idle + serves the dashboard, spawns agents, and runs the background work — the idle sweeps, notifications, chat, the CI watch. -- **A session is one agent working one task**, in its own git worktree on its own +- **An agent is one task being worked**, in its own checkout on its own branch. It streams what it does as events; you can watch, answer its questions, and chat with it live — or not be there at all. -- **Work leaves as a pull request.** When a session ends with real work, the work is - pushed and a PR opened. Empty sessions publish nothing. +- **Work leaves as a pull request.** When an agent ends with real work, the work is + pushed and a pull request opened. An agent that committed nothing publishes nothing. - **When nobody is around**, the daemon plays product manager: it drains the confirmed-task queue, refills it by triaging and planning tickets, keeps CI green on the PRs it opened, and merges them once checks pass — all bounded by the @@ -64,7 +64,7 @@ views it wants shown, the ready-for-merge signal, and the questions it stops to ## Layout -- `src/` — the CLI, the daemon, the session lifecycle, git handoff, autonomy, and +- `src/` — the CLI, the daemon, the agent lifecycle, git handoff, autonomy, and the chat surfaces. Node only. - `dashboard/` — the browser app: a Vite SPA the daemon serves as static files, talking back over plain HTTP. See [its README](./dashboard/README.md). diff --git a/packages/framework/dashboard/README.md b/packages/framework/dashboard/README.md index 65bdefc51..86175981b 100644 --- a/packages/framework/dashboard/README.md +++ b/packages/framework/dashboard/README.md @@ -13,7 +13,7 @@ IPC: - **Reads** — `POST /_rpc/` (`rpc/reads.ts`, `rpc/projects.ts`, …) for agent history, an agent's replay, and the surfaced PLAN/TODO docs. - **Live event stream** — Server-Sent Events at `GET /_rpc/events` (`rpc/events.ts`) tailing - the selected session's `.the-framework/events.jsonl`; each new line becomes one SSE frame. + the selected agent's `.the-framework/events.jsonl`; each new line becomes one SSE frame. The `rpc/` modules are typed stubs: each is declared against the implementation's own signature in `../src/dashboard-rpc/`, so a renamed or re-shaped RPC is a type error here rather than a 404 in @@ -43,8 +43,8 @@ pnpm --filter framework dev:dashboard ``` Populate a project to watch: run `dev:daemon` instead of `dev:dashboard`, which brings a real -daemon up in the dev server's own process, and start a session from the UI. The dashboard is the -only way to start one — the CLI keeps four options and no verbs, and a session's whole +daemon up in the dev server's own process, and start an agent from the UI. The dashboard is the +only way to start one — the CLI keeps four options and no verbs, and an agent's whole configuration travels to it as a JSON spec (`--agent `), never as flags. ## Tests diff --git a/packages/framework/dashboard/components/AddProjectPanel.tsx b/packages/framework/dashboard/components/AddProjectPanel.tsx index 6443e4e89..f3548e938 100644 --- a/packages/framework/dashboard/components/AddProjectPanel.tsx +++ b/packages/framework/dashboard/components/AddProjectPanel.tsx @@ -58,9 +58,9 @@ export function AddProjectPanel({ onAdded, onClose }: { onAdded: () => void; onC // Trust confirmed (#439) -> install + register. const confirmAdd = async () => { if (busy || !path) return - const result = await run(() => sendAddProject(path), 'Failed to add the project.') - if (result?.ok) { - setAdded({ alreadyActivated: result.alreadyActivated }) + const outcome = await run(() => sendAddProject(path), 'Failed to add the project.') + if (outcome.ok) { + setAdded({ alreadyActivated: outcome.value.alreadyActivated }) onAdded() } } diff --git a/packages/framework/dashboard/components/AgentActionsMenu.tsx b/packages/framework/dashboard/components/AgentActionsMenu.tsx index 429d9667c..212e6bb8e 100644 --- a/packages/framework/dashboard/components/AgentActionsMenu.tsx +++ b/packages/framework/dashboard/components/AgentActionsMenu.tsx @@ -102,19 +102,19 @@ export function AgentActionsMenu({ const openApp = (target: 'files' | 'editor') => run(() => sendOpenInApp(projectId, target, agentId ?? undefined), 'Failed to open.') const stopSession = () => - void run(() => sendStop(projectId, agentId ?? undefined).then(() => true), 'Could not stop the session.').then(result => { - if (result) setStopRequested(true) + void run(() => sendStop(projectId, agentId ?? undefined), 'Could not stop the session.').then(outcome => { + if (outcome.ok) setStopRequested(true) }) const mergeAgent = () => { if (!agentId) return - void run(() => sendMerge(projectId, agentId), 'Could not arm the merge.').then(result => { - if (result?.ok) setMergeRequested(true) + void run(() => sendMerge(projectId, agentId), 'Could not arm the merge.').then(outcome => { + if (outcome.ok) setMergeRequested(true) }) } const removeWorktree = () => { if (!agentId) return - void run(() => sendRemoveWorktree(projectId, agentId), 'Could not remove the worktree.').then(result => { - if (result !== undefined) onWorktreeRemoved?.() + void run(() => sendRemoveWorktree(projectId, agentId), 'Could not remove the worktree.').then(outcome => { + if (outcome.ok) onWorktreeRemoved?.() }) } @@ -237,7 +237,7 @@ export function AgentActionsMenu({ confirmLabel="Delete" confirmBusyLabel="Deleting…" fallbackError="Could not delete the agent." - onConfirm={() => sendDeleteAgent(projectId, agentId).then(result => (result.ok ? result : Promise.reject(new Error(result.error))))} + onConfirm={() => sendDeleteAgent(projectId, agentId)} onSuccess={onDeleted} /> )} diff --git a/packages/framework/dashboard/components/AgentComposer.tsx b/packages/framework/dashboard/components/AgentComposer.tsx index 53de12b4c..b9658ab16 100644 --- a/packages/framework/dashboard/components/AgentComposer.tsx +++ b/packages/framework/dashboard/components/AgentComposer.tsx @@ -118,13 +118,11 @@ export function AgentComposer({ return } if (live) { - // sendMessage resolves void; map success to `true` so it is tellable from useAction's - // failure `undefined`. - const result = await run( - () => sendMessage(projectId, text, agentId ?? undefined).then(() => true), + const outcome = await run( + () => sendMessage(projectId, text, agentId ?? undefined), 'Could not send — the agent may have just ended. Your text is kept, try again.', ) - if (result) { + if (outcome.ok) { setQueued(text) composerRef.current?.clear() } diff --git a/packages/framework/dashboard/components/ChoicePanel.tsx b/packages/framework/dashboard/components/ChoicePanel.tsx index a8e61c341..30210deb8 100644 --- a/packages/framework/dashboard/components/ChoicePanel.tsx +++ b/packages/framework/dashboard/components/ChoicePanel.tsx @@ -22,7 +22,6 @@ export function ChoicePanel({ agentId: agentId, choice, active = false, - countdown = true, inline = false, onAnswered, send, @@ -35,13 +34,6 @@ export function ChoicePanel({ /** Inline in the transcript (#1455 item 6): a rounded card in the flow rather than a * full-bleed rail section. Behaviour is identical — only the container changes. */ inline?: boolean - /** - * Whether autopilot's auto-accept countdown may run here (#1455). The launcher's questions hub - * turns it off: it renders every parked session's gate at once, and a page that answers all of - * them ten seconds after being opened is not a hub, it is a mass auto-accept. The session's own - * rail keeps the countdown — there the user chose to look at that one agent. - */ - countdown?: boolean /** * Told once the pick is posted and accepted, with what was picked. The launcher's hub uses it * to collapse the answered card to a single line (#1455 bonus 2); the rail passes nothing — @@ -54,7 +46,7 @@ export function ChoicePanel({ * panel is the same either way, which is the point: a cloud session's question is answered * exactly like a local one. */ - send?: ((pick: string | string[], by: 'user' | 'autopilot') => Promise) | undefined + send?: ((pick: string | string[]) => Promise) | undefined }) { const { busy, error, run } = useAction() // Posted and accepted by the daemon; the panel stays parked (buttons off, status shown) @@ -64,27 +56,20 @@ export function ChoicePanel({ const [checked, setChecked] = useState>( () => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []), ) - // The countdown's auto-accept fires from a closure captured when the countdown started; - // the ref keeps it reading the boxes as they are at fire time (#948). + // Accept reads the boxes as they are when it fires, not as they were when it was wired up. const checkedRef = useRef(checked) checkedRef.current = checked - const [secondsLeft, setSecondsLeft] = useState(null) - const [cancelled, setCancelled] = useState(false) const parked = busy || sent - const post = (pick: string | string[], by: 'user' | 'autopilot' = 'user') => { - const deliver = send ?? ((p: string | string[], b: 'user' | 'autopilot') => sendChoice(projectId, choice.id, p, b, agentId ?? undefined)) - // Both deliverers resolve void and run() reports failure as undefined, so map a delivered - // pick to a value — otherwise success is indistinguishable from a failed post. - void run(() => deliver(pick, by).then(() => true as const), 'Could not send your choice — try again.').then( - delivered => { - if (delivered) { - setSent(true) - onAnswered?.(pick) - } - }, - ) + const post = (pick: string | string[]) => { + const deliver = send ?? ((p: string | string[]) => sendChoice(projectId, choice.id, p, 'user', agentId ?? undefined)) + void run(() => deliver(pick), 'Could not send your choice — try again.').then(outcome => { + if (outcome.ok) { + setSent(true) + onAnswered?.(pick) + } + }) } const toggle = (id: string) => @@ -95,17 +80,10 @@ export function ChoicePanel({ }) // What Accept picks: the checked subset for a multi-select, else the recommended option (the - // first when the agent named none). Shared by the button, the countdown, and Ctrl+Enter. + // first when the agent named none). Shared by the button and Ctrl+Enter. const recommendedId = choice.recommended ?? choice.options[0]?.id const autoPick = (): string | string[] => (choice.multi ? [...checkedRef.current] : (recommendedId ?? '')) - const accept = (by: 'user' | 'autopilot' = 'user') => post(autoPick(), by) - - // Any mouse movement cancels the auto-accept — the human is here, so let them pick. - useEffect(() => { - const cancel = () => setCancelled(true) - window.addEventListener('mousemove', cancel, { once: true }) - return () => window.removeEventListener('mousemove', cancel) - }, []) + const accept = () => post(autoPick()) // Ctrl+Enter accepts the recommended pick (page.ts parity, #440). Only the active gate // (the first in the rail) binds it, so the shortcut is unambiguous with several gates open. diff --git a/packages/framework/dashboard/components/CloudAgentNotice.tsx b/packages/framework/dashboard/components/CloudAgentNotice.tsx index e5752c07b..fbb45b6aa 100644 --- a/packages/framework/dashboard/components/CloudAgentNotice.tsx +++ b/packages/framework/dashboard/components/CloudAgentNotice.tsx @@ -116,7 +116,7 @@ function ParkedQuestion({ Sending “{failure.labels.join(', ')}” failed{failure.note ? `: ${failure.note}` : ''}. Pick again, or answer in the session.

)} - + Answer it in the session diff --git a/packages/framework/dashboard/components/EventList.tsx b/packages/framework/dashboard/components/EventList.tsx index de6aca3ba..8556e3633 100644 --- a/packages/framework/dashboard/components/EventList.tsx +++ b/packages/framework/dashboard/components/EventList.tsx @@ -110,14 +110,14 @@ function rowTone(e: FrameworkEvent): string { * - milestones (a CLEAN `end`, `ready-for-merge`) — green, how far the agent got; a stopped or * failed end is not a milestone (failure is already red, stopped stays neutral), and * `handoff` stays muted because its body reports per-rung outcomes that may be mixed - * - pushed surfaces (`view`, `browser-stream`, `preview`) — primary, the agent showing you something + * - pushed surfaces (`view`, `browser-stream`, `browser`) — primary, the agent showing you something */ function badgeTone(e: FrameworkEvent): string { const semantic = rowTone(e) if (semantic) return semantic if (e.kind === 'choice' || e.kind === 'choice-resolved') return 'text-warning' if ((e.kind === 'end' && e.ok) || e.kind === 'ready-for-merge') return 'text-success' - if (e.kind === 'view' || e.kind === 'browser-stream' || e.kind === 'browser' || e.kind === 'preview') return 'text-primary' + if (e.kind === 'view' || e.kind === 'browser-stream' || e.kind === 'browser') return 'text-primary' return '' } diff --git a/packages/framework/dashboard/components/OpenQuestions.LOGIC.md b/packages/framework/dashboard/components/OpenQuestions.LOGIC.md index cba24bbff..49a77ac11 100644 --- a/packages/framework/dashboard/components/OpenQuestions.LOGIC.md +++ b/packages/framework/dashboard/components/OpenQuestions.LOGIC.md @@ -20,7 +20,7 @@ The "Waiting on you" section: every open question [1] across all projects, longe ## Business logic — TL;DR - **When the section exists** - the daemon's list of open questions [1] is re-read every 5 seconds; with nothing open and nothing just answered, no section at all. -- **One card per question** - each card names the agent [3] and its project, offers "Open session →" into that agent, and shows the gate [2] card with the auto-accept countdown off. +- **One card per question** - each card names the agent [3] and its project, offers "Open session →" into that agent, and shows the gate [2] card, which is never answered for the user. - **Answering** - a pick [4] is posted against the question's own project and agent (through the bridge [8] for a cloud session [7]); a failed post keeps the gate open with the reason shown. - **An answered question collapses and stays** - it becomes a ✓ line that expands to show the options with the pick marked, keeps "Open session →", and survives the daemon dropping the gate until the page is reloaded. - **Order, count and the jump list** - open cards first in the daemon's order, then answered leftovers; the heading counts open ones only; with more than one card a jump list on the right scrolls to any of them. @@ -48,7 +48,7 @@ See `## Context`. The section is titled "Waiting on you · ". Cards scroll inside their own area, capped at 70% of the viewport's height. Each open card has: - A header button (tooltip "Open this session") showing the agent's [3] label — its session name [5], else the first line of its intent cut at 80 characters, else its agent id — and the project's name, with "Open session →" on the right. Clicking it opens that agent's agent view [6], switching project when the agent belongs to another project. -- The gate [2] card itself (`ChoicePanel.tsx`: the question, its options, the recommended one), rendered with its countdown off so nothing here is ever accepted automatically, whatever the autopilot preference says. +- The gate [2] card itself (`ChoicePanel.tsx`: the question, its options, the recommended one). Nothing here is ever accepted automatically, whatever the autopilot preference says: the hub shows every parked gate at once, and answering them all would be a mass auto-accept. ### Answering diff --git a/packages/framework/dashboard/components/OpenQuestions.test.tsx b/packages/framework/dashboard/components/OpenQuestions.test.tsx index 710982ec5..c931b9031 100644 --- a/packages/framework/dashboard/components/OpenQuestions.test.tsx +++ b/packages/framework/dashboard/components/OpenQuestions.test.tsx @@ -8,7 +8,7 @@ const onOpenQuestions = vi.hoisted(() => vi.fn()) vi.mock('../rpc/reads.js', () => ({ onOpenQuestions })) const sendChoice = vi.hoisted(() => vi.fn()) vi.mock('../rpc/control.js', () => ({ sendChoice })) -// Preferences plumbing is not under test; autopilot reads ON so the countdown-off contract below +// Preferences plumbing is not under test; autopilot reads ON so the never-accepted contract below // is observable (a hub must never tick down, however the preference is set). vi.mock('../lib/preferences.js', () => ({ usePreferences: () => ({ autopilot: true }), @@ -86,12 +86,12 @@ describe('OpenQuestions (#1455 item 4)', () => { await waitFor(() => expect(screen.getByText('fix the flaky test')).toBeTruthy()) }) - test('the autopilot countdown never runs in the hub, even with autopilot on', async () => { + test('the hub never answers a question for the user, even with autopilot on', async () => { onOpenQuestions.mockResolvedValue([question()]) render() await waitFor(() => expect(screen.getByText('Start the next backlog item?')).toBeTruthy()) - // ChoicePanel with a countdown shows "● Auto accept in Ns…"; the hub must not (#1455): it - // renders every parked gate at once, and a page that answers them all is a mass auto-accept. + // A gate accepted for the user would say so ("● Auto accept in Ns…"); the hub must never: + // it renders every parked gate at once, and answering them all is a mass auto-accept. expect(screen.queryByText(/Auto accept in/)).toBeNull() }) }) diff --git a/packages/framework/dashboard/components/OpenQuestions.tsx b/packages/framework/dashboard/components/OpenQuestions.tsx index d2745eb99..d0fba1465 100644 --- a/packages/framework/dashboard/components/OpenQuestions.tsx +++ b/packages/framework/dashboard/components/OpenQuestions.tsx @@ -30,8 +30,8 @@ interface Answered { * vanish under the cursor when the poll drops the resolved gate, and clicking the line * re-expands what was picked. The memory is per-mount on purpose — a reload starts clean. * - * The countdown is off on purpose (see ChoicePanel.countdown): a hub that renders every gate at - * once must not auto-accept them all ten seconds after the launcher opens. + * Nothing here is ever answered for the user: a hub that renders every parked gate at once must + * not accept any of them on its own. */ export function OpenQuestions({ onOpenAgent, @@ -112,7 +112,6 @@ export function OpenQuestions({ projectId={question.projectId} agentId={question.agentId} choice={question.choice} - countdown={false} onAnswered={pick => setAnswered(prev => new Map(prev).set(key, { question, pick })) } diff --git a/packages/framework/dashboard/components/TicketDetailPage.tsx b/packages/framework/dashboard/components/TicketDetailPage.tsx index ebd5d1bc7..1acfcc143 100644 --- a/packages/framework/dashboard/components/TicketDetailPage.tsx +++ b/packages/framework/dashboard/components/TicketDetailPage.tsx @@ -37,11 +37,11 @@ export function TicketDetailPage({ const queue = async () => { if (!ticket) return - const result = await run( + const outcome = await run( () => sendQueueTicket(projectId, ticket.title, { file: ticket.file, ...(ticket.priority ? { priority: ticket.priority } : {}) }), 'The ticket could not be queued.', ) - if (result?.ok) setQueued(true) + if (outcome.ok) setQueued(true) } // The manual lock release (#1420): nothing times a `.lock.md` out anymore, so a dead agent's @@ -51,8 +51,8 @@ export function TicketDetailPage({ const holder = ticket?.lockedByAgent?.name ?? ticket?.lockedBy const release = async () => { if (!ticket) return - const result = await run(() => sendReleaseTicketLock(projectId, ticket.file), 'The lock could not be released.') - if (result?.ok) setReleased(true) + const outcome = await run(() => sendReleaseTicketLock(projectId, ticket.file), 'The lock could not be released.') + if (outcome.ok) setReleased(true) } return ( diff --git a/packages/framework/dashboard/components/TicketsPage.tsx b/packages/framework/dashboard/components/TicketsPage.tsx index d60d0f4b2..fa0398f3d 100644 --- a/packages/framework/dashboard/components/TicketsPage.tsx +++ b/packages/framework/dashboard/components/TicketsPage.tsx @@ -97,17 +97,17 @@ export function TicketsPage({ const { busy, error, run } = useAction() const startPlan = async (projectId: string, file: string) => { const prompt = planTicketPrompt(file) - const result = await run(() => sendStart(projectId, prompt, 'prompt'), 'The planning agent could not be started.') - if (result?.ok) onAgentStarted?.(projectId, prompt, result.agentId) + const outcome = await run(() => sendStart(projectId, prompt, 'prompt'), 'The planning agent could not be started.') + if (outcome.ok) onAgentStarted?.(projectId, prompt, outcome.value.agentId) } // Unattended with the ticket named on the options, exactly as the panel's own start column does. const startWork = async (projectId: string, file: string) => { const prompt = workOnTicketPrompt(file) - const result = await run( + const outcome = await run( () => sendStart(projectId, prompt, 'prompt', { unattended: true, ticket: `tickets/${file}` }), 'The work agent could not be started.', ) - if (result?.ok) onAgentStarted?.(projectId, prompt, result.agentId) + if (outcome.ok) onAgentStarted?.(projectId, prompt, outcome.value.agentId) } // The page-wide queue-adds: every unclaimed shown ticket joins the AI queue — the work the // framework picks up on its own — as an implementation entry, or as the [Plan tickets] @@ -138,7 +138,7 @@ export function TicketsPage({ // title as the entry, linked back to the ticket, its priority picking the section. const [queuedKey, setQueuedKey] = useState(null) const queueShownTickets = async (targets: { projectId: string; ticket: WorkspaceTicket }[], key: string) => { - const done = await run(async () => { + const outcome = await run(async () => { const open = await readOpenQueue() for (const { projectId, ticket } of targets) { if (open.tickets.has(`${projectId}\n${ticket.file}`)) continue @@ -150,7 +150,7 @@ export function TicketsPage({ } return { ok: true as const } }, 'The tickets could not be queued.') - if (done?.ok) setQueuedKey(key) + if (outcome.ok) setQueuedKey(key) } // Queue the tickets' PLANS: one `Create tickets/.plan.md` entry each — the [Plan @@ -160,7 +160,7 @@ export function TicketsPage({ // plan could matter. const [plansQueuedKey, setPlansQueuedKey] = useState(null) const queueShownPlans = async (targets: { projectId: string; ticket: WorkspaceTicket }[], key: string) => { - const done = await run(async () => { + const outcome = await run(async () => { const open = await readOpenQueue() for (const { projectId, ticket } of targets) { if (open.texts.has(`${projectId}\n${planTicketPrompt(ticket.file)}`)) continue @@ -173,7 +173,7 @@ export function TicketsPage({ } return { ok: true as const } }, 'The plans could not be queued.') - if (done?.ok) setPlansQueuedKey(key) + if (outcome.ok) setPlansQueuedKey(key) } // A project deselected in the Project facet disappears entirely — its section would otherwise diff --git a/packages/framework/dashboard/components/TicketsPanel.tsx b/packages/framework/dashboard/components/TicketsPanel.tsx index 2d4f9a342..90307ff05 100644 --- a/packages/framework/dashboard/components/TicketsPanel.tsx +++ b/packages/framework/dashboard/components/TicketsPanel.tsx @@ -341,10 +341,10 @@ export function TicketsPanel({ const configure = () => onSelectProject(projectId) const startSession = async (prompt: string, failure: string, options: { unattended?: boolean; ticket?: string } = {}) => { - const result = await run(() => sendStart(projectId, prompt, 'prompt', options), failure) + const outcome = await run(() => sendStart(projectId, prompt, 'prompt', options), failure) // Jump to the session doing the work, so its progress is watchable instead of the panel // sitting on stale rows until files land. - if (result?.ok) onAgentStarted?.(prompt, result.agentId) + if (outcome.ok) onAgentStarted?.(prompt, outcome.value.agentId) } // Unattended (#1279): an update fired by a button is routine work, not a conversation — it diff --git a/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md index 8b8ce5989..1c433426b 100644 --- a/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md +++ b/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md @@ -14,7 +14,8 @@ Wires a trigger character to a floating suggestion menu in the composer [1]'s pr - **The query is matched ignoring letter case** - what the user typed after the trigger character is lower-cased before the trigger's own item source filters on it. - **The menu floats at the caret** - it opens just below the caret, flips above it when the caret is near the bottom of the window, and follows the caret through scrolling and window resizing. - **An empty source shows a note, a mistyped query hides the menu** - a fresh trigger over an empty source shows the trigger's note, while a query that matches nothing hides the menu but keeps the trigger armed, so the menu is back the moment a later key matches. -- **Keys reach the menu while a trigger is open** - key presses go to the menu list first, which steers and picks as described in `SuggestionList.tsx`; Escape is passed through untouched. +- **Keys reach the menu while a trigger is open** - key presses go to the menu list first, which steers and picks as described in `SuggestionList.tsx`. +- **Escape dismisses the menu** - the first Escape closes an open menu and goes no further; the trigger stays armed but silent until a fresh one opens, and a second Escape reaches the surface around the editor. - **A pick replaces the trigger text** - the picked item and the span from the trigger character to the caret go to the trigger's own insertion, which replaces that span. - **The open menu is announced** - while the menu is visible the editor reads as an expanded combobox pointing at the highlighted entry, and the same signal is what makes Enter pick from the menu instead of sending the prompt. @@ -70,6 +71,16 @@ See `## Context`. While a trigger is open, every key press is offered to the menu list before the editor sees it; the list decides which keys it consumes (arrows, Enter and Tab, per `SuggestionList.tsx`) and the rest reach the editor as normal typing. Escape is the one key never offered to the list: it passes through to the editor and the page, and does not close the trigger by itself. +### Escape dismisses the menu + +#### Context + +**Problem**: Escape over an open menu means "close this". Letting it through untouched left the menu on screen after the keystroke that dismisses it, and because an open menu is what makes Enter pick instead of send, the next Enter picked a suggestion the user thought they had dismissed. + +#### Business logic + +The first Escape while a menu is showing closes it and stops there: the menu is hidden, the editor stops reading as expanded, and nothing else on the page acts on that keystroke. The trigger itself stays armed but silent — typing on does not bring the menu back, and only a fresh trigger opens one again. With no menu showing, Escape is not taken: it reaches the surface around the editor as it always did. + ### A pick replaces the trigger text #### Context diff --git a/packages/framework/dashboard/components/prompt-editor/suggestion.ts b/packages/framework/dashboard/components/prompt-editor/suggestion.ts index 6ec0c6830..a784ec401 100644 --- a/packages/framework/dashboard/components/prompt-editor/suggestion.ts +++ b/packages/framework/dashboard/components/prompt-editor/suggestion.ts @@ -55,6 +55,9 @@ function makeRender(config: TriggerConfig) { let getRect: (() => DOMRect | null) | null = null // The editor's contenteditable, for the aria combobox wiring while the menu is open. let editorDom: HTMLElement | null = null + // Escape dismisses this menu for as long as the trigger stays armed: typing on does not bring + // it back, and only a fresh trigger opens one again. + let dismissed = false const setActive = (id: string | null): void => { if (!editorDom) return @@ -68,7 +71,7 @@ function makeRender(config: TriggerConfig) { // stray `<`/`@` in prose is not a trap. The plugin stays active — the menu reappears if // a later key matches. const note = items.length === 0 && !props.query ? config.emptyNote : undefined - const visible = items.length > 0 || !!note + const visible = !dismissed && (items.length > 0 || !!note) if (el) el.style.display = visible ? '' : 'none' // aria-expanded tracks what the user actually sees, not the plugin's active range: a // mistyped query hides the menu while the plugin stays armed, and both the a11y tree and @@ -94,6 +97,7 @@ function makeRender(config: TriggerConfig) { return { onStart(props: RenderProps) { + dismissed = false el = document.createElement('div') el.style.position = 'fixed' el.style.zIndex = '50' @@ -113,10 +117,22 @@ function makeRender(config: TriggerConfig) { draw(props) }, onKeyDown(props: { event: KeyboardEvent }) { - if (props.event.key === 'Escape') return false + // Escape closes the menu and stops there. Leaving it to the editor kept the menu on screen + // after the keystroke that means "dismiss this", and the editor's Enter-to-send guard reads + // an open menu — so the next Enter picked a suggestion instead of sending. A second Escape, + // with no menu open, reaches the surface around the editor as it always did. + if (props.event.key === 'Escape') { + if (!el || el.style.display === 'none') return false + dismissed = true + el.style.display = 'none' + editorDom?.setAttribute('aria-expanded', 'false') + setActive(null) + return true + } return listRef?.onKeyDown(props.event) ?? false }, onExit() { + dismissed = false window.removeEventListener('scroll', reposition, true) window.removeEventListener('resize', reposition) editorDom?.setAttribute('aria-expanded', 'false') diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md index 1bce58f9d..6c942107c 100644 --- a/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md +++ b/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md @@ -16,17 +16,17 @@ Defines the tokens of the composer [1]'s prompt editor: inline chips that stand ## Business logic — TL;DR -- **Five kinds of token** - macro, action, reference, project and file; the kind decides the chip's color and which trigger menu inserts it. +- **Four kinds of token** - macro, action, project and file; the kind decides the chip's color and which trigger menu inserts it. - **The macro catalog** - the six angle-bracket tags the preset prompts repeat, each with a one-line hint for the menu. - **The action catalog** - the three call-shaped tokens that make the agent stop at a gate [3] or push a view [4], each with a hint. -- **Recognizing a token in text** - an upper-case angle-bracket tag or a `show…()` call anywhere in free text is a token; this is how a loaded preset is turned into chips. +- **Recognizing a token in text** - an angle-bracket tag or a `show…()` call anywhere in free text is a token, whatever its letter case; this is how a loaded preset is turned into chips, and it recognizes exactly what typing the same text recognizes. - **Normalizing a known token** - a token matching a catalogued one in any letter case becomes that catalogued token; an unknown one keeps its exact spelling. - **A chip is one unit and serializes verbatim** - a chip is edited as a whole, shows its label, and is written to the prompt as its exact text with no markdown escaping. - **Chips form while typing** - a tag becomes a chip the moment its closing `>` is typed, a call the moment its closing `)` is typed. ## Business logic -### Five kinds of token +### Four kinds of token #### Context @@ -34,7 +34,7 @@ See `## Context`. #### Business logic -A token is of one of five kinds: `macro` (an angle-bracket tag), `action` (a call such as `showChoices()`), `reference` (a general mention), `project` (an `@` mention of a registered project) and `file` (a `#` mention of a file of the open project). Every token carries a label, which is what its chip shows, and a text, which is the exact string written to the prompt; a catalogued token also carries a hint, the one-line description its menu entry shows. The kind is stamped on the chip and picks its color. Which trigger menu inserts which kind is decided by the trigger definitions in `PromptEditor.tsx`. +A token is of one of four kinds: `macro` (an angle-bracket tag), `action` (a call such as `showChoices()`), `project` (an `@` mention of a registered project) and `file` (a `#` mention of a file of the open project). Every token carries a label, which is what its chip shows, and a text, which is the exact string written to the prompt; a catalogued token also carries a hint, the one-line description its menu entry shows. The kind is stamped on the chip and picks its color. Which trigger menu inserts which kind is decided by the trigger definitions in `PromptEditor.tsx`. ### The macro catalog @@ -64,7 +64,7 @@ Three actions are catalogued: `showChoices()` "Single-select gate", `showMultiSe #### Business logic -Anywhere in free text, a token is either a tag written as `<`, an upper-case letter, any run of upper-case letters, digits and underscores, and `>`, or a call written as `show`, one or more letters, and `()`. A lower-case tag in loaded text is not a token. Turning loaded text into chips is done by `tokenize.ts` with this rule. +Anywhere in free text, a token is either a tag written as `<`, a letter, any run of letters, digits and underscores, and `>`, or a call written as `show`, one or more letters, and `()`. Letter case does not decide what counts: the rule recognizes exactly what typing the same text recognizes, so a loaded `` becomes the canonical `` chip just as a typed one does. Turning loaded text into chips is done by `tokenize.ts` with this rule. ### Normalizing a known token diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md new file mode 100644 index 000000000..da2296baf --- /dev/null +++ b/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md @@ -0,0 +1,5 @@ +What the tests cover: + +- **Recognizing a token in text** - a tag and a call are found anywhere in free text, and letter case does not decide what counts, so what a loaded preset offers up as chips is what typing the same text offers. +- **What is not a token** - a stray `<` or `>` in prose, an empty tag, a tag starting with a digit, and a bare `show()` are all left alone. +- **Normalizing a recognized token** - a catalogued tag or call takes its canonical spelling however it was written, so a loaded or typed `` becomes ``; an unknown one keeps exactly what was written, as a tag or, when it ends in a call's brackets, as a call. diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.test.ts b/packages/framework/dashboard/components/prompt-editor/tokens.test.ts new file mode 100644 index 000000000..419dd542d --- /dev/null +++ b/packages/framework/dashboard/components/prompt-editor/tokens.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest' +import { TOKEN_PATTERN, specForText } from './tokens.js' + +/** Every token the pattern finds in `text`, as the tokenizer walks it. */ +function found(text: string): string[] { + return [...text.matchAll(TOKEN_PATTERN)].map(m => m[0]) +} + +describe('recognizing a token in text', () => { + test('tags and calls are found anywhere in free text', () => { + expect(found('Do the work, then and call showChoices() when unsure.')).toEqual(['', 'showChoices()']) + }) + + // What typing recognizes and what loading recognizes have to be the same set, or a preset + // carrying `` reaches the agent as typed while typing it produces ``. + test('letter case does not decide what counts as a tag', () => { + expect(found(' and and ')).toEqual(['', '', '']) + }) + + test('what is not a token', () => { + expect(found('2 < 3 and x > y')).toEqual([]) + expect(found('<>')).toEqual([]) + expect(found('<9lives>')).toEqual([]) + expect(found('show()')).toEqual([]) + }) +}) + +describe('normalizing a recognized token', () => { + test('a catalogued token takes its canonical spelling, whatever the case it was written in', () => { + expect(specForText('')).toMatchObject({ kind: 'macro', label: 'AWAIT', text: '' }) + expect(specForText('')).toMatchObject({ text: '' }) + expect(specForText('showchoices()')).toMatchObject({ kind: 'action', text: 'showChoices()' }) + }) + + test('an unknown token keeps exactly what was written', () => { + expect(specForText('')).toMatchObject({ kind: 'macro', label: 'MY_TAG', text: '' }) + expect(specForText('showSomething()')).toMatchObject({ kind: 'action', label: 'showSomething()', text: 'showSomething()' }) + }) +}) diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.ts b/packages/framework/dashboard/components/prompt-editor/tokens.ts index 2fda2d6ef..6198a4a02 100644 --- a/packages/framework/dashboard/components/prompt-editor/tokens.ts +++ b/packages/framework/dashboard/components/prompt-editor/tokens.ts @@ -2,12 +2,12 @@ import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core' // The prompt editor's tokens (#470). A token is an inline chip that reads as a pill in the // editor but serializes back to the EXACT plain text the agent already parses today — an -// angle-bracket macro (``, ``), an action call (`showMultiSelect()`), or -// a reference (`@my-app`). Because a chip flattens to its `text` verbatim, the prompt over +// angle-bracket macro (``, ``), an action call (`showMultiSelect()`), a +// project (`@my-app`) or a file. Because a chip flattens to its `text` verbatim, the prompt over // the wire is unchanged: presets, the agent contract, everything downstream stays the same. /** What a token is, which drives its chip colour and which menu inserts it. */ -export type TokenKind = 'macro' | 'action' | 'reference' | 'project' | 'file' +export type TokenKind = 'macro' | 'action' | 'project' | 'file' /** One insertable token: how it reads (label) and how it serializes (text). */ export interface TokenSpec { @@ -37,8 +37,14 @@ export const ACTION_TOKENS: TokenSpec[] = [ { kind: 'action', label: 'showMarkdown()', text: 'showMarkdown()', hint: 'Push a markdown view' }, ] -/** Match any insertable token in free text, so a loaded preset can be chip-ified (tokenize.ts). */ -export const TOKEN_PATTERN = /<[A-Z][A-Z0-9_]*>|show[A-Za-z]+\(\)/g +/** + * Match any insertable token in free text, so a loaded preset can be chip-ified (tokenize.ts). + * Letter case does not decide what counts as a tag — `specForText` normalizes a catalogued one — + * so this matches exactly what typing the same text matches (the input rules below). Taking + * uppercase alone made the two disagree: a preset carrying `` stayed plain text and + * reached the agent as typed, while typing it into the same box produced ``. + */ +export const TOKEN_PATTERN = /<[A-Za-z][A-Za-z0-9_]*>|show[A-Za-z]+\(\)/g /** * The token spec for a matched string. A catalogued macro/action matches case-insensitively diff --git a/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md b/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md index 4e21650e1..016a6f38d 100644 --- a/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md +++ b/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md @@ -67,7 +67,7 @@ See `## Context`. #### Business logic -An action fails by throwing or by answering with a refused result; either way the dialog stays open and shows the failure's message in red under the body, or the host's fallback text, "Something went wrong." by default, when the failure carries no message. The user may confirm again or cancel. When the dialog is opened again later, any earlier message is cleared. The dialog treats an action as succeeded only when it resolves to a result; one that resolves to nothing keeps the dialog open without a message. +An action fails by throwing or by answering with a refused result; either way the dialog stays open and shows the failure's message in red under the body, or the host's fallback text, "Something went wrong." by default, when the failure carries no message. The user may confirm again or cancel. When the dialog is opened again later, any earlier message is cleared. An action that succeeds with nothing to report closes the dialog like any other success: only a refusal or a throw keeps it open. ### Success closes first, then the host continues diff --git a/packages/framework/dashboard/components/ui/confirm-dialog.tsx b/packages/framework/dashboard/components/ui/confirm-dialog.tsx index 8bf7d7633..3a0f004b5 100644 --- a/packages/framework/dashboard/components/ui/confirm-dialog.tsx +++ b/packages/framework/dashboard/components/ui/confirm-dialog.tsx @@ -36,7 +36,7 @@ export function ConfirmDialog({ body: ReactNode confirmLabel: string confirmBusyLabel?: string - /** Runs on confirm; returning a falsy/thrown result keeps the dialog open with the error. */ + /** Runs on confirm; a refused or thrown result keeps the dialog open with the error. */ onConfirm: () => Promise /** Fires once, after the dialog has closed on a successful confirm — safe to navigate away in. */ onSuccess?: () => void @@ -49,8 +49,8 @@ export function ConfirmDialog({ const { busy, error, run, reset } = useAction() const confirm = (): void => { - void run(onConfirm, fallbackError).then(result => { - if (result === undefined) return + void run(onConfirm, fallbackError).then(outcome => { + if (!outcome.ok) return setOpen(false) // After the close, so a caller that unmounts this (navigating off the deleted session) does // not tear the dialog down mid-transition. diff --git a/packages/framework/dashboard/components/ui/sidebar.LOGIC.md b/packages/framework/dashboard/components/ui/sidebar.LOGIC.md index f042b346b..c647b3e72 100644 --- a/packages/framework/dashboard/components/ui/sidebar.LOGIC.md +++ b/packages/framework/dashboard/components/ui/sidebar.LOGIC.md @@ -13,7 +13,6 @@ Provides the dashboard's sidebar shell: a side column that can be expanded or co - **Expanded or collapsed, toggled three ways** - the sidebar starts expanded unless its host says otherwise; the "Toggle Sidebar" button, the grab strip on its edge and Cmd/Ctrl+B anywhere on the page flip it. - **Three collapsing modes** - off-canvas slides the sidebar fully out of view, icon mode shrinks it to a narrow strip of icons and hides everything that needs width, and the non-collapsible mode keeps it as a fixed column. - **A drawer on a narrow screen** - narrower than 768 pixels, the sidebar becomes a drawer that slides in from its side, closed by default and opened by the same toggles. -- **The state is written to a cookie that nothing reads** - every toggle records the state in a `sidebar_state` cookie kept for seven days, but the dashboard never reads it back, so a reload starts from the default again. - **Rows follow the collapsed state** - a row can be marked active, comes in three sizes, shows its tooltip only while the sidebar is collapsed to icons, may carry a badge, a hover-only action and a nested sub-list, and has a placeholder row for loading. ## Business logic @@ -48,16 +47,6 @@ The sidebar is 16rem wide when expanded, sits on the left unless the host puts i When the window is narrower than 768 pixels, a collapsible sidebar is not shown as a column at all: it becomes a drawer, 18rem wide, sliding in from the sidebar's side. The drawer is closed by default and its open state is separate from the desktop state, so folding the sidebar on a desktop does not change what a narrow window shows. The drawer's own close button is hidden; it closes through the same toggles or by dismissing it as any sheet (`sheet.tsx`). A non-collapsible sidebar stays a column even on a narrow screen. A row's tooltip is never shown in the drawer. -### The state is written to a cookie that nothing reads - -#### Context - -**Problem**: a sidebar the user folded should stay folded after a reload; the state is recorded for that purpose, but nothing restores it. - -#### Business logic - -Every change of the desktop state writes a cookie named `sidebar_state` holding `true` or `false`, for the whole site and for seven days. No part of the dashboard reads that cookie, so after a reload the sidebar starts from its default (or from whatever its host decides) regardless of what was recorded. The drawer state on a narrow screen is never recorded. - ### Rows follow the collapsed state #### Context diff --git a/packages/framework/dashboard/components/ui/sidebar.tsx b/packages/framework/dashboard/components/ui/sidebar.tsx index 26d55cfe5..c28747298 100644 --- a/packages/framework/dashboard/components/ui/sidebar.tsx +++ b/packages/framework/dashboard/components/ui/sidebar.tsx @@ -20,8 +20,6 @@ import { useIsMobile } from '../../lib/use-mobile.js' // forward the wrapper's div props onto the Sheet's Dialog root. `--sidebar-*` tokens live in // tailwind.css. -const SIDEBAR_COOKIE_NAME = 'sidebar_state' -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = '16rem' const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_ICON = '3rem' @@ -74,8 +72,6 @@ function SidebarProvider({ } else { _setOpen(openState) } - // Persist the state so it survives a reload. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` }, [setOpenProp, open], ) diff --git a/packages/framework/dashboard/design/build.mts b/packages/framework/dashboard/design/build.mts index 7a44c6c48..34ce8b965 100644 --- a/packages/framework/dashboard/design/build.mts +++ b/packages/framework/dashboard/design/build.mts @@ -6,8 +6,9 @@ import { build } from 'vite' import { PREVIEWS, type Preview } from './previews.js' // Builds the design gallery: one self-contained HTML file per card, each carrying the shipped -// stylesheet inline and rendering its component in both themes. `pnpm design:build`, then -// DesignSync uploads design/out/**. Cards are static — no client JS — so hover/open states are +// stylesheet inline and rendering its component in both themes, into design/out/. `pnpm +// design:build` writes it; publishing it is somebody's own business, and nothing in this +// repository does it. Cards are static — no client JS — so hover/open states are // shown as separate rendered instances rather than something to click. const here = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/framework/dashboard/design/previews.tsx b/packages/framework/dashboard/design/previews.tsx index f722f2666..e28f904b8 100644 --- a/packages/framework/dashboard/design/previews.tsx +++ b/packages/framework/dashboard/design/previews.tsx @@ -152,7 +152,7 @@ function Buttons() { return (
- + @@ -209,7 +209,7 @@ function Cards() {
- Session activity + Agent activity

Header plus content, the dashboard default.

@@ -218,7 +218,7 @@ function Cards() {
- Active sessions + Active agents
3
@@ -230,9 +230,9 @@ function Cards() { function StatTiles() { const tiles = [ ['Projects', 12, false], - ['Active sessions', 3, true], - ['Open TODOs', 47, false], - ['Total sessions', 218, false], + ['Active agents', 3, true], + ['Queue entries', 47, false], + ['Total agents', 218, false], ] as const return (
@@ -293,14 +293,14 @@ function EmptyStates() {
-

No finished sessions yet.

+

No finished agents yet.

-

Nothing in the backlog.

+

Nothing on the agent queue.

diff --git a/packages/framework/dashboard/lib/use-action.test.ts b/packages/framework/dashboard/lib/use-action.test.ts index 8d25cfa77..bd7e8833b 100644 --- a/packages/framework/dashboard/lib/use-action.test.ts +++ b/packages/framework/dashboard/lib/use-action.test.ts @@ -3,34 +3,36 @@ import { act, renderHook } from '@testing-library/react' import { useAction } from './use-action.js' describe('useAction', () => { - test('a successful action returns the result, sets no error, and settles busy', async () => { + test('a successful action carries its value, sets no error, and settles busy', async () => { const { result } = renderHook(() => useAction()) let out: unknown await act(async () => { out = await result.current.run(async () => ({ ok: true, url: 'x' })) }) - expect(out).toEqual({ ok: true, url: 'x' }) + expect(out).toEqual({ ok: true, value: { ok: true, url: 'x' } }) expect(result.current.busy).toBe(false) expect(result.current.error).toBe(null) }) - test('a { ok: false } result routes into error and returns undefined', async () => { + test('a { ok: false } result routes into error and reports the action as not ok', async () => { const { result } = renderHook(() => useAction()) let out: unknown = 'sentinel' await act(async () => { out = await result.current.run(async () => ({ ok: false, error: 'nope' })) }) - expect(out).toBe(undefined) + expect(out).toEqual({ ok: false }) expect(result.current.error).toBe('nope') }) test('a thrown error routes into error, falling back when it carries no message', async () => { const { result } = renderHook(() => useAction()) + let out: unknown = 'sentinel' await act(async () => { - await result.current.run(async () => { + out = await result.current.run(async () => { throw new Error('boom') }) }) + expect(out).toEqual({ ok: false }) expect(result.current.error).toBe('boom') await act(async () => { await result.current.run(async () => { @@ -40,14 +42,25 @@ describe('useAction', () => { expect(result.current.error).toBe('fallback msg') }) - test('a void action returns undefined and sets no error on success', async () => { + // The distinction the outcome exists for: an action that succeeds with nothing to report is + // not a failure, and a caller must be able to tell the two apart without a stand-in value. + test('an action that succeeds with nothing is ok, and tellable from one that failed', async () => { const { result } = renderHook(() => useAction()) - let out: unknown = 'sentinel' + let succeeded: unknown = 'sentinel' await act(async () => { - out = await result.current.run(async () => {}) + succeeded = await result.current.run(async () => {}) }) - expect(out).toBe(undefined) + expect(succeeded).toEqual({ ok: true, value: undefined }) expect(result.current.error).toBe(null) + + let failed: unknown = 'sentinel' + await act(async () => { + failed = await result.current.run(async () => { + throw new Error('boom') + }) + }) + expect(failed).toEqual({ ok: false }) + expect(result.current.error).toBe('boom') }) test('reset clears the error', async () => { diff --git a/packages/framework/dashboard/lib/use-action.ts b/packages/framework/dashboard/lib/use-action.ts index fff1d52d5..a5584c0a6 100644 --- a/packages/framework/dashboard/lib/use-action.ts +++ b/packages/framework/dashboard/lib/use-action.ts @@ -1,34 +1,47 @@ import { useCallback, useState } from 'react' +/** + * What an action did: `ok` says whether it succeeded, and only then is there a `value`. + * + * Said as a pair rather than as "the value, or `undefined` when it did not succeed", because an + * action that succeeds with nothing to report is not a failure: with the value alone, a `sendStop` + * that resolves void was indistinguishable from one that threw, and every such caller had to map + * its success to a stand-in value to tell the two apart. + */ +export type ActionOutcome = { ok: true; value: Succeeded } | { ok: false } + +/** What is left of an action's result once its own refusal branch is ruled out. */ +type Succeeded = Exclude + // The write-side twin of use-async's read hooks. Every mutation panel hand-rolled the same // shape: flip a busy flag, clear the error, await the RPC, route a `{ ok: false, error }` // result or a thrown error into an error string, and reset busy in a finally. That is this -// hook, once. `run` returns the RPC result on success (so the caller does only its success -// side) and `undefined` when the action did not succeed. `fallback` names the error for a -// thrown failure that carries no message of its own. +// hook, once. `run` reports whether the action succeeded and carries its value when it did, so +// the caller does only its success side. `fallback` names the error for a thrown failure that +// carries no message of its own. export function useAction(): { busy: boolean error: string | null reset: () => void - run: (fn: () => Promise, fallback?: string) => Promise + run: (fn: () => Promise, fallback?: string) => Promise> } { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const reset = useCallback(() => setError(null), []) - const run = useCallback(async (fn: () => Promise, fallback = 'Something went wrong.'): Promise => { + const run = useCallback(async (fn: () => Promise, fallback = 'Something went wrong.'): Promise> => { setBusy(true) setError(null) try { const result = await fn() if (isFailure(result)) { setError(result.error ?? fallback) - return undefined + return { ok: false } } - return result + return { ok: true, value: result as Succeeded } } catch (err) { setError(err instanceof Error ? err.message : fallback) - return undefined + return { ok: false } } finally { setBusy(false) } diff --git a/packages/framework/dashboard/lib/use-agent-handoff.ts b/packages/framework/dashboard/lib/use-agent-handoff.ts index 9273a5789..d7764eac7 100644 --- a/packages/framework/dashboard/lib/use-agent-handoff.ts +++ b/packages/framework/dashboard/lib/use-agent-handoff.ts @@ -41,9 +41,9 @@ export function useAgentHandoff(projectId: string, agentId: string | null | unde const act = (which: 'push' | 'pr' | 'merge', fn: () => Promise, fallback: string): void => { setPending(which) - void run(fn, fallback).then(result => { + void run(fn, fallback).then(outcome => { setPending(null) - if (result !== undefined) reload() + if (outcome.ok) reload() }) } diff --git a/packages/framework/dashboard/lib/use-start-agent.ts b/packages/framework/dashboard/lib/use-start-agent.ts index d5dc5f86b..c65a30253 100644 --- a/packages/framework/dashboard/lib/use-start-agent.ts +++ b/packages/framework/dashboard/lib/use-start-agent.ts @@ -28,13 +28,13 @@ export function useStartAgent(): { options: StartArgs[3], fallback = 'Failed to start the agent.', ) => { - const result = await run(async () => { - const outcome = await sendStart(projectId, text, kind, options) + const outcome = await run(async () => { + const started = await sendStart(projectId, text, kind, options) // The daemon's refusal is phrased for its own log; give the dashboard its words. - if (!outcome.ok && outcome.busy) return { ...outcome, error: 'An agent is already active for this project.' } - return outcome + if (!started.ok && started.busy) return { ...started, error: 'An agent is already active for this project.' } + return started }, fallback) - return result?.ok ? result : undefined + return outcome.ok ? outcome.value : undefined } return { busy, error, reset, start } } diff --git a/packages/framework/dashboard/tailwind.css b/packages/framework/dashboard/tailwind.css index 77224cf02..e3d097c00 100644 --- a/packages/framework/dashboard/tailwind.css +++ b/packages/framework/dashboard/tailwind.css @@ -236,7 +236,6 @@ body { color: var(--foreground); border-color: var(--border); } -.pe-token[data-token='reference'], .pe-token[data-token='project'] { background: var(--accent); color: var(--accent-foreground); diff --git a/packages/framework/src/agent-messages.test.ts b/packages/framework/src/agent-messages.test.ts index a0c6b914e..71ff0695f 100644 --- a/packages/framework/src/agent-messages.test.ts +++ b/packages/framework/src/agent-messages.test.ts @@ -37,7 +37,7 @@ test('AgentMessageQueue push() is a no-op after close()', async () => { assert.equal(await q.next(), undefined) }) -test('AgentMessageQueue next() unblocks on abort (Stop / budget cap)', async () => { +test('AgentMessageQueue next() unblocks on abort (a Stop)', async () => { const q = new AgentMessageQueue() const ac = new AbortController() const pending = q.next(ac.signal) diff --git a/packages/framework/src/agent-messages.ts b/packages/framework/src/agent-messages.ts index 9c9e85a27..af2a320ab 100644 --- a/packages/framework/src/agent-messages.ts +++ b/packages/framework/src/agent-messages.ts @@ -27,7 +27,7 @@ export interface AgentMessages { /** * The next user message. Returns an already-queued message immediately (drain * between turns); otherwise waits for one (stay-open). Resolves `undefined` when - * the agent should stop waiting — the signal aborted (Stop / budget cap) or the + * the agent should stop waiting — the signal aborted (a Stop, or an answer marked stop) or the * source was closed — so the loop ends cleanly rather than hanging. */ next(signal?: AbortSignal): Promise diff --git a/packages/framework/src/agent.ts b/packages/framework/src/agent.ts index cdb26823b..56ff1ad50 100644 --- a/packages/framework/src/agent.ts +++ b/packages/framework/src/agent.ts @@ -225,7 +225,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { } // The backlog loop (#323): with the opening work settled, consume the agent's own TODO - // backlog one gated entry per turn until it is empty. The session signal (Stop / budget cap + // backlog one gated entry per turn until it is empty. The session signal (a Stop, an answer marked stop) // #322) and the item cap bound it for unattended sessions. let todo: TodoLoopResult | undefined if (kind === 'build' && !handsOff && (opts.todoLoop ?? opts.driver.id !== 'fake')) { diff --git a/packages/framework/src/auto-pm.ts b/packages/framework/src/auto-pm.ts index 36d29d823..ecbc07862 100644 --- a/packages/framework/src/auto-pm.ts +++ b/packages/framework/src/auto-pm.ts @@ -358,13 +358,14 @@ export function pinnedPlanJob(job: AutoPmJob, assignment: PlanAssignment): AutoP * the maintenance sweep (#882), which is paced by a calendar because it looks at static history and * would otherwise never come due — hence its own {@link AUTO_PM_MAINTENANCE_JOB} outside the cycle. * - * The gated triage sibling (#698) is deliberately not here: it ends in ``, so firing it with - * nobody at the keyboard would park an agent against a human who will never answer. + * The presets that end in `` (Research, Suggest tickets to work on) are deliberately not + * here: firing one with nobody at the keyboard would park an agent against a human who will never + * answer. * - * Each triage prompt pins its own session name and aborts if that branch already exists, so a - * rotation that comes round again while the previous triage is still in flight is a no-op rather - * than a duplicate. The rotation still advances past it, which is the wanted behaviour: the next - * idle tick tries the next job instead of retrying a job that is already running. + * Each triage prompt pins its own session name, so a triage always lands on the same branch. What + * keeps a rotation from triaging twice is the routine lock (`routine-locks.ts`), taken before the + * agent starts: a firing that finds the lock held stands down without spending an agent, and the + * rotation advances past it rather than retrying a job that is already running. */ export const AUTO_PM_JOBS: readonly AutoPmJob[] = [ { diff --git a/packages/framework/src/await-gate.LOGIC.md b/packages/framework/src/await-gate.LOGIC.md index 5070ba234..357ee4df1 100644 --- a/packages/framework/src/await-gate.LOGIC.md +++ b/packages/framework/src/await-gate.LOGIC.md @@ -104,7 +104,7 @@ An exchange answers at most 5 consecutive gates [3]. When the turn after the fif - On a single-select gate the recommended option is the one the agent named, else the first option. It is shown pre-selected. - A headless agent, one with no surface at all to ask on, takes the recommended option without pausing. A multi-select takes its pre-checked set. -- A gate parked for a pick resolves to the recommended option the moment the agent is stopped or hits its budget cap, and also when the ask itself fails; the wait never rejects. +- A gate parked for a pick resolves to the recommended option the moment the agent is stopped, and also when the ask itself fails; the wait never rejects. - An answer that names no option of the gate resolves to the recommended option rather than to an unknown id. - The resolution recorded on the event stream [10] says who picked: the user, the dashboard on the user's behalf, or the automatic fallback. diff --git a/packages/framework/src/await-gate.ts b/packages/framework/src/await-gate.ts index 099662eab..e18391047 100644 --- a/packages/framework/src/await-gate.ts +++ b/packages/framework/src/await-gate.ts @@ -164,7 +164,7 @@ function promptContinuation(session: DriverSession, deps: AwaitTurnDeps): (quest * and a follow-up reopens the conversation via `--resume` (#762), like Claude Code web. * `stayOpen` keeps the old park-for-the-next-message lifecycle, for an agent whose own terminal * dashboard is the only surface — it has no daemon to resume through, so ending would leave - * its composer a dead end; that agent still ends on Stop / budget cap (next -> undefined). + * its composer a dead end; that agent still ends on a Stop (next -> undefined). * * Reports the settled `exhausted` of the *last* chat turn (#742): entering chat means the * opening prompt's await-round cap is no longer the agent's end reason, and a phase that ends @@ -186,7 +186,7 @@ export async function runChatPhase(session: DriverSession, messages: AgentMessag // session's natural end, and the agent's `end` event follows right behind it. message = deps.signal?.aborted ? undefined : messages.takeQueued() } - if (message === undefined) return { turn, exhausted, stopped: false } // idle queue / Stop / budget cap: end the conversation. + if (message === undefined) return { turn, exhausted, stopped: false } // idle queue or a Stop: end the conversation. // The message shows in the feed as the driver's own `start` event (the YOU row), so it is not // echoed as a separate log line — that only duplicated it. turn = await session.prompt(message.text, { ...signalOpt, resume: true }) @@ -248,7 +248,7 @@ const PROCEED: ChoicePick = { picked: 'proceed', by: 'auto' } /** * Resolve with the human's pick, or fall back to `fallback` if the pick rejects or - * the agent aborts first (user stop / budget cap #322) — so a gate parked for input + * the agent aborts first (a user stop) — so a gate parked for input * never hangs. Never rejects. Cleans up its abort listener either way. The fallback * is the single-select `proceed` by default; a multi-select passes its default set. */ diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 95ea1742a..9b90fc32e 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -427,7 +427,7 @@ interface AgentEpilogue { maybeFireOnBeforeMergeable: () => Promise /** Hand the session's work back (#1102): push its branch and open a draft PR, if still armed. */ maybeAutoHandoff: () => Promise - /** True once the agent stopped cleanly (interrupt / budget cap) rather than failed. */ + /** True once the agent stopped cleanly (an interrupt, or an answer marked stop) rather than failed. */ isStopped: () => boolean /** How a real failure is labelled: "run", "research", or "prompt run". */ failLabel: string @@ -435,7 +435,7 @@ interface AgentEpilogue { /** * Run one engine (a build or a direct prompt) and settle it identically: on success print its - * line; on a clean stop (interrupt / budget cap #322) report it; on a real failure report it. The + * line; on a clean stop (an interrupt, or an answer marked stop) report it; on a real failure report it. The * teardown — flush the store, close the control channel + consumption guard — runs either way. * Returns the exit code (0 on success or a clean stop, 1 on a failure). Shared by both agent paths * so their epilogues cannot drift. @@ -462,7 +462,7 @@ async function settleAgent(ctx: AgentEpilogue, run: () => Promise<{ successLine: } catch (err) { ctx.clearInterrupt() await ctx.store?.close() - // A clean stop (Stop button, Ctrl+C, or a budget cap #322) is not a failure: report it and + // A clean stop (the Stop button, Ctrl+C, or an answer marked stop) is not a failure: report it and // exit 0. The dashboard that spawned this session shows the stopped state from its event log. if (ctx.isStopped()) { io.out('\n■ Stopped.') @@ -580,7 +580,7 @@ export interface AgentJournal { sawReadyForMerge: () => boolean /** The pull request the agent asked for via an `open-pr` block (#1567/#1618), if any. */ pullRequest: () => ParsedPullRequest | undefined - /** The agent stopped cleanly (user interrupt / budget cap #322) rather than failed. */ + /** The agent stopped cleanly (a user interrupt, or an answer marked stop) rather than failed. */ stoppedCleanly: () => boolean /** Hold the browser preview's port until the session opens (#829/#813). */ announceBrowserPort: (port: number) => void @@ -606,8 +606,8 @@ export function createAgentJournal(deps: { }): AgentJournal { const { io, cwd, store, agentId } = deps // The framework's own verdict that the agent stopped cleanly rather than failed — set by a - // user interrupt or a budget cap (#322). Trusted over which signal aborted, since a budget - // stop trips an internal signal the CLI never sees. + // user interrupt, or by an answer the user marked stop. Trusted over which signal aborted, + // since an answer's stop trips an internal signal the CLI never sees. let stoppedCleanly = false let sawReadyForMerge = false // The branch as last read off the checkout (#1277): what the `branch` events said, so the @@ -1009,9 +1009,6 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise { if (!journal.sawReadyForMerge()) return skip('not-ready-for-merge') if (journal.stoppedCleanly()) return skip('run-stopped') if (fake) return skip('fake-run') - // --eco-auto-maintenance (#314) no longer skips the whole agent: since #537 this prompt - // also carries `## Business knowledge`, which the flag does not name. It drops just - // `## Maintenance` inside renderOnBeforeMergeablePrompt() instead. // Every line of the prompt names the session, so there is nothing to queue without one. // An agent that made changes has one; this is the agent that ignored the instruction. const sessionName = journal.sessionName() @@ -1413,7 +1410,7 @@ export function promptAgentSpec(prompt: string, cwd: string, vanilla = false): A /** * Run one direct prompt by spawning `framework --agent `, reusing the whole agent path - * (preflight, driver, budget cap, session archive). The child inherits stdio so its agent streams to the + * (preflight, driver, session archive). The child inherits stdio so its agent streams to the * terminal. Note the spec carries no `onBeforeMergeable`, so a quality pass never triggers its own * on-before-mergeable prompt (the recursion guard). Resolves true on a clean exit (0). Never * re-execs a test entry (fork-bomb guard). diff --git a/packages/framework/src/control.test.ts b/packages/framework/src/control.test.ts index e15f02705..a56196519 100644 --- a/packages/framework/src/control.test.ts +++ b/packages/framework/src/control.test.ts @@ -33,12 +33,12 @@ test('appendControl + watchControl deliver entries in order', async () => { try { await appendControl(cwd, { kind: 'stop' }) await appendControl(cwd, { kind: 'choice', id: 'plan-approval', pick: 'proceed', by: 'user' }) - await appendControl(cwd, { kind: 'choice', id: 'await-multiselect', pick: ['opt:0', 'opt:2'], by: 'autopilot' }) + await appendControl(cwd, { kind: 'choice', id: 'await-multiselect', pick: ['opt:0', 'opt:2'], by: 'auto' }) assert.ok(await until(() => seen.length === 3), `saw ${seen.length} of 3 entries`) assert.deepEqual(seen[0], { kind: 'stop' }) assert.deepEqual(seen[1], { kind: 'choice', id: 'plan-approval', pick: 'proceed', by: 'user' }) - assert.deepEqual(seen[2], { kind: 'choice', id: 'await-multiselect', pick: ['opt:0', 'opt:2'], by: 'autopilot' }) + assert.deepEqual(seen[2], { kind: 'choice', id: 'await-multiselect', pick: ['opt:0', 'opt:2'], by: 'auto' }) } finally { watcher.close() await rm(cwd, { recursive: true, force: true }) diff --git a/packages/framework/src/dashboard-rpc/control.LOGIC.md b/packages/framework/src/dashboard-rpc/control.LOGIC.md index d49a1d711..ad86e0b48 100644 --- a/packages/framework/src/dashboard-rpc/control.LOGIC.md +++ b/packages/framework/src/dashboard-rpc/control.LOGIC.md @@ -79,7 +79,7 @@ See `## Context`. #### Business logic - **Stop**: a stop entry, with nothing to validate. The agent's process aborts what it is doing. -- **A pick** [17]: the gate's [2] id, the pick (one option id for a single choice, or the chosen subset for a multiple choice, which may be empty), and who picked. Who picked is the user unless the caller says otherwise; the record can also say the pick was made by the dashboard's autopilot countdown or automatically, for an agent nobody is watching. +- **A pick** [17]: the gate's [2] id, the pick (one option id for a single choice, or the chosen subset for a multiple choice, which may be empty), and who picked. Who picked is the user unless the caller says otherwise; the record can also say the pick was made automatically, for an agent nobody is watching. - **A message** [3]: the text is trimmed, and an empty or whitespace-only message is dropped without writing anything. The agent drains messages between turns [15], each one continuing the same driver session [18]. - **A handoff change** [4]: the level must be one of the four rungs, `local`, `push`, `pr` or `merge`; anything else is ignored and nothing is written. One rung travels, never a set of stages: a surface offering the stages as separate boxes resolves them to a rung on its own side, where an impossible combination (a pull request without a push) settles down to the rung actually asked for instead of being repaired upward into a push nobody ticked. The change is steering rather than a setting because it is about this one agent, and the agent echoes what it applied back as an event, so surfaces read the agent's own record rather than local state a reload would lose. diff --git a/packages/framework/src/dashboard/projects.ts b/packages/framework/src/dashboard/projects.ts index 5188c8929..29b4d72ac 100644 --- a/packages/framework/src/dashboard/projects.ts +++ b/packages/framework/src/dashboard/projects.ts @@ -9,7 +9,8 @@ import type { ProjectError } from '../project-errors.js' /** * The multi-project read side (#392): projects the daemon serves come from the registry (#390), * and every read resolves a project id to that project's path before running the per-cwd reader - * underneath. One daemon serves them all; it runs one agent at a time per project (#393). + * underneath. One daemon serves them all, running as many agents per project as their own + * concurrency allows (#736), each in its own checkout. */ /** One project's summary for the Projects sidebar (#314). */ diff --git a/packages/framework/src/dashboard/queue.ts b/packages/framework/src/dashboard/queue.ts index 43bb211d3..369b00b9c 100644 --- a/packages/framework/src/dashboard/queue.ts +++ b/packages/framework/src/dashboard/queue.ts @@ -24,9 +24,10 @@ export interface ProjectQueue { items: QueueItem[] } -// A markdown list item (`-`, `*`, or `1.`), any leading indent. Same rule as the sweep's -// `parseTodoEntries` (todo-loop.ts), deliberately: the queue's readers must agree on what an -// entry is, or the card says "Nothing queued" while the sweep drains the same file (#1296). +// A markdown list item (`-`, `*`, or `1.`), any leading indent. Deliberately the same rule the +// drain reads the queue by — the `queue` skill's own parser, which `todo-loop.ts` calls — because +// the queue's readers must agree on what an entry is, or the card says "Nothing queued" while the +// sweep drains the same file (#1296). const LIST_ITEM = /^\s*(?:[-*]|\d+\.)\s+(.*\S)\s*$/ // A GitHub-style task checkbox at the start of an item's text: `[ ]` open, `[x]` done. const CHECKBOX = /^\[([ xX])\]\s*(.*)$/ diff --git a/packages/framework/src/dashboard/quota.ts b/packages/framework/src/dashboard/quota.ts index ba8561489..e95dab796 100644 --- a/packages/framework/src/dashboard/quota.ts +++ b/packages/framework/src/dashboard/quota.ts @@ -97,8 +97,9 @@ export function pollerQuotaSource( * not just during an agent, because the panel has to show where the account stands * even when nothing is running. * - * Separate from the per-agent guard on purpose — that one exists to pause an agent - * and dies with it, this one exists to draw a bar. + * Separate from the reading an agent takes for itself: that one dies with the agent, and + * nothing stops a running agent over quota anyway. This one exists to draw a bar, and to + * answer whether unattended work may start. */ export function defaultQuotaSource(env: NodeJS.ProcessEnv = process.env): QuotaSource { const driver = new ClaudeCodeDriver() diff --git a/packages/framework/src/events.LOGIC.md b/packages/framework/src/events.LOGIC.md index 01d658a0d..668354b97 100644 --- a/packages/framework/src/events.LOGIC.md +++ b/packages/framework/src/events.LOGIC.md @@ -26,26 +26,25 @@ Fixes the vocabulary of the event stream [1]: every kind of event an agent [2] c [14] turn: one prompt sent to the driver; the coding agent's own loop runs to completion and answers with a final message. [15] driver session: the coding agent's own conversation for one agent, which the driver can resume by its session id. [16] sweep: a background job the daemon runs on its clock: Auto PM, the CI watch, the notification watchers, the sweep that reclaims checkouts, the branch-links sweep, the cloud scratch sweep, cloud work adoption. -[17] autopilot: the dashboard's "Autopilot" option: while it is on, a gate's recommended option is accepted after a countdown unless the user picks first. -[18] unattended: said of an agent nobody is watching: its gates take the recommended option and it ends when its work settles. The opposite is attended. -[19] ready for merge: the signal an agent emits when it believes its work is complete: it flips the agent's badge from building to ready and authorizes the handoff. -[20] the `agent-data` branch: the branch of a project's repository used as a file store for everything agents share: tickets, the agent queue, the runs, routine locks. -[21] drain: starting an agent on the agent queue's first open entry — the half of Auto PM that spends existing work. -[22] session name: the name an agent gives its own work (`[a-z0-9-]+`); its branch is renamed to `agent-` and the dashboard labels the agent by it. -[23] location: where an agent's turns run: `local` (this machine), `actions` (a GitHub Actions runner), or `web` (a Claude Code cloud session). -[24] cloud anchor: an empty commit a web agent pushes before its task leaves this machine, unique to the agent: the branch the cloud session later pushes descends from it, which is how the daemon recognises that branch as the agent's (cloud work adoption). -[25] cloud session: a Claude Code cloud session on claude.ai, the far end of a `web` agent. -[26] stop: ending an agent before it finishes: the Stop button, Ctrl-C, or a pick marked to stop. -[27] CI watch: the sweep that merges the pull requests The Framework opened once their checks pass, and starts a fix agent when a check goes red. -[28] the agent queue: `TODO_AGENTS.md` on the `agent-data` branch: every task agents will work next, in priority sections, worked top-down. An item on it is a queue entry. -[29] settled: said of an agent whose work has stopped and which is waiting for the user: it is alive, takes messages, and does nothing until told. +[17] unattended: said of an agent nobody is watching: its gates take the recommended option and it ends when its work settles. The opposite is attended. +[18] ready for merge: the signal an agent emits when it believes its work is complete: it flips the agent's badge from building to ready and authorizes the handoff. +[19] the `agent-data` branch: the branch of a project's repository used as a file store for everything agents share: tickets, the agent queue, the runs, routine locks. +[20] drain: starting an agent on the agent queue's first open entry — the half of Auto PM that spends existing work. +[21] session name: the name an agent gives its own work (`[a-z0-9-]+`); its branch is renamed to `agent-` and the dashboard labels the agent by it. +[22] location: where an agent's turns run: `local` (this machine), `actions` (a GitHub Actions runner), or `web` (a Claude Code cloud session). +[23] cloud anchor: an empty commit a web agent pushes before its task leaves this machine, unique to the agent: the branch the cloud session later pushes descends from it, which is how the daemon recognises that branch as the agent's (cloud work adoption). +[24] cloud session: a Claude Code cloud session on claude.ai, the far end of a `web` agent. +[25] stop: ending an agent before it finishes: the Stop button, Ctrl-C, or a pick marked to stop. +[26] CI watch: the sweep that merges the pull requests The Framework opened once their checks pass, and starts a fix agent when a check goes red. +[27] the agent queue: `TODO_AGENTS.md` on the `agent-data` branch: every task agents will work next, in priority sections, worked top-down. An item on it is a queue entry. +[28] settled: said of an agent whose work has stopped and which is waiting for the user: it is alive, takes messages, and does nothing until told. ## Business logic — TL;DR - **The opening events** - the session opening, the session id once known, the full system channel and the intent say what the agent is, what it was told and what it was asked; a continuation opens with its own session opening, so readers keep the latest. - **The coding agent's progress, forwarded** - every progress event the coding agent reports is forwarded verbatim onto the stream and never decided on. - **What the agent shows the user** - a view updates in place by title, a reported error stays in the log as history, a log line narrates, and the agent's browser travels as a page URL and a stream port only, never as frames. -- **A gate and its pick** - a gate is a question, at least one option and, for a single-select gate, a recommended option; a checklist pre-checks options instead; the pick is one option id or the chosen subset, and says whether the user, the autopilot countdown or nobody picked. +- **A gate and its pick** - a gate is a question, at least one option and, for a single-select gate, a recommended option; a checklist pre-checks options instead; the pick is one option id or the chosen subset, and says whether the user or nobody picked. - **Ready for merge and the pull request text** - the ready-for-merge signal flips the agent from building to ready without blocking it; the pull request title and description the agent wrote travel as an event the handoff uses, the latest one winning. - **Facts that must survive a reload** - what the handoff is armed to do, the ticket being implemented, the branch and session name, the pull request once opened, and the cloud anchor each travel as events because only an event reaches a tab opened later. - **The on-before-mergeable outcome** - the follow-up queued its prompts, queued them without finishing cleanly, or declined for one of five reasons; it is silent when the option was off. @@ -93,13 +92,12 @@ Every progress event the driver [13] reports is wrapped and forwarded verbatim o - A log line: one line of The Framework's own narration ("Finishing the session (await limit reached).", "Handed off: …"). - The browser's page: the URL the agent's browser is showing, emitted for the first real `http` or `https` page and again on every change of page, so the agent view can host the live preview at the point in the stream where it was used. Only the URL travels. It is emitted again after each session opening, because the dashboard shows only the events since the latest session opening; readers fold repeats of the same URL in place, like a re-shown view. - The browser stream: the agent's browser preview is up and listening on a loopback port. Only the port travels: the dashboard reaches the stream through the daemon, which proxies to that port, so the agent's own browser endpoint is never reachable from the web. Frames never enter the log, because someone will type a password into that pane. -- A preview: a generated app booted and serving at a URL, with the command that serves it, so the user can open it. The vocabulary defines it and the terminal renders it; no part of the agent's lifecycle emits it today. ### A gate and its pick #### Context -**User story**: the coding agent [7] stops to ask ("Approve this plan?"). The dashboard shows the question as a card with its options, one of them recommended, and the user picks; or the autopilot [17] countdown accepts the recommended option; or nobody is watching and the recommended option is taken. A checklist question shows checkboxes instead and the user ticks a subset. +**User story**: the coding agent [7] stops to ask ("Approve this plan?"). The dashboard shows the question as a card with its options, one of them recommended, and the user picks; or nobody is watching and the recommended option is taken. A checklist question shows checkboxes instead and the user ticks a subset. #### Business logic @@ -108,12 +106,11 @@ A gate [3], emitted when the agent [2] pauses on a question and waits for a pick - an id unique to this pending question; the pick is posted back against it; - the title: the question shown above the options; - the options, at least one, each with a stable id posted back when picked, a label, and optionally a one-line detail under the label (for instance why an alternative lost); -- for a single-select gate, the recommended option's id: pre-selected in the dashboard, accepted by the autopilot [17] countdown, and taken when nobody can answer; +- for a single-select gate, the recommended option's id: pre-selected in the dashboard, and taken when nobody can answer; - for a checklist, a flag marking it as one. A checklist has no single recommended option: each option instead says whether it starts checked, and the pick is the chosen subset of option ids, which may be empty. An option's starting state is ignored for a single-select gate; -- optionally the delay after which the autopilot countdown accepts the recommended option, 10 seconds by default; - optionally the markdown file under approval (a plan such as `PLAN_.agent.md`), which the right rail renders beside the question. -The resolution of a gate is emitted as its own event: the gate's id, what was picked (one option id, or the subset for a checklist), and who picked: the user, the autopilot countdown, or nobody, which is an unattended [18] agent taking the recommended option. A pick that arrives without saying who picked counts as the user's. +The resolution of a gate is emitted as its own event: the gate's id, what was picked (one option id, or the subset for a checklist), and who picked: the user, or nobody, which is an unattended [17] agent taking the recommended option. A pick that arrives without saying who picked counts as the user's. A pick is normalized to a list of option ids wherever a list is needed: a subset is copied as it is, a single option id becomes a one-item list, and an empty id becomes an empty list. @@ -125,7 +122,7 @@ A pick is normalized to a list of option ids wherever a list is needed: a subset #### Business logic -- Ready for merge [19]: the agent [2] signaled that it believes the work is complete and ready for human review. Non-blocking: it flips the agent's badge from building to ready, and the on-before-mergeable follow-up hangs off it. +- Ready for merge [18]: the agent [2] signaled that it believes the work is complete and ready for human review. Non-blocking: it flips the agent's badge from building to ready, and the on-before-mergeable follow-up hangs off it. - The pull request text: the title and description the agent asked for through its `open-pr` signal. This is how an agent opens a pull request through The Framework instead of running `gh pr create` itself, so the ticket's issue reference and the recording of the pull request number still apply. The title is the agent's name for the work and the description is what changed; either may be absent when the agent wrote only the other. Non-blocking; the handoff [5] uses the latest one. Both are read off a turn's [14] final message as turn signals [12]; the parsing rules are `turn-gate.ts`'s. @@ -139,10 +136,10 @@ Both are read off a turn's [14] final message as turn signals [12]; the parsing #### Business logic - What the handoff [5] is armed to do: whether a push and whether a pull request are armed, and whether a merge is. Emitted at the start and again whenever the dashboard's checkboxes change it, which is what makes the boxes survive a reload. The merge flag has no checkbox and never changes during the agent [2], so every re-emit repeats it; when it is absent it reads as off, the conservative display. It is carried so the armed line can say the most consequential half of the plan: without it, a merge-armed agent would advertise "open a draft PR" and then merge. -- The ticket the agent was started to implement, as a path `tickets/.md` on the `agent-data` branch [20]. Emitted once at the start, and only when The Framework itself chose the ticket: the drain [21] agent whose queue entry links back to the ticket it was queued from. Absent means nobody knows what the agent is implementing, which is the case for every hand-written prompt. -- The branch the agent's work is on, observed off the checkout [9] rather than guessed: emitted at the start with the branch the agent actually begins on, and again whenever a later read finds it changed, since the agent renames its own branch through `branches name`. When the branch carries a session name [22], the event carries it too. The session name is read off the branch by the agent's process, the one writer that knows which branch the checkout was created on; a reader of the stream alone cannot tell that birth branch from a named one. Every surface resolves the branch and the session name from this event first. +- The ticket the agent was started to implement, as a path `tickets/.md` on the `agent-data` branch [19]. Emitted once at the start, and only when The Framework itself chose the ticket: the drain [20] agent whose queue entry links back to the ticket it was queued from. Absent means nobody knows what the agent is implementing, which is the case for every hand-written prompt. +- The branch the agent's work is on, observed off the checkout [9] rather than guessed: emitted at the start with the branch the agent actually begins on, and again whenever a later read finds it changed, since the agent renames its own branch through `branches name`. When the branch carries a session name [21], the event carries it too. The session name is read off the branch by the agent's process, the one writer that knows which branch the checkout was created on; a reader of the stream alone cannot tell that birth branch from a named one. Every surface resolves the branch and the session name from this event first. - The pull request the agent's work is on, its number and URL, the moment one is opened for it, so that no surface has to guess the pull request from the branch afterwards. -- The cloud anchor [24]: the empty commit an agent whose location [23] is `web` pushed before its task left this machine, unique to the agent. The branch the cloud session [25] later works on is a `claude/*` name of the cloud's own choosing, never the agent's designated branch, and is recognized as the agent's by descending from this commit; the daemon's cloud work adoption matches the anchor against the remote's `claude/*` heads once the cloud session has pushed. +- The cloud anchor [23]: the empty commit an agent whose location [22] is `web` pushed before its task left this machine, unique to the agent. The branch the cloud session [24] later works on is a `claude/*` name of the cloud's own choosing, never the agent's designated branch, and is recognized as the agent's by descending from this commit; the daemon's cloud work adoption matches the anchor against the remote's `claude/*` heads once the cloud session has pushed. ### The on-before-mergeable outcome @@ -156,7 +153,7 @@ Emitted only when the option was on, so an agent [2] that never asked for the st - queued: the follow-up queued the quality prompts; - incomplete: it queued them but did not finish cleanly; -- skipped, with the reason: the agent never signaled ready for merge [19], so there is nothing to follow up; the agent was stopped [26] rather than finished; the driver [13] is the fake driver, so there is no coding agent [7] to hand the follow-up to; the agent never named its work, so its branch is still the birth branch while every line of the follow-up prompt names the session name [22]; or The Framework cannot find its own program to start the follow-up with. +- skipped, with the reason: the agent never signaled ready for merge [18], so there is nothing to follow up; the agent was stopped [25] rather than finished; the driver [13] is the fake driver, so there is no coding agent [7] to hand the follow-up to; the agent never named its work, so its branch is still the birth branch while every line of the follow-up prompt names the session name [21]; or The Framework cannot find its own program to start the follow-up with. The step itself is `on-before-mergeable-prompt.ts`'s. @@ -171,7 +168,7 @@ The step itself is `on-before-mergeable-prompt.ts`'s. What the handoff [5] actually did, as one of: - done: whether the branch was pushed, the pull request's URL and number when one was opened, and how the merge went when a merge was armed (next section); -- skipped, with the reason and optionally how the merge went. The reasons: the handoff is not armed, since neither a push nor a pull request was asked for (the `local` rung); the branch no longer exists (deleted, or never created); the agent [2] committed nothing the base branch does not already have; the repository has no remote to push to; the branch already has a pull request, and opening a second one is the one mistake this must not make; the branch's pull request is merged or closed and its head is still the branch tip, so everything the agent did already reached a human and there is nothing left to publish (only that exact case: an agent that kept committing after its pull request merged gets a fresh pull request instead); the branch is already on the remote at this commit and only a push was asked for; the agent was stopped [26] rather than finished; or the driver [13] is the fake driver, so there is nothing real to publish; +- skipped, with the reason and optionally how the merge went. The reasons: the handoff is not armed, since neither a push nor a pull request was asked for (the `local` rung); the branch no longer exists (deleted, or never created); the agent [2] committed nothing the base branch does not already have; the repository has no remote to push to; the branch already has a pull request, and opening a second one is the one mistake this must not make; the branch's pull request is merged or closed and its head is still the branch tip, so everything the agent did already reached a human and there is nothing left to publish (only that exact case: an agent that kept committing after its pull request merged gets a fresh pull request instead); the branch is already on the remote at this commit and only a push was asked for; the agent was stopped [25] rather than finished; or the driver [13] is the fake driver, so there is nothing real to publish; - failed, at the push step or at the pull request step, with the error. The handoff itself is `cli.ts`'s. @@ -188,9 +185,9 @@ When the agent [2] was armed for the `merge` rung of the handoff [5], the merge - auto-armed, the preferred outcome: GitHub's own auto-merge takes the pull request, so it lands when its checks pass rather than before them; - merged: the fallback where the repository does not allow auto-merge, and the pull request was merged directly; -- watched: GitHub cannot arm the merge and the pull request's checks have not passed yet, so the CI watch [27] takes the pull request and merges it once its checks go green, because merging directly there would land before CI; +- watched: GitHub cannot arm the merge and the pull request's checks have not passed yet, so the CI watch [26] takes the pull request and merges it once its checks go green, because merging directly there would land before CI; - failed, with the error: never a failed handoff, since the pull request exists either way and a human can still merge it by hand; -- withheld: the merge never ran, because it was armed but not authorized, and the pull request opened as a draft for a human instead. The two reasons: the agent never signaled ready for merge [19], so the work was never declared done; or the agent's own to-do list, `TODO_.agent.md`, still has open entries. The agent queue [28] never withholds a merge: it is decoupled from any one agent. +- withheld: the merge never ran, because it was armed but not authorized, and the pull request opened as a draft for a human instead. The two reasons: the agent never signaled ready for merge [18], so the work was never declared done; or the agent's own to-do list, `TODO_.agent.md`, still has open entries. The agent queue [27] never withholds a merge: it is decoupled from any one agent. ### Settled, spend and the end @@ -200,6 +197,6 @@ When the agent [2] was armed for the `merge` rung of the handoff [5], the merge #### Business logic -- Settled [29]: the work has settled and the agent [2] is parked on the user. Its process is still alive and takes messages, but it does nothing until told. Emitted each time the agent parks and undone by the coding agent's [7] next turn [14] start, so "working or waiting for me" is answerable from the stream rather than from a status that only changes when the agent ends. +- Settled [28]: the work has settled and the agent [2] is parked on the user. Its process is still alive and takes messages, but it does nothing until told. Emitted each time the agent parks and undone by the coding agent's [7] next turn [14] start, so "working or waiting for me" is answerable from the stream rather than from a status that only changes when the agent ends. - Usage: the agent's cumulative token counts (input, output, cache reads, cache creation), its turn count and, when priced, its cost in USD, emitted after each turn that reports usage; the dashboard renders it as a live spend readout. The cost is absent when the coding agent reports tokens but no price. Nothing stops an agent for its cost: there is no per-agent cost cap. -- The end: the agent finished. It says whether the agent finished well; when it did not, whether it was stopped [26] by the user rather than failing, so a surface shows "stopped" rather than "failed"; and an optional detail. +- The end: the agent finished. It says whether the agent finished well; when it did not, whether it was stopped [25] by the user rather than failing, so a surface shows "stopped" rather than "failed"; and an optional detail. diff --git a/packages/framework/src/events.test.LOGIC.md b/packages/framework/src/events.test.LOGIC.md index aa842ae43..cee672b71 100644 --- a/packages/framework/src/events.test.LOGIC.md +++ b/packages/framework/src/events.test.LOGIC.md @@ -8,7 +8,6 @@ What the tests cover, across the pick normalization in `events.ts`, the session - **The session id line** - "session abc123", with " — " appended once a session link is known. - **The system prompt line** - only the length is shown: "system prompt sent (5 chars)". - **The forwarded prompt line** - the coding agent's turn start shows the prompt text itself ("› prompt: Build this app end to end"), not just "prompt sent"; a long prompt is cut to well under 160 characters and ends in an ellipsis. -- **The preview line** - "▶ your app is running at ". - **The end line** - "✓ finished", "■ stopped", or "✗ failed: ", so a stop never reads as a failure. - **The usage line** - "spend: $0.0400 over 2 turns", with "turn" in the singular for one; when the coding agent reported tokens but no price the line reads "tokens: 12,224 (6 out) over 1 turn — no price reported" and carries no dollar sign, because a "$0.0000" would read as free rather than unknown; the token total is the input, cache-read and output tokens together. - **The quota line** - "· quota allowed (five_hour)", "! quota running low (five_hour)" and "✗ quota exhausted (five_hour)" by the reported status, each followed by the reset time as an ISO timestamp; a status never seen before still renders as "quota " rather than vanishing or crashing. diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts index f3fc3cd8e..cd64dde0f 100644 --- a/packages/framework/src/events.test.ts +++ b/packages/framework/src/events.test.ts @@ -114,13 +114,6 @@ test('formatFrameworkEvent shows a preview of the driver prompt, not just "promp assert.ok(line.length < 160 && line.endsWith('…')) }) -test('formatFrameworkEvent renders a preview line', () => { - assert.equal( - formatFrameworkEvent({ kind: 'preview', url: 'http://localhost:3000', command: 'npm run dev' }), - '▶ your app is running at http://localhost:3000', - ) -}) - test('formatFrameworkEvent distinguishes finished / stopped / failed (#218)', () => { assert.equal(formatFrameworkEvent({ kind: 'end', ok: true }), '✓ finished') assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, stopped: true }), '■ stopped') diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index a55e06153..1858f556c 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -15,7 +15,7 @@ export interface ChoiceOption { /** * An interactive choice the agent pauses on until a pick arrives (#304). Emitted as * a `choice` {@link FrameworkEvent}; the dashboard renders it in a panel and posts - * the pick back. The recommended option is the default the autopilot auto-accepts. + * the pick back. The recommended option is what an agent nobody is watching takes. */ export interface ChoiceRequest { /** Unique id for this pending choice; the pick is posted back against it. */ @@ -25,7 +25,7 @@ export interface ChoiceRequest { /** The options to choose between (at least one). */ options: readonly ChoiceOption[] /** - * The option id pre-selected as the default (autopilot auto-accepts it). Required + * The option id pre-selected as the default (taken when nobody is watching). Required * for a single-select; omitted for a {@link multi} select, where each option's own * {@link ChoiceOption.default} drives the pre-checked set instead. */ @@ -36,8 +36,6 @@ export interface ChoiceRequest { * *subset* of ids rather than one. Absent = the single-select gate (#304). */ multi?: boolean - /** Auto-accept the recommended option after this many ms when autopilot is on. Default 10000. */ - autoAcceptMs?: number /** The markdown file under approval (e.g. `PLAN_.agent.md`); the doc sidebar renders it. */ file?: string } @@ -49,7 +47,7 @@ export interface ChoiceRequest { export type OnBeforeMergeableSkip = /** The agent never signalled `setReadyForMerge()`, so there is nothing to clean up after. */ | 'not-ready-for-merge' - /** The agent was stopped (Stop button, Ctrl+C, budget cap) rather than finished. */ + /** The agent was stopped (the Stop button, Ctrl+C, an answer marked stop) rather than finished. */ | 'run-stopped' /** A fake/offline run: no agent to hand the follow-up prompt to. */ | 'fake-run' @@ -84,7 +82,7 @@ export type AutoHandoffSkip = | 'already-landed' /** The branch is already on the remote at this commit, and only the push was asked for. */ | 'already-pushed' - /** The agent was stopped (Stop button, Ctrl+C, budget cap) rather than finished. */ + /** The agent was stopped (the Stop button, Ctrl+C, an answer marked stop) rather than finished. */ | 'run-stopped' /** A fake/offline run: nothing real to publish. */ | 'fake-run' @@ -119,8 +117,8 @@ export type AutoMergeOutcome = | { outcome: 'withheld'; reason: MergeWithheldReason } | { outcome: 'failed'; error: string } -/** Who resolved a {@link ChoiceRequest}: a human, the autopilot countdown, or a headless auto-accept. */ -export type ChoiceBy = 'user' | 'autopilot' | 'auto' +/** Who resolved a {@link ChoiceRequest}: a human, or a headless auto-accept. */ +export type ChoiceBy = 'user' | 'auto' /** What a {@link import('./agent.js').RunFrameworkOptions.requestChoice} handler resolves with. */ export interface ChoicePick { @@ -170,12 +168,6 @@ export type FrameworkEvent = | { kind: 'intent'; text: string } /** The wrapped agent's own progress, forwarded verbatim (never gated on). */ | { kind: 'driver'; event: DriverEvent } - /** - * The generated app is booted and serving. Emitted after a successful agent when - * a serve config is set: the app is kept running so the user can open it, and - * the dashboard shows a live preview link (torn down on Ctrl+C). - */ - | { kind: 'preview'; url: string; command: string } /** * The agent's browser preview is up and listening on this loopback port (#813). * @@ -319,11 +311,11 @@ export type FrameworkEvent = | { kind: 'settled' } /** * Cumulative token + cost usage for the agent so far (#322). Emitted after each - * agent turn that reports usage; the dashboard renders a live spend readout and - * the agent stops itself once `costUsd` reaches the budget cap, if one is set. + * agent turn that reports usage, for the dashboard's live spend readout. Nothing + * gates on it: an agent already running is never cut short over spending, and the + * account's quota decides what may *start* instead. * - * `costUsd` is absent when the agent reports tokens but no price (#540), which - * is also when no budget cap can fire. + * `costUsd` is absent when the agent reports tokens but no price (#540). */ | { kind: 'usage' diff --git a/packages/framework/src/maintenance.ts b/packages/framework/src/maintenance.ts index 085919918..317dd0476 100644 --- a/packages/framework/src/maintenance.ts +++ b/packages/framework/src/maintenance.ts @@ -7,8 +7,8 @@ import { nodeFs } from './node-fs.js' * finds the commits each repo has grown since its last maintenance review, and runs * the maintainability loop on them. Per-repo review state is a small local file * (`.the-framework/maintenance.json`, gitignored) recording the last-reviewed commit, - * so a sweep only ever acts on new work. The capacity gate is the existing budget cap - * (`--max-cost`). #298's "check the limit" half is reachable after all — the agent + * so a sweep only ever acts on new work. The capacity gate is the quota boundary, which + * decides whether unattended work may start. #298's "check the limit" half is reachable — the agent * reports the account's quota per turn (#517) and on demand (#521) — but this sweep * does not gate on it yet; that is #519's consumption limits. */ diff --git a/packages/framework/src/on-before-mergeable-prompt.test.ts b/packages/framework/src/on-before-mergeable-prompt.test.ts index 654469e29..95f4b10bd 100644 --- a/packages/framework/src/on-before-mergeable-prompt.test.ts +++ b/packages/framework/src/on-before-mergeable-prompt.test.ts @@ -44,9 +44,9 @@ test('renderOnBeforeMergeablePrompt names the session on every entry', () => { assert.match(prompt, /Apply \.the-framework\/presets\/security_audit\.md with tf\.params\.what set to "changes introduced by add-oauth"/) }) -test('renderOnBeforeMergeablePrompt defaults absent settings to off rather than throwing (#556)', () => { - // The template reads `tf.settings.technical_control`, so an absent `settings` would throw - // on the property access rather than read as off. +test('renderOnBeforeMergeablePrompt renders the same prompt every time (#556)', () => { + // Nothing but the session name and the preset paths reaches the template, so two renders of + // the same session are the same prompt — and the presets it queues are the two it names. const prompt = renderOnBeforeMergeablePrompt({ session_name: 'add-oauth' }) assert.doesNotMatch(prompt, /readability/) assert.equal(prompt, renderOnBeforeMergeablePrompt({ session_name: 'add-oauth' })) diff --git a/packages/framework/src/on-before-mergeable-prompt.ts b/packages/framework/src/on-before-mergeable-prompt.ts index 3b8ddfe6c..72518a078 100644 --- a/packages/framework/src/on-before-mergeable-prompt.ts +++ b/packages/framework/src/on-before-mergeable-prompt.ts @@ -11,11 +11,8 @@ import { presetContext } from './presets.js' * #556 — the previous suite executed maintainability, readability and security-audit as * three child runs on the spot, which does not compose with the queue. * - * Flattened rather than verbatim, which is the one place this departs from the doc: the - * doc nests `${{ tf.session_name }}` inside the outer `${{ ... }}` and puts backticks - * inside a backtick template literal. {@link renderTemplate}'s fragment regex is - * non-greedy, so the outer fragment closes on the inner `}}` and the remainder is not - * valid JS. Same branch, same output, one fragment. + * The markdown is the prompt, verbatim: it is compiled into the package at build time and + * used as it is written, so what a reviewer reads in `prompts/` is what an agent is sent. * * Two sections: `## Maintenance` queues the quality presets, and `## Business knowledge` * (#537) asks the agent to fold what it learned back into {@link BUSINESS_KNOWLEDGE_DOCS}. diff --git a/packages/framework/src/preset-catalog.ts b/packages/framework/src/preset-catalog.ts index 5f0fe7185..973d0bb31 100644 --- a/packages/framework/src/preset-catalog.ts +++ b/packages/framework/src/preset-catalog.ts @@ -128,9 +128,10 @@ export const presets = { * whether the work is cheap. Keeping them apart lets the rotation queue the cheap batch and the * significant batch on separate turns rather than in one indiscriminate sweep. * - * Each prompt pins its own `` and aborts when `agent-` - * already exists. That collision guard is what makes them safe to fire on a schedule: a triage - * still in flight owns the branch, so the next firing does nothing instead of triaging twice. + * Each prompt pins its own ``, so a triage always lands on the same branch and is + * recognizable there. What makes them safe to fire on a schedule is the routine lock the daemon + * takes before starting one (`routine-locks.ts`), which holds across machines: a triage still in + * flight owns the lock, so the next firing stands down instead of triaging twice. * * Both end with the same rule (#1641): a triage only writes `TODO_AGENTS.md`, never a ticket's * code. It is one file, `prompts/triage_scope.md`, appended here rather than pasted into each diff --git a/packages/framework/src/terminal.LOGIC.md b/packages/framework/src/terminal.LOGIC.md index 16013e11d..7f3359d99 100644 --- a/packages/framework/src/terminal.LOGIC.md +++ b/packages/framework/src/terminal.LOGIC.md @@ -27,7 +27,7 @@ Renders an agent's [1] event stream [2] in a terminal, one human-readable line p ## Business logic — TL;DR -- **The agent's setup** - the driver and model in the checkout with the session link, the prompt, the branch, the ticket, the cloud anchor, the preview and browser addresses, the pull request number. +- **The agent's setup** - the driver and model in the checkout with the session link, the prompt, the branch, the ticket, the cloud anchor, the browser addresses, the pull request number. - **What the agent signals** - its log lines, errors with their detail indented, views by title, "✓ ready for merge", the pull request it wrote, and "done for now" when it is settled. - **Gates and picks** - the question with one option per line, the recommended one marked, and the pick with who made it. - **The handoff, announced then reported** - one line saying what will happen when the agent ends, then what happened to the push and the pull request, and always a line for the merge. diff --git a/packages/framework/src/terminal.ts b/packages/framework/src/terminal.ts index f67362d52..aa4307780 100644 --- a/packages/framework/src/terminal.ts +++ b/packages/framework/src/terminal.ts @@ -17,8 +17,6 @@ export function formatFrameworkEvent(event: FrameworkEvent): string { return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}` case 'system-prompt': return ` system prompt sent (${event.text.length} chars)` - case 'preview': - return `▶ your app is running at ${event.url}` case 'browser-stream': return `◆ browser preview: http://127.0.0.1:${event.port}/stream` case 'browser': diff --git a/packages/framework/src/todo-loop.LOGIC.md b/packages/framework/src/todo-loop.LOGIC.md index a00b59a66..c6235cf70 100644 --- a/packages/framework/src/todo-loop.LOGIC.md +++ b/packages/framework/src/todo-loop.LOGIC.md @@ -2,9 +2,9 @@ Works the agent queue [1] once a build agent's [2] opening work settles, as the ## Context -**User story**: the user starts a build with a very large scope, or a research preset that queues deep dives; the agent [6] adds follow-ups to the agent queue [1] as it goes, and once its opening work settles it works them one at a time. With the dashboard watching, a card "Start the next queue item?" precedes each entry, and the dashboard's autopilot [9] accepts it after a countdown, so a switched-on autopilot consumes the whole queue; unattended [10], nothing is asked and the agent ends when the queue is empty. +**User story**: the user starts a build with a very large scope, or a research preset that queues deep dives; the agent [6] adds follow-ups to the agent queue [1] as it goes, and once its opening work settles it works them one at a time. With the dashboard watching, a card "Start the next queue item?" precedes each entry, and the dashboard's autopilot [9] accepts it after a countdown, so a switched-on autopilot consumes the whole queue; unattended [9], nothing is asked and the agent ends when the queue is empty. -**Problem**: the queue lives on the `agent-data` branch [5], which the agent's checkout [11] does not hold, and every edit of that branch goes through the `queue` skill's [12] one writer; so The Framework, not the agent, reads the queue and takes entries off it, and the agent is only ever told the one entry to complete. Left unattended, the loop must stay bounded: a stop [13] ends any turn [14], the entry cap bounds the agent, and a removal that never lands must not re-serve the same entry. +**Problem**: the queue lives on the `agent-data` branch [5], which the agent's checkout [10] does not hold, and every edit of that branch goes through the `queue` skill's [11] one writer; so The Framework, not the agent, reads the queue and takes entries off it, and the agent is only ever told the one entry to complete. Left unattended, the loop must stay bounded: a stop [12] ends any turn [13], the entry cap bounds the agent, and a removal that never lands must not re-serve the same entry. ## Glossary @@ -16,24 +16,23 @@ Works the agent queue [1] once a build agent's [2] opening work settles, as the [6] agent: the unit of work: one task worked by a coding agent under The Framework's control — in its own checkout, on its own branch, streaming events, handed off when it ends. Started from the dashboard by the user, or by the daemon. [7] drain: starting an agent on the agent queue's first open entry — the half of Auto PM that spends existing work. [8] the Overview: the dashboard's cross-project page at `/`. -[9] autopilot: the dashboard's switch that accepts a gate's recommended option for the user after a countdown. -[10] unattended: said of an agent nobody is watching: its gates take the recommended option and it ends when its work settles. The opposite is attended. -[11] checkout: an agent's own working copy of the project: a git worktree under the project's `.branches/` directory, named as its branch. -[12] skill: one of the four capabilities an agent is taught — `branches`, `tickets`, `queue`, `logs` — each a package with the instructions the agent reads, a command on the agent's PATH, and an API the product calls. -[13] stop: ending an agent before it finishes: the Stop button, Ctrl-C, or a pick marked to stop. -[14] turn: one prompt sent to the driver; the coding agent's own loop runs to completion and answers with a final message. -[15] hands-off: said of an agent whose work leaves this machine, so its first prompt is the whole agent: an agent whose location is `web`. -[16] driver session: the coding agent's own conversation for one agent, which the driver can resume by its session id. -[17] event stream: everything an agent does, one event per line appended to `.the-framework/events.jsonl` in its checkout; every surface (dashboard, terminal, archive, run) is a projection of it. -[18] turn signals: what The Framework reads off a turn's final message: the ready-for-merge signal, the pull request title and body, markdown views, reported errors, and the gate it stops at. -[19] ready for merge: the signal an agent emits when it believes its work is complete: it flips the agent's badge from building to ready and authorizes the handoff. -[20] gate: a question with options at which an agent stops and waits for an answer: it emits the question in its turn's final message, the dashboard shows it as a card, and the answer re-prompts the agent. When nobody can answer, the recommended option is taken. -[21] pick: the answer to a gate: the option or options chosen, by the user or automatically. -[22] await limit: the cap on consecutive gates within one exchange; an agent still asking past it finishes with its latest turn. -[23] handoff: what happens to an agent's work when the agent ends, as one ladder of four levels: `local` (keep the work in its checkout), `push` (push its branch), `pr` (also open a pull request — the default), `merge` (also merge it). -[24] sweep: a background job the daemon runs on its clock: Auto PM, the CI watch, the notification watchers, the sweep that reclaims checkouts, the branch-links sweep, the cloud scratch sweep, cloud work adoption. -[25] routine: a preset the daemon fires on its own on a schedule — update tickets, triage quick, triage consensual, plan tickets, maintenance — each switchable off and runnable on demand. -[26] session name: the name an agent gives its own work (`[a-z0-9-]+`); its branch is renamed to `agent-` and the dashboard labels the agent by it. +[9] unattended: said of an agent nobody is watching: its gates take the recommended option and it ends when its work settles. The opposite is attended. +[10] checkout: an agent's own working copy of the project: a git worktree under the project's `.branches/` directory, named as its branch. +[11] skill: one of the four capabilities an agent is taught — `branches`, `tickets`, `queue`, `logs` — each a package with the instructions the agent reads, a command on the agent's PATH, and an API the product calls. +[12] stop: ending an agent before it finishes: the Stop button, Ctrl-C, or a pick marked to stop. +[13] turn: one prompt sent to the driver; the coding agent's own loop runs to completion and answers with a final message. +[14] hands-off: said of an agent whose work leaves this machine, so its first prompt is the whole agent: an agent whose location is `web`. +[15] driver session: the coding agent's own conversation for one agent, which the driver can resume by its session id. +[16] event stream: everything an agent does, one event per line appended to `.the-framework/events.jsonl` in its checkout; every surface (dashboard, terminal, archive, run) is a projection of it. +[17] turn signals: what The Framework reads off a turn's final message: the ready-for-merge signal, the pull request title and body, markdown views, reported errors, and the gate it stops at. +[18] ready for merge: the signal an agent emits when it believes its work is complete: it flips the agent's badge from building to ready and authorizes the handoff. +[19] gate: a question with options at which an agent stops and waits for an answer: it emits the question in its turn's final message, the dashboard shows it as a card, and the answer re-prompts the agent. When nobody can answer, the recommended option is taken. +[20] pick: the answer to a gate: the option or options chosen, by the user or automatically. +[21] await limit: the cap on consecutive gates within one exchange; an agent still asking past it finishes with its latest turn. +[22] handoff: what happens to an agent's work when the agent ends, as one ladder of four levels: `local` (keep the work in its checkout), `push` (push its branch), `pr` (also open a pull request — the default), `merge` (also merge it). +[23] sweep: a background job the daemon runs on its clock: Auto PM, the CI watch, the notification watchers, the sweep that reclaims checkouts, the branch-links sweep, the cloud scratch sweep, cloud work adoption. +[24] routine: a preset the daemon fires on its own on a schedule — update tickets, triage quick, triage consensual, plan tickets, maintenance — each switchable off and runnable on demand. +[25] session name: the name an agent gives its own work (`[a-z0-9-]+`); its branch is renamed to `agent-` and the dashboard labels the agent by it. ## Business logic — TL;DR @@ -54,11 +53,11 @@ Works the agent queue [1] once a build agent's [2] opening work settles, as the #### Context -**Business logic story**: the agent lifecycle in `agent.ts` starts the loop once the opening exchange has settled, for a build agent [2] that is not hands-off [15]: a prompt agent stops at its one prompt, and a hands-off agent's work is not on this machine. A build's live chat comes after the loop. +**Business logic story**: the agent lifecycle in `agent.ts` starts the loop once the opening exchange has settled, for a build agent [2] that is not hands-off [14]: a prompt agent stops at its one prompt, and a hands-off agent's work is not on this machine. A build's live chat comes after the loop. #### Business logic -The loop drives the agent's [6] existing driver session [16] in the agent's checkout [11] and writes what it does to the agent's event stream [17]. It reads each turn's [14] turn signals [18] with one reader kept for the whole loop, so ready for merge [19] fires once across every entry and an error restated on later entries is reported once (the reader's rules are `turn-gate.ts`'s). It runs with the agent's stop [13] signal, with a gate [20] answerer when a surface has one, and with the entry cap, 25 unless its caller sets another. +The loop drives the agent's [6] existing driver session [15] in the agent's checkout [10] and writes what it does to the agent's event stream [16]. It reads each turn's [13] turn signals [17] with one reader kept for the whole loop, so ready for merge [18] fires once across every entry and an error restated on later entries is reported once (the reader's rules are `turn-gate.ts`'s). It runs with the agent's stop [12] signal, with a gate [19] answerer when a surface has one, and with the entry cap, 25 unless its caller sets another. ### One entry per turn, fresh off the branch @@ -68,17 +67,17 @@ See `## Context`. #### Business logic -Each round begins by checking for a stop [13]; a stopped agent [6] ends the loop before another entry. The round then fetches the `agent-data` branch [5] and reads the queue's open entries in file order, since a long-lived agent's local view may trail what other writers pushed; by the `queue` skill's [12] rules an entry is a markdown list item, a task checkbox counting only while unchecked, so a queue written in priority sections drains in priority order. The first open entry is the one to work. An empty queue, or none written at all, ends the loop as done: when at least one entry was worked, "Queue done: empty after N item(s)." is written to the event stream [17]; a loop that finds nothing on its first round writes nothing at all. On the first round the count is announced: "Queue: N open item(s).". Wherever the loop names the entry, an entry longer than 100 characters is cut to 100 characters followed by an ellipsis. +Each round begins by checking for a stop [12]; a stopped agent [6] ends the loop before another entry. The round then fetches the `agent-data` branch [5] and reads the queue's open entries in file order, since a long-lived agent's local view may trail what other writers pushed; by the `queue` skill's [11] rules an entry is a markdown list item, a task checkbox counting only while unchecked, so a queue written in priority sections drains in priority order. The first open entry is the one to work. An empty queue, or none written at all, ends the loop as done: when at least one entry was worked, "Queue done: empty after N item(s)." is written to the event stream [16]; a loop that finds nothing on its first round writes nothing at all. On the first round the count is announced: "Queue: N open item(s).". Wherever the loop names the entry, an entry longer than 100 characters is cut to 100 characters followed by an ellipsis. ### The gate before each entry #### Context -**User story**: attended, the user decides before every entry whether the agent [6] goes on: the dashboard shows the card, the autopilot [9] accepts it after a countdown, and "Stop the queue loop" ends the loop and leaves the rest of the queue for later. +**User story**: attended, the user decides before every entry whether the agent [6] goes on: the dashboard shows the card, and "Stop the queue loop" ends the loop and leaves the rest of the queue for later. #### Business logic -Only when a surface can answer does the loop ask: a gate [20] titled "Start the next queue item? (N open)" with two options, "Work on: ", which is the recommended one, and "Stop the queue loop". Each round's gate is distinct from the one before, so a surface never confuses a new question with the answer it just gave. A pick [21] to stop writes "Queue loop stopped by you (N item(s) left)." to the event stream [17] and ends the loop with the reason "stopped"; the agent [6] itself is not ended, and the queue keeps its entries. Unattended [10], no gate is emitted and the entry is started. +Only when a surface can answer does the loop ask: a gate [19] titled "Start the next queue item? (N open)" with two options, "Work on: ", which is the recommended one, and "Stop the queue loop". Each round's gate is distinct from the one before, so a surface never confuses a new question with the answer it just gave. A pick [20] to stop writes "Queue loop stopped by you (N item(s) left)." to the event stream [16] and ends the loop with the reason "stopped"; the agent [6] itself is not ended, and the queue keeps its entries. Unattended [9], no gate is emitted and the entry is started. ### The prompt for one entry @@ -88,37 +87,37 @@ See `## Context`. #### Business logic -"Queue item K: " is written to the event stream [17], K counting the entries worked so far plus one, and the agent [6] is prompted with exactly "Work on exactly this task from the project's agent queue, and nothing else:", the entry, and "Complete it fully and verify your work. Do not start any other task; the framework takes this entry off the queue when the turn ends.". The turn [14] is a turn like any other: a gate [20] it stops at is answered and the agent resumed, up to the await limit [22] (the rounds are `await-gate.ts`'s), and its views, errors, ready-for-merge [19] signal and pull request are read. The entry counts as worked once the turn is over, whatever the turn produced. +"Queue item K: " is written to the event stream [16], K counting the entries worked so far plus one, and the agent [6] is prompted with exactly "Work on exactly this task from the project's agent queue, and nothing else:", the entry, and "Complete it fully and verify your work. Do not start any other task; the framework takes this entry off the queue when the turn ends.". The turn [13] is a turn like any other: a gate [19] it stops at is answered and the agent resumed, up to the await limit [21] (the rounds are `await-gate.ts`'s), and its views, errors, ready-for-merge [18] signal and pull request are read. The entry counts as worked once the turn is over, whatever the turn produced. ### A rejecting pick ends the whole agent #### Context -**Problem**: a plan the user declined inside an entry's turn [14] must not be followed by the handoff [23] publishing the very work that was rejected. +**Problem**: a plan the user declined inside an entry's turn [13] must not be followed by the handoff [22] publishing the very work that was rejected. #### Business logic -When a gate [20] inside the entry's turn [14] is answered with a pick [21] marked to stop, the loop writes "Session stopped by your answer (N item(s) left)." to the event stream [17] and ends with the reason "stopped" together with the mark that the whole agent [6] is stopped; the agent lifecycle then ends the agent as stopped by the user's answer, and no handoff [23] runs. This is distinct from "Stop the queue loop" at the gate before an entry, which ends only the loop. +When a gate [19] inside the entry's turn [13] is answered with a pick [20] marked to stop, the loop writes "Session stopped by your answer (N item(s) left)." to the event stream [16] and ends with the reason "stopped" together with the mark that the whole agent [6] is stopped; the agent lifecycle then ends the agent as stopped by the user's answer, and no handoff [22] runs. This is distinct from "Stop the queue loop" at the gate before an entry, which ends only the loop. ### The Framework takes the entry off the queue #### Context -**Problem**: the agent's [6] checkout [11] does not hold the `agent-data` branch [5], and every edit of the queue must go through the one writer; and re-doing finished work is worse than stopping with the queue intact. +**Problem**: the agent's [6] checkout [10] does not hold the `agent-data` branch [5], and every edit of the queue must go through the one writer; and re-doing finished work is worse than stopping with the queue intact. #### Business logic -After the turn [14], the entry is taken off the queue through the `queue` skill [12], which re-reads the fresh queue before writing and counts an entry already gone, removed by someone else meanwhile, as landed. The removal is tried up to two times in a row; when neither lands, "Queue loop stopped: "" could not be taken off the queue after 2 attempt(s)." is written to the event stream [17] and the loop ends with the reason "stalled", the queue left as it was. +After the turn [13], the entry is taken off the queue through the `queue` skill [11], which re-reads the fresh queue before writing and counts an entry already gone, removed by someone else meanwhile, as landed. The removal is tried up to two times in a row; when neither lands, "Queue loop stopped: "" could not be taken off the queue after 2 attempt(s)." is written to the event stream [16] and the loop ends with the reason "stalled", the queue left as it was. ### Bounds #### Context -**Problem**: an unattended [10] agent [6] must not work an unbounded queue on one subscription; and a stop [13] must end the loop without extra narration, since the agent is ending anyway. +**Problem**: an unattended [9] agent [6] must not work an unbounded queue on one subscription; and a stop [12] must end the loop without extra narration, since the agent is ending anyway. #### Business logic -At most 25 entries are worked in one agent [6] unless the caller sets another cap. When the cap is reached, the queue is read again from the `agent-data` branch [5] as this machine last saw it, without fetching: when it is empty the loop ends as done, otherwise "Queue loop stopped at the 25-item cap; N item(s) left." is written to the event stream [17] and the loop ends with the reason "max-items". A stop [13] that arrives mid-loop ends it with the reason "stopped" and writes nothing. The result names how many entries were worked, whatever their outcome, and why the loop ended. +At most 25 entries are worked in one agent [6] unless the caller sets another cap. When the cap is reached, the queue is read again from the `agent-data` branch [5] as this machine last saw it, without fetching: when it is empty the loop ends as done, otherwise "Queue loop stopped at the 25-item cap; N item(s) left." is written to the event stream [16] and the loop ends with the reason "max-items". A stop [12] that arrives mid-loop ends it with the reason "stopped" and writes nothing. The result names how many entries were worked, whatever their outcome, and why the loop ended. ### The next queued ticket @@ -128,13 +127,13 @@ At most 25 entries are worked in one agent [6] unless the caller sets another ca #### Business logic -The next queued ticket is the ticket the queue's first open entry links to, read from the `agent-data` branch [5] as this machine last saw it, without fetching: the same copy the sweep [24] consults when it decides whether there is anything to drain [7], so the entry named is the entry that decision was made on. "First" is the queue's first open entry, because the "Drain queue" preset works the first open entry only and the queue reads in file order. Which entry links to a ticket is the `tickets` skill's [12] rule: only a markdown link into `tickets/`. An empty queue, or a first open entry that is plain text, names no ticket, even when a later entry links to one. It is a best guess by construction: the agent [6] reads the queue a moment later, and an entry taken off in between moves it on. Being wrong costs a mislabeled lane on the Overview [8] and nothing else, since no agent is started or steered by it. +The next queued ticket is the ticket the queue's first open entry links to, read from the `agent-data` branch [5] as this machine last saw it, without fetching: the same copy the sweep [23] consults when it decides whether there is anything to drain [7], so the entry named is the entry that decision was made on. "First" is the queue's first open entry, because the "Drain queue" preset works the first open entry only and the queue reads in file order. Which entry links to a ticket is the `tickets` skill's [11] rule: only a markdown link into `tickets/`. An empty queue, or a first open entry that is plain text, names no ticket, even when a later entry links to one. It is a best guess by construction: the agent [6] reads the queue a moment later, and an entry taken off in between moves it on. Being wrong costs a mislabeled lane on the Overview [8] and nothing else, since no agent is started or steered by it. ### A hand-started drain #### Context -**Problem**: the daemon knows its own drain [7] by a mark on the routine [25], but a drain the user starts from the dashboard arrives as bare prompt text; unrecognized, the ticket it implements would sit in no lane on the Overview [8]. +**Problem**: the daemon knows its own drain [7] by a mark on the routine [24], but a drain the user starts from the dashboard arrives as bare prompt text; unrecognized, the ticket it implements would sit in no lane on the Overview [8]. #### Business logic @@ -144,8 +143,8 @@ A prompt is a drain [7] when, trimmed, it equals the rendered "Drain queue" pres #### Context -**Business logic story**: the ready-for-merge [19] signal is the agent's [6] own word that it is done and what authorizes the handoff's [23] merge. This check is a safety belt beside that word: it catches an agent declaring done while its own session backlog file still says otherwise, and nothing more. +**Business logic story**: the ready-for-merge [18] signal is the agent's [6] own word that it is done and what authorizes the handoff's [22] merge. This check is a safety belt beside that word: it catches an agent declaring done while its own session backlog file still says otherwise, and nothing more. #### Business logic -An agent's [6] own backlog is the file `TODO_.agent.md` at the root of its checkout [11], the file a research preset or a very large scope has the agent keep for its own work, where the session name [26] is the agent's. It still has open work when that file exists and holds at least one open entry by the `queue` skill's [12] entry rules; a file with every checkbox ticked has none. No session name, a name that cannot name a file (only letters, digits, dots, underscores and dashes can; a path separator above all cannot), and a missing or unreadable file all count as no open work: pendingness unknown is not pendingness. The agent queue [1] is never consulted here: it is decoupled from agents, and withholding on it would mean an armed merge never fires while the project has any backlog at all. The handoff [23] in `cli.ts` uses this to withhold an armed merge once the agent has signaled ready for merge [19]; the push and the pull request go ahead regardless. +An agent's [6] own backlog is the file `TODO_.agent.md` at the root of its checkout [10], the file a research preset or a very large scope has the agent keep for its own work, where the session name [25] is the agent's. It still has open work when that file exists and holds at least one open entry by the `queue` skill's [11] entry rules; a file with every checkbox ticked has none. No session name, a name that cannot name a file (only letters, digits, dots, underscores and dashes can; a path separator above all cannot), and a missing or unreadable file all count as no open work: pendingness unknown is not pendingness. The agent queue [1] is never consulted here: it is decoupled from agents, and withholding on it would mean an armed merge never fires while the project has any backlog at all. The handoff [22] in `cli.ts` uses this to withhold an armed merge once the agent has signaled ready for merge [18]; the push and the pull request go ahead regardless. diff --git a/packages/framework/src/todo-loop.test.ts b/packages/framework/src/todo-loop.test.ts index f487ea51b..5f46e9522 100644 --- a/packages/framework/src/todo-loop.test.ts +++ b/packages/framework/src/todo-loop.test.ts @@ -198,7 +198,7 @@ test('an aborted signal ends the loop before starting another entry', async () = } }) -test('a backlog turn emits its signals: views, errors, session name, ready-for-merge', async () => { +test('a backlog turn emits its signals: views, errors, ready-for-merge', async () => { const repo = await repoWorkspace() await seedQueue(repo, '- [ ] tidy the login redirect\n') try { diff --git a/packages/framework/src/todo-loop.ts b/packages/framework/src/todo-loop.ts index c69d33f65..28c1be167 100644 --- a/packages/framework/src/todo-loop.ts +++ b/packages/framework/src/todo-loop.ts @@ -119,13 +119,13 @@ export interface TodoLoopOptions { * autopilot off means a human gate per item (#323). Headless runs don't pause. */ requestChoice?: ((req: ChoiceRequest) => Promise) | undefined - /** The agent signal; aborting (Stop button / budget cap #322) ends the loop. */ + /** The agent signal; aborting (the Stop button, an answer marked stop) ends the loop. */ signal?: AbortSignal | undefined /** Hard cap on entries worked in one agent. Default {@link DEFAULT_MAX_TODO_ITEMS}. */ maxItems?: number | undefined } -/** The default per-agent cap on queue entries — a backstop beside the budget cap (#322). */ +/** The default per-agent cap on queue entries, so one agent cannot work the queue forever. */ const DEFAULT_MAX_TODO_ITEMS = 25 /** How many consecutive failed removals before the loop stops rather than spins. */ @@ -146,7 +146,7 @@ export async function runTodoLoop(opts: TodoLoopOptions): Promise