From 8a714b9d2faca6c48e614b8f65592388aa2e40fa Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 22:39:55 +0200 Subject: [PATCH 1/4] spike(server): threads MCP toolkit that spawns and messages child threads (#3) Adds a threads toolkit (spawn_thread, message_thread, read_thread, list_child_threads) next to device, preview and pullRequests. A bridge mirrors each child thread's lifecycle into its parent's activities as the task.* rows the Agents panel already folds, and can send each finished turn back to the parent. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.ts | 7 + .../src/mcp/toolkits/threads/handlers.ts | 364 ++++++++++++++++++ .../toolkits/threads/subagentThreadId.test.ts | 18 + .../mcp/toolkits/threads/subagentThreadId.ts | 25 ++ apps/server/src/mcp/toolkits/threads/tools.ts | 143 +++++++ 5 files changed, 557 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/threads/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts create mode 100644 apps/server/src/mcp/toolkits/threads/subagentThreadId.ts create mode 100644 apps/server/src/mcp/toolkits/threads/tools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad..ce24afcb62 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,8 @@ import { } from "./toolkits/preview/tools.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { ThreadsToolkitHandlersLive } from "./toolkits/threads/handlers.ts"; +import { ThreadsToolkit } from "./toolkits/threads/tools.ts"; import { DeviceScreenshotToolkitHandlersLive, DeviceStandardToolkitHandlersLive, @@ -608,6 +610,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest Layer.provide(PullRequestsToolkitHandlersLive), ); +export const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( + Layer.provide(ThreadsToolkitHandlersLive), +); + const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( Layer.provide(DeviceStandardToolkitHandlersLive), ); @@ -631,5 +637,6 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, + ThreadsToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts new file mode 100644 index 0000000000..71dfbd7be7 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -0,0 +1,364 @@ +import { + CommandId, + EventId, + MessageId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationSession, + type OrchestrationThreadShell, + type ProviderOptionSelection, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderService from "../../../provider/Services/ProviderService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; +import { type SubagentStatus, ThreadsToolError, ThreadsToolkit } from "./tools.ts"; + +const REPORT_TEXT_LIMIT = 4_000; + +/** The toolkit's coarse status vocabulary for a thread's provider session. */ +export function subagentStatusOf(session: OrchestrationSession | null): SubagentStatus { + switch (session?.status) { + case undefined: + case "starting": + return "starting"; + case "running": + return "running"; + case "idle": + case "ready": + return "idle"; + case "error": + return "failed"; + case "interrupted": + case "stopped": + return "stopped"; + } +} + +/** Provider option id that carries reasoning effort, per driver. */ +export function effortOptionId(driverKind: string): string { + switch (driverKind) { + case "codex": + case "grok": + return "reasoningEffort"; + case "opencode": + return "variant"; + default: + return "effort"; + } +} + +const fail = (reason: string) => Effect.fail(new ThreadsToolError({ reason })); + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const providers = yield* ProviderService.ProviderService; + const crypto = yield* Crypto.Crypto; + + /** Child thread id -> whether its turn results go back to the parent. In memory: a spike limit. */ + const reportBack = new Map(); + /** Child thread id -> last status the parent's Agents panel was told. */ + const lastStatus = new Map(); + /** Child thread id -> assistant message id last reported to the parent. */ + const lastReported = new Map(); + + const uuid = crypto.randomUUIDv4.pipe(Effect.orDie); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const commandId = (tag: string) => + Effect.map(uuid, (id) => CommandId.make(`server:mcp-threads-${tag}:${id}`)); + + const dispatch = (command: Parameters[0]) => + engine + .dispatch(command) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : fail(`Command ${command.type} failed: ${Cause.pretty(cause).slice(0, 500)}`), + ), + ); + + const threadShell = (threadId: string) => + snapshots.getThreadShellById(ThreadId.make(threadId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ); + + const lastAssistantMessage = (threadId: string) => + snapshots.getThreadDetailById(ThreadId.make(threadId)).pipe( + Effect.map((thread) => { + if (Option.isNone(thread)) return null; + const messages = thread.value.messages; + const userMessageCount = messages.filter((message) => message.role === "user").length; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.role === "assistant" && message.text.trim().length > 0) { + return { id: message.id, text: message.text, userMessageCount }; + } + } + return { id: null, text: null, userMessageCount }; + }), + Effect.catchCause(() => Effect.succeed(null)), + ); + + const summarize = (thread: OrchestrationThreadShell) => + Effect.gen(function* () { + const last = yield* lastAssistantMessage(thread.id); + return { + threadId: thread.id, + title: thread.title, + status: subagentStatusOf(thread.session), + instanceId: thread.session?.providerInstanceId ?? thread.modelSelection.instanceId, + model: thread.modelSelection.model, + lastError: thread.session?.lastError ?? null, + lastAssistantMessage: last?.text ?? null, + userMessageCount: last?.userMessageCount ?? 0, + }; + }); + + /** The calling thread, plus a guard that the target is one of its children. */ + const callerChild = (threadId: string) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + if (parentThreadIdOf(threadId) !== scope.threadId) { + return yield* fail(`Thread ${threadId} is not a child of this thread.`); + } + const child = yield* threadShell(threadId); + if (!child) return yield* fail(`Thread ${threadId} was not found.`); + return child; + }); + + const appendParentActivity = ( + parentThreadId: string, + kind: "task.started" | "task.progress" | "task.updated" | "task.completed", + summary: string, + payload: Record, + ) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* dispatch({ + type: "thread.activity.append", + commandId: yield* commandId("activity"), + threadId: ThreadId.make(parentThreadId), + activity: { + id: EventId.make(yield* uuid), + tone: "info", + kind, + summary, + // agentKind "agent" is what admits a task row to the Agents panel. + payload: { agentKind: "agent", taskType: "t3_thread", ...payload }, + turnId: null, + createdAt, + }, + createdAt, + }); + }); + + const startTurn = (thread: OrchestrationThreadShell, text: string) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* dispatch({ + type: "thread.turn.start", + commandId: yield* commandId("turn"), + threadId: thread.id, + message: { + messageId: MessageId.make(yield* uuid), + role: "user", + text, + attachments: [], + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt, + }); + }); + + /** + * Mirrors a child thread's lifecycle into its parent's activities as the + * task.* rows the Agents panel already folds, and optionally sends each + * finished turn's reply back to the parent as a message. + */ + const bridge = Effect.fn("ThreadsToolkit.bridge")(function* (event: OrchestrationEvent) { + if (event.aggregateKind !== "thread" || !isSubagentThreadId(event.aggregateId)) return; + const childId = event.aggregateId; + const parentId = parentThreadIdOf(childId)!; + if (event.type === "thread.created") { + const selection = event.payload.modelSelection; + const effort = selection.options?.find((option) => + ["effort", "reasoningEffort", "variant"].includes(option.id), + )?.value; + lastStatus.set(childId, "starting"); + yield* appendParentActivity(parentId, "task.started", `Started ${event.payload.title}`, { + taskId: childId, + title: event.payload.title, + role: selection.instanceId, + model: selection.model, + ...(typeof effort === "string" ? { effort } : {}), + detail: event.payload.title, + }); + return; + } + if (event.type !== "thread.session-set") return; + const status = subagentStatusOf(event.payload.session); + const previous = lastStatus.get(childId); + if (status === previous) return; + lastStatus.set(childId, status); + if (status === "running") { + yield* appendParentActivity(parentId, "task.updated", "Subagent working", { + taskId: childId, + status: "running", + }); + return; + } + if (status === "failed") { + yield* appendParentActivity(parentId, "task.updated", "Subagent failed", { + taskId: childId, + status: "failed", + error: event.payload.session.lastError ?? "Provider session error", + }); + return; + } + if (status === "stopped") { + yield* appendParentActivity(parentId, "task.updated", "Subagent stopped", { + taskId: childId, + status: "interrupted", + }); + return; + } + if (status !== "idle") return; + const last = yield* lastAssistantMessage(childId); + yield* appendParentActivity(parentId, "task.progress", "Subagent idle", { + taskId: childId, + status: "idle", + ...(last?.text ? { summary: last.text } : {}), + }); + if ( + reportBack.get(childId) !== true || + !last?.id || + !last.text || + lastReported.get(childId) === last.id + ) { + return; + } + lastReported.set(childId, last.id); + const parent = yield* threadShell(parentId); + const child = yield* threadShell(childId); + if (!parent) return; + const text = + last.text.length > REPORT_TEXT_LIMIT + ? `${last.text.slice(0, REPORT_TEXT_LIMIT)}…` + : last.text; + yield* startTurn( + parent, + `[Subagent ${child?.title ?? childId} (thread ${childId}) finished a turn]\n\n${text}`, + ); + }); + + const events = yield* engine.subscribeDomainEvents; + yield* Effect.forkScoped( + Stream.runForEach(events, (event) => + bridge(event).pipe( + Effect.catchCause((cause) => + Effect.logWarning("threads toolkit bridge skipped an event", { + eventType: event.type, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + + return ThreadsToolkit.of({ + spawn_thread: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const parent = yield* threadShell(scope.threadId); + if (!parent) return yield* fail(`Thread ${scope.threadId} was not found.`); + const instanceId = ProviderInstanceId.make( + input.instanceId ?? parent.modelSelection.instanceId, + ); + const sameInstance = instanceId === parent.modelSelection.instanceId; + const model = input.model ?? (sameInstance ? parent.modelSelection.model : undefined); + if (!model) return yield* fail("Pass model when instanceId differs from this thread's."); + const info = yield* providers + .getInstanceInfo(instanceId) + .pipe(Effect.catchCause(() => fail(`Unknown provider instance ${instanceId}.`))); + const options: ProviderOptionSelection[] = input.effort + ? [{ id: effortOptionId(info.driverKind), value: input.effort }] + : []; + const modelSelection = { + instanceId, + model, + ...(options.length > 0 ? { options } : {}), + }; + const childId = ThreadId.make( + makeSubagentThreadId(parent.id, (yield* uuid).replaceAll("-", "").slice(0, 12)), + ); + reportBack.set(childId, input.reportBack !== false); + const createdAt = yield* nowIso; + const runtimeMode = input.runtimeMode ?? parent.runtimeMode; + yield* dispatch({ + type: "thread.create", + commandId: yield* commandId("create"), + threadId: childId, + projectId: parent.projectId, + title: input.title ?? `Subagent: ${input.task.slice(0, 60)}`, + modelSelection, + runtimeMode, + interactionMode: "default", + branch: parent.branch, + worktreePath: parent.worktreePath, + createdAt, + }); + const child = yield* threadShell(childId); + if (!child) return yield* fail(`Child thread ${childId} was not created.`); + yield* startTurn(child, input.task); + return { threadId: childId, parentThreadId: parent.id, instanceId, model }; + }), + message_thread: ({ threadId, text }) => + Effect.gen(function* () { + const child = yield* callerChild(threadId); + const statusBefore = subagentStatusOf(child.session); + const driver = + child.session?.providerName ?? + (yield* providers.getInstanceInfo(child.modelSelection.instanceId).pipe( + Effect.map((info) => info.driverKind as string), + Effect.catchCause(() => Effect.succeed("unknown")), + )); + yield* startTurn(child, text); + return { + threadId, + statusBefore, + delivery: + statusBefore !== "running" + ? ("new-turn" as const) + : driver === "codex" + ? ("queued-turn" as const) + : ("steer" as const), + }; + }), + read_thread: ({ threadId }) => callerChild(threadId).pipe(Effect.flatMap(summarize)), + list_child_threads: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const shells = yield* snapshots.getShellSnapshot().pipe( + Effect.map((snapshot) => snapshot.threads), + Effect.catchCause(() => fail("Could not read threads.")), + ); + const children = shells.filter((thread) => parentThreadIdOf(thread.id) === scope.threadId); + return { threads: yield* Effect.forEach(children, summarize) }; + }), + }); +}); + +export const ThreadsToolkitHandlersLive = ThreadsToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts new file mode 100644 index 0000000000..727013b663 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; + +describe("subagent thread ids", () => { + it("round-trips the parent, including nested children", () => { + const child = makeSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f", "abc123"); + expect(isSubagentThreadId(child)).toBe(true); + expect(parentThreadIdOf(child)).toBe("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f"); + const grandchild = makeSubagentThreadId(child, "def456"); + expect(parentThreadIdOf(grandchild)).toBe(child); + }); + + it("leaves user threads alone", () => { + expect(isSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBe(false); + expect(parentThreadIdOf("sub.")).toBeNull(); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts new file mode 100644 index 0000000000..a1cb7ec2bf --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts @@ -0,0 +1,25 @@ +/** + * Spike convention (toolboxmd/t3code#3): a thread spawned by another thread + * through the `threads` MCP toolkit carries its parent in its own id, + * `sub..`. It needs no contract, projector or + * migration change, survives restarts, and lets every client classify a + * thread from its shell alone. A production version would replace it with a + * real `parentThreadId` field on `thread.create` and the thread shell. + * + * Keep in sync with apps/web/src/components/subagentThreads.ts. + */ +const PREFIX = "sub."; + +export function makeSubagentThreadId(parentThreadId: string, suffix: string): string { + return `${PREFIX}${parentThreadId}.${suffix}`; +} + +export function isSubagentThreadId(threadId: string): boolean { + return threadId.startsWith(PREFIX) && threadId.lastIndexOf(".") > PREFIX.length; +} + +/** The spawning thread's id, or null for a thread the user started. */ +export function parentThreadIdOf(threadId: string): string | null { + if (!isSubagentThreadId(threadId)) return null; + return threadId.slice(PREFIX.length, threadId.lastIndexOf(".")); +} diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts new file mode 100644 index 0000000000..63cc81da6d --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -0,0 +1,143 @@ +import { RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; + +export class ThreadsToolError extends Schema.TaggedError()("ThreadsToolError", { + reason: Schema.String, +}) { + override get message(): string { + return this.reason; + } +} + +export const SubagentStatus = Schema.Literals(["starting", "running", "idle", "failed", "stopped"]); +export type SubagentStatus = typeof SubagentStatus.Type; + +export const SpawnThreadInput = Schema.Struct({ + task: TrimmedNonEmptyString.annotate({ + description: "The first message the child thread receives: its whole task.", + }), + instanceId: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Provider instance to run the child on, for example claudeAgent, codex, opencode or grok. Defaults to this thread's provider instance.", + }), + ), + model: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Model id on that instance. Defaults to this thread's model.", + }), + ), + effort: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Reasoning effort (Claude effort, Codex/Grok reasoningEffort, OpenCode variant).", + }), + ), + title: Schema.optional(TrimmedNonEmptyString), + runtimeMode: Schema.optional(RuntimeMode), + reportBack: Schema.optional( + Schema.Boolean.annotate({ + description: + "When true (default), each time the child finishes a turn its final reply is sent to this thread as a message.", + }), + ), +}); + +export const SpawnThreadResult = Schema.Struct({ + threadId: Schema.String, + parentThreadId: Schema.String, + instanceId: Schema.String, + model: Schema.String, +}); + +export const MessageThreadInput = Schema.Struct({ + threadId: TrimmedNonEmptyString.annotate({ description: "A child thread of this thread." }), + text: TrimmedNonEmptyString, +}); + +export const MessageThreadResult = Schema.Struct({ + threadId: Schema.String, + statusBefore: SubagentStatus, + delivery: Schema.Literals(["new-turn", "steer", "queued-turn"]).annotate({ + description: + "new-turn: the child was idle and starts a turn. steer: joins the running turn (Claude, OpenCode, Grok). queued-turn: runs after the current turn (Codex).", + }), +}); + +export const ThreadSummary = Schema.Struct({ + threadId: Schema.String, + title: Schema.String, + status: SubagentStatus, + instanceId: Schema.NullOr(Schema.String), + model: Schema.String, + lastError: Schema.NullOr(Schema.String), + lastAssistantMessage: Schema.NullOr(Schema.String), + userMessageCount: Schema.Int, +}); + +const SpawnThreadTool = Tool.make("spawn_thread", { + description: + "Start a child thread in this project on a chosen provider instance, model and effort, and send it a task. Returns immediately with the child's thread id; the child shows in this thread's Agents panel. Use read_thread to see its reply, message_thread to talk to it.", + parameters: SpawnThreadInput, + success: SpawnThreadResult, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Spawn child thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +const MessageThreadTool = Tool.make("message_thread", { + description: + "Send a message to one of this thread's child threads, whether it is working or idle.", + parameters: MessageThreadInput, + success: MessageThreadResult, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Message child thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +const ReadThreadTool = Tool.make("read_thread", { + description: + "Read a child thread's status and its latest assistant reply. Poll it, or rely on reportBack, to learn when the child is done.", + parameters: Schema.Struct({ threadId: TrimmedNonEmptyString }), + success: ThreadSummary, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Read child thread") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListChildThreadsTool = Tool.make("list_child_threads", { + description: "List this thread's child threads and their status.", + success: Schema.Struct({ threads: Schema.Array(ThreadSummary) }), + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "List child threads") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ThreadsToolkit = Toolkit.make( + SpawnThreadTool, + MessageThreadTool, + ReadThreadTool, + ListChildThreadsTool, +); From ea5306a5dbd8adff7cb83825d7951ddac53826dc Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 22:40:50 +0200 Subject: [PATCH 2/4] spike(web): group child threads in a collapsible Subagents sidebar shelf (#3) Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.tsx | 50 +++++++++++++++++-- .../sidebar/SidebarSubagentsShelf.tsx | 43 ++++++++++++++++ apps/web/src/components/subagentThreads.ts | 25 ++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx create mode 100644 apps/web/src/components/subagentThreads.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 12b06d7394..66b9b473c0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -211,6 +211,8 @@ import { import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; +import { isSubagentThreadId } from "./subagentThreads"; +import { SidebarSubagentsShelf } from "./sidebar/SidebarSubagentsShelf"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -254,6 +256,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; +const SUBAGENTS_SHELF_EXPANDED_KEY = "t3code:sidebar:subagents-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -2521,6 +2524,7 @@ export default function Sidebar() { activeThreads, snoozedThreads, settledThreads, + subagentThreads, snoozeNow, } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: @@ -2539,9 +2543,15 @@ export default function Sidebar() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + const subagents: EnvironmentThreadShell[] = []; const draggable = new Set(); const activeReorderable = new Set(); for (const thread of visible) { + // Threads spawned by another thread live on their own shelf, not in the inbox. + if (isSubagentThreadId(thread.id)) { + subagents.push(thread); + continue; + } const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user @@ -2617,6 +2627,7 @@ export default function Sidebar() { firstValidTimestampMs(right.snoozedUntil ?? null), ), settledThreads: sortSettledThreadsForSidebar(settled), + subagentThreads: sortThreadsForSidebar(subagents), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); @@ -2626,8 +2637,14 @@ export default function Sidebar() { const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], + () => [ + ...pinnedThreads, + ...activeThreads, + ...snoozedThreads, + ...settledThreads, + ...subagentThreads, + ], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads, subagentThreads], ); const searchEnvironmentIds = useMemo( () => @@ -2749,6 +2766,15 @@ export default function Sidebar() { () => setSnoozedShelfExpanded((value) => !value), [setSnoozedShelfExpanded], ); + const [subagentsShelfExpanded, setSubagentsShelfExpanded] = useLocalStorage( + SUBAGENTS_SHELF_EXPANDED_KEY, + false, + Schema.Boolean, + ); + const toggleSubagentsShelf = useCallback( + () => setSubagentsShelfExpanded((value) => !value), + [setSubagentsShelfExpanded], + ); const visibleSnoozedThreads = useMemo(() => { if (snoozedShelfExpanded) return snoozedThreads; // The open thread must never vanish behind the collapsed shelf: a @@ -4879,6 +4905,23 @@ export default function Sidebar() { break; } } + items.push( + + {subagentThreads.map((thread) => ( +
  • + {renderThreadRowInner(thread, "settled")} +
  • + ))} +
    , + ); return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( @@ -4903,7 +4946,8 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length === + settledThreads.length + + subagentThreads.length === 0 ? (
    {projects.length === 0 ? ( diff --git a/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx b/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx new file mode 100644 index 0000000000..497e91c884 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx @@ -0,0 +1,43 @@ +import { ChevronDownIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Collapsible shelf for threads another thread spawned (toolboxmd/t3code#3 + * spike). Sits with the Snoozed and Settled shelves at the bottom of the + * list but outside the sortable list: subagent rows are not drag targets. + */ +export function SidebarSubagentsShelf(props: { + count: number; + expanded: boolean; + onToggle: () => void; + children: ReactNode; +}) { + if (props.count === 0) return null; + return ( +
  • + + {props.expanded ? ( +
      + {props.children} +
    + ) : null} +
  • + ); +} diff --git a/apps/web/src/components/subagentThreads.ts b/apps/web/src/components/subagentThreads.ts new file mode 100644 index 0000000000..63650b57e8 --- /dev/null +++ b/apps/web/src/components/subagentThreads.ts @@ -0,0 +1,25 @@ +/** + * Spike convention (toolboxmd/t3code#3): a thread spawned by another thread + * through the `threads` MCP toolkit carries its parent in its own id, + * `sub..`. It needs no contract, projector or + * migration change, survives restarts, and lets every client classify a + * thread from its shell alone. A production version would replace it with a + * real `parentThreadId` field on `thread.create` and the thread shell. + * + * Keep in sync with apps/server/src/mcp/toolkits/threads/subagentThreadId.ts. + */ +const PREFIX = "sub."; + +export function makeSubagentThreadId(parentThreadId: string, suffix: string): string { + return `${PREFIX}${parentThreadId}.${suffix}`; +} + +export function isSubagentThreadId(threadId: string): boolean { + return threadId.startsWith(PREFIX) && threadId.lastIndexOf(".") > PREFIX.length; +} + +/** The spawning thread's id, or null for a thread the user started. */ +export function parentThreadIdOf(threadId: string): string | null { + if (!isSubagentThreadId(threadId)) return null; + return threadId.slice(PREFIX.length, threadId.lastIndexOf(".")); +} From e35842979c593b6242e6b83f6d6b61314311436d Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 23:05:06 +0200 Subject: [PATCH 3/4] spike(server): report steer for every provider and refuse messages to a starting child (#3) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/threads/handlers.ts | 23 +++++++++---------- .../toolkits/threads/subagentThreadId.test.ts | 2 +- apps/server/src/mcp/toolkits/threads/tools.ts | 4 ++-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 71dfbd7be7..5a6108a795 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -293,6 +293,9 @@ const make = Effect.gen(function* () { const info = yield* providers .getInstanceInfo(instanceId) .pipe(Effect.catchCause(() => fail(`Unknown provider instance ${instanceId}.`))); + if (!info.enabled) { + return yield* fail(`Provider instance ${instanceId} is disabled in T3 Code settings.`); + } const options: ProviderOptionSelection[] = input.effort ? [{ id: effortOptionId(info.driverKind), value: input.effort }] : []; @@ -329,22 +332,18 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const child = yield* callerChild(threadId); const statusBefore = subagentStatusOf(child.session); - const driver = - child.session?.providerName ?? - (yield* providers.getInstanceInfo(child.modelSelection.instanceId).pipe( - Effect.map((info) => info.driverKind as string), - Effect.catchCause(() => Effect.succeed("unknown")), - )); + // Live run: a message sent before the first turn starts left a Claude + // turn open forever, so wait for the child to be running or idle. + if (statusBefore === "starting") { + return yield* fail(`Thread ${threadId} is still starting. Retry in a few seconds.`); + } yield* startTurn(child, text); return { threadId, statusBefore, - delivery: - statusBefore !== "running" - ? ("new-turn" as const) - : driver === "codex" - ? ("queued-turn" as const) - : ("steer" as const), + // Live runs: Claude, Codex, OpenCode and Grok all fold a message sent + // mid-turn into the running turn. + delivery: statusBefore === "running" ? ("steer" as const) : ("new-turn" as const), }; }), read_thread: ({ threadId }) => callerChild(threadId).pipe(Effect.flatMap(summarize)), diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts index 727013b663..ac578f7d7f 100644 --- a/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index 63cc81da6d..b548c92594 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -64,9 +64,9 @@ export const MessageThreadInput = Schema.Struct({ export const MessageThreadResult = Schema.Struct({ threadId: Schema.String, statusBefore: SubagentStatus, - delivery: Schema.Literals(["new-turn", "steer", "queued-turn"]).annotate({ + delivery: Schema.Literals(["new-turn", "steer"]).annotate({ description: - "new-turn: the child was idle and starts a turn. steer: joins the running turn (Claude, OpenCode, Grok). queued-turn: runs after the current turn (Codex).", + "new-turn: the child was idle and starts a turn. steer: the message joins the child's running turn.", }), }); From 8638154bb8dcf8b8594a967d925cbfce012e9d48 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 09:31:18 +0200 Subject: [PATCH 4/4] spike(web): hide child threads from the sidebar and open them from the Agents panel (#3) Replaces the Subagents shelf: child threads are filtered out of the sidebar, and their Agents panel rows link to the child thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/AgentThreadLink.tsx | 30 +++++++++++ apps/web/src/components/AgentsPanel.tsx | 5 +- apps/web/src/components/Sidebar.tsx | 51 ++----------------- .../sidebar/SidebarSubagentsShelf.tsx | 43 ---------------- 4 files changed, 39 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/AgentThreadLink.tsx delete mode 100644 apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx diff --git a/apps/web/src/components/AgentThreadLink.tsx b/apps/web/src/components/AgentThreadLink.tsx new file mode 100644 index 0000000000..0da1534688 --- /dev/null +++ b/apps/web/src/components/AgentThreadLink.tsx @@ -0,0 +1,30 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { isSubagentThreadId } from "./subagentThreads"; + +/** + * Agents panel rows for child threads (toolboxmd/t3code#3 spike) open that + * thread, where the user reads it and messages it with the normal composer. + * Rows for native provider subagents stay plain. + */ +export function AgentThreadLink(props: { + agentId: string; + environmentId: EnvironmentId | null; + children: ReactNode; +}) { + if (props.environmentId === null || !isSubagentThreadId(props.agentId)) { + return props.children; + } + return ( + + {props.children} + + ); +} diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 43f1fa123f..8dd3f0956f 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -25,6 +25,7 @@ import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; +import { AgentThreadLink } from "~/components/AgentThreadLink"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; @@ -561,7 +562,9 @@ export function AgentsPanel({ Direct spawns
    {model.directAgents.map((agent) => ( - + + + ))} ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 66b9b473c0..13cdbb80af 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -212,7 +212,6 @@ import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Side import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { isSubagentThreadId } from "./subagentThreads"; -import { SidebarSubagentsShelf } from "./sidebar/SidebarSubagentsShelf"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -256,7 +255,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; -const SUBAGENTS_SHELF_EXPANDED_KEY = "t3code:sidebar:subagents-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -2524,7 +2522,6 @@ export default function Sidebar() { activeThreads, snoozedThreads, settledThreads, - subagentThreads, snoozeNow, } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: @@ -2536,6 +2533,8 @@ export default function Sidebar() { const visible = threads.filter( (thread) => thread.archivedAt === null && + // Child threads open from their parent's Agents panel, not the sidebar. + !isSubagentThreadId(thread.id) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); @@ -2543,15 +2542,9 @@ export default function Sidebar() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - const subagents: EnvironmentThreadShell[] = []; const draggable = new Set(); const activeReorderable = new Set(); for (const thread of visible) { - // Threads spawned by another thread live on their own shelf, not in the inbox. - if (isSubagentThreadId(thread.id)) { - subagents.push(thread); - continue; - } const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user @@ -2627,7 +2620,6 @@ export default function Sidebar() { firstValidTimestampMs(right.snoozedUntil ?? null), ), settledThreads: sortSettledThreadsForSidebar(settled), - subagentThreads: sortThreadsForSidebar(subagents), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); @@ -2637,14 +2629,8 @@ export default function Sidebar() { const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [ - ...pinnedThreads, - ...activeThreads, - ...snoozedThreads, - ...settledThreads, - ...subagentThreads, - ], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads, subagentThreads], + () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const searchEnvironmentIds = useMemo( () => @@ -2766,15 +2752,6 @@ export default function Sidebar() { () => setSnoozedShelfExpanded((value) => !value), [setSnoozedShelfExpanded], ); - const [subagentsShelfExpanded, setSubagentsShelfExpanded] = useLocalStorage( - SUBAGENTS_SHELF_EXPANDED_KEY, - false, - Schema.Boolean, - ); - const toggleSubagentsShelf = useCallback( - () => setSubagentsShelfExpanded((value) => !value), - [setSubagentsShelfExpanded], - ); const visibleSnoozedThreads = useMemo(() => { if (snoozedShelfExpanded) return snoozedThreads; // The open thread must never vanish behind the collapsed shelf: a @@ -4905,23 +4882,6 @@ export default function Sidebar() { break; } } - items.push( - - {subagentThreads.map((thread) => ( -
  • - {renderThreadRowInner(thread, "settled")} -
  • - ))} -
    , - ); return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( @@ -4946,8 +4906,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length + - subagentThreads.length === + settledThreads.length === 0 ? (
    {projects.length === 0 ? ( diff --git a/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx b/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx deleted file mode 100644 index 497e91c884..0000000000 --- a/apps/web/src/components/sidebar/SidebarSubagentsShelf.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ChevronDownIcon } from "lucide-react"; -import type { ReactNode } from "react"; - -import { cn } from "~/lib/utils"; - -/** - * Collapsible shelf for threads another thread spawned (toolboxmd/t3code#3 - * spike). Sits with the Snoozed and Settled shelves at the bottom of the - * list but outside the sortable list: subagent rows are not drag targets. - */ -export function SidebarSubagentsShelf(props: { - count: number; - expanded: boolean; - onToggle: () => void; - children: ReactNode; -}) { - if (props.count === 0) return null; - return ( -
  • - - {props.expanded ? ( -
      - {props.children} -
    - ) : null} -
  • - ); -}