Skip to content
Closed
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
2 changes: 1 addition & 1 deletion packages/the-framework/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ flowchart TD

**Spending limits.** The whole quota policy is one line: unattended work may spend up to the pro-rated share of the account's week that has elapsed, rising continuously with the clock. Nothing to configure — the week is read from the account itself. Two properties fall out: nothing is left on the floor (the boundary reaches the full allowance exactly as the week resets), and background work cannot starve the user (unattended work stands down past the boundary). A slider moves that stand-down line — but for work the user asked for, the slider only ever *loosens* the gate, and it is re-read live, so raising it unparks a waiting agent without a restart. The two gates fail in opposite directions on purpose: no readable quota means unattended work does not start, while user-requested work carries on. The gate is on *starting*, and only on starting: an agent already going is never interrupted to economise, because by then the tokens are spent, the work is half-done, and what is saved is the cheap part while what is lost is the expensive part.

**Surfaces.** The daemon serves the dashboard and answers all its reads from the files agents write. Non-local binds demand a shared token, because a daemon that spawns processes on a reachable port is remote code execution. For a saved remote device, the local daemon — never the browser — talks to the device's daemon and streams its events back over the local origin; the device's token is saved only in the user's own browser and handed to the local daemon per call. A shared link re-serves one agent's event stream read-only, from the same daemon that owns it. An agent can also run elsewhere: on a Claude cloud session (fire-and-forget: it opens its own PR), or on GitHub Actions (dispatch, poll, read back the uploaded transcript; continuity between turns is the branch the previous turn pushed) — with a browser extension inside the user's own claude.ai tab bridging cloud sessions back, so a question a cloud agent parks on becomes a dashboard card. An agent can launch a real Chrome that both it and a watching human attach to at once; when it hits a login wall, captcha, or 2FA it parks on a gate and hands the browser over — it never types a password. On Discord, notification watchers post agent activity and what needs a human; Discord is a way out, not a way in.
**Surfaces.** The daemon serves the dashboard and answers all its reads from the files agents write. Non-local binds demand a shared token, because a daemon that spawns processes on a reachable port is remote code execution. For a saved remote device, the local daemon — never the browser — talks to the device's daemon and streams its events back over the local origin; the device's token is saved only in the user's own browser and handed to the local daemon per call. An agent can also run elsewhere: on a Claude cloud session (fire-and-forget: it opens its own PR), or on GitHub Actions (dispatch, poll, read back the uploaded transcript; continuity between turns is the branch the previous turn pushed) — with a browser extension inside the user's own claude.ai tab bridging cloud sessions back, so a question a cloud agent parks on becomes a dashboard card. An agent can launch a real Chrome that both it and a watching human attach to at once; when it hits a login wall, captcha, or 2FA it parks on a gate and hands the browser over — it never types a password. On Discord, notification watchers post agent activity and what needs a human; Discord is a way out, not a way in.

**What lands in git.** One record of what happened: each agent's own event log, archived under a per-user directory keyed by the git identity, so cleaning the repo cannot erase the past and two people on one repo do not conflict. The daemon commits those archives after an idle window, only those paths, skipping while someone holds the index. Tickets — `tickets/<DATE>_<SLUG>.md`, the human-facing roadmap, with optional plan and claim siblings, parsed tolerantly. And the queue file plus a human-readable log of what The Framework did to the project.

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/dashboard/App.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The entire dashboard is this one page: it reads the selection from the address,
- The page owns what the views share: the agent list, the project list (each project carrying what the daemon currently finds wrong with it), project files, the cross-project needs-you queue, and the one live event stream the main view and right rail both read.
- A just-started session shows live before its record exists; with no id known yet, the page follows the output and adopts the running session once it surfaces.
- Live and finished agents are the same view — only the "live" flag flips when an agent ends.
- A shared watch link renders that one agent read-only; a daemon that stops answering gets a banner, so a dead backend never looks like a quiet agent.
- A daemon that stops answering gets a banner, so a dead backend never looks like a quiet agent.

## Rationales

Expand Down
22 changes: 5 additions & 17 deletions packages/the-framework/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { TicketPlanPage } from './components/TicketPlanPage.js'
import { AgentView } from './components/AgentView.js'
import { agentLabel } from './lib/agent-label.js'
import { RightRail } from './components/RightRail.js'
import { RelayView } from './components/RelayView.js'
import { NotFound } from './components/NotFound.js'
import { useLiveEvents } from './lib/use-live-events.js'
import { useAgents } from './lib/use-agents.js'
Expand Down Expand Up @@ -244,7 +243,7 @@ export function App() {
}

// The live agent feed is owned here so both the main view and the right rail's views tab read
// one shared event stream. Hooks run before the relay early return below.
// one shared event stream.
// The agent whose feed and controls are in play is simply the one in the URL; in the no-id
// fallback there is none yet, and a null id resolves to the project root, as before.
const { events, lost } = useLiveEvents(projectId, agentId, agentStart.tick)
Expand All @@ -258,25 +257,14 @@ export function App() {
// from AgentView rather than being folded here: a finished agent's events live in its archived log,
// which that view is the one to read.

// On the relay (#426), the URL carries `?run=<id>` and there is no local registry or
// files — show that one agent read-only. Guarded on `window` so the module can be loaded
// without a browser at all, where it resolves to the full shell.
const relayAgent = typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('run')

// Is an agent working (#875)? Drives the mark and the tab icon. Both off on the relay: there is
// no project registry behind it, so the cross-project read cannot answer, and RelayView owns
// both from its one agent's feed instead.
const local = relayAgent === null
const working = useWorking(local)
useFavicon(working, local)
// Is an agent working (#875)? Drives the mark and the tab icon.
const working = useWorking()
useFavicon(working)

// Whether the daemon answers at all (#948). Without this, a dead daemon froze every surface
// silently: the channels retry their transport without a verdict and the polls keep their
// last value, so "the agent went quiet" and "nothing on this page is live" looked identical.
const healthy = useDaemonHealth(local)

// Hooks above run unconditionally (rules of hooks); this early return is safe after them.
if (relayAgent) return <RelayView agentId={relayAgent} />
const healthy = useDaemonHealth()

// Route the main pane: the Overview dashboard when no project is selected (#471); else the
// project home/launcher, a running agent's live output, or a finished agent's replay. Each live
Expand Down
3 changes: 1 addition & 2 deletions packages/the-framework/dashboard/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,12 @@ The dashboard UI: a browser app served by the daemon that renders everything the
- The URL is the selection: the overview at `/`, a project at `/{projectId}`, one agent at `/{projectId}/{agentId}`, plus cross-project tickets, a per-ticket page and its plan page, and settings. An agent is a link you can paste, reload, and bookmark — there is no selection state to disagree with the address bar.
- An agent's events stream live over one channel bound to its own log; everything else polls. A finished agent reads from the archive instead, catching up whenever the live channel outgrew it.
- The dashboard is a plain client-side app: one static page the daemon serves for every address, and all the behaviour in the browser — no server rendering, no framework between the page and the app.
- Watch mode: opened against a shared link, the same app renders one agent read-only.

**The overview** is ordered by what governs what: the quota bar first (a week-track with pace and projection — the one figure that decides what agents may do next), then everything that needs *you* — the open-questions hub, every agent's unanswered question across all projects, answerable right there in one scrolling view — then the agents working now, the full AI queue of every project (uncollapsed: a plan you cannot read is not a plan), routine work, and the hottest tickets. An onboarding checklist sits on top until dismissed; each step's "done" is derived from a real fact (a registered project, a ticket on disk, a granted permission, stored credentials), so a step cannot be ticked by clicking it and work done outside the dashboard shows up ticked anyway.

**The composer** starts and steers agents. Typing a prompt starts an attended build; picking a preset starts an unattended one. In-editor triggers pull in presets and actions, files, projects, and macro tags; option menus write straight to the user's or project's preferences. Pre-flight checks warn before the agent is spent — a missing or logged-out GitHub CLI, a repo that can't auto-merge. On an agent, the composer is its control: a live one takes messages (options are baked at spawn and hidden), a stopped one offers to resume with reduced options, and the submit slot doubles as Stop while it works.

**The agent view** is a transcript with the controls inline: its questions render as answerable cards exactly where they happened (resolved ones collapse to a checkmark), and its live browser screencast renders inline too, degrading to a last still when the agent ends. Around the transcript: changed files with diffs, git status, the handoff panel (push, open PR, merge), agent-authored views, docs, and history rails, and an actions menu (stop, open in editor or on GitHub, remove worktree, delete it, copy a resume command, copy a shareable watch link).
**The agent view** is a transcript with the controls inline: its questions render as answerable cards exactly where they happened (resolved ones collapse to a checkmark), and its live browser screencast renders inline too, degrading to a last still when the agent ends. Around the transcript: changed files with diffs, git status, the handoff panel (push, open PR, merge), agent-authored views, docs, and history rails, and an actions menu (stop, open in editor or on GitHub, remove worktree, delete it, copy a resume command).

**Tickets** are the roadmap surface: a cross-project list with client-side faceted filtering (text, priority/effort/uncertainty buckets or ranges, topics, planning stage, project), sorting, and a group-by-project toggle — the whole view mirrored to the URL so it can be shared. Each ticket row leads with a start button that spins up an unattended agent implementing that one ticket, and shows whether a plan exists: a link to a page rendering the plan when it does, a button that starts an agent to write one when it doesn't. Queueing a ticket into the AI queue happens from the ticket's own page.

Expand Down
23 changes: 5 additions & 18 deletions packages/the-framework/dashboard/components/AgentFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@ import type { ReactNode } from 'react'
import type { FrameworkEvent } from '../../src/index.js'
import { TriangleAlert } from 'lucide-react'
import { EventList } from './EventList.js'
import { AgentOverview } from './AgentOverview.js'

// One agent's feed: the agent overview plus the live/replayed event log, or a waiting placeholder
// before anything has streamed. Shared by the agent's own view (AgentView, which shows the session
// link in its action bar instead — `showSessionLink={false}`) and the read-only relay watch view
// (RelayView, which keeps it since it has no action bar). `lost` is the live channel's health
// (#948): while the stream is down the feed is behind reality, and saying so beats letting
// "the agent went quiet" and "the connection died" look identical.
// One agent's feed: the live/replayed event log, or a waiting placeholder before anything has
// streamed. `lost` is the live channel's health (#948): while the stream is down the feed is
// behind reality, and saying so beats letting "the agent went quiet" and "the connection died"
// look identical.
export function AgentFeed({
events,
showSessionLink = true,
showName = true,
showStatus = true,
lost = false,
stick = true,
openAt,
Expand All @@ -25,15 +19,9 @@ export function AgentFeed({
}: {
events: FrameworkEvent[]
/** The feed's own project/run (#1455 item 6): with a projectId the log's `choice` rows become
* the interaction (inline panels/answered cards). The relay watch passes nothing — read-only. */
* the interaction (inline panels/answered cards). */
projectId?: string | undefined
agentId?: string | null | undefined
showSessionLink?: boolean
/** The agent's own view sets this false: its action bar's breadcrumb already names the session. */
showName?: boolean
/** The agent's own view sets this false: its action bar carries the status beside the ⋮ menu. */
showStatus?: boolean
/** The agent's own view sets this false: its right rail pins the loop's verdict under the tabs. */
lost?: boolean
/** A finished log is static (#1026): it does not follow new output, and opens at its end. */
stick?: boolean
Expand All @@ -60,7 +48,6 @@ export function AgentFeed({
return (
<>
{lostBanner}
<AgentOverview events={events} showSessionLink={showSessionLink} showName={showName} showStatus={showStatus} />
<EventList
events={events}
stick={stick}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The agent-overview cards projected from the event stream — the status line and an honestly-labelled link to the live session — each rendered only once its data has arrived, with embedding views opting out of the parts their own chrome already shows.
The agent-overview cards projected from the event stream — the status line and an honestly-labelled link to the live session — each rendered only once its data has arrived.

## Before modifying/creating SPEC.md files

Expand Down
21 changes: 4 additions & 17 deletions packages/the-framework/dashboard/components/AgentOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,17 @@ import { cn } from '../lib/utils.js'
// shows nothing extra.
export function AgentOverview({
events,
showSessionLink = true,
showName = true,
showStatus = true,
}: {
events: FrameworkEvent[]
showSessionLink?: boolean
/** The agent's own view sets this false: its action bar already names the session in the breadcrumb,
* so the status line just shows the state (and reads the same whether or not the agent reported a
* name). The relay watch and project home keep it, since they have no breadcrumb. */
showName?: boolean
/** The agent's own view sets this false too: the status is a label in its toolbar, beside the ⋮
* menu, rather than a banner over the feed. The relay watch and project home have no toolbar,
* so they keep the line. */
showStatus?: boolean
}) {
const session = sessionInfo(events)
const progress = agentProgress(events)
const status = showStatus ? agentStatusPill(events) : null
const status = agentStatusPill(events)

// The "Open session" link, labeled honestly: a headless Claude Code run has no per-session
// URL, so the generic app entry (claude.ai/code) is shown as "Open Claude Code" with the id
// surfaced separately, not as a deep link to that id. See {@link describeSessionLink}. The
// run's own view moves this into its action bar, so it opts out via `showSessionLink={false}`.
const sessionLink = showSessionLink ? describeSessionLink(session) : null
// surfaced separately, not as a deep link to that id. See {@link describeSessionLink}.
const sessionLink = describeSessionLink(session)

if (!sessionLink && !status) return null

Expand All @@ -43,7 +30,7 @@ export function AgentOverview({
{status && (
<div className="flex items-center gap-2 text-sm md:col-span-2">
<span className={cn('h-2.5 w-2.5 shrink-0 rounded-full', status.dot)} aria-hidden />
{showName && progress.sessionName && <span className="font-medium">{progress.sessionName}</span>}
{progress.sessionName && <span className="font-medium">{progress.sessionName}</span>}
<span className={cn('text-xs', status.tone)}>{status.label}</span>
</div>
)}
Expand Down
3 changes: 0 additions & 3 deletions packages/the-framework/dashboard/components/AgentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,6 @@ export function AgentView({
events={shown}
projectId={projectId}
agentId={agentId}
showSessionLink={false}
showName={false}
showStatus={false}
lost={lost}
{...(feedLive ? {} : { stick: false, openAt: 'end' as const, emptyLabel: 'This agent has no events.' })}
// A web agent's log dead-ends at the hand-off (#1265): the mirror box rides the tail of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('EventList inline choice rows (#1455 item 6)', () => {
expect(sendChoice).toHaveBeenCalledWith('p1', 'gate-1', 'work', 'user', 'r1')
})

test('without a projectId the row keeps the formatter text (the read-only relay watch)', () => {
test('without a projectId the row keeps the formatter text', () => {
render(<EventList events={[gate()]} stick={false} />)
expect(screen.queryByRole('button', { name: /Work on it/ })).toBeNull()
expect(screen.getByText(/Start the next backlog item\?/)).toBeTruthy()
Expand Down Expand Up @@ -231,7 +231,7 @@ describe('EventList inline choice rows (#1455 item 6)', () => {
})

// The latest `browser` row hosts the live inline preview (#1455 item 6b); earlier rows and the
// read-only relay watch keep the formatter's text, and an ended agent's pane degrades (#1359).
// a feed without both halves keeps the formatter's text, and an ended agent's pane degrades (#1359).
describe('EventList inline browser rows (#1455 item 6b)', () => {
const browser = (url = 'https://app.test/'): FrameworkEvent => ({ kind: 'browser', url })

Expand Down Expand Up @@ -259,7 +259,7 @@ describe('EventList inline browser rows (#1455 item 6b)', () => {
expect(screen.getByText(/browser · https:\/\/a\.test\//)).toBeTruthy()
})

test('without a agentId the row keeps the formatter text (the read-only relay watch)', () => {
test('without a agentId the row keeps the formatter text', () => {
render(<EventList events={[browser()]} stick={false} projectId="p1" />)
expect(screen.queryByAltText("The agent's browser")).toBeNull()
expect(screen.getByText(/browser: https:\/\/app\.test\//)).toBeTruthy()
Expand Down
Loading
Loading