From 12d574fa1a957c7eedf2b6813b7d7fb478f050ae Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 22:39:55 +0200 Subject: [PATCH 1/9] 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 bc05571d230e52dbc6a9b7daa21a7d3f63e39d14 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 22:40:50 +0200 Subject: [PATCH 2/9] 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 c2e29e408ec0bfc2ad46288519e6da58b08f182e Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 24 Sep 2026 23:05:06 +0200 Subject: [PATCH 3/9] 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 a9e5fb794688ac08a21a4c99c170279cfb575992 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 09:31:18 +0200 Subject: [PATCH 4/9] 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} -
  • - ); -} From c7017934948ebd0efe8a9480ead80ff3550720ce Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 11:19:00 +0200 Subject: [PATCH 5/9] feat(threads): production child threads with Agents panel listing and toolkit guards (#8) Child threads spawned through the threads MCP toolkit are real T3 threads whose sub.. id carries the parent link, hidden from both sidebars and listed with status in the parent's Agents panel. Production hardening over spike/3-threads-toolkit: documented the id-convention tradeoff against a real parentThreadId (contract, projector, persistence migration too invasive for this fork), extracted the steer/new-turn delivery rule, pinned per-driver effort options, and refused messages to still-starting children with a retryable error. Built with OpenCode (Muse Spark). --- .../mcp/toolkits/threads/childThreads.test.ts | 52 +++++++++++++++++++ .../src/mcp/toolkits/threads/handlers.ts | 36 +++++++++---- .../mcp/toolkits/threads/subagentThreadId.ts | 18 ++++--- apps/server/src/mcp/toolkits/threads/tools.ts | 2 +- apps/web/src/components/AgentThreadLink.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 15 ++++-- .../src/components/subagentThreads.test.ts | 19 +++++++ apps/web/src/components/subagentThreads.ts | 9 ++-- 8 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/threads/childThreads.test.ts create mode 100644 apps/web/src/components/subagentThreads.test.ts diff --git a/apps/server/src/mcp/toolkits/threads/childThreads.test.ts b/apps/server/src/mcp/toolkits/threads/childThreads.test.ts new file mode 100644 index 0000000000..aca3e7db66 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/childThreads.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deliveryOf, effortOptionId, subagentStatusOf } from "./handlers.ts"; + +const session = (status: string) => + ({ + status, + providerInstanceId: "codex", + activeTurnId: null, + lastError: null, + }) as unknown as Parameters[0]; + +describe("child thread status vocabulary", () => { + it("maps a missing session to starting so the message guard holds", () => { + expect(subagentStatusOf(null)).toBe("starting"); + }); + + it("collapses provider session states onto the toolkit vocabulary", () => { + expect(subagentStatusOf(session("starting"))).toBe("starting"); + expect(subagentStatusOf(session("running"))).toBe("running"); + expect(subagentStatusOf(session("idle"))).toBe("idle"); + expect(subagentStatusOf(session("ready"))).toBe("idle"); + expect(subagentStatusOf(session("error"))).toBe("failed"); + expect(subagentStatusOf(session("interrupted"))).toBe("stopped"); + expect(subagentStatusOf(session("stopped"))).toBe("stopped"); + }); +}); + +describe("child thread effort routing", () => { + it("uses each driver's advertised reasoning-effort option", () => { + expect(effortOptionId("codex")).toBe("reasoningEffort"); + expect(effortOptionId("grok")).toBe("reasoningEffort"); + expect(effortOptionId("opencode")).toBe("variant"); + }); + + it("falls back to effort for Claude, Cursor and Antigravity", () => { + expect(effortOptionId("claudeAgent")).toBe("effort"); + expect(effortOptionId("cursor")).toBe("effort"); + expect(effortOptionId("antigravity")).toBe("effort"); + }); +}); + +describe("child thread message delivery", () => { + it("steers a running child and opens a turn everywhere else", () => { + expect(deliveryOf("running")).toBe("steer"); + expect(deliveryOf("idle")).toBe("new-turn"); + expect(deliveryOf("failed")).toBe("new-turn"); + expect(deliveryOf("stopped")).toBe("new-turn"); + // starting never reaches deliveryOf: the message tool refuses it first. + expect(deliveryOf("starting")).toBe("new-turn"); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 5a6108a795..865befcc9d 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -44,7 +44,13 @@ export function subagentStatusOf(session: OrchestrationSession | null): Subagent } } -/** Provider option id that carries reasoning effort, per driver. */ +/** + * Provider option id that carries reasoning effort, per driver. Codex and + * Grok advertise `reasoningEffort`, OpenCode advertises `variant`, and + * Claude, Cursor and Antigravity read `effort`. ModelSelection options are + * free-form id/value pairs that adapters ignore when unknown, so sending + * `effort` to Antigravity (which has no effort control) is a harmless no-op. + */ export function effortOptionId(driverKind: string): string { switch (driverKind) { case "codex": @@ -59,13 +65,29 @@ export function effortOptionId(driverKind: string): string { const fail = (reason: string) => Effect.fail(new ThreadsToolError({ reason })); +/** + * How a message to a child is delivered. A message sent while the child + * works steers its running turn (a new turn supersedes the running one at + * the orchestration layer, uniformly for every provider); an idle, failed + * or stopped child starts a fresh turn. Callers must refuse `starting` + * before reaching here: a message sent before the first turn starts left a + * turn open forever in live runs. + */ +export function deliveryOf(statusBefore: SubagentStatus): "new-turn" | "steer" { + return statusBefore === "running" ? "steer" : "new-turn"; +} + 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. */ + /** Child thread id -> whether its turn results go back to the parent. + * Process-local by design: a restart loses pending reportBack flags and + * the status/message dedupe below, so an already-idle child may re-report + * one turn after a restart. Children created after the restart are + * unaffected. */ const reportBack = new Map(); /** Child thread id -> last status the parent's Agents panel was told. */ const lastStatus = new Map(); @@ -332,19 +354,11 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const child = yield* callerChild(threadId); const statusBefore = subagentStatusOf(child.session); - // 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, - // 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), - }; + return { threadId, statusBefore, delivery: deliveryOf(statusBefore) }; }), read_thread: ({ threadId }) => callerChild(threadId).pipe(Effect.flatMap(summarize)), list_child_threads: () => diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts index a1cb7ec2bf..761ed2e4d0 100644 --- a/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts @@ -1,10 +1,16 @@ /** - * 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. + * Child-thread identity (toolboxmd/t3code#8): a thread spawned by another + * thread through the `threads` MCP toolkit carries its parent in its own id, + * `sub..`. + * + * A real optional `parentThreadId` on `thread.create` carried to the thread + * shell would be cleaner, but it is too invasive for this fork: it would + * require coordinated changes to ThreadCreateCommand, ThreadCreatedPayload, + * OrchestrationThread, OrchestrationThreadShell, the decider, the projector, + * the ProjectionThread persistence schema plus SQLite layer plus a new + * migration, and the shell snapshot mapping, with backfill for existing + * threads. The id convention needs none of that, survives restarts, and lets + * every client classify a thread from its shell alone. * * Keep in sync with apps/web/src/components/subagentThreads.ts. */ diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index b548c92594..d62e27b835 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -97,7 +97,7 @@ const SpawnThreadTool = Tool.make("spawn_thread", { const MessageThreadTool = Tool.make("message_thread", { description: - "Send a message to one of this thread's child threads, whether it is working or idle.", + "Send a message to one of this thread's child threads once it has started working or gone idle. A child that is still starting refuses with a retryable error; retry in a few seconds.", parameters: MessageThreadInput, success: MessageThreadResult, failure: ThreadsToolError, diff --git a/apps/web/src/components/AgentThreadLink.tsx b/apps/web/src/components/AgentThreadLink.tsx index 0da1534688..810327e565 100644 --- a/apps/web/src/components/AgentThreadLink.tsx +++ b/apps/web/src/components/AgentThreadLink.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { isSubagentThreadId } from "./subagentThreads"; /** - * Agents panel rows for child threads (toolboxmd/t3code#3 spike) open that + * Agents panel rows for child threads (toolboxmd/t3code#8) open that * thread, where the user reads it and messages it with the normal composer. * Rows for native provider subagents stay plain. */ diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 075ccea0a1..8d5d9a4a29 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -23,6 +23,7 @@ import { } from "./ThreadStatusIndicators"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; +import { isSubagentThreadId } from "./subagentThreads"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -1331,7 +1332,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + // Child threads open from their parent's Agents panel, not the sidebar. + projectThreads.filter( + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), + ), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -3398,7 +3402,11 @@ export default function LegacySidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), + // Child threads open from their parent's Agents panel, not the sidebar. + () => + sidebarThreads.filter( + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), + ), [sidebarThreads], ); const sortedProjects = useMemo(() => { @@ -3438,7 +3446,8 @@ export default function LegacySidebar() { sortedProjects.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + // Child threads open from their parent's Agents panel, not the sidebar. + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), ), sidebarThreadSortOrder, ); diff --git a/apps/web/src/components/subagentThreads.test.ts b/apps/web/src/components/subagentThreads.test.ts new file mode 100644 index 0000000000..9f08a26816 --- /dev/null +++ b/apps/web/src/components/subagentThreads.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreads"; + +describe("child 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 so the sidebar keeps showing them", () => { + expect(isSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBe(false); + expect(parentThreadIdOf("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBeNull(); + expect(parentThreadIdOf("sub.")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/subagentThreads.ts b/apps/web/src/components/subagentThreads.ts index 63650b57e8..880653b73d 100644 --- a/apps/web/src/components/subagentThreads.ts +++ b/apps/web/src/components/subagentThreads.ts @@ -1,10 +1,11 @@ /** - * Spike convention (toolboxmd/t3code#3): a thread spawned by another thread - * through the `threads` MCP toolkit carries its parent in its own id, + * Child-thread identity (toolboxmd/t3code#8): 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. + * thread from its shell alone. A real optional `parentThreadId` on + * `thread.create` would be cleaner but is too invasive for this fork (see + * the server copy for the full tradeoff). * * Keep in sync with apps/server/src/mcp/toolkits/threads/subagentThreadId.ts. */ From 95a8f6c10e3a31c4af95e6fd3ffcf40bb662c215 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 11:46:23 +0200 Subject: [PATCH 6/9] fix(desktop): run bundle self-check probe under plain Node (#8) verifyPackagedBundleIsSelfContained spread ambient process.env into the probe, so a shell with ELECTRON_RUN_AS_NODE=1 leaked the flag into the validation command and broke the no-Electron-probe invariant the Windows payload test asserts. Scrub the flag like the neighboring fff probe curates its own env. Built with OpenCode (Muse Spark). --- scripts/build-desktop-artifact.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3645eeb5c1..481fe8716d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2077,6 +2077,12 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node // and the bun adapters are covered by the shared runtime-external closure // and emitted-bundle checks. + // The probe must run under plain Node even when the orchestrator itself + // runs as ELECTRON_RUN_AS_NODE (e.g. tests spawned from an Electron + // toolchain shell): inheriting that flag would validate the bundle under + // a different runtime than users get. + const probeEnv: Record = { ...process.env, NODE_PATH: "" }; + delete probeEnv.ELECTRON_RUN_AS_NODE; yield* runCommand( ChildProcess.make( process.execPath, @@ -2092,7 +2098,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // NODE_PATH would let a createRequire call inside the bundle resolve // a missing external from outside the packaged tree, which is the // whole thing this is trying to rule out. - env: { ...process.env, NODE_PATH: "" }, + env: probeEnv, }, ), { From bf33464dc6315cbdc939a91f749f8ad145ddb7b0 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 12:08:55 +0200 Subject: [PATCH 7/9] fix(tests): make failing gates hermetic without weakening assertions (#8) - desktop: run the bundle self-check probe without ELECTRON_RUN_AS_NODE so validation does not depend on the orchestrator's ambient runtime flag. - server: resolve the entrypoint fixture dir, matching the fully-resolved module URLs Node reports (macOS symlinks TMPDIR through /private/var). Built with OpenCode (Muse Spark). --- apps/server/src/entrypoint.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts index bf8247fd42..0842a628bd 100644 --- a/apps/server/src/entrypoint.test.ts +++ b/apps/server/src/entrypoint.test.ts @@ -48,7 +48,10 @@ describe("isEntrypoint", () => { it.skipIf(!symlinksSupported)( "matches through a symlinked entrypoint, as npm and npx install it", () => { - const dir = makeTempDir(); + // Node reports the entry module URL fully resolved, so resolve the + // fixture dir too: under a symlinked TMPDIR (macOS /var -> /private/var) + // an unresolved dir would test a URL Node never produces. + const dir = NodeFS.realpathSync(makeTempDir()); const real = NodePath.join(dir, "bin.mjs"); const link = NodePath.join(dir, "t3"); NodeFS.writeFileSync(real, ""); From 7a94621840093b9dc71324d880377604752aad5e Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 13:28:25 +0200 Subject: [PATCH 8/9] fix(threads): consume the hot domain-event stream in the threads toolkit (#8) The toolkit handlers layer builds with the HTTP routes. Acquiring subscribeDomainEvents at build time failed every route-layer test whose engine does not implement it (193 server.test.ts cases). Use streamDomainEvents, as the upstream reactors do. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/toolkits/threads/handlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 865befcc9d..7ef9ba5f1e 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -286,9 +286,11 @@ const make = Effect.gen(function* () { ); }); - const events = yield* engine.subscribeDomainEvents; + // Consume the hot stream like the upstream reactors do. This layer builds + // with the HTTP routes, so it must not acquire an engine subscription at + // build time (`subscribeDomainEvents`). yield* Effect.forkScoped( - Stream.runForEach(events, (event) => + Stream.runForEach(engine.streamDomainEvents, (event) => bridge(event).pipe( Effect.catchCause((cause) => Effect.logWarning("threads toolkit bridge skipped an event", { From a7de0cdd8ee736908148cdcf5c7832c28ddc8abd Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 13:52:05 +0200 Subject: [PATCH 9/9] style: format GLOSSARY.md so vp fmt --check passes (#8) Co-Authored-By: Claude Opus 5.5 (1M context) --- GLOSSARY.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 66506f49de..b492b1ee6c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -3,11 +3,11 @@ Canonical terms for the toolboxmd fork. Names follow the Lightbringer series by Brent Weeks, the source of the company name. -| Term | Meaning | Avoid | -| --- | --- | --- | -| Luxin | The company; also the work agents produce (code, PRs). | | -| Chromeria | This app: the gateway where the user directs every agent across harnesses, with more interfaces (such as the OpenBot mode) over time. Built from the T3 Code fork. | T3 Code Fork, Bridge | -| Drafter | An agent working for the user on one harness and model. | bot (reserved for OpenBot's named Bots) | -| Prism | Planned product name for Model Router: splits work across models and balances capacity. Not renamed yet. | | -| Toolbox | Planned name for the user's own future harness. | | -| T3 Code | The upstream project this fork tracks (`pingdotgg/t3code`). | | +| Term | Meaning | Avoid | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| Luxin | The company; also the work agents produce (code, PRs). | | +| Chromeria | This app: the gateway where the user directs every agent across harnesses, with more interfaces (such as the OpenBot mode) over time. Built from the T3 Code fork. | T3 Code Fork, Bridge | +| Drafter | An agent working for the user on one harness and model. | bot (reserved for OpenBot's named Bots) | +| Prism | Planned product name for Model Router: splits work across models and balances capacity. Not renamed yet. | | +| Toolbox | Planned name for the user's own future harness. | | +| T3 Code | The upstream project this fork tracks (`pingdotgg/t3code`). | |