Repository navigation
feat(handoff): let an agent hand its thread off to a fresh one #8492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
42cca5c
7efe229
f7f9b83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "name": "t3", | ||
| "description": "Skills T3 Code ships into every session it spawns.", | ||
| "version": "0.1.0" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #!/bin/sh | ||
| # t3 CLI shim, shipped with the T3 Code Claude plugin. Sessions have no | ||
| # installed `t3` on PATH, so T3 Code injects this shim's path as $T3_CLI. | ||
| # | ||
| # The server also injects the exact CLI entry ($T3_CLI_ENTRY) and the runtime | ||
| # to read it with ($T3_CLI_RUNTIME): in a packaged desktop app the entry lives | ||
| # inside an asar archive, which only the Electron binary can read, and it does | ||
| # so through ELECTRON_RUN_AS_NODE (a plain `node` ignores that variable). | ||
| if [ -n "$T3_CLI_ENTRY" ]; then | ||
| ELECTRON_RUN_AS_NODE=1 exec "${T3_CLI_RUNTIME:-node}" "$T3_CLI_ENTRY" "$@" | ||
| fi | ||
|
|
||
| # Fallback for sessions spawned without that env: resolve the entry relative to | ||
| # this shim, source layout first, then the bundled dist layout. | ||
| dir="$(cd "$(dirname "$0")" && pwd)" | ||
| for entry in "$dir/../../src/bin.ts" "$dir/../../bin.mjs"; do | ||
| if [ -f "$entry" ]; then | ||
| exec node "$entry" "$@" | ||
| fi | ||
| done | ||
| echo "t3 CLI entry point not found next to $dir" >&2 | ||
| exit 127 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| --- | ||
| name: handoff | ||
| description: Hand off work from this T3 Code thread to a fresh one. Use when the user asks to hand off, continue in a new thread/session, or spin the remaining work out into a follow-up. Composes a name and summary of the work so far and asks T3 Code to create, seed, and start the new thread. | ||
| --- | ||
|
|
||
| # Handoff | ||
|
|
||
| You are running inside a thread managed by T3 Code. A **handoff** transfers work to | ||
| a brand-new thread in the same project: you write the new thread's name and a | ||
| summary of the work so far, and T3 Code creates the thread, seeds it with your | ||
| summary as its first user message, and starts it immediately. The current | ||
| thread is left untouched; both threads record the lineage. | ||
|
|
||
| The user may give a focus — what the *next* session is for (e.g. "hand off | ||
| the remaining test failures"). Tailor everything to that focus. Without one, | ||
| hand off the natural continuation of the current work. | ||
|
|
||
| ## Compose the handoff | ||
|
|
||
| **Name** (a few words): what the new thread is about — it becomes the thread's | ||
| title. Name the destination, not the origin ("Fix flaky auth tests", not | ||
| "Continuation of session"). | ||
|
|
||
| **Summary** (the new thread's first user message — its agent starts cold with | ||
| nothing else): | ||
|
|
||
| - State the goal of the new thread first, then the context needed to pursue it. | ||
| - Reference, don't duplicate: point at files, commits, branches, and docs by | ||
| path/hash instead of pasting their contents. | ||
| - Include what was already tried and ruled out, current state (what's done, | ||
| what's broken, what's untested), and the concrete next steps. | ||
| - If specific skills, commands, or docs helped in this session, add a short | ||
| "Suggested skills/tools" section naming them. | ||
| - Never include secrets, tokens, or credentials — reference where they live | ||
| instead. | ||
| - Write it as instructions to a capable colleague, not as a transcript. | ||
|
|
||
| ## Run the handoff | ||
|
|
||
| Send the summary on stdin (heredoc) to the T3 Code CLI — `$T3_CLI` when set, | ||
| otherwise `t3`. Keep the quotes around `"${T3_CLI:-t3}"` exactly as written: | ||
|
|
||
| ```bash | ||
| "${T3_CLI:-t3}" handoff --name "<the name>" <<'HANDOFF_SUMMARY' | ||
| <the summary> | ||
| HANDOFF_SUMMARY | ||
| ``` | ||
|
|
||
| The command prints the new thread's title, id, and URL. Relay them to the | ||
| user as confirmation, e.g. "Handed off to **<name>** — <url>". If the command | ||
| fails, report its error output; do not retry with made-up credentials or | ||
| fabricate a confirmation. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| /** | ||
| * `d handoff` — the CLI half of agent-driven handoff (ADR 0002). | ||
| * | ||
| * Runs *inside* a d-spawned agent session: the `t3:handoff` skill has the live | ||
| * agent compose the new thread's name and summary, then invoke this command | ||
| * with the summary on stdin. It calls back to the running server using the | ||
| * env vars d injected at session spawn (`T3_SERVER_ORIGIN`, `T3_SERVER_TOKEN`) | ||
| * and prints the created thread's title, id, and URL. | ||
| * | ||
| * ```sh | ||
| * d handoff --name "Fix flaky auth tests" <<'HANDOFF_SUMMARY' | ||
| * Goal: ... | ||
| * HANDOFF_SUMMARY | ||
| * ``` | ||
| * | ||
| * @module cli/handoff | ||
| */ | ||
| import * as Config from "effect/Config"; | ||
| import * as Console from "effect/Console"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Schema from "effect/Schema"; | ||
| import { Command, Flag } from "effect/unstable/cli"; | ||
| import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http"; | ||
|
|
||
| import { | ||
| T3_SERVER_ORIGIN_ENV, | ||
| T3_SERVER_TOKEN_ENV, | ||
| HANDOFF_HTTP_PATH, | ||
| HandoffResponse, | ||
| } from "../handoff/protocol.ts"; | ||
|
|
||
| export class HandoffCliError extends Schema.TaggedErrorClass<HandoffCliError>()("HandoffCliError", { | ||
| message: Schema.String, | ||
| }) {} | ||
|
|
||
| const optionalEnv = (name: string) => | ||
| Config.string(name).pipe(Config.option, Config.map(Option.getOrUndefined)); | ||
|
|
||
| const readStdin = Effect.tryPromise({ | ||
| try: async () => { | ||
| const chunks: Array<Buffer> = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf8"); | ||
| }, | ||
| catch: () => new HandoffCliError({ message: "Failed to read the summary from stdin." }), | ||
| }); | ||
|
|
||
| const decodeHandoffResponse = Schema.decodeUnknownEffect(HandoffResponse); | ||
|
|
||
| const readErrorMessage = (body: unknown): string | undefined => { | ||
| if (body && typeof body === "object" && "message" in body) { | ||
| const message = (body as { message?: unknown }).message; | ||
| return typeof message === "string" ? message : undefined; | ||
| } | ||
| return undefined; | ||
| }; | ||
|
|
||
| export const handoffCommand = Command.make("handoff", { | ||
| name: Flag.string("name").pipe( | ||
| Flag.withDescription("Name for the new thread; becomes its title."), | ||
| ), | ||
| }).pipe( | ||
| Command.withDescription( | ||
| "Hand off work to a new thread (run inside a d-managed agent session; summary on stdin).", | ||
| ), | ||
| Command.withHandler((flags) => | ||
| Effect.gen(function* () { | ||
| const origin = yield* optionalEnv(T3_SERVER_ORIGIN_ENV); | ||
| const token = yield* optionalEnv(T3_SERVER_TOKEN_ENV); | ||
| if (!origin || !token) { | ||
| return yield* new HandoffCliError({ | ||
| message: | ||
| `Missing ${T3_SERVER_ORIGIN_ENV}/${T3_SERVER_TOKEN_ENV} — ` + | ||
| "`d handoff` only works inside a d-managed agent session.", | ||
| }); | ||
| } | ||
|
|
||
| const summary = (yield* readStdin).trim(); | ||
| if (summary.length === 0) { | ||
| return yield* new HandoffCliError({ | ||
| message: "Provide the handoff summary on stdin (e.g. via a heredoc).", | ||
| }); | ||
| } | ||
| const name = flags.name.trim(); | ||
| if (name.length === 0) { | ||
| return yield* new HandoffCliError({ message: "--name must not be empty." }); | ||
| } | ||
|
|
||
| const httpClient = yield* HttpClient.HttpClient; | ||
| const response = yield* httpClient | ||
| .post(`${origin}${HANDOFF_HTTP_PATH}`, { | ||
| headers: { | ||
| authorization: `Bearer ${token}`, | ||
| }, | ||
| body: HttpBody.jsonUnsafe({ name, summary }), | ||
| }) | ||
| .pipe( | ||
| Effect.mapError( | ||
| (cause) => | ||
| new HandoffCliError({ | ||
| message: `Could not reach the d server at ${origin}: ${String(cause)}`, | ||
| }), | ||
| ), | ||
| ); | ||
|
Comment on lines
+101
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This wrapper derives its message from Posted via Macroscope — Effect Service Conventions |
||
|
|
||
| const body: unknown = yield* response.json.pipe( | ||
| Effect.mapError( | ||
| () => | ||
| new HandoffCliError({ | ||
| message: `The d server responded with an unreadable body (HTTP ${response.status}).`, | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| if (response.status < 200 || response.status >= 300) { | ||
| return yield* new HandoffCliError({ | ||
| message: readErrorMessage(body) ?? `Handoff failed (HTTP ${response.status}).`, | ||
| }); | ||
| } | ||
|
|
||
| const result = yield* decodeHandoffResponse(body).pipe( | ||
| Effect.mapError( | ||
| () => | ||
| new HandoffCliError({ | ||
| message: "The d server returned an unexpected handoff response.", | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| yield* Console.log( | ||
| [ | ||
| `Handed off to "${result.title}"`, | ||
| `Thread: ${result.threadId}`, | ||
| `URL: ${origin}/${result.environmentId}/${result.threadId}`, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium The printed 🤖 Copy this AI Prompt to have your agent fix this: |
||
| ].join("\n"), | ||
| ); | ||
| }).pipe(Effect.provide(FetchHttpClient.layer)), | ||
| ), | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /** | ||
| * HandoffHttpServer — `POST /handoff`, the server side of the `d handoff` | ||
| * CLI callback. | ||
| * | ||
| * Authenticates with the same per-session bearer credential the MCP transport | ||
| * uses (`McpSessionRegistry`): the token was injected into the session's | ||
| * environment at spawn, it expires with the session, and it resolves to the | ||
| * thread it was issued for — so the parent thread id for lineage comes from | ||
| * the credential itself and cannot be spoofed by the calling process. | ||
| * | ||
| * @module handoff/HandoffHttpServer | ||
| */ | ||
| import * as Data from "effect/Data"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Schema from "effect/Schema"; | ||
| import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
|
|
||
| import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; | ||
| import { performHandoff } from "./HandoffService.ts"; | ||
| import { HANDOFF_HTTP_PATH, HandoffRequest, type HandoffResponse } from "./protocol.ts"; | ||
|
|
||
| const unauthorized = HttpServerResponse.jsonUnsafe( | ||
| { | ||
| error: "invalid_handoff_credential", | ||
| message: "A valid session-scoped bearer credential is required.", | ||
| }, | ||
| { | ||
| status: 401, | ||
| headers: { | ||
| "cache-control": "no-store", | ||
| "www-authenticate": "Bearer", | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| class InvalidHandoffRequestError extends Data.TaggedError("InvalidHandoffRequestError")<{ | ||
| readonly cause: unknown; | ||
| }> {} | ||
|
|
||
| const decodeHandoffRequest = Schema.decodeUnknownEffect(HandoffRequest); | ||
|
|
||
| export const handoffRouteLayer = HttpRouter.add( | ||
| "POST", | ||
| HANDOFF_HTTP_PATH, | ||
| Effect.gen(function* () { | ||
| const request = yield* HttpServerRequest.HttpServerRequest; | ||
| const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
|
|
||
| const authorization = request.headers.authorization; | ||
| const token = | ||
| authorization?.startsWith("Bearer ") === true | ||
| ? authorization.slice("Bearer ".length).trim() | ||
| : ""; | ||
| const invocation = yield* McpSessionRegistry.resolveActiveMcpToken(token); | ||
| if (!invocation) return unauthorized; | ||
|
|
||
| const payload = yield* request.json.pipe( | ||
| Effect.flatMap((body) => decodeHandoffRequest(body)), | ||
| Effect.mapError((cause) => new InvalidHandoffRequestError({ cause })), | ||
| ); | ||
|
|
||
| const result = yield* performHandoff({ | ||
| parentThreadId: invocation.threadId, | ||
| request: payload, | ||
| }); | ||
| const environmentId = yield* serverEnvironment.getEnvironmentId; | ||
|
|
||
| return HttpServerResponse.jsonUnsafe({ | ||
| threadId: result.threadId, | ||
| environmentId, | ||
| title: result.title, | ||
| } satisfies HandoffResponse); | ||
| }).pipe( | ||
| Effect.catchTags({ | ||
| InvalidHandoffRequestError: () => | ||
| Effect.succeed( | ||
| HttpServerResponse.jsonUnsafe( | ||
| { | ||
| error: "invalid_request", | ||
| message: "Body must be JSON with non-empty `name` and `summary`.", | ||
| }, | ||
| { status: 400 }, | ||
| ), | ||
| ), | ||
| HandoffParentNotFoundError: (error) => | ||
| Effect.succeed( | ||
| HttpServerResponse.jsonUnsafe( | ||
| { | ||
| error: "parent_thread_not_found", | ||
| message: `Thread ${error.parentThreadId} no longer exists or is archived.`, | ||
| }, | ||
| { status: 404 }, | ||
| ), | ||
| ), | ||
| HandoffDispatchError: (error) => | ||
| Effect.logError("handoff dispatch failed", { cause: error }).pipe( | ||
| Effect.as( | ||
| HttpServerResponse.jsonUnsafe( | ||
| { | ||
| error: "handoff_failed", | ||
| message: `Handoff failed while dispatching (${error.stage}).`, | ||
| }, | ||
| { status: 500 }, | ||
| ), | ||
| ), | ||
| ), | ||
| }), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HandoffCliErrorstores an unstructuredmessageas its only attribute, and seven semantically distinct failures are funnelled through it with baked-in strings and nocause: 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 anoverride get message()derived from them, and a realcause: Schema.Defect()wherever an underlying failure is being wrapped. For example:The existing user-facing strings can be preserved verbatim in the getters, so CLI output does not change.
Posted via Macroscope — Effect Service Conventions