- No finished sessions yet.
+ No finished agents yet.
- Nothing in the backlog.
+ Nothing on the agent queue.
- Add a TODO
+ Add an entry
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