Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/server/claude-plugin/.claude-plugin/plugin.json
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"
}
22 changes: 22 additions & 0 deletions apps/server/claude-plugin/bin/t3
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
52 changes: 52 additions & 0 deletions apps/server/claude-plugin/skills/handoff/SKILL.md
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.
12 changes: 12 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ const buildCmd = Command.make(
} else {
yield* Effect.logWarning("[cli] Web dist not found — skipping client bundle.");
}

// The t3 Claude plugin is resolved at runtime from dist/claude-plugin next
// to the bundle (see resolveT3ClaudePluginLocation); without this copy bundled
// builds silently lose the t3:handoff skill.
const pluginSource = path.join(serverDir, "claude-plugin");
const pluginTarget = path.join(serverDir, "dist/claude-plugin");
if (yield* fs.exists(pluginSource)) {
yield* fs.copy(pluginSource, pluginTarget);
yield* Effect.log("[cli] Bundled t3 Claude plugin into dist/claude-plugin");
} else {
yield* Effect.logWarning("[cli] Claude plugin not found — skipping plugin bundle.");
}
}),
).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pairCommand } from "./cli/pair.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { sharedServerCommandFlags } from "./cli/config.ts";
import { isEntrypoint } from "./entrypoint.ts";
import { handoffCommand } from "./cli/handoff.ts";
import { projectCommand } from "./cli/project.ts";
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { serviceCommand } from "./cli/service.ts";
Expand Down Expand Up @@ -59,6 +60,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
servicePreflightCommand,
triageCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
handoffCommand,
]),
);

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
].join("\n");
}

const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
const CLOUT3_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);

const confirmRelayClientInstall = (version: string) =>
Prompt.run(
Expand Down Expand Up @@ -301,7 +301,7 @@ const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
Effect.flatMap((client) =>
client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }),
),
Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT),
Effect.timeout(CLOUT3_CLI_LIVE_SERVER_TIMEOUT),
),
),
);
Expand Down
142 changes: 142 additions & 0 deletions apps/server/src/cli/handoff.ts
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,
}) {}
Comment on lines +33 to +35

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


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

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


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

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.

].join("\n"),
);
}).pipe(Effect.provide(FetchHttpClient.layer)),
),
);
110 changes: 110 additions & 0 deletions apps/server/src/handoff/HandoffHttpServer.ts
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 },
),
),
),
}),
),
);
Loading
Loading