Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions packages/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <n> Dashboard port (default: 4200).
--host <addr> Bind address (default: 127.0.0.1). A non-loopback address
Expand All @@ -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
Expand All @@ -46,25 +46,25 @@ 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
account's own quota week.

## 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).
Expand Down
6 changes: 3 additions & 3 deletions packages/framework/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ IPC:
- **Reads** — `POST /_rpc/<name>` (`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
Expand Down Expand Up @@ -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 <path>`), never as flags.

## Tests
Expand Down
6 changes: 3 additions & 3 deletions packages/framework/dashboard/components/AddProjectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
14 changes: 7 additions & 7 deletions packages/framework/dashboard/components/AgentActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()
})
}

Expand Down Expand Up @@ -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}
/>
)}
Expand Down
8 changes: 3 additions & 5 deletions packages/framework/dashboard/components/AgentComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
46 changes: 12 additions & 34 deletions packages/framework/dashboard/components/ChoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export function ChoicePanel({
agentId: agentId,
choice,
active = false,
countdown = true,
inline = false,
onAnswered,
send,
Expand All @@ -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 —
Expand All @@ -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<unknown>) | undefined
send?: ((pick: string | string[]) => Promise<unknown>) | undefined
}) {
const { busy, error, run } = useAction()
// Posted and accepted by the daemon; the panel stays parked (buttons off, status shown)
Expand All @@ -64,27 +56,20 @@ export function ChoicePanel({
const [checked, setChecked] = useState<Set<string>>(
() => 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<number | null>(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) =>
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function ParkedQuestion({
Sending “{failure.labels.join(', ')}” failed{failure.note ? `: ${failure.note}` : ''}. Pick again, or answer in the session.
</p>
)}
<ChoicePanel projectId={projectId} agentId={agentId} choice={bridgeChoiceRequest(question)} countdown={false} inline send={bridgeSend(sessionId)} />
<ChoicePanel projectId={projectId} agentId={agentId} choice={bridgeChoiceRequest(question)} inline send={bridgeSend(sessionId)} />
<a href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 px-4 pb-2.5 text-xs text-primary hover:underline">
Answer it in the session
<ExternalLink className="h-3 w-3" aria-hidden />
Expand Down
4 changes: 2 additions & 2 deletions packages/framework/dashboard/components/EventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -48,7 +48,7 @@ See `## Context`.
The section is titled "Waiting on you · <count of open questions>". 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(<OpenQuestions onOpenAgent={vi.fn()} />)
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()
})
})
Expand Down
5 changes: 2 additions & 3 deletions packages/framework/dashboard/components/OpenQuestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }))
}
Expand Down
8 changes: 4 additions & 4 deletions packages/framework/dashboard/components/TicketDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down
Loading
Loading