feat(handoff): let an agent hand its thread off to a fresh one - #8492
mfattakhov wants to merge 3 commits into
Conversation
Sessions fill their context window before the work is done, and "continue this in a new thread" was manual: re-explain the goal, dig up paths and decisions, paste them in — losing detail at exactly the moment the old session knows the work best. The live agent already holds the full context natively, so it writes the recap itself. A T3-owned local SDK plugin is injected into every Claude session carrying a `handoff` skill, invoked plugin-qualified as `t3:handoff`. The skill composes a name and a first-message-quality summary and runs `t3 handoff --name "<name>"` with the summary on stdin, which POSTs to /handoff on the running server. The server creates the child in the parent's project, seeds it with the summary as its first user message via the existing bootstrap turn-start path, and starts it in the background. Model, permission mode, and interaction mode carry from the parent; branch, worktree, and environment mode never carry implicitly. The agent-chosen name is passed as the title with titleSeed omitted, so the auto-titler leaves it alone. A seed that fails to dispatch compensates the created thread away rather than leaving a husk. Auth reuses the session's existing MCP bearer credential, injected at spawn as T3_SERVER_ORIGIN / T3_THREAD_ID / T3_SERVER_TOKEN / T3_CLI. It is already per-thread, already expiring, already revoked when the session stops — and the server resolves it back to the parent, so lineage identity comes from the credential rather than from anything the agent claims. T3_CLI is a single executable shim path, never a "runtime + script" pair, because zsh does not word-split unquoted parameters. Lineage is recorded as handoff.created / handoff.received thread activities carrying the other thread's id, so it needs no migration and renders in the existing work log with cross-thread links. The parent is otherwise untouched: no auto-settle, no auto-snooze, no injected turn. v1 is Claude-only; the HTTP route and handoff service are provider-neutral.
…ckaged builds resolveT3ClaudePluginDir only found the plugin in the monorepo source layout; bundled builds (npx t3, desktop sidecar) never saw it, dropping t3:handoff from the skills picker. Copy claude-plugin into dist during the server build, which both the npm package and the desktop artifact stage wholesale.
…d apps The plugin shipped inside app.asar (and inside the Windows server.asar sidecar), where only the server's asar-aware Electron fs could see it. The `claude` subprocess is a plain process, so the plugin loaded nowhere while skill discovery — reading through that same fs — still advertised t3:handoff in the picker. Selecting it did nothing. Unpack claude-plugin in both archives, resolve the plugin to its .unpacked twin (and report nothing plus a warning when there is no twin, instead of handing over a path that exists only for the server), and inject the CLI entry and the runtime that can read it so the bin/t3 shim reaches a bundle that stays packed.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
Reviewed the new Effect code in this PR (apps/server/src/handoff/*, apps/server/src/cli/handoff.ts, apps/server/src/provider/Drivers/ClaudePlugin.ts, and the Claude adapter/provider wiring) against the Effect service conventions.
Dependency acquisition looks right: performHandoff and resolveT3ClaudePluginLocation take their dependencies from the environment (OrchestrationEngineService, ProjectionSnapshotQuery, Crypto, FileSystem, Path), the new t3PluginDir option is pure configuration, Effect.catchTags is used for the route's known failures, and the CLI's Effect.provide(FetchHttpClient.layer) at the command handler matches the existing cli/pair.ts boundary.
Findings are limited to error modeling: the new HandoffCliError is a single class whose only data is a pre-formatted message (one site interpolating a stringified cause), and HandoffDispatchError's stage is set to "create" for two failures that are neither the create dispatch nor a dispatch at all.
Posted via Macroscope — Effect Service Conventions
| Effect.mapError( | ||
| (cause) => | ||
| new HandoffCliError({ | ||
| message: `Could not reach the d server at ${origin}: ${String(cause)}`, | ||
| }), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
This wrapper derives its message from String(cause) and then discards the cause, so the underlying HttpClientError (and its stack) is lost and arbitrary defect text ends up in a caller-visible message. Suggest keeping the failure as cause on a dedicated error class with origin as a structural attribute, and deriving the message only from those attributes (see the comment on the error declaration). The same applies to the response.json and decode wrappers below (L110-L116, L125-L131), which drop their causes entirely.
Posted via Macroscope — Effect Service Conventions
| const parent = yield* projectionSnapshotQuery.getThreadShellById(input.parentThreadId).pipe( | ||
| Effect.mapError( | ||
| (cause) => | ||
| new HandoffDispatchError({ | ||
| parentThreadId: input.parentThreadId, | ||
| stage: "create", | ||
| cause, | ||
| }), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
The parent-shell read is not a dispatch, yet its failure is reported as HandoffDispatchError with stage: "create"; the compensating thread.delete at L127-L133 is also labelled "create". Since stage is the field that identifies the failure structurally — and it reaches callers verbatim in HandoffHttpServer's Handoff failed while dispatching (${error.stage}) 500 message — both cases are mislabelled.
Suggest adding the real stages to the literal union (e.g. "parent-lookup" and "compensate", or a separate error class for the lookup) and using them at these two sites. Adding the known childThreadId to HandoffDispatchError would also retain the entity context available at every dispatch wrapping site.
Posted via Macroscope — Effect Service Conventions
| export class HandoffCliError extends Schema.TaggedErrorClass<HandoffCliError>()("HandoffCliError", { | ||
| message: Schema.String, | ||
| }) {} |
There was a problem hiding this comment.
HandoffCliError stores an unstructured message as its only attribute, and seven semantically distinct failures are funnelled through it with baked-in strings and no cause: missing session env (L74), empty stdin (L83), empty --name (L89), stdin read failure (L48, cause dropped), transport failure (L103), unreadable body (L112), non-2xx status (L119), undecodable response (L127).
Consider following the pattern already used in cli/pair.ts / cli/project.ts: one class per failure with structured, serializable attributes plus an override get message() derived from them, and a real cause: Schema.Defect() wherever an underlying failure is being wrapped. For example:
export class HandoffSessionEnvMissingError extends Schema.TaggedErrorClass<HandoffSessionEnvMissingError>()(
"HandoffSessionEnvMissingError",
{ missing: Schema.Array(Schema.String) },
) {
override get message(): string {
return `Missing ${this.missing.join("/")} — \`t3 handoff\` only works inside a T3 Code-managed agent session.`;
}
}
export class HandoffServerRequestError extends Schema.TaggedErrorClass<HandoffServerRequestError>()(
"HandoffServerRequestError",
{ origin: Schema.String, cause: Schema.Defect() },
) {
override get message(): string {
return `Could not reach the T3 Code server at ${this.origin}.`;
}
}The existing user-facing strings can be preserved verbatim in the getters, so CLI output does not change.
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
UI consistency review of the web changes in this PR. One finding: the new handoff work-log row does not share the geometry/typography of the sibling rows it is interleaved with in the same Work Log group. The read-state baseline wiring (uiStateStore, Sidebar.logic, both sidebars, ThreadStatusIndicators) is consistent — every resolveThreadStatusPill/hasUnseenCompletion call site was updated, and the timeline link resolves its environment from the bound thread's activeThreadEnvironmentId rather than the globally active environment.
Posted via Macroscope — UI Consistency
| <div className="flex items-center gap-1.5 rounded-md px-0.5 py-0.5 text-[12px] leading-5"> | ||
| <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65"> | ||
| <ArrowRightLeftIcon className="size-3.5" aria-hidden /> | ||
| </span> | ||
| <span className="min-w-0 truncate font-medium text-foreground/82">{workEntry.label}</span> |
There was a problem hiding this comment.
This row renders inside the same WorkGroupSection list as PlainWorkEntryRow, but uses a different icon gutter and text scale: size-5 wrapper + size-3.5 icon vs. the shared size-6 wrapper + size-4 stroke-[1.8] opacity-70 icon, and text-[12px] leading-5 vs. text-sm leading-relaxed. Interleaved with tool/activity rows the handoff label starts ~4px left of every other row's label and is a step smaller, so the work log's left icon column no longer lines up. text-muted-foreground/65 also bypasses the text-icon-muted token the other work-log icons use, and text-foreground/82 is a one-off alpha (/80 elsewhere).
Consider matching the sibling row metrics and tokens:
| <div className="flex items-center gap-1.5 rounded-md px-0.5 py-0.5 text-[12px] leading-5"> | |
| <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65"> | |
| <ArrowRightLeftIcon className="size-3.5" aria-hidden /> | |
| </span> | |
| <span className="min-w-0 truncate font-medium text-foreground/82">{workEntry.label}</span> | |
| <div className="flex items-center gap-1.5 rounded-md px-0.5 py-0.5 text-sm leading-relaxed"> | |
| <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> | |
| <ArrowRightLeftIcon className="block size-4 shrink-0 stroke-[1.8] opacity-70" aria-hidden /> | |
| </span> | |
| <span className="min-w-0 truncate font-medium text-foreground/80">{workEntry.label}</span> |
Posted via Macroscope — UI Consistency
| [ | ||
| `Handed off to "${result.title}"`, | ||
| `Thread: ${result.threadId}`, | ||
| `URL: ${origin}/${result.environmentId}/${result.threadId}`, |
There was a problem hiding this comment.
🟡 Medium cli/handoff.ts:137
The printed URL is unusable for remote users because it is built from T3_SERVER_ORIGIN, the MCP callback origin; McpSessionRegistry normalizes wildcard binds to 127.0.0.1, so remote handoffs advertise a localhost URL on the user's machine. Use the server's public connection URL (for example, return it in HandoffResponse) when constructing this link.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cli/handoff.ts around line 137:
The printed `URL` is unusable for remote users because it is built from `T3_SERVER_ORIGIN`, the MCP callback origin; `McpSessionRegistry` normalizes wildcard binds to `127.0.0.1`, so remote handoffs advertise a localhost URL on the user's machine. Use the server's public connection URL (for example, return it in `HandoffResponse`) when constructing this link.
|
|
||
| // The baseline only pins read state if it survives a reload, so a client that | ||
| // just minted one writes it out now instead of waiting for the first change. | ||
| persistState(useUiStateStore.getState()); |
There was a problem hiding this comment.
🟡 Medium src/uiStateStore.ts:436
Opening a second same-origin tab can overwrite newer UI state from another tab, reverting project order, expansion, endpoint, or visit state. persistState(useUiStateStore.getState()) writes this tab’s snapshot unconditionally after its initial read; only persist here when readStateBaselineAt was newly minted, or otherwise avoid the unconditional write.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/uiStateStore.ts around line 436:
Opening a second same-origin tab can overwrite newer UI state from another tab, reverting project order, expansion, endpoint, or visit state. `persistState(useUiStateStore.getState())` writes this tab’s snapshot unconditionally after its initial read; only persist here when `readStateBaselineAt` was newly minted, or otherwise avoid the unconditional write.
| // No titleSeed on the seed turn: the model-chosen name IS the title, and | ||
| // canReplaceThreadTitle never overwrites a non-default title without a | ||
| // matching seed — so the auto-titler leaves it alone. | ||
| yield* dispatchStage("turn-start", { |
There was a problem hiding this comment.
🟡 Medium handoff/HandoffService.ts:138
If crypto.randomUUIDv4 fails while constructing the thread.turn.start command, the request defects after thread.create has succeeded and leaves an empty child thread behind. The yield* commandId("turn-start") and message ID generation run before Effect.onError is attached to the dispatch effect; wrap the entire command-construction effect, not just dispatchStage, in the compensation handler.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/handoff/HandoffService.ts around line 138:
If `crypto.randomUUIDv4` fails while constructing the `thread.turn.start` command, the request defects after `thread.create` has succeeded and leaves an empty child thread behind. The `yield* commandId("turn-start")` and message ID generation run before `Effect.onError` is attached to the dispatch effect; wrap the entire command-construction effect, not just `dispatchStage`, in the compensation handler.
| // default smart unpack extracts native libraries, which loaders find in | ||
| // app.asar.unpacked. Windows additionally ships the server tree as the | ||
| // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE). | ||
| asarUnpack: [...DESKTOP_ASAR_UNPACK], |
There was a problem hiding this comment.
🟠 High scripts/build-desktop-artifact.ts:2104
Packaged macOS and Linux builds leave .claude-plugin/plugin.json inside app.asar, so resolveT3ClaudePluginLocation rejects the plugin and every packaged Claude session loses t3:handoff. The ** in DESKTOP_ASAR_UNPACK does not traverse the dot-prefixed .claude-plugin directory; add an explicit unpack pattern for that directory.
- asarUnpack: [...DESKTOP_ASAR_UNPACK],
+ asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/claude-plugin/.claude-plugin/**/*"],🤖 Copy this AI Prompt to have your agent fix this:
In file @scripts/build-desktop-artifact.ts around line 2104:
Packaged macOS and Linux builds leave `.claude-plugin/plugin.json` inside `app.asar`, so `resolveT3ClaudePluginLocation` rejects the plugin and every packaged Claude session loses `t3:handoff`. The `**` in `DESKTOP_ASAR_UNPACK` does not traverse the dot-prefixed `.claude-plugin` directory; add an explicit unpack pattern for that directory.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f7f9b83. Configure here.
| // `claude` subprocess, which is a plain process and cannot read an asar | ||
| // archive: packed, the plugin exists for the server but for nothing else, so | ||
| // t3:handoff would show up in the picker and load nowhere. | ||
| export const DESKTOP_ASAR_UNPACK = ["apps/server/dist/claude-plugin/**/*"] as const; |
There was a problem hiding this comment.
Desktop unpack misses plugin manifest
High Severity
macOS/Linux asarUnpack uses the glob claude-plugin/**/*, which does not match the dot-directory .claude-plugin. Plugin resolution then fails the unpacked-twin manifest check and disables t3:handoff in packaged desktop builds. The Windows sidecar already avoids this by unpacking the directory prefix instead of a glob.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit f7f9b83. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds a substantial end-to-end handoff workflow spanning Claude session injection, bearer-authenticated server routing, orchestration, desktop packaging, and UI state rather than a small isolated change. Unresolved findings also identify risks in packaged plugin availability, remote URLs, cross-tab persistence, and failure compensation, so the production impact requires human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |


Agent sessions fill their context window before the work is done, and "continue this in a fresh thread" is manual today: you re-explain the goal, dig up file paths and decisions, and paste them into a new thread — losing detail every time, at exactly the moment the old session knows the work best and you want to be hands-off.
The live agent already holds the full context natively, so it writes the recap itself. A T3-owned local SDK plugin is injected into every Claude session carrying a
handoffskill, invoked plugin-qualified ast3:handoff. The skill composes a name and a first-message-quality summary and runst3 handoff --name "<name>"with the summary on stdin, which POSTs to/handoffon the running server. The server creates the child in the parent's project, seeds it with the summary as its first user message through the existing bootstrap turn-start path, and starts it immediately in the background. Model, permission mode, and interaction mode carry from the parent; branch, worktree, and environment mode never carry implicitly. The agent-chosen name is passed astitlewithtitleSeedomitted so the auto-titler leaves it alone, and a seed that fails to dispatch compensates the created thread away rather than leaving a husk.There is deliberately no server-side summarizer. That shape was considered and rejected: it brings a summarization timeout on long sessions, a config-dir/instance-resolution trap, untracked token spend, and a lossy projection fallback — all of which the agent-driven shape deletes at once, because the agent that has the context is the one writing the recap.
Auth reuses the session's existing MCP bearer credential, injected at spawn as
T3_SERVER_ORIGIN/T3_THREAD_ID/T3_SERVER_TOKEN/T3_CLI. It is already per-thread, already expiring, already revoked when the session stops — and the server resolves the token back to the parent thread, so lineage identity comes from the credential rather than from anything the agent claims. Lineage is recorded ashandoff.created/handoff.receivedthread activities carrying the other thread's id, so it needs no migration and renders in the existing work log with cross-thread links. The parent is otherwise untouched: no auto-settle, no auto-snooze, no injected turn, which makes handoff ordering-safe by construction.How I use it: I say "hand this off" (or the agent notices context pressure and does it), optionally scoped — "hand off just the remaining test failures". The successor arrives with what was tried and ruled out, the current state, concrete next steps, and the skills that actually helped, all referenced by path or hash rather than inlined, so the child's context window starts lean. I get a confirmation with the child's title, id, and URL in the parent transcript, and the child shows up in the sidebar like a thread I typed myself. Because it works over plain HTTP to the running server, it behaves identically whether I am local, on the tailnet, or through a tunnel — which matters since most of my sessions are remote.
The two follow-up commits are packaging: the plugin needs to be copied into
distfor bundled builds (npx t3, desktop sidecar), and unpacked from the Electron asar archives, because theclaudesubprocess is a plain process and cannot read through the server's asar-aware fs. Without them the skill picker advertisedt3:handoffand selecting it did nothing.v1 is Claude-only — Codex, Cursor, Grok, and OpenCode have no plugin/skill-injection equivalent wired — but the HTTP route and handoff service are provider-neutral, so adding an adapter later is env injection plus a skill-delivery mechanism, not a redesign.
Docs:
docs/user/handoff.mdfor behavior,docs/internals/handoff.mdfor the architecture record.Model: Claude Opus 5 (1M context), harness: Claude Code.
Note
Medium Risk
Touches session-scoped auth, orchestration dispatch, and Claude session env/plugin packaging; failures are mostly localized but a bad handoff path could create or leak partial thread state without compensation working.
Overview
Adds agent-driven handoff: a shipped
t3Claude plugin exposest3:handoff, the live agent writes a name and summary, then runst3 handoff(summary on stdin) which POSTs/handoffon the running server using session env (T3_SERVER_ORIGIN,T3_SERVER_TOKEN,T3_CLIshim + optional asar-aware entry/runtime).The server authenticates with the same MCP bearer token (
resolveActiveMcpToken), derives the parent thread from the credential, andperformHandoffcreates a child in the parent project (model/modes carry; branch/worktree do not), starts a seed user turn, recordshandoff.created/handoff.receivedactivities, and deletes the child if turn-start fails.Claude integration resolves and injects the local plugin, discovers plugin-qualified skills, and bundles
claude-plugin→dist/claude-plugin; desktop builds unpack the plugin from asar so theclaudesubprocess can load it.Web: work-log rows link parent/child threads;
readStateBaselineAtin persisted UI state so never-visited threads created after the baseline can show as unread (e.g. handoff children) without lighting up all history on sidebar switch.User and internals docs added under
docs/user/handoff.mdanddocs/internals/handoff.md.Reviewed by Cursor Bugbot for commit f7f9b83. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add agent thread handoff via POST
/handoffendpoint, CLI, and Claude pluginperformHandoffin HandoffService.ts which creates a child thread from a parent, seeds it with a summary turn, records lineage activities, and deletes the child if seeding failsPOST /handoffin HandoffHttpServer.ts authenticating via MCP session bearer token, with 400/401/404/500 error responsest3 handoffCLI command in handoff.ts that readsT3_SERVER_ORIGIN/T3_SERVER_TOKENfrom env, sends a summary via stdin, and prints the new thread detailshandoffskill in claude-plugin/ with a shell shim that works in both source and asar-packaged environments; ClaudeAdapter.ts injects callback env vars so the plugin can call back into the serverreadStateBaselineAtso never-visited threads created before baseline are treated as readapps/server/dist/claude-pluginfrom asar on all platforms; if the unpacked twin is missing at runtime,resolveT3ClaudePluginLocationreturns undefined and the handoff skill is unavailable📊 Macroscope summarized f7f9b83. 28 files reviewed, 4 issues evaluated, 0 issues filtered, 4 comments posted
🗂️ Filtered Issues