MusePi

/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:

Does not cover:

Implementation files

Trigger path

  1. /handoff is declared in builtin slash command metadata (slash-commands/builtin-registry.ts) with optional inline hint: [focus instructions].
  2. In interactive input handling (InputController), submit text matching /handoff or /handoff ... is intercepted before normal prompt submission.
  3. The editor is cleared and handleHandoffCommand(customInstructions?) is called.
  4. CommandController.handleHandoffCommand performs a preflight guard using current entries:
    • Counts type === "message" entries.
    • If < 2, it warns: Nothing to hand off (no messages yet) and returns.

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?):

2) Generate and capture output

generateHandoffFromContext(...) lives in packages/agent/src/compaction/compaction.ts next to summarization. It is the handoff request contract: it issues one instrumentedCompleteSimple(...) (the OTEL-instrumented completeSimple oneshot wrapper) against the caller-built Context, forcing toolChoice: "none" and reasoning: resolveCompactionEffort(model, thinkingLevel) over whatever the caller’s streamOptions carried:

await instrumentedCompleteSimple(
  model,
  context, // system prompt + normalized tools + transformed history + trailing handoff prompt
  {
    ...streamOptions, // apiKey, signal, sessionId, promptCacheKey, serviceTier, hooks
    reasoning: resolveCompactionEffort(model, options.thinkingLevel),
    toolChoice: "none",
  },
  { telemetry, oneshotKind: "handoff" },
);

(generateHandoff(messages, …) remains exported for downstream callers and now builds a basic Context from systemPrompt/tools/convertToLlm and delegates to generateHandoffFromContext. AgentSession no longer uses it because it cannot apply the host’s transform pipeline or cache routing.)

Important generation properties:

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

An explicit user cancellation throws Error("Handoff cancelled"). Harness-initiated aborts preserve a supplied reason, or surface Handoff aborted by session when none is supplied. A manual handoff whose generation is empty/whitespace-only throws Handoff generation produced no content; auto-handoff returns undefined so maintenance can fall back to context-full compaction.

AgentSession.handoff() always clears #handoffAbortController in finally.

4) New session creation

If text was generated and not aborted:

  1. Flush current session writer (sessionManager.flush()).
  2. Cancel session-owned async jobs.
  3. Start a brand-new session with parentSession pointing at the previous session file when one exists.
  4. Reset in-memory agent state (agent.reset()).
  5. Rebind agent.sessionId to the new session id.
  6. Rekey/reset Hindsight and Mnemopi memory session tracking for the new session.
  7. Clear the queued next-turn context array (#pendingNextTurnMessages) and the scheduled hidden next-turn generation (#scheduledHiddenNextTurnGeneration). The agent’s steering and follow-up queues are already cleared by agent.reset() in step 4.
  8. 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:

6) Rebuild active agent context

After injection:

  1. buildDisplaySessionContext() resolves message list for current leaf.
  2. agent.replaceMessages(sessionContext.messages) makes the injected handoff message active context.
  3. Todo phases are synchronized from the new branch.
  4. 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:

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:

Direct abortHandoff() passes an unreasoned abort signal to completeSimple(...); handoff() normalizes it to Error("Handoff cancelled"), and command controller maps it to cancellation UI. AgentSession.abort(...) instead aborts the handoff first with its harness reason (or Handoff aborted by session), so subsequent compaction cancellation cannot mask that failure as a user cancellation.

Interactive /handoff path

InputController’s global editor.onEscape handler dispatches on live session state instead of swapping handlers: while isGeneratingHandoff is true, pressing Escape calls session.abortHandoff(), which aborts the completeSimple(...) request through #handoffAbortController.

Aborted vs failed handoff

Current UI classification:

Additional nuance: if generation completes but no text is returned, handoff() returns undefined and controller currently reports cancelled, not failed. An extension-cancelled session_before_switch returns undefined, which the interactive controller reports as cancelled. Empty generation is not an extension cancellation: manual handoff throws; auto-handoff returns undefined only for its context-full fallback.

Short-session and minimum-content guardrails

Two guards prevent low-signal handoffs:

This avoids creating a new session with empty/near-empty handoff context.

State transition summary

High-level state flow:

  1. Interactive slash command intercepted.
  2. Preflight message-count guard.
  3. #handoffAbortController created (isGeneratingHandoff = true).
  4. generateHandoff(...) issues one instrumentedCompleteSimple(...) request with live system prompt, tools, message history, current thinking level, and trailing handoff prompt.
  5. Assistant response text blocks are joined; tool-call blocks are discarded.
  6. If missing text → return undefined; if aborted → cancellation error path.
  7. 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.handoffSaveToDisk is enabled
  8. Controller rebuilds chat UI and announces success.
  9. #handoffAbortController cleared (isGeneratingHandoff = false).

Known assumptions and limitations