/handoff generation pipeline
This document describes how the coding-agent implements /handoff: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior.
Scope
Covers:
- Interactive
/handoffcommand dispatch AgentSession.handoff()lifecycle and state transitionsgenerateHandoff(...)request shape- 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.tspackages/agent/src/compaction/compaction.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
#handoffAbortControllerand links any caller-provided abort signal to it. - Resolves the current model API key through
ModelRegistry. - Calls
generateHandoff(...)with:- live agent messages (
agent.state.messages), - the current model and API key,
- the base system prompt (
#baseSystemPrompt), - the live tool array (
agent.state.tools), - optional focus instructions,
- coding-agent message conversion (
convertToLlm), - provider metadata, current thinking level, and
initiatorOverride: "agent".
- live agent messages (
generateHandoff(...) lives in packages/agent/src/compaction/compaction.ts next to summarization. It renders packages/agent/src/compaction/prompts/handoff-document.md via renderHandoffPrompt(...) with optional additionalFocus.
2) Generate and capture output
generateHandoff(...) converts the existing AgentMessage[] history to real LLM Message[] history, then appends one trailing agent-attributed user message containing the rendered handoff prompt.
The request uses completeSimple(...) directly:
await completeSimple(
model,
{
systemPrompt,
messages: requestMessages,
tools,
},
{
apiKey,
signal,
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
toolChoice: "none",
initiatorOverride,
metadata,
},
);
Important generation properties:
- The request preserves the live provider cache prefix by reusing the same system prompt, tool definitions, and real message history shape as the active agent.
- The handoff instruction is a trailing
usermessage, not a developer message, so the cached prefix remains aligned with the prior turn. toolChoice: "none"prevents intentional tool dispatch.- The returned assistant content is filtered to text blocks and joined with
\n; stray tool-call blocks are ignored if a provider does not honortoolChoice: "none". stopReason === "error"throws a generation error.
No agent-loop events are used for capture. The handoff path no longer waits for agent_end and no longer scans the latest assistant message.
3) Cancellation checks
Cancellation throws Error("Handoff cancelled"); a completed generation with no text returns undefined.
- caller signal aborts
#handoffAbortController completeSimple(...)receives the abort signal- aborted handoff signal or provider
AbortErroris normalized toError("Handoff cancelled") - empty generated text returns
undefined
AgentSession.handoff() always clears #handoffAbortController in finally.
4) New session creation
If text was generated and not aborted:
- Flush current session writer (
sessionManager.flush()). - Cancel session-owned async jobs.
- Start a brand-new session with
parentSessionpointing at the previous session file when one exists. - Reset in-memory agent state (
agent.reset()). - Rebind
agent.sessionIdto the new session id. - Rekey/reset Hindsight and Mnemopi memory session tracking for the new session.
- Clear queued context arrays (
#steeringMessages,#followUpMessages,#pendingNextTurnMessages) and any scheduled hidden next-turn generation. - Reset todo reminder counter.
5) Handoff-context injection
The generated handoff document is wrapped by coding-agent session glue 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,
undefined,
"agent",
);
Semantics:
customType:"handoff"display:true(visible in TUI rebuild)- attribution:
"agent" - Entry type:
custom_message(participates in LLM context)
6) Rebuild active agent context
After injection:
buildDisplaySessionContext()resolves message list for current leaf.agent.replaceMessages(sessionContext.messages)makes the injected handoff message active context.- Todo phases are synchronized from the new branch.
- Method returns
{ document: handoffText, savedPath? }.
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
Handoff generation is a oneshot request, not a visible agent turn. The generated handoff text is not appended to the old session as an assistant message.
Result: the original session keeps its prior transcript unchanged except for data already persisted before handoff began.
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.
Auto-triggered handoffs can additionally write a timestamped handoff-*.md artifact under the session artifacts directory when compaction.handoffSaveToDisk is enabled. Manual /handoff does not write that artifact.
Controller/UI behavior
CommandController.handleHandoffCommand behavior:
- Shows a status loader:
Generating handoff… (esc to cancel). - 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
- Stops the loader, restores the previous Escape handler, and requests render at end.
Manual /handoff no longer streams the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after generation completes.
Cancellation semantics
Session-level cancellation primitive
AgentSession exposes:
abortHandoff()→ aborts#handoffAbortControllerisGeneratingHandoff→ true while controller exists
When this abort path is used, the abort signal is passed to completeSimple(...); handoff() normalizes the cancellation to Error("Handoff cancelled"), and command controller maps it to cancellation UI.
Interactive /handoff path
The command controller installs a temporary Escape handler for /handoff while the loader is visible. Pressing Escape calls session.abortHandoff(), which aborts the completeSimple(...) request through #handoffAbortController.
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()/generateHandoff()/ provider request path - UI shows
Handoff failed: ...
- any other thrown error from
Additional nuance: if generation completes but no text is returned, 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).generateHandoff(...)issues onecompleteSimple(...)request with live system prompt, tools, message history, current thinking level, and trailing handoff prompt.- Assistant response text blocks are joined; tool-call blocks are discarded.
- If missing text → return
undefined; if aborted → cancellation error path. - If present:
- flush old session
- cancel async jobs
- create new empty session with previous session as parent
- reset runtime queues/counters
- append
custom_message(handoff) - optionally save an auto-triggered handoff document under the session artifacts directory when
compaction.handoffSaveToDiskis enabled
- Controller rebuilds chat UI and announces success.
#handoffAbortControllercleared (isGeneratingHandoff = false).
Known assumptions and limitations
- No structural validation checks that generated markdown follows the requested section format.
- Missing generated text is reported as cancellation in controller UX.
- Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates after generation completes.
- Auto-triggered handoffs can write a timestamped
handoff-*.mdartifact whencompaction.handoffSaveToDiskis enabled; write failure is logged and does not fail the handoff.