Skip to content

Latest commit

 

History

History
140 lines (110 loc) · 7.44 KB

File metadata and controls

140 lines (110 loc) · 7.44 KB

Model-proposed threads (propose_thread)

An agent working on one task keeps noticing others. The parser it just fixed sits behind a settings loader that swallows malformed input; the module it read has no tests; the migration it worked around should just be done. Every one of those has three bad endings and one good one. It can silently widen the task and hand back a diff nobody asked for. It can bury the suggestion in a paragraph of prose that scrolls away. It can block the turn asking permission for work that is not running. Or it can offer the work as a thread the user starts with one click.

propose_thread is the fourth one.

What it is not

It is not a permission prompt, and the difference is the whole design.

A permission prompt interrupts because something is already happening and cannot proceed without an answer: the shell command is composed, the loop is blocked, and "decide later" is not on the menu. That is why approvals are modal (src/renderer/views/approval-dialog.ts), why they coalesce a burst into one prompt, and why Approve is briefly disabled when the batch changes underneath a click.

A proposal is the opposite shape. Nothing is running. Nothing is blocked. The agent has already moved on. Ignoring the card forever is a perfectly good outcome — it costs nobody anything, and the offer is still there next week. So it renders inline in the transcript, as a card among the turn's other cards, and the only thing it asks for is a click it will never chase.

The offer

The agent calls propose_thread with four things that matter:

Argument What it is
title A short name — the card's heading, and the new thread's title
summary Plain-language description of what the run would do
rationale Why it deserves its own thread rather than this one (optional)
prompt The exact text the new thread starts with
files Paths it expects to touch, for display only (optional)

summary and prompt are separate on purpose. The summary is what the user reads and decides on; the prompt is machine text written for an agent with none of this conversation as context. Cards that show the prompt as the description are how these surfaces turn into unreadable JSON dumps, so the card leads with the summary and keeps the prompt one disclosure away.

The tool returns immediately. The agent is told the offer was made, never whether it was accepted — see threadProposalAcknowledgement in thread-proposal.ts, where the wording lives with its test. Two failure modes it exists to prevent: an acknowledgement that reads as success would let the model report work nobody agreed to, and one that reads as rejection would invite it to re-propose the same thing on the next turn.

The card

Three states, all of them in the transcript:

  • Standing offer. Open by default. The title, the summary, the rationale, a chip saying the work gets its own checkout, a chip listing the files, the prompt behind a disclosure, and two buttons: Start this thread and Not now.
  • Started. Collapsed to one quiet line with a check and Open thread. The link lives in the header, not the body: a settled card is collapsed, and "take me to that work" must not be behind a disclosure. If the thread it made has since been deleted, the line stays honest and drops the link.
  • Dismissed. Collapsed to one quiet line. Expanding it offers Bring it back, because "not now" is usually about timing rather than about the idea.

Only the standing offer keeps the accent rail; settled cards drop to plain border and no fill, because they are history rather than an ask.

Starting one

Accepting walks the same road the composer walks for a first message — checkout committed in main, then the prompt into the transcript, then a fresh human turn tree, then dispatch (src/renderer/controller/thread-proposals.ts). What it adds is the isolation the card promised: the checkout is requested as 'worktree', so the proposed work gets its own branch and directory instead of landing on top of whatever the user has open.

Order matters: the thread is created first and its in-flight autosave is awaited before the checkout IPC needs it. A failed checkout leaves an empty thread — no user message, no dispatch, and the offer still standing on the card that made it.

When isolation is not available

The repository has the last word. Current main fails an explicit worktree request closed for a non-Git folder, remote project, detached HEAD or submodules; an explicit request also overrides a project's automatic-worktree preference. The failure is shown after navigation, and no work is dispatched. See worktree-policy.ts.

The controller additionally defends against a returned shared grant (for example, a previously persisted checkout decision). This is not the current policy's ordinary fallback for a new explicit worktree request.

A degraded grant is not a cosmetic difference. The user clicked a card offering work "in its own checkout"; what a shared checkout means is the agent editing the working tree they already have open, alongside whatever they are doing in it. That is not the thing they agreed to.

So consent is taken before dispatch, not reported after it. Once prepareCheckout comes back with shared, the controller stops and asks through the confirmSharedCheckout callback its caller supplies — injected rather than imported, so the controller stays free of views, and required rather than optional, because a caller with no way to ask cannot honour the promise the card made. Reporting the fallback afterwards was the first attempt at this and it was wrong: by the time a notice appears the run has started and the files are already moving.

Declining costs nothing. Nothing has dispatched, so the working tree is untouched; the prompt is parked as a draft on the empty thread so the click is not simply lost, and no decision is recorded — the offer is still standing on the card, which is true, because it was never started.

Accepting records the granted mode on the decision, so the settled card reads Started in the shared checkout rather than Thread started. The card made the promise, so the card is where it is corrected — and unlike a dialog, that correction is still there tomorrow. A decision written before this was captured carries no checkoutMode and reads as "not known" rather than as isolated.

Where the answer lives

The proposal itself is never persisted twice: it is the propose_thread call's own arguments, already in the transcript, addressable by tool-call id. Only the answer is stored, as threadProposals on the offering thread's meta.json (one row per proposal, last write wins). The thread a proposal created also records proposedBy pointing back at the offer.

The answer lives on the offering thread rather than being derived from whether a started thread still exists — deleting that thread must not put the card back to "start this?", and a dismissal has no thread to derive anything from at all.

Related