/handoff generation pipeline
This document describes how the coding-agent implements /handoff today: trigger path, generation prompt, completion capture, session switch, and context reinjection.
Scope
Covers:
- Interactive
/handoffcommand dispatch AgentSession.handoff()lifecycle and state transitions- How handoff output is captured from assistant output
- How old/new sessions persist handoff data differently
- UI behavior for success, cancel, and failure
Does not cover:
- Generic tree navigation/branch internals
- Non-handoff session commands (
/new,/fork,/resume)
Implementation files
../src/modes/controllers/input-controller.ts../src/modes/controllers/command-controller.ts../src/session/agent-session.ts../src/session/session-manager.ts../src/extensibility/slash-commands.ts
Trigger path
/handoffis declared in builtin slash command metadata (slash-commands.ts) with optional inline hint:[focus instructions].- In interactive input handling (
InputController), submit text matching/handoffor/handoff ...is intercepted before normal prompt submission. - The editor is cleared and
handleHandoffCommand(customInstructions?)is called. CommandController.handleHandoffCommandperforms a preflight guard using current entries:- Counts
type === "message"entries. - If
< 2, it warns:Nothing to hand off (no messages yet)and returns.
- Counts
The same minimum-content guard exists again inside AgentSession.handoff() and throws if violated. This duplicates safety at both UI and session layers.
End-to-end lifecycle
1) Start handoff generation
AgentSession.handoff(customInstructions?):
- Reads current branch entries (
sessionManager.getBranch()) - Validates minimum message count (
>= 2) - Creates
#handoffAbortController - Builds a fixed, inline prompt requesting a structured handoff document (
Goal,Constraints & Preferences,Progress,Key Decisions,Critical Context,Next Steps) - Appends
Additional focus: ...if custom instructions are provided
Prompt is sent via:
await this.prompt(handoffPrompt, { expandPromptTemplates: false });expandPromptTemplates: false prevents slash/prompt-template expansion of this internal instruction payload.
2) Capture completion
Before sending prompt, handoff() subscribes to session events and waits for agent_end.
On agent_end, it extracts handoff text from agent state by scanning backward for the most recent assistant message, then concatenating all content blocks where type === "text" with \n.
Important extraction assumptions:
- Only text blocks are used; non-text content is ignored.
- It assumes the latest assistant message corresponds to handoff generation.
- It does not parse markdown sections or validate format compliance.
- If assistant output has no text blocks, handoff is treated as missing.
3) Cancellation checks
handoff() returns undefined when either condition holds:
- no captured handoff text, or
#handoffAbortController.signal.abortedis true
It always clears #handoffAbortController in finally.
4) New session creation
If text was captured and not aborted:
- Flush current session writer (
sessionManager.flush()) - Start a brand-new session (
sessionManager.newSession()) - Reset in-memory agent state (
agent.reset()) - Rebind
agent.sessionIdto new session id - Clear queued context arrays (
#steeringMessages,#followUpMessages,#pendingNextTurnMessages) - Reset todo reminder counter
newSession() creates a fresh header and empty entry list (leaf reset to null). In the handoff path, no parentSession is passed.
5) Handoff-context injection
The generated handoff document is wrapped and appended to the new session as a custom_message entry:
<handoff-context>
...handoff text...
</handoff-context>
The above is a handoff document from a previous session. Use this context to continue the work seamlessly.Insertion call:
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);Semantics:
customType:"handoff"display:true(visible in TUI rebuild)- Entry type:
custom_message(participates in LLM context)
6) Rebuild active agent context
After injection:
sessionManager.buildSessionContext()resolves message list for current leafagent.replaceMessages(sessionContext.messages)makes the injected handoff message active context- Method returns
{ document: handoffText }
At this point, the active LLM context in the new session contains the injected handoff message, not the old transcript.
Persistence model: old session vs new session
Old session
During generation, normal message persistence remains active. The assistant handoff response is persisted as a regular message entry on message_end.
Result: the original session contains the visible generated handoff as part of historical transcript.
New session
After session reset, handoff is persisted as custom_message with customType: "handoff".
buildSessionContext() converts this entry into a runtime custom/user-context message via createCustomMessage(...), so it is included in future prompts from the new session.
Controller/UI behavior
CommandController.handleHandoffCommand behavior:
- Calls
await session.handoff(customInstructions) - If result is
undefined:showError("Handoff cancelled") - On success:
rebuildChatFromMessages()(loads new session context, including injected handoff)- invalidates status line and editor top border
- reloads todos
- appends success chat line:
New session started with handoff context
- On exception:
- if message is
"Handoff cancelled"or error name isAbortError:showError("Handoff cancelled") - otherwise:
showError("Handoff failed: <message>")
- if message is
- Requests render at end
Cancellation semantics (current behavior)
Session-level cancellation primitive
AgentSession exposes:
abortHandoff()→ aborts#handoffAbortControllerisGeneratingHandoff→ true while controller exists
When this abort path is used, the handoff subscriber rejects with Error("Handoff cancelled"), and command controller maps it to cancellation UI.
Interactive /handoff path limitation
In current interactive controller wiring, /handoff does not install a dedicated Escape handler that calls abortHandoff() (unlike compaction/branch-summary paths that temporarily override editor.onEscape).
Practical impact:
- There is session-level cancellation support, but no handoff-specific keybinding hook in the
/handoffcommand path. - User interruption may still occur through broader agent abort paths, but that is not the same explicit cancellation channel used by
abortHandoff().
Aborted vs failed handoff
Current UI classification:
Aborted/cancelled
abortHandoff()path triggers"Handoff cancelled", or- thrown
AbortError - UI shows
Handoff cancelled
Failed
- any other thrown error from
handoff()/ prompt pipeline (model/API validation errors, runtime exceptions, etc.) - UI shows
Handoff failed: ...
- any other thrown error from
Additional nuance: if generation completes but no text is extracted, handoff() returns undefined and controller currently reports cancelled, not failed.
Short-session and minimum-content guardrails
Two guards prevent low-signal handoffs:
- UI layer (
handleHandoffCommand): warns and returns early for< 2message entries - Session layer (
handoff()): throws the same condition as an error
This avoids creating a new session with empty/near-empty handoff context.
State transition summary
High-level state flow:
- Interactive slash command intercepted
- Preflight message-count guard
#handoffAbortControllercreated (isGeneratingHandoff = true)- Internal handoff prompt submitted (visible in chat as normal assistant generation)
- On
agent_end, last assistant text extracted - If missing/aborted → return
undefinedor cancellation error path - If present:
- flush old session
- create new empty session
- reset runtime queues/counters
- append
custom_message(handoff) - rebuild and replace active agent messages
- Controller rebuilds chat UI and announces success
#handoffAbortControllercleared (isGeneratingHandoff = false)
Known assumptions and limitations
- Handoff extraction is heuristic: "last assistant text blocks"; no structural validation.
- No hard check that generated markdown follows requested section format.
- Missing extracted text is reported as cancellation in controller UX.
/handoffinteractive flow currently lacks a dedicated Escape→abortHandoff()binding.- New session lineage metadata (
parentSession) is not set by this path.