,
-) =>
- declare((params: P) =>
- Effect.gen(function* () {
- const { limits } = yield* writingCaller;
- const requested = modes(params);
- const started: StartedModes = {
- runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode),
- interactionMode: yield* resolveInteractionMode(
- limits.interactionMode,
- requested.interactionMode,
- ),
- };
- return yield* handle(params, started);
- }),
+) => {
+ const started = Effect.fn("McpToolAccess.started")(function* (params: P) {
+ const { limits } = yield* writingCaller;
+ const requested = modes(params);
+ return {
+ runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode),
+ interactionMode: yield* resolveInteractionMode(
+ limits.interactionMode,
+ requested.interactionMode,
+ ),
+ } satisfies StartedModes;
+ });
+ return declare(
+ (params: P) => Effect.flatMap(started(params), (modes) => handle(params, modes)),
+ started,
);
+};
const fullAccessRequired =
"Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";
@@ -209,7 +247,10 @@ export const writesEnvironment = (
const check = orchestratingCaller.pipe(
Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)),
);
- return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check))));
+ return declare(
+ (params: P) => check.pipe(Effect.flatMap(() => handle(params, check))),
+ () => check,
+ );
};
type CheckServices = Effect.Services;
@@ -246,14 +287,73 @@ export class HandlersLayer, EX = never, R
const checkedHandlers = (declarations: Declarations): Handlers =>
Struct.map(declarations, checkedHandler);
+/**
+ * A tool runs in a linked environment when its parameters take this target
+ * as `environmentId`. Other tools may name an environment for their own
+ * reasons, such as the one to link or to move a thread to.
+ */
+const takesLinkedEnvironment = (tool: Tool.Any) => {
+ const parameters: unknown = tool.parametersSchema;
+ return (
+ Predicate.hasProperty(parameters, "fields") &&
+ Predicate.hasProperty(parameters.fields, "environmentId") &&
+ parameters.fields.environmentId === OrchestratorMcpEnvironmentTarget
+ );
+};
+
+const linksUnavailable = new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: "This environment cannot reach linked environments.",
+});
+
+/**
+ * Every tool that takes a linked environment runs there the same way: its
+ * declaration checks the caller here, and the same call goes to the other
+ * environment, which checks the rest. The handler here never sees
+ * `environmentId`, so it cannot leak into what it writes.
+ */
+const routed = Effect.fn("McpToolAccess.routed")(function* >(
+ toolkit: Toolkit.Toolkit,
+ declarations: Handlers,
+ checked: Toolkit.HandlersFrom,
+) {
+ // Optional so toolkits that never leave this environment build without it.
+ const forwarding = yield* Effect.serviceOption(PeerForwarding.PeerForwarding);
+ const handlers: Record = { ...checked };
+ for (const [name, declaration] of Object.entries>(declarations)) {
+ const tool = toolkit.tools[name];
+ if (tool === undefined || !takesLinkedEnvironment(tool)) continue;
+ const remote = remoteCheckOf(declaration);
+ if (remote === undefined)
+ return yield* Effect.die(
+ `${name} takes a linked environment, but it acts as its calling thread, which is here.`,
+ );
+ const here = handlers[name] as (
+ params: object,
+ ) => Effect.Effect>;
+ handlers[name] = ({ environmentId, ...params }: { readonly environmentId?: EnvironmentId }) =>
+ Effect.gen(function* () {
+ const scope = yield* McpInvocationContext.McpInvocationContext;
+ const target = PeerForwarding.remoteTarget(scope, environmentId);
+ if (target === undefined) return yield* here(params);
+ yield* (remote as RemoteCheck)(params);
+ if (Option.isNone(forwarding)) return yield* linksUnavailable;
+ return yield* forwarding.value.call(scope, tool, target, { ...params, environmentId });
+ });
+ }
+ return handlers as Toolkit.HandlersFrom;
+});
+
export const toLayer = , EX = never, RX = never>(
toolkit: Toolkit.Toolkit,
build: Handlers | Effect.Effect, EX, RX>,
-): HandlersLayer> =>
- handlersLayer(
+): HandlersLayer> => {
+ // Checked as soon as they are given, so a forged declaration fails here.
+ const withDeclarations = (declarations: Handlers) =>
+ routed(toolkit, declarations, checkedHandlers(declarations));
+ return handlersLayer(
toolkit.toLayer(
- Effect.isEffect(build)
- ? Effect.map(build, checkedHandlers>)
- : checkedHandlers(build),
+ Effect.isEffect(build) ? Effect.flatMap(build, withDeclarations) : withDeclarations(build),
),
);
+};
diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts
index edaa8777b422e..9deb8aac21629 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.ts
@@ -104,7 +104,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
import {
- clientRuntimeModeCeiling,
+ clientModeCeiling,
type McpInvocationScope,
type McpThreadInvocationScope,
requireThreadScope,
@@ -1158,10 +1158,7 @@ const make = Effect.gen(function* () {
if (scope.thread === undefined) {
return {
parent: undefined,
- limits: {
- runtimeMode: clientRuntimeModeCeiling(scope.client),
- interactionMode: "default",
- } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode },
+ limits: clientModeCeiling(scope.client),
} as const;
}
const parent = yield* loadProjection(scope.thread.threadId);
@@ -2896,6 +2893,54 @@ const make = Effect.gen(function* () {
yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
const key = yield* requestKey(input.clientRequestId);
+ // A peer from before `stop` drops it when decoding, so it interrupts
+ // the run instead, as this call did then.
+ if (input.stop === true) {
+ const commandId = stableCommandId({ scope, requestKey: key, operation: "thread-stop" });
+ // The cause goes to the log; the caller learns only that the stop failed.
+ const stopFailed = (error: unknown) =>
+ Effect.logWarning("orchestrator-mcp.thread-stop-failed", {
+ threadId: input.threadId,
+ error,
+ }).pipe(
+ Effect.andThen(
+ Effect.fail(
+ failure("orchestration_error", `Unable to stop thread ${input.threadId}.`),
+ ),
+ ),
+ );
+ const stopped = yield* threadManagement
+ .dispatch({
+ type: "thread.stop",
+ commandId,
+ threadId: input.threadId,
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
+ })
+ .pipe(Effect.catch(stopFailed));
+ yield* threadManagement
+ .stopDelegatedTasks({ threadId: input.threadId, commandId, reason: input.reason })
+ .pipe(Effect.catch(stopFailed));
+ // Queue holds and completion disposal also update runs. Only the
+ // original interrupt request identifies the run Stop reached.
+ const request = stopped.storedEvents.find(
+ (stored) =>
+ stored.event.type === "turn-item.updated" &&
+ stored.event.payload.type === "run_interrupt_request" &&
+ (stored.event.payload.providerTurnId !== null ||
+ stopped.storedEvents.some(
+ (updated) =>
+ updated.event.type === "run.updated" &&
+ updated.event.runId === stored.event.runId &&
+ updated.event.payload.status === "interrupted",
+ )),
+ );
+ const interrupted = request?.event.runId;
+ return {
+ threadId: input.threadId,
+ runId: interrupted ?? null,
+ status: interrupted === undefined ? "no_active_run" : "interrupt_requested",
+ } satisfies OrchestratorMcpThreadInterruptResult;
+ }
const result = yield* threadManagement
.interruptThread({
projectId: target.thread.projectId,
diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
index 09b4bd4f424d7..88e590f9bc3b2 100644
--- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
+++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
@@ -46,6 +46,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
+import * as PeerForwarding from "../peer/PeerForwarding.ts";
const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
@@ -220,6 +221,7 @@ describe("orchestrator MCP toolkit", () => {
Layer.provide(layerProviderRegistry),
Layer.provide(ServerSettings.layerTest()),
Layer.provide(layerUnusedScheduledTaskStub),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provideMerge(
SecretRequests.layer.pipe(
diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts
index ba8dc4c3e5765..9cc010a02cb88 100644
--- a/apps/server/src/mcp/threadAccess.ts
+++ b/apps/server/src/mcp/threadAccess.ts
@@ -68,10 +68,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () {
scope,
threads,
caller: undefined,
- limits: {
- runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client),
- interactionMode: "default",
- },
+ limits: McpInvocationContext.clientModeCeiling(scope.client),
} satisfies Caller;
}
const caller = yield* threads
diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts
index 02216cec576de..c5031b77423c7 100644
--- a/apps/server/src/mcp/toolkits/core.test.ts
+++ b/apps/server/src/mcp/toolkits/core.test.ts
@@ -51,6 +51,7 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
+import * as PeerForwarding from "../../peer/PeerForwarding.ts";
const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
@@ -71,6 +72,7 @@ const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadR
const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
);
const threadId = ThreadId.make("mcp-core-thread");
@@ -542,6 +544,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
),
@@ -597,6 +600,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
),
),
),
@@ -679,6 +683,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
),
@@ -771,6 +776,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
),
diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
index 0df604a850110..d996026b9bf41 100644
--- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
+++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
@@ -2,6 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts";
import { OrchestratorToolkit } from "./tools.ts";
import * as Effect from "effect/Effect";
+import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import { readCaller } from "../../threadAccess.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
@@ -140,6 +141,13 @@ const handlers = {
return yield* service.waitForThread(scope, input);
}),
),
+ t3_environment_links: McpToolAccess.reads(() =>
+ Effect.gen(function* () {
+ const scope = yield* McpInvocationContext.McpInvocationContext;
+ const forwarding = yield* PeerForwarding.PeerForwarding;
+ return yield* forwarding.links(scope);
+ }),
+ ),
t3_thread_interrupt: McpToolAccess.writesThreads(
(input) => [input.threadId],
(input) =>
diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts
index 567cb92727326..0a15244b0bb15 100644
--- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts
+++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts
@@ -2,6 +2,7 @@ import {
OrchestratorMcpCapabilitiesInput,
OrchestratorMcpCapabilitiesResult,
OrchestrationV2StopWorkflowInput,
+ OrchestratorMcpEnvironmentLinksResult,
OrchestratorMcpCreateThreadsInput,
OrchestratorMcpCreateThreadsResult,
OrchestratorMcpDelegateTaskInput,
@@ -36,6 +37,7 @@ import { Tool, Toolkit } from "effect/ai";
import * as Schema from "effect/Schema";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
@@ -45,6 +47,8 @@ const dependencies = [
ThreadManagementService.ThreadManagementService,
OrchestratorMcpService.OrchestratorMcpService,
];
+/** Listing this environment's links. */
+const linkDependencies = [...dependencies, PeerForwarding.PeerForwarding];
const threadMetadataDependencies = [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
@@ -53,7 +57,7 @@ const threadMetadataDependencies = [
const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
description:
- "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.",
+ "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy. Pass environmentId to list a linked environment's providers instead.",
parameters: OrchestratorMcpCapabilitiesInput,
success: OrchestratorMcpCapabilitiesResult,
failure: OrchestratorMcpFailure,
@@ -194,7 +198,7 @@ const CreateThreadsTool = Tool.make("create_threads", {
const ThreadListTool = Tool.make("t3_thread_list", {
description:
- "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
+ "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Pass environmentId to list a linked environment's threads; projectId is then required. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.",
parameters: OrchestratorMcpThreadListInput,
success: OrchestratorMcpThreadListResult,
failure: OrchestratorMcpFailure,
@@ -208,7 +212,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
const ThreadReadTool = Tool.make("t3_thread_read", {
description:
- "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.",
+ "Read durable state and a paginated timeline from any T3 thread in this environment, or in a linked one with environmentId. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.",
parameters: OrchestratorMcpThreadReadInput,
success: OrchestratorMcpThreadReadResult,
failure: OrchestratorMcpFailure,
@@ -236,7 +240,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", {
const ThreadSendTool = Tool.make("t3_thread_send", {
description:
- "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
+ "Send a message to any T3 thread in this environment, or in a linked one with environmentId. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
parameters: OrchestratorMcpThreadSendInput,
success: OrchestratorMcpThreadSendResult,
failure: OrchestratorMcpFailure,
@@ -249,7 +253,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", {
const ThreadWaitTool = Tool.make("t3_thread_wait", {
description:
- "Wait for a T3 thread run to reach a terminal durable state. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.",
+ "Wait for a T3 thread run to reach a terminal durable state, here or in a linked environment with environmentId. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.",
parameters: OrchestratorMcpThreadWaitInput,
success: OrchestratorMcpThreadWaitResult,
failure: OrchestratorMcpFailure,
@@ -263,7 +267,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", {
const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {
description:
- "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.",
+ "Request interruption of a running turn in any T3 thread in this environment, or in a linked one with environmentId. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. stop=true stops the thread as its Stop button does instead: it also holds queued messages, ends pull request watches and stops the tasks it delegated. clientRequestId makes retries idempotent.",
parameters: OrchestratorMcpThreadInterruptInput,
success: OrchestratorMcpThreadInterruptResult,
failure: OrchestratorMcpFailure,
@@ -273,6 +277,19 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {
.annotate(Tool.Title, "Interrupt a T3 thread")
.annotate(Tool.Destructive, true);
+const EnvironmentLinksTool = Tool.make("t3_environment_links", {
+ description:
+ "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_thread_wait, t3_thread_read or t3_project_list, to act there. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.",
+ success: OrchestratorMcpEnvironmentLinksResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: linkDependencies,
+})
+ .annotate(Tool.Title, "List linked environments")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true);
+
export const OrchestratorToolkit = Toolkit.make(
OrchestratorCapabilitiesTool,
DelegateTaskTool,
@@ -291,4 +308,5 @@ export const OrchestratorToolkit = Toolkit.make(
ThreadSendTool,
ThreadWaitTool,
ThreadInterruptTool,
+ EnvironmentLinksTool,
);
diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts
index 4c22393bf4761..01de75a5bba32 100644
--- a/apps/server/src/mcp/toolkits/project/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts
@@ -22,6 +22,7 @@ import * as ServerConfig from "../../../config.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
+import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../../../vcs/VcsProcess.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
@@ -72,6 +73,7 @@ const clientLaunchHarness = (input: {
}),
Layer.mock(ThreadManagement.ThreadManagementService)({}),
Layer.mock(ThreadLaunch.ThreadLaunchService)({
+ getAcceptedLaunch: () => Effect.succeed(Option.none()),
checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void),
launch: (launch) => {
input.launched.push(launch);
@@ -101,6 +103,7 @@ const clientLaunchHarness = (input: {
: Option.none(),
),
}),
+ Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
NodeServices.layer,
).pipe(
@@ -197,6 +200,44 @@ it.effect(
}),
);
+it.effect("a launch retried with the same clientRequestId replays the first one", () =>
+ Effect.gen(function* () {
+ const launched: Array = [];
+ const { projectId, dependencies } = clientLaunchHarness({
+ runtimeModeCeiling: "auto",
+ launched,
+ });
+ const toolkit = yield* ProjectToolkit.pipe(
+ Effect.provide(
+ McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
+ ),
+ );
+ const launch = (clientRequestId: string | undefined) =>
+ toolkit
+ .handle("t3_thread_launch", {
+ title: "Fix",
+ projectId,
+ message: "Fix the bug",
+ ...(clientRequestId === undefined ? {} : { clientRequestId }),
+ })
+ .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
+
+ yield* launch("fix-1");
+ yield* launch("fix-1");
+ yield* launch("fix-2");
+ yield* launch(undefined);
+ yield* launch(undefined);
+ const ids = launched.map((input) => [input.commandId, input.threadId]);
+ // The same key sends the same command and thread ids, which the launch
+ // service replays instead of starting a second thread.
+ expect(ids[0]).toEqual(ids[1]);
+ expect(ids[0]?.[0]).toContain("client%3Asession-1");
+ expect(ids[2]).not.toEqual(ids[0]);
+ // Without a key every launch is new.
+ expect(ids[3]).not.toEqual(ids[4]);
+ }),
+);
+
it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts
index 380a1b52ec963..168b16293e654 100644
--- a/apps/server/src/mcp/toolkits/project/handlers.ts
+++ b/apps/server/src/mcp/toolkits/project/handlers.ts
@@ -1,4 +1,10 @@
-import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
+import {
+ CommandId,
+ MessageId,
+ ThreadId,
+ OrchestratorMcpFailure,
+ ProjectId,
+} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
@@ -12,6 +18,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts";
import { ProjectToolkit } from "./tools.ts";
@@ -58,9 +65,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
(input) => input,
(input, { runtimeMode, interactionMode }) =>
Effect.gen(function* () {
+ const scope = yield* McpInvocationContext.McpInvocationContext;
const context = yield* readCaller();
const { caller } = context;
- const commandId = yield* newCommandId();
+ // A retry with the same key replays the first launch: the ids derive
+ // from the caller and the key, and the launch service replays a
+ // command it already accepted.
+ const commandId =
+ input.clientRequestId === undefined
+ ? yield* newCommandId()
+ : CommandId.make(
+ `mcp:launch:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(input.clientRequestId)}`,
+ );
const threadId = ThreadId.make(commandId);
const messageId = MessageId.make(commandId);
const attachments = input.attachments ?? [];
@@ -91,6 +107,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
),
)).projectId
: yield* resolveProjectId(context, input.projectId);
+ // Accepted creates skip mutable base/default-model preflights.
+ // Message intake still handles their replay and preparation.
+ const acceptedLaunch =
+ input.clientRequestId === undefined
+ ? undefined
+ : Option.getOrUndefined(
+ yield* ThreadLaunch.ThreadLaunchService.pipe(
+ Effect.flatMap((launches) =>
+ launches.getAcceptedLaunch({ commandId, threadId, projectId }),
+ ),
+ Effect.mapError(unavailable),
+ ),
+ );
const readProject = Project.ProjectService.pipe(
Effect.flatMap((projects) => projects.getById(projectId)),
Effect.mapError(unavailable),
@@ -108,7 +137,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
// A launch is accepted before its worktree is provisioned, so a base
// ref that cannot resolve is refused while the agent can still act on it.
const strategy = input.workspaceStrategy;
- if (strategy?.type === "worktree")
+ if (acceptedLaunch === undefined && strategy?.type === "worktree")
yield* ThreadLaunch.ThreadLaunchService.pipe(
Effect.flatMap((launches) =>
launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }),
@@ -142,6 +171,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
)
: undefined;
const modelSelection =
+ acceptedLaunch?.modelSelection ??
input.modelSelection ??
caller?.modelSelection ??
projectDefaults?.defaultModelSelection ??
diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts
index 2114565d8201e..38c620d0d1e72 100644
--- a/apps/server/src/mcp/toolkits/project/tools.ts
+++ b/apps/server/src/mcp/toolkits/project/tools.ts
@@ -13,6 +13,8 @@ import {
ProjectCreatePayload,
ProjectUpdatePayload,
ProjectId,
+ OrchestratorMcpClientRequestId,
+ OrchestratorMcpEnvironmentTarget,
OrchestratorMcpFailure,
SourceControlCloneRepositoryInput,
SourceControlCloneRepositoryResult,
@@ -45,8 +47,9 @@ const shared = {
const ProjectListTool = Tool.make("t3_project_list", {
...shared,
description:
- "List registered projects in this environment. Pages use the current project snapshot and may shift between calls.",
+ "List registered projects in this environment, or in a linked one with environmentId. Pages use the current project snapshot and may shift between calls.",
parameters: Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
cursor: Schema.optional(NonNegativeInt),
limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))),
}),
@@ -61,7 +64,10 @@ const ProjectReadTool = Tool.make("t3_project_read", {
...shared,
description:
"Read a registered project in this environment, including its workspace and saved scripts.",
- parameters: Schema.Struct({ projectId: ProjectId }),
+ parameters: Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
+ projectId: ProjectId,
+ }),
})
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false);
@@ -104,8 +110,9 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
const ThreadLaunchTool = Tool.make("t3_thread_launch", {
...shared,
description:
- 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
+ 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Name a thread in a linked environment by title instead: its link would open here. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with. Pass environmentId to launch in a linked environment; projectId and modelSelection then name that environment\'s project and providers (t3_project_list and orchestrator_capabilities with the same environmentId list them), and attachments are not supported.',
parameters: Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
projectId: Schema.optional(ProjectId),
scratch: Schema.optional(
Schema.Boolean.annotate({
@@ -130,6 +137,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
}),
),
attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))),
+ clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
}),
success: Schema.Struct({
threadId: ThreadId,
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index 3f3942e75d082..43554490dc989 100644
--- a/apps/server/src/observability/RpcInstrumentation.ts
+++ b/apps/server/src/observability/RpcInstrumentation.ts
@@ -59,6 +59,9 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverGetStorageCleanupReport]: "server",
[WS_METHODS.serverGetDatabaseStorage]: "server",
[WS_METHODS.serverGetSettings]: "server",
+ [WS_METHODS.peerLinksList]: "peerLinks",
+ [WS_METHODS.peerLinksLink]: "peerLinks",
+ [WS_METHODS.peerLinksUnlink]: "peerLinks",
[WS_METHODS.serverUpdateSettings]: "server",
[WS_METHODS.serverSearchAcpRegistry]: "server",
[WS_METHODS.serverPrepareAcpRegistryAgent]: "server",
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 49b344961b3fb..962b574890c43 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -1467,6 +1467,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__t3_thread_search",
"mcp__t3-code__t3_preview_list",
"mcp__t3-code__t3_environment_read",
+ "mcp__t3-code__t3_environment_links",
"mcp__t3-code__t3_queue_list",
"mcp__t3-code__t3_queue_read",
"mcp__t3-code__html_preview",
diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts
index bf50bbc8395ee..680efcd594c45 100644
--- a/apps/server/src/orchestration-v2/EventSink.ts
+++ b/apps/server/src/orchestration-v2/EventSink.ts
@@ -199,6 +199,11 @@ export interface EventSinkV2Shape {
readonly latestSequence: (input?: {
readonly threadId?: ThreadId;
}) => Effect.Effect;
+ readonly read: (input: {
+ readonly threadId: ThreadId;
+ readonly throughSequence: number;
+ readonly eventType: OrchestrationV2DomainEvent["type"];
+ }) => Stream.Stream;
readonly readByCommandId: (input: {
readonly commandId: CommandId;
}) => Stream.Stream;
@@ -982,6 +987,14 @@ const layerBase: Layer.Layer<
}),
),
),
+ read: (input) =>
+ eventStore
+ .read(input)
+ .pipe(
+ Stream.mapError(
+ (cause) => new EventSinkStreamError({ threadId: input.threadId, cause }),
+ ),
+ ),
readByCommandId: (input) =>
eventStore.readByCommandId(input).pipe(
Stream.mapError(
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index 8c8620088fa94..182070c44e36c 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -383,6 +383,12 @@ export interface OrchestratorV2Shape {
readonly getThreadEventSequence: (
threadId: ThreadId,
) => Effect.Effect;
+ /** Committed history at a fixed sequence, without subscribing to live events. */
+ readonly readStoredEvents: (input: {
+ readonly threadId: ThreadId;
+ readonly throughSequence: number;
+ readonly eventType: OrchestrationV2DomainEvent["type"];
+ }) => Stream.Stream;
readonly streamStoredEvents: Stream.Stream;
readonly streamStoredEventsFrom: (input?: {
readonly threadId?: ThreadId;
@@ -574,6 +580,15 @@ function hasLiveRun(projection: Pick):
);
}
+/** A queued run cancelled before it started owns no work, so Stop never reaches it. */
+function latestDequeuedRun(
+ runs: ReadonlyArray,
+): OrchestrationV2Run | undefined {
+ return runs.findLast(
+ (run) => run.status !== "queued" && !(run.status === "cancelled" && run.startedAt === null),
+ );
+}
+
function withPullRequestWatch(
link: ThreadPullRequestLink,
watch: ThreadPullRequestWatch | undefined,
@@ -9576,7 +9591,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
? undefined
: projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId);
const hasBackgroundWork =
- run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id &&
+ run?.id === latestDequeuedRun(projection.runs)?.id &&
derivePendingBackgroundWork({
latestRun: run,
providerThreads: projection.providerThreads,
@@ -10047,7 +10062,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
);
const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* (
- command: Extract,
+ // Client Stops fit this internal shape without its retry bound.
+ command: Extract,
events: Ref.Ref>,
effects: Ref.Ref>,
) {
@@ -10062,7 +10078,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
),
);
if (projection.thread.deletedAt !== null) return undefined;
- const latestRun = projection.runs.findLast((run) => run.status !== "queued");
+ const latestRun = latestDequeuedRun(projection.runs);
+ if (command.throughSequence !== undefined && latestRun !== undefined) {
+ // A queued run already belonged to Stop even if it started before the delegated walk.
+ const existed = yield* eventSink
+ .read({
+ threadId: command.threadId,
+ throughSequence: command.throughSequence,
+ eventType: "run.created",
+ })
+ .pipe(
+ Stream.runFold(
+ () => false,
+ (found, stored) => found || stored.event.runId === latestRun.id,
+ ),
+ Effect.mapError(
+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
+ ),
+ );
+ if (!existed) return undefined;
+ // Partial retries keep queue/watch changes thread-wide while the original run is current.
+ }
const target =
projection.runs.findLast(
(run) =>
@@ -11619,6 +11655,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}),
),
);
+ if (
+ command.type === "message.dispatch" &&
+ command.dispatchMode?.type === "defer_start" &&
+ (yield* DispatchModeLimit) !== undefined
+ ) {
+ const runCreated = storedEvents.find(
+ (stored) => stored.event.type === "run.created",
+ )?.event;
+ if (runCreated?.type === "run.created") {
+ const current = yield* projectionStore
+ .getThreadRecords(command.threadId, ["runs"])
+ .pipe(
+ Effect.mapError(
+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
+ ),
+ );
+ // Replaying this receipt can resume workspace preparation, so it must
+ // respect the caller's current ceiling just like a fresh dispatch.
+ if (
+ current.runs.some(
+ (run) => run.id === runCreated.payload.id && run.status === "preparing",
+ )
+ ) {
+ yield* refuseAboveDispatchModeLimit(command, command.threadId, current.thread);
+ }
+ }
+ }
if (command.type === "queue.resume" || command.type === "queued-run.start") {
yield* mapDispatchError(command)(
startNextQueuedRun(
@@ -11703,7 +11766,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// A settle that ended nothing still records its receipt: a replayed Stop
// effect then finds it instead of settling work that appeared since.
const resultSequence = yield* Effect.gen(function* () {
- const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) });
+ // Stop receipts also bound other threads; other commands keep their thread cursor.
+ const sequence = yield* eventSink.latestSequence(
+ command.type === "thread.stop" ? undefined : { threadId: commandThreadId(command) },
+ );
yield* commandReceipts.insertIfAbsent({
commandId: command.commandId,
threadId: commandThreadId(command),
@@ -12168,6 +12234,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
eventSink
.latestSequence({ threadId })
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))),
+ readStoredEvents: (input) =>
+ eventSink
+ .read(input)
+ .pipe(Stream.mapError((cause) => new OrchestratorDomainEventStreamError({ cause }))),
streamStoredEvents: eventSink.stream().pipe(
Stream.mapError(
(cause) =>
@@ -12324,6 +12394,12 @@ const layerUnavailable: Layer.Layer = Layer.succeed(
cause: "Orchestration V2 live runtime is not configured.",
}),
),
+ readStoredEvents: () =>
+ Stream.fail(
+ new OrchestratorDomainEventStreamError({
+ cause: "Orchestration V2 live runtime is not configured.",
+ }),
+ ),
streamStoredEventsFrom: () =>
Stream.fail(
new OrchestratorDomainEventStreamError({
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
index 0391412fe5f9b..842889df21b8b 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
@@ -14,6 +14,7 @@ import {
ComposerContextId,
type ChatAttachment,
CommandId,
+ EnvironmentId,
DEFAULT_SERVER_SETTINGS,
GitCommandError,
MessageId,
@@ -58,6 +59,10 @@ import * as EffectOutbox from "./EffectOutbox.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ThreadLaunch from "./ThreadLaunchService.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import * as McpToolAccess from "../mcp/McpToolAccess.ts";
+import * as ProjectHandlers from "../mcp/toolkits/project/handlers.ts";
+import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ThreadManagement from "./ThreadManagementService.ts";
import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";
@@ -113,6 +118,7 @@ const claudeAdapter = {
} as ProviderAdapter.ProviderAdapterV2["Service"];
interface HarnessOptions {
+ readonly getProject?: ProjectService.ProjectService["Service"]["getById"];
readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"];
readonly adapters?: ReadonlyArray;
readonly managedFolders?: Layer.Layer;
@@ -178,14 +184,16 @@ function makeHarness(options: HarnessOptions = {}) {
bootstrap: () => Effect.die("unused"),
update: () => Effect.die("unused"),
delete: () => Effect.die("unused"),
- getById: (id) =>
- Effect.succeed(
- id === projectId
- ? Option.some(project)
- : id === otherProjectId
- ? Option.some(otherProject)
- : Option.none(),
- ),
+ getById:
+ options.getProject ??
+ ((id) =>
+ Effect.succeed(
+ id === projectId
+ ? Option.some(project)
+ : id === otherProjectId
+ ? Option.some(otherProject)
+ : Option.none(),
+ )),
getByWorkspaceRoot: () => Effect.succeed(Option.some(project)),
snapshot: Effect.die("unused"),
getShell: () => Effect.die("unused"),
@@ -1386,6 +1394,180 @@ it.effect("replays a server-allocated launch", () =>
}),
);
+it.effect("MCP replays accepted launches and checks worktree membership", () =>
+ Effect.gen(function* () {
+ const setupEntered = yield* Deferred.make();
+ const allowSetup = yield* Deferred.make();
+ let changed = false;
+ const harness = makeHarness({
+ getProject: (id) =>
+ Effect.sync(() =>
+ id === projectId
+ ? Option.some({ ...project, defaultModelSelection: changed ? null : modelSelection })
+ : id === otherProjectId
+ ? Option.some(otherProject)
+ : Option.none(),
+ ),
+ hasCommit: ({ refName }) => Effect.sync(() => refName === "HEAD" || !changed),
+ git: { hasRefNamed: () => Effect.succeed(false) },
+ runSetup: () =>
+ Deferred.succeed(setupEntered, undefined).pipe(
+ Effect.andThen(Deferred.await(allowSetup)),
+ Effect.as({ status: "no-script" as const }),
+ ),
+ });
+ const context = McpInvocationContext.McpInvocationContext.of({
+ environmentId: EnvironmentId.make("launch-retry"),
+ requestNamespace: "client:launch-retry",
+ thread: undefined,
+ client: { sessionId: "launch-retry", label: "Test", access: "full-access" },
+ issuedAt: 0,
+ capabilities: new Set(["orchestration" as const]),
+ });
+ const dependencies = Layer.mergeAll(
+ harness.layer,
+ NodeCrypto.layer,
+ NodeServices.layer,
+ Layer.succeed(McpInvocationContext.McpInvocationContext, context),
+ Layer.mock(GitVcsDriver.GitVcsDriver)({
+ listWorktreePaths: () => Effect.succeed([project.workspaceRoot, "/repo-worktrees/feature"]),
+ }),
+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-launch-retry-" }).pipe(
+ Layer.provide(NodeServices.layer),
+ ),
+ );
+ yield* Effect.gen(function* () {
+ const toolkit = yield* ProjectToolkit.pipe(
+ Effect.provide(McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer)),
+ );
+ const input = {
+ title: "Only once",
+ projectId,
+ clientRequestId: "lost-response",
+ runtimeMode: "approval-required" as const,
+ message: "Do the work",
+ workspaceStrategy: { type: "worktree" as const, baseRef: "temporary-base" },
+ };
+ const launch = (params: Parameters>[1]) =>
+ toolkit.handle("t3_thread_launch", params).pipe(
+ Stream.unwrap,
+ Stream.runCollect,
+ Effect.map((results) => results.at(-1)?.result),
+ );
+ const fs = yield* FileSystem.FileSystem;
+ const outsidePath = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unregistered-" });
+ const outsideStrategy = { type: "existing_worktree" as const, worktreePath: outsidePath };
+ const refusal = {
+ code: "invalid_request",
+ message:
+ "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.",
+ };
+ const idleInput = {
+ title: "Idle accepted launch",
+ projectId,
+ clientRequestId: "idle-retry",
+ workspaceStrategy: { type: "root" as const },
+ };
+ assert.include(yield* launch(idleInput), { runId: null, status: null });
+ assert.include(
+ yield* launch({
+ ...idleInput,
+ message: "Run outside the project",
+ workspaceStrategy: outsideStrategy,
+ }),
+ refusal,
+ );
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ const idle = yield* threads.getThreadProjection(
+ ThreadId.make("mcp:launch:client%3Alaunch-retry:idle-retry"),
+ );
+ assert.equal(idle.messages.length, 0);
+ assert.equal(idle.runs.length, 0);
+ assert.equal(idle.thread.worktreePath, null);
+ assert.equal(harness.createWorktree.mock.calls.length, 0);
+ assert.equal(harness.runSetup.mock.calls.length, 0);
+
+ const first = yield* launch(input);
+ assert.isDefined(first);
+ assert.notProperty(first, "code");
+ yield* Deferred.await(setupEntered);
+ changed = true;
+
+ assert.include(yield* launch({ ...input, workspaceStrategy: outsideStrategy }), refusal);
+
+ assert.deepEqual(yield* launch(input), first);
+ assert.deepEqual(yield* launch({ ...input, modelSelection: claudeModelSelection }), first);
+ assert.include(yield* launch({ ...input, projectId: otherProjectId }), {
+ code: "orchestration_error",
+ });
+ assert.include(
+ yield* launch(input).pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, {
+ ...context,
+ client: { ...context.client!, access: "read-only" },
+ }),
+ ),
+ { code: "capability_denied" },
+ );
+ assert.include(yield* launch({ ...input, clientRequestId: "fresh" }), {
+ code: "invalid_request",
+ });
+ assert.include(
+ yield* launch({
+ ...input,
+ clientRequestId: "fresh-root",
+ workspaceStrategy: { type: "root" },
+ }),
+ { code: "invalid_request" },
+ );
+ const threadId = ThreadId.make("mcp:launch:client%3Alaunch-retry:lost-response");
+ assert.equal((yield* threads.getThreadShell(threadId))?.runtimeMode, "approval-required");
+ yield* threads.dispatch({
+ type: "thread.runtime-mode.set",
+ commandId: CommandId.make("owner:raise-launch-modes"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+ assert.include(
+ yield* launch(input).pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, {
+ ...context,
+ client: { ...context.client!, access: "approval-required" },
+ }),
+ ),
+ { code: "orchestration_error" },
+ );
+ const projection = yield* threads.getThreadProjection(threadId);
+ assert.equal(projection.thread.runtimeMode, "full-access");
+ assert.equal(projection.messages.length, 1);
+ assert.equal(projection.runs.length, 1);
+ assert.equal(projection.runs[0]?.status, "preparing");
+ assert.deepEqual(projection.runs[0]?.workspacePreparation, input.workspaceStrategy);
+ assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature");
+ assert.equal(harness.createWorktree.mock.calls.length, 1);
+ assert.equal(harness.runSetup.mock.calls.length, 1);
+ yield* Deferred.succeed(allowSetup, undefined);
+ yield* threads.streamStoredEventsFrom({ threadId }).pipe(
+ Stream.filter(
+ (stored) =>
+ stored.commandId === CommandId.make(`${threadId}:release`) &&
+ stored.event.type === "run.updated",
+ ),
+ Stream.runHead,
+ );
+ assert.include(
+ yield* launch(input).pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, {
+ ...context,
+ client: { ...context.client!, access: "approval-required" },
+ }),
+ ),
+ { threadId, status: "starting" },
+ );
+ }).pipe(Effect.provide(dependencies));
+ }),
+);
+
it.effect("replays an accepted launch after the project disables the instance", () => {
const harness = makeHarness();
return Effect.gen(function* () {
@@ -1423,6 +1605,40 @@ it.effect("replays an accepted launch after the project disables the instance",
}).pipe(Effect.provide(harness.layer));
});
+it.effect("an agent's launch never starts work above the modes it asks for", () => {
+ const harness = makeHarness();
+ return Effect.gen(function* () {
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ // The first launch, at full access, creates the thread without a message.
+ const created = yield* launches.launch(
+ launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }),
+ );
+ // The same key comes back from a narrower agent, now carrying a message,
+ // as a retry or a concurrent launch that lost the create would.
+ const failed = yield* launches
+ .launch({
+ ...launchInput({
+ command: "command:launch:narrowed-retry",
+ thread: "thread:narrowed",
+ message: "Run this",
+ }),
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ createdBy: "agent",
+ creationSource: "mcp",
+ })
+ .pipe(Effect.flip);
+ if (failed._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
+ }
+ assert.equal(failed.operation, "dispatch-message");
+ const projection = yield* threads.getThreadProjection(created.threadId);
+ assert.equal(projection.runs.length, 0);
+ assert.equal(projection.messages.length, 0);
+ }).pipe(Effect.provide(harness.layer));
+});
+
it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
const harness = makeHarness();
return Effect.gen(function* () {
@@ -1641,19 +1857,38 @@ it.effect("schedules an accepted preparing message exactly once across concurren
text: "Resume preparation",
attachments: [],
modelSelection: input.modelSelection,
- dispatchMode: { type: "defer_start" },
+ dispatchMode: { type: "defer_start", workspaceStrategy: input.workspaceStrategy },
createdBy: input.createdBy,
creationSource: input.creationSource,
});
const preparing = yield* threads.getThreadProjection(input.threadId);
assert.equal(preparing.runs[0]?.status, "preparing");
- const [first, second] = yield* Effect.all([launches.launch(input), launches.launch(input)], {
+ const retry = { ...input, workspaceStrategy: { type: "root" as const } };
+ const [first, second] = yield* Effect.all([launches.launch(retry), launches.launch(retry)], {
concurrency: "unbounded",
});
- yield* Deferred.await(setupEntered);
+ const prepared = yield* Effect.raceFirst(
+ Deferred.await(setupEntered).pipe(Effect.as(true)),
+ threads.streamStoredEventsFrom({ threadId: input.threadId }).pipe(
+ Stream.filter(
+ (stored) =>
+ stored.commandId === CommandId.make(input.commandId + ":release") &&
+ stored.event.type === "run.updated",
+ ),
+ Stream.runHead,
+ Effect.as(false),
+ ),
+ );
+ assert.isTrue(prepared, "the accepted worktree setup must run before releasing the run");
assert.isTrue(first.resumed);
assert.isTrue(second.resumed);
+ assert.equal(harness.createWorktree.mock.calls.length, 1);
+ assert.equal(harness.runSetup.mock.calls[0]?.[0].worktreePath, "/repo-worktrees/feature");
+ assert.deepEqual(
+ (yield* threads.getThreadProjection(input.threadId)).runs[0]?.workspacePreparation,
+ input.workspaceStrategy,
+ );
assert.equal(harness.runSetup.mock.calls.length, 1);
yield* Deferred.succeed(allowSetup, undefined);
}).pipe(Effect.provide(harness.layer));
@@ -1766,6 +2001,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
}),
),
),
+ getAcceptedLaunch: launches.getAcceptedLaunch,
checkWorktreeBase: launches.checkWorktreeBase,
retryPreparation: launches.retryPreparation,
prepareRun: launches.prepareRun,
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
index f1c3d432a10be..892a457368cea 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
@@ -11,6 +11,7 @@ import {
type OrchestrationV2CreationSource,
type OrchestrationV2ProviderThreadNativeMetadata,
type OrchestrationV2ThreadProjection,
+ type OrchestrationV2ThreadShell,
type ProviderDriverKind,
type ProviderInteractionMode,
ProjectId,
@@ -49,6 +50,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import type * as Orchestrator from "./Orchestrator.ts";
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
+import { DispatchModeLimit } from "./DispatchModeLimit.ts";
import * as ThreadManagement from "./ThreadManagementService.ts";
export type ThreadLaunchWorkspaceStrategy =
@@ -166,6 +168,9 @@ export class ThreadLaunchService extends Context.Service<
ThreadLaunchResult,
ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
>;
+ readonly getAcceptedLaunch: (
+ input: Pick,
+ ) => Effect.Effect, ThreadLaunchError>;
/**
* `launch` accepts such a launch and reports the failure inside the
* thread; a caller that needs it before a thread exists checks first.
@@ -214,7 +219,11 @@ const make = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));
const mapError =
- (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
+ (
+ input: Pick,
+ operation: ThreadLaunchError["operation"],
+ threadId?: ThreadId,
+ ) =>
(cause: unknown) =>
new ThreadLaunchError({
operation,
@@ -224,11 +233,39 @@ const make = Effect.gen(function* () {
cause,
});
- const readReceipt = (input: ThreadLaunchInput, commandId: CommandId) =>
+ const readReceipt = (
+ input: Pick,
+ commandId: CommandId,
+ ) =>
receipts
.getByCommandId(commandId)
.pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId)));
+ const getAcceptedLaunch: ThreadLaunchService["Service"]["getAcceptedLaunch"] = Effect.fn(
+ "ThreadLaunchService.getAcceptedLaunch",
+ )(function* (input) {
+ const receipt = yield* readReceipt(input, input.commandId);
+ if (Option.isNone(receipt)) return Option.none();
+ if (
+ receipt.value.status !== "accepted" ||
+ receipt.value.commandType !== "thread.create" ||
+ (input.threadId !== undefined && input.threadId !== receipt.value.threadId)
+ ) {
+ return yield* mapError(input, "read-receipt")("This command cannot be replayed as a launch.");
+ }
+ const shell = yield* threads
+ .getThreadShell(receipt.value.threadId)
+ .pipe(Effect.mapError(mapError(input, "create-thread", receipt.value.threadId)));
+ if (shell === null || shell.projectId !== input.projectId) {
+ return yield* mapError(
+ input,
+ "resolve-project",
+ receipt.value.threadId,
+ )("The accepted launch does not belong to this project.");
+ }
+ return Option.some(shell);
+ });
+
const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* (
input: ThreadLaunchInput,
threadId: ThreadId,
@@ -940,7 +977,6 @@ const make = Effect.gen(function* () {
if (project.id !== input.projectId) {
return yield* mapError(input, "resolve-project", threadId)("Project identity changed.");
}
-
let runId: RunId | null = null;
let messageWasAlreadyAccepted = false;
if (input.initialMessage !== undefined) {
@@ -973,7 +1009,20 @@ const make = Effect.gen(function* () {
createdBy: input.createdBy,
creationSource: input.creationSource,
})
- .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)));
+ .pipe(
+ // An agent's launch may land on a thread a replay, or a
+ // concurrent launch with the same key, created with broader modes
+ // than it asks for. The orchestrator refuses the message under
+ // the thread's lock. The user's own clients launch into their
+ // own drafts, whatever modes those hold.
+ input.createdBy === "agent"
+ ? Effect.provideService(DispatchModeLimit, {
+ runtimeMode: input.runtimeMode,
+ interactionMode: input.interactionMode,
+ })
+ : (effect) => effect,
+ Effect.mapError(mapError(input, "dispatch-message", threadId)),
+ );
const runCreated = dispatched.storedEvents.find(
(stored) => stored.event.type === "run.created",
);
@@ -990,9 +1039,8 @@ const make = Effect.gen(function* () {
const projection = yield* threads
.getThreadProjection(threadId)
.pipe(Effect.mapError(mapError(input, "create-thread", threadId)));
- const runIsPreparing =
- runId !== null &&
- projection.runs.some((run) => run.id === runId && run.status === "preparing");
+ const acceptedRun = projection.runs.find((run) => run.id === runId);
+ const runIsPreparing = acceptedRun?.status === "preparing";
const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing;
const boundWorktreePath = projection.thread.worktreePath;
const preparationStrategy: ThreadLaunchWorkspaceStrategy =
@@ -1019,11 +1067,20 @@ const make = Effect.gen(function* () {
Effect.mapError(mapError(input, "update-thread", threadId)),
);
if (preparationStillRequired) {
- yield* schedulePreparation(
- { ...input, workspaceStrategy: preparationStrategy },
- threadId,
- runId,
- );
+ if (acceptedRun?.workspacePreparation !== undefined) {
+ yield* scheduleRunPreparation(
+ { commandId: input.commandId, threadId, runId: acceptedRun.id },
+ projection,
+ acceptedRun,
+ acceptedRun.workspacePreparation,
+ );
+ } else {
+ yield* schedulePreparation(
+ { ...input, workspaceStrategy: preparationStrategy },
+ threadId,
+ runId,
+ );
+ }
} else {
yield* releasePreparation(input.commandId);
}
@@ -1112,7 +1169,13 @@ const make = Effect.gen(function* () {
);
};
- return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun });
+ return ThreadLaunchService.of({
+ launch,
+ getAcceptedLaunch,
+ checkWorktreeBase,
+ retryPreparation,
+ prepareRun,
+ });
});
export const layer = Layer.effect(ThreadLaunchService, make);
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts
index 543b3e2f89fe1..ea0de29b814e9 100644
--- a/apps/server/src/orchestration-v2/ThreadManagementService.ts
+++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts
@@ -43,6 +43,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
+import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
@@ -1099,24 +1100,66 @@ const make = Effect.gen(function* () {
const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn(
"ThreadManagementService.stopDelegatedTasks",
)(function* (input) {
- const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);
+ const receipt = yield* receipts
+ .getByCommandId(input.commandId)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new Orchestrator.OrchestratorProjectionError({ threadId: input.threadId, cause }),
+ ),
+ );
+ return yield* stopDelegatedTasksThrough(
+ input,
+ Option.isSome(receipt) ? receipt.value.resultSequence : undefined,
+ );
+ });
+
+ const stopDelegatedTasksThrough: (
+ input: Parameters[0],
+ throughSequence: number | undefined,
+ ) => ReturnType = Effect.fn(
+ "ThreadManagementService.stopDelegatedTasksThrough",
+ )(function* (input, throughSequence) {
+ // A retry must not stop tasks delegated after the original Stop, including
+ // when an earlier walk stopped some children and failed on another. Follow-up
+ // tasks reuse a child thread, so the set walks its subtree only once.
+ const childThreadIds = new Set(
+ throughSequence === undefined
+ ? (yield* orchestrator.getThreadRecords(input.threadId, ["subagents"])).subagents.flatMap(
+ (task) =>
+ task.origin === "app_owned" && task.childThreadId !== null
+ ? [task.childThreadId]
+ : [],
+ )
+ : yield* orchestrator
+ .readStoredEvents({
+ threadId: input.threadId,
+ throughSequence,
+ eventType: "subagent.updated",
+ })
+ .pipe(
+ Stream.filterMap((stored) =>
+ stored.event.type === "subagent.updated" &&
+ stored.event.payload.origin === "app_owned" &&
+ stored.event.payload.childThreadId !== null
+ ? Result.succeed(stored.event.payload.childThreadId)
+ : Result.failVoid,
+ ),
+ Stream.runCollect,
+ ),
+ );
const failures: Array<
Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
> = [];
- const stoppedChildThreadIds = new Set();
- for (const task of subagents) {
- if (task.origin !== "app_owned" || task.childThreadId === null) continue;
- const threadId = task.childThreadId;
- // Follow-up tasks reuse a child thread; walk its subtree only once.
- if (stoppedChildThreadIds.has(threadId)) continue;
- stoppedChildThreadIds.add(threadId);
+ for (const threadId of childThreadIds) {
yield* dispatch({
type: "thread.stop",
commandId: CommandId.make(`${input.commandId}:stop:${threadId}`),
threadId,
+ ...(throughSequence === undefined ? {} : { throughSequence }),
...(input.reason === undefined ? {} : { reason: input.reason }),
}).pipe(
- Effect.andThen(stopDelegatedTasks({ ...input, threadId })),
+ Effect.andThen(stopDelegatedTasksThrough({ ...input, threadId }, throughSequence)),
Effect.catch((error) =>
Effect.logWarning("Unable to stop a delegated task", {
parentThreadId: input.threadId,
diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts
index c8b490008763a..d5b6b5508e2a5 100644
--- a/apps/server/src/orchestration-v2/ThreadStop.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts
@@ -1,8 +1,13 @@
import { assert, it } from "@effect/vitest";
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
import {
CommandId,
ContextTransferId,
EventId,
+ EnvironmentId,
MessageId,
NodeId,
type OrchestrationV2DomainEvent,
@@ -25,13 +30,23 @@ import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import * as Stream from "effect/Stream";
import * as EventStore from "./EventStore.ts";
+import * as EventSink from "./EventSink.ts";
import * as TestClock from "effect/testing/TestClock";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ServerSettings from "../serverSettings.ts";
+import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts";
+import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import * as ThreadLaunch from "./ThreadLaunchService.ts";
+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import * as SecretRequests from "../secrets/SecretRequests.ts";
import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
+import { DispatchModeLimit } from "./DispatchModeLimit.ts";
import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as EffectWorker from "./EffectWorker.ts";
@@ -51,24 +66,42 @@ const adapter = {
planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
openSession: () => Effect.die("Runs here never reach a provider"),
} as ProviderAdapter.ProviderAdapterV2["Service"];
-const layerDatabase = SqlitePersistence.layerMemory;
-const layerTest = ThreadManagementService.layer.pipe(
- Layer.provideMerge(
- Layer.mergeAll(
- layerDatabase,
- ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
- EventStore.layer.pipe(Layer.provide(layerDatabase)),
- EffectOutbox.layer.pipe(Layer.provide(layerDatabase)),
- ProviderReplayHarness.layerWithRegistry(
- { name: "thread-stop" },
- ProviderAdapterRegistry.layerFromAdapters([adapter]),
- { databaseLayer: layerDatabase, runEffectWorker: false },
+const fileDatabase = (dbPath: string) =>
+ SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer));
+const makeLayerTest = (
+ layerDatabase: ReturnType = SqlitePersistence.layerMemory,
+) =>
+ ThreadManagementService.layer.pipe(
+ Layer.provideMerge(
+ Layer.mergeAll(
+ layerDatabase,
+ ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
+ EventStore.layer.pipe(Layer.provide(layerDatabase)),
+ EffectOutbox.layer.pipe(Layer.provide(layerDatabase)),
+ ProviderReplayHarness.layerWithRegistry(
+ { name: "thread-stop" },
+ ProviderAdapterRegistry.layerFromAdapters([adapter]),
+ { databaseLayer: layerDatabase, runEffectWorker: false },
+ ),
),
),
- ),
- Layer.provideMerge(ServerSettings.layerTest()),
- Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
-);
+ Layer.provideMerge(ServerSettings.layerTest()),
+ Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
+ );
+const layerTest = makeLayerTest();
+
+const layerMcp = (layerDatabase: ReturnType) =>
+ OrchestratorMcpService.layer.pipe(
+ Layer.provideMerge(makeLayerTest(layerDatabase)),
+ Layer.provide(NodeCrypto.layer),
+ Layer.provide(ProviderAdapterRegistry.layerFromAdapters([adapter])),
+ Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})),
+ Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
+ Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
+ Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
+ Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
+ );
const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2));
@@ -533,7 +566,7 @@ it.effect("Stop still stops the thread when a delegated child cannot be read", (
it.effect("Stop ends watches, holds queues, and stops the delegated tasks under the thread", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
- const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const sink = yield* EventSink.EventSinkV2;
const threads = yield* ThreadManagementService.ThreadManagementService;
const sql = yield* SqlClient.SqlClient;
const parentThreadId = ThreadId.make("thread:stop-parent");
@@ -543,16 +576,21 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under
assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2))));
const grandchildThreadId = yield* delegate(childThreadId, "grandchild task");
yield* send(childThreadId, "child follow-up", "queue_after_active");
+ yield* send(childThreadId, "child queued after follow-up", "queue_after_active");
const now = yield* DateTime.now;
const firstRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!;
- yield* projections.apply({
- id: EventId.make("event:stop-parent:first-run-completed"),
- type: "run.updated",
- threadId: parentThreadId,
- runId: firstRun.id,
- occurredAt: now,
- payload: { ...firstRun, status: "completed", completedAt: now },
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make("event:stop-parent:first-run-completed"),
+ type: "run.updated",
+ threadId: parentThreadId,
+ runId: firstRun.id,
+ occurredAt: now,
+ payload: { ...firstRun, status: "completed", completedAt: now },
+ },
+ ],
});
yield* send(parentThreadId, "second", "start_immediately");
const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!;
@@ -580,15 +618,44 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under
"delegated-tasks.stop",
);
+ const childRun = (yield* orchestrator.getThreadProjection(childThreadId)).runs[0]!;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make("event:stop-child:first-run-completed"),
+ type: "run.updated",
+ threadId: childThreadId,
+ runId: childRun.id,
+ occurredAt: now,
+ payload: { ...childRun, status: "completed", completedAt: now },
+ },
+ ],
+ });
+ yield* orchestrator.dispatch({
+ type: "queue.resume",
+ commandId: CommandId.make("resume-child-before-stop-walk"),
+ threadId: childThreadId,
+ });
+ assert.deepEqual((yield* threadState(childThreadId)).runs, ["completed", "starting", "queued"]);
+
yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId });
assert.deepEqual(yield* threadState(childThreadId), {
- runs: ["interrupted", "queued:held"],
+ runs: ["completed", "interrupted", "queued:held"],
watched: [],
});
assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]);
yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId });
- assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]);
+ assert.deepEqual((yield* threadState(childThreadId)).runs, [
+ "completed",
+ "interrupted",
+ "queued:held",
+ ]);
+
+ yield* send(parentThreadId, "new work", "start_immediately");
+ const newChild = yield* delegate(parentThreadId, "new task after Stop");
+ yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId });
+ assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]);
}).pipe(Effect.provide(layerTest)),
);
@@ -1116,40 +1183,266 @@ it.effect.each([
}).pipe(Effect.provide(layerTest)),
);
-it.effect("a delegated task that cannot be stopped fails the walk after its siblings stop", () =>
+it.effect("a partial delegated Stop retries only the original children that failed", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
- const projections = yield* ProjectionStore.ProjectionStoreV2;
const threads = yield* ThreadManagementService.ThreadManagementService;
const parentThreadId = ThreadId.make("thread:stop-partial");
yield* createWatchingThread(parentThreadId, 6);
yield* send(parentThreadId, "work", "start_immediately");
- const childThreadId = yield* delegate(parentThreadId, "stoppable task");
- const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!;
+ const rootRefused = yield* threads
+ .dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("stop-root-above-limit"),
+ threadId: parentThreadId,
+ })
+ .pipe(
+ Effect.provideService(DispatchModeLimit, {
+ runtimeMode: "approval-required",
+ interactionMode: "default",
+ }),
+ Effect.exit,
+ );
+ assert.isTrue(Exit.isFailure(rootRefused));
+ assert.deepEqual((yield* threadState(parentThreadId)).runs, ["starting"]);
+ const blockedChild = yield* delegate(parentThreadId, "default task");
+ const advancedChild = yield* delegate(parentThreadId, "default task that advances");
+ const stoppedChild = yield* delegate(parentThreadId, "plan task");
+ const blockedGrandchild = yield* delegate(stoppedChild, "broader grandchild");
+ yield* orchestrator.dispatch({
+ type: "thread.runtime-mode.set",
+ commandId: CommandId.make("approval-child"),
+ threadId: stoppedChild,
+ runtimeMode: "approval-required",
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.interaction-mode.set",
+ commandId: CommandId.make("plan-child"),
+ threadId: stoppedChild,
+ interactionMode: "plan",
+ });
+ const commandId = CommandId.make("stop-partial");
+ yield* orchestrator.dispatch({ type: "thread.stop", commandId, threadId: parentThreadId });
+ const walked = yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId }).pipe(
+ Effect.provideService(DispatchModeLimit, {
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ }),
+ Effect.exit,
+ );
+ assert.isTrue(Exit.isFailure(walked));
+ assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted"]);
+ assert.deepEqual((yield* threadState(blockedChild)).runs, ["starting"]);
+ assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["starting"]);
+
+ yield* orchestrator.dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("independent-child-stop"),
+ threadId: advancedChild,
+ });
+ yield* send(advancedChild, "new work before failed Stop retry", "start_immediately");
+ yield* send(stoppedChild, "new child work", "start_immediately");
+ const newGrandchild = yield* delegate(stoppedChild, "new grandchild");
+ yield* send(parentThreadId, "new parent work", "start_immediately");
+ const newChild = yield* delegate(parentThreadId, "new child");
+ yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId });
+ assert.deepEqual((yield* threadState(blockedChild)).runs, ["interrupted"]);
+ assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["interrupted"]);
+ assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted", "starting"]);
+ assert.deepEqual((yield* threadState(advancedChild)).runs, ["interrupted", "starting"]);
+ assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]);
+ assert.deepEqual((yield* threadState(newGrandchild)).runs, ["starting"]);
+ }).pipe(Effect.provide(layerTest)),
+);
+
+it.effect.each([
+ {
+ name: "an active run past a queue cancelled since",
+ afterStop: ["queue", "cancel"],
+ expected: { runs: ["interrupted", "cancelled"], watched: [] },
+ },
+ {
+ name: "a finished run past a queue cancelled since",
+ afterStop: ["queue", "cancel", "complete"],
+ expected: { runs: ["completed", "cancelled"], watched: [] },
+ },
+ {
+ name: "a run queued before Stop that started since",
+ beforeStop: ["queue"],
+ afterStop: ["complete", "resume"],
+ expected: { runs: ["completed", "interrupted"], watched: [] },
+ },
+ {
+ name: "a run queued before Stop that started after newer work finished",
+ beforeStop: ["queue"],
+ afterStop: ["complete", "start", "complete", "resume"],
+ expected: { runs: ["completed", "completed", "interrupted"], watched: [] },
+ },
+ {
+ // Partial retries keep queue/watch changes thread-wide while the original run is current.
+ name: "the original run and the queue and watch added since",
+ afterStop: ["queue", "watch"],
+ expected: { runs: ["interrupted", "queued:held"], watched: [] },
+ },
+ {
+ name: "nothing when only newer work started",
+ afterStop: ["complete", "start"],
+ expected: { runs: ["completed", "starting"], watched: [40] },
+ },
+ {
+ name: "nothing when newer work finished past a queue cancelled since",
+ afterStop: ["complete", "start", "queue", "cancel", "complete"],
+ expected: { runs: ["completed", "completed", "cancelled"], watched: [40] },
+ },
+] as const)("a Stop retry reaches $name", ({ beforeStop = [], afterStop, expected }) =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const threadId = ThreadId.make(
+ `thread:stop-retry-bound:${afterStop.join(",")}:${beforeStop.join(",")}`,
+ );
+ let step = 0;
+ const apply = (action: (typeof beforeStop)[number] | (typeof afterStop)[number]) =>
+ Effect.gen(function* () {
+ step++;
+ if (action === "queue")
+ return yield* send(threadId, `queued ${step}`, "queue_after_active");
+ if (action === "start") return yield* send(threadId, `new ${step}`, "start_immediately");
+ if (action === "watch") return yield* watch(threadId, 41);
+ const runs = (yield* orchestrator.getThreadProjection(threadId)).runs;
+ if (action === "resume")
+ return yield* orchestrator.dispatch({
+ type: "queue.resume",
+ commandId: CommandId.make(`resume:${step}`),
+ threadId,
+ });
+ if (action === "cancel")
+ return yield* orchestrator.dispatch({
+ type: "queued-run.cancel",
+ commandId: CommandId.make(`cancel:${step}`),
+ threadId,
+ runId: runs.findLast((run) => run.status === "queued")!.id,
+ });
+ const now = yield* DateTime.now;
+ const run = runs.findLast((candidate) => candidate.status === "starting")!;
+ yield* projections.apply({
+ id: EventId.make(`event:stop-retry-bound:${step}`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "completed", startedAt: now, completedAt: now },
+ });
+ });
+ yield* createWatchingThread(threadId, 40);
+ yield* send(threadId, "original", "start_immediately");
+ for (const action of beforeStop) yield* apply(action);
+ // The original Stop's receipt bound; its walk failed before reaching this thread.
+ const throughSequence = yield* orchestrator.getThreadEventSequence(threadId);
+ for (const action of afterStop) yield* apply(action);
+ yield* orchestrator.dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("retry-stop"),
+ threadId,
+ throughSequence,
+ });
+ assert.deepEqual(yield* threadState(threadId), {
+ runs: [...expected.runs],
+ watched: [...expected.watched],
+ });
+ }).pipe(Effect.provide(layerTest)),
+);
+
+it.effect("thread.stop interrupts background work past a queue cancelled before it ran", () =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const threadId = ThreadId.make("thread:stop-background-cancelled-queue");
+ yield* createWatchingThread(threadId, 42);
+ yield* send(threadId, "work", "start_immediately");
+ yield* send(threadId, "queued", "queue_after_active");
+ const [run, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs;
+ yield* orchestrator.dispatch({
+ type: "queued-run.cancel",
+ commandId: CommandId.make("cancel-before-background"),
+ threadId,
+ runId: queued!.id,
+ });
+ const now = yield* DateTime.now;
+ const providerTurnId = ProviderTurnId.make("provider-turn:stop-background");
yield* projections.apply({
- id: EventId.make("event:stop-partial:missing-task"),
- type: "subagent.updated",
- threadId: parentThreadId,
- runId: task.runId!,
- nodeId: NodeId.make("node:stop-partial:missing"),
- providerInstanceId: instanceId,
- occurredAt: yield* DateTime.now,
+ id: EventId.make("event:stop-background:turn"),
+ type: "provider-turn.updated",
+ threadId,
+ occurredAt: now,
payload: {
- ...task,
- id: NodeId.make("node:stop-partial:missing"),
- childThreadId: ThreadId.make("thread:stop-partial:missing"),
- startedAt: DateTime.subtract(task.startedAt!, { hours: 1 }),
+ id: providerTurnId,
+ providerThreadId: run!.providerThreadId!,
+ nodeId: run!.rootNodeId!,
+ runAttemptId: run!.activeAttemptId!,
+ nativeTurnRef: null,
+ ordinal: 1,
+ status: "completed",
+ startedAt: now,
+ completedAt: now,
},
});
+ const backgroundId = TurnItemId.make("item:stop-background");
+ yield* projections.apply({
+ id: EventId.make("event:stop-background:item"),
+ type: "turn-item.updated",
+ threadId,
+ runId: run!.id,
+ occurredAt: now,
+ payload: {
+ id: backgroundId,
+ threadId,
+ runId: run!.id,
+ nodeId: run!.rootNodeId!,
+ providerThreadId: run!.providerThreadId!,
+ providerTurnId,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 1,
+ status: "running",
+ title: "Background command",
+ startedAt: now,
+ completedAt: null,
+ updatedAt: now,
+ type: "command_execution",
+ input: "test background work",
+ },
+ });
+ yield* projections.apply({
+ id: EventId.make("event:stop-background:completed"),
+ type: "run.updated",
+ threadId,
+ runId: run!.id,
+ occurredAt: now,
+ payload: { ...run!, status: "completed", startedAt: now, completedAt: now },
+ });
- const walked = yield* Effect.exit(
- threads.stopDelegatedTasks({
- threadId: parentThreadId,
- commandId: CommandId.make("stop-partial"),
- }),
+ yield* orchestrator.dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("stop-background"),
+ threadId,
+ });
+ const stopped = yield* orchestrator.getThreadProjection(threadId);
+ assert.notInclude(
+ ["pending", "running", "waiting"],
+ stopped.turnItems.find((item) => item.id === backgroundId)?.status,
);
- assert.isTrue(Exit.isFailure(walked));
- assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted"]);
+ assert.deepEqual(
+ stopped.turnItems
+ .filter((item) => item.type === "run_interrupt_request")
+ .map((item) => item.runId),
+ [run!.id],
+ );
+ assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 43))));
+ assert.deepEqual(yield* threadState(threadId), {
+ runs: ["completed", "cancelled"],
+ watched: [],
+ });
}).pipe(Effect.provide(layerTest)),
);
@@ -1471,3 +1764,119 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const)
);
}).pipe(Effect.provide(layerTest)),
);
+
+it.effect("an MCP Stop replays its original result after the database and services reopen", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-stop-replay-" });
+ const database = fileDatabase(path.join(dir, "statev2.sqlite"));
+ const scope: McpInvocationContext.McpInvocationScope = {
+ environmentId: EnvironmentId.make("stop-replay"),
+ requestNamespace: "client:stop-replay",
+ capabilities: new Set(["orchestration"]),
+ issuedAt: 1,
+ thread: undefined,
+ client: { sessionId: "stop-replay", label: "Stop replay", access: "full-access" },
+ };
+ const threadId = ThreadId.make("thread:mcp-stop-replay");
+ const stop = { threadId, stop: true, clientRequestId: "stop" };
+ const first = yield* Effect.gen(function* () {
+ const mcp = yield* OrchestratorMcpService.OrchestratorMcpService;
+ const idle = ThreadId.make("thread:mcp-stop-idle");
+ yield* createWatchingThread(idle, 102);
+ yield* send(idle, "finished work", "start_immediately");
+ yield* send(idle, "held queue", "queue_after_active");
+ const idleProjection = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(idle);
+ const idleRun = idleProjection.runs[0]!;
+ const now = yield* DateTime.now;
+ yield* (yield* EventSink.EventSinkV2).write({
+ events: [
+ {
+ id: EventId.make("event:mcp-stop-idle:completed"),
+ type: "run.updated",
+ threadId: idle,
+ runId: idleRun.id,
+ occurredAt: now,
+ payload: { ...idleRun, status: "completed", completedAt: now },
+ },
+ {
+ id: EventId.make("event:mcp-stop-idle:held"),
+ type: "run.updated",
+ threadId: idle,
+ runId: idleProjection.runs[1]!.id,
+ occurredAt: now,
+ payload: { ...idleProjection.runs[1]!, queueHeld: true },
+ },
+ ],
+ });
+ assert.deepEqual(
+ yield* mcp.interruptThread(scope, {
+ ...stop,
+ threadId: idle,
+ clientRequestId: "idle-stop",
+ }),
+ {
+ threadId: idle,
+ runId: null,
+ status: "no_active_run",
+ },
+ );
+ yield* createWatchingThread(threadId, 101);
+ const sent = yield* mcp.sendToThread(scope, {
+ threadId,
+ message: "first",
+ clientRequestId: "send",
+ });
+ const childThreadId = yield* delegate(threadId, "original child");
+ yield* delegate(childThreadId, "original grandchild");
+ const stopped = yield* mcp.interruptThread(scope, stop);
+ assert.equal(stopped.runId, sent.runId);
+ yield* send(childThreadId, "child work after parent stopped", "start_immediately");
+ const store = yield* EventStore.EventStoreV2;
+ const childSequence = yield* store.latestSequence({ threadId: childThreadId });
+ const noopStop = { ...stop, clientRequestId: "already-stopped-parent" };
+ const noopStopped = yield* mcp.interruptThread(scope, noopStop);
+ assert.deepEqual(noopStopped, { threadId, runId: null, status: "no_active_run" });
+ assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "interrupted"]);
+ const sql = yield* SqlClient.SqlClient;
+ const [noopReceipt] = yield* sql<{ command_id: string; result_sequence: number }>`
+ SELECT command_id, result_sequence FROM orchestration_command_receipts
+ WHERE aggregate_id = ${threadId} AND command_type = 'thread.stop'
+ ORDER BY result_sequence DESC LIMIT 1
+ `;
+ assert.isAtLeast(noopReceipt!.result_sequence, childSequence);
+ assert.deepEqual(
+ yield* store
+ .readByCommandId({ commandId: CommandId.make(noopReceipt!.command_id) })
+ .pipe(Stream.runCollect),
+ [],
+ );
+ return { sent, stopped, childThreadId, noopStop, noopStopped };
+ }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh)));
+
+ yield* Effect.gen(function* () {
+ const mcp = yield* OrchestratorMcpService.OrchestratorMcpService;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const replay = yield* mcp.sendToThread(scope, {
+ threadId,
+ message: "first",
+ clientRequestId: "send",
+ });
+ assert.equal(replay.runId, first.sent.runId);
+ assert.equal((yield* orchestrator.getThreadProjection(threadId)).runs.length, 1);
+ yield* send(threadId, "after restart", "start_immediately");
+ const newChild = yield* delegate(threadId, "child after restart");
+ yield* send(first.childThreadId, "child work after restart", "start_immediately");
+ assert.deepEqual(yield* mcp.interruptThread(scope, first.noopStop), first.noopStopped);
+ assert.deepEqual((yield* threadState(first.childThreadId)).runs, [
+ "interrupted",
+ "interrupted",
+ "starting",
+ ]);
+ assert.deepEqual(yield* mcp.interruptThread(scope, stop), first.stopped);
+ assert.deepEqual((yield* threadState(threadId)).runs, ["interrupted", "starting"]);
+ assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]);
+ }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh)));
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts
new file mode 100644
index 0000000000000..b556136ee4bec
--- /dev/null
+++ b/apps/server/src/peer/PeerForwarding.test.ts
@@ -0,0 +1,612 @@
+import { NodeHttpServer } from "@effect/platform-node";
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import {
+ EnvironmentId,
+ OrchestratorMcpEnvironmentTarget,
+ type OrchestrationV2Run,
+ type SourceControlCloneRepositoryInput,
+ type ModelSelection,
+ ProjectId,
+ ProviderInstanceId,
+ RunId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { expect, it } from "@effect/vitest";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import { McpSchema, McpServer } from "effect/ai";
+
+import * as ServerConfig from "../config.ts";
+import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
+import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
+import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
+import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
+import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
+import * as SecretRequests from "../secrets/SecretRequests.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as McpHttpServer from "../mcp/McpHttpServer.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import { liveThreadShell } from "../mcp/McpToolAccess.testkit.ts";
+import { AttachmentToolkit } from "../mcp/toolkits/attachment/tools.ts";
+import { DeviceToolkit } from "../mcp/toolkits/device/tools.ts";
+import { EnvironmentToolkit } from "../mcp/toolkits/environment/tools.ts";
+import { HtmlToolkit } from "../mcp/toolkits/html/tools.ts";
+import { OrchestratorToolkit } from "../mcp/toolkits/orchestrator/tools.ts";
+import { PreviewToolkit } from "../mcp/toolkits/preview/tools.ts";
+import { PreviewControlsToolkit } from "../mcp/toolkits/previewControls/tools.ts";
+import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts";
+import { PullRequestsToolkit } from "../mcp/toolkits/pullRequests/tools.ts";
+import { ThreadToolkit } from "../mcp/toolkits/thread/tools.ts";
+import { WorktreeToolkit } from "../mcp/toolkits/worktree/tools.ts";
+import * as PeerForwarding from "./PeerForwarding.ts";
+import * as PeerLinks from "./PeerLinks.ts";
+import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts";
+
+const laptop = descriptorOf("environment-laptop", "Laptop");
+const box = descriptorOf("environment-box", "Box");
+const boxProject = ProjectId.make("project:box");
+const boxThread = ThreadId.make("thread:box-auto");
+const boxRun = RunId.make("run:box-auto");
+
+/** What B's thread service was asked to do. */
+interface Seen {
+ readonly sends: ReadonlyArray;
+ readonly waits: ReadonlyArray;
+ readonly launches: ReadonlyArray;
+ readonly projects: ReadonlyArray;
+ readonly clones: ReadonlyArray;
+ /** Commands dispatched and delegated-task stops, as ` `. */
+ readonly stops: ReadonlyArray;
+ readonly selections: ReadonlyArray;
+}
+const nothingSeen: Seen = {
+ sends: [],
+ waits: [],
+ launches: [],
+ projects: [],
+ clones: [],
+ stops: [],
+ selections: [],
+};
+
+/**
+ * B's threads: one `auto` thread in `boxProject`, whose run finishes on the
+ * third wait. Every send and wait is recorded.
+ */
+const boxThreads = (seen: Ref.Ref) => {
+ const shell = {
+ ...liveThreadShell(boxThread, { runtimeMode: "auto" }),
+ projectId: boxProject,
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5",
+ options: [{ id: "reasoning", value: "high" }],
+ },
+ };
+ const run = (status: OrchestrationV2Run["status"]) =>
+ ({ id: boxRun, status }) as unknown as OrchestrationV2Run;
+ const projection = { thread: shell, runs: [run("running")], runtimeRequests: [] } as never;
+ return Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadShell: (threadId) => Effect.succeed(threadId === boxThread ? shell : null),
+ getProjectThreadRecords: () => Effect.succeed(projection),
+ getThreadRecords: () => Effect.succeed(projection),
+ listProjectThreads: () => Effect.succeed([shell] as never),
+ sendToThread: (input) =>
+ Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe(
+ Effect.as({ run: run("running"), delivery: "started" } as never),
+ ),
+ dispatch: (command) =>
+ Ref.update(seen, (current) => ({
+ ...current,
+ selections:
+ command.type === "thread.model-selection.set"
+ ? [...current.selections, command.modelSelection]
+ : current.selections,
+ stops: [
+ ...current.stops,
+ `${command.type} ${"threadId" in command ? command.threadId : ""}`,
+ ],
+ })).pipe(
+ // The stop interrupts the thread's running run.
+ Effect.as({
+ sequence: 0,
+ storedEvents: [
+ {
+ sequence: 1,
+ commandId: command.commandId,
+ event: {
+ type: "turn-item.updated",
+ runId: boxRun,
+ payload: { type: "run_interrupt_request", providerTurnId: "turn:box" },
+ },
+ },
+ ],
+ } as never),
+ ),
+ stopDelegatedTasks: (input) =>
+ Ref.update(seen, (current) => ({
+ ...current,
+ stops: [...current.stops, `delegated-tasks.stop ${input.threadId}`],
+ })),
+ waitForThread: (input) =>
+ Ref.updateAndGet(seen, (current) => ({ ...current, waits: [...current.waits, input] })).pipe(
+ Effect.map(({ waits }) => ({
+ threadId: input.threadId,
+ run: run(waits.length < 3 ? "running" : "completed"),
+ timedOut: waits.length < 3,
+ })),
+ ),
+ });
+};
+
+/** B launches whatever it is asked to, and records it. */
+const boxLaunches = (seen: Ref.Ref) =>
+ Layer.mock(ThreadLaunch.ThreadLaunchService)({
+ getAcceptedLaunch: () => Effect.succeed(Option.none()),
+ launch: (input) =>
+ Ref.update(seen, (current) => ({ ...current, launches: [...current.launches, input] })).pipe(
+ Effect.as({
+ threadId: input.threadId,
+ projection: {
+ thread: {
+ id: input.threadId,
+ projectId: input.projectId,
+ modelSelection: input.modelSelection,
+ title: input.title,
+ },
+ runs: [],
+ },
+ resumed: false,
+ } as unknown as ThreadLaunch.ThreadLaunchResult),
+ ),
+ });
+
+const boxProjectRecord = (input: {
+ readonly projectId: ProjectId;
+ readonly title: string;
+ readonly workspaceRoot: string;
+}) => ({
+ id: input.projectId,
+ title: input.title,
+ workspaceRoot: input.workspaceRoot,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-10-18T00:00:00.000Z",
+ updatedAt: "2026-10-18T00:00:00.000Z",
+ deletedAt: null,
+});
+
+/** B clones and registers whatever it is asked to, and records it. */
+const boxProjects = (seen: Ref.Ref) =>
+ Layer.merge(
+ Layer.mock(ProjectService.ProjectService)({
+ // A launch from outside a thread there starts from the project's defaults.
+ getById: (projectId) =>
+ Effect.succeed(
+ projectId === boxProject
+ ? Option.some(
+ boxProjectRecord({
+ projectId,
+ title: "box",
+ workspaceRoot: "/workspace/box-project",
+ }),
+ )
+ : Option.none(),
+ ),
+ create: (input) =>
+ Ref.update(seen, (current) => ({
+ ...current,
+ projects: [...current.projects, input],
+ })).pipe(Effect.as(boxProjectRecord(input))),
+ }),
+ Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
+ cloneRepository: (input) =>
+ Ref.update(seen, (current) => ({ ...current, clones: [...current.clones, input] })).pipe(
+ Effect.as({
+ cwd: input.destinationPath,
+ remoteUrl: "https://github.com/pingdotgg/t3code.git",
+ repository: null,
+ }),
+ ),
+ }),
+ );
+
+/** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */
+const serveBox = (seen: Ref.Ref) =>
+ servePeer(
+ box,
+ Layer.mergeAll(
+ McpHttpServer.layerOrchestratorToolkit,
+ McpHttpServer.layerProjectRegistration,
+ McpHttpServer.layerThreadToolkit,
+ ).pipe(
+ Layer.provide(NodeCrypto.layer),
+ Layer.provide(boxThreads(seen)),
+ Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
+ Layer.provide(boxLaunches(seen)),
+ Layer.provide(
+ Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
+ ),
+ Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
+ Layer.provide(boxProjects(seen)),
+ Layer.provide(
+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe(
+ Layer.provide(NodeServices.layer),
+ ),
+ ),
+ Layer.provide(NodeServices.layer),
+ Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
+ Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
+ Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
+ ),
+ );
+
+const mcpClient = McpSchema.McpServerClient.of({
+ clientId: 1,
+ protocolVersion: "2025-06-18",
+ clientCapabilities: {},
+ clientInfo: { name: "peer-forwarding-test", version: "1" },
+ initializePayload: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "peer-forwarding-test", version: "1" },
+ },
+ getClient: Effect.die("unused"),
+});
+
+/**
+ * Environment A: its own orchestrator toolkit with forwarding, and a thread
+ * caller whose modes the test picks. Its local threads never matter here.
+ */
+const makeLaptop = Effect.gen(function* () {
+ const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build);
+ const layerHere = Layer.mergeAll(
+ McpHttpServer.layerOrchestratorToolkit,
+ McpHttpServer.layerProjectRegistration,
+ McpHttpServer.layerThreadToolkit,
+ ).pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
+ Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})),
+ Layer.provide(
+ Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
+ ),
+ Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
+ Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})),
+ Layer.provide(
+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-laptop-" }).pipe(
+ Layer.provide(NodeServices.layer),
+ ),
+ ),
+ Layer.provide(NodeServices.layer),
+ Layer.provide(PeerForwarding.layer),
+ Layer.provide(Layer.succeedContext(linking)),
+ Layer.provide(NodeCrypto.layer),
+ Layer.provideMerge(
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadShell: (threadId) =>
+ Effect.succeed(
+ liveThreadShell(
+ threadId,
+ threadId === ThreadId.make("thread:laptop-plan")
+ ? { runtimeMode: "full-access", interactionMode: "plan" }
+ : { runtimeMode: "full-access" },
+ ),
+ ),
+ getProjectThreadRecords: ({ threadId }) =>
+ Effect.succeed({
+ thread: liveThreadShell(threadId, { runtimeMode: "full-access" }),
+ } as never),
+ }),
+ ),
+ Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
+ Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
+ Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
+ Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.fresh,
+ );
+ const here = yield* Layer.build(layerHere);
+ const server = Context.get(here, McpServer.McpServer);
+ const scopeOf = (
+ caller: string,
+ capabilities: ReadonlySet = new Set(["orchestration"]),
+ ): McpInvocationContext.McpInvocationScope => ({
+ environmentId: laptop.environmentId,
+ requestNamespace: `provider-session:${caller}`,
+ thread: {
+ threadId: ThreadId.make(caller),
+ providerSessionId: `provider-session:${caller}`,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ },
+ client: undefined,
+ capabilities,
+ issuedAt: 1,
+ });
+ const call = (
+ caller: string,
+ name: string,
+ args: Record,
+ capabilities?: ReadonlySet,
+ ) =>
+ server
+ .callTool({ name, arguments: args })
+ .pipe(
+ Effect.provideService(
+ McpInvocationContext.McpInvocationContext,
+ scopeOf(caller, capabilities),
+ ),
+ Effect.provideService(McpSchema.McpServerClient, mcpClient),
+ );
+ return { links: Context.get(linking, PeerLinks.PeerLinks), call };
+});
+
+const failureOf = (result: McpSchema.CallToolResult) => {
+ const text = result.content[0];
+ return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined;
+};
+
+it.effect("an agent here works on a linked environment's threads within its own modes", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const seen = yield* Ref.make(nothingSeen);
+ const b = yield* serveBox(seen);
+ const a = yield* makeLaptop;
+ yield* linkTo(a.links, b, "auto");
+
+ const listed = yield* a.call("thread:laptop-full", "t3_environment_links", {});
+ expect(listed.structuredContent).toMatchObject({
+ environmentId: laptop.environmentId,
+ links: [
+ { environmentId: box.environmentId, label: "Box", status: "reachable", access: "auto" },
+ ],
+ });
+
+ const threads = yield* a.call("thread:laptop-full", "t3_thread_list", {
+ environmentId: box.environmentId,
+ projectId: boxProject,
+ });
+ expect(threads.isError).toBe(false);
+ expect(threads.structuredContent).toMatchObject({
+ projectId: boxProject,
+ threads: [{ threadId: boxThread, runtimeMode: "auto" }],
+ });
+
+ // A full-access agent here is held to the link's `auto` there, which
+ // the box's `auto` thread is within.
+ const sent = yield* a.call("thread:laptop-full", "t3_thread_send", {
+ environmentId: box.environmentId,
+ threadId: boxThread,
+ message: "Carry on there.",
+ clientRequestId: "send-1",
+ });
+ expect(sent.isError).toBe(false);
+ expect(sent.structuredContent).toMatchObject({ threadId: boxThread, delivery: "started" });
+
+ // A plan-mode agent here may not steer that thread there: the box
+ // refuses it, because the call carries the agent's own modes.
+ const planned = yield* a.call("thread:laptop-plan", "t3_thread_send", {
+ environmentId: box.environmentId,
+ threadId: boxThread,
+ message: "Change course.",
+ });
+ expect(failureOf(planned)).toMatchObject({ code: "interaction_mode_escalation_denied" });
+ expect((yield* Ref.get(seen)).sends).toHaveLength(1);
+
+ // stop=true stops the thread there as its Stop button does: the whole
+ // thread and the tasks it delegated, not just the running turn.
+ const stopped = yield* a.call("thread:laptop-full", "t3_thread_interrupt", {
+ environmentId: box.environmentId,
+ threadId: boxThread,
+ stop: true,
+ clientRequestId: "stop-1",
+ });
+ expect(stopped.structuredContent).toMatchObject({
+ threadId: boxThread,
+ runId: boxRun,
+ status: "interrupt_requested",
+ });
+ expect((yield* Ref.get(seen)).stops).toEqual([
+ `thread.stop ${boxThread}`,
+ `delegated-tasks.stop ${boxThread}`,
+ ]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("keeps a forwarded wait short and its retries scoped to the caller", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const seen = yield* Ref.make(nothingSeen);
+ const b = yield* serveBox(seen);
+ const a = yield* makeLaptop;
+ yield* linkTo(a.links, b, "auto");
+
+ const waited = yield* a.call("thread:laptop-full", "t3_thread_wait", {
+ environmentId: box.environmentId,
+ threadId: boxThread,
+ timeoutMs: 600_000,
+ });
+ expect(waited.structuredContent).toEqual({
+ threadId: boxThread,
+ runId: boxRun,
+ status: "completed",
+ timedOut: false,
+ });
+ // Three pieces of at most 50 s each, the later ones pinned to the run
+ // the first one found.
+ expect(
+ (yield* Ref.get(seen)).waits.map(({ timeoutMs, runId }) => [timeoutMs, runId]),
+ ).toEqual([
+ [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, undefined],
+ [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun],
+ [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun],
+ ]);
+
+ const send = (caller: string, clientRequestId: string) =>
+ a.call(caller, "t3_thread_send", {
+ environmentId: box.environmentId,
+ threadId: boxThread,
+ message: "Once.",
+ clientRequestId,
+ });
+ yield* send("thread:laptop-full", "same-key");
+ yield* send("thread:laptop-full", "same-key");
+ yield* send("thread:laptop-other", "same-key");
+ const [first, retried, other] = (yield* Ref.get(seen)).sends.map((input) => input.commandId);
+ // The box sees every caller here as the one link, so the key is scoped
+ // to its caller before it leaves: a retry matches, another agent's same
+ // key does not.
+ expect(retried).toBe(first);
+ expect(other).not.toBe(first);
+ expect(first).not.toContain("same-key");
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("launches in a linked environment with the caller's modes, retry-safe", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const seen = yield* Ref.make(nothingSeen);
+ const b = yield* serveBox(seen);
+ const a = yield* makeLaptop;
+ yield* linkTo(a.links, b, "full-access");
+ const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" };
+ const launch = (caller: string, args: Record = {}) =>
+ a.call(caller, "t3_thread_launch", {
+ environmentId: box.environmentId,
+ projectId: boxProject,
+ title: "Run the suite there",
+ modelSelection: codex,
+ message: "Run the suite.",
+ clientRequestId: "launch-1",
+ ...args,
+ });
+
+ const first = yield* launch("thread:laptop-plan");
+ expect(first.isError).toBe(false);
+ expect(first.structuredContent).toMatchObject({
+ projectId: boxProject,
+ modelSelection: codex,
+ });
+ const retried = yield* launch("thread:laptop-plan");
+ const [launched, relaunched] = (yield* Ref.get(seen)).launches;
+ // The thread there runs with the caller's own modes here, below the
+ // link's full access, and a retry asks for the same thread.
+ expect(launched).toMatchObject({ runtimeMode: "full-access", interactionMode: "plan" });
+ expect(relaunched?.threadId).toBe(launched?.threadId);
+ expect(retried.structuredContent).toEqual(first.structuredContent);
+
+ // An escalation past the caller's own modes is refused here.
+ const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" });
+ expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" });
+ // A pending upload lives here, so it cannot go along.
+ yield* Ref.set(b.bearers, []);
+ const withUpload = yield* launch("thread:laptop-full", {
+ clientRequestId: "launch-2",
+ attachments: [
+ { type: "image", id: "upload-1", name: "a.png", mimeType: "image/png", sizeBytes: 1 },
+ ],
+ });
+ expect(failureOf(withUpload)).toMatchObject({ code: "invalid_request" });
+ expect(yield* Ref.get(b.bearers)).toEqual([]);
+ expect((yield* Ref.get(seen)).launches).toHaveLength(2);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it("reaches a linked environment only through the messaging and delegation tools", () => {
+ const toolkits = [
+ OrchestratorToolkit,
+ ProjectToolkit,
+ ThreadToolkit,
+ PullRequestsToolkit,
+ WorktreeToolkit,
+ EnvironmentToolkit,
+ AttachmentToolkit,
+ DeviceToolkit,
+ HtmlToolkit,
+ PreviewToolkit,
+ PreviewControlsToolkit,
+ ];
+ const forwarded = toolkits
+ .flatMap((toolkit) => Object.values(toolkit.tools))
+ .filter((tool) => {
+ const parameters: { readonly fields?: Record } = tool.parametersSchema;
+ return parameters.fields?.environmentId === OrchestratorMcpEnvironmentTarget;
+ })
+ .map((tool) => tool.name)
+ .sort();
+ expect(forwarded).toEqual([
+ "orchestrator_capabilities",
+ "t3_project_list",
+ "t3_project_read",
+ "t3_thread_interrupt",
+ "t3_thread_launch",
+ "t3_thread_list",
+ "t3_thread_read",
+ "t3_thread_send",
+ "t3_thread_wait",
+ ]);
+});
+
+it.effect("refuses what a linked environment cannot take before anything leaves", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const seen = yield* Ref.make(nothingSeen);
+ const b = yield* serveBox(seen);
+ const a = yield* makeLaptop;
+ yield* linkTo(a.links, b, "auto");
+ yield* Ref.set(b.bearers, []);
+
+ const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", {
+ environmentId: EnvironmentId.make("environment-elsewhere"),
+ threadId: boxThread,
+ });
+ expect(failureOf(unknown)).toMatchObject({ code: "invalid_request" });
+ expect(yield* Ref.get(b.bearers)).toEqual([]);
+
+ // The box sees only the link's credential, so one that may not control
+ // threads here may not reach them there either.
+ const previewOnly = new Set(["preview"]);
+ for (const [name, args] of [
+ ["t3_environment_links", {}],
+ ["t3_project_list", { environmentId: box.environmentId }],
+ ["t3_thread_list", { environmentId: box.environmentId, projectId: boxProject }],
+ ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }],
+ ["t3_thread_wait", { environmentId: box.environmentId, threadId: boxThread }],
+ [
+ "t3_thread_send",
+ { environmentId: box.environmentId, threadId: boxThread, message: "Carry on there." },
+ ],
+ [
+ "t3_thread_launch",
+ {
+ environmentId: box.environmentId,
+ projectId: boxProject,
+ title: "Start there",
+ modelSelection: { instanceId: "codex", model: "gpt-5" },
+ message: "Start there.",
+ },
+ ],
+ ] as const) {
+ const refused = yield* a.call("thread:laptop-full", name, args, previewOnly);
+ expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" });
+ }
+ expect(yield* Ref.get(b.bearers)).toEqual([]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts
new file mode 100644
index 0000000000000..de13d75550813
--- /dev/null
+++ b/apps/server/src/peer/PeerForwarding.ts
@@ -0,0 +1,193 @@
+import {
+ type EnvironmentId,
+ OrchestratorMcpFailure,
+ type OrchestratorMcpEnvironmentLinksResult,
+ OrchestratorMcpThreadWaitInput,
+ OrchestratorMcpThreadWaitResult,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Hex from "effect/encoding/Hex";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import type * as Tool from "effect/ai/Tool";
+
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import { readCaller, unavailable } from "../mcp/threadAccess.ts";
+import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as PeerLinks from "./PeerLinks.ts";
+import * as PeerMcpClient from "./PeerMcpClient.ts";
+
+/**
+ * A peer's `/mcp` holds a wait open for its whole timeout without sending
+ * anything, and T3 Connect's edge drops a request idle for about 100 s, so a
+ * forwarded wait goes out in pieces no longer than this.
+ */
+export const FORWARDED_WAIT_CHUNK_MS = 50_000;
+const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
+const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
+
+type Scope = McpInvocationContext.McpInvocationScope;
+
+/**
+ * Runs T3 MCP tools in a linked environment for an agent here. The tool's own
+ * declaration checks the agent here first; the linked environment then holds
+ * the call to the link's access, narrowed to the agent's modes, which every
+ * forwarded call carries.
+ */
+export class PeerForwarding extends Context.Service<
+ PeerForwarding,
+ {
+ /**
+ * Runs `tool` in the linked environment `environmentId` with `params`. A
+ * `t3_thread_wait` goes in pieces short enough to survive any route.
+ */
+ readonly call: (
+ scope: Scope,
+ tool: T,
+ environmentId: EnvironmentId,
+ params: Tool.Parameters,
+ ) => Effect.Effect, OrchestratorMcpFailure>;
+ /** `t3_thread_wait` there, in pieces short enough to survive any route. */
+ readonly waitForThread: (
+ scope: Scope,
+ environmentId: EnvironmentId,
+ input: OrchestratorMcpThreadWaitInput,
+ ) => Effect.Effect;
+ /** The environments this one links to, as an agent sees them. */
+ readonly links: (
+ scope: Scope,
+ ) => Effect.Effect;
+ }
+>()("t3/peer/PeerForwarding") {}
+
+/** The linked environment a call names, or nothing when it names this one. */
+export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) =>
+ environmentId === scope.environmentId ? undefined : environmentId;
+
+const make = Effect.gen(function* () {
+ const peers = yield* PeerMcpClient.PeerMcpClient;
+ const links = yield* PeerLinks.PeerLinks;
+ const crypto = yield* Crypto.Crypto;
+ const threads = yield* ThreadManagement.ThreadManagementService;
+
+ /**
+ * The caller's own limits here, which travel with every call it forwards.
+ * The peer sees only the link's credential, so a credential that may not
+ * control threads here is refused here.
+ */
+ const callerLimits = (scope: Scope) =>
+ readCaller().pipe(
+ Effect.map((caller) => caller.limits),
+ Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+ Effect.provideService(ThreadManagement.ThreadManagementService, threads),
+ );
+
+ /**
+ * Every caller here reaches the peer as the one link session, so a retry
+ * key is scoped to its caller before it leaves: two agents here reusing a
+ * key never collide there, and one agent retrying gets its first result.
+ */
+ const scopedRequestId = (namespace: string, clientRequestId: string) =>
+ crypto.digest("SHA-256", new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe(
+ Effect.map((digest) => `fwd:${Hex.encode(digest)}`),
+ Effect.orDie,
+ );
+
+ /** Tool and contract schemas are plain data: they encode and decode without services. */
+ const forward = Effect.fn("PeerForwarding.forward")(function* (
+ scope: Scope,
+ toolName: string,
+ parameters: Schema.Top,
+ success: Schema.Top,
+ environmentId: EnvironmentId,
+ params: unknown,
+ ) {
+ const limits = yield* callerLimits(scope);
+ const jsonParameters = Schema.toCodecJson(parameters as Schema.Encoder);
+ const encoded = (yield* Schema.encodeUnknownEffect(jsonParameters)(params).pipe(
+ Effect.mapError(unavailable),
+ )) as Record;
+ const { environmentId: _target, ...args } = encoded;
+ // A pending upload lives here, so it cannot go with the call.
+ if (Array.isArray(args.attachments) && args.attachments.length > 0) {
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "A launch in a linked environment cannot carry attachments yet.",
+ });
+ }
+ if (typeof args.clientRequestId === "string") {
+ args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId);
+ }
+ return yield* peers.call({
+ environmentId,
+ tool: toolName,
+ arguments: args,
+ limits,
+ success: Schema.toCodecJson(success as Schema.Decoder),
+ });
+ });
+
+ const waitForThread: PeerForwarding["Service"]["waitForThread"] = Effect.fn(
+ "PeerForwarding.waitForThread",
+ )(function* (scope, environmentId, input) {
+ let remaining = Math.min(
+ MAX_WAIT_TIMEOUT_MS,
+ Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
+ );
+ let runId = input.runId;
+ while (true) {
+ const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS);
+ const result = yield* forward(
+ scope,
+ "t3_thread_wait",
+ OrchestratorMcpThreadWaitInput,
+ OrchestratorMcpThreadWaitResult,
+ environmentId,
+ { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk },
+ );
+ remaining -= chunk;
+ if (!result.timedOut || remaining <= 0) return result;
+ // Later pieces wait for the run the first one picked, not a newer one.
+ runId = result.runId ?? undefined;
+ }
+ });
+
+ const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) =>
+ tool.name === "t3_thread_wait"
+ ? (waitForThread(
+ scope,
+ environmentId,
+ params as OrchestratorMcpThreadWaitInput,
+ ) as Effect.Effect, OrchestratorMcpFailure>)
+ : forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params);
+
+ const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")(
+ function* (scope) {
+ yield* callerLimits(scope);
+ const listed = yield* links.list.pipe(
+ Effect.mapError(
+ (error) =>
+ new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }),
+ ),
+ );
+ return {
+ environmentId: scope.environmentId,
+ links: listed.map((link) => ({
+ environmentId: link.environmentId,
+ label: link.label,
+ status: link.status,
+ access: link.access,
+ expiresAt: DateTime.formatIso(link.expiresAt),
+ lastError: link.lastError,
+ })),
+ };
+ },
+ );
+
+ return PeerForwarding.of({ call, waitForThread, links: listLinks });
+});
+
+export const layer = Layer.effect(PeerForwarding, make);
diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts
new file mode 100644
index 0000000000000..983cad32549f3
--- /dev/null
+++ b/apps/server/src/peer/PeerLinks.test.ts
@@ -0,0 +1,406 @@
+import { NodeHttpServer } from "@effect/platform-node";
+import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts";
+import { expect, it } from "@effect/vitest";
+import * as Context from "effect/Context";
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import * as TestClock from "effect/testing/TestClock";
+import { Tool, Toolkit } from "effect/ai";
+
+import * as McpHttpServer from "../mcp/McpHttpServer.ts";
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import * as McpToolAccess from "../mcp/McpToolAccess.ts";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as PeerLinks from "./PeerLinks.ts";
+import {
+ descriptorOf,
+ layerLinkingEnvironment,
+ servePeer,
+ type ServedPeer,
+} from "./PeerLinks.testkit.ts";
+import * as PeerMcpClient from "./PeerMcpClient.ts";
+
+/** What B's probe saw: the calling client and the modes it may start work with. */
+const Probe = Schema.Struct({ caller: Schema.String, modes: Schema.String });
+
+const ProbeToolkit = Toolkit.make(
+ Tool.make("probe", {
+ description: "Reports the calling client and the modes it may start work with.",
+ success: Probe,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: [McpInvocationContext.McpInvocationContext],
+ }),
+ Tool.make("refuse", {
+ description: "Always refuses.",
+ success: Probe,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ }),
+);
+
+const probeHandlers = McpToolAccess.toLayer(ProbeToolkit, {
+ probe: McpToolAccess.reads(() =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.map((scope) => {
+ const modes = McpInvocationContext.clientModeCeiling(scope.client);
+ return {
+ caller: scope.client?.label ?? "none",
+ modes: `${modes.runtimeMode}/${modes.interactionMode}`,
+ };
+ }),
+ ),
+ ),
+ refuse: McpToolAccess.reads(() =>
+ Effect.fail(
+ new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }),
+ ),
+ ),
+});
+
+const serveB = (descriptor: ExecutionEnvironmentDescriptor) =>
+ servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers));
+
+/** Environment A, which links to B. */
+const makeA = (
+ descriptor: ExecutionEnvironmentDescriptor,
+ beforeSecretRemove?: Effect.Effect,
+) =>
+ layerLinkingEnvironment(descriptor, beforeSecretRemove).pipe(
+ Layer.build,
+ Effect.map((context) => ({
+ links: Context.get(context, PeerLinks.PeerLinks),
+ peer: Context.get(context, PeerMcpClient.PeerMcpClient),
+ })),
+ );
+
+const laptop = descriptorOf("environment-laptop", "Laptop");
+const box = descriptorOf("environment-box", "Box");
+const probe = (
+ a: Effect.Success>,
+ limits: PeerMcpClient.PeerCallLimits = { runtimeMode: "full-access", interactionMode: "default" },
+ tool = "probe",
+) =>
+ a.peer.call({
+ environmentId: box.environmentId,
+ tool,
+ arguments: {},
+ limits,
+ success: Probe,
+ });
+
+const linkedSessions = (b: ServedPeer) => b.linkedSessions;
+
+it.effect("links with a pairing code, and the peer holds its agents to the limits they carry", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+
+ const refused = yield* a.links
+ .link({ url: b.url, pairingCode: "not-a-code", access: "auto" })
+ .pipe(Effect.flip);
+ expect(refused.reason).toBe("pairing_rejected");
+ expect(yield* linkedSessions(b)).toEqual([]);
+
+ const pairing = yield* b.auth.issuePairingCredential();
+ const linked = yield* a.links.link({
+ url: b.url,
+ pairingCode: pairing.credential,
+ access: "auto",
+ });
+ expect(linked).toMatchObject({
+ environmentId: box.environmentId,
+ label: "Box",
+ urls: [b.url],
+ access: "auto",
+ });
+ // B lists the link under A's name, where it can be revoked.
+ expect((yield* linkedSessions(b)).map((session) => session.client.label)).toEqual([
+ "T3 Code · Laptop",
+ ]);
+ expect(
+ (yield* a.links.list).map(({ environmentId, status }) => [environmentId, status]),
+ ).toEqual([[box.environmentId, "reachable"]]);
+
+ // The caller's limits narrow the link's access; they never widen it.
+ expect(
+ yield* probe(a, { runtimeMode: "approval-required", interactionMode: "plan" }),
+ ).toEqual({ caller: "T3 Code · Laptop", modes: "approval-required/plan" });
+ expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" });
+
+ // A refusal keeps the peer's reason.
+ const notFound = yield* probe(a, undefined, "refuse").pipe(Effect.flip);
+ expect(notFound).toMatchObject({ code: "thread_not_found", message: "No such thread." });
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("two links racing to one peer leave the stored access matching the token", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ const [first, second] = yield* Effect.all(
+ [b.auth.issuePairingCredential(), b.auth.issuePairingCredential()],
+ { concurrency: 2 },
+ );
+ yield* Effect.all(
+ [
+ a.links.link({ url: b.url, pairingCode: first.credential, access: "read-only" }),
+ a.links.link({ url: b.url, pairingCode: second.credential, access: "full-access" }),
+ ],
+ { concurrency: 2 },
+ );
+ const [stored] = yield* a.links.list;
+ // The token sent is the one issued with the access the row records.
+ const { modes } = yield* probe(a);
+ expect(modes).toBe(
+ stored!.access === "read-only" ? "approval-required/default" : "full-access/default",
+ );
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("never sends a link's token to an address that stops answering as that peer", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ const pairing = yield* b.auth.issuePairingCredential();
+ yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" });
+ yield* probe(a);
+ expect((yield* Ref.get(b.bearers)).length).toBeGreaterThan(0);
+
+ // The address now answers as another environment, as a reused LAN address would.
+ yield* Ref.set(b.descriptor, descriptorOf("environment-impostor", "Impostor"));
+ yield* Ref.set(b.bearers, []);
+ const failed = yield* probe(a).pipe(Effect.flip);
+ expect(failed.message).toContain("Box did not answer");
+ expect(yield* Ref.get(b.bearers)).toEqual([]);
+ expect((yield* a.links.list).map((link) => link.status)).toEqual(["unreachable"]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("reads one stored link without asking the peer", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ const pairing = yield* b.auth.issuePairingCredential();
+ yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" });
+ yield* Ref.set(b.probes, 0);
+
+ const stored = yield* a.links.get(box.environmentId);
+ expect(Option.map(stored, (link) => link.label)).toEqual(Option.some("Box"));
+ expect(Option.isNone(yield* a.links.get(laptop.environmentId))).toBe(true);
+ expect(yield* Ref.get(b.probes)).toBe(0);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("tells the agent to link again once the peer revokes the link or it expires", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ const pairing = yield* b.auth.issuePairingCredential();
+ yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" });
+ yield* probe(a);
+
+ const [session] = yield* linkedSessions(b);
+ yield* b.auth.revokeSession(session!.sessionId);
+ const revoked = yield* probe(a).pipe(Effect.flip);
+ expect(revoked.code).toBe("capability_denied");
+ expect(revoked.message).toContain("revoked there");
+
+ yield* TestClock.adjust(Duration.days(31));
+ expect((yield* a.links.list).map((link) => link.status)).toEqual(["expired"]);
+ yield* Ref.set(b.bearers, []);
+ const expired = yield* probe(a).pipe(Effect.flip);
+ expect(expired.code).toBe("capability_denied");
+ expect(expired.message).toContain("expired");
+ expect(yield* Ref.get(b.bearers)).toEqual([]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect(
+ "refuses to link itself, plain http off this machine and the tailnet, or a peer that cannot hold a linked agent to its limits",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(
+ descriptorOf("environment-box", "Box", { mcpModeLimitHeader: false }),
+ );
+ const a = yield* makeA(laptop);
+ const pairing = yield* b.auth.issuePairingCredential();
+ const older = yield* a.links
+ .link({ url: b.url, pairingCode: pairing.credential, access: "auto" })
+ .pipe(Effect.flip);
+ expect(older.reason).toBe("incompatible");
+
+ // Plain http to a LAN address would carry the code and token in clear.
+ const lan = yield* a.links
+ .link({
+ url: "http://192.168.1.20:3773",
+ pairingCode: pairing.credential,
+ access: "auto",
+ })
+ .pipe(Effect.flip);
+ expect(lan.message).toContain("plain http");
+ const lanAlternate = yield* a.links
+ .link({
+ url: b.url,
+ alternateUrls: ["http://192.168.1.20:3773"],
+ pairingCode: pairing.credential,
+ access: "auto",
+ })
+ .pipe(Effect.flip);
+ expect(lanAlternate.message).toContain("plain http");
+
+ yield* Ref.set(b.descriptor, laptop);
+ const self = yield* a.links
+ .link({ url: b.url, pairingCode: pairing.credential, access: "auto" })
+ .pipe(Effect.flip);
+ expect(self.reason).toBe("self");
+ // Neither attempt spent the pairing code on a session.
+ expect(yield* linkedSessions(b)).toEqual([]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("reads a tool's answer from an event stream carrying other messages first", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ const pairing = yield* b.auth.issuePairingCredential();
+ yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" });
+ const answered = yield* probe(a);
+
+ yield* Ref.set(b.sse, true);
+ expect(yield* probe(a)).toEqual(answered);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect(
+ "keeps a link and its token when forgetting it fails, and never forgets a newer link",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const removal = yield* Ref.make<"fail" | "pause" | null>(null);
+ const removalEntered = yield* Deferred.make();
+ const allowRemoval = yield* Deferred.make();
+ const a = yield* makeA(
+ laptop,
+ Ref.get(removal).pipe(
+ Effect.flatMap((action) =>
+ action === "fail"
+ ? Effect.fail(
+ new ServerSecretStore.SecretStoreRemoveError({
+ resource: "test credential",
+ cause: new Error("removal failed"),
+ }),
+ )
+ : action === "pause"
+ ? Deferred.succeed(removalEntered, undefined).pipe(
+ Effect.andThen(Deferred.await(allowRemoval)),
+ )
+ : Effect.void,
+ ),
+ ),
+ );
+ const pairing = yield* b.auth.issuePairingCredential();
+ yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" });
+ const linked = yield* a.links.get(box.environmentId);
+
+ yield* Ref.set(removal, "fail");
+ expect((yield* a.links.unlink(box.environmentId).pipe(Effect.flip)).reason).toBe("storage");
+ expect(yield* a.links.get(box.environmentId)).toEqual(linked);
+ expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" });
+
+ // Linking again while an unlink is forgetting the old token keeps the new link.
+ yield* Ref.set(removal, "pause");
+ const unlinking = yield* a.links.unlink(box.environmentId).pipe(Effect.forkScoped);
+ yield* Deferred.await(removalEntered);
+ yield* Ref.set(removal, null);
+ const repairing = yield* b.auth.issuePairingCredential();
+ const relinked = yield* a.links.link({
+ url: b.url,
+ pairingCode: repairing.credential,
+ access: "approval-required",
+ });
+ yield* Deferred.succeed(allowRemoval, undefined);
+ expect(yield* Fiber.join(unlinking)).toBe(false);
+ expect(yield* a.links.get(box.environmentId)).toEqual(Option.some(relinked));
+ expect(yield* probe(a)).toEqual({
+ caller: "T3 Code · Laptop",
+ modes: "approval-required/default",
+ });
+ expect(yield* a.links.unlink(box.environmentId)).toBe(true);
+ expect(yield* a.links.get(box.environmentId)).toEqual(Option.none());
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
+it.effect("never repeats an address's credentials or a peer's own text in a link error", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const b = yield* serveB(box);
+ const a = yield* makeA(laptop);
+ for (const url of [
+ "file://username:password@example.test/path?token=private",
+ "http://username:password@192.168.1.20:3773/path?token=private",
+ ]) {
+ const invalid = yield* a.links
+ .link({ url, pairingCode: "unused", access: "auto" })
+ .pipe(Effect.flip);
+ expect(invalid.message).not.toMatch(/username|password|private|example\.test/);
+ }
+ const peerText = "PRIVATE-PEER-RESPONSE\n".repeat(200);
+ for (const [path, body, message] of [
+ [
+ "/oauth/mcp/register",
+ { error: "invalid_client_metadata", error_description: peerText },
+ "The peer refused to register this environment. Check its connection settings.",
+ ],
+ [
+ "/oauth/mcp/decision",
+ { _tag: "AuthMcpApprovalError", message: peerText },
+ "The peer refused link approval. Check the pairing code and try again.",
+ ],
+ [
+ "/oauth/mcp/token",
+ { error: "invalid_grant", error_description: peerText },
+ "The peer refused the sign-in token. Create a new pairing code and try again.",
+ ],
+ [
+ "/oauth/mcp/decision",
+ { redirectTo: "http://localhost/?error_description=" + encodeURIComponent(peerText) },
+ "The peer did not approve the link. Check the pairing code and try again.",
+ ],
+ ] as const) {
+ yield* Ref.set(
+ b.replies,
+ new Map([[path, { status: "redirectTo" in body ? 200 : 400, body }]]),
+ );
+ const code = yield* b.auth.issuePairingCredential();
+ const failed = yield* a.links
+ .link({ url: b.url, pairingCode: code.credential, access: "auto" })
+ .pipe(Effect.flip);
+ expect(failed.reason).toBe("pairing_rejected");
+ expect(failed.message).toBe(message);
+ }
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts
new file mode 100644
index 0000000000000..7110eafcc9287
--- /dev/null
+++ b/apps/server/src/peer/PeerLinks.testkit.ts
@@ -0,0 +1,221 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import {
+ EnvironmentHttpApi,
+ EnvironmentId,
+ type AuthMcpClientAccess,
+ type ExecutionEnvironmentDescriptor,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Ref from "effect/Ref";
+import * as FetchHttpClient from "effect/http/FetchHttpClient";
+import * as HttpRouter from "effect/http/HttpRouter";
+import * as HttpServer from "effect/http/HttpServer";
+import * as HttpServerRequest from "effect/http/HttpServerRequest";
+import * as HttpServerResponse from "effect/http/HttpServerResponse";
+import * as HttpApi from "effect/http-api/HttpApi";
+import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
+
+import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
+import * as AuthHttp from "../auth/http.ts";
+import * as McpOAuth from "../auth/McpOAuth.ts";
+import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerConfig from "../config.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as ServerHttp from "../http.ts";
+import * as McpHttpServer from "../mcp/McpHttpServer.ts";
+import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
+import * as Sqlite from "../persistence/Sqlite.ts";
+import * as PeerLinks from "./PeerLinks.ts";
+import * as PeerMcpClient from "./PeerMcpClient.ts";
+
+class PeerTestApi extends HttpApi.make("environment")
+ .add(EnvironmentHttpApi.groups.metadata)
+ .add(EnvironmentHttpApi.groups.mcpOAuth) {}
+
+export const descriptorOf = (
+ environmentId: string,
+ label: string,
+ capabilities: Partial = {},
+): ExecutionEnvironmentDescriptor => ({
+ environmentId: EnvironmentId.make(environmentId),
+ label,
+ platform: { os: "linux", arch: "x64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities },
+});
+
+/**
+ * The environment a link points at, as it runs: its descriptor, MCP OAuth,
+ * and `/mcp` with `tools` behind the real client authenticator, on a real
+ * socket. The descriptor can be swapped, as when its address comes to answer
+ * as another environment, and every bearer it receives is recorded.
+ */
+export const servePeer = (
+ initial: ExecutionEnvironmentDescriptor,
+ tools: Layer.Layer,
+) =>
+ Effect.gen(function* () {
+ const descriptor = yield* Ref.make(initial);
+ const bearers = yield* Ref.make>([]);
+ const probes = yield* Ref.make(0);
+ // Answers tool calls as an event stream with other messages ahead of the answer.
+ const sse = yield* Ref.make(false);
+ // Canned answers by path, standing in for a peer's own refusals.
+ const replies = yield* Ref.make>(
+ new Map(),
+ );
+ const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, {
+ getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)),
+ getDescriptor: Ref.get(descriptor),
+ });
+ const authContext = yield* EnvironmentAuth.layer.pipe(
+ Layer.provide(Sqlite.layerMemory),
+ Layer.provideMerge(ServerSecretStore.layer),
+ Layer.provideMerge(ServerEnvironment.layerIdentity),
+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-peer-" })),
+ Layer.provideMerge(NodeServices.layer),
+ Layer.fresh,
+ Layer.build,
+ );
+ const layerRecordBearers = HttpRouter.middleware(
+ (httpEffect) =>
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ if (request.url.startsWith("/.well-known/t3/environment")) {
+ yield* Ref.update(probes, (count) => count + 1);
+ }
+ const authorization = request.headers.authorization;
+ if (authorization !== undefined) {
+ yield* Ref.update(bearers, (seen) => [...seen, authorization]);
+ }
+ const reply = (yield* Ref.get(replies)).get(request.url);
+ if (reply !== undefined)
+ return HttpServerResponse.jsonUnsafe(reply.body, { status: reply.status });
+ const response = yield* httpEffect;
+ if (
+ !(yield* Ref.get(sse)) ||
+ request.url !== "/mcp" ||
+ response.status !== 200 ||
+ response.body._tag !== "Uint8Array"
+ )
+ return response;
+ const answer = new TextDecoder().decode(response.body.body);
+ const split = answer.indexOf(",") + 1;
+ return HttpServerResponse.text(
+ [
+ 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}',
+ "",
+ 'data: {"jsonrpc":"2.0","id":999,"error":{"code":-1,"message":"other request"}}',
+ "",
+ `data: ${answer.slice(0, split)}`,
+ `data: ${answer.slice(split)}`,
+ "",
+ "",
+ ].join("\r\n"),
+ { contentType: "text/event-stream" },
+ );
+ }),
+ { global: true },
+ );
+ const layerRoutes = Layer.mergeAll(
+ HttpApiBuilder.layer(PeerTestApi).pipe(
+ Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))),
+ Layer.provide(ServerHttp.layerServerEnvironmentHttpApi),
+ Layer.provide(AuthHttp.layerAuthenticatedAuth),
+ ),
+ tools.pipe(
+ Layer.provideMerge(McpHttpServer.layerMcpTransport),
+ Layer.provide(
+ Layer.mock(McpSessionRegistry.McpSessionRegistry)({
+ resolve: () => Effect.succeed(undefined),
+ }),
+ ),
+ Layer.provide(McpOAuth.layerMcpClientAuthenticator),
+ ),
+ ).pipe(
+ Layer.provide(layerRecordBearers),
+ Layer.provide(layerEnvironment),
+ Layer.provide(Layer.succeedContext(authContext)),
+ );
+ // Fresh, so the peer never shares a toolkit or MCP server with the
+ // environment that links to it in the same test.
+ yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe(
+ Layer.fresh,
+ Layer.build,
+ );
+ const address = (yield* HttpServer.HttpServer).address;
+ if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address");
+ const url = `http://127.0.0.1:${address.port}`;
+ const auth = Context.get(authContext, EnvironmentAuth.EnvironmentAuth);
+ return {
+ url,
+ auth,
+ descriptor,
+ bearers,
+ sse,
+ replies,
+ /** How often its descriptor was asked for, as a probe of whether it answers. */
+ probes,
+ /** The sessions linking created there, which its Connections lists. */
+ linkedSessions: auth
+ .listSessions()
+ .pipe(
+ Effect.map((sessions) =>
+ sessions.filter((session) => session.client.label?.startsWith("T3 Code · ")),
+ ),
+ ),
+ };
+ });
+
+export type ServedPeer = Effect.Success>;
+
+/**
+ * The environment that links: its own database and secret store, reaching
+ * peers over plain HTTP. Provide it to anything built on its links.
+ */
+export const layerLinkingEnvironment = (
+ descriptor: ExecutionEnvironmentDescriptor,
+ beforeSecretRemove: Effect.Effect = Effect.void,
+) =>
+ PeerMcpClient.layer.pipe(
+ Layer.provideMerge(PeerLinks.layer),
+ Layer.provide(
+ Layer.mergeAll(
+ Sqlite.layerMemory,
+ Layer.effect(
+ ServerSecretStore.ServerSecretStore,
+ Effect.map(ServerSecretStore.ServerSecretStore, (secrets) =>
+ ServerSecretStore.ServerSecretStore.of({
+ ...secrets,
+ remove: (name) => beforeSecretRemove.pipe(Effect.andThen(secrets.remove(name))),
+ }),
+ ),
+ ).pipe(Layer.provide(ServerSecretStore.layer)),
+ FetchHttpClient.layer,
+ Layer.succeed(ServerEnvironment.ServerEnvironment, {
+ getEnvironmentId: Effect.succeed(descriptor.environmentId),
+ getDescriptor: Effect.succeed(descriptor),
+ }),
+ ),
+ ),
+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-here-" })),
+ Layer.provideMerge(NodeServices.layer),
+ Layer.fresh,
+ );
+
+/** Links `links` to `peer` with a fresh pairing code from it. */
+export const linkTo = (
+ links: PeerLinks.PeerLinks["Service"],
+ peer: ServedPeer,
+ access: AuthMcpClientAccess = "auto",
+) =>
+ peer.auth
+ .issuePairingCredential()
+ .pipe(
+ Effect.flatMap((pairing) =>
+ links.link({ url: peer.url, pairingCode: pairing.credential, access }),
+ ),
+ );
diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts
new file mode 100644
index 0000000000000..ade53f16e1e8b
--- /dev/null
+++ b/apps/server/src/peer/PeerLinks.ts
@@ -0,0 +1,490 @@
+import {
+ AuthMcpClientAccess,
+ AuthMcpRegistrationError,
+ AuthMcpTokenError,
+ EnvironmentHttpApi,
+ EnvironmentId,
+ type PeerLink,
+ type PeerLinkCreateInput,
+ PeerLinkError,
+ type PeerLinkStatus,
+ type PeerLinkSummary,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Base64Url from "effect/encoding/Base64Url";
+import * as Hex from "effect/encoding/Hex";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/sql/SqlClient";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpApiClient from "effect/http-api/HttpApiClient";
+import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification";
+
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+
+/** The redirect registered for a link. Nothing ever listens there: the code comes back in the response. */
+const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link";
+/**
+ * How long a peer's descriptor may take before a route counts as unreachable.
+ * A tailnet or relayed route to a machine that is waking up takes longer.
+ */
+const probeTimeout = (origin: string) =>
+ isLocalLoopbackHost(new URL(origin).hostname) ? Duration.seconds(3) : Duration.seconds(10);
+
+/** A usable link: where to reach the peer and the token to send there. */
+export interface ResolvedPeerLink {
+ readonly link: PeerLink;
+ /** The first address that answered as this peer. */
+ readonly url: string;
+ readonly token: string;
+}
+
+/**
+ * Links from this environment to others. Linking signs this environment in to
+ * the peer's `/mcp` as an outside agent with a pairing code from the peer, the
+ * same MCP OAuth flow an outside agent uses, without a browser. The peer's
+ * session token is kept in the server secret store; the table keeps the rest.
+ */
+export class PeerLinks extends Context.Service<
+ PeerLinks,
+ {
+ readonly link: (input: PeerLinkCreateInput) => Effect.Effect;
+ readonly list: Effect.Effect, PeerLinkError>;
+ /** The stored link, without asking the peer whether it answers. */
+ readonly get: (
+ environmentId: EnvironmentId,
+ ) => Effect.Effect, PeerLinkError>;
+ /** Forgets the link here. The peer still lists the session until it is revoked there. */
+ readonly unlink: (environmentId: EnvironmentId) => Effect.Effect;
+ /** An address that answers as the linked peer, with its token, ready to call. */
+ readonly resolve: (
+ environmentId: EnvironmentId,
+ ) => Effect.Effect;
+ /** Records the outcome of a call, so listing shows whether the peer answered last time. */
+ readonly recordOutcome: (
+ environmentId: EnvironmentId,
+ outcome: { readonly error: string | null },
+ ) => Effect.Effect;
+ }
+>()("t3/peer/PeerLinks") {}
+
+interface PeerLinkRow {
+ readonly environment_id: string;
+ readonly secret_name: string;
+ readonly label: string;
+ readonly urls_json: string;
+ readonly access: string;
+ readonly linked_at: string;
+ readonly expires_at: string;
+ readonly last_reached_at: string | null;
+ readonly last_error: string | null;
+}
+
+const decodeUrls = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.String)));
+const encodeUrls = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String)));
+const decodeAccess = Schema.decodeUnknownEffect(AuthMcpClientAccess);
+
+/** Fails with a fixed storage error that keeps its cause; the log gets the details. */
+const failStorage = (cause: unknown) =>
+ Effect.logWarning("Peer link storage failed", cause).pipe(
+ Effect.andThen(
+ Effect.fail(
+ new PeerLinkError({
+ reason: "storage",
+ message:
+ "Linked environments could not be read or saved here. The server log has details.",
+ cause,
+ }),
+ ),
+ ),
+ );
+
+/**
+ * The base URL a peer is reached at, normalised to its origin. The pairing
+ * code and then the link's token travel to it, so plain http is accepted only
+ * where the network already encrypts: this machine, or a Tailscale address.
+ */
+const peerOrigin = (url: string) => {
+ const parsed = URL.parse(url);
+ if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) {
+ return Effect.fail(
+ new PeerLinkError({
+ reason: "unreachable",
+ message: "The peer address must use http or https.",
+ }),
+ );
+ }
+ if (
+ parsed.protocol === "http:" &&
+ !isLocalLoopbackHost(parsed.hostname) &&
+ !isTailnetHost(parsed.hostname)
+ ) {
+ return Effect.fail(
+ new PeerLinkError({
+ reason: "unreachable",
+ message:
+ "The peer address uses plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.",
+ }),
+ );
+ }
+ return Effect.succeed(parsed.origin);
+};
+
+const make = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const crypto = yield* Crypto.Crypto;
+ const httpClient = yield* HttpClient.HttpClient;
+ const environment = yield* ServerEnvironment.ServerEnvironment;
+
+ /** A fresh name for one link's token; the row records it. */
+ const newSecretName = crypto.randomBytes(16).pipe(
+ Effect.map((bytes) => `peer-link-${Hex.encode(bytes)}`),
+ Effect.orDie,
+ );
+
+ const clientFor = (origin: string) =>
+ HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe(
+ Effect.provideService(HttpClient.HttpClient, httpClient),
+ );
+
+ /** The environment answering at `origin`, or nothing when it does not answer as T3 Code. */
+ const describe = (origin: string) =>
+ clientFor(origin).pipe(
+ Effect.flatMap((client) => client.metadata.descriptor()),
+ Effect.timeout(probeTimeout(origin)),
+ Effect.option,
+ );
+
+ const toLink = Effect.fn("PeerLinks.toLink")(function* (row: PeerLinkRow) {
+ return {
+ environmentId: EnvironmentId.make(row.environment_id),
+ label: row.label,
+ urls: yield* decodeUrls(row.urls_json),
+ access: yield* decodeAccess(row.access),
+ linkedAt: DateTime.makeUnsafe(row.linked_at),
+ expiresAt: DateTime.makeUnsafe(row.expires_at),
+ lastReachedAt: row.last_reached_at === null ? null : DateTime.makeUnsafe(row.last_reached_at),
+ lastError: row.last_error,
+ } satisfies PeerLink;
+ }, Effect.catch(failStorage));
+
+ const readRows = sql`
+ SELECT * FROM peer_environment_links ORDER BY label, environment_id
+ `.pipe(Effect.catch(failStorage));
+
+ const readRow = (environmentId: EnvironmentId) =>
+ sql`
+ SELECT * FROM peer_environment_links WHERE environment_id = ${environmentId}
+ `.pipe(
+ Effect.catch(failStorage),
+ Effect.map((rows) => Option.fromNullishOr(rows[0])),
+ );
+
+ /** Signs in to the peer's `/mcp` with a pairing code: register, decide, exchange. */
+ const signIn = Effect.fn("PeerLinks.signIn")(
+ function* (origin: string, input: PeerLinkCreateInput, clientName: string) {
+ const client = yield* clientFor(origin);
+ // Only the peer's own refusals are rejections. A dropped connection or
+ // an answer this version cannot read is classified below.
+ const registered = yield* client.mcpOAuth
+ .register({ payload: { client_name: clientName, redirect_uris: [LINK_REDIRECT_URI] } })
+ .pipe(
+ // A plain Schema.Error has no tag to catch by.
+ Effect.catchIf(Schema.is(AuthMcpRegistrationError), (error) =>
+ Effect.fail(
+ new PeerLinkError({
+ reason: "pairing_rejected",
+ message:
+ "The peer refused to register this environment. Check its connection settings.",
+ cause: error,
+ }),
+ ),
+ ),
+ );
+ const verifier = Base64Url.encode(yield* crypto.randomBytes(32).pipe(Effect.orDie));
+ const challenge = Base64Url.encode(
+ yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe(Effect.orDie),
+ );
+ const state = Base64Url.encode(yield* crypto.randomBytes(16).pipe(Effect.orDie));
+ const resource = `${origin}/mcp`;
+ const decided = yield* client.mcpOAuth
+ .decision({
+ payload: {
+ authorization: {
+ response_type: "code",
+ client_id: registered.client_id,
+ redirect_uri: LINK_REDIRECT_URI,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ state,
+ resource,
+ },
+ decision: { _tag: "pairing-code", access: input.access, code: input.pairingCode },
+ },
+ })
+ .pipe(
+ Effect.catchTags({
+ AuthMcpApprovalError: (error) =>
+ Effect.fail(
+ new PeerLinkError({
+ reason: "pairing_rejected",
+ message: "The peer refused link approval. Check the pairing code and try again.",
+ cause: error,
+ }),
+ ),
+ }),
+ );
+ const redirect = URL.parse(decided.redirectTo);
+ if (redirect === null) {
+ return yield* new PeerLinkError({
+ reason: "incompatible",
+ message: `${origin} answered the sign-in with an address this version cannot read. Update both environments.`,
+ });
+ }
+ const code = redirect.searchParams.get("code");
+ if (code === null || redirect.searchParams.get("state") !== state) {
+ return yield* new PeerLinkError({
+ reason: "pairing_rejected",
+ message: "The peer did not approve the link. Check the pairing code and try again.",
+ });
+ }
+ return yield* client.mcpOAuth
+ .token({
+ payload: {
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: LINK_REDIRECT_URI,
+ client_id: registered.client_id,
+ code_verifier: verifier,
+ resource,
+ },
+ })
+ .pipe(
+ Effect.catchIf(Schema.is(AuthMcpTokenError), (error) =>
+ Effect.fail(
+ new PeerLinkError({
+ reason: "pairing_rejected",
+ message:
+ "The peer refused the sign-in token. Create a new pairing code and try again.",
+ cause: error,
+ }),
+ ),
+ ),
+ );
+ },
+ (effect, origin) =>
+ effect.pipe(
+ // Endpoint errors are mapped above; what is left is the transport or an
+ // answer this version cannot decode.
+ Effect.catchTags({
+ SchemaError: (cause) =>
+ Effect.fail(
+ new PeerLinkError({
+ reason: "incompatible",
+ message: `${origin} answered in a shape this version does not understand. Update both environments.`,
+ cause,
+ }),
+ ),
+ HttpClientError: (cause) =>
+ Effect.fail(
+ new PeerLinkError({
+ reason: "unreachable",
+ message: `${origin} stopped answering while linking.`,
+ cause,
+ }),
+ ),
+ }),
+ ),
+ );
+
+ const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) {
+ const origins = yield* Effect.forEach([input.url, ...(input.alternateUrls ?? [])], peerOrigin);
+ const primary = origins[0]!;
+ const descriptor = yield* describe(primary);
+ if (Option.isNone(descriptor)) {
+ return yield* new PeerLinkError({
+ reason: "not_a_t3_environment",
+ message: `Nothing at ${primary} answered as a T3 Code environment.`,
+ });
+ }
+ const peer = descriptor.value;
+ const self = yield* environment.getDescriptor;
+ if (peer.environmentId === self.environmentId) {
+ return yield* new PeerLinkError({
+ reason: "self",
+ message: "That address is this environment.",
+ });
+ }
+ if (peer.capabilities.mcpModeLimitHeader !== true) {
+ return yield* new PeerLinkError({
+ reason: "incompatible",
+ message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`,
+ });
+ }
+ const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`);
+ const now = yield* DateTime.now;
+ const linked: PeerLink = {
+ environmentId: peer.environmentId,
+ label: peer.label,
+ urls: origins,
+ access: input.access,
+ linkedAt: now,
+ expiresAt: DateTime.add(now, { seconds: issued.expires_in }),
+ lastReachedAt: now,
+ lastError: null,
+ };
+ // The token goes under a name of its own before the row points at it,
+ // so the row and its token are replaced together.
+ const secretName = yield* newSecretName;
+ yield* secrets
+ .set(secretName, new TextEncoder().encode(issued.access_token))
+ .pipe(Effect.catch(failStorage));
+ const urlsJson = yield* encodeUrls(origins).pipe(Effect.catch(failStorage));
+ const previous = yield* readRow(peer.environmentId);
+ yield* sql`
+ INSERT INTO peer_environment_links (
+ environment_id, secret_name, label, urls_json, access, linked_at, expires_at,
+ last_reached_at, last_error
+ ) VALUES (
+ ${linked.environmentId}, ${secretName}, ${linked.label}, ${urlsJson}, ${linked.access},
+ ${DateTime.formatIso(linked.linkedAt)}, ${DateTime.formatIso(linked.expiresAt)},
+ ${DateTime.formatIso(now)}, NULL
+ )
+ ON CONFLICT (environment_id) DO UPDATE SET
+ secret_name = excluded.secret_name,
+ label = excluded.label,
+ urls_json = excluded.urls_json,
+ access = excluded.access,
+ linked_at = excluded.linked_at,
+ expires_at = excluded.expires_at,
+ last_reached_at = excluded.last_reached_at,
+ last_error = NULL
+ `.pipe(Effect.catch(failStorage));
+ // Linking again replaces the old token. A racing link may leave its own
+ // behind; it is unused either way.
+ if (Option.isSome(previous) && previous.value.secret_name !== secretName) {
+ yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore);
+ }
+ return linked;
+ });
+
+ const statusOf = (link: PeerLink, now: DateTime.Utc) =>
+ DateTime.isLessThanOrEqualTo(link.expiresAt, now)
+ ? Effect.succeed("expired")
+ : Effect.findFirst(link.urls, (url) =>
+ describe(url).pipe(
+ Effect.map(
+ (found) => Option.isSome(found) && found.value.environmentId === link.environmentId,
+ ),
+ ),
+ ).pipe(
+ Effect.map((answered): PeerLinkStatus =>
+ Option.isSome(answered) ? "reachable" : "unreachable",
+ ),
+ );
+
+ const list: PeerLinks["Service"]["list"] = Effect.gen(function* () {
+ const links = yield* Effect.forEach(yield* readRows, toLink);
+ const now = yield* DateTime.now;
+ return yield* Effect.forEach(
+ links,
+ (link) => statusOf(link, now).pipe(Effect.map((status) => ({ ...link, status }))),
+ { concurrency: "unbounded" },
+ );
+ });
+
+ const get: PeerLinks["Service"]["get"] = (environmentId) =>
+ readRow(environmentId).pipe(
+ Effect.flatMap(
+ Option.match({
+ onNone: () => Effect.succeed(Option.none()),
+ onSome: (row) => Effect.map(toLink(row), Option.some),
+ }),
+ ),
+ );
+
+ const unlink: PeerLinks["Service"]["unlink"] = Effect.fn("PeerLinks.unlink")(
+ function* (environmentId) {
+ const row = yield* readRow(environmentId);
+ if (Option.isNone(row)) return false;
+ yield* secrets.remove(row.value.secret_name).pipe(Effect.catch(failStorage));
+ const deleted = yield* sql<{ secret_name: string }>`
+ DELETE FROM peer_environment_links
+ WHERE environment_id = ${environmentId} AND secret_name = ${row.value.secret_name}
+ RETURNING secret_name
+ `.pipe(Effect.catch(failStorage));
+ return deleted.length > 0;
+ },
+ );
+
+ const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")(
+ function* (environmentId) {
+ const row = yield* readRow(environmentId);
+ if (Option.isNone(row)) {
+ return yield* new PeerLinkError({
+ reason: "unknown_link",
+ message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`,
+ });
+ }
+ const link = yield* toLink(row.value);
+ if (DateTime.isLessThanOrEqualTo(link.expiresAt, yield* DateTime.now)) {
+ return yield* new PeerLinkError({
+ reason: "expired",
+ message: `The link to ${link.label} expired. Link it again with a new pairing code from ${link.label}.`,
+ });
+ }
+ const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage));
+ if (Option.isNone(stored)) {
+ return yield* new PeerLinkError({
+ reason: "unknown_link",
+ message: `The link to ${link.label} lost its credential. Link it again.`,
+ });
+ }
+ // The token goes only to an address that proves it is this peer, so a
+ // reused LAN address or a moved hostname never receives it.
+ const url = yield* Effect.findFirst(link.urls, (candidate) =>
+ describe(candidate).pipe(
+ Effect.map(
+ (found) => Option.isSome(found) && found.value.environmentId === environmentId,
+ ),
+ ),
+ );
+ if (Option.isNone(url)) {
+ return yield* new PeerLinkError({
+ reason: "unreachable",
+ message: `${link.label} did not answer at ${link.urls.join(", ")}.`,
+ });
+ }
+ return { link, url: url.value, token: new TextDecoder().decode(stored.value) };
+ },
+ );
+
+ const recordOutcome: PeerLinks["Service"]["recordOutcome"] = (environmentId, outcome) =>
+ DateTime.now.pipe(
+ Effect.flatMap((now) =>
+ outcome.error === null
+ ? sql`
+ UPDATE peer_environment_links
+ SET last_reached_at = ${DateTime.formatIso(now)}, last_error = NULL
+ WHERE environment_id = ${environmentId}
+ `
+ : sql`
+ UPDATE peer_environment_links SET last_error = ${outcome.error}
+ WHERE environment_id = ${environmentId}
+ `,
+ ),
+ Effect.asVoid,
+ Effect.ignoreCause({ log: true }),
+ );
+
+ return PeerLinks.of({ link, list, get, unlink, resolve, recordOutcome });
+});
+
+export const layer = Layer.effect(PeerLinks, make);
diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts
new file mode 100644
index 0000000000000..30adbbdc86aee
--- /dev/null
+++ b/apps/server/src/peer/PeerMcpClient.ts
@@ -0,0 +1,270 @@
+import {
+ type EnvironmentId,
+ OrchestratorMcpFailure,
+ type PeerLinkError,
+ type ProviderInteractionMode,
+ type RuntimeMode,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import * as HttpBody from "effect/http/HttpBody";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientRequest from "effect/http/HttpClientRequest";
+
+import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
+import * as PeerLinks from "./PeerLinks.ts";
+
+/** The only MCP protocol version a T3 Code `/mcp` registers. */
+const PROTOCOL_VERSION = "2025-06-18";
+
+/** The limits a call carries to the peer: the calling agent's modes here. */
+export interface PeerCallLimits {
+ readonly runtimeMode: RuntimeMode;
+ readonly interactionMode: ProviderInteractionMode;
+}
+
+/**
+ * Calls a linked environment's T3 MCP tools as this environment's outside
+ * agent. Each call carries the calling agent's limits in `T3-Mode-Limit`, so
+ * the peer enforces the narrower of those and the link's own access.
+ */
+export class PeerMcpClient extends Context.Service<
+ PeerMcpClient,
+ {
+ /** Calls `tool` on the peer and decodes its structured result with `success`. */
+ readonly call: >(input: {
+ readonly environmentId: EnvironmentId;
+ readonly tool: string;
+ readonly arguments: Record;
+ readonly limits: PeerCallLimits;
+ readonly success: S;
+ }) => Effect.Effect;
+ }
+>()("t3/peer/PeerMcpClient") {}
+
+const ToolCallResponse = Schema.Struct({
+ id: Schema.Number,
+ result: Schema.optional(
+ Schema.Struct({
+ content: Schema.Array(
+ Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) }),
+ ),
+ structuredContent: Schema.optional(Schema.Unknown),
+ isError: Schema.optional(Schema.Boolean),
+ }),
+ ),
+ error: Schema.optional(Schema.Struct({ code: Schema.Number, message: Schema.String })),
+});
+const ToolCallResponseJson = Schema.fromJsonString(ToolCallResponse);
+const decodeToolCallResponse = Schema.decodeUnknownEffect(ToolCallResponseJson);
+const decodeToolCallResponseOption = Schema.decodeUnknownOption(ToolCallResponseJson);
+const decodeFailure = Schema.decodeUnknownOption(
+ Schema.fromJsonString(
+ Schema.Struct({ code: OrchestratorMcpFailure.fields.code, message: Schema.String }),
+ ),
+);
+
+/** A peer link problem, as the agent that asked hears it. */
+const fromLinkError = (error: PeerLinkError) =>
+ new OrchestratorMcpFailure({
+ code:
+ error.reason === "unknown_link"
+ ? "invalid_request"
+ : error.reason === "expired"
+ ? "capability_denied"
+ : "orchestration_error",
+ message: error.message,
+ });
+
+/**
+ * The body of an MCP response: JSON, or the server-sent event answering
+ * `requestId`. A peer may stream notifications ahead of the answer.
+ */
+const responseJson = (contentType: string | undefined, text: string, requestId: number) => {
+ if (contentType?.includes("text/event-stream") !== true) return text;
+ for (const event of text.replace(/\r\n?/g, "\n").split("\n\n")) {
+ const data = event
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice("data:".length).replace(/^ /, ""))
+ .join("\n");
+ const decoded = decodeToolCallResponseOption(data);
+ if (
+ decoded._tag === "Some" &&
+ decoded.value.id === requestId &&
+ (decoded.value.result !== undefined || decoded.value.error !== undefined)
+ ) {
+ return data;
+ }
+ }
+ return "";
+};
+
+const make = Effect.gen(function* () {
+ const links = yield* PeerLinks.PeerLinks;
+ const httpClient = yield* HttpClient.HttpClient;
+ // One MCP session per peer and address, reused across calls. The peer keeps
+ // sessions in memory, so a restart there answers 404 and we start a new one.
+ const sessions = yield* Ref.make>(new Map());
+
+ const post = (url: string, token: string, body: unknown, headers: Record) =>
+ httpClient.execute(
+ HttpClientRequest.post(`${url}/mcp`).pipe(
+ HttpClientRequest.setHeaders({
+ accept: "application/json, text/event-stream",
+ authorization: `Bearer ${token}`,
+ ...headers,
+ }),
+ HttpClientRequest.setBody(HttpBody.text(JSON.stringify(body), "application/json")),
+ ),
+ );
+
+ const openSession = Effect.fn("PeerMcpClient.openSession")(function* (
+ url: string,
+ token: string,
+ limitHeader: Record,
+ ) {
+ const initialized = yield* post(
+ url,
+ token,
+ {
+ jsonrpc: "2.0",
+ id: 0,
+ method: "initialize",
+ params: {
+ protocolVersion: PROTOCOL_VERSION,
+ capabilities: {},
+ clientInfo: { name: "t3code-peer", version: "1" },
+ },
+ },
+ limitHeader,
+ );
+ const sessionId = initialized.headers["mcp-session-id"];
+ if (initialized.status !== 200 || sessionId === undefined) {
+ return yield* new OrchestratorMcpFailure({
+ code: initialized.status === 401 ? "capability_denied" : "orchestration_error",
+ message:
+ initialized.status === 401
+ ? "The linked environment no longer accepts this link. It may have been revoked there; link it again."
+ : `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`,
+ });
+ }
+ yield* post(
+ url,
+ token,
+ { jsonrpc: "2.0", method: "notifications/initialized" },
+ { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION },
+ );
+ return sessionId;
+ });
+
+ const call: PeerMcpClient["Service"]["call"] = Effect.fn("PeerMcpClient.call")(
+ function* (input) {
+ const resolved = yield* links
+ .resolve(input.environmentId)
+ .pipe(Effect.mapError(fromLinkError));
+ const limitHeader = {
+ [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`,
+ };
+ const sessionKey = `${input.environmentId} ${resolved.url}`;
+ const callOnce = (sessionId: string) =>
+ post(
+ resolved.url,
+ resolved.token,
+ {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: { name: input.tool, arguments: input.arguments },
+ },
+ { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION },
+ );
+ const existing = (yield* Ref.get(sessions)).get(sessionKey);
+ const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader));
+ let response = yield* callOnce(sessionId);
+ if (response.status === 404) {
+ const renewed = yield* openSession(resolved.url, resolved.token, limitHeader);
+ yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed));
+ response = yield* callOnce(renewed);
+ } else if (existing === undefined) {
+ yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId));
+ }
+ if (response.status === 401) {
+ return yield* new OrchestratorMcpFailure({
+ code: "capability_denied",
+ message:
+ "The linked environment no longer accepts this link. It may have been revoked there; link it again.",
+ });
+ }
+ if (response.status !== 200) {
+ return yield* new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: `The linked environment answered HTTP ${response.status}.`,
+ });
+ }
+ const body = yield* decodeToolCallResponse(
+ responseJson(response.headers["content-type"], yield* response.text, 1),
+ ).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: "The linked environment answered in an unexpected shape.",
+ }),
+ ),
+ );
+ if (body.error !== undefined || body.result === undefined) {
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: body.error?.message ?? "The linked environment returned no result.",
+ });
+ }
+ const text = body.result.content.map((part) => part.text ?? "").join("");
+ if (body.result.isError === true) {
+ // A refusal keeps the peer's own code, so the agent sees why it was refused.
+ const refused = decodeFailure(text);
+ return yield* refused._tag === "Some"
+ ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message })
+ : new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: text || "The linked environment refused the call.",
+ });
+ }
+ const success = Schema.fromJsonString(input.success);
+ return yield* Schema.decodeUnknownEffect(success)(text).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message:
+ "The linked environment's answer did not match this version. Update both environments.",
+ }),
+ ),
+ );
+ },
+ (effect, input) =>
+ effect.pipe(
+ Effect.tapError((error) =>
+ links.recordOutcome(input.environmentId, { error: error.message }),
+ ),
+ Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })),
+ // Recorded above with the transport's own message; the agent hears it plainly.
+ Effect.catchTags({
+ HttpClientError: () =>
+ Effect.fail(
+ new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: "The linked environment stopped answering.",
+ }),
+ ),
+ }),
+ ),
+ );
+
+ return PeerMcpClient.of({ call });
+});
+
+export const layer = Layer.effect(PeerMcpClient, make);
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 5979eee6c18d7..7ec9ec7169b45 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -70,6 +70,7 @@ import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.t
import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts";
import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts";
import Migration0067 from "./Migrations/067_WebhookDedupe.ts";
+import Migration0068 from "./Migrations/068_PeerLinks.ts";
const migrationEntries = [
[1, "OrchestrationEvents", Migration0001],
@@ -140,6 +141,7 @@ const migrationEntries = [
[65, "CodexUsageNativeTurnIndex", Migration0065],
[66, "RepairClientSettlementTimestamps", Migration0066],
[67, "WebhookDedupe", Migration0067],
+ [68, "PeerLinks", Migration0068],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/068_PeerLinks.ts b/apps/server/src/persistence/Migrations/068_PeerLinks.ts
new file mode 100644
index 0000000000000..5551ccdd07a25
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/068_PeerLinks.ts
@@ -0,0 +1,24 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // Other environments this one signed in to as an outside MCP agent. The
+ // session token itself is kept in the server secret store under
+ // `secret_name`, which is unique to each link, so a row always names its
+ // own token even when two links to one environment race.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS peer_environment_links (
+ environment_id TEXT PRIMARY KEY,
+ secret_name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ urls_json TEXT NOT NULL,
+ access TEXT NOT NULL,
+ linked_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ last_reached_at TEXT,
+ last_error TEXT
+ )
+ `;
+});
diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
index 990d450cde6b5..edaca42042f23 100644
--- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
+++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
@@ -47,6 +47,7 @@ describe("V2 preview upgrade", () => {
[65, "CodexUsageNativeTurnIndex"],
[66, "RepairClientSettlementTimestamps"],
[67, "WebhookDedupe"],
+ [68, "PeerLinks"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
@@ -137,6 +138,7 @@ describe("V2 preview upgrade", () => {
[65, "CodexUsageNativeTurnIndex"],
[66, "RepairClientSettlementTimestamps"],
[67, "WebhookDedupe"],
+ [68, "PeerLinks"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 48d701103c077..373a96a887172 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -65,6 +65,9 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR
import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts";
import * as TerminalManager from "./terminal/Manager.ts";
import * as McpHttpServer from "./mcp/McpHttpServer.ts";
+import * as PeerForwarding from "./peer/PeerForwarding.ts";
+import * as PeerLinks from "./peer/PeerLinks.ts";
+import * as PeerMcpClient from "./peer/PeerMcpClient.ts";
import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import * as DeviceService from "./device/DeviceService.ts";
@@ -722,6 +725,13 @@ const layerMakeRoutes = Layer.mergeAll(
Layer.provide(layerPullRequestService),
Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))),
Layer.provide(PreviewBrowser.layer),
+ // Links to other environments, shared by Settings (WebSocket) and agents (/mcp).
+ Layer.provide(
+ PeerForwarding.layer.pipe(
+ Layer.provideMerge(PeerMcpClient.layer),
+ Layer.provideMerge(PeerLinks.layer),
+ ),
+ ),
Layer.provide(PreviewAutomationBroker.layer),
Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))),
Layer.provide(layerCommandReadiness),
diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts
index a9a028ec94bdf..a2268402fa4af 100644
--- a/apps/server/src/ws.test.ts
+++ b/apps/server/src/ws.test.ts
@@ -46,6 +46,7 @@ import * as ModelManifest from "./provider/ModelManifest.ts";
import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts";
import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
+import * as PeerLinks from "./peer/PeerLinks.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
@@ -315,6 +316,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(OtlpEndpointCheck.OtlpEndpointCheck)({}),
Layer.mock(PairingGrantStore.PairingGrantStore)({}),
+ Layer.mock(PeerLinks.PeerLinks)({}),
Layer.mock(PortScanner.PortDiscovery)({}),
Layer.mock(PreviewManager.PreviewManager)({}),
Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}),
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 1ba4ff99ff644..d4de6dcb1227a 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1,6 +1,7 @@
import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
+import * as PeerLinks from "./peer/PeerLinks.ts";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -1285,6 +1286,7 @@ export const layerWsRpc = (
const storageCleanup = yield* StorageCleanup.StorageCleanup;
const databaseStorage = yield* DatabaseStorage.DatabaseStorage;
const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const peerLinks = yield* PeerLinks.PeerLinks;
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
@@ -2381,6 +2383,11 @@ export const layerWsRpc = (
[WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow,
[WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports,
[WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status,
+ [WS_METHODS.peerLinksList]: (_input) =>
+ peerLinks.list.pipe(Effect.map((links) => ({ links }))),
+ [WS_METHODS.peerLinksLink]: (input) => peerLinks.link(input),
+ [WS_METHODS.peerLinksUnlink]: ({ environmentId }) =>
+ peerLinks.unlink(environmentId).pipe(Effect.map((removed) => ({ removed }))),
[WS_METHODS.serverGetSettings]: (_input) =>
serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
[WS_METHODS.serverUpdateSettings]:
@@ -3142,6 +3149,7 @@ export const layer = Layer.unwrap(
const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
const pullRequests = yield* PullRequestService.PullRequestService;
const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
+ const peerLinks = yield* PeerLinks.PeerLinks;
const sql = yield* SqlClient.SqlClient;
return HttpRouter.add(
"GET",
@@ -3233,6 +3241,7 @@ export const layer = Layer.unwrap(
reviewGuides,
),
),
+ Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)),
Layer.provide(
SourceControlDiscovery.layer.pipe(
Layer.provide(
diff --git a/docs/community-patches/native-messaging/CHECKS.md b/docs/community-patches/native-messaging/CHECKS.md
new file mode 100644
index 0000000000000..c0ebb7801193a
--- /dev/null
+++ b/docs/community-patches/native-messaging/CHECKS.md
@@ -0,0 +1,15 @@
+# Publication checks
+
+The repository's changed-file check was run against the exact v0.1.2 base on Windows x64, Node 26.12.1, Vite+ 1.3.0:
+
+```sh
+vp run check:changed 044f07257c9684ae4543bd1e1ebf9c394e9e3830
+```
+
+Its Windows runner first needed a local Node launcher adapter: spawning the pnpm `vp.cmd` wrapper directly as `vp` returned ENOENT. The adapter resolved that one invocation to Node and the installed Vite+ entry point. It is verification tooling, not a feature-source change.
+
+The running check passed lint (warnings), knip, generated GraphQL checks, and typechecks for the selected 29 packages. It ran 30 test files: 536 tests passed and three failed. Two failures in OrchestratorMcpToolkit.integration.test.ts convert a file URL pathname into a malformed Windows drive path. One ServerEnvironment.test.ts fixture cannot create its symlink under the current Windows permissions. Those fixture operations are present in the official v0.1.2 code; this preview does not change them or enable machine-wide privileges. The aggregate changed-file test gate remains nonzero on this host.
+
+One peer test needed formatter whitespace and a trailing comma. The preview patch includes that presentation-only cleanup; production code matches the earlier focused/runtime package. Format and lint then passed on all delivered source/docs, and the 14 peer tests passed after formatting. Their count overlaps earlier focused tests.
+
+Earlier focused and fresh-home runtime evidence remains separately identified in VERIFICATION.json and RUNTIME-VERIFICATION.json. No full-suite, physical network, other-platform, or client UI acceptance is claimed. The download is a community source preview with known validation limits.
diff --git a/docs/community-patches/native-messaging/INSTALL.md b/docs/community-patches/native-messaging/INSTALL.md
new file mode 100644
index 0000000000000..9e9e8bbf1f1b1
--- /dev/null
+++ b/docs/community-patches/native-messaging/INSTALL.md
@@ -0,0 +1,51 @@
+# Build and try the Native messaging source preview
+
+This preview targets official T3 Code v0.1.2, commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Windows backend setup was exercised with Node 26.12.1 and Vite+ 1.3.0. It provides source code, not a desktop installer or automatic app update. Use disposable state for the first trial.
+
+## Build the already-patched branch
+
+Install Node and Vite+ using the [upstream development setup](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/docs/operations/development.md#first-checkout). The recorded exercise used the versions named above. Clone the [feature branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2), then install its pinned dependencies and build the server:
+
+```sh
+git clone --branch community/native-messaging-v0.1.2 --single-branch https://github.com/zainmitchell/t3code.git t3-native-messaging-example
+cd t3-native-messaging-example
+vp i --frozen-lockfile
+vp run --filter t3 build:bundle
+```
+
+The branch already contains the patch. This path needs no additional `git apply` step.
+
+## Apply only the patch to an existing clean source checkout
+
+Alternatively, download the source-patch ZIP from the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1), check its SHA-256 against the Release's checksum file, and extract it outside the checkout. In a clean official source checkout:
+
+```sh
+git checkout --detach v0.1.2
+git rev-parse HEAD
+git apply --check /path/to/t3-v0.1.2-native-messaging.patch
+git apply /path/to/t3-v0.1.2-native-messaging.patch
+vp i --frozen-lockfile
+vp run --filter t3 build:bundle
+```
+
+The HEAD output before application must be `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Replace `/path/to` with the extracted package directory. MANIFEST.json binds the delivered patch and resulting source-file hashes. The full MIT notice must accompany copies or substantial portions.
+
+## Start with a new T3 home
+
+Choose an unused loopback port and new disposable directories. From the source checkout:
+
+```sh
+node apps/server/dist/bin.mjs serve --host 127.0.0.1 --port 47831 --base-dir /path/to/new-example-home --no-browser /path/to/example-project
+```
+
+On Windows, replace both paths with absolute paths. The built CLI uses `--base-dir`; the development runner's `--home-dir` is a different interface. Runtime state lives below `/userdata`. The headless server prints its readiness and pairing details. Those details are credentials and stay private. The source retains upstream package-version metadata; this preview is identified by its source base and patch checksum, not an official application release stamp.
+
+The recorded exercise used fresh homes, synthetic projects, disabled providers, authenticated application APIs, and scheduler subscriptions. Exact manual UI navigation, a desktop installer, production-state upgrades, real provider actions, physical remote routes, and other operating systems remain unverified. See RUNTIME-VERIFICATION.json and VERIFICATION.json.
+
+## Use messaging and linked environments
+
+The launch and Stop improvements also work locally. Supply a stable `clientRequestId` for one top-level launch and reuse it when recovering that same unanswered request. Follow the returned thread and run to determine actual preparation and execution success. `t3_thread_interrupt` with `stop: true` requests the broader Stop operation; acceptance does not prove every arbitrary process ended.
+
+For another environment, run the built CLI's `environment link --help` and use its destination pairing code and access options. The agent discovers links with `t3_environment_links` and uses `environmentId` only on supported native tools. Both servers need compatible code. Keep pairing details private and use owner-trusted environments. The destination can revoke its connection.
+
+The patch adds peer-link migration 68 after the official stable history. A custom fork that already used that number requires integration. Before any existing-state upgrade, stop the server and retain a complete backup. Reversing source code does not restore an earlier database. This preview does not include the later link-origin ownership, remote delegation, or handoff layers described in UPSTREAM.md.
diff --git a/docs/community-patches/native-messaging/LICENSE b/docs/community-patches/native-messaging/LICENSE
new file mode 100644
index 0000000000000..55ee675bb1de9
--- /dev/null
+++ b/docs/community-patches/native-messaging/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 T3 Tools Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/docs/community-patches/native-messaging/MANIFEST.json b/docs/community-patches/native-messaging/MANIFEST.json
new file mode 100644
index 0000000000000..898359f6c6de0
--- /dev/null
+++ b/docs/community-patches/native-messaging/MANIFEST.json
@@ -0,0 +1,206 @@
+{
+ "feature": "native-messaging",
+ "target": "T3 Code v0.1.2",
+ "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830",
+ "patch": "t3-v0.1.2-native-messaging.patch",
+ "patchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771",
+ "files": [
+ {
+ "path": "apps/server/src/auth/RpcAuthorization.ts",
+ "sha256": "e78cb6d1cb81f3bdc756b78dad47e4b2048a7a4eb027c704082f3364d5e72056"
+ },
+ {
+ "path": "apps/server/src/binCli.ts",
+ "sha256": "c549d86f3fd7f8332317bbb36ef3c8a79439d809dc72ea7564543b01e4e69b46"
+ },
+ {
+ "path": "apps/server/src/cli/environment.ts",
+ "sha256": "172e89ef2f88b35429d93069e775a867aa1ef9b84e53b70c5a0357540225c167"
+ },
+ {
+ "path": "apps/server/src/environment/ServerEnvironment.ts",
+ "sha256": "22dccb94768fe1db4f163c319eec6ab37595fb25188d9c99aed6baae79bba20e"
+ },
+ {
+ "path": "apps/server/src/mcp/McpHttpServer.test.ts",
+ "sha256": "7eecf3a4d17f5f4d6d2387b564cf0ea3a6d1a9cc16531965fd0385c349564ae4"
+ },
+ {
+ "path": "apps/server/src/mcp/McpHttpServer.ts",
+ "sha256": "b0dabeb950e83d16ec6cfd255c5823887d52860afb8c6e06547222349160affb"
+ },
+ {
+ "path": "apps/server/src/mcp/McpInvocationContext.ts",
+ "sha256": "2e8e6ac8a94ded58f680068795939a186f9895982cc18593fc0c9d4a909ace11"
+ },
+ {
+ "path": "apps/server/src/mcp/McpToolAccess.test.ts",
+ "sha256": "fb9c9cfe1e0acbf63ad8ec4a15d26f5eac89b3072c1553ba46c1d593cf7684ec"
+ },
+ {
+ "path": "apps/server/src/mcp/McpToolAccess.ts",
+ "sha256": "674f1a36a6692e6554b921c1ce3eb162f391f312a572b3644652149bd9c495e8"
+ },
+ {
+ "path": "apps/server/src/mcp/OrchestratorMcpService.ts",
+ "sha256": "eff6fff44650f4f6b0fa3e15c386fd8050dfca0f74a15d93afb95a230dfcd322"
+ },
+ {
+ "path": "apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts",
+ "sha256": "29f3530126b5abc502ce1263c5ca368522af2ce561302fde57f42e8029b3b690"
+ },
+ {
+ "path": "apps/server/src/mcp/threadAccess.ts",
+ "sha256": "53dfdaedcfe21f1b26a8920a45118ecea1a6bbc85c6cb188cd514ed5c15eb5e3"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/core.test.ts",
+ "sha256": "8ef7e6d79e73a65c15e9b74f477c5dd7bc796d60d4dd7005a33a49acdf7950c0"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/orchestrator/handlers.ts",
+ "sha256": "feb50dadcf2e622d00ce0e7149b0efde6f4a53713c8a8c2799165f7216c138e7"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/orchestrator/tools.ts",
+ "sha256": "b8ba1c87fcefc7dd53f794bb1fb9fe2f673e2c00e19c035cf5619a86e8c2a6d0"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/project/handlers.test.ts",
+ "sha256": "45e23026016bcca7841b95f83811b622df11cb29d8a40ed024f1bcfb52e681b4"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/project/handlers.ts",
+ "sha256": "1b49a0de2b5ac66c6d291409f88299aa74527483d1c2c0faa1baf7e60f5245bf"
+ },
+ {
+ "path": "apps/server/src/mcp/toolkits/project/tools.ts",
+ "sha256": "bb3ff81c497f41a0712152558abb1f5f4941533bd7a8b5ec6d9e7a69d25e406e"
+ },
+ {
+ "path": "apps/server/src/observability/RpcInstrumentation.ts",
+ "sha256": "9c33cf6e9f10342e2024c29e03413dbdf61108511b86743fe428ac6013a70142"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts",
+ "sha256": "ef86666e632ff589726f10815ca508e2def0ca5e15d4fb8184300e3b8d856821"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/EventSink.ts",
+ "sha256": "8269187be612e9de9855abac3a41844e446dca6b28a240b5c55100ceb81bff36"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/Orchestrator.ts",
+ "sha256": "c71b08648dd1ceb907d958fed12834d179fc6efa12c5584013ca606ec611b7f1"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/ThreadLaunchService.test.ts",
+ "sha256": "ff4decbfdf3c29994a484c38f52e36d500c54a4f83f3dcfaf3072379df0ef0cc"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/ThreadLaunchService.ts",
+ "sha256": "481b2806dc7be11caa497c4103e1979910247c6dcca460cc55eca0da270ab25c"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/ThreadManagementService.ts",
+ "sha256": "bffae4c868071fc8ef867e602377a799cb32435dfc4264974b58713f922694cd"
+ },
+ {
+ "path": "apps/server/src/orchestration-v2/ThreadStop.test.ts",
+ "sha256": "65053c430f20f0510b6fc47b52dc492bb7e1375ac4ebbaabc8d39d6e9812ab30"
+ },
+ {
+ "path": "apps/server/src/peer/PeerForwarding.test.ts",
+ "sha256": "7cbd913e0b456a7775aa1055f67a0493735cf6ee21a87a25df3d412bc73acebe"
+ },
+ {
+ "path": "apps/server/src/peer/PeerForwarding.ts",
+ "sha256": "7df568fb8d004789fe2238a7d08f43035238ddd359bfa58db510a8b11d5ea887"
+ },
+ {
+ "path": "apps/server/src/peer/PeerLinks.test.ts",
+ "sha256": "dd1db16508c34b80b2c902881aad52220218e16287c091d7b56842ae2d8e5b4c"
+ },
+ {
+ "path": "apps/server/src/peer/PeerLinks.testkit.ts",
+ "sha256": "b508650556fcaae33d091641d24e04803c3b1d2370b0e4d7de5a5cea9dd63248"
+ },
+ {
+ "path": "apps/server/src/peer/PeerLinks.ts",
+ "sha256": "8ea2e467a65ab7be37d6cf8b6a7b7ac7e1c113821a22af0608ec89379af0a784"
+ },
+ {
+ "path": "apps/server/src/peer/PeerMcpClient.ts",
+ "sha256": "8ea28a7cb960ca78dac2739387e77a27767966668116c5b7dbdc0c061c1366f5"
+ },
+ {
+ "path": "apps/server/src/persistence/Migrations.ts",
+ "sha256": "582b186d05f419d890438ab30675189bb1838d0a60831c1936069c5c2ccc531a"
+ },
+ {
+ "path": "apps/server/src/persistence/Migrations/068_PeerLinks.ts",
+ "sha256": "142095a7f4f0452c2cd495b412d7ac48552a705c066ec3122836a64b5be72a62"
+ },
+ {
+ "path": "apps/server/src/persistence/reconcileV2PreviewMigration.test.ts",
+ "sha256": "b59632ffc23913deab2bf10b2e7616765870833ec426b515272b34023d68647f"
+ },
+ {
+ "path": "apps/server/src/server.ts",
+ "sha256": "5e95f17e2114e86a728146f25183521adddb1fcbab6c85b3490b7065ffee9651"
+ },
+ {
+ "path": "apps/server/src/ws.test.ts",
+ "sha256": "a3c0a9e623c87153c5068993d778bc772693ef65121b0fd9f0e469cd80800e07"
+ },
+ {
+ "path": "apps/server/src/ws.ts",
+ "sha256": "78cd7cd75b7ac340e6431abbde2c47ea7bbc5e780da2493f26537118b57de05a"
+ },
+ {
+ "path": "packages/client-runtime/src/t3ToolSummary.ts",
+ "sha256": "9a8078166863153b873856975e8f3adb3cf59b4e3d6e7ba79beef98b1c842ca4"
+ },
+ {
+ "path": "packages/contracts/src/environment.ts",
+ "sha256": "9011f75b91ba51614606202703f726d10414e6af7ea1e55d287aee306c52f0e8"
+ },
+ {
+ "path": "packages/contracts/src/index.ts",
+ "sha256": "4463c76f97f288781e4d1afb243a4de70b54c307bf70277ee291a3b3cddb319d"
+ },
+ {
+ "path": "packages/contracts/src/orchestrationV2.ts",
+ "sha256": "2a21c9fa0dd99336d9761adfc29b8dc7d52396a489a67ffca1de6d9dec2a163d"
+ },
+ {
+ "path": "packages/contracts/src/orchestratorMcp.ts",
+ "sha256": "b660da3811d8bf1c2cbdda7c69cda68ce7509b21491eabea7570490465e2e918"
+ },
+ {
+ "path": "packages/contracts/src/peerLink.ts",
+ "sha256": "e5ab7f1f2046655adb9320fa366e0ff4b6c59419fe0988bec858488c1e5c46f6"
+ },
+ {
+ "path": "packages/contracts/src/rpc.test.ts",
+ "sha256": "7868ff2223d2a3d53d1c8d684974ff03021d676bcc10236954ea2199e40ba81a"
+ },
+ {
+ "path": "packages/contracts/src/rpc.ts",
+ "sha256": "48712970ee2bb77daf03028d91eb0d16f7b02a79bf37af69197d0b3e85957acc"
+ },
+ {
+ "path": "packages/provider-core/src/server/orchestrationInstructions.ts",
+ "sha256": "4894921fcc3663173446c4899bc29a42e14d5d93d680ac8193f7e63967d795e4"
+ },
+ {
+ "path": "packages/shared/src/t3McpToolPresentation.ts",
+ "sha256": "925ba48c5d1039cdba3719f80dde0754169af83d146ef4f0cd15880465eb57d0"
+ }
+ ],
+ "license": "MIT; retain included upstream copyright and permission notice",
+ "format": "Unified source diff; no contributor history or author metadata included",
+ "privacyScreen": "Patch and complete changed-file contents screened; no findings",
+ "previewCleanup": "Whitespace-only formatting of one peer test; production code is identical to the previously verified package",
+ "originalPackagePatchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb"
+}
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
new file mode 100644
index 0000000000000..bd2d4195b6f99
--- /dev/null
+++ b/docs/community-patches/native-messaging/README.md
@@ -0,0 +1,81 @@
+# Native agent messaging for T3 Code v0.1.2
+
+Native agent messaging lets coding agents coordinate through T3's own threads. This community patch adds reliable top-level launches, permission limits that follow forwarded requests, and a full Stop operation. It also supports selected native tools across explicitly linked T3 environments.
+
+For example, a coding agent can start a reviewer, send an existing worker a changed requirement, follow its run, and request a stop. If the answer to a launch request is lost, the caller can retry the same request without blindly creating another worker and worktree.
+
+**Source preview:** the [feature branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) contains the applied code. The [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) provides the source patch, setup guide, evidence, and checksums. No desktop installer is supplied.
+
+This preview integrates T3's existing coordination work with selected reliability changes. [Upstream PR attribution and package scope](UPSTREAM.md) identifies the original and support PRs; this release does not replace that contribution route.
+
+## What the patch adds
+
+T3 v0.1.2 already provides local thread messaging and the queue, steer, and restart send modes. The patch extends those existing tools with the following behavior.
+
+### Recover an unanswered launch
+
+A top-level launch can carry a `clientRequestId`. Retrying the same operation with the same key can return the thread the server already accepted. An accepted launch is recognized before changed base references or model defaults can cause it to be treated as a fresh request.
+
+This helps a controller recover from a lost response without starting duplicate work. Request keys are scoped to the caller. A new operation needs a new key.
+
+Receiving a thread id does not establish that its workspace is ready or its task succeeded. The caller follows the recorded thread and run to learn the outcome.
+
+### Keep the caller's permission limits
+
+A forwarded request carries the originating agent's runtime permission level and plan or execution setting. The destination also applies the access granted to the connection. Broader access on the connection does not grant broader access to the agent making that request.
+
+Both participating servers need compatible patches so the destination understands and enforces the forwarded limits. These checks govern operations through T3. They do not sandbox arbitrary programs or replace the operating system's permissions.
+
+### Request a complete Stop
+
+The native thread tool accepts `stop: true` for the full Stop operation. It requests interruption of the current turn, holds queued messages, ends associated pull-request watches, and stops app-owned delegated tasks.
+
+Retrying that operation keeps delegated-task cancellation bounded to the original recorded task history. An old retry should not cancel a task delegated afterward. An accepted Stop is not proof that every arbitrary process outside T3 has ended.
+
+### Address an authorized environment
+
+An owner can create a server-owned link to another T3 environment. Supported native tools can then discover its projects and models, launch a thread, read or message a thread, wait for a run, and interrupt work there.
+
+The servers handle authentication and keep connection credentials in the server secret store. Forwarded retry keys are scoped to the caller. A forwarded wait uses bounded requests while remaining attached to the run it first selected.
+
+The destination owns its threads and execution. A remotely launched top-level thread remains a top-level thread. It does not automatically gain delegated-child lineage or completion delivery.
+
+## How this helps T3 users
+
+Users can keep coordination in visible T3 conversations instead of copying updates between tools or addressing a worker through a provider-specific session name. A controller can recover an unanswered launch and distinguish a current-turn interrupt from the broader Stop operation.
+
+These controls are useful when implementation and review run in separate threads, when several providers participate in one workflow, or when work needs an authorized environment's capabilities. Local launch and Stop improvements do not require another computer.
+
+For a software factory controller, messaging provides ways to address and control workers. Issue selection, capacity accounting, result acceptance, and integration remain separate responsibilities. This messaging patch does not include a factory lifecycle or a model-free tick service.
+
+Sending a message can start a model turn on the receiver and consume provider allowance. No monetary or performance saving is claimed for this patch.
+
+## Dependencies and integration scope
+
+All support changes are bundled for the exact v0.1.2 source base: launch contracts and persistence, caller limits, full Stop requests, peer-link storage and migration 68, the MCP client/forwarder, CLI/service wiring, and matching transport/tool guidance. No tick or factory patch is required. Both servers need compatible code for forwarded calls. The [upstream PR map](UPSTREAM.md) distinguishes this reduced package from the larger cross-environment stack.
+
+## Version and reuse direction
+
+The prepared source patch targets the official T3 Code `v0.1.2` source at commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Other releases and modified forks need their own compatibility checks. A nightly 3669 package is a separate target and is not part of this stable publication.
+
+Reuse requires the matching source patch, a clean checkout of that base, dependency installation, and a source build. The checked preparation used Node 26.12.1 and Vite+ 1.3.0. Building the server bundle does not install or update an existing desktop application. No application installer was produced.
+
+The patch adds the `PeerLinks` migration at id 68 after the official v0.1.2 history. A fork that already used that id needs a deliberate migration integration. Start any manual exercise with disposable state and a throwaway repository. Before changing an existing installation, stop its server and keep a complete backup. Reversing a source patch does not restore an earlier database.
+
+Use [INSTALL.md](INSTALL.md) for the already-patched branch or source-patch ZIP. The [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) includes the versioned code patch, full license notice, manifest, verification records, and checksums.
+
+## Current verification limits
+
+The source package passed 196 focused tests and 31 supplementary tests, relevant typechecks, static checks, and server bundling on Windows x64. The 14 peer tests rerun after cleanup are an overlapping subset. A fresh-home backend exercise also passed startup, peer-link migration, authenticated project API and client RPC checks with every provider disabled and no thread/session projections. The proof server stopped. See [RUNTIME-VERIFICATION.json](RUNTIME-VERIFICATION.json). The tool tests use synthetic boundaries. They do not establish physical host-to-host operation, real T3 Connect routes, actual provider/model execution, other operating systems, or full web, desktop, and mobile runtime behavior.
+
+Pending local uploads cannot be forwarded as attachments. Environment targeting applies only to the explicitly supported tool set. The complete repository test suite and installation against existing production state were not exercised. See the [verification summary](VERIFICATION.json) for the counts and evidence boundaries.
+
+## Feedback and upstream context
+
+Useful feedback includes which coordination failure matters most in your workflow, how retry and Stop behavior should be presented, and which host-to-host scenario would make the best first real-route demonstration.
+
+[Native thread control, #8433](https://github.com/pingdotgg/t3code/discussions/8433), records related upstream capabilities. [Cross-computer threads, #6935](https://github.com/pingdotgg/t3code/discussions/6935), covers a related use case. Those discussions are context for the individual behaviors described here. This page does not claim that local thread messaging is new to T3.
+
+This is community fork work, with no upstream endorsement or merge commitment claimed. The source and documentation retain the [MIT notice](LICENSE). A source release must retain its required copyright and permission notice and applicable upstream dependency notices.
+
+The additional [publication checks](CHECKS.md) passed static checks and 536 selected tests but recorded three Windows fixture failures. The aggregate test gate is not green on this host. The preview adds only formatter cleanup in one test to the previously exercised code.
diff --git a/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json b/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json
new file mode 100644
index 0000000000000..871a0b36da415
--- /dev/null
+++ b/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json
@@ -0,0 +1,30 @@
+{
+ "sourceBase": "044f07257c9684ae4543bd1e1ebf9c394e9e3830",
+ "patchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb",
+ "host": "Windows x64",
+ "node": "26.12.1",
+ "feature": "native-messaging",
+ "observed": {
+ "freshHomeStartup": "passed",
+ "peerLinkMigration": "present",
+ "authenticatedProjectApi": "passed",
+ "authenticatedClientRpc": "passed",
+ "runtimeSourceMapBinding": "critical changed services match source",
+ "threadAndSessionProjections": 0
+ },
+ "enabledProviders": 0,
+ "proofServersStopped": true,
+ "notVerified": [
+ "Manual task-creation UI steps",
+ "Desktop installation or production-state upgrade",
+ "Real provider turns or worker-directed actions",
+ "External CI integration",
+ "Physical host-to-host routes or T3 Connect",
+ "Other operating systems",
+ "Full client/device acceptance",
+ "MCP OAuth client acceptance"
+ ],
+ "scope": "Windows disposable backend proof; no live production installation",
+ "distributedPreviewPatchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771",
+ "previewDifference": "Whitespace-only formatting in PeerForwarding.test.ts; original behavioral evidence is for the patchSha256 already recorded above"
+}
diff --git a/docs/community-patches/native-messaging/UPSTREAM.md b/docs/community-patches/native-messaging/UPSTREAM.md
new file mode 100644
index 0000000000000..1af3c87486317
--- /dev/null
+++ b/docs/community-patches/native-messaging/UPSTREAM.md
@@ -0,0 +1,30 @@
+# Upstream work and integration scope
+
+This community preview integrates existing T3 work with selected reliability changes on the exact stable v0.1.2 source base. It does not originate the underlying native thread-control or linked-environment design. The complete delivered code delta is identified by MANIFEST.json and the source-patch checksum.
+
+## Existing upstream PRs
+
+The core upstream route is the original stack, checked on 2026-10-25:
+
+| PR | Role | Observed status |
+| -------------------------------------------------------- | ------------------------------------------------------------------------ | --------------- |
+| [#16653](https://github.com/pingdotgg/t3code/pull/16653) | Forwarded runtime and interaction ceilings through `T3-Mode-Limit`. | Open, unmerged. |
+| [#16654](https://github.com/pingdotgg/t3code/pull/16654) | Caller-scoped `clientRequestId` for native top-level launches. | Open, unmerged. |
+| [#16655](https://github.com/pingdotgg/t3code/pull/16655) | Outgoing environment links, OAuth pairing, storage, CLI, and MCP client. | Open, unmerged. |
+| [#16684](https://github.com/pingdotgg/t3code/pull/16684) | Native tool targeting and forwarding to a linked environment. | Open, unmerged. |
+
+The later replacement stack [#21670](https://github.com/pingdotgg/t3code/pull/21670), [#21671](https://github.com/pingdotgg/t3code/pull/21671), [#21672](https://github.com/pingdotgg/t3code/pull/21672), and [#21673](https://github.com/pingdotgg/t3code/pull/21673) was closed without merge. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) says the maintainer retained the original stack. Existing support PRs [#22445](https://github.com/pingdotgg/t3code/pull/22445), [#22446](https://github.com/pingdotgg/t3code/pull/22446), [#22447](https://github.com/pingdotgg/t3code/pull/22447), and [#22448](https://github.com/pingdotgg/t3code/pull/22448) target those original layers. This preview creates no replacement PR and requests no merge of the older closed stack.
+
+The package is a reduced source integration, not a byte-for-byte copy of the current PR heads or the full cross-environment stack. It includes launch-retry, forwarded-limit, Stop-retry, and peer response/cleanup changes present in its recorded source snapshot. Tests and runtime evidence here apply to this package, not to every upstream PR revision.
+
+## What must be applied
+
+For the exact official v0.1.2 base, all supporting changes in this package are bundled in one patch. Do not apply the linked PR stack in addition to this ZIP. It needs no native tick or software factory patch and introduces no new dependency manifest or lockfile.
+
+Both participating servers need compatible code for forwarded limits. Link access caps runtime and interaction modes. Use owner-trusted environments; this reduced package does not provide the later link-origin ownership layer or per-project grant configuration. Peer-link migration 68 must fit the destination's migration history.
+
+The later [link-origin layer #16695](https://github.com/pingdotgg/t3code/pull/16695), linked-environment UI, remote `delegate_task`, conversation import, and thread handoff are outside this package. A remote top-level launch does not become a delegated child with automatic result delivery. A separate 3669 package remains a different, unfinished target.
+
+Original upstream design and implementation are credited through the PRs above. The owner reports Opus-generated additions; this preparation used GPT-6.1 Sol through Codex for extraction, small cleanup, verification, examples, and documentation. The full upstream MIT copyright and permission notice is retained in LICENSE. This community release carries no maintainer endorsement.
+
+Related feature discussions include [launch request keys #16186](https://github.com/pingdotgg/t3code/discussions/16186) and [Claude-specific cross-machine messaging #16230](https://github.com/pingdotgg/t3code/discussions/16230). This version-pinned preview shares integration and verification evidence; it does not open a competing feature PR.
diff --git a/docs/community-patches/native-messaging/VERIFICATION.json b/docs/community-patches/native-messaging/VERIFICATION.json
new file mode 100644
index 0000000000000..192dabb97ad8f
--- /dev/null
+++ b/docs/community-patches/native-messaging/VERIFICATION.json
@@ -0,0 +1,47 @@
+{
+ "target": "Official T3 Code v0.1.2 source",
+ "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830",
+ "patchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb",
+ "host": "Windows x64",
+ "node": "26.12.1",
+ "focused": {
+ "tests": 196,
+ "files": 8,
+ "exitCode": 0
+ },
+ "supplemental": {
+ "tests": 31,
+ "files": 5,
+ "exitCode": 0
+ },
+ "postCleanupPeerRecheck": {
+ "tests": 14,
+ "files": 2,
+ "exitCode": 0
+ },
+ "typecheck": "passed",
+ "format": "passed",
+ "lint": "passed with warnings",
+ "unusedCodeCheck": "passed",
+ "serverBundle": "passed",
+ "applyChecks": "passed alone and in either order with the other package; identical combined tree",
+ "combinedFocused": {
+ "tests": 242,
+ "files": 12,
+ "exitCode": 0
+ },
+ "combinedTypecheck": "passed",
+ "combinedServerBundle": "passed",
+ "exampleSmoke": null,
+ "notVerified": [
+ "Full repository test suite",
+ "Physical cross-machine routes or real T3 Connect",
+ "Real provider/model execution",
+ "Browser/desktop/mobile runtime behavior",
+ "Other operating systems",
+ "Installation against existing production state"
+ ],
+ "distribution": "Source patch only. No application binary, private evidence, account identity, or fork history.",
+ "distributedPreviewPatchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771",
+ "previewDifference": "Whitespace-only formatting in PeerForwarding.test.ts; original behavioral evidence is for the patchSha256 already recorded above"
+}
diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts
index df655c1d6c218..966a823b5f27a 100644
--- a/packages/client-runtime/src/t3ToolSummary.ts
+++ b/packages/client-runtime/src/t3ToolSummary.ts
@@ -333,6 +333,9 @@ export function summarizeT3ToolCalls(
case "environment-update":
label = phrase("Updated", "update", `environment preferences ${times}`);
break;
+ case "environment-links":
+ label = phrase("Listed", "list", `linked environments ${times}`);
+ break;
case "attachment-prepare":
label = phrase(
"Prepared",
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 18d09c304a83e..a90ed977323a8 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -152,6 +152,14 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
"server"`) and streams them over `/api/preview-stream`. Clients
without a local browser runtime open server tabs here. */
serverBrowser: Schema.optionalKey(Schema.Boolean),
+ /** Server links to other environments (`PeerLink`) and its agents can work
+ in them. Absent on servers from before linking, which clients must not
+ offer linking for. */
+ peerLinks: Schema.optionalKey(Schema.Boolean),
+ /** Server's `/mcp` narrows an OAuth client's modes from `T3-Mode-Limit`, so
+ a linked environment's calls keep its agent's limits. Another environment
+ links to this one only when it is set. */
+ mcpModeLimitHeader: Schema.optionalKey(Schema.Boolean),
});
export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 7f38a38dbbae9..d9680e959287f 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -39,6 +39,7 @@ export * from "./orchestrationProject.ts";
export * from "./orchestrationV2.ts";
export * from "./applicationEvent.ts";
export * from "./orchestratorMcp.ts";
+export * from "./peerLink.ts";
export * from "./threadMetadataMcp.ts";
export * from "./threadPullRequest.ts";
export * from "./threadSearch.ts";
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 881a316f59ae1..a6b542609c050 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -3062,6 +3062,17 @@ const OrchestrationV2InternalCommand = Schema.Union([
}),
),
}),
+ /**
+ * Internal so no client can bound a Stop retry to earlier history; client
+ * Stops use the public `OrchestrationV2Command` variant.
+ */
+ Schema.Struct({
+ type: Schema.Literal("thread.stop"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ reason: Schema.optional(Schema.String),
+ throughSequence: Schema.optional(NonNegativeInt),
+ }),
/**
* Internal so no client can mark a request saved without the value being
* stored.
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index c962c3975c17d..c3a3103a18bb1 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -2,8 +2,10 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";
+import { AuthMcpClientAccess } from "./auth.ts";
import {
ContextTransferId,
+ EnvironmentId,
IsoDateTime,
MessageId,
NodeId,
@@ -46,7 +48,7 @@ const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120
const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({
description: "Optional concise display title.",
});
-const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(
+export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(
Schema.isMaxLength(256),
).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });
@@ -305,7 +307,20 @@ const OrchestratorMcpProjectTarget = Schema.optional(
}),
);
+/**
+ * A linked environment to act in instead of this one. The tools that take it
+ * forward the call there, where the link's access and the caller's own modes
+ * both apply.
+ */
+export const OrchestratorMcpEnvironmentTarget = Schema.optional(
+ EnvironmentId.annotate({
+ description:
+ "A linked environment to do this in; t3_environment_links lists them. Omit for this environment. Ids (projects, threads, runs) then belong to that environment.",
+ }),
+);
+
export const OrchestratorMcpThreadListInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
projectId: OrchestratorMcpProjectTarget,
statuses: Schema.optional(
Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)),
@@ -378,6 +393,7 @@ const clampedPositiveInt = (maximum: number) =>
);
export const OrchestratorMcpThreadReadInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
threadId: ThreadId,
itemId: Schema.optional(TurnItemId),
textOffset: Schema.optional(NonNegativeInt),
@@ -472,6 +488,7 @@ export const OrchestratorMcpThreadReadResult = Schema.Struct({
export type OrchestratorMcpThreadReadResult = typeof OrchestratorMcpThreadReadResult.Type;
export const OrchestratorMcpThreadSendInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
threadId: ThreadId,
message: OrchestratorMcpPrompt,
mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])),
@@ -490,6 +507,7 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({
export type OrchestratorMcpThreadSendResult = typeof OrchestratorMcpThreadSendResult.Type;
export const OrchestratorMcpThreadWaitInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
threadId: ThreadId,
runId: Schema.optional(RunId),
timeoutMs: Schema.optional(Schema.Finite),
@@ -505,9 +523,16 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({
export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type;
export const OrchestratorMcpThreadInterruptInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
threadId: ThreadId,
runId: Schema.optional(RunId),
reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))),
+ /**
+ * Stop the thread as its Stop button does instead: interrupt its running
+ * turn whichever run that is, hold its queued messages, end its pull request
+ * watches and stop every task it delegated. `runId` is then not used.
+ */
+ stop: Schema.optional(Schema.Boolean),
clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
});
export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type;
@@ -524,6 +549,7 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({
export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type;
export const OrchestratorMcpCapabilitiesInput = Schema.Struct({
+ environmentId: OrchestratorMcpEnvironmentTarget,
search: Schema.optional(
TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({
description:
@@ -561,6 +587,25 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({
});
export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type;
+export const OrchestratorMcpEnvironmentLink = Schema.Struct({
+ environmentId: EnvironmentId,
+ label: Schema.String,
+ /** Whether it answered just now. An expired link must be linked again there. */
+ status: Schema.Literals(["reachable", "unreachable", "expired"]),
+ /** The most the linked environment lets this one's agents do there. */
+ access: AuthMcpClientAccess,
+ expiresAt: IsoDateTime,
+ lastError: Schema.NullOr(Schema.String),
+});
+export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLink.Type;
+
+export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({
+ environmentId: EnvironmentId,
+ links: Schema.Array(OrchestratorMcpEnvironmentLink),
+});
+export type OrchestratorMcpEnvironmentLinksResult =
+ typeof OrchestratorMcpEnvironmentLinksResult.Type;
+
export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
parentThreadId: Schema.NullOr(ThreadId),
inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId),
diff --git a/packages/contracts/src/peerLink.ts b/packages/contracts/src/peerLink.ts
new file mode 100644
index 0000000000000..022b1b9b9f8c5
--- /dev/null
+++ b/packages/contracts/src/peerLink.ts
@@ -0,0 +1,66 @@
+import * as Schema from "effect/Schema";
+
+import { AuthMcpClientAccess } from "./auth.ts";
+import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+/**
+ * A link from this environment to another one (the peer). This environment
+ * signed in to the peer's `/mcp` as an outside agent, with a pairing code from
+ * the peer, so its agents can work there. The peer enforces the access chosen
+ * at linking, and revokes the link from its own Connections.
+ */
+export const PeerLink = Schema.Struct({
+ environmentId: EnvironmentId,
+ label: Schema.String,
+ /** Addresses the peer was linked at, tried in order. */
+ urls: Schema.Array(TrimmedNonEmptyString),
+ access: AuthMcpClientAccess,
+ linkedAt: Schema.DateTimeUtc,
+ expiresAt: Schema.DateTimeUtc,
+ lastReachedAt: Schema.NullOr(Schema.DateTimeUtc),
+ lastError: Schema.NullOr(Schema.String),
+});
+export type PeerLink = typeof PeerLink.Type;
+
+/** Whether a link can be used now, as worked out when it is listed. */
+export const PeerLinkStatus = Schema.Literals(["reachable", "unreachable", "expired"]);
+export type PeerLinkStatus = typeof PeerLinkStatus.Type;
+
+export const PeerLinkSummary = Schema.Struct({
+ ...PeerLink.fields,
+ status: PeerLinkStatus,
+});
+export type PeerLinkSummary = typeof PeerLinkSummary.Type;
+
+export const PeerLinkCreateInput = Schema.Struct({
+ /** Where the peer answers: an https, Tailscale, LAN or loopback base URL. */
+ url: TrimmedNonEmptyString,
+ /** More addresses for the same peer, tried after `url`. */
+ alternateUrls: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)),
+ /** A pairing code from the peer (`t3 pair` there, or its Connections settings). */
+ pairingCode: TrimmedNonEmptyString,
+ access: AuthMcpClientAccess,
+});
+export type PeerLinkCreateInput = typeof PeerLinkCreateInput.Type;
+
+export const PeerLinkRemoveInput = Schema.Struct({ environmentId: EnvironmentId });
+export type PeerLinkRemoveInput = typeof PeerLinkRemoveInput.Type;
+
+export const PeerLinkRemoveResult = Schema.Struct({ removed: Schema.Boolean });
+export type PeerLinkRemoveResult = typeof PeerLinkRemoveResult.Type;
+
+/** Why linking or calling a peer failed, in words a user can act on. */
+export class PeerLinkError extends Schema.TaggedError()("PeerLinkError", {
+ reason: Schema.Literals([
+ "unreachable",
+ "not_a_t3_environment",
+ "incompatible",
+ "self",
+ "pairing_rejected",
+ "expired",
+ "unknown_link",
+ "storage",
+ ]),
+ message: Schema.String,
+ cause: Schema.optionalKey(Schema.Defect()),
+}) {}
diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts
index 718cd3e50f84d..ced58ca806906 100644
--- a/packages/contracts/src/rpc.test.ts
+++ b/packages/contracts/src/rpc.test.ts
@@ -35,6 +35,28 @@ describe("WebSocket RPC contracts", () => {
).toBe(true);
});
+ it("drops a client-supplied retry bound from a dispatched Stop", () => {
+ const dispatchCommand = WsRpcGroup.requests.get(ORCHESTRATION_V2_WS_METHODS.dispatchCommand);
+ if (dispatchCommand === undefined) throw new Error("dispatchCommand is not registered");
+ const decode = Schema.decodeUnknownSync(dispatchCommand.payloadSchema);
+
+ const decoded = decode({
+ type: "thread.stop",
+ commandId: "stop-1",
+ threadId: "thread-1",
+ reason: "User stopped the thread.",
+ throughSequence: 0,
+ });
+
+ expect(decoded).not.toHaveProperty("throughSequence");
+ expect(decoded).toStrictEqual({
+ type: "thread.stop",
+ commandId: "stop-1",
+ threadId: "thread-1",
+ reason: "User stopped the thread.",
+ });
+ });
+
it("lets the server pick a new worktree's folder", () => {
const createWorktree = WsRpcGroup.requests.get(WS_METHODS.vcsCreateWorktree);
if (createWorktree === undefined) throw new Error("vcs.createWorktree is not registered");
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index d7a11234c4467..2973a54862375 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -14,6 +14,14 @@ import {
McpAppToolInfoInput,
McpAppUpdateModelContextInput,
} from "./mcpApps.ts";
+import {
+ PeerLink,
+ PeerLinkCreateInput,
+ PeerLinkError,
+ PeerLinkRemoveInput,
+ PeerLinkRemoveResult,
+ PeerLinkSummary,
+} from "./peerLink.ts";
import {
ChatGptReconnectProfileInput,
ChatGptReconnectProfile,
@@ -482,6 +490,9 @@ export const WS_METHODS = {
serverRunStorageCleanup: "server.runStorageCleanup",
serverGetStorageCleanupReport: "server.getStorageCleanupReport",
serverGetDatabaseStorage: "server.getDatabaseStorage",
+ peerLinksList: "peerLinks.list",
+ peerLinksLink: "peerLinks.link",
+ peerLinksUnlink: "peerLinks.unlink",
serverGetSettings: "server.getSettings",
serverUpdateSettings: "server.updateSettings",
serverDiscoverSourceControl: "server.discoverSourceControl",
@@ -755,6 +766,24 @@ const WsServerGetDatabaseStorageRpc = Rpc.make(WS_METHODS.serverGetDatabaseStora
error: EnvironmentAuthorizationError,
});
+const WsPeerLinksListRpc = Rpc.make(WS_METHODS.peerLinksList, {
+ payload: Schema.Struct({}),
+ success: Schema.Struct({ links: Schema.Array(PeerLinkSummary) }),
+ error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]),
+});
+
+const WsPeerLinksLinkRpc = Rpc.make(WS_METHODS.peerLinksLink, {
+ payload: PeerLinkCreateInput,
+ success: PeerLink,
+ error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]),
+});
+
+const WsPeerLinksUnlinkRpc = Rpc.make(WS_METHODS.peerLinksUnlink, {
+ payload: PeerLinkRemoveInput,
+ success: PeerLinkRemoveResult,
+ error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]),
+});
+
const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
payload: Schema.Struct({}),
success: ServerSettings,
@@ -1909,6 +1938,9 @@ export const WsRpcGroup = RpcGroup.make(
WsServerGetDatabaseStorageRpc,
WsServerGetSettingsRpc,
WsServerUpdateSettingsRpc,
+ WsPeerLinksListRpc,
+ WsPeerLinksLinkRpc,
+ WsPeerLinksUnlinkRpc,
WsServerDiscoverSourceControlRpc,
WsServerSearchAcpRegistryRpc,
WsServerPrepareAcpRegistryAgentRpc,
diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts
index 88053dee9b546..796751c562484 100644
--- a/packages/provider-core/src/server/orchestrationInstructions.ts
+++ b/packages/provider-core/src/server/orchestrationInstructions.ts
@@ -14,8 +14,9 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`,
`- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing.
+- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to any T3 tool that takes one, such as \`t3_thread_launch\`, \`t3_thread_send\`, \`t3_thread_wait\`, \`t3_thread_read\`, or \`t3_project_list\`; project, thread, run, and model ids then belong to that environment, so pass \`threadId\` explicitly. Work there runs within your own modes and the link's access.
- When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one.
-- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`,
+- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. Name a thread in a linked environment by title instead: its link would open here.`,
`### Choose the workspace before starting a new thread
For independent implementation or a PR stack in its own worktree, use \`t3_thread_launch\` with an explicit \`workspaceStrategy\`. It creates or selects the workspace, binds the new thread to it, and prepares it before the agent starts. Put the task in \`message\`, not \`prompt\`:
@@ -28,7 +29,7 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO
\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree.
-\`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.`,
+Pass a \`clientRequestId\` to \`t3_thread_launch\` and reuse it when retrying: the same key returns the thread the first call created. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance.`,
`Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them.
ACP fallback: some ACP agents accept the injected MCP server but fail to expose its tools. When the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call the same tools through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset when T3 runs as a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`. This is the supported T3 transport fallback, not an ordinary shell-based substitute for delegation.
diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts
index 21f8be3941f95..975361f36b1c0 100644
--- a/packages/shared/src/t3McpToolPresentation.ts
+++ b/packages/shared/src/t3McpToolPresentation.ts
@@ -50,6 +50,7 @@ export type T3McpToolSummaryAction =
| "project-delete"
| "project-clone"
| "environment-read"
+ | "environment-links"
| "environment-update"
| "attachment-prepare"
| "attachment-discard"
@@ -309,6 +310,10 @@ const T3_MCP_TOOLS: Readonly> = {
["Read", "Reading", "Read", "environment preferences"],
"environment-read",
),
+ t3_environment_links: tool(
+ ["List", "Listing", "Listed", "linked environments"],
+ "environment-links",
+ ),
t3_environment_preferences_update: tool(
["Update", "Updating", "Updated", "environment preferences"],
"environment-update",
From a5d9539709ff26396363d609784f54bf016f4ffd Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:03:37 -0500
Subject: [PATCH 02/10] docs: list native messaging feature and support PR
dependencies
Pair the original feature layers with support PRs 22445 through 22448 in the overview and upstream map. Explain the difference between upstream integration and the bundled stable source patch.
Documentation: GPT-6.1 Sol through Codex in T3 Code.
---
.../native-messaging/README.md | 13 ++++++++++++
.../native-messaging/UPSTREAM.md | 20 ++++++++++---------
2 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
index bd2d4195b6f99..c200dd5fc08e8 100644
--- a/docs/community-patches/native-messaging/README.md
+++ b/docs/community-patches/native-messaging/README.md
@@ -8,6 +8,19 @@ For example, a coding agent can start a reviewer, send an existing worker a chan
This preview integrates T3's existing coordination work with selected reliability changes. [Upstream PR attribution and package scope](UPSTREAM.md) identifies the original and support PRs; this release does not replace that contribution route.
+## Upstream PR dependencies and support fixes
+
+| Required feature layer | Original feature PR | Support/integration PR | What the layer supplies |
+| ------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+| Caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Runtime and interaction ceilings for forwarded calls. |
+| Launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Recover accepted launches on retry, with caller and worktree validation. |
+| Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Pairing, credentials, storage, MCP client, migration 68, and response/cleanup fixes. |
+| Native linked-environment tools | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Native-tool forwarding plus Stop, validation, and wire-codec fixes. |
+
+The original feature order is mode limits → launch request keys → peer links → native-tool forwarding. Each support PR targets its matching original feature branch. These are paired feature/support routes, not eight independent patches to apply sequentially. Current PR heads can advance independently; this table is not a verified cherry-pick recipe for current main.
+
+For the published v0.1.2 preview, the selected supporting changes are already bundled in the single source patch. No separate PR application is required. Do not apply these PRs on top of that download. The preview is a reduced, version-pinned integration, not an assertion that it contains every change in the latest PR heads. The evidence attached to a newer PR head does not automatically validate this stable package.
+
## What the patch adds
T3 v0.1.2 already provides local thread messaging and the queue, steer, and restart send modes. The patch extends those existing tools with the following behavior.
diff --git a/docs/community-patches/native-messaging/UPSTREAM.md b/docs/community-patches/native-messaging/UPSTREAM.md
index 1af3c87486317..3706750f1e162 100644
--- a/docs/community-patches/native-messaging/UPSTREAM.md
+++ b/docs/community-patches/native-messaging/UPSTREAM.md
@@ -2,18 +2,20 @@
This community preview integrates existing T3 work with selected reliability changes on the exact stable v0.1.2 source base. It does not originate the underlying native thread-control or linked-environment design. The complete delivered code delta is identified by MANIFEST.json and the source-patch checksum.
-## Existing upstream PRs
+## Upstream PR dependencies and support fixes
-The core upstream route is the original stack, checked on 2026-10-25:
+| Required feature layer | Original feature PR | Support/integration PR | What the layer supplies |
+| ------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+| Caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Runtime and interaction ceilings for forwarded calls. |
+| Launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Recover accepted launches on retry, with caller and worktree validation. |
+| Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Pairing, credentials, storage, MCP client, migration 68, and response/cleanup fixes. |
+| Native linked-environment tools | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Native-tool forwarding plus Stop, validation, and wire-codec fixes. |
-| PR | Role | Observed status |
-| -------------------------------------------------------- | ------------------------------------------------------------------------ | --------------- |
-| [#16653](https://github.com/pingdotgg/t3code/pull/16653) | Forwarded runtime and interaction ceilings through `T3-Mode-Limit`. | Open, unmerged. |
-| [#16654](https://github.com/pingdotgg/t3code/pull/16654) | Caller-scoped `clientRequestId` for native top-level launches. | Open, unmerged. |
-| [#16655](https://github.com/pingdotgg/t3code/pull/16655) | Outgoing environment links, OAuth pairing, storage, CLI, and MCP client. | Open, unmerged. |
-| [#16684](https://github.com/pingdotgg/t3code/pull/16684) | Native tool targeting and forwarding to a linked environment. | Open, unmerged. |
+The original feature order is mode limits → launch request keys → peer links → native-tool forwarding. Each support PR targets its matching original feature branch. These are paired feature/support routes, not eight independent patches to apply sequentially. Current PR heads can advance independently; this table is not a verified cherry-pick recipe for current main.
-The later replacement stack [#21670](https://github.com/pingdotgg/t3code/pull/21670), [#21671](https://github.com/pingdotgg/t3code/pull/21671), [#21672](https://github.com/pingdotgg/t3code/pull/21672), and [#21673](https://github.com/pingdotgg/t3code/pull/21673) was closed without merge. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) says the maintainer retained the original stack. Existing support PRs [#22445](https://github.com/pingdotgg/t3code/pull/22445), [#22446](https://github.com/pingdotgg/t3code/pull/22446), [#22447](https://github.com/pingdotgg/t3code/pull/22447), and [#22448](https://github.com/pingdotgg/t3code/pull/22448) target those original layers. This preview creates no replacement PR and requests no merge of the older closed stack.
+For the published v0.1.2 preview, the selected supporting changes are already bundled in the single source patch. No separate PR application is required. Do not apply these PRs on top of that download. The preview is a reduced, version-pinned integration, not an assertion that it contains every change in the latest PR heads. The evidence attached to a newer PR head does not automatically validate this stable package.
+
+The later replacement stack [#21670](https://github.com/pingdotgg/t3code/pull/21670), [#21671](https://github.com/pingdotgg/t3code/pull/21671), [#21672](https://github.com/pingdotgg/t3code/pull/21672), and [#21673](https://github.com/pingdotgg/t3code/pull/21673) was closed without merge. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) says the maintainer retained the original stack. This preview creates no replacement PR and requests no merge of the older closed stack.
The package is a reduced source integration, not a byte-for-byte copy of the current PR heads or the full cross-environment stack. It includes launch-retry, forwarded-limit, Stop-retry, and peer response/cleanup changes present in its recorded source snapshot. Tests and runtime evidence here apply to this package, not to every upstream PR revision.
From fb4df062d92e644cff7cecac436b22c20fc6e10a Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:19:58 -0500
Subject: [PATCH 03/10] docs: clarify review evidence, AI attribution and
temporary fork intent
Make model and harness names explicit and direct patch bug reports to the fork. For messaging, preserve the closed PR route and distinguish current CI execution from revision-specific local evidence.
Documentation: GPT-6.1 Sol through Codex in T3 Code.
---
.../native-messaging/README.md | 17 +++++++++
.../native-messaging/REVIEW-STATUS.md | 35 +++++++++++++++++++
.../native-messaging/UPSTREAM.md | 17 ++++++++-
3 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 docs/community-patches/native-messaging/REVIEW-STATUS.md
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
index c200dd5fc08e8..879d4f2ce48d4 100644
--- a/docs/community-patches/native-messaging/README.md
+++ b/docs/community-patches/native-messaging/README.md
@@ -21,6 +21,8 @@ The original feature order is mode limits → launch request keys → peer links
For the published v0.1.2 preview, the selected supporting changes are already bundled in the single source patch. No separate PR application is required. Do not apply these PRs on top of that download. The preview is a reduced, version-pinned integration, not an assertion that it contains every change in the latest PR heads. The evidence attached to a newer PR head does not automatically validate this stable package.
+See the [current PR status and exact revision evidence](REVIEW-STATUS.md), including CI that did not execute and reported local checks. The [upstream map](UPSTREAM.md#historical-pr-route) preserves the closed replacement history.
+
## What the patch adds
T3 v0.1.2 already provides local thread messaging and the queue, steer, and restart send modes. The patch extends those existing tools with the following behavior.
@@ -92,3 +94,18 @@ Useful feedback includes which coordination failure matters most in your workflo
This is community fork work, with no upstream endorsement or merge commitment claimed. The source and documentation retain the [MIT notice](LICENSE). A source release must retain its required copyright and permission notice and applicable upstream dependency notices.
The additional [publication checks](CHECKS.md) passed static checks and 536 selected tests but recorded three Windows fixture failures. The aggregate test gate is not green on this host. The preview adds only formatter cleanup in one test to the previously exercised code.
+
+## Maintenance and upstream intent
+
+This is a temporary community patch fork. The owner maintains these patches and tracks bugs in [this fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire the fork-specific patches as upstream meets the need.
+
+We would welcome upstream adoption of suitable changes through maintainer-approved direction and focused contributions. This preview carries no upstream endorsement, approval, merge commitment, or promise that its complete stable bundle will be merged as one PR.
+
+## AI models and roles
+
+| Model | Role in this fork/release work | Harness |
+| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
+| **Claude Opus 5.5** | Fork implementation and integration work. Existing T3 code retains its original authorship and upstream credits. | T3 Code's Claude provider. |
+| **GPT-6.1 Sol** | Source extraction, limited cleanup, source/evidence and documentation review, focused verification, examples, and publication preparation. The tick documentation also received a GPT-6.1 Sol review. | Codex harness in T3 Code. |
+
+These roles do not claim an independent full code audit of every upstream revision. CodeRabbit review of the source release has not been established; automated PR statuses and human/agent verification are reported separately.
diff --git a/docs/community-patches/native-messaging/REVIEW-STATUS.md b/docs/community-patches/native-messaging/REVIEW-STATUS.md
new file mode 100644
index 0000000000000..1475002063378
--- /dev/null
+++ b/docs/community-patches/native-messaging/REVIEW-STATUS.md
@@ -0,0 +1,35 @@
+# PR status and revision-specific evidence
+
+PR metadata snapshot: **2026-10-25T20:07:06.624342+00:00**. CI-run lookup: **2026-10-25T20:18:39.720240+00:00**. Follow each checks/CI link for later changes. This page records a snapshot, not continuous monitoring.
+
+| PR | Head checked | Merge status | Build/test CI observed |
+| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [`d868b6296`](https://github.com/pingdotgg/t3code/commit/d868b6296c8a24bbc150ebb4d96d264316206099) | Open, unmerged; merge conflicts | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16653/checks) |
+| [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [`071e66164`](https://github.com/pingdotgg/t3code/commit/071e6616495613844a1ff062446e41b4fc586d22) | Open, unmerged; clean merge calculation | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16654/checks) |
+| [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [`197930788`](https://github.com/pingdotgg/t3code/commit/197930788c33a086de927374a30e02da4c6336c4) | Open, unmerged; merge conflicts | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16655/checks) |
+| [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [`641ee467a`](https://github.com/pingdotgg/t3code/commit/641ee467aa5ceb9126f7c52e2b8b002bf258e0a4) | Open, unmerged; BLOCKED | [No build/test jobs in retrieved rollup](https://github.com/pingdotgg/t3code/pull/16684/checks) |
+| [#22445](https://github.com/pingdotgg/t3code/pull/22445) | [`c6f7adcec`](https://github.com/pingdotgg/t3code/commit/c6f7adcecf5dd1e6e4b9e8beab2bc3c364b4249c) | Open, unmerged; UNSTABLE | [No build/test jobs in retrieved rollup](https://github.com/pingdotgg/t3code/pull/22445/checks) |
+| [#22446](https://github.com/pingdotgg/t3code/pull/22446) | [`6d84a1e53`](https://github.com/pingdotgg/t3code/commit/6d84a1e536c1418ea0ff459806e4bd08a5a0b606) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736203342); 0 jobs |
+| [#22447](https://github.com/pingdotgg/t3code/pull/22447) | [`fc5325405`](https://github.com/pingdotgg/t3code/commit/fc53254058e72f5f262176b6475a75c09033803c) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736209630); 0 jobs |
+| [#22448](https://github.com/pingdotgg/t3code/pull/22448) | [`c49288085`](https://github.com/pingdotgg/t3code/commit/c49288085b726e96c0d0002936977dc671590dff) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736215022); 0 jobs |
+
+All eight PRs were open and unmerged in this snapshot. GitHub's green rollup on the four support PRs includes labeling/CodeRabbit status; it does not establish that build/test CI ran. Their CI workflow results above must be read separately. `action_required` with zero jobs is not an executed test failure. `UNSTABLE` and `BLOCKED` are GitHub merge-state values, not diagnoses of a functional defect. Some original-PR jobs were skipped. A `vouch:unvouched` label concerns contributor trust under upstream policy, not whether a feature works.
+
+## What evidence covers the current support heads?
+
+These are reported results from the linked PR records, not new tests performed by the stable-package publication task:
+
+| Support PR | Revision-specific evidence and limits |
+| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Its description identifies current `c6f7adce` as the automatic merge; reports 110 passing checks and two Windows fixture failures also reproduced on pristine main, plus typecheck/static checks. |
+| [#22446](https://github.com/pingdotgg/t3code/pull/22446#issuecomment-6334626735) | Current `6d84a1e53`: reports 114 focused tests, typecheck and static checks. The retained existing-worktree membership safeguard is an explicit maintainer review point; no new two-machine claim. |
+| [#22447](https://github.com/pingdotgg/t3code/pull/22447#issuecomment-6334627047) | Current `fc5325405`: reports 138 focused tests, typecheck/static checks, and the two relevant replay regressions checked on the combined candidate. No new two-machine claim. |
+| [#22448](https://github.com/pingdotgg/t3code/pull/22448#issuecomment-6334627496) | Current `c49288085`: reports 264 server tests, four contracts tests, typechecks/static checks, and independent checks of two overlapping launch regressions. Historical two-machine results do not establish runtime acceptance of this head. |
+
+The [top PR description](https://github.com/pingdotgg/t3code/pull/22448) scopes its eight two-machine scenarios to `820e789c3d08b51612a1f244bd829561c8808bbc`. A [later Nightly integration report](https://github.com/pingdotgg/t3code/pull/22448#issuecomment-6324259910) names a different tested integration revision and limits. Preserve those revision boundaries.
+
+The stable v0.1.2 preview is another source target. Its own manifest, checksums, focused tests, three disclosed Windows fixture failures, and disposable backend exercise are the evidence for that download. Neither its results nor historical runtime results automatically validate the latest upstream PR heads.
+
+## How to judge a suspected failure
+
+Use the exact head and a reproducible failed behavior or executed check to identify a defect. An open or closed PR, missing CI execution, a conflict, or a green administrative status alone cannot establish whether the feature works. Maintainer approval and merge acceptance remain separate from functional verification.
diff --git a/docs/community-patches/native-messaging/UPSTREAM.md b/docs/community-patches/native-messaging/UPSTREAM.md
index 3706750f1e162..11f14c0066135 100644
--- a/docs/community-patches/native-messaging/UPSTREAM.md
+++ b/docs/community-patches/native-messaging/UPSTREAM.md
@@ -19,6 +19,21 @@ The later replacement stack [#21670](https://github.com/pingdotgg/t3code/pull/21
The package is a reduced source integration, not a byte-for-byte copy of the current PR heads or the full cross-environment stack. It includes launch-retry, forwarded-limit, Stop-retry, and peer response/cleanup changes present in its recorded source snapshot. Tests and runtime evidence here apply to this package, not to every upstream PR revision.
+## Historical PR route
+
+The four capabilities remain necessary for this linked-environment design. Their older replacement PRs were closed without merging when maintainers retained the original feature stack. Applicable fixes now have the original/support integration routes below. Closed here records contribution history; it is not a finding that the code was broken.
+
+| Closed replacement PR | Capability | Current original / support route |
+| -------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) |
+| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Recoverable launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) |
+| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links and credentials | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) |
+| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) |
+
+The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records why the original route was retained. These historical replacements are not four extra patches to apply on top of the current integrations or the bundled stable download.
+
+See [REVIEW-STATUS.md](REVIEW-STATUS.md) for the merge state, CI execution, and exact revision evidence.
+
## What must be applied
For the exact official v0.1.2 base, all supporting changes in this package are bundled in one patch. Do not apply the linked PR stack in addition to this ZIP. It needs no native tick or software factory patch and introduces no new dependency manifest or lockfile.
@@ -27,6 +42,6 @@ Both participating servers need compatible code for forwarded limits. Link acces
The later [link-origin layer #16695](https://github.com/pingdotgg/t3code/pull/16695), linked-environment UI, remote `delegate_task`, conversation import, and thread handoff are outside this package. A remote top-level launch does not become a delegated child with automatic result delivery. A separate 3669 package remains a different, unfinished target.
-Original upstream design and implementation are credited through the PRs above. The owner reports Opus-generated additions; this preparation used GPT-6.1 Sol through Codex for extraction, small cleanup, verification, examples, and documentation. The full upstream MIT copyright and permission notice is retained in LICENSE. This community release carries no maintainer endorsement.
+Original upstream design and implementation are credited through the PRs above. The owner identifies Claude Opus 5.5 for the fork additions; this preparation used GPT-6.1 Sol through Codex for extraction, small cleanup, verification, examples, and documentation. The full upstream MIT copyright and permission notice is retained in LICENSE. This community release carries no maintainer endorsement.
Related feature discussions include [launch request keys #16186](https://github.com/pingdotgg/t3code/discussions/16186) and [Claude-specific cross-machine messaging #16230](https://github.com/pingdotgg/t3code/discussions/16230). This version-pinned preview shares integration and verification evidence; it does not open a competing feature PR.
From 5301b79e4b0c8bca820185121355c6d71e139514 Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:48:22 -0500
Subject: [PATCH 04/10] docs: explain patch purpose, dependencies and public
history
Bring the why, when and how into the feature brief, distinguish bundled dependencies from later upstream layers, and tie history/evidence to exact revisions.
Documentation: GPT-6.1 Sol through Codex in T3 Code.
---
.../native-messaging/README.md | 138 +++++++++---------
.../native-messaging/UPSTREAM.md | 67 +++++----
2 files changed, 105 insertions(+), 100 deletions(-)
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
index 879d4f2ce48d4..55f8cecaaf594 100644
--- a/docs/community-patches/native-messaging/README.md
+++ b/docs/community-patches/native-messaging/README.md
@@ -1,111 +1,117 @@
# Native agent messaging for T3 Code v0.1.2
-Native agent messaging lets coding agents coordinate through T3's own threads. This community patch adds reliable top-level launches, permission limits that follow forwarded requests, and a full Stop operation. It also supports selected native tools across explicitly linked T3 environments.
+This community source preview extends T3's existing native thread tools with recoverable launches, full Stop/retry behavior, forwarded permission ceilings, and selected tools for explicitly linked environments. It is a reduced stable integration of existing T3 work and selected fixes, with the PR relationships and verification limits below.
-For example, a coding agent can start a reviewer, send an existing worker a changed requirement, follow its run, and request a stop. If the answer to a launch request is lost, the caller can retry the same request without blindly creating another worker and worktree.
+## Why this exists
-**Source preview:** the [feature branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) contains the applied code. The [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) provides the source patch, setup guide, evidence, and checksums. No desktop installer is supplied.
+A controller can lose a launch response after T3 accepted it. Blindly retrying may start duplicate workers and worktrees. A permission-limited agent can also need another environment's tools without inheriting that environment's broader credential permissions. Cancellation needs to cover queued and delegated work, while a retry of an old Stop must not cancel newer work.
-This preview integrates T3's existing coordination work with selected reliability changes. [Upstream PR attribution and package scope](UPSTREAM.md) identifies the original and support PRs; this release does not replace that contribution route.
+The goal is predictable control through visible T3 conversations. Provider-specific session names and manual relays do not provide the same T3 thread/run address. This is useful to a human supervising several agents and to a software factory controller, without making either controller the owner of every provider's internal protocol.
-## Upstream PR dependencies and support fixes
+## When to use it
-| Required feature layer | Original feature PR | Support/integration PR | What the layer supplies |
-| ------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
-| Caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Runtime and interaction ceilings for forwarded calls. |
-| Launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Recover accepted launches on retry, with caller and worktree validation. |
-| Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Pairing, credentials, storage, MCP client, migration 68, and response/cleanup fixes. |
-| Native linked-environment tools | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Native-tool forwarding plus Stop, validation, and wire-codec fixes. |
+- Implementation and review run in separate T3 threads or providers, and need concrete follow-ups.
+- A controller must recover an unanswered launch and follow the already accepted work.
+- Cancellation needs full Stop semantics rather than only an interrupt of the current turn.
+- A workflow needs an explicitly authorized environment's projects, models, or threads. This remote path is optional; local coordination remains useful on one machine.
-The original feature order is mode limits → launch request keys → peer links → native-tool forwarding. Each support PR targets its matching original feature branch. These are paired feature/support routes, not eight independent patches to apply sequentially. Current PR heads can advance independently; this table is not a verified cherry-pick recipe for current main.
+Local thread control already exists upstream. [#8433](https://github.com/pingdotgg/t3code/discussions/8433) records that work; [#16186](https://github.com/pingdotgg/t3code/discussions/16186) covers launch retry keys. [#6935](https://github.com/pingdotgg/t3code/discussions/6935) and [#16230](https://github.com/pingdotgg/t3code/discussions/16230) cover related cross-computer/provider-specific use cases. This preview shares a stable integration, not a competing claim to originate those capabilities.
-For the published v0.1.2 preview, the selected supporting changes are already bundled in the single source patch. No separate PR application is required. Do not apply these PRs on top of that download. The preview is a reduced, version-pinned integration, not an assertion that it contains every change in the latest PR heads. The evidence attached to a newer PR head does not automatically validate this stable package.
+## How it changes T3
-See the [current PR status and exact revision evidence](REVIEW-STATUS.md), including CI that did not execute and reported local checks. The [upstream map](UPSTREAM.md#historical-pr-route) preserves the closed replacement history.
+**Recover a launch.** A top-level launch can carry `clientRequestId`. Accepted receipts can recover the original thread before mutable base/default-model checks reinterpret it as new work. A new operation needs a new key. The key belongs to the invocation namespace, derived from the provider session or authenticated MCP client session; it is not a global identity guaranteed across replacement sessions. Retain the returned thread ID and follow preparation/run history. A returned ID alone does not prove the worker ran successfully.
-## What the patch adds
+**Carry the caller's limits.** Authenticated MCP requests propagate runtime and interaction ceilings through `T3-Mode-Limit`. A peer credential does not authorize a caller to widen its modes. Destination authorization and the link's access still apply. These checks govern T3 operations, not arbitrary host programs.
-T3 v0.1.2 already provides local thread messaging and the queue, steer, and restart send modes. The patch extends those existing tools with the following behavior.
+**Address another environment.** The server owns the configured link, OAuth pairing, stored credentials, MCP client, and forwarding. Supported tools can discover projects/models, launch ordinary threads, read/message them, wait for a run, and interrupt it at the destination. Forwarded retry identities are caller-scoped. Waits use bounded requests and remain attached to the selected run. Destination-local thread links are omitted from results because they would open in the wrong environment.
-### Recover an unanswered launch
+**Stop the intended work.** The broader native Stop operation requests interruption, holds queued messages, ends PR watches, and requests cancellation of app-owned delegated tasks. Replaying the accepted Stop stays bounded to its recorded task history. It does not prove every arbitrary external descendant process has ended.
-A top-level launch can carry a `clientRequestId`. Retrying the same operation with the same key can return the thread the server already accepted. An accepted launch is recognized before changed base references or model defaults can cause it to be treated as a fresh request.
+For a software factory, this supplies worker addresses and control. It does not select issues, reserve capacity, accept results, integrate code, or provide the factory lifecycle. A remote top-level launch does not become a delegated child. Sending a message can start a model turn at the receiver; messaging is not free model execution or the separate model-free tick feature.
-This helps a controller recover from a lost response without starting duplicate work. Request keys are scoped to the caller. A new operation needs a new key.
+## Required PR layers and bundled dependencies
-Receiving a thread id does not establish that its workspace is ready or its task succeeded. The caller follows the recorded thread and run to learn the outcome.
+T3 already provides local launch/read/send tools, including queue, steer, and restart. These additions extend request reliability, full Stop behavior, and linked-environment targeting. The four feature layers below form the upstream linked-environment route; each support PR targets the corresponding original feature branch.
-### Keep the caller's permission limits
+| Required layer and purpose | Original feature | Support/integration work | Stable preview |
+| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------ |
+| Caller mode ceilings prevent a forwarding credential from widening an agent's permissions. | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Included. |
+| Launch request keys recover accepted work after a lost response. | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Included, with the selected snapshot's replay/validation behavior. |
+| Peer links provide authenticated pairing, credential storage, MCP transport, and migration 68. | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Included, with selected response/cleanup fixes. |
+| Native tools target the linked environment; integration includes Stop, validation, and codec work. | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Included in the reduced tool set described here. |
-A forwarded request carries the originating agent's runtime permission level and plan or execution setting. The destination also applies the access granted to the connection. Broader access on the connection does not grant broader access to the agent making that request.
+All eight were open and unmerged when checked on 2026-10-25. The feature order is mode ceilings, launch request keys, peer links, then forwarding. These are paired feature/support routes, not eight patches to apply sequentially. Support heads can advance independently; their latest heads are not all a single verified linear stack.
-Both participating servers need compatible patches so the destination understands and enforces the forwarded limits. These checks govern operations through T3. They do not sandbox arbitrary programs or replace the operating system's permissions.
+For the exact official v0.1.2 base, the download already bundles its selected changes in one 48-file patch. It includes contracts, RPC/MCP authorization, launch and Stop persistence, handlers, WebSocket/service wiring, CLI, peer storage/client/forwarder, migration registration, tool/provider guidance, and tests. **Do not apply the linked PRs on top of the stable download.** No native tick or factory patch, new package manifest, or lockfile change is required. The manifest identifies the complete source delta; it is not a claim to contain every later fix in current PR heads.
-### Request a complete Stop
+The package reuses T3's existing orchestration, thread/project services, authentication, secret storage, provider adapters, contracts, and Effect runtime. Those dependencies come from the pinned base and lockfile. Forwarding requires compatible code and an authorized link at both servers. Local launch/Stop improvements do not require another computer.
-The native thread tool accepts `stop: true` for the full Stop operation. It requests interruption of the current turn, holds queued messages, ends associated pull-request watches, and stops app-owned delegated tasks.
+## PR history and later layers
-Retrying that operation keeps delegated-task cancellation bounded to the original recorded task history. An old retry should not cancel a task delegated afterward. An accepted Stop is not proof that every arbitrary process outside T3 has ended.
+The older replacement route was closed without merging when the original feature stack was retained. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records that decision. Closed here is contribution history, not a diagnosis that the feature was broken.
-### Address an authorized environment
+| Closed replacement | Capability | Current original / support route |
+| -------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded mode ceilings | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) |
+| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Launch replay | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) |
+| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) |
+| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) |
+| [#21459](https://github.com/pingdotgg/t3code/pull/21459) | Earlier standalone launch-receipt replacement | Historical launch work; current route [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446). |
-An owner can create a server-owned link to another T3 environment. Supported native tools can then discover its projects and models, launch a thread, read or message a thread, wait for a run, and interrupt work there.
+The closed PRs are not additional prerequisites to apply again. Their own test evidence and implementation details apply to their named revisions. For example, #21459 documents key-encoding/length and replay work; this reduced preview must not inherit all of that PR's behavior or evidence by citation alone.
-The servers handle authentication and keep connection credentials in the server secret store. Forwarded retry keys are scoped to the caller. A forwarded wait uses bounded requests while remaining attached to the run it first selected.
+The larger upstream cross-environment effort also has these **related later layers, outside this package**:
-The destination owns its threads and execution. A remotely launched top-level thread remains a top-level thread. It does not automatically gain delegated-child lineage or completion delivery.
+| Open related PR | Purpose | Relationship to this preview |
+| -------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------- |
+| [#16695](https://github.com/pingdotgg/t3code/pull/16695) | Keep work started through a link within that link. | Later ownership/lifetime layer; not supplied by this reduced patch. |
+| [#16698](https://github.com/pingdotgg/t3code/pull/16698) | Linked-environment controls in web/mobile Connections. | UI layer; this preview uses its bundled CLI/service path. |
+| [#16719](https://github.com/pingdotgg/t3code/pull/16719) | Remote `delegate_task`. | Automatic delegated-child lineage/completion is not included here. |
+| [#16731](https://github.com/pingdotgg/t3code/pull/16731) | Import conversation history. | Not included. |
+| [#16734](https://github.com/pingdotgg/t3code/pull/16734) | Package/apply a thread's Git work. | Not included. |
+| [#16751](https://github.com/pingdotgg/t3code/pull/16751) | Hand a thread off to another environment. | Not included. |
+| [#16758](https://github.com/pingdotgg/t3code/pull/16758) | Web/mobile Continue-on controls. | Not included. |
-## How this helps T3 users
+These links preserve relevant context without turning later capabilities into prerequisites for the stable ZIP. Original design/implementation credit remains with the upstream authors. #21459 separately credits GPT-6.1 Sol implementation, GPT-6 Astra tests, and Claude Opus 5.5/GPT-6 Sol reviews. Those are historical upstream credits, not new reviews of this archive.
-Users can keep coordination in visible T3 conversations instead of copying updates between tools or addressing a worker through a provider-specific session name. A controller can recover an unanswered launch and distinguish a current-turn interrupt from the broader Stop operation.
+## What works, what is pending, and how to review it
-These controls are useful when implementation and review run in separate threads, when several providers participate in one workflow, or when work needs an authorized environment's capabilities. Local launch and Stop improvements do not require another computer.
+The [PR status map](REVIEW-STATUS.md) separates each head, merge state, CI execution, reported local checks, and older runtime evidence. Open/unmerged, conflicts, a contributor-trust label, and a functional failure are different facts. A green labeling/CodeRabbit rollup is not proof that build/test CI ran. Current support-PR comments report local checks at their named heads; several CI runs require action and executed zero jobs. Historical two-machine results retain their exact tested revisions. None of that automatically validates this separate stable source package.
-For a software factory controller, messaging provides ways to address and control workers. Issue selection, capacity accounting, result acceptance, and integration remain separate responsibilities. This messaging patch does not include a factory lifecycle or a model-free tick service.
+For this package, Windows preparation passed 196 focused tests and 31 supplementary tests, relevant typechecks/static checks, and server bundling. The 14 peer tests repeated after formatting overlap those totals. A disposable fresh-home backend exercise passed startup, peer migration, authenticated project API and application RPC with all providers disabled and zero thread/session projections. See [focused evidence](VERIFICATION.json), [runtime evidence](RUNTIME-VERIFICATION.json), and [publication checks](CHECKS.md).
-Sending a message can start a model turn on the receiver and consume provider allowance. No monetary or performance saving is claimed for this patch.
+The later selected check passed 536 tests and static checks but failed three Windows fixture operations already present in v0.1.2: two malformed fixture paths and one symlink-permission case. Its aggregate test gate is not green. The release includes formatting-only cleanup in one peer test; production code matches the earlier exercise.
-## Dependencies and integration scope
+Physical routes/T3 Connect, real provider turns, every client/device, other operating systems, desktop installation, and existing-state upgrades remain unverified for this stable preview. Pending uploads cannot be forwarded as attachments. Only the supported tool set accepts environment targeting. Use owner-trusted environments: later link-origin ownership and per-project grant configuration are not supplied here. Historical key edge-case results from other PR heads are not stable-package acceptance claims.
-All support changes are bundled for the exact v0.1.2 source base: launch contracts and persistence, caller limits, full Stop requests, peer-link storage and migration 68, the MCP client/forwarder, CLI/service wiring, and matching transport/tool guidance. No tick or factory patch is required. Both servers need compatible code for forwarded calls. The [upstream PR map](UPSTREAM.md) distinguishes this reduced package from the larger cross-environment stack.
+## Source, installation, and public history
-## Version and reuse direction
+Use the [applied source branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) or the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1). The release includes the patch, manifest, MIT notice, guides, evidence and checksums; GitHub's tag archive contains full patched source. [INSTALL.md](INSTALL.md) explains both paths.
-The prepared source patch targets the official T3 Code `v0.1.2` source at commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Other releases and modified forks need their own compatibility checks. A nightly 3669 package is a separate target and is not part of this stable publication.
+The exact base is official T3 Code v0.1.2, `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. The exercised toolchain was Windows x64, Node 26.12.1 and Vite+ 1.3.0. It needs a source build, not a prompt/skill import, and supplies no desktop installer. PeerLinks adds migration 68 after the released stable history. Custom histories need deliberate integration. Try disposable state first; reversing a patch does not restore an older database.
-Reuse requires the matching source patch, a clean checkout of that base, dependency installation, and a source build. The checked preparation used Node 26.12.1 and Vite+ 1.3.0. Building the server bundle does not install or update an existing desktop application. No application installer was produced.
+| Public milestone | Record | Scope |
+| ----------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
+| Initial documentation | [3a590cdb](https://github.com/zainmitchell/t3code/commit/3a590cdbcb09f759709e694a59affd8febe13966) | Documentation only. |
+| Stable source preview | [1f21af5c](https://github.com/zainmitchell/t3code/commit/1f21af5ca057b36a6f0fed38df84bb278cc3cfd8) | Source and preview download identity. |
+| PR map, history, evidence and model attribution | [fb4df062](https://github.com/zainmitchell/t3code/commit/fb4df062d92e644cff7cecac436b22c20fc6e10a) | Documentation; source patch unchanged. |
+| Current explanation/history consolidation | This guide and its Git history. | Documentation; frozen release bytes remain unchanged. |
-The patch adds the `PeerLinks` migration at id 68 after the official v0.1.2 history. A fork that already used that id needs a deliberate migration integration. Start any manual exercise with disposable state and a throwaway repository. Before changing an existing installation, stop its server and keep a complete backup. Reversing a source patch does not restore an earlier database.
+Useful feedback is a concrete coordination failure, its target revision, or a missing integration dependency. Upstream direction/scope and maintainer acceptance remain separate from fork testing.
-Use [INSTALL.md](INSTALL.md) for the already-patched branch or source-patch ZIP. The [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) includes the versioned code patch, full license notice, manifest, verification records, and checksums.
+## Maintenance, upstream intent, and bug reports
-## Current verification limits
+This is a temporary community patch fork for free, self-hosted open-source use. The owner maintains the patches and tracks bugs in [the fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire these fork-specific changes as upstream meets the need.
-The source package passed 196 focused tests and 31 supplementary tests, relevant typechecks, static checks, and server bundling on Windows x64. The 14 peer tests rerun after cleanup are an overlapping subset. A fresh-home backend exercise also passed startup, peer-link migration, authenticated project API and client RPC checks with every provider disabled and no thread/session projections. The proof server stopped. See [RUNTIME-VERIFICATION.json](RUNTIME-VERIFICATION.json). The tool tests use synthetic boundaries. They do not establish physical host-to-host operation, real T3 Connect routes, actual provider/model execution, other operating systems, or full web, desktop, and mobile runtime behavior.
+We welcome upstream adoption through maintainer-approved direction and focused contributions. The stable bundle is a distribution unit, not a demand to merge that entire bundle as one upstream PR. This work carries no upstream endorsement, approval, or merge commitment. Reports should identify the source base, patch/version, reproduction, expected and observed behavior, and suitably redacted evidence.
-Pending local uploads cannot be forwarded as attachments. Environment targeting applies only to the explicitly supported tool set. The complete repository test suite and installation against existing production state were not exercised. See the [verification summary](VERIFICATION.json) for the counts and evidence boundaries.
-
-## Feedback and upstream context
-
-Useful feedback includes which coordination failure matters most in your workflow, how retry and Stop behavior should be presented, and which host-to-host scenario would make the best first real-route demonstration.
-
-[Native thread control, #8433](https://github.com/pingdotgg/t3code/discussions/8433), records related upstream capabilities. [Cross-computer threads, #6935](https://github.com/pingdotgg/t3code/discussions/6935), covers a related use case. Those discussions are context for the individual behaviors described here. This page does not claim that local thread messaging is new to T3.
-
-This is community fork work, with no upstream endorsement or merge commitment claimed. The source and documentation retain the [MIT notice](LICENSE). A source release must retain its required copyright and permission notice and applicable upstream dependency notices.
-
-The additional [publication checks](CHECKS.md) passed static checks and 536 selected tests but recorded three Windows fixture failures. The aggregate test gate is not green on this host. The preview adds only formatter cleanup in one test to the previously exercised code.
-
-## Maintenance and upstream intent
-
-This is a temporary community patch fork. The owner maintains these patches and tracks bugs in [this fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire the fork-specific patches as upstream meets the need.
-
-We would welcome upstream adoption of suitable changes through maintainer-approved direction and focused contributions. This preview carries no upstream endorsement, approval, merge commitment, or promise that its complete stable bundle will be merged as one PR.
+The [MIT notice](LICENSE) must accompany copies or substantial portions. Retain applicable dependency notices when distributing a built application. No paid hosting or application installer is provided by this preview.
## AI models and roles
-| Model | Role in this fork/release work | Harness |
-| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
-| **Claude Opus 5.5** | Fork implementation and integration work. Existing T3 code retains its original authorship and upstream credits. | T3 Code's Claude provider. |
-| **GPT-6.1 Sol** | Source extraction, limited cleanup, source/evidence and documentation review, focused verification, examples, and publication preparation. The tick documentation also received a GPT-6.1 Sol review. | Codex harness in T3 Code. |
+| Model | Work credited here | Harness |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- |
+| Claude Opus 5.5 | Fork implementation/integration work; original T3 source retains its upstream authorship. | T3 Code's Claude provider. |
+| GPT-6.1 Sol | Source extraction, limited cleanup, source/evidence and documentation review, focused verification, examples, and publication preparation. Native tick also received a documentation review with this model. | Codex in T3 Code. |
-These roles do not claim an independent full code audit of every upstream revision. CodeRabbit review of the source release has not been established; automated PR statuses and human/agent verification are reported separately.
+These credits do not claim a new independent audit of every upstream revision. Historical upstream model credits remain attached to their own PRs. CodeRabbit review of this fork's source has not yet been established; a review request, a bot status, and a completed review are different states.
diff --git a/docs/community-patches/native-messaging/UPSTREAM.md b/docs/community-patches/native-messaging/UPSTREAM.md
index 11f14c0066135..0a7a5431d7336 100644
--- a/docs/community-patches/native-messaging/UPSTREAM.md
+++ b/docs/community-patches/native-messaging/UPSTREAM.md
@@ -1,47 +1,46 @@
-# Upstream work and integration scope
+# Dependencies and history
-This community preview integrates existing T3 work with selected reliability changes on the exact stable v0.1.2 source base. It does not originate the underlying native thread-control or linked-environment design. The complete delivered code delta is identified by MANIFEST.json and the source-patch checksum.
+## Required PR layers and bundled dependencies
-## Upstream PR dependencies and support fixes
+T3 already provides local launch/read/send tools, including queue, steer, and restart. These additions extend request reliability, full Stop behavior, and linked-environment targeting. The four feature layers below form the upstream linked-environment route; each support PR targets the corresponding original feature branch.
-| Required feature layer | Original feature PR | Support/integration PR | What the layer supplies |
-| ------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
-| Caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Runtime and interaction ceilings for forwarded calls. |
-| Launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Recover accepted launches on retry, with caller and worktree validation. |
-| Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Pairing, credentials, storage, MCP client, migration 68, and response/cleanup fixes. |
-| Native linked-environment tools | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Native-tool forwarding plus Stop, validation, and wire-codec fixes. |
+| Required layer and purpose | Original feature | Support/integration work | Stable preview |
+| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------ |
+| Caller mode ceilings prevent a forwarding credential from widening an agent's permissions. | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Included. |
+| Launch request keys recover accepted work after a lost response. | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Included, with the selected snapshot's replay/validation behavior. |
+| Peer links provide authenticated pairing, credential storage, MCP transport, and migration 68. | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Included, with selected response/cleanup fixes. |
+| Native tools target the linked environment; integration includes Stop, validation, and codec work. | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Included in the reduced tool set described here. |
-The original feature order is mode limits → launch request keys → peer links → native-tool forwarding. Each support PR targets its matching original feature branch. These are paired feature/support routes, not eight independent patches to apply sequentially. Current PR heads can advance independently; this table is not a verified cherry-pick recipe for current main.
+All eight were open and unmerged when checked on 2026-10-25. The feature order is mode ceilings, launch request keys, peer links, then forwarding. These are paired feature/support routes, not eight patches to apply sequentially. Support heads can advance independently; their latest heads are not all a single verified linear stack.
-For the published v0.1.2 preview, the selected supporting changes are already bundled in the single source patch. No separate PR application is required. Do not apply these PRs on top of that download. The preview is a reduced, version-pinned integration, not an assertion that it contains every change in the latest PR heads. The evidence attached to a newer PR head does not automatically validate this stable package.
+For the exact official v0.1.2 base, the download already bundles its selected changes in one 48-file patch. It includes contracts, RPC/MCP authorization, launch and Stop persistence, handlers, WebSocket/service wiring, CLI, peer storage/client/forwarder, migration registration, tool/provider guidance, and tests. **Do not apply the linked PRs on top of the stable download.** No native tick or factory patch, new package manifest, or lockfile change is required. The manifest identifies the complete source delta; it is not a claim to contain every later fix in current PR heads.
-The later replacement stack [#21670](https://github.com/pingdotgg/t3code/pull/21670), [#21671](https://github.com/pingdotgg/t3code/pull/21671), [#21672](https://github.com/pingdotgg/t3code/pull/21672), and [#21673](https://github.com/pingdotgg/t3code/pull/21673) was closed without merge. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) says the maintainer retained the original stack. This preview creates no replacement PR and requests no merge of the older closed stack.
+The package reuses T3's existing orchestration, thread/project services, authentication, secret storage, provider adapters, contracts, and Effect runtime. Those dependencies come from the pinned base and lockfile. Forwarding requires compatible code and an authorized link at both servers. Local launch/Stop improvements do not require another computer.
-The package is a reduced source integration, not a byte-for-byte copy of the current PR heads or the full cross-environment stack. It includes launch-retry, forwarded-limit, Stop-retry, and peer response/cleanup changes present in its recorded source snapshot. Tests and runtime evidence here apply to this package, not to every upstream PR revision.
+## PR history and later layers
-## Historical PR route
+The older replacement route was closed without merging when the original feature stack was retained. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records that decision. Closed here is contribution history, not a diagnosis that the feature was broken.
-The four capabilities remain necessary for this linked-environment design. Their older replacement PRs were closed without merging when maintainers retained the original feature stack. Applicable fixes now have the original/support integration routes below. Closed here records contribution history; it is not a finding that the code was broken.
+| Closed replacement | Capability | Current original / support route |
+| -------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded mode ceilings | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) |
+| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Launch replay | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) |
+| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) |
+| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) |
+| [#21459](https://github.com/pingdotgg/t3code/pull/21459) | Earlier standalone launch-receipt replacement | Historical launch work; current route [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446). |
-| Closed replacement PR | Capability | Current original / support route |
-| -------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
-| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded caller mode limits | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) |
-| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Recoverable launch request keys | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) |
-| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links and credentials | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) |
-| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) |
+The closed PRs are not additional prerequisites to apply again. Their own test evidence and implementation details apply to their named revisions. For example, #21459 documents key-encoding/length and replay work; this reduced preview must not inherit all of that PR's behavior or evidence by citation alone.
-The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records why the original route was retained. These historical replacements are not four extra patches to apply on top of the current integrations or the bundled stable download.
+The larger upstream cross-environment effort also has these **related later layers, outside this package**:
-See [REVIEW-STATUS.md](REVIEW-STATUS.md) for the merge state, CI execution, and exact revision evidence.
+| Open related PR | Purpose | Relationship to this preview |
+| -------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------- |
+| [#16695](https://github.com/pingdotgg/t3code/pull/16695) | Keep work started through a link within that link. | Later ownership/lifetime layer; not supplied by this reduced patch. |
+| [#16698](https://github.com/pingdotgg/t3code/pull/16698) | Linked-environment controls in web/mobile Connections. | UI layer; this preview uses its bundled CLI/service path. |
+| [#16719](https://github.com/pingdotgg/t3code/pull/16719) | Remote `delegate_task`. | Automatic delegated-child lineage/completion is not included here. |
+| [#16731](https://github.com/pingdotgg/t3code/pull/16731) | Import conversation history. | Not included. |
+| [#16734](https://github.com/pingdotgg/t3code/pull/16734) | Package/apply a thread's Git work. | Not included. |
+| [#16751](https://github.com/pingdotgg/t3code/pull/16751) | Hand a thread off to another environment. | Not included. |
+| [#16758](https://github.com/pingdotgg/t3code/pull/16758) | Web/mobile Continue-on controls. | Not included. |
-## What must be applied
-
-For the exact official v0.1.2 base, all supporting changes in this package are bundled in one patch. Do not apply the linked PR stack in addition to this ZIP. It needs no native tick or software factory patch and introduces no new dependency manifest or lockfile.
-
-Both participating servers need compatible code for forwarded limits. Link access caps runtime and interaction modes. Use owner-trusted environments; this reduced package does not provide the later link-origin ownership layer or per-project grant configuration. Peer-link migration 68 must fit the destination's migration history.
-
-The later [link-origin layer #16695](https://github.com/pingdotgg/t3code/pull/16695), linked-environment UI, remote `delegate_task`, conversation import, and thread handoff are outside this package. A remote top-level launch does not become a delegated child with automatic result delivery. A separate 3669 package remains a different, unfinished target.
-
-Original upstream design and implementation are credited through the PRs above. The owner identifies Claude Opus 5.5 for the fork additions; this preparation used GPT-6.1 Sol through Codex for extraction, small cleanup, verification, examples, and documentation. The full upstream MIT copyright and permission notice is retained in LICENSE. This community release carries no maintainer endorsement.
-
-Related feature discussions include [launch request keys #16186](https://github.com/pingdotgg/t3code/discussions/16186) and [Claude-specific cross-machine messaging #16230](https://github.com/pingdotgg/t3code/discussions/16230). This version-pinned preview shares integration and verification evidence; it does not open a competing feature PR.
+These links preserve relevant context without turning later capabilities into prerequisites for the stable ZIP. Original design/implementation credit remains with the upstream authors. #21459 separately credits GPT-6.1 Sol implementation, GPT-6 Astra tests, and Claude Opus 5.5/GPT-6 Sol reviews. Those are historical upstream credits, not new reviews of this archive.
From eaa1a4ebce0a8ef7565b69383dfe25c274379b9a Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:04:56 -0500
Subject: [PATCH 05/10] fix(server): keep peer tool refusals out of link health
Apply verified fork-review findings and correct the disposable installation instructions. Preserve the frozen preview.1 assets and upstream base.
Implementation and validation: GPT-6.1 Sol through Codex in T3 Code. Automated findings: CodeRabbit.
---
apps/server/src/peer/PeerForwarding.test.ts | 5 +
apps/server/src/peer/PeerLinks.test.ts | 24 +++
apps/server/src/peer/PeerMcpClient.ts | 153 +++++++++---------
.../native-messaging/INSTALL.md | 11 +-
4 files changed, 117 insertions(+), 76 deletions(-)
diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts
index b556136ee4bec..80ae4bd224629 100644
--- a/apps/server/src/peer/PeerForwarding.test.ts
+++ b/apps/server/src/peer/PeerForwarding.test.ts
@@ -398,6 +398,7 @@ it.effect("an agent here works on a linked environment's threads within its own
// A plan-mode agent here may not steer that thread there: the box
// refuses it, because the call carries the agent's own modes.
+ yield* a.links.recordOutcome(box.environmentId, { error: "Earlier transport failure." });
const planned = yield* a.call("thread:laptop-plan", "t3_thread_send", {
environmentId: box.environmentId,
threadId: boxThread,
@@ -405,6 +406,10 @@ it.effect("an agent here works on a linked environment's threads within its own
});
expect(failureOf(planned)).toMatchObject({ code: "interaction_mode_escalation_denied" });
expect((yield* Ref.get(seen)).sends).toHaveLength(1);
+ expect(yield* a.links.get(box.environmentId)).toMatchObject({
+ _tag: "Some",
+ value: { lastError: null },
+ });
// stop=true stops the thread there as its Stop button does: the whole
// thread and the tasks it delegated, not just the running turn.
diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts
index 983cad32549f3..aa80b4f82bc2d 100644
--- a/apps/server/src/peer/PeerLinks.test.ts
+++ b/apps/server/src/peer/PeerLinks.test.ts
@@ -136,8 +136,28 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit
expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" });
// A refusal keeps the peer's reason.
+ yield* a.links.recordOutcome(box.environmentId, { error: "Earlier transport failure." });
const notFound = yield* probe(a, undefined, "refuse").pipe(Effect.flip);
expect(notFound).toMatchObject({ code: "thread_not_found", message: "No such thread." });
+ expect(yield* a.links.get(box.environmentId)).toMatchObject({
+ _tag: "Some",
+ value: { lastError: null },
+ });
+
+ const mismatch = yield* a.peer
+ .call({
+ environmentId: box.environmentId,
+ tool: "probe",
+ arguments: {},
+ limits: { runtimeMode: "auto", interactionMode: "default" },
+ success: Schema.Struct({ incompatible: Schema.String }),
+ })
+ .pipe(Effect.flip);
+ expect(mismatch.message).toContain("did not match this version");
+ expect(yield* a.links.get(box.environmentId)).toMatchObject({
+ _tag: "Some",
+ value: { lastError: null },
+ });
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
@@ -220,6 +240,10 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex
const revoked = yield* probe(a).pipe(Effect.flip);
expect(revoked.code).toBe("capability_denied");
expect(revoked.message).toContain("revoked there");
+ expect(yield* a.links.get(box.environmentId)).toMatchObject({
+ _tag: "Some",
+ value: { lastError: revoked.message },
+ });
yield* TestClock.adjust(Duration.days(31));
expect((yield* a.links.list).map((link) => link.status)).toEqual(["expired"]);
diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts
index 30adbbdc86aee..7971c34016f74 100644
--- a/apps/server/src/peer/PeerMcpClient.ts
+++ b/apps/server/src/peer/PeerMcpClient.ts
@@ -161,27 +161,32 @@ const make = Effect.gen(function* () {
return sessionId;
});
- const call: PeerMcpClient["Service"]["call"] = Effect.fn("PeerMcpClient.call")(
- function* (input) {
- const resolved = yield* links
- .resolve(input.environmentId)
- .pipe(Effect.mapError(fromLinkError));
- const limitHeader = {
- [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`,
- };
- const sessionKey = `${input.environmentId} ${resolved.url}`;
- const callOnce = (sessionId: string) =>
- post(
- resolved.url,
- resolved.token,
- {
- jsonrpc: "2.0",
- id: 1,
- method: "tools/call",
- params: { name: input.tool, arguments: input.arguments },
- },
- { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION },
- );
+ const call: PeerMcpClient["Service"]["call"] = Effect.fn("PeerMcpClient.call")(function* (input) {
+ const resolved = yield* links.resolve(input.environmentId).pipe(
+ Effect.tapError((error) =>
+ error.reason === "unreachable" || error.reason === "expired"
+ ? links.recordOutcome(input.environmentId, { error: error.message })
+ : Effect.void,
+ ),
+ Effect.mapError(fromLinkError),
+ );
+ const limitHeader = {
+ [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`,
+ };
+ const sessionKey = `${input.environmentId} ${resolved.url}`;
+ const callOnce = (sessionId: string) =>
+ post(
+ resolved.url,
+ resolved.token,
+ {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: { name: input.tool, arguments: input.arguments },
+ },
+ { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION },
+ );
+ const response = yield* Effect.gen(function* () {
const existing = (yield* Ref.get(sessions)).get(sessionKey);
const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader));
let response = yield* callOnce(sessionId);
@@ -205,64 +210,62 @@ const make = Effect.gen(function* () {
message: `The linked environment answered HTTP ${response.status}.`,
});
}
- const body = yield* decodeToolCallResponse(
- responseJson(response.headers["content-type"], yield* response.text, 1),
- ).pipe(
- Effect.mapError(
- () =>
- new OrchestratorMcpFailure({
- code: "orchestration_error",
- message: "The linked environment answered in an unexpected shape.",
- }),
- ),
- );
- if (body.error !== undefined || body.result === undefined) {
- return yield* new OrchestratorMcpFailure({
- code: "invalid_request",
- message: body.error?.message ?? "The linked environment returned no result.",
- });
- }
- const text = body.result.content.map((part) => part.text ?? "").join("");
- if (body.result.isError === true) {
- // A refusal keeps the peer's own code, so the agent sees why it was refused.
- const refused = decodeFailure(text);
- return yield* refused._tag === "Some"
- ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message })
- : new OrchestratorMcpFailure({
- code: "orchestration_error",
- message: text || "The linked environment refused the call.",
- });
- }
- const success = Schema.fromJsonString(input.success);
- return yield* Schema.decodeUnknownEffect(success)(text).pipe(
- Effect.mapError(
- () =>
+ return { contentType: response.headers["content-type"], text: yield* response.text };
+ }).pipe(
+ Effect.tapError((error) =>
+ links.recordOutcome(input.environmentId, { error: error.message }),
+ ),
+ Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })),
+ Effect.catchTags({
+ HttpClientError: () =>
+ Effect.fail(
new OrchestratorMcpFailure({
code: "orchestration_error",
- message:
- "The linked environment's answer did not match this version. Update both environments.",
+ message: "The linked environment stopped answering.",
}),
- ),
- );
- },
- (effect, input) =>
- effect.pipe(
- Effect.tapError((error) =>
- links.recordOutcome(input.environmentId, { error: error.message }),
- ),
- Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })),
- // Recorded above with the transport's own message; the agent hears it plainly.
- Effect.catchTags({
- HttpClientError: () =>
- Effect.fail(
- new OrchestratorMcpFailure({
- code: "orchestration_error",
- message: "The linked environment stopped answering.",
- }),
- ),
- }),
+ ),
+ }),
+ );
+ const body = yield* decodeToolCallResponse(
+ responseJson(response.contentType, response.text, 1),
+ ).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: "The linked environment answered in an unexpected shape.",
+ }),
+ ),
+ );
+ if (body.error !== undefined || body.result === undefined) {
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: body.error?.message ?? "The linked environment returned no result.",
+ });
+ }
+ const text = body.result.content.map((part) => part.text ?? "").join("");
+ if (body.result.isError === true) {
+ // A refusal keeps the peer's own code, so the agent sees why it was refused.
+ const refused = decodeFailure(text);
+ return yield* refused._tag === "Some"
+ ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message })
+ : new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message: text || "The linked environment refused the call.",
+ });
+ }
+ const success = Schema.fromJsonString(input.success);
+ return yield* Schema.decodeUnknownEffect(success)(text).pipe(
+ Effect.mapError(
+ () =>
+ new OrchestratorMcpFailure({
+ code: "orchestration_error",
+ message:
+ "The linked environment's answer did not match this version. Update both environments.",
+ }),
),
- );
+ );
+ });
return PeerMcpClient.of({ call });
});
diff --git a/docs/community-patches/native-messaging/INSTALL.md b/docs/community-patches/native-messaging/INSTALL.md
index 9e9e8bbf1f1b1..3e996079328e3 100644
--- a/docs/community-patches/native-messaging/INSTALL.md
+++ b/docs/community-patches/native-messaging/INSTALL.md
@@ -46,6 +46,15 @@ The recorded exercise used fresh homes, synthetic projects, disabled providers,
The launch and Stop improvements also work locally. Supply a stable `clientRequestId` for one top-level launch and reuse it when recovering that same unanswered request. Follow the returned thread and run to determine actual preparation and execution success. `t3_thread_interrupt` with `stop: true` requests the broader Stop operation; acceptance does not prove every arbitrary process ended.
-For another environment, run the built CLI's `environment link --help` and use its destination pairing code and access options. The agent discovers links with `t3_environment_links` and uses `environmentId` only on supported native tools. Both servers need compatible code. Keep pairing details private and use owner-trusted environments. The destination can revoke its connection.
+For another environment, run the built CLI's `environment link --help` and obtain a pairing code from the destination. Store the link in the same home used by the server above:
+
+```sh
+node apps/server/dist/bin.mjs environment link https://peer.example --pairing-code DESTINATION_PAIRING_CODE --access approval-required --base-dir /path/to/new-example-home
+node apps/server/dist/bin.mjs environment list --base-dir /path/to/new-example-home
+```
+
+Replace the destination URL and pairing-code placeholder. Use the same absolute `--base-dir` for `serve`, `environment link`, and `environment list`. Without that flag or a matching `T3CODE_HOME`, the CLI can store the link in a different home from the running server.
+
+The agent discovers links with `t3_environment_links` and uses `environmentId` only on supported native tools. Both servers need compatible code. Keep pairing details private and use owner-trusted environments. The destination can revoke its connection.
The patch adds peer-link migration 68 after the official stable history. A custom fork that already used that number requires integration. Before any existing-state upgrade, stop the server and retain a complete backup. Reversing source code does not restore an earlier database. This preview does not include the later link-origin ownership, remote delegation, or handoff layers described in UPSTREAM.md.
From f5deb26f305de80e1e11a0d3538d37a4ca7c0088 Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:06:34 -0500
Subject: [PATCH 06/10] docs: distinguish reviewed branch fixes from frozen
preview downloads
Describe the verified corrections and scoped evidence. Keep original release checksums and installation limitations explicit.
Documentation: GPT-6.1 Sol through Codex in T3 Code.
---
.../native-messaging/BRANCH-UPDATES.md | 19 +++++++++++++++++++
.../native-messaging/INSTALL.md | 2 ++
.../native-messaging/MANIFEST.json | 5 ++++-
.../native-messaging/README.md | 2 ++
4 files changed, 27 insertions(+), 1 deletion(-)
create mode 100644 docs/community-patches/native-messaging/BRANCH-UPDATES.md
diff --git a/docs/community-patches/native-messaging/BRANCH-UPDATES.md b/docs/community-patches/native-messaging/BRANCH-UPDATES.md
new file mode 100644
index 0000000000000..f776269c618c7
--- /dev/null
+++ b/docs/community-patches/native-messaging/BRANCH-UPDATES.md
@@ -0,0 +1,19 @@
+# Review fixes after preview.1
+
+These changes are on the review branch at source commit [`eaa1a4ebce0a8ef7565b69383dfe25c274379b9a`](https://github.com/zainmitchell/t3code/commit/eaa1a4ebce0a8ef7565b69383dfe25c274379b9a). [Fork review PR #1](https://github.com/zainmitchell/t3code/pull/1) remains the review record. The original preview.1 tag, ZIP, raw patch and checksum file are unchanged. Downloading preview.1 does not include these later fixes. Build the branch to try the corrected source while review continues.
+
+## What changed
+
+The review branch records connection outcomes before interpreting a peer tool result. A valid tool refusal or an incompatible tool-result schema still reaches the caller as an error, but clears an old connection error. HTTP, session, unreachable-route and expired-link failures still record a link problem. Local unknown-link failures do not become connection outcomes.
+
+The regression first reproduced a mode refusal appearing as the link's `lastError`. The corrected peer tests cover that refusal, `thread_not_found`, stale-error clearing, decode mismatch and a revoked credential remaining visible as a link failure.
+
+The installation guide now gives explicit `environment link` and `environment list` commands with the same `--base-dir` as `serve`. Built CLI help confirmed the options. That check did not perform a new remote pairing.
+
+## Verification
+
+The changed-file gate passed formatting, lint, knip, GraphQL checks, typechecking of two affected packages, and all 14 peer tests in two executed files. Four test paths were selected. This count overlaps the original preview evidence.
+
+The earlier preview's broader Windows fixture failures and runtime limitations remain recorded in CHECKS.md and RUNTIME-VERIFICATION.json. A passing follow-up gate is not a full-suite, full-client, real-provider, physical-remote-route, other-OS, existing-state-upgrade or production acceptance claim. No deployment or upstream merge follows from these checks. CodeRabbit's previous review returned findings; assessment and fixes do not establish approval of the new head.
+
+Implementation and validation of these fixes: GPT-6.1 Sol through Codex in T3 Code. Automated findings: CodeRabbit. The original Claude Opus 5.5 and inherited upstream authorship remain credited in README.md and UPSTREAM.md.
diff --git a/docs/community-patches/native-messaging/INSTALL.md b/docs/community-patches/native-messaging/INSTALL.md
index 3e996079328e3..164104b99c5e1 100644
--- a/docs/community-patches/native-messaging/INSTALL.md
+++ b/docs/community-patches/native-messaging/INSTALL.md
@@ -1,5 +1,7 @@
# Build and try the Native messaging source preview
+The current branch includes [review fixes after preview.1](BRANCH-UPDATES.md). The preview.1 download remains frozen and does not include those fixes. Its MANIFEST.json describes that original patch. Use the branch build below to try the corrected source while review continues.
+
This preview targets official T3 Code v0.1.2, commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Windows backend setup was exercised with Node 26.12.1 and Vite+ 1.3.0. It provides source code, not a desktop installer or automatic app update. Use disposable state for the first trial.
## Build the already-patched branch
diff --git a/docs/community-patches/native-messaging/MANIFEST.json b/docs/community-patches/native-messaging/MANIFEST.json
index 898359f6c6de0..4eafc2e8d3359 100644
--- a/docs/community-patches/native-messaging/MANIFEST.json
+++ b/docs/community-patches/native-messaging/MANIFEST.json
@@ -202,5 +202,8 @@
"format": "Unified source diff; no contributor history or author metadata included",
"privacyScreen": "Patch and complete changed-file contents screened; no findings",
"previewCleanup": "Whitespace-only formatting of one peer test; production code is identical to the previously verified package",
- "originalPackagePatchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb"
+ "originalPackagePatchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb",
+ "artifactScope": "Frozen preview.1 archive and raw patch; these hashes do not describe later branch files.",
+ "reviewFollowupSourceCommit": "eaa1a4ebce0a8ef7565b69383dfe25c274379b9a",
+ "reviewFollowup": "BRANCH-UPDATES.md"
}
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
index 55f8cecaaf594..66d432a4ccf1d 100644
--- a/docs/community-patches/native-messaging/README.md
+++ b/docs/community-patches/native-messaging/README.md
@@ -1,5 +1,7 @@
# Native agent messaging for T3 Code v0.1.2
+The review branch now includes [fixes after the frozen preview.1 download](BRANCH-UPDATES.md), including their verification and remaining limits. The original archive and raw patch are unchanged.
+
This community source preview extends T3's existing native thread tools with recoverable launches, full Stop/retry behavior, forwarded permission ceilings, and selected tools for explicitly linked environments. It is a reduced stable integration of existing T3 work and selected fixes, with the PR relationships and verification limits below.
## Why this exists
From 6a42c2359072b859c4cc57d1ac3e70f851f8d744 Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:56:06 -0500
Subject: [PATCH 07/10] fix(server): bound Stop retries and validate idle
launch bases
Reach background work owned before the original Stop while preserving newer runs, queues, watches and provider rosters. Validate a worktree base before an accepted idle thread receives its first message.
Implementation and validation: GPT-6.1 Sol through Codex in T3 Code. Findings: CodeRabbit.
---
.../src/mcp/toolkits/project/handlers.test.ts | 66 ++++++++-
.../src/mcp/toolkits/project/handlers.ts | 20 ++-
.../src/orchestration-v2/Orchestrator.ts | 124 ++++++++++++-----
.../src/orchestration-v2/ThreadStop.test.ts | 130 +++++++++++++++---
4 files changed, 286 insertions(+), 54 deletions(-)
diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts
index 01de75a5bba32..874095d600e13 100644
--- a/apps/server/src/mcp/toolkits/project/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts
@@ -27,6 +27,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../../../vcs/VcsProcess.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
+import { liveThreadShell } from "../../McpToolAccess.testkit.ts";
import * as ProjectHandlers from "./handlers.ts";
import { ProjectToolkit } from "./tools.ts";
@@ -38,6 +39,8 @@ const clientLaunchHarness = (input: {
readonly launched: Array;
readonly workspaceRoot?: string;
readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
+ readonly accepted?: boolean;
+ readonly messageCount?: number;
}) => {
const projectId = ProjectId.make("project:client-target");
const modelSelection = {
@@ -71,9 +74,16 @@ const clientLaunchHarness = (input: {
issuedAt: 0,
capabilities: new Set(["orchestration" as const]),
}),
- Layer.mock(ThreadManagement.ThreadManagementService)({}),
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getMessageCount: () => Effect.succeed(input.messageCount ?? 0),
+ }),
Layer.mock(ThreadLaunch.ThreadLaunchService)({
- getAcceptedLaunch: () => Effect.succeed(Option.none()),
+ getAcceptedLaunch: ({ threadId }) =>
+ Effect.succeed(
+ input.accepted === true && threadId !== undefined
+ ? Option.some({ ...liveThreadShell(threadId), projectId, modelSelection })
+ : Option.none(),
+ ),
checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void),
launch: (launch) => {
input.launched.push(launch);
@@ -297,6 +307,58 @@ it.effect("a launch binds only an existing checkout that is one of the project's
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+it.effect.each([
+ { name: "first message", messageCount: 0, message: "Begin work.", checksBase: true },
+ { name: "message replay", messageCount: 1, message: "Begin work.", checksBase: false },
+ { name: "idle replay", messageCount: 0, message: undefined, checksBase: false },
+])("an accepted launch validates its base for $name", ({ messageCount, message, checksBase }) =>
+ Effect.gen(function* () {
+ const launched: Array = [];
+ let checks = 0;
+ const { projectId, dependencies } = clientLaunchHarness({
+ runtimeModeCeiling: "auto",
+ launched,
+ accepted: true,
+ messageCount,
+ checkWorktreeBase: ({ projectId }) => {
+ checks++;
+ return Effect.fail(
+ new ThreadLaunch.ThreadLaunchBaseRefError({
+ projectId,
+ projectTitle: "Client target",
+ workspaceRoot: "/projects/client-target",
+ baseRef: "missing-base",
+ }),
+ );
+ },
+ });
+ const toolkit = yield* ProjectToolkit.pipe(
+ Effect.provide(
+ McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
+ ),
+ );
+ const result = (yield* toolkit
+ .handle("t3_thread_launch", {
+ title: "Fix",
+ projectId,
+ clientRequestId: "accepted-idle",
+ ...(message === undefined ? {} : { message }),
+ workspaceStrategy: { type: "worktree", baseRef: "missing-base", startFromOrigin: false },
+ })
+ .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies))).at(-1)?.result;
+ expect(checks).toBe(checksBase ? 1 : 0);
+ expect(launched).toHaveLength(checksBase ? 0 : 1);
+ expect(result).toMatchObject(
+ checksBase
+ ? {
+ code: "invalid_request",
+ message: expect.stringContaining("The accepted thread remains idle."),
+ }
+ : { projectId },
+ );
+ }),
+);
+
it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () =>
Effect.gen(function* () {
const launched: Array = [];
diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts
index 168b16293e654..633c29b0e1b24 100644
--- a/apps/server/src/mcp/toolkits/project/handlers.ts
+++ b/apps/server/src/mcp/toolkits/project/handlers.ts
@@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
+import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
@@ -107,8 +108,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
),
)).projectId
: yield* resolveProjectId(context, input.projectId);
- // Accepted creates skip mutable base/default-model preflights.
- // Message intake still handles their replay and preparation.
+ // Accepted creates reuse their model. Only their first message needs
+ // base validation; message replays reuse stored preparation.
const acceptedLaunch =
input.clientRequestId === undefined
? undefined
@@ -137,7 +138,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
// A launch is accepted before its worktree is provisioned, so a base
// ref that cannot resolve is refused while the agent can still act on it.
const strategy = input.workspaceStrategy;
- if (acceptedLaunch === undefined && strategy?.type === "worktree")
+ const acceptedIdleFirstMessage =
+ acceptedLaunch !== undefined &&
+ strategy?.type === "worktree" &&
+ (input.message !== undefined || attachments.length > 0) &&
+ (yield* ThreadManagement.ThreadManagementService.pipe(
+ Effect.flatMap((threads) => threads.getMessageCount(threadId)),
+ Effect.mapError(unavailable),
+ )) === 0;
+ if (
+ (acceptedLaunch === undefined || acceptedIdleFirstMessage) &&
+ strategy?.type === "worktree"
+ )
yield* ThreadLaunch.ThreadLaunchService.pipe(
Effect.flatMap((launches) =>
launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }),
@@ -146,7 +158,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
(error) =>
new OrchestratorMcpFailure({
code: "invalid_request",
- message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${
+ message: `${error.message} ${acceptedLaunch === undefined ? "No thread was created." : "The accepted thread remains idle."} Pass a branch, tag, or commit that exists there${
strategy.startFromOrigin === true
? "; the project has no usable remote to fetch it from."
: ", or set startFromOrigin:true if it exists only on a remote."
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index 182070c44e36c..fbf81d4ed1e55 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -8860,6 +8860,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}
for (const providerThread of input.projection.providerThreads) {
if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue;
+ if (
+ providerThread.lastRunOrdinal !== null &&
+ providerThread.lastRunOrdinal > input.throughRunOrdinal
+ )
+ continue;
if (providerThread.id === input.stoppedProviderThreadId) {
// A refused newer start can take ownership and reject the stopped
// turn's roster clear. Reconcile it without hiding surviving work.
@@ -9408,20 +9413,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
now,
});
}
- // A new turn may have started since Stop; its work is not this Stop's.
- if (
- projection.runs.some(
- (run) =>
- run.id !== stoppedRun.id &&
- (run.status === "preparing" || run.status === "starting" || run.status === "running"),
- )
- ) {
- return;
- }
+ const backgroundRunIds = new Set(
+ projection.runs.filter((run) => run.ordinal <= stoppedRun.ordinal).map((run) => run.id),
+ );
+ const backgroundProjection = projection.runs.some((run) => !backgroundRunIds.has(run.id))
+ ? {
+ ...projection,
+ turnItems: projection.turnItems.filter(
+ (item) => item.runId !== null && backgroundRunIds.has(item.runId),
+ ),
+ subagents: projection.subagents.filter(
+ (task) => task.runId !== null && backgroundRunIds.has(task.runId),
+ ),
+ }
+ : projection;
yield* settleBackgroundWork({
command,
events,
- projection,
+ projection: backgroundProjection,
stoppedProviderThreadId: command.providerThreadId,
throughRunOrdinal: stoppedRun.ordinal,
now,
@@ -9554,6 +9563,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
command: Extract,
events: Ref.Ref>,
effects: Ref.Ref>,
+ stopRunIds?: ReadonlySet,
) {
const projection = yield* loadProjectionForCommand(
command,
@@ -9590,14 +9600,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
run?.providerThreadId === null
? undefined
: projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId);
+ const backgroundProjection =
+ stopRunIds === undefined
+ ? projection
+ : {
+ ...projection,
+ runs: projection.runs.filter((candidate) => stopRunIds.has(candidate.id)),
+ turnItems: projection.turnItems.filter(
+ (item) => item.runId !== null && stopRunIds.has(item.runId),
+ ),
+ subagents: projection.subagents.filter(
+ (task) => task.runId !== null && stopRunIds.has(task.runId),
+ ),
+ };
const hasBackgroundWork =
- run?.id === latestDequeuedRun(projection.runs)?.id &&
+ run?.id === latestDequeuedRun(backgroundProjection.runs)?.id &&
derivePendingBackgroundWork({
latestRun: run,
- providerThreads: projection.providerThreads,
- turnItems: projection.turnItems,
- activeProviderThreadId: projection.thread.activeProviderThreadId,
- runs: projection.runs,
+ providerThreads: backgroundProjection.providerThreads.filter(
+ (thread) =>
+ stopRunIds === undefined ||
+ thread.lastRunOrdinal === null ||
+ thread.lastRunOrdinal <= (run?.ordinal ?? -1),
+ ),
+ turnItems: backgroundProjection.turnItems,
+ activeProviderThreadId:
+ stopRunIds === undefined
+ ? projection.thread.activeProviderThreadId
+ : (run?.providerThreadId ?? null),
+ runs: backgroundProjection.runs,
}).length > 0;
// A failed start has no provider turn. Background work still belongs
// to the provider thread, so Stop reaches its latest accepted turn.
@@ -9686,8 +9717,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
{ readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number }
>();
for (const item of pendingBackgroundTurnItems({
- turnItems: projection.turnItems,
- runs: projection.runs,
+ turnItems: backgroundProjection.turnItems,
+ runs: backgroundProjection.runs,
})) {
const turn = projection.providerTurns.find(
(candidate) => candidate.id === item.providerTurnId,
@@ -9873,7 +9904,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
yield* settleBackgroundWork({
command,
events,
- projection,
+ projection: backgroundProjection,
stoppedProviderThreadId: providerThread.id,
throughRunOrdinal: run.ordinal,
now,
@@ -10078,8 +10109,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
),
);
if (projection.thread.deletedAt !== null) return undefined;
- const latestRun = latestDequeuedRun(projection.runs);
- if (command.throughSequence !== undefined && latestRun !== undefined) {
+ let stopRuns = projection.runs;
+ if (command.throughSequence !== undefined) {
// A queued run already belonged to Stop even if it started before the delegated walk.
const existed = yield* eventSink
.read({
@@ -10089,27 +10120,51 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
})
.pipe(
Stream.runFold(
- () => false,
- (found, stored) => found || stored.event.runId === latestRun.id,
+ () => new Set(),
+ (found, stored) => {
+ if (stored.event.type === "run.created") found.add(stored.event.payload.id);
+ return found;
+ },
),
Effect.mapError(
(cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
),
);
- if (!existed) return undefined;
+ stopRuns = projection.runs.filter((run) => existed.has(run.id));
// Partial retries keep queue/watch changes thread-wide while the original run is current.
}
+ const latestRun = latestDequeuedRun(stopRuns);
+ if (command.throughSequence !== undefined && latestRun === undefined) return undefined;
+ const keepNewerWork = latestDequeuedRun(projection.runs)?.id !== latestRun?.id;
+ const stopRunIds = new Set(stopRuns.map((run) => run.id));
+ const orphanProjection = keepNewerWork
+ ? {
+ ...projection,
+ subagents: projection.subagents.filter(
+ (task) => task.runId !== null && stopRunIds.has(task.runId),
+ ),
+ }
+ : projection;
const target =
- projection.runs.findLast(
+ stopRuns.findLast(
(run) =>
run.status === "preparing" || run.status === "starting" || run.status === "running",
) ??
(derivePendingBackgroundWork({
latestRun,
- providerThreads: projection.providerThreads,
- turnItems: projection.turnItems,
- activeProviderThreadId: projection.thread.activeProviderThreadId,
- runs: projection.runs,
+ providerThreads: projection.providerThreads.filter(
+ (thread) =>
+ !keepNewerWork ||
+ thread.lastRunOrdinal === null ||
+ thread.lastRunOrdinal <= (latestRun?.ordinal ?? -1),
+ ),
+ turnItems: projection.turnItems.filter(
+ (item) => !keepNewerWork || (item.runId !== null && stopRunIds.has(item.runId)),
+ ),
+ activeProviderThreadId: keepNewerWork
+ ? (latestRun?.providerThreadId ?? null)
+ : projection.thread.activeProviderThreadId,
+ runs: stopRuns,
}).length > 0
? latestRun
: undefined);
@@ -10123,11 +10178,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
commandId: command.commandId,
threadId: command.threadId,
runId: target.id,
- holdQueue: true,
+ holdQueue: !keepNewerWork,
...(command.reason === undefined ? {} : { reason: command.reason }),
},
interruptEvents,
interruptEffects,
+ keepNewerWork ? stopRunIds : undefined,
),
);
// A failed interrupt may have written part of its events, so they are kept apart.
@@ -10136,7 +10192,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const enqueued = yield* Ref.get(interruptEffects);
yield* Ref.update(events, (existing) => [...existing, ...written]);
yield* Ref.update(effects, (existing) => [...existing, ...enqueued]);
- yield* cancelIdleOrphans({ command, events, projection });
+ yield* cancelIdleOrphans({ command, events, projection: orphanProjection });
return interrupted.value;
}
yield* Effect.logWarning("thread.stop could not interrupt the running turn", {
@@ -10148,13 +10204,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const now = yield* DateTime.now;
// Record that Stop reached the turn it could not interrupt and the thread's last executed
// run, so a late tool call from their agent starts nothing.
- const executed = latestExecutedRun(projection.runs);
+ const executed = latestExecutedRun(stopRuns);
const reached = new Map();
if (target !== undefined) reached.set(target.id, target);
if (executed !== null && !hasLiveRun({ runs: [executed] })) reached.set(executed.id, executed);
for (const run of reached.values()) {
yield* markStoppedRun({ command, events, thread: projection.thread, run, now });
}
+ if (keepNewerWork) {
+ yield* cancelIdleOrphans({ command, events, projection: orphanProjection });
+ return undefined;
+ }
yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now });
yield* cancelIdleOrphans({ command, events, projection });
return undefined;
diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts
index d5b6b5508e2a5..81d92a72bc96f 100644
--- a/apps/server/src/orchestration-v2/ThreadStop.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts
@@ -1353,7 +1353,13 @@ it.effect.each([
}).pipe(Effect.provide(layerTest)),
);
-it.effect("thread.stop interrupts background work past a queue cancelled before it ran", () =>
+it.effect.each([
+ { later: "ordinary", phase: "stop" },
+ { later: "newer active run", phase: "stop" },
+ { later: "newer completed run", phase: "stop" },
+ { later: "newer active run", phase: "settle" },
+ { later: "newer completed run", phase: "settle" },
+] as const)("$phase interrupts bounded background work with $later", ({ later, phase }) =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
@@ -1422,27 +1428,119 @@ it.effect("thread.stop interrupts background work past a queue cancelled before
payload: { ...run!, status: "completed", startedAt: now, completedAt: now },
});
- yield* orchestrator.dispatch({
- type: "thread.stop",
- commandId: CommandId.make("stop-background"),
+ const throughSequence = yield* orchestrator.getThreadEventSequence(threadId);
+ const newerBackgroundId = TurnItemId.make("item:stop-background:newer");
+ if (later !== "ordinary") {
+ yield* send(threadId, "newer work", "start_immediately");
+ const newerProjection = yield* orchestrator.getThreadProjection(threadId);
+ const newer = newerProjection.runs.at(-1)!;
+ const newerProviderThread = newerProjection.providerThreads.find(
+ (thread) => thread.id === newer.providerThreadId,
+ )!;
+ yield* projections.apply({
+ id: EventId.make("event:stop-background:newer-roster"),
+ type: "provider-thread.updated",
+ threadId,
+ runId: newer.id,
+ occurredAt: now,
+ payload: {
+ ...newerProviderThread,
+ lastRunOrdinal: newer.ordinal,
+ pendingBackgroundTasks: [{ taskId: "newer-roster-task", kind: "command" }],
+ updatedAt: now,
+ },
+ });
+ yield* projections.apply({
+ id: EventId.make("event:stop-background:newer-item"),
+ type: "turn-item.updated",
+ threadId,
+ runId: newer.id,
+ occurredAt: now,
+ payload: {
+ id: newerBackgroundId,
+ threadId,
+ runId: newer.id,
+ nodeId: newer.rootNodeId!,
+ providerThreadId: newer.providerThreadId!,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 1,
+ status: "running",
+ title: "Newer background command",
+ startedAt: now,
+ completedAt: null,
+ updatedAt: now,
+ type: "command_execution",
+ input: "newer work",
+ },
+ });
+ yield* send(threadId, "newer queued work", "queue_after_active");
+ if (later === "newer completed run")
+ yield* projections.apply({
+ id: EventId.make("event:stop-background:newer-completed"),
+ type: "run.updated",
+ threadId,
+ runId: newer.id,
+ occurredAt: now,
+ payload: { ...newer, status: "completed", startedAt: now, completedAt: now },
+ });
+ yield* watch(threadId, 43);
+ }
+ const before = yield* threadState(threadId);
+ const beforeProviderWork = (yield* orchestrator.getThreadProjection(
threadId,
- });
+ )).providerThreads.map((thread) => ({
+ id: thread.id,
+ pendingBackgroundTasks: thread.pendingBackgroundTasks,
+ }));
+ if (phase === "settle")
+ yield* orchestrator.dispatch({
+ type: "thread.background-work.settle",
+ commandId: CommandId.make("settle-background"),
+ threadId,
+ providerThreadId: run!.providerThreadId!,
+ providerTurnId,
+ });
+ else
+ yield* orchestrator.dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("stop-background"),
+ threadId,
+ ...(later === "ordinary" ? {} : { throughSequence }),
+ });
const stopped = yield* orchestrator.getThreadProjection(threadId);
assert.notInclude(
["pending", "running", "waiting"],
stopped.turnItems.find((item) => item.id === backgroundId)?.status,
);
- assert.deepEqual(
- stopped.turnItems
- .filter((item) => item.type === "run_interrupt_request")
- .map((item) => item.runId),
- [run!.id],
- );
- assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 43))));
- assert.deepEqual(yield* threadState(threadId), {
- runs: ["completed", "cancelled"],
- watched: [],
- });
+ if (phase === "stop")
+ assert.deepEqual(
+ stopped.turnItems
+ .filter((item) => item.type === "run_interrupt_request")
+ .map((item) => item.runId),
+ [run!.id],
+ );
+ if (later === "ordinary") {
+ assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 43))));
+ assert.deepEqual(yield* threadState(threadId), {
+ runs: ["completed", "cancelled"],
+ watched: [],
+ });
+ } else {
+ assert.deepEqual(yield* threadState(threadId), before);
+ assert.equal(
+ stopped.turnItems.find((item) => item.id === newerBackgroundId)?.status,
+ "running",
+ );
+ assert.deepEqual(
+ stopped.providerThreads.map((thread) => ({
+ id: thread.id,
+ pendingBackgroundTasks: thread.pendingBackgroundTasks,
+ })),
+ beforeProviderWork,
+ );
+ }
}).pipe(Effect.provide(layerTest)),
);
From 691c5e38b5f47f11cdf3a50a978764ef63ccc233 Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:57:50 -0500
Subject: [PATCH 08/10] docs: record bounded Stop and idle-launch review
evidence
Describe both outside-diff fixes and the limits of the regression and source-composition proof. Preserve frozen preview assets.
Documentation: GPT-6.1 Sol through Codex in T3 Code.
---
.../native-messaging/BRANCH-UPDATES.md | 14 ++++++++++++--
.../native-messaging/MANIFEST.json | 2 +-
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/docs/community-patches/native-messaging/BRANCH-UPDATES.md b/docs/community-patches/native-messaging/BRANCH-UPDATES.md
index f776269c618c7..5fbdbbb357f6d 100644
--- a/docs/community-patches/native-messaging/BRANCH-UPDATES.md
+++ b/docs/community-patches/native-messaging/BRANCH-UPDATES.md
@@ -1,6 +1,6 @@
# Review fixes after preview.1
-These changes are on the review branch at source commit [`eaa1a4ebce0a8ef7565b69383dfe25c274379b9a`](https://github.com/zainmitchell/t3code/commit/eaa1a4ebce0a8ef7565b69383dfe25c274379b9a). [Fork review PR #1](https://github.com/zainmitchell/t3code/pull/1) remains the review record. The original preview.1 tag, ZIP, raw patch and checksum file are unchanged. Downloading preview.1 does not include these later fixes. Build the branch to try the corrected source while review continues.
+These changes are on the review branch at source commit [`6a42c2359072b859c4cc57d1ac3e70f851f8d744`](https://github.com/zainmitchell/t3code/commit/6a42c2359072b859c4cc57d1ac3e70f851f8d744). [Fork review PR #1](https://github.com/zainmitchell/t3code/pull/1) remains the review record. The original preview.1 tag, ZIP, raw patch and checksum file are unchanged. Downloading preview.1 does not include these later fixes. Build the branch to try the corrected source while review continues.
## What changed
@@ -10,7 +10,17 @@ The regression first reproduced a mode refusal appearing as the link's `lastErro
The installation guide now gives explicit `environment link` and `environment list` commands with the same `--base-dir` as `serve`. Built CLI help confirmed the options. That check did not perform a new remote pairing.
-## Verification
+## Stop-boundary and idle-launch follow-up
+
+A retried delegated Stop now selects runs recorded before the original Stop boundary, including a queued run that started later. It reaches their known background work even when a newer run exists. Interruption and settlement preserve newer runs, queued work, watches, background items and provider task lists. The settlement callback follows the same ownership bound. Work without a recorded run association is not inferred to belong to an older Stop.
+
+An accepted idle thread receiving its first message now validates a requested worktree base before starting preparation. A missing base returns an actionable error while the accepted thread remains idle. Ordinary message replays keep the stored preparation and skip mutable base checks. An idle replay without a message also remains unchanged.
+
+The regression checks reproduced the Stop and first-message defects before correction. All 59 focused Stop and project-handler tests passed after correction. The final changed-file gate passed all 70 tests in four executed files, with six test paths selected, plus formatting, lint, knip, GraphQL checks and typechecking of two affected packages. Counts overlap earlier coverage.
+
+The current messaging and tick source/test diffs apply in either order to the official base and produce the same tree. This was checked in disposable Git indexes. Combined runtime acceptance remains unverified.
+
+## Earlier verification
The changed-file gate passed formatting, lint, knip, GraphQL checks, typechecking of two affected packages, and all 14 peer tests in two executed files. Four test paths were selected. This count overlaps the original preview evidence.
diff --git a/docs/community-patches/native-messaging/MANIFEST.json b/docs/community-patches/native-messaging/MANIFEST.json
index 4eafc2e8d3359..f578a765ef34e 100644
--- a/docs/community-patches/native-messaging/MANIFEST.json
+++ b/docs/community-patches/native-messaging/MANIFEST.json
@@ -204,6 +204,6 @@
"previewCleanup": "Whitespace-only formatting of one peer test; production code is identical to the previously verified package",
"originalPackagePatchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb",
"artifactScope": "Frozen preview.1 archive and raw patch; these hashes do not describe later branch files.",
- "reviewFollowupSourceCommit": "eaa1a4ebce0a8ef7565b69383dfe25c274379b9a",
+ "reviewFollowupSourceCommit": "6a42c2359072b859c4cc57d1ac3e70f851f8d744",
"reviewFollowup": "BRANCH-UPDATES.md"
}
From 40947bc8f55f4ac918bf16febb1be56856bf8cee Mon Sep 17 00:00:00 2001
From: zzaaiinn
Date: Mon, 26 Oct 2026 11:48:06 -0500
Subject: [PATCH 09/10] fix(review): restrict public review context pending
verification
---
.coderabbit.config.ts | 21 +++++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts
index 06983eaa71fd1..d7e17b2652ed8 100644
--- a/.coderabbit.config.ts
+++ b/.coderabbit.config.ts
@@ -1,16 +1,24 @@
-import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config";
+import { defineConfig, type CodeRabbitConfig, type CodeRabbitContext } from "@coderabbitai/config";
+
+// The SDK requires unexported enums here; these literals match the service's configuration schema.
+type KnowledgeBase = NonNullable;
+const localScope = "local" as NonNullable["scope"];
+const disabledIntegration = "disabled" as NonNullable["usage"];
const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? "");
export default defineConfig((ctx) => ({
+ inheritance: false,
reviews: {
high_level_summary: false,
review_status: false,
request_changes_workflow: false,
+ related_issues: false,
+ related_prs: false,
allow_author_approval: !isGated(ctx),
auto_review: {
- enabled: true,
+ enabled: false,
},
pre_merge_checks: {
docstrings: { mode: "off" },
@@ -29,6 +37,15 @@ export default defineConfig((ctx) => ({
],
},
knowledge_base: {
+ automatic_linking_mode: "disabled",
+ linked_repositories: [],
+ issues: { scope: localScope },
+ pull_requests: { scope: localScope },
+ learnings: { scope: localScope },
+ jira: { usage: disabledIntegration },
+ linear: { usage: disabledIntegration },
+ mcp: { usage: disabledIntegration },
+ web_search: { enabled: false },
code_guidelines: {
filePatterns: [
{ files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" },
From c6fcdb8a90251b8c5a99ab019d5eae02bfc5ad8f Mon Sep 17 00:00:00 2001
From: zainmitchell <337498276+zainmitchell@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:14:58 -0500
Subject: [PATCH 10/10] docs: mark native messaging preview superseded by
official nightly 3824 [skip ci]
Linked environments now ship in official T3 Code nightly 3824 and later.
New installs should use the official nightly; this branch and the
preview.1 release stay as historical records.
Co-Authored-By: Claude Opus 5.5
---
.../community-patches/native-messaging/INSTALL.md | 7 ++++++-
docs/community-patches/native-messaging/README.md | 15 ++++++++++++---
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/docs/community-patches/native-messaging/INSTALL.md b/docs/community-patches/native-messaging/INSTALL.md
index 164104b99c5e1..e91d18fd8971f 100644
--- a/docs/community-patches/native-messaging/INSTALL.md
+++ b/docs/community-patches/native-messaging/INSTALL.md
@@ -1,6 +1,11 @@
# Build and try the Native messaging source preview
-The current branch includes [review fixes after preview.1](BRANCH-UPDATES.md). The preview.1 download remains frozen and does not include those fixes. Its MANIFEST.json describes that original patch. Use the branch build below to try the corrected source while review continues.
+> [!WARNING]
+> **Superseded on 2026-10-28: do not use this guide for new installs.** Use official T3 Code [nightly v0.1.4-nightly.20261028.3824](https://github.com/pingdotgg/t3code/releases/tag/v0.1.4-nightly.20261028.3824) or any later nightly, which includes linked environments. In the official nightly, turn on **Linked environments** in **Settings → Labs** on both machines (on mobile, **Settings → Labs** lists each connected machine). Then, on the machine you will work from, open **Settings → Connections → Linked environments** and choose **Link**. See the [official remote access guide](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/user/remote-access.md#link-another-environment).
+>
+> Do not apply this patch, or its branch, on top of an official nightly. Upgrading or migrating a T3 home that ran this preview to an official nightly has not been verified, and we have no tested migration path. This preview added its own peer-link migration to the v0.1.2 history. The steps below are kept as a historical record of how the v0.1.2 preview was built.
+
+The current branch includes [review fixes after preview.1](BRANCH-UPDATES.md). The preview.1 download remains frozen and does not include those fixes. Its MANIFEST.json describes that original patch. The branch build below was the way to try the corrected source; it is now historical.
This preview targets official T3 Code v0.1.2, commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Windows backend setup was exercised with Node 26.12.1 and Vite+ 1.3.0. It provides source code, not a desktop installer or automatic app update. Use disposable state for the first trial.
diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md
index 66d432a4ccf1d..320bec601b8b1 100644
--- a/docs/community-patches/native-messaging/README.md
+++ b/docs/community-patches/native-messaging/README.md
@@ -1,8 +1,17 @@
# Native agent messaging for T3 Code v0.1.2
+> [!IMPORTANT]
+> **Superseded on 2026-10-28. Do not install this patch for new setups.** Linked environments are now part of official T3 Code. Use [nightly v0.1.4-nightly.20261028.3824](https://github.com/pingdotgg/t3code/releases/tag/v0.1.4-nightly.20261028.3824) or any later nightly instead. The original feature PRs this preview bundled, [#16653](https://github.com/pingdotgg/t3code/pull/16653) mode limits, [#16654](https://github.com/pingdotgg/t3code/pull/16654) launch keys, [#16655](https://github.com/pingdotgg/t3code/pull/16655) links and [#16684](https://github.com/pingdotgg/t3code/pull/16684) forwarding, merged upstream on 2026-10-27, along with [#16695](https://github.com/pingdotgg/t3code/pull/16695) link confinement, [#16698](https://github.com/pingdotgg/t3code/pull/16698) Connections UI, [#16719](https://github.com/pingdotgg/t3code/pull/16719) remote delegation and [#17130](https://github.com/pingdotgg/t3code/pull/17130) machine mentions with the Labs beta.
+>
+> In the official nightly, turn on **Linked environments** in **Settings → Labs** on both machines (on mobile, **Settings → Labs** lists each connected machine). Then, on the machine you will work from, open **Settings → Connections → Linked environments** and choose **Link**. The [official remote access guide](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/user/remote-access.md#link-another-environment) covers pairing codes, access levels and revoking a link; the [peer-link notes](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/internals/remote.md#peer-links) describe how the beta gates both sides.
+>
+> Do not apply this patch, or its branch, on top of an official nightly. Upgrading or migrating a T3 home that ran this preview to an official nightly has not been verified, and we have no tested migration path. This preview added its own peer-link migration to the v0.1.2 history.
+>
+> The rest of this page is the original preview description, kept for history, attribution and evidence. Its PR states are dated snapshots.
+
The review branch now includes [fixes after the frozen preview.1 download](BRANCH-UPDATES.md), including their verification and remaining limits. The original archive and raw patch are unchanged.
-This community source preview extends T3's existing native thread tools with recoverable launches, full Stop/retry behavior, forwarded permission ceilings, and selected tools for explicitly linked environments. It is a reduced stable integration of existing T3 work and selected fixes, with the PR relationships and verification limits below.
+This community source preview extended T3's existing native thread tools with recoverable launches, full Stop/retry behavior, forwarded permission ceilings, and selected tools for explicitly linked environments. It is a reduced stable integration of existing T3 work and selected fixes, with the PR relationships and verification limits below.
## Why this exists
@@ -88,7 +97,7 @@ Physical routes/T3 Connect, real provider turns, every client/device, other oper
## Source, installation, and public history
-Use the [applied source branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) or the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1). The release includes the patch, manifest, MIT notice, guides, evidence and checksums; GitHub's tag archive contains full patched source. [INSTALL.md](INSTALL.md) explains both paths.
+For new installs, use the official nightly named at the top of this page. The [applied source branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) and the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) remain available as historical records. The release includes the patch, manifest, MIT notice, guides, evidence and checksums; GitHub's tag archive contains full patched source. [INSTALL.md](INSTALL.md) explains both paths.
The exact base is official T3 Code v0.1.2, `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. The exercised toolchain was Windows x64, Node 26.12.1 and Vite+ 1.3.0. It needs a source build, not a prompt/skill import, and supplies no desktop installer. PeerLinks adds migration 68 after the released stable history. Custom histories need deliberate integration. Try disposable state first; reversing a patch does not restore an older database.
@@ -103,7 +112,7 @@ Useful feedback is a concrete coordination failure, its target revision, or a mi
## Maintenance, upstream intent, and bug reports
-This is a temporary community patch fork for free, self-hosted open-source use. The owner maintains the patches and tracks bugs in [the fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire these fork-specific changes as upstream meets the need.
+This is a temporary community patch fork for free, self-hosted open-source use. The owner maintains the patches and tracks bugs in [the fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire these fork-specific changes as upstream meets the need. Upstream now provides linked environments, so this messaging patch is retired and receives no further fixes.
We welcome upstream adoption through maintainer-approved direction and focused contributions. The stable bundle is a distribution unit, not a demand to merge that entire bundle as one upstream PR. This work carries no upstream endorsement, approval, or merge commitment. Reports should identify the source base, patch/version, reproduction, expected and observed behavior, and suitably redacted evidence.