Skip to content

feat(handoff): let an agent hand its thread off to a fresh one - #8492

Closed
mfattakhov wants to merge 3 commits into
pingdotgg:mainfrom
mfattakhov:mfattakhov/handoff-v1
Closed

mfattakhov wants to merge 3 commits into
pingdotgg:mainfrom
mfattakhov:mfattakhov/handoff-v1

Conversation

@mfattakhov

@mfattakhov mfattakhov commented Aug 28, 2026 •

Copy link
Copy Markdown

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 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 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 as title with titleSeed omitted 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 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, 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 dist for bundled builds (npx t3, desktop sidecar), and unpacked from the Electron asar archives, because the claude subprocess is a plain process and cannot read through the server's asar-aware fs. Without them the skill picker advertised t3:handoff and 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.md for behavior, docs/internals/handoff.md for 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 t3 Claude plugin exposes t3:handoff, the live agent writes a name and summary, then runs t3 handoff (summary on stdin) which POSTs /handoff on the running server using session env (T3_SERVER_ORIGIN, T3_SERVER_TOKEN, T3_CLI shim + optional asar-aware entry/runtime).

The server authenticates with the same MCP bearer token (resolveActiveMcpToken), derives the parent thread from the credential, and performHandoff creates a child in the parent project (model/modes carry; branch/worktree do not), starts a seed user turn, records handoff.created / handoff.received activities, 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 the claude subprocess can load it.

Web: work-log rows link parent/child threads; readStateBaselineAt in 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.md and docs/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 /handoff endpoint, CLI, and Claude plugin

  • Adds performHandoff in 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 fails
  • Exposes POST /handoff in HandoffHttpServer.ts authenticating via MCP session bearer token, with 400/401/404/500 error responses
  • Adds the t3 handoff CLI command in handoff.ts that reads T3_SERVER_ORIGIN/T3_SERVER_TOKEN from env, sends a summary via stdin, and prints the new thread details
  • Ships a Claude plugin manifest and handoff skill 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 server
  • UI: MessagesTimeline.tsx renders handoff lineage entries with navigation links; Sidebar.logic.ts and uiStateStore.ts add a readStateBaselineAt so never-visited threads created before baseline are treated as read
  • Risk: build-desktop-artifact.ts now unpacks apps/server/dist/claude-plugin from asar on all platforms; if the unpacked twin is missing at runtime, resolveT3ClaudePluginLocation returns undefined and the handoff skill is unavailable
📊 Macroscope summarized f7f9b83. 28 files reviewed, 4 issues evaluated, 0 issues filtered, 4 comments posted

🗂️ Filtered Issues

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.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 56bad244-9409-400b-a409-3a2752644107

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 28, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +101 to +107
Effect.mapError(
(cause) =>
new HandoffCliError({
message: `Could not reach the d server at ${origin}: ${String(cause)}`,
}),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +81 to +90
const parent = yield* projectionSnapshotQuery.getThreadShellById(input.parentThreadId).pipe(
Effect.mapError(
(cause) =>
new HandoffDispatchError({
parentThreadId: input.parentThreadId,
stage: "create",
cause,
}),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +33 to +35
export class HandoffCliError extends Schema.TaggedErrorClass<HandoffCliError>()("HandoffCliError", {
message: Schema.String,
}) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +1566 to +1570
<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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
<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}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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", {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f7f9b83. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Approvability

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

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant