From 5f95653f25ae0f5655145fa83d94d618c2951432 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 14:34:42 -0800 Subject: [PATCH 001/690] fix(server): Claude context meter no longer shows an auto-compact point when auto-compaction is off (#36417) --- .../Adapters/ClaudeAdapterV2.test.ts | 12 ++++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2c69c18a1f12b..343675e2cfc40 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9109,6 +9109,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "300000" }, threshold: 267_000, }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"autoCompactWindow":400000,"autoCompactEnabled":false}', + threshold: undefined, + }, + { + autoCompactWindow: "400000", + model: "claude-opus-5-5", + environment: { DISABLE_AUTO_COMPACT: "1" }, + threshold: undefined, + }, ])( "ignores stale snapshots after compaction and reports threshold $autoCompactWindow for $model", ({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 18bf66a1fdacc..f5d39bfd7d087 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -270,6 +270,11 @@ function claudePromptCacheTtlMs( // getContextUsage reports a 150,000 window as a 117,000 threshold, for every catalog model. const CLAUDE_AUTO_COMPACT_RESERVE_TOKENS = 33_000; +// Claude Code's isEnvTruthy. +function isClaudeEnvTruthy(value: string | undefined): boolean { + return value !== undefined && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + function claudeAutoCompactThreshold(autoCompactWindow: string, maxTokens: number | null) { const windowTokens = Number(autoCompactWindow); return windowTokens > 0 && (maxTokens === null || windowTokens < maxTokens) @@ -3260,6 +3265,7 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( const decodeClaudeCompactSettings = Schema.decodeUnknownEffect( Schema.fromJsonString( Schema.Struct({ + autoCompactEnabled: Schema.optionalKey(Schema.Unknown), autoCompactWindow: Schema.optionalKey(Schema.Unknown), modelSettings: Schema.optionalKey(Schema.Unknown), }), @@ -5473,6 +5479,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( modelSelection: ModelSelection, launchArgSettings: ClaudeSdkSettings | undefined, ) { + // With auto-compaction off Claude never compacts, so there is no threshold to report. + for (const name of ["DISABLE_COMPACT", "DISABLE_AUTO_COMPACT"]) { + const value = yield* resolveClaudeEnv(cwd, launchArgSettings, name); + if (isClaudeEnvTruthy(value)) return ""; + } + const launchEnabled = launchArgSettings?.autoCompactEnabled; + let autoCompactEnabled = typeof launchEnabled === "boolean" ? launchEnabled : undefined; + for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { + if (autoCompactEnabled !== undefined) break; + const settings = yield* fileSystem.readFileString(settingsFile).pipe( + Effect.flatMap(decodeClaudeCompactSettings), + Effect.orElseSucceed(() => undefined), + ); + if (typeof settings?.autoCompactEnabled === "boolean") { + autoCompactEnabled = settings.autoCompactEnabled; + } + } + if (autoCompactEnabled === false) return ""; if (adapterOptions.settings.autoCompactWindow !== "") return adapterOptions.settings.autoCompactWindow; const validWindow = (value: unknown) => { From c8db8be305056bda6f82f96a43c65db178e62d36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 14:45:20 -0800 Subject: [PATCH 002/690] fix(web): a run waiting on foreground subagents says so instead of Thinking (#36334) --- apps/mobile/src/lib/threadActivity.ts | 21 +++- .../chat/MessagesTimeline.logic.test.ts | 107 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 32 +++++- .../components/chat/TimelineWorkGroupRows.tsx | 2 +- .../src/state/subagentRuntime.ts | 4 + 5 files changed, 161 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cbc9e5e61a054..5b9f07ed77332 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -6,7 +6,10 @@ import { linkRequestTargetName } from "@t3tools/client-runtime/link-request"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; -import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + isLiveSubagentTurnItem, + waitingOnSubagentsLabel, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { turnItemDetailRevision, @@ -1817,11 +1820,12 @@ export function deriveThreadFeedPresentation( row.activities[0]?.projectedItem.item.status === "running"), ) ) { + const waitingOn = activeRunId === null ? 0 : runningSubagentsAtRunTail(feed, activeRunId); result.push( thinkingRow( activeWorkStartedAt, activeRunId, - zen ? liveAnswerPreview(feed) : null, + waitingOn > 0 ? waitingOnSubagentsLabel(waitingOn) : zen ? liveAnswerPreview(feed) : null, latestRun?.providerStartingLabel, ), ); @@ -1928,6 +1932,19 @@ function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean { const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +/** Reads the feed before tool visibility filters it, so hidden newer parent work still counts. */ +function runningSubagentsAtRunTail(feed: ReadonlyArray, runId: RunId): number { + let running = 0; + for (let index = feed.length - 1; index >= 0; index -= 1) { + const entry = feed[index]!; + if (entry.type !== "activity-group" || entry.runId !== runId) break; + const items = entry.activities.map((activity) => activity.projectedItem.item); + if (items.some((item) => item.type !== "subagent")) break; + running += items.filter(isLiveSubagentTurnItem).length; + } + return running; +} + function thinkingRow( createdAt: string, runId: RunId | null, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 1bdb26aecfd62..7bbdf9db1784b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4888,6 +4888,113 @@ describe("live subagents after their parent turn settles", () => { }); }); +describe("parent run waiting on foreground subagents", () => { + const runId = RunId.make("parent-run"); + const threadId = ThreadId.make("parent-thread"); + const at = (second: number) => + DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`); + const base = (id: string, second: number) => ({ + id: TurnItemId.make(id), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: ProviderTurnId.make("parent-provider-turn"), + nativeItemRef: null, + parentItemId: null, + ordinal: second, + title: null, + startedAt: at(second), + completedAt: at(second), + updatedAt: at(second), + }); + const child = (id: string, second: number): OrchestrationV2TurnItem => ({ + ...base(id, second), + status: "running", + completedAt: null, + type: "subagent", + subagentId: NodeId.make(id), + origin: "provider_native", + driver: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + childThreadId: null, + prompt: `Read ${id}`, + result: null, + }); + + const liveRow = (later: ReadonlyArray, toolCallVisibility?: "hideAll") => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("user", 0), + status: "completed", + type: "user_message", + messageId: MessageId.make("user"), + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Research these in parallel", + attachments: [], + }, + { + ...base("assistant", 1), + status: "completed", + type: "assistant_message", + messageId: MessageId.make("assistant"), + text: "Dispatching three subagents.", + streaming: false, + }, + child("a", 2), + { ...child("b", 3), status: "completed", completedAt: at(3) }, + child("c", 4), + ...later, + ] satisfies OrchestrationV2TurnItem[] + ).map((item) => ({ + position: item.ordinal, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })), + optimisticMessages: [], + }), + latestRun: { + runId, + status: "running", + startedAt: DateTime.formatIso(at(0)), + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: DateTime.formatIso(at(0)), + turnDiffSummaries: [], + supportsConversationRollback: false, + ...(toolCallVisibility ? { toolCallVisibility } : {}), + }).find((row) => row.id === "live-activity-row"); + const thinking = { + kind: "thinking", + id: "live-activity-row", + createdAt: DateTime.formatIso(at(0)), + }; + + it("names the running children while the open run has done nothing since launching them", () => { + expect(liveRow([])).toEqual({ ...thinking, label: "Waiting on 2 subagents" }); + expect(liveRow([], "hideAll")).toEqual({ ...thinking, label: "Waiting on 2 subagents" }); + }); + + it("keeps Thinking when a hidden newer parent command follows the children", () => { + const command: OrchestrationV2TurnItem = { + ...base("command", 5), + status: "running", + completedAt: null, + type: "command_execution", + input: "sleep 5", + }; + expect(liveRow([command], "hideAll")).toEqual(thinking); + }); +}); + describe("subagent continued by a later turn", () => { const threadId = ThreadId.make("parent-thread"); const run1 = RunId.make("run-1"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b2460d1fb43b2..9208e409ba205 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,10 @@ import * as DateTime from "effect/DateTime"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; -import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + isLiveSubagentTurnItem, + waitingOnSubagentsLabel, +} from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; @@ -532,6 +535,8 @@ type MessagesTimelineRowContent = kind: "thinking"; id: string; createdAt: string | null; + /** Replaces "Thinking" while the run has done nothing since launching children that still run. */ + label?: string; groupId?: string; expanded?: boolean; } @@ -1451,6 +1456,19 @@ export function liveAnswerPreview(entries: ReadonlyArray): string return null; } +/** Reads entries before tool visibility filters them, so hidden newer parent work still counts. */ +function runningSubagentsAtRunTail(entries: ReadonlyArray, runId: RunId): number { + let running = 0; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + if (entry.kind !== "event") break; + const { item } = entry.projectedItem; + if (item.type !== "subagent" || item.runId !== runId) break; + if (isLiveSubagentTurnItem(item)) running += 1; + } + return running; +} + function timelineRowEntries( input: Pick< MessagesTimelineRowsInput, @@ -2316,10 +2334,15 @@ export function deriveMessagesTimelineRows(input: { } nextRows.push(...viewedImagesRows(groupId, failedGroupAnchor.createdAt, groupedEntries)); } else { + const waitingOn = + unsettledRunId === null + ? 0 + : runningSubagentsAtRunTail(input.timelineEntries, unsettledRunId); nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt ?? null, + ...(waitingOn > 0 ? { label: waitingOnSubagentsLabel(waitingOn) } : {}), }); } } @@ -2679,7 +2702,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); case "thinking": { const bt = b as typeof a; - return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; + return ( + a.createdAt === bt.createdAt && + a.groupId === bt.groupId && + a.expanded === bt.expanded && + a.label === bt.label + ); } case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 8fd7436d0e099..33608965ef313 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -435,7 +435,7 @@ export function ThinkingTimelineRow({ row }: { row: Extract Date: Fri, 13 Nov 2026 14:50:16 -0800 Subject: [PATCH 003/690] fix(server): a follow-up to a delegated child waits for its awaited background work (#36411) --- apps/server/src/mcp/OrchestratorMcpService.ts | 4 +- .../DelegatedCompletionDelivery.test.ts | 240 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 55 +++- .../orchestration-v2/RestartBackgroundNote.ts | 27 +- 4 files changed, 312 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e3cbb4aca9417..2e5877e9ab8ed 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -102,7 +102,7 @@ import type { ProjectionRecordFilter, } from "../orchestration-v2/ProjectionStore.ts"; import { - delegatedTaskAwaitsSubagents, + delegatedTaskAwaitsWork, delegatedTaskResultRun, delegatedTaskRuns, runOutcome, @@ -1640,7 +1640,7 @@ const make = Effect.gen(function* () { ? progress.state : !isTerminalTaskStatus(taskStatusForRun(taskResultRun)) ? "working" - : delegatedTaskAwaitsSubagents(followupRuns, childControls.subagents) + : delegatedTaskAwaitsWork(followupRuns, childControls) ? "waiting_for_children" : "result_available"; const resultRunIds = [ diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index b0886435d0b5d..22827388bffa0 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -3190,6 +3190,246 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }), ); + it.effect.each(["woken", "cancelled"] as const)( + "holds a follow-up open while the child's awaited background command runs (%s)", + (ending) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make(`thread:followup-command-parent-${ending}`); + const projectId = ProjectId.make(`project:followup-command-${ending}`); + const parentRunId = RunId.make(`run:followup-command-parent-${ending}`); + const rootNodeId = NodeId.make(`node:followup-command-parent-${ending}`); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make(`node:followup-command-seed-${ending}`), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: `followup-command-child-${ending}`, + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + + const followupMessageId = MessageId.make(`message:followup-command-send-${ending}`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`command:followup-command-send-${ending}`), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: followupMessageId, + text: "Start the check in the background, wait on it, and reply when it ends.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const followupRun = (yield* orchestrator.getThreadProjection( + child.childThreadId, + )).runs.find((run) => run.userMessageId === followupMessageId)!; + const followupTaskId = followupRun.delegatedTaskId!; + const parent = yield* orchestrator.getThreadProjection(parentThreadId); + + // The child awaits a background command, replies with an interim note and ends its turn. + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-turn-ended-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-awaited-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [ + { taskId: "sleep-90", kind: "command" as const, awaited: true as const }, + ], + }, + }, + { + id: EventId.make(`event:followup-command-interim-${ending}`), + type: "message.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { + id: MessageId.make(`message:followup-command-interim-${ending}`), + threadId: child.childThreadId, + runId: followupRun.id, + nodeId: null, + role: "assistant", + text: "INTERIM", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:followup-command-run-completed-${ending}`), + type: "run.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: { ...followupRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const held = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + ); + assert.isNull( + held?.result ?? null, + "the follow-up closed with its interim reply while the awaited command still ran", + ); + assert.equal(held?.status, "running"); + + if (ending === "cancelled") { + yield* orchestrator.cancelDelegatedTask({ + parentThreadId, + taskId: followupTaskId, + command: { + type: "thread.stop", + commandId: CommandId.make(`command:followup-command-cancel-${ending}`), + threadId: child.childThreadId, + stopDelegatedTasks: false, + }, + stopDescendants: () => Effect.void, + }); + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-stopped-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-stopped-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [], + }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + const stopped = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).subagents.find((task) => task.id === followupTaskId); + assert.equal(stopped?.cancelledRunId, followupRun.id); + assert.equal(stopped?.status, "interrupted"); + return; + } + + // The finished command wakes the child; that turn continues the same follow-up. + const wakeMessageId = MessageId.make(`message:followup-command-wake-${ending}`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`command:followup-command-wake-${ending}`), + threadId: child.childThreadId, + messageId: wakeMessageId, + text: "Background activity updated", + notification: { + source: { kind: "command" }, + outcome: "completed", + summary: 'Command "sleep 90" finished', + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const wakeRun = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs.find( + (run) => run.userMessageId === wakeMessageId, + )!; + assert.equal(wakeRun.delegatedTaskId, followupTaskId); + yield* eventSink.write({ + commandId: reconcileCommandId(`followup-command-wake-ended-${ending}`), + events: [ + { + id: EventId.make(`event:followup-command-settled-${ending}`), + type: "provider-thread.updated", + threadId: child.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.providerThreads[0]!, + id: ProviderThreadId.make(`provider-thread:followup-command-child-${ending}`), + appThreadId: child.childThreadId, + pendingBackgroundTasks: [], + }, + }, + { + id: EventId.make(`event:followup-command-final-${ending}`), + type: "message.updated", + threadId: child.childThreadId, + runId: wakeRun.id, + occurredAt: now, + payload: { + id: MessageId.make(`message:followup-command-final-${ending}`), + threadId: child.childThreadId, + runId: wakeRun.id, + nodeId: null, + role: "assistant", + text: "FINAL CHECK-OK", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:followup-command-wake-completed-${ending}`), + type: "run.updated", + threadId: child.childThreadId, + runId: wakeRun.id, + occurredAt: now, + payload: { ...wakeRun, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* orchestrator.recoverDelegatedTasks; + + const finished = yield* orchestrator.getThreadProjection(parentThreadId); + const childTasks = finished.subagents.filter( + (task) => task.childThreadId === child.childThreadId, + ); + assert.deepEqual( + childTasks.map((task) => task.id).toSorted(), + [child.taskId, followupTaskId].toSorted(), + ); + const followup = childTasks.find((task) => task.id === followupTaskId); + assert.equal(followup?.status, "completed"); + assert.equal(followup?.result, "FINAL CHECK-OK"); + }), + ); + it.effect("waits for a run the follow-up's child queued on its own thread", () => Effect.gen(function* () { const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index dfcf279a0392e..398a4c29c0989 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -128,7 +128,7 @@ import { cancelledBeforeStart, continuationFailedBeforePrompt, continuationStartRetryDelayMs, - delegatedTaskAwaitsSubagents, + delegatedTaskAwaitsWork, delegatedTaskResultRun, delegatedTaskRuns, isRestartContinuationSource, @@ -9489,9 +9489,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (row) => row.id === message.runId, ); if (run === undefined || run.delegatedTaskId !== undefined) return; + // A wake continues the latest follow-up while it is open, as it continues the original task. + const latestFollowupId = + command.notification === undefined + ? undefined + : (yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe(mapDispatchError(command))).runs + .filter((row) => row.id !== run.id && row.delegatedTaskId !== undefined) + .toSorted((a, b) => b.ordinal - a.ordinal)[0]?.delegatedTaskId; const parent = yield* projectionStore .getThreadRecords(parentThreadId, ["runs", "subagents", "providerTurns", "attempts"], { - subagentIds: child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : [], + subagentIds: [ + ...(child.forkedFrom?.type === "node" ? [child.forkedFrom.nodeId] : []), + ...(latestFollowupId === undefined ? [] : [latestFollowupId]), + ], }) .pipe(mapDispatchError(command)); const original = parent.subagents.find( @@ -9501,6 +9513,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio task.id === (child.forkedFrom?.type === "node" ? child.forkedFrom.nodeId : undefined), ); if (original === undefined || original.result === null) return; + const openFollowup = parent.subagents.find( + (task) => + task.id === latestFollowupId && + task.origin === "app_owned" && + task.childThreadId === child.id && + task.cancelledRunId === undefined && + task.result === null, + ); + if (openFollowup !== undefined) { + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: child.id, + runId: run.id, + occurredAt: message.createdAt, + payload: { ...run, delegatedTaskId: openFollowup.id }, + }); + return; + } const owner = followUpOwnerRun(parent.runs); if (owner === null || owner.rootNodeId === null) return; const now = message.createdAt; @@ -14115,8 +14148,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); return; } - // A follow-up ends with the subagents its turns started, as the original task does. - if (delegatedTaskAwaitsSubagents(taskRuns, childControls.subagents)) return; + // A follow-up ends with the work it awaits, as the original task does. A cancelled task + // does not wait for the background work its cancel is stopping. + if ( + delegatedTaskAwaitsWork( + taskRuns, + options.task.cancelledRunId === undefined + ? childControls + : { subagents: childControls.subagents, providerThreads: [] }, + ) + ) + return; if ( yield* childAwaitsRestartContinuation( taskRuns.length === 0 ? childControls.runs : taskRuns, @@ -15691,7 +15733,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( run === undefined || delegatedTaskTerminalStatus(run.status) === null || - delegatedTaskAwaitsSubagents(runs, child.subagents) + delegatedTaskAwaitsWork(runs, child) ) return true; return yield* childAwaitsRestartContinuation(runs, run); @@ -15786,7 +15828,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? child.thread.forkedFrom?.type === "node" && child.thread.forkedFrom.nodeId === input.taskId && progressAfterTurn(child).state !== "result_available" - : delegatedTaskTerminalStatus(runOutcome(followUpRun)) === null; + : delegatedTaskTerminalStatus(runOutcome(followUpRun)) === null || + delegatedTaskAwaitsWork(delegatedTaskRuns(child, input.taskId), child); yield* threadDispatch.withLock( command.threadId, Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 3de10f4e944ad..23a95bc2bd20f 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -2,6 +2,7 @@ import type { NodeId, OrchestrationV2ConversationMessage, OrchestrationV2PendingBackgroundTask, + OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, @@ -10,6 +11,7 @@ import type { OrchestrationV2TurnItem, } from "@t3tools/contracts"; import { subagentHoldsCompletion } from "@t3tools/provider-core/server/subagentProjection"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestRootProviderFailure, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; @@ -224,15 +226,28 @@ export function delegatedTaskRuns( return projection.runs.filter((run) => owned.has(run.id)); } -/** A follow-up task's runs still owe a subagent they started its result. */ -export function delegatedTaskAwaitsSubagents( +/** + * A follow-up task's runs still owe a subagent they started its result, or the child still + * awaits background work, as the original task waits on both. + */ +export function delegatedTaskAwaitsWork( runs: ReadonlyArray, - subagents: ReadonlyArray< - Pick - >, + child: { + readonly subagents: ReadonlyArray< + Pick + >; + readonly providerThreads: ReadonlyArray< + Pick + >; + }, ): boolean { const runIds = new Set(runs.map((run) => run.id)); - return subagents.some((task) => runIds.has(task.runId) && subagentHoldsCompletion(task)); + return ( + child.subagents.some((task) => runIds.has(task.runId) && subagentHoldsCompletion(task)) || + child.providerThreads.some((thread) => + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []), + ) + ); } /** A follow-up task's latest run, passing over queued work it no longer waits on, as the original task does. */ From 08b8689a856bb282350bbb30c94e5d8cd22fec7a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 14:54:05 -0800 Subject: [PATCH 004/690] fix(provider-grok): Grok follow-ups queue instead of cancelling the running tool (#36422) --- .../orchestration-v2/CommandPolicy.test.ts | 23 +++++++ .../testkit/fixtures/index.ts | 6 -- .../message_steering/grok_transcript.ndjson | 67 ------------------- packages/provider-grok/src/server/adapter.ts | 2 + 4 files changed, 25 insertions(+), 73 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 85babb8a869bf..14a803b1d1d60 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; +import { makeGrokAcpAdapterFlavor } from "@t3tools/provider-grok/testing"; import * as CommandPolicy from "./CommandPolicy.ts"; const commandId = CommandId.make("command-policy-test"); @@ -211,6 +212,28 @@ it("queues a steer for a provider that cannot steer either way", () => { ); }); +// regression: #27014 a Grok follow-up cancels the foreground tool the user wanted to finish +it("does not steer a Grok follow-up by interrupting its running turn", () => { + const grok = makeGrokAcpAdapterFlavor( + { makeRuntime: () => Effect.never } as unknown as Parameters< + typeof makeGrokAcpAdapterFlavor + >[0], + "/home/ada", + ).capabilities; + const projection = dispatchProjection(grok); + for (const deliveryIntent of ["auto", "steer"] as const) { + const mode = CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + deliveryIntent, + ); + assert.isFalse( + mode.type === "steer_active" && !grok.turns.supportsActiveSteering, + `${deliveryIntent} follow-up resolved to ${mode.type} by interrupt-restart`, + ); + } +}); + it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index ce6d739b1cdd3..1eae24c5bb2b4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -1779,12 +1779,6 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray, HOME is /home/grok-replay and the recording user is grok-replay. T3-owned prompt text, MCP servers and initialize params are . Personal skills, machine identity, account settings and announcement broadcasts are removed, as are responses to Grok-internal request ids that T3's protocol drops. Timestamps are kept as recorded.","droppedFrames":5}} -{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":"","clientInfo":"","_meta":{"clientType":"extension"}}}} -{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"cached_token","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"","agentVersion":"1.0.41","agentId":"00000000-0000-4000-8000-000000000000","agentInstanceId":"00000000-0000-4000-8000-000000000000","hostname":"grok-replay","modelState":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} -{"type":"emit_inbound","label":"notification:_x.ai/mcp/servers_updated","frame":{"kind":"notification","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"model_switch","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/mcp_initialized","frame":{"kind":"notification","method":"_x.ai/mcp_initialized","params":{"sessionId":"00000000-0000-4000-8000-000000000001","mcpToolCount":0,"elapsedMs":0}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2263,"eventId":"00000000-0000-4000-8000-000000000001-0","agentTimestampMs":1790300235225,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2263,"eventId":"00000000-0000-4000-8000-000000000001-1","agentTimestampMs":1790300235246,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:model_changed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"model_changed","model_id":"grok-4.7","reasoning_effort":"high"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"00000000-0000-4000-8000-000000000001","models":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.7","options":[{"value":"grok-4.7","name":"Grok 4.7"},{"value":"grok-4.7-build-fast","name":"Grok 4.7 Fast"},{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High","description":"Maximum reasoning for the hardest tasks."},{"value":"high","name":"High","description":"Thorough reasoning and quality. Recommended."},{"value":"medium","name":"Medium","description":"Strong quality with a faster turnaround."},{"value":"low","name":"Low","description":"Fastest responses. Best for simple tasks."}]}],"_meta":{"currentWorkingDirectory":"","codebaseIndexed":[],"isGitRepo":true,"gitRoot":"","showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.7","category":"model","label":"Grok 4.7","selected":true},{"id":"grok-4.7-build-fast","category":"model","label":"Grok 4.7 Fast","selected":false},{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":false},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","selected":false},{"id":"high","category":"mode","label":"High","description":"Thorough reasoning and quality. Recommended.","selected":true},{"id":"medium","category":"mode","label":"Medium","description":"Strong quality with a faster turnaround.","selected":false},{"id":"low","category":"mode","label":"Low","description":"Fastest responses. Best for simple tasks.","selected":false}]},"x.ai/sessionDetail":{"sessionId":"00000000-0000-4000-8000-000000000001","kind":"build","cwd":"","currentModelId":"grok-4.7"},"x.ai/memoryMode":"v2"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: steering fixture initial response\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235622,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} -{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"00000000-0000-4000-8000-000000000001","_meta":{"cancelTrigger":"ctrl_c"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-1","stop_reason":"cancelled","elapsed_ms":22},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-4","agentTimestampMs":1790300235647,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-1","stopReason":"cancelled","agentResult":null,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1790300235649,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":2263,"modelId":"grok-4.7","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"},"cancelTrigger":"ctrl_c"}}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nActually, respond with exactly: steering fixture observed\n"},{"type":"text","text":""}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235729,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} -{"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-8","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":1}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":2}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-10","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":3}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-11","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":4}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-12","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":5}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" respond"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-13","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":6}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-14","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":7}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-15","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":8}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-16","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":9}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-17","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":10}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ste"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-18","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":11}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ering"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-19","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":12}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fixture"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-20","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":13}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" observed"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-21","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":14}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-22","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":15}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ste"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-23","agentTimestampMs":1790300237880,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":16}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ering"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-24","agentTimestampMs":1790300237881,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":17}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" fixture"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-25","agentTimestampMs":1790300237940,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":18}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" observed"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-26","agentTimestampMs":1790300237940,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentMessageChunk","chunkId":19}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"/home/grok-replay/.grok/bundled/workflows/deep-research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}},{"name":"build-with-ai","description":"Build AI apps on SpaceXAI (XAI_API_KEY + api.x.ai)","input":{"hint":""},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/build-with-ai/SKILL.md","bareName":"build-with-ai","qualifiedName":"bundled:build-with-ai"}},{"name":"code-review","description":"Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a deep code quality audit or an especially harsh maintainability review.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/code-review/SKILL.md","bareName":"code-review","qualifiedName":"bundled:code-review"}},{"name":"create-skill","description":"Create a new Grok skill","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-skill/SKILL.md","bareName":"create-skill","qualifiedName":"bundled:create-skill"}},{"name":"create-workflow","description":"Author a new multi-agent workflow","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/create-workflow/SKILL.md","bareName":"create-workflow","qualifiedName":"bundled:create-workflow"}},{"name":"execute-plan","description":"Execute a PR Plan DAG from a design document. Parses the plan, topologically sorts it, implements PRs in parallel using worktree-isolated subagents, runs mandatory orchestrator-level review, and assembles either a Graphite PR stack or a plain-git branch stack depending on tool availability.","input":{"hint":" [--effort N] [--concurrency N] [--dry-run] [--resume ] [--instructions \"...\"] [--no-graphite] [--auto-pr]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/execute-plan/SKILL.md","bareName":"execute-plan","qualifiedName":"bundled:execute-plan"}},{"name":"game-assets","description":"Engine-ready game art, 2D sprites to 3D models","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/game-assets/SKILL.md","bareName":"game-assets","qualifiedName":"bundled:game-assets"}},{"name":"bundled:imagine","description":"Prompting and workflow guidance for Imagine image and video tools","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/imagine/SKILL.md","bareName":"imagine","qualifiedName":"bundled:imagine"}},{"name":"implement","description":"Run the full implement-review-fix loop using implementer and reviewer personas. Supports effort-based multi-reviewer scaling (2–7 reviewers) with automatic specialization selection. Includes memory-based feedback loop that learns from past review patterns. Loops until all reviewers find 0 issues of any severity.","input":{"hint":"[--effort N] "},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/implement/SKILL.md","bareName":"implement","qualifiedName":"bundled:implement"}},{"name":"learn","description":"Learn from your own Grok Build sessions and tune your setup: add skills for things you keep typing, fix stale skill instructions, and retire skills, plugins, or MCP servers you never use. Use on \"/learn\", \"learn from my traces\", \"what skills do I never use\". Not for summarizing sessions into notes or memory.","input":{"hint":"[--mode step|auto|report] [--days N | --since-last | --limit N] [--per-trace] [--include-headless] [--cwd PATH] [--focus TEXT] [--resume]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/learn/SKILL.md","bareName":"learn","qualifiedName":"bundled:learn"}},{"name":"long-running-background-tasks","description":"Required reading before you start, watch, or wait on anything that keeps running after you launch it — background jobs, watchers, scheduled loops, CI, pull requests, training runs, dev servers, long builds. Read it before you launch such work and before you report on its state. Saying where a running job stands counts as working on it. Use when: about to launch, supervise, inspect, diagnose, or report on work that keeps running after it is started.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/long-running-background-tasks/SKILL.md","bareName":"long-running-background-tasks","qualifiedName":"bundled:long-running-background-tasks"}},{"name":"pr-babysit","description":"Monitor PRs, fix CI failures, address review comments (human and bot/automated), resolve merge conflicts, and restack stacks. Supports independent PRs, Graphite stacks, and GitHub stacked PRs (gh-stack). Optional ship lands a ready PR bottom-up when every job has passed and no comments remain open.","input":{"hint":"add [--ship] | remove | list | check | ship [on|off] [number...]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/pr-babysit/SKILL.md","bareName":"pr-babysit","qualifiedName":"bundled:pr-babysit"}},{"name":"resume-claude","description":"Continue from a recent Claude Code session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-claude/SKILL.md","bareName":"resume-claude","qualifiedName":"bundled:resume-claude"}},{"name":"resume-codex","description":"Continue from a recent Codex session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-codex/SKILL.md","bareName":"resume-codex","qualifiedName":"bundled:resume-codex"}},{"name":"resume-cursor","description":"Continue from a recent Cursor session","input":{"hint":"[words describing the session | session id]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/resume-cursor/SKILL.md","bareName":"resume-cursor","qualifiedName":"bundled:resume-cursor"}},{"name":"review","description":"Run a reviewer subagent against your whole branch vs main, your uncommitted working state, a named branch, a single GitHub PR, or an entire Graphite/stacked-PR stack (one reviewer subagent per PR). A bare /review defaults to reviewing your uncommitted working state when the tree is dirty, or your whole branch vs main when the tree is clean. Main, local, and branch modes write a review file plus a summary to disk. PR and stack modes post the findings as a PENDING GitHub review for the user to inspect and submit through the UI. Use --main for an explicit clean-tree branch review, --stack for stacked PRs, --submit to publish immediately as COMMENT, or --approve to publish and approve; the default remains PENDING.","input":{"hint":"[--main | --local | --branch | --pr | --stack | ] [--approve | --submit]"},"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/review/SKILL.md","bareName":"review","qualifiedName":"bundled:review"}},{"name":"skill-design-principles","description":"Concise, high-signal principles for writing and editing skills well. Use whenever authoring or editing a skill.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/skill-design-principles/SKILL.md","bareName":"skill-design-principles","qualifiedName":"bundled:skill-design-principles"}},{"name":"statusline","description":"Configure the Grok Build status line.","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/statusline/SKILL.md","bareName":"statusline","qualifiedName":"bundled:statusline"}},{"name":"threejs-frame-conventions","description":"One frame for Three.js controls, camera, and facing","input":null,"_meta":{"scope":"bundled","path":"/home/grok-replay/.grok/bundled/skills/threejs-frame-conventions/SKILL.md","bareName":"threejs-frame-conventions","qualifiedName":"bundled:threejs-frame-conventions"}},{"name":"learn-traces","description":"Workflow: Map-reduce a collected set of Grok Build sessions into a curated list of harness changes: new or edited skills, plugin enables, deletes","input":{"hint":"[--agent-budget N] [--effort LEVEL] [args]"},"_meta":{"workflowSource":"bundled","workflowPath":"/home/grok-replay/.grok/bundled/workflows/learn-traces.rhai"}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":22482,"eventId":"00000000-0000-4000-8000-000000000001-27","agentTimestampMs":1790300238036,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":60}}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:response_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":20630,"output_tokens":52,"cache_read_input_tokens":1792,"cache_creation_input_tokens":0,"reasoning_tokens":48},"signature":"cNT0TTliyhDVuYf3s5VZfOfJbZ45UY6L5HBO+Xb/gBknGuT6PJ2VPHHFehMK0YlGQp0HdgesFPfH+4r5wgsonXfDZrP6QRgdIc6uzdd/tZRCuYdQaXYeuQok7T7Zy7IJip8ts9EFpP6IESqUwU/SDd2eUnc/trneRbDUTjoksGYLl3d3YCGz+1sK6fSy00huVXUpqABRPOcMn/gK6qtQVneVkGMYW/wD5Wvxp3OGC/LCwu9PT4WUYQtoPOi5ObKcFdKPj5U9y6HssWazr6kSfqt1ox2f7z5Dq/Iu57NxLuJEDZt99A/1Qo1i3DbUuUet6jEL7JkCcBwsZrBKcAu5d8wk7xV8D6rUNtpXpr0XPCZMUQLFUPL2tg"}}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-2","stop_reason":"end_turn","usage":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200,"modelUsage":{"grok-4.7-build":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200}},"numTurns":1},"elapsed_ms":2358},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-30","agentTimestampMs":1790300238089}}}} -{"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-2","stopReason":"end_turn","agentResult":null}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1790300238090,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-2","promptId":"t3-xai-prompt-2","totalTokens":22482,"modelId":"grok-4.7","inputTokens":22422,"outputTokens":52,"cachedReadTokens":1792,"reasoningTokens":48,"usage":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200,"modelUsage":{"grok-4.7-build":{"inputTokens":22422,"outputTokens":52,"totalTokens":22474,"cachedReadTokens":1792,"cacheCreationTokens":0,"reasoningTokens":48,"modelCalls":1,"apiDurationMs":2245,"costUsdTicks":144391200}},"numTurns":1}}}}} -{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["00000000-0000-4000-8000-000000000001"]}}} -{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} -{"type":"runtime_exit","status":"success"} diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 7340ad213bed1..0e8d9b6e04714 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -117,6 +117,8 @@ const GrokProviderCapabilitiesV2 = { ...AcpProviderCapabilitiesV2.checkpointing, providerCanReadConversationSnapshot: true, }, + // Cancelling a Grok prompt kills its running foreground tool. + turns: { ...AcpProviderCapabilitiesV2.turns, supportsSteeringByInterruptRestart: false }, } satisfies OrchestrationV2ProviderCapabilities; export interface GrokAdapterV2Options { From b5379160993f49edbc4458e091356da3bcd47c4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:13:38 -0800 Subject: [PATCH 005/690] fix(server): thread list builds metadata only for the page it returns (#36434) --- .../src/mcp/OrchestratorMcpService.test.ts | 24 +++++ apps/server/src/mcp/OrchestratorMcpService.ts | 51 ++++++++--- apps/server/src/mcp/toolkits/core.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 10 ++ .../src/orchestration-v2/ProjectionStore.ts | 91 +++++++++++++++++-- .../ProviderTurnControlService.test.ts | 1 + .../ThreadManagementService.ts | 53 +++++------ apps/server/src/peer/PeerForwarding.test.ts | 1 + 8 files changed, 181 insertions(+), 51 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 03810fbefe599..a017590b1f30a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3975,6 +3975,7 @@ describe("OrchestratorMcpService thread list", () => { ], }, ]; + const shellRequests: Array> = []; const service = layerOrchestratorService.pipe( Layer.provide( Layer.mergeAll( @@ -3987,6 +3988,13 @@ describe("OrchestratorMcpService thread list", () => { (thread) => input.projectId === undefined || thread.projectId === input.projectId, ), ), + getShellSnapshot: (options) => { + const threadIds = options?.threadIds ?? shells.map((thread) => thread.id); + shellRequests.push(threadIds); + return Effect.succeed({ + threads: shells.filter((thread) => threadIds.includes(thread.id)), + } as never); + }, }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -4080,6 +4088,22 @@ describe("OrchestratorMcpService thread list", () => { }).pipe(Effect.provide(service)), ); + it.effect("builds shells only for the threads on the page", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + shellRequests.length = 0; + const listed = yield* mcp.listThreads(client, { + projectId: ProjectId.make("project:a"), + limit: 1, + }); + assert.deepStrictEqual( + [listed.threads.map((thread) => thread.threadId), listed.total, listed.nextCursor], + [["thread:a-snoozed"], 3, 1], + ); + assert.deepStrictEqual(shellRequests, [[ThreadId.make("thread:a-snoozed")]]); + }).pipe(Effect.provide(service)), + ); + it.effect("points a caller outside any thread at allProjects when it omits projectId", () => Effect.gen(function* () { const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 2e5877e9ab8ed..fc741bcac6ab5 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -3104,16 +3104,14 @@ const make = Effect.gen(function* () { if (!input.allProjects && input.projectId !== undefined) { yield* requireProject(input.projectId); } + const listFailure = (error: unknown) => + failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`); const projectThreads = yield* threadManagement .listProjectThreads({ projectId: projectId ?? undefined, includeSubagents: input.includeSubagents !== false, }) - .pipe( - Effect.mapError((error) => - failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), - ), - ); + .pipe(Effect.mapError(listFailure)); const nowMs = yield* Clock.currentTimeMillis; // An empty list is no filter: clients that fill every optional field send `[]`. const statuses = @@ -3121,29 +3119,58 @@ const make = Effect.gen(function* () { ? null : new Set(input.statuses); const titleContains = input.titleContains?.toLocaleLowerCase(); - const filtered = projectThreads - .filter((thread) => statuses === null || statuses.has(listedThreadStatus(thread))) + const candidates = projectThreads .filter( (thread) => input.settled === undefined || threadSettlement(thread).settled === input.settled, ) .filter( - (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + (thread) => + titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), ) + // Only a snooze that ends later can hold, so the rest skip building a shell. .filter( (thread) => - titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), + input.snoozed !== true || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs), ); + // Shells are the costly part, so build them only for the threads the page needs. + const shellsOf = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => + threads.length === 0 + ? Effect.succeed([]) + : threadManagement + .getShellSnapshot({ location: "active", threadIds: threads.map(({ id }) => id) }) + .pipe( + Effect.map((snapshot) => { + const shellsById = new Map(snapshot.threads.map((shell) => [shell.id, shell])); + return threads.flatMap(({ id }) => shellsById.get(id) ?? []); + }), + Effect.mapError(listFailure), + ); const cursor = input.cursor ?? 0; const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT; - const page = filtered.slice(cursor, cursor + limit); - const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; + let page: ReadonlyArray; + let total: number; + if (statuses === null && input.snoozed === undefined) { + total = candidates.length; + page = yield* shellsOf(candidates.slice(cursor, cursor + limit)); + } else { + // Status and snooze state are derived from the shell. + const filtered = (yield* shellsOf(candidates)) + .filter((thread) => statuses === null || statuses.has(listedThreadStatus(thread))) + .filter( + (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + ); + total = filtered.length; + page = filtered.slice(cursor, cursor + limit); + } + const nextCursor = cursor + limit < total ? cursor + limit : null; return { projectId, currentThreadId: parent?.thread.id ?? null, threads: page.map((shell) => listItemFromShell(scope, shell, nowMs)), nextCursor, - total: filtered.length, + total, } satisfies OrchestratorMcpThreadListResult; }), readThread: Effect.fn("OrchestratorMcpService.readThread")(function* (scope, input) { diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index b2aaed70322cf..35a18f5f0b460 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1328,6 +1328,7 @@ it.effect( Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ listProjectThreads: () => Effect.succeed([unsettled, settled]), + getShellSnapshot: () => Effect.succeed({ threads: [unsettled, settled] } as never), }), ), Layer.provide(ServerSettings.layerTest()), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 398a4c29c0989..c732e0ca67ccf 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4,6 +4,7 @@ import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, + ProjectId, } from "@t3tools/contracts"; import { latestExecutedRun, @@ -173,6 +174,7 @@ import { type ProjectionCheckpointContext, type OlderSettledThreadsListing, type ActiveShellIndexEntry, + type ActiveThreadListEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -504,6 +506,9 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly listActiveThreads: ( + projectId?: ProjectId, + ) => Effect.Effect, OrchestratorV2Error>; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -16040,6 +16045,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); }), + listActiveThreads: Effect.fn("OrchestratorV2.listActiveThreads")(function (projectId) { + return projectionStore + .listActiveThreads(projectId) + .pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 78422b56535af..e1306f58273e6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -467,6 +467,19 @@ export interface ActiveShellIndexEntry extends Pick< readonly hasRemoteTasks: boolean; } +/** What thread listings filter and order an active thread by, without building its shell. */ +export type ActiveThreadListEntry = Pick< + OrchestrationV2ThreadShell, + | "id" + | "projectId" + | "title" + | "lineage" + | "settledOverride" + | "settledAt" + | "snoozedUntil" + | "updatedAt" +>; + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -567,6 +580,10 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Every active thread, or one project's. */ + readonly listActiveThreads: ( + projectId?: ProjectId, + ) => Effect.Effect, ProjectionStoreV2Error>; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -1150,6 +1167,21 @@ const encodeTimelineSources = Schema.encodeEffect( Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.NullOr(Schema.String) })), ), ); +const shellIndexLineage = (row: { + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly side_chat: number | null; +}): OrchestrationV2ThreadShell["lineage"] => ({ + parentThreadId: row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), +}); + const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); // Empty filters still scan SQLite rows; short-circuit them, including turnItemTypes: []. @@ -8770,16 +8802,7 @@ export const layer: Layer.Layer = return rows.map((row): ActiveShellIndexEntry => ({ id: ThreadId.make(row.thread_id), projectId: ProjectId.make(row.project_id), - lineage: { - parentThreadId: - row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), - relationshipToParent: - row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" - ? row.relationship_to_parent - : null, - rootThreadId: ThreadId.make(row.root_thread_id), - ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), - }, + lineage: shellIndexLineage(row), settledOverride: row.settled_override === "settled" || row.settled_override === "active" ? row.settled_override @@ -8792,6 +8815,53 @@ export const layer: Layer.Layer = })); }, Effect.mapError(shellSnapshotReadError)); + const listActiveThreads = Effect.fn("ProjectionStore.listActiveThreads")(function* ( + projectId?: ProjectId, + ) { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly title: string; + readonly updated_at: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly side_chat: number | null; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly snoozed_until: string | null; + }>` + SELECT + t.thread_id, + t.project_id, + t.title, + t.updated_at, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.lineage.sideChat') AS side_chat, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND t.archived_at IS NULL${projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}`} + `; + return rows.map((row): ActiveThreadListEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + title: row.title, + lineage: shellIndexLineage(row), + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + updatedAt: DateTime.makeUnsafe(row.updated_at), + })); + }, Effect.mapError(shellSnapshotReadError)); + const getThreadLinkMetadata = Effect.fn("ProjectionStore.getThreadLinkMetadata")(function* ( threadIds: ReadonlyArray, ) { @@ -8981,6 +9051,7 @@ export const layer: Layer.Layer = listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), countThreads: readOnReader(countThreads), listActiveShellIndex: readOnReader(listActiveShellIndex), + listActiveThreads: readOnReader(listActiveThreads), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 3ede421ad72ab..c3d1096cb3c93 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -218,6 +218,7 @@ it.effect( listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), countThreads: () => Effect.die("unused countThreads"), listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), + listActiveThreads: () => Effect.die("unused listActiveThreads"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7788baca062fd..482674b772d43 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -3,6 +3,7 @@ import type { OrchestrationV2SearchThreadResult, } from "@t3tools/contracts"; import type { + ActiveThreadListEntry, ProjectionRecordField, ProjectionRecordFilter, ProjectionRecords, @@ -30,7 +31,6 @@ import { type OrchestrationV2ThreadShellSnapshot, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadTranscript, - type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, ProjectId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -451,7 +451,7 @@ export interface ThreadManagementServiceShape { readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; - }) => Effect.Effect, ThreadManagementError>; + }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( input: ThreadManagementSendInput, ) => Effect.Effect; @@ -835,33 +835,28 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator - .getShellSnapshot({ - location: "active", - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - }) - .pipe( - Effect.mapError( - (cause) => - new ThreadManagementProjectThreadsListError({ - projectId: input.projectId, - cause, - }), - ), - Effect.map((snapshot) => - snapshot.threads - .filter((thread) => - input.includeSubagents - ? thread.lineage.sideChat !== true - : isListedThreadLineage(thread.lineage), - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || - right.id.localeCompare(left.id), - ), - ), - ); + orchestrator.listActiveThreads(input.projectId).pipe( + Effect.mapError( + (cause) => + new ThreadManagementProjectThreadsListError({ + projectId: input.projectId, + cause, + }), + ), + Effect.map((threads) => + threads + .filter((thread) => + input.includeSubagents + ? thread.lineage.sideChat !== true + : isListedThreadLineage(thread.lineage), + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ), + ), + ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 3c0e2652ea48b..84fe35c6f21f8 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -95,6 +95,7 @@ const boxThreads = (seen: Ref.Ref, linkSession: Ref.Ref) => { getProjectThreadRecords: (input) => projectionOf(input.threadId), getThreadRecords: (threadId) => projectionOf(threadId), listProjectThreads: () => linked.pipe(Effect.map((shell) => [shell] as never)), + getShellSnapshot: () => linked.pipe(Effect.map((shell) => ({ threads: [shell] }) as never)), sendToThread: (input) => Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe( Effect.as({ run: run("running"), delivery: "started" } as never), From 3c1be119b9c55e0c9d7406cf1601573bf8200c0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:15:57 -0800 Subject: [PATCH 006/690] fix(web): middle-clicking empty chat space on Linux no longer pastes into the composer (#36436) --- apps/web/src/components/ChatView.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 754edc3912db5..693030ae23c33 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,7 +123,15 @@ import { orchestrationV2WorktreePending } from "@t3tools/shared/orchestrationV2T import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; -import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + type MouseEvent as ReactMouseEvent, + type SetStateAction, + use, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { BackgroundThreadContext } from "./chat/backgroundThread"; import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; @@ -3318,6 +3326,7 @@ export default function ChatView(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} data-chat-column inert={rightPanelMaximized} + onMouseUp={preventMiddleClickPasteOutsideEditable} > {embedded ? null : (
Date: Fri, 13 Nov 2026 15:18:57 -0800 Subject: [PATCH 007/690] fix(web): closing Settings no longer leaves it as a back/forward stop (#36437) --- .../sidebar/mainAppLocation.browser.test.tsx | 21 +++++++++++++++++++ .../src/components/sidebar/mainAppLocation.ts | 21 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx index 4c5195534582f..24fb13f0f9acf 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx +++ b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx @@ -74,4 +74,25 @@ describe("main app return location", () => { sidebarBack: THREAD_PATH, }); }); + + it("leaves no stop on Settings for history back after it closes", async () => { + const otherThreadPath = "/environment-one/thread-two"; + await using screen = await render(, { router: THREAD_PATH }); + const router = screen.router!; + using _tracking = { [Symbol.dispose]: startMainAppLocationTracking(router) }; + + await settled(router, router.navigate({ href: otherThreadPath })); + await settled(router, router.navigate({ href: "/settings/general" })); + await userEvent.click(screen.getByRole("button", { name: "Close modal" })); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(otherThreadPath)); + + router.history.forward(); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(otherThreadPath)); + router.history.back(); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(THREAD_PATH)); + router.history.forward(); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(otherThreadPath)); + router.history.forward(); + await vi.waitFor(() => expect(router.state.location.pathname).toBe(otherThreadPath)); + }); }); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 049ecf3035b42..7cc8db53a9189 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,4 +1,4 @@ -import { useNavigate, type AnyRouter } from "@tanstack/react-router"; +import { useNavigate, useRouter, type AnyRouter } from "@tanstack/react-router"; import { GUEST_PAGE_PATH } from "../../guestPage"; import { withoutAssistantCitationHash } from "../../lib/assistantCitationNavigation"; @@ -20,6 +20,7 @@ const SHELL_LESS_PATHS = new Set(["/pair", "/connect", "/connect-agent", GUEST_P let mainAppHref: string | null = null; let appModalReturnHref: string | null = null; +let appModalReturnHistoryIndex: number | null = null; let appModalReturnThreadTarget: ThreadRouteTarget | null = null; /** Remembers where Settings, Usage, and the utility pages lead back to. */ @@ -29,6 +30,9 @@ export function startMainAppLocationTracking(router: AnyRouter): () => void { if (isAppModalPage(pathname) || SHELL_LESS_PATHS.has(pathname)) return; const href = withoutAssistantCitationHash(router.state.location.href); appModalReturnHref = href; + // Only an entry that is exactly the return location can be stepped back to. + appModalReturnHistoryIndex = + href === router.state.location.href ? router.state.location.state.__TSR_index : null; appModalReturnThreadTarget = resolveThreadRouteTarget( router.state.matches.at(-1)?.params ?? {}, ); @@ -51,5 +55,18 @@ export function useNavigateToMainApp() { /** Leaves Settings or Usage for the page that opened it. */ export function useCloseAppModal() { const navigate = useNavigate(); - return () => navigate({ href: appModalReturnHref ?? mainAppHref ?? "/" }); + const router = useRouter(); + return () => { + const href = appModalReturnHref ?? mainAppHref ?? "/"; + const returnIndex = appModalReturnHistoryIndex ?? 0; + const steps = router.state.location.state.__TSR_index - returnIndex; + if (returnIndex === 0 || steps <= 0) return navigate({ href }); + // Go back to the entry before the return page and push it again: only a push drops the + // modal's entries from forward history, and reusing the old entry avoids a duplicate stop. + const unsubscribe = router.history.subscribe(() => { + unsubscribe(); + void navigate({ href }); + }); + router.history.go(-(steps + 1)); + }; } From eb14ddb79924a6cbcb6c26d1b97c9816c9732c55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:36:52 -0800 Subject: [PATCH 008/690] fix(opencode): OpenCode 1.x threads report context-window usage (#36430) --- .../src/server/OpenCodeRuntime.ts | 49 ++++++++++++------- .../src/server/adapter.test.ts | 17 +++++++ .../provider-opencode/src/server/adapter.ts | 40 +++++++++++++++ 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 88ff243eccd47..d3e791bce9088 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -267,24 +267,23 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } -export type OpenCodeVariantCache = Map | undefined>; +export interface OpenCodeCatalogModel { + readonly variants: ReadonlySet; + readonly contextWindow: number | undefined; +} -export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( +export type OpenCodeVariantCache = Map; + +/** The model's catalog entry; a failed read returns undefined and is not cached. */ +export const readOpenCodeCatalogModel = Effect.fnUntraced(function* ( client: OpencodeClient, - model: ParsedOpenCodeModelSlug | undefined, - variant: string | undefined, + model: ParsedOpenCodeModelSlug, abortSignal?: AbortSignal, cache?: OpenCodeVariantCache, ) { - if (!variant || !model) return variant; - const key = `${model.providerID}/${model.modelID}`; - const cached = cache?.get(key); - if (cache?.has(key)) { - return cached !== undefined && !cached.has(variant) ? undefined : variant; - } + if (cache?.has(key)) return cache.get(key); - // Only a confirmed catalog mismatch can invalidate a persisted selection. const response = yield* runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, @@ -293,16 +292,30 @@ export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => undefined), ); - if (response?.data === undefined) return variant; + if (response?.data === undefined) return undefined; const catalogModel = response.data.all.find((provider) => provider.id === model.providerID) ?.models[model.modelID]; - const variants = catalogModel ? new Set(Object.keys(catalogModel.variants ?? {})) : undefined; - if (cache !== undefined) { - // Keep only this model's names; provider.list can return several megabytes. - cache.set(key, variants); - } - return variants !== undefined && !variants.has(variant) ? undefined : variant; + const entry: OpenCodeCatalogModel | undefined = catalogModel && { + variants: new Set(Object.keys(catalogModel.variants ?? {})), + contextWindow: catalogModel.limit?.input ?? catalogModel.limit?.context, + }; + // Keep only what this model needs; provider.list can return several megabytes. + cache?.set(key, entry); + return entry; +}); + +export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( + client: OpencodeClient, + model: ParsedOpenCodeModelSlug | undefined, + variant: string | undefined, + abortSignal?: AbortSignal, + cache?: OpenCodeVariantCache, +) { + if (!variant || !model) return variant; + // Only a confirmed catalog mismatch can invalidate a persisted selection. + const entry = yield* readOpenCodeCatalogModel(client, model, abortSignal, cache); + return entry !== undefined && !entry.variants.has(variant) ? undefined : variant; }); export interface OpenCodeSkill { diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 47cedeefc1c9e..c1c89b2b96263 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -775,6 +775,18 @@ describe("OpenCodeAdapterV2", () => { let promptId = ""; const harness = yield* makeOpenCodeRuntimeHarness(`usage-${ending}`, "root", { event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + provider: { + list: async () => ({ + data: { + all: [ + { + id: "anthropic", + models: { "claude-sonnet": { limit: { context: 200_000, output: 8_000 } } }, + }, + ], + }, + }), + }, session: { create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), promptAsync: async (input: { messageID: string }) => { @@ -895,6 +907,11 @@ describe("OpenCodeAdapterV2", () => { hasSubagents: false, }, ); + // The thread's context window reads tokenUsage: the last step's context fill. + if (ending === "completed") { + assert.equal(completed?.providerTurn.tokenUsage?.usedTokens, 22); + assert.equal(completed?.providerTurn.tokenUsage?.maxTokens, 200_000); + } }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer))), ); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b017a8861f408..4d265ab9dc460 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -226,6 +226,7 @@ interface OpenCodeTurnTokenUsageAccumulator { reasoningTokens: number; hasSubagents: boolean; complete: boolean; + lastStep: OpenCodeStepUsage | null; } function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { @@ -241,6 +242,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula reasoningTokens: 0, hasSubagents: false, complete: true, + lastStep: null, }; } @@ -255,6 +257,29 @@ function accumulateOpenCodeStepUsage( accumulator.cacheCreationTokens += part.tokens.cache.write; accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; accumulator.reasoningTokens += part.tokens.reasoning; + accumulator.lastStep = part; +} + +// The last step's prompt plus output is the context the next request starts from. +function openCodeContextUsage( + lastStep: OpenCodeStepUsage | null, + contextWindow: number | undefined, + completedAt: DateTime.Utc, +): Pick { + if (lastStep === null) return {}; + const { tokens } = lastStep; + const inputTokens = tokens.input + tokens.cache.read + tokens.cache.write; + return { + tokenUsage: { + usedTokens: inputTokens + tokens.output, + maxTokens: contextWindow ?? null, + inputTokens, + cachedInputTokens: tokens.cache.read, + outputTokens: tokens.output, + reasoningOutputTokens: tokens.reasoning, + updatedAt: DateTime.formatIso(completedAt), + }, + }; } interface ActiveOpenCodeTurn { @@ -1179,6 +1204,11 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function reasoningTokens: turn.usage.reasoningTokens, hasSubagents: turn.usage.hasSubagents, }, + ...openCodeContextUsage( + turn.usage.lastStep, + variantCache.get(turn.modelSelection.model.trim())?.contextWindow, + completedAt, + ), }), }; Object.assign(turn.providerTurn, providerTurn); @@ -2094,6 +2124,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled"); } } + // Prompts read the catalog only for variants; a completed turn needs its context window. + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model); + if (status === "completed" && turn.usage.lastStep !== null && parsedModel !== null) { + yield* OpenCodeRuntime.readOpenCodeCatalogModel( + client, + parsedModel, + undefined, + variantCache, + ); + } yield* emitProviderTurn(state, turn, status, completedAt); const threadDisposition = terminal?.threadDisposition ?? "reusable"; yield* updateProviderThread(state, { From 2d689687f57acafe1b85cbc9d82583ea66d2d0fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:44:35 -0800 Subject: [PATCH 009/690] fix(web): bare @ in the composer lists workspace files (#36435) --- .../src/state/queries.filesystem.test.ts | 18 +++++++++++++++++- apps/mobile/src/state/queries.ts | 2 +- apps/mobile/src/state/queryTargets.ts | 4 ++-- .../components/chat/useComposerMenuItems.ts | 2 +- apps/web/src/state/queries.filesystem.test.ts | 1 + apps/web/src/state/queries.ts | 2 +- 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/state/queries.filesystem.test.ts b/apps/mobile/src/state/queries.filesystem.test.ts index 8737caccf14bd..46d4fecc9e4d6 100644 --- a/apps/mobile/src/state/queries.filesystem.test.ts +++ b/apps/mobile/src/state/queries.filesystem.test.ts @@ -7,6 +7,7 @@ const state = vi.hoisted(() => ({ phase: "connected" as "connected" | "offline", sessionAtom: {}, searchAtom: {}, + searchRequests: vi.fn(), })); vi.mock("react", async (importOriginal) => ({ @@ -27,7 +28,12 @@ vi.mock("./presentation", () => ({ })); vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); vi.mock("./projects", () => ({ - projectEnvironment: { searchEntries: () => state.searchAtom }, + projectEnvironment: { + searchEntries: (target: unknown) => { + state.searchRequests(target); + return state.searchAtom; + }, + }, })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); @@ -59,6 +65,7 @@ beforeEach(() => { state.session = null; state.sessionError = null; state.phase = "connected"; + state.searchRequests.mockClear(); }); it("keeps a file search pending until its grant loads, then shows matches", () => { @@ -74,3 +81,12 @@ it("keeps a file search pending until its grant loads, then shows matches", () = isPending: false, }); }); + +it("searches workspace entries for a bare @", () => { + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + useComposerPathSearch({ ...target, query: "" }); + expect(state.searchRequests).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { cwd: target.cwd, query: "", limit: 20 }, + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index a4cdbc6244204..38890d7d57506 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -426,7 +426,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { const searchTarget = debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null ? { environmentId: debouncedTarget.environmentId, input: { diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts index a52da3fc13483..69237d663826a 100644 --- a/apps/mobile/src/state/queryTargets.ts +++ b/apps/mobile/src/state/queryTargets.ts @@ -8,8 +8,8 @@ export interface CheckpointDiffTarget { readonly ignoreWhitespace: boolean; } -export function normalizeComposerPathSearchQuery(query: string | null): string { - return query?.trim() ?? ""; +export function normalizeComposerPathSearchQuery(query: string | null): string | null { + return query?.trim() ?? null; } export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 8090157604986..70ff7e8fd3f04 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -396,7 +396,7 @@ export function useComposerMenuItems({ return []; })(); const isLoading = - (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "path" && workspaceEntries.isPending) || ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && workspaceSkillsScanning) || (composerTriggerKind === "pull-request" && diff --git a/apps/web/src/state/queries.filesystem.test.ts b/apps/web/src/state/queries.filesystem.test.ts index 64a307bb2fd70..0e604c68f5f84 100644 --- a/apps/web/src/state/queries.filesystem.test.ts +++ b/apps/web/src/state/queries.filesystem.test.ts @@ -100,6 +100,7 @@ const contentTarget = { }; it.each([ + { query: "", expected: "" }, { query: "src", expected: "src" }, { query: "a".repeat(256), expected: "a".repeat(256) }, { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 0461f3ac2b02a..aa97d5f17aa66 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -347,7 +347,7 @@ export function useProjectPathSearch( } export function useComposerPathSearch(target: ComposerPathSearchTarget) { - return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT, { allowEmptyQuery: true }); } interface ProjectContentSearchTarget { From 6b47939e606b17fc6fab278eec3fcbde65995514 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:46:05 -0800 Subject: [PATCH 010/690] fix(mobile): thread list project filter lists recently active projects first (#36433) --- .../src/features/home/HomeRouteScreen.tsx | 17 ++++++++++++++--- apps/mobile/src/features/home/HomeScreen.tsx | 10 +++++++++- .../threads/ThreadNavigationSidebar.tsx | 15 +++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index cc66e114d21be..6fdaa28914921 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,11 @@ import { useAtomValueWhileVisible, useHomeRouteVisible, } from "./home-route-visibility"; -import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "./homeThreadList"; +import { + buildHomeProjectScopes, + resolveHomeProjectFilterScope, + sortHomeProjectScopes, +} from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; @@ -138,11 +142,18 @@ export function HomeRouteScreen() { ); const projectFilterOptions = useMemo( () => - pickableProjectGroups(projectScopes).map((scope) => ({ + pickableProjectGroups( + sortHomeProjectScopes({ + scopes: projectScopes, + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })), - [projectScopes], + [projectScopes, threads], ); const selectedProjectKey = resolveHomeProjectFilterScope(projectScopes, projects, listOptions)?.key ?? null; diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index af25be18bb46f..8110cca26793d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -92,6 +92,7 @@ import { buildHomeProjectScopes, findHomeProjectScope, type HomeProjectSortOrder, + sortHomeProjectScopes, } from "./homeThreadList"; import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; @@ -678,7 +679,14 @@ export function HomeScreen(props: HomeScreenProps) { ({ + projects={pickableProjectGroups( + sortHomeProjectScopes({ + scopes: projectScopes, + threads: props.threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + ).map((scope) => ({ key: scope.key, label: scope.title, }))} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index fb0db83a4dab7..6aa576f984fce 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -45,7 +45,11 @@ import { requestThreadMessageJump } from "./threadMessageJump"; import { useThreadListSearchMatches } from "./useThreadListSearchMatches"; import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenuItems } from "../home/home-list-filter-menu"; -import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "../home/homeThreadList"; +import { + buildHomeProjectScopes, + resolveHomeProjectFilterScope, + sortHomeProjectScopes, +} from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -178,7 +182,14 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { }), [projects, options.selectedEnvironmentId, options.projectGroupingMode], ); - const projectFilterOptions = pickableProjectGroups(projectScopes).map((scope) => ({ + const projectFilterOptions = pickableProjectGroups( + sortHomeProjectScopes({ + scopes: projectScopes, + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })); From 2d80cb6bc75fe85b187d92c4322099b17e7f912e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:54:32 -0800 Subject: [PATCH 011/690] fix(clients): live working timers count on the host's clock (#36420) --- .../features/threads/ThreadDetailScreen.tsx | 6 ++- .../threads/floating-working-control.tsx | 19 +++++++-- .../threads/floating-working-status.ts | 3 +- .../features/threads/thread-list-v2-items.tsx | 12 +++++- apps/server/src/ws.ts | 1 + apps/web/src/components/ChatView.logic.ts | 7 +++- apps/web/src/components/ChatView.tsx | 1 + .../chat/TimelineWorkGroupRows.logic.ts | 18 ++++----- .../components/chat/TimelineWorkGroupRows.tsx | 3 +- .../components/chat/useLocalDispatchState.ts | 4 +- .../components/sidebar/SidebarThreadRow.tsx | 21 +++++++--- packages/client-runtime/src/clock.test.ts | 37 +++++++++++++++++- packages/client-runtime/src/clock.ts | 39 +++++++++++++++++++ .../client-runtime/src/rpc/session.test.ts | 24 ++++++++++++ packages/client-runtime/src/rpc/session.ts | 13 +++++++ packages/client-runtime/src/state/server.ts | 4 +- packages/contracts/src/server.ts | 6 +++ 17 files changed, 190 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b36e408c2a5d1..a3e69f65e0827 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -829,7 +829,11 @@ function ThreadDetailBody( return { kind: "compacting" }; } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { - return { kind: "working", startedAt: props.activeWorkStartedAt }; + return { + kind: "working", + environmentId: props.environmentId, + startedAt: props.activeWorkStartedAt, + }; } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a41fc2b3de39c..55028a58b7df2 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,4 +1,6 @@ +import { toServerClock } from "@t3tools/client-runtime/clock"; import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; +import type { EnvironmentId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; import { type ReactNode, useEffect, useRef, useState } from "react"; @@ -597,7 +599,12 @@ function FloatingStatusLabel(props: { ); } return ( - + ); } @@ -638,17 +645,21 @@ function StatusLabelRow(props: { } function WorkingDuration(props: { + readonly environmentId: EnvironmentId; readonly startedAt: string; readonly onLayout: (event: LayoutChangeEvent) => void; }) { return ( - + ); } -function WorkingTimer(props: { readonly startedAt: string }) { +function WorkingTimer(props: { + readonly environmentId: EnvironmentId; + readonly startedAt: string; +}) { const nowMs = useVisibleSecondClock(true); return ( - Working {formatWorkingDuration(props.startedAt, nowMs)} + Working {formatWorkingDuration(props.startedAt, toServerClock(props.environmentId, nowMs))} ); } diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index e3c137ebfd259..4152ae44227e1 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -1,4 +1,5 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface FloatingStatusAction { readonly label: string; @@ -8,7 +9,7 @@ export interface FloatingStatusAction { } export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "working"; readonly environmentId: EnvironmentId; readonly startedAt: string } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } | { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d724d8056e95e..b6bf0c98e349c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,7 +25,12 @@ import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; -import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type EnvironmentMachineKind, +} from "@t3tools/contracts"; +import { toServerClock } from "@t3tools/client-runtime/clock"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; @@ -153,6 +158,7 @@ const TABULAR_DURATION_STYLE = { } as const; function ThreadListV2Duration(props: { + readonly environmentId: EnvironmentId; readonly startedAt: string | null; readonly leadingSpace: boolean; }) { @@ -163,7 +169,7 @@ function ThreadListV2Duration(props: { <> {props.leadingSpace ? " " : null} - {formatWorkingDurationLabel(nowMs - startedAtMs)} + {formatWorkingDurationLabel(toServerClock(props.environmentId, nowMs) - startedAtMs)} ); @@ -1254,11 +1260,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4d9a0f9bd410e..996383c4e95b4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1885,6 +1885,7 @@ export const layerWsRpc = ( }), newProjectsRoot: managedFolders.namedProjectsRoot, ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), + serverTime: DateTime.formatIso(yield* DateTime.now), }; }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 94fc6a8cad279..92a456f12d308 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,3 +1,4 @@ +import { toServerClock } from "@t3tools/client-runtime/clock"; import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; import { deriveLatestThreadRun, @@ -979,15 +980,19 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, options?: { + environmentId?: EnvironmentId; preparingWorktree?: boolean; latestUserMessageId?: ChatMessage["id"] | null; submissionIntent?: ComposerSubmissionIntent; }, ): LocalDispatchSnapshot { + const environmentId = options?.environmentId ?? activeThread?.environmentId; const latestRun = activeThread?.latestRun ?? null; const runtime = activeThread?.runtime ?? null; return { - startedAt: new Date().toISOString(), + startedAt: new Date( + environmentId === undefined ? Date.now() : toServerClock(environmentId, Date.now()), + ).toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: options?.latestUserMessageId ?? null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 693030ae23c33..b161342f231d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1441,6 +1441,7 @@ export default function ChatView(props: ChatViewProps) { isSendBusy, backgroundSubmissionPending, } = useLocalDispatchState({ + environmentId, activeThread, activeLatestRun, latestUserMessageId: diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts index e4a8fe4a583c9..bb53e5fa896d1 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts @@ -1,20 +1,20 @@ +import { toServerClock } from "@t3tools/client-runtime/clock"; +import type { EnvironmentId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -function formatWorkingTimer(startIso: string, endIso: string): string | null { +export function formatWorkingTimerNow(startIso: string, environmentId: EnvironmentId): string { const startedAtMs = Date.parse(startIso); - const endedAtMs = Date.parse(endIso); - if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { - return null; + if (!Number.isFinite(startedAtMs)) { + return "0s"; } - const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + const elapsedSeconds = Math.max( + 0, + Math.floor((toServerClock(environmentId, Date.now()) - startedAtMs) / 1000), + ); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } return formatDuration(elapsedSeconds * 1_000); } - -export function formatWorkingTimerNow(startIso: string): string { - return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; -} diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 33608965ef313..1784f38da4557 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -127,9 +127,10 @@ export const WorkGroupSection = memo(function WorkGroupSection({ }); function WorkingTimer({ createdAt }: { createdAt: string }) { + const { activeThreadEnvironmentId } = use(TimelineRowCtx); return ( formatWorkingTimerNow(createdAt), true)} + ref={tickTextContent(() => formatWorkingTimerNow(createdAt, activeThreadEnvironmentId), true)} className="tabular-nums" /> ); diff --git a/apps/web/src/components/chat/useLocalDispatchState.ts b/apps/web/src/components/chat/useLocalDispatchState.ts index 977028db17b9e..eee86c699d8c5 100644 --- a/apps/web/src/components/chat/useLocalDispatchState.ts +++ b/apps/web/src/components/chat/useLocalDispatchState.ts @@ -1,4 +1,4 @@ -import { type MessageId, type RuntimeRequestId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type RuntimeRequestId } from "@t3tools/contracts"; import { useState } from "react"; import { type ComposerSubmissionIntent } from "../../composer-logic"; import { type SessionPhase, type Thread } from "../../types"; @@ -9,6 +9,7 @@ import { } from "../ChatView.logic"; export function useLocalDispatchState(input: { + environmentId: EnvironmentId; activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; latestUserMessageId: MessageId | null; @@ -50,6 +51,7 @@ export function useLocalDispatchState(input: { } return createLocalDispatchSnapshot(input.activeThread, { ...options, + environmentId: input.environmentId, latestUserMessageId: input.latestUserMessageId, }); }); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 89ab26f51b9a0..216f6635f7088 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -26,6 +26,7 @@ import { resolveThreadProviderStack, } from "@t3tools/client-runtime/state/models"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { toServerClock } from "@t3tools/client-runtime/clock"; import { AuthOrchestrationOperateScope, type EnvironmentId, @@ -163,18 +164,20 @@ function JumpHintBadge(props: { label: string }) { const JUMP_HINT_SLOT_CLASS_NAME = "[&>:not([data-jump-hint])]:invisible [&>:not([data-jump-hint])]:absolute"; -function WorkingDuration(props: { startedAt: string | null }) { +function WorkingDuration(props: { environmentId: EnvironmentId; startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; if (Number.isNaN(startedMs)) return null; - return ; + return ; } // Self-ticking so only this span re-renders each second, not the whole row; // the shared tick lands every working row's update in the same frame. -function WorkingDurationLabel(props: { startedMs: number }) { +function WorkingDurationLabel(props: { environmentId: EnvironmentId; startedMs: number }) { const nowMs = useNowSecond(); return ( - {formatWorkingDurationLabel(nowMs - props.startedMs)} + + {formatWorkingDurationLabel(toServerClock(props.environmentId, nowMs) - props.startedMs)} + ); } @@ -1524,11 +1527,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {topStatus.label} {status === "preparing" || status === "working" ? ( - + ) : status === "waiting" ? ( - + ) : null} diff --git a/packages/client-runtime/src/clock.test.ts b/packages/client-runtime/src/clock.test.ts index ca8130a379ad4..1f45860addf7c 100644 --- a/packages/client-runtime/src/clock.test.ts +++ b/packages/client-runtime/src/clock.test.ts @@ -1,5 +1,6 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { deadlineStore, secondTick } from "./clock.ts"; +import { deadlineStore, recordServerClock, secondTick, toServerClock } from "./clock.ts"; describe("deadlineStore", () => { afterEach(() => { @@ -138,3 +139,37 @@ describe("secondTick", () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe("server clock", () => { + const HOUR = 3_600_000; + + it("counts from a host clock running ahead, minus only the reply's latency", () => { + const environmentId = EnvironmentId.make("ahead"); + recordServerClock(environmentId, { + serverTimeMs: HOUR + 1_000, + requestedAtMs: 900, + receivedAtMs: 1_050, + }); + expect(toServerClock(environmentId, 2_050)).toBe(HOUR + 2_000); + }); + + it("counts from a host clock running behind, minus only the request's latency", () => { + const environmentId = EnvironmentId.make("behind"); + recordServerClock(environmentId, { + serverTimeMs: 1_000, + requestedAtMs: HOUR + 900, + receivedAtMs: HOUR + 1_050, + }); + expect(toServerClock(environmentId, HOUR + 1_900)).toBe(2_000); + }); + + it("leaves clocks that agree within the round trip unshifted", () => { + const environmentId = EnvironmentId.make("synced"); + recordServerClock(environmentId, { + serverTimeMs: 5_000, + requestedAtMs: 1_000, + receivedAtMs: 9_000, + }); + expect(toServerClock(environmentId, 12_345)).toBe(12_345); + }); +}); diff --git a/packages/client-runtime/src/clock.ts b/packages/client-runtime/src/clock.ts index d0e9db7259530..f8f1290730084 100644 --- a/packages/client-runtime/src/clock.ts +++ b/packages/client-runtime/src/clock.ts @@ -1,5 +1,7 @@ // @effect-diagnostics globalTimers:off globalDate:off - Render clocks for React hooks, outside an Effect runtime. +import type { EnvironmentId } from "@t3tools/contracts"; + /** * A `useSyncExternalStore`-shaped clock. Snapshots stay cached between * notifications while subscribed, as `useSyncExternalStore` requires. @@ -70,6 +72,43 @@ export const minuteTick: ClockStore = makeTickStore( 60_000, ); +// How far each environment's clock runs ahead of this one. Timers read it on +// every tick, so a reconnect's new reading lands without a re-render. +const serverClockOffsets = new Map(); + +/** + * The host stamped `serverTimeMs` between the request and its reply, so the + * offset lies within that window. Keep the one nearest 0, so clocks that agree + * read as 0 instead of shifting every timer by network latency. + */ +export function recordServerClock( + environmentId: EnvironmentId, + sample: { + readonly serverTimeMs: number | undefined; + readonly requestedAtMs: number; + readonly receivedAtMs: number; + }, +): void { + const { serverTimeMs, requestedAtMs, receivedAtMs } = sample; + if (serverTimeMs === undefined || !Number.isFinite(serverTimeMs)) { + serverClockOffsets.delete(environmentId); + return; + } + serverClockOffsets.set( + environmentId, + serverTimeMs > receivedAtMs + ? serverTimeMs - receivedAtMs + : serverTimeMs < requestedAtMs + ? serverTimeMs - requestedAtMs + : 0, + ); +} + +/** Converts client epoch milliseconds to the environment's clock. */ +export function toServerClock(environmentId: EnvironmentId, clientMs: number): number { + return clientMs + (serverClockOffsets.get(environmentId) ?? 0); +} + export interface DeadlineStore extends ClockStore { /** * Re-checks `Date.now()` and notifies if the deadline has passed. For callers diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index cc1932b54ea76..b6e0908a1f1a9 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -35,6 +35,7 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { toServerClock } from "../clock.ts"; import { makeEnvironmentServerConfigState } from "../state/server.ts"; import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; @@ -1048,6 +1049,29 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("reads the host clock from each connection's config, dropping it when absent", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const connectWith = (config: unknown) => + Effect.scoped( + Effect.gen(function* () { + const socketIndex = sockets.length; + const session = yield* factory.connect(PREPARED); + const ready = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets, socketIndex); + socket.open(); + yield* completeInitialConfig(socket, config); + yield* Fiber.join(ready); + }), + ); + + yield* connectWith({ ...ENCODED_SERVER_CONFIG, serverTime: "1970-01-01T01:00:00.000Z" }); + expect(toServerClock(PREPARED.environmentId, 0)).toBe(3_600_000); + yield* connectWith(ENCODED_SERVER_CONFIG); + expect(toServerClock(PREPARED.environmentId, 0)).toBe(0); + }), + ); + it.effect("reaches ready when a newer server sends unknown config members", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 66c7d8b9908bd..8be598dc178ab 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -9,6 +9,7 @@ import { SOCKET_FRAME_PARTS_QUERY_PARAM, } from "@t3tools/shared/socketFrameParts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -46,6 +47,7 @@ import { withoutEnvironmentThemes, } from "../state/serverConfigProjection.ts"; import { environmentMismatchError } from "../connection/errors.ts"; +import { recordServerClock } from "../clock.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; @@ -295,6 +297,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( reason: "remote-unavailable", detail: `${connection.label} config subscription ended.`, }); + const configRequestedAtMs = yield* Clock.currentTimeMillis; const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( onDemandSnapshots(serverConfigInput), ).pipe( @@ -332,6 +335,16 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( yield* PubSub.publish(serverConfigUpdates, buffered.value); } if (event.type === "snapshot") { + // Later snapshots may repeat the first one's serverTime. Record + // before readiness so the first timers already use its clock. + if (!(yield* Deferred.isDone(initialConfigDeferred))) { + const { serverTime } = event.config; + recordServerClock(connection.environmentId, { + serverTimeMs: serverTime === undefined ? undefined : Date.parse(serverTime), + requestedAtMs: configRequestedAtMs, + receivedAtMs: yield* Clock.currentTimeMillis, + }); + } yield* Deferred.succeed(initialConfigDeferred, event.config); } }), diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 55fd0c928280d..f16861f5333fd 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -489,11 +489,13 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { - // A cached stall would read as a fresh freeze on the next launch. + // A cached stall would read as a fresh freeze on the next launch, and + // serverTime changes every connect, which would rewrite the cache each time. const { environmentThemes: _themes, usageLimitSources: _sources, lastEventLoopStall: _stall, + serverTime: _serverTime, ...cacheable } = config; if (lastPersisted !== undefined && sameCacheValue(lastPersisted, cacheable)) { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e8bcda3a76f5d..706f920770751 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -726,6 +726,12 @@ export const ServerConfig = Schema.Struct({ * answering. Clients do not cache it, since it describes the host at connect. */ lastEventLoopStall: Schema.optionalKey(ServerEventLoopStall), + /** + * The host's clock when it built the config, so clients can count live + * elapsed times against host timestamps despite clock skew. Only a + * subscription's first snapshot is fresh; later snapshots may repeat it. + */ + serverTime: Schema.optionalKey(IsoDateTime), /** * Never sent in a config snapshot: the theme stream emits the current set * before any change, so a snapshot carrying it too would hand every From 770028f965e0639e4eefb639f25f2b211d5ccdfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:56:48 -0800 Subject: [PATCH 012/690] perf(relay): lighter Postgres load and PS-160 cluster (#36442) Co-authored-by: Claude Opus 5.5 --- .../AgentActivityPublisher.test.ts | 67 +++++++++++++++++++ .../agentActivity/AgentActivityPublisher.ts | 21 +++--- .../src/agentActivity/DeliveryAttempts.ts | 2 +- infra/relay/src/db.ts | 2 +- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 2f4ed3ff02272..679c410470397 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -207,6 +207,73 @@ describe("AgentActivityPublisher", () => { ); }); + it.effect.each([ + { + name: "skips the activity query without a running iOS Live Activity", + activityToken: null, + listCalls: 0, + }, + { + name: "aggregates activity for a running iOS Live Activity", + activityToken: "activity-token", + listCalls: 1, + }, + ])("$name", ({ activityToken, listCalls }) => { + const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; + const ios = { ...target("ios"), activity_push_token: activityToken }; + let listed = 0; + const hasAggregate: boolean[] = []; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + yield* publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state, + }); + expect(listed).toBe(listCalls); + expect(hasAggregate).toEqual([listCalls > 0]); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + listForUser: () => + Effect.sync(() => { + listed += 1; + return [state]; + }), + }), + ), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + hasAggregate.push(input.aggregate !== null); + return null; + }), + }), + ), + Layer.succeed(FcmDeliveries.FcmDeliveries, { + enqueue: () => Effect.succeed(null), + process: () => Effect.void, + }), + ), + ), + ), + ), + ); + }); + it.effect("replays the latest aggregate when a Live Activity token registers", () => { const registeredTarget: LiveActivities.TargetRow = { ...target("device-1"), diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index d58a91c7f4eae..6533d021f0f29 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -63,17 +63,18 @@ const make = Effect.gen(function* () { readonly state: RelayAgentActivityState | null; readonly nowMs: number; }) { - const activeStates = input.deliveryUser.liveActivitiesEnabled - ? yield* rows.listForUser({ userId: input.deliveryUser.userId }) - : []; - const liveActivityAggregate = input.deliveryUser.liveActivitiesEnabled - ? makeAggregateState({ - activeStates, - terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, - nowMs: input.nowMs, - }) - : null; const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); + // Only iOS targets with a running Live Activity read the aggregate, and few users have one, + // so skip the per-user activity query otherwise. + const liveActivityAggregate = + input.deliveryUser.liveActivitiesEnabled && + targets.some((target) => target.platform !== "android" && target.activity_push_token) + ? makeAggregateState({ + activeStates: yield* rows.listForUser({ userId: input.deliveryUser.userId }), + terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, + nowMs: input.nowMs, + }) + : null; return yield* Effect.forEach( targets, Effect.fnUntraced(function* (target) { diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index fef33de106d55..d638712d380c6 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -68,7 +68,7 @@ export class DeliveryAttempts extends Context.Service< >()("t3code-relay/agentActivity/DeliveryAttempts") {} const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; -export const DELIVERY_ATTEMPT_RETENTION_DAYS = 7; +export const DELIVERY_ATTEMPT_RETENTION_DAYS = 1; export const DELIVERY_ATTEMPT_PRUNE_BATCH_SIZE = 5_000; // Caps each cron run so draining the pre-retention backlog cannot monopolize the database. export const DELIVERY_ATTEMPT_PRUNE_MAX_BATCHES = 4; diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index 0b7fba38bffa8..52133544d4641 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -49,7 +49,7 @@ export const PlanetscaleDatabase = Effect.gen(function* () { ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { name: "t3coderelay", region: { slug: "us-west" }, - clusterSize: "PS_80", + clusterSize: "PS_160", migrations: { dir: schema.out, table: "relay_migrations" }, replicas: 2, }).pipe(RemovalPolicy.retain()) From a6d91409729be6ba854b3f16adedc08847e3b24c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 15:57:32 -0800 Subject: [PATCH 013/690] fix(mobile): mobile:install --session works on pooled devices (#36447) --- .agents/skills/test-t3-mobile/SKILL.md | 13 +++++--- scripts/mobile-native-client.ts | 41 ++++++++++++++++++-------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 306038007e9a0..a11eee4e3f4eb 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -43,10 +43,15 @@ prints one JSON line; on failure it names the next command. Mobile verification includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, -cloud, or remote host), run `vp run mobile:install --session ` -from the directory you opened the session in. It installs the cached build into -that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also -opens it against Metro) and never builds. +cloud, or remote host), run `vp run mobile:install --session +--config ` from the directory you opened the session in, passing the +`--config` file from `device_open`'s `targetArgs`. Without it, a device pool's +gateway refuses the install, wrongly saying agent-device is too old. It installs +the cached build into that session (`--abi arm64-v8a` for an ARM Android device; +pool Android emulators are x86_64, the default; `--metro ` also opens it +against Metro). Without a cached build for this checkout it builds first, which can +outlast the lease, so run `vp run mobile:install --prepare` (with +the same `--abi`) before you open the device. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 9e6c38f7bd175..e28bf957e3bce 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -175,9 +175,14 @@ const command = Effect.fn("nativeClient.command")(function* ( args: string[], inherit = false, cwd?: string, + withoutEnvironment: ReadonlyArray = [], ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = yield* HostProcess.Environment; + const environment = Object.fromEntries( + Object.entries(yield* HostProcess.Environment).filter( + ([name]) => !withoutEnvironment.includes(name), + ), + ); const log = inherit ? yield* BuildLog : null; const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args); const child = yield* spawner.spawn( @@ -1098,6 +1103,7 @@ const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function session: string, abi: string, metro: string | undefined, + config: string | undefined, ) { const started = yield* Clock.currentTimeMillis; const path = yield* Path.Path; @@ -1108,6 +1114,13 @@ const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function ); // agent-device scopes session ownership to the workspace (working directory) it runs in. const workspace = yield* HostProcess.WorkingDirectory; + // A session behind a gateway (a device pool) is reachable only with the config that opened it. + const target = ["--session", session, ...(config ? ["--config", config] : [])]; + // Daemon variables in the shell (a device-pool login) override the config and authenticate + // as someone who does not hold this lease; T3 Code's own launcher drops them the same way. + const agentDeviceEnvironment = config + ? ["AGENT_DEVICE_DAEMON_BASE_URL", "AGENT_DEVICE_DAEMON_AUTH_TOKEN", "AGENT_DEVICE_CONFIG"] + : []; const deviceAbi = platform === "android" ? abi : null; const task = deviceAbi ? `native-client:android-${deviceAbi}` : `native-client:${platform}`; const current = yield* fingerprint(platform); @@ -1118,17 +1131,10 @@ const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function const downloadOnDevice = source ? command( node, - [ - agentDevice, - "install-from-source", - source.url, - "--header", - source.header, - "--session", - session, - ], + [agentDevice, "install-from-source", source.url, "--header", source.header, ...target], true, workspace, + agentDeviceEnvironment, ).pipe( Effect.as(true), Effect.catch((error) => @@ -1150,9 +1156,10 @@ const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function yield* Console.error(`Installing into agent-device session ${session}...`); yield* command( node, - [agentDevice, "install", bundleId, local.file, "--session", session], + [agentDevice, "install", bundleId, local.file, ...target], true, workspace, + agentDeviceEnvironment, ); return local; }); @@ -1165,11 +1172,11 @@ const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function "open", bundleId, `t3code-dev://expo-development-client/?url=${url}&disableOnboarding=1&disableFab=1&disableAutoLaunch=1`, - "--session", - session, + ...target, ], true, workspace, + agentDeviceEnvironment, ); } yield* reportTelemetry( @@ -1213,6 +1220,12 @@ const main = Command.make( Flag.withDescription("With --session, open the dev client against this Metro URL."), Flag.optional, ), + config: Flag.String("config").pipe( + Flag.withDescription( + "With --session, the agent-device config the session was opened with (T3 Code's device_open returns it in targetArgs). Needed for a device pool behind a gateway.", + ), + Flag.optional, + ), prepare: Flag.Boolean("prepare").pipe( Flag.withDescription( "Fetch or build the client for this checkout without a device (Android: for --abi). Run it before leasing a device: a build can outlast the lease.", @@ -1233,6 +1246,7 @@ const main = Command.make( session, abi: sessionAbi, metro, + config, prepare, expectFingerprint, }) { @@ -1280,6 +1294,7 @@ const main = Command.make( session.value, sessionAbi, Option.getOrUndefined(metro), + Option.getOrUndefined(config), ); } const target = yield* resolveDevice(platform, Option.getOrUndefined(requested)); From 9d8998b436f7d81295386e4f6aa7012784aa86a6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 13 Nov 2026 16:11:09 -0800 Subject: [PATCH 014/690] fix(native): KDE SnapShot no longer loses the first reply from KWin (#36218) Co-authored-by: Claude Fable 5.2 --- native/kde-snap-shot/src/main.rs | 44 +++++++++++++-------- native/kde-snap-shot/src/transport_tests.rs | 6 +-- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/native/kde-snap-shot/src/main.rs b/native/kde-snap-shot/src/main.rs index ec12567793972..2c303d408dba8 100644 --- a/native/kde-snap-shot/src/main.rs +++ b/native/kde-snap-shot/src/main.rs @@ -71,10 +71,11 @@ struct Bounds { height: u32, } -struct ScriptReply { - owner: String, - result: mpsc::SyncSender, -} +const REPLY: &str = "/com/t3tools/KdeCapture"; + +/// KWin's bus name and the channel of the script call in flight. `None` between calls. +#[derive(Default)] +struct ScriptReply(Option<(String, mpsc::SyncSender)>); #[zbus::interface(name = "com.t3tools.KdeCapture")] impl ScriptReply { fn reply( @@ -82,7 +83,12 @@ impl ScriptReply { value: &str, #[zbus(header)] header: zbus::message::Header<'_>, ) -> zbus::fdo::Result<()> { - if header.sender().map(|name| name.as_str()) != Some(self.owner.as_str()) { + let Some((owner, result)) = &self.0 else { + return Err(zbus::fdo::Error::AccessDenied( + "No script is running".into(), + )); + }; + if header.sender().map(|name| name.as_str()) != Some(owner.as_str()) { return Err(zbus::fdo::Error::AccessDenied("Not KWin".into())); } if value.len() > 64 * 1024 { @@ -90,23 +96,29 @@ impl ScriptReply { "Oversized window metadata".into(), )); } - let _ = self.result.try_send(value.to_owned()); + let _ = result.try_send(value.to_owned()); Ok(()) } } +/// Serves the reply object from the start, so zbus waits for its method-call listener. +/// A listener started after connecting can miss the first call from KWin. +fn connect(builder: Builder<'_>) -> zbus::Result { + builder + .method_timeout(DEADLINE) + .serve_at(REPLY, ScriptReply::default())? + .build() +} + /// Scripts exist only for this operation. Unload even when evaluation/callback fails. fn script(connection: &Connection, directory: &Path, body: &str) -> Result { let dbus = zbus::blocking::fdo::DBusProxy::new(connection)?; let owner = dbus.get_name_owner("org.kde.KWin".try_into()?)?.to_string(); let (send, receive) = mpsc::sync_channel(1); - connection.object_server().at( - "/com/t3tools/KdeCapture", - ScriptReply { - owner, - result: send, - }, - )?; + let reply = connection + .object_server() + .interface::<_, ScriptReply>(REPLY)?; + reply.get_mut().0 = Some((owner, send)); let path = directory.join("window.js"); let name = format!("t3-capture-{}", std::process::id()); let destination = serde_json::to_string( @@ -147,9 +159,7 @@ fn script(connection: &Connection, directory: &Path, body: &str) -> Result = scripting.call("unloadScript", &(&name,)); result })(); - connection - .object_server() - .remove::("/com/t3tools/KdeCapture")?; + reply.get_mut().0 = None; let _ = std::fs::remove_file(path); result } @@ -336,7 +346,7 @@ fn capture(connection: &Connection, directory: &Path) -> Result> fn run() -> Result<()> { let args: Vec = std::env::args().collect(); - let connection = Builder::session()?.method_timeout(DEADLINE).build()?; + let connection = connect(Builder::session()?)?; match args.get(1).map(String::as_str) { Some("check") => { check(&connection)?; diff --git a/native/kde-snap-shot/src/transport_tests.rs b/native/kde-snap-shot/src/transport_tests.rs index 83e72556c0d18..39a292e23d37c 100644 --- a/native/kde-snap-shot/src/transport_tests.rs +++ b/native/kde-snap-shot/src/transport_tests.rs @@ -183,11 +183,7 @@ impl Fixture { .unwrap() .build() .unwrap(); - let client = Builder::address(address.trim()) - .unwrap() - .method_timeout(DEADLINE) - .build() - .unwrap(); + let client = connect(Builder::address(address.trim()).unwrap()).unwrap(); Self { daemon, directory, From 6689a04af84d561c0c595103d9023b29172a0543 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:11:13 -0800 Subject: [PATCH 015/690] fix(server): t3 connect status no longer reports a publish-only link as exposed (#33967) --- apps/server/src/cli/connect.test.ts | 20 ++++++++++++++++++-- apps/server/src/cli/connect.ts | 24 ++++++++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 2e91c079f161f..2a51d059df67f 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -64,7 +64,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) (it) => { const runConnect = ( recordedPid: number, - options?: { readonly portFreed?: boolean; readonly command?: "unlink" | "status" }, + options?: { + readonly portFreed?: boolean; + readonly command?: "unlink" | "status"; + readonly linkMode?: "managed" | "publish_only"; + }, ) => Effect.gen(function* () { const requests: Array = []; @@ -105,7 +109,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) yield* fs.makeDirectory(`${baseDir}/userdata/secrets`, { recursive: true }); yield* fs.writeFileString( `${baseDir}/userdata/secrets/${CLOUD_CLI_DESIRED_LINK_SECRET}.bin`, - "managed", + options.linkMode ?? "managed", ); } @@ -179,6 +183,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) }), ); + it.effect("status does not report a publish-only link as exposed", () => + Effect.gen(function* () { + const { printed } = yield* runConnect(process.pid, { + command: "status", + linkMode: "publish_only", + }); + + assert.include(printed, "Exposure: publish-only"); + assert.notInclude(printed, "Other devices can reach this machine"); + }), + ); + it.effect("asks a running server to stop its tunnel", () => Effect.gen(function* () { const { requests, printed, warnings } = yield* runConnect(process.pid); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index f95da6326a35f..8b3a3449dff6f 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -196,6 +196,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : status.desired && status.authenticated ? "pending server startup" : "not provisioned"; + const publishOnly = status.desired && status.linkMode === "publish_only"; const nextStep = !status.authenticated ? "Run `t3 connect link` to authorize and enable T3 Connect." : status.revoked @@ -203,30 +204,37 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : !status.desired ? "Run `t3 connect link` to enable T3 Connect." : !status.linked - ? "Start T3 to provision the environment link and launch its managed tunnel." + ? publishOnly + ? "Start T3 to provision the environment link." + : "Start T3 to provision the environment link and launch its managed tunnel." : undefined; return [ "T3 Connect", - ` Exposure: ${status.desired ? "enabled" : "disabled"}`, + ` Exposure: ${publishOnly ? "publish-only" : status.desired ? "enabled" : "disabled"}`, ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, ` Environment link: ${provisioned}`, ` Relay: ${status.relayUrl ?? "not provisioned"}`, ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, ...formatRelayClientStatus(status.relayClient), - ...(!status.desired || status.linkMode !== "managed" + ...(!status.desired ? [] - : status.reachability === null + : publishOnly ? [ "", - "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.", + "This link only publishes agent activity. Remote access through T3 Connect is off, so other devices need their own route to this machine, such as LAN or Tailscale.", ] - : status.reachability.status === "reachable" + : status.reachability === null ? [ "", - `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`, + "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.", ] - : ["", formatReachabilityFailure(status.reachability)]), + : status.reachability.status === "reachable" + ? [ + "", + `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`, + ] + : ["", formatReachabilityFailure(status.reachability)]), ...(nextStep ? ["", `Next: ${nextStep}`] : []), ].join("\n"); } From 7ae21bf164065506d27fb60bf8c1e5e1eac0af1a Mon Sep 17 00:00:00 2001 From: ValeraZSD <48602572+ValeraZSD@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:12:20 +0100 Subject: [PATCH 016/690] fix(web): don't label Windows paths outside the workspace as relative (#28469) Co-authored-by: Julius Marminge --- apps/web/src/filePathDisplay.test.ts | 16 ++++++++++++++++ apps/web/src/filePathDisplay.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/filePathDisplay.test.ts diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts new file mode 100644 index 0000000000000..08295e540eb95 --- /dev/null +++ b/apps/web/src/filePathDisplay.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatWorkspaceRelativePath } from "./filePathDisplay"; + +describe("formatWorkspaceRelativePath", () => { + it("labels only relative paths, not Windows paths outside the workspace", () => { + const workspaceRoot = "C:\\Users\\mike\\dev-stuff\\t3code"; + expect( + formatWorkspaceRelativePath("C:\\Users\\mike\\.claude\\settings.json:3", workspaceRoot), + ).toBe("C:/Users/mike/.claude/settings.json:3"); + expect(formatWorkspaceRelativePath("D:/data/report.csv", workspaceRoot)).toBe( + "D:/data/report.csv", + ); + expect(formatWorkspaceRelativePath("src/app.ts", workspaceRoot)).toBe("t3code/src/app.ts"); + }); +}); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 9d59a83630a5c..b2b31365bfa30 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -46,7 +46,7 @@ export function formatWorkspaceRelativePath( } else if (pathForCompare.startsWith(workspaceWithSeparator)) { const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); displayPath = `${workspaceLabel}/${relativeSuffix}`; - } else if (!normalizedPath.startsWith("/")) { + } else if (!normalizedPath.startsWith("/") && !isWindowsAbsolutePath(normalizedPath)) { const relativePath = stripRelativePrefixes(normalizedPath); displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) ? normalizedPath From 6bce2226decf0e5d1864fecb53e3e91ce1672acc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:17:57 -0800 Subject: [PATCH 017/690] fix(server): multi-line guest messages are no longer refused or stored with CRLF (#26542) --- apps/server/src/guest/http.test.ts | 22 ++++++++++++++++++++++ apps/server/src/guest/http.ts | 9 ++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/guest/http.test.ts b/apps/server/src/guest/http.test.ts index e4c2402197b59..8452c4758f625 100644 --- a/apps/server/src/guest/http.test.ts +++ b/apps/server/src/guest/http.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { GUEST_MESSAGE_MAX_LENGTH } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { HttpServerRequest } from "effect/http"; @@ -26,3 +27,24 @@ it.effect("keeps a JSON file a guest attaches as a file, not a form field", () = ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("keeps a multi-line form message within the length limit as the guest typed it", () => + Effect.gen(function* () { + // At the limit with LF, over it once each line break is counted as CRLF. + const lines = Array.from({ length: 50 }, () => "x".repeat(79)); + const text = lines.join("\n").padEnd(GUEST_MESSAGE_MAX_LENGTH, "x"); + const form = new FormData(); + form.append("shareId", "share-1"); + form.append("text", text); + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/api/guest/messages?share=share-1", { + method: "POST", + body: form, + }), + ); + const submission = yield* readSubmission.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + assert.strictEqual(submission?.fields.text, text); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/guest/http.ts b/apps/server/src/guest/http.ts index b3e856200c845..c92dfaaa4dc5c 100644 --- a/apps/server/src/guest/http.ts +++ b/apps/server/src/guest/http.ts @@ -192,9 +192,12 @@ export const readSubmission = Effect.gen(function* () { }), ), ); - const fields = yield* decodeSubmit({ shareId: parts.shareId, text: parts.text ?? "" }).pipe( - Effect.option, - ); + const text = parts.text ?? ""; + const fields = yield* decodeSubmit({ + shareId: parts.shareId, + // Form data sends every line break as CRLF; the textarea gave the guest LF. + text: typeof text === "string" ? text.replaceAll("\r\n", "\n") : text, + }).pipe(Effect.option); if (fields._tag === "None") return null; const files = parts.file; const uploads: ReadonlyArray = Array.isArray(files) From 88d4d06ea325513d54879e956c311d21cffa7b29 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 13 Nov 2026 16:25:25 -0800 Subject: [PATCH 018/690] perf(desktop): provider checks no longer compete with server startup (#35712) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.test.ts | 18 ++- apps/desktop/src/app/DesktopApp.ts | 130 +++++++++++------- .../src/backend/DesktopBackendManager.test.ts | 36 +++++ .../src/backend/DesktopBackendManager.ts | 33 +++-- .../telemetry/DesktopTelemetryPublisher.ts | 2 + .../Drivers/ClaudeMaintenance.test.ts | 1 + apps/server/src/provider/ProviderHostLive.ts | 10 ++ .../DesktopTelemetryReceiver.ts | 6 + apps/server/src/server.ts | 12 ++ apps/server/src/serverLifecycleEvents.ts | 12 +- packages/contracts/src/resourceTelemetry.ts | 8 ++ .../provider-core/src/server/ProviderHost.ts | 2 + .../src/server/managedProvider.test.ts | 45 ++++++ .../src/server/managedProvider.ts | 11 +- 14 files changed, 259 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 10e0d1cd16c9b..aca3b4fc7fe66 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -86,6 +86,7 @@ it.effect.each([false, true])( const historyStarted = yield* Deferred.make(); const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); + const tokenRequested = yield* Deferred.make(); const events: string[] = []; const record = (event: string) => Effect.sync(() => { @@ -144,7 +145,7 @@ it.effect.each([false, true])( configure: Effect.void, } as PreviewPasskeys.PreviewPasskeys["Service"]), Effect.provideService(DesktopApplicationMenu.DesktopApplicationMenu, { - configure: Effect.void, + configure: record("menu"), }), Effect.provideService(DesktopUpdates.DesktopUpdates, { configure: Effect.void, @@ -222,7 +223,12 @@ it.effect.each([false, true])( Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, {}), Layer.mock(DesktopIpc.DesktopIpc, {}), - Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}), + Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, { + getBearerToken: record("token").pipe( + Effect.andThen(Deferred.succeed(tokenRequested, undefined)), + Effect.as("token"), + ), + }), Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}), Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), @@ -253,7 +259,13 @@ it.effect.each([false, true])( ); yield* Deferred.await(registrationStarted); yield* Deferred.await(windowStarted); - assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); + yield* Deferred.await(tokenRequested); + // The backend spawns before menus, and its token is minted without waiting for the renderer. + assert.deepEqual( + events.filter((event) => event !== "token"), + ["identity", "ready", "identity", "backend", "menu", "window"], + ); + assert.isAbove(events.indexOf("token"), events.indexOf("backend")); assert.isFalse(yield* Deferred.isDone(registrationStopped)); const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7ebeaa74a021d..0e7f949bc042c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -22,6 +22,7 @@ import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopHomeOwner from "../backend/DesktopHomeOwner.ts"; +import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; @@ -168,13 +169,12 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( }, ); -const bootstrap = Effect.gen(function* () { +// Spawns the local backend as soon as the protocol, IPC, and legacy storage that every window needs exist. +const startLocalBackend = Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const desktopWindow = yield* DesktopWindow.DesktopWindow; - const snapShot = yield* DesktopSnapShot.DesktopSnapShot; - const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -193,32 +193,13 @@ const bootstrap = Effect.gen(function* () { yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, ); - yield* snapShot.initialize; - // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has - // no cached one. Without it, the command is left to Settings. - const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; - yield* Effect.forkScoped( - (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( - Effect.flatMap((found) => - found || environment.platform === "win32" - ? installCli - : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), - ), - ), - ); - if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); - if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.createMainIfBackendReady; - } - return; + return false; } - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -237,7 +218,8 @@ const bootstrap = Effect.gen(function* () { "This T3 home is unavailable", DesktopHomeOwner.homeOwnedMessage(owner), ); - return yield* (yield* ElectronApp.ElectronApp).quit; + yield* (yield* ElectronApp.ElectronApp).quit; + return false; } const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); @@ -275,29 +257,73 @@ const bootstrap = Effect.gen(function* () { ); } - if (!(yield* Ref.get(state.quitting))) { - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { - yield* desktopWindow.showConnectingSplash; - } - yield* primaryBackend.start; - yield* logBootstrapInfo("bootstrap backend start requested"); - // WSL-only fallback can change the primary endpoint before readiness. - const config = yield* primaryBackend.currentConfig; - if ( - !environment.isDevelopment && - !settings.wslOnly && - Option.isSome(config) && - Option.isNone(config.value.preflightFailure) - ) { - yield* desktopWindow.ensureMain; + if (yield* Ref.get(state.quitting)) { + return false; + } + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } + yield* primaryBackend.start; + yield* logBootstrapInfo("bootstrap backend start requested"); + // The renderer asks for this token first; minting it at readiness saves a round trip. + yield* Effect.forkScoped( + (yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth).getBearerToken.pipe( + Effect.ignore, + ), + ); + return true; +}).pipe(Effect.withSpan("desktop.startLocalBackend")); + +const bootstrap = Effect.fn("desktop.bootstrap")(function* (backendStarted: boolean) { + const state = yield* DesktopState.DesktopState; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const snapShot = yield* DesktopSnapShot.DesktopSnapShot; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const settings = yield* desktopSettings.get; + if (settings.localEnvironmentEnabled && (!backendStarted || (yield* Ref.get(state.quitting)))) { + return; + } + + yield* snapShot.initialize; + // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has + // no cached one. Without it, the command is left to Settings. + const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; + yield* Effect.forkScoped( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( + Effect.flatMap((found) => + found || environment.platform === "win32" + ? installCli + : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), + ), + ), + ); + + if (!settings.localEnvironmentEnabled) { + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; } - yield* appActivation.start.pipe( - Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), - Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), - ); - yield* Effect.forkScoped(wslBackend.reconcile); + return; } -}).pipe(Effect.withSpan("desktop.bootstrap")); + + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; + // WSL-only fallback can change the primary endpoint before readiness. + const config = yield* primaryBackend.currentConfig; + if ( + !environment.isDevelopment && + !settings.wslOnly && + Option.isSome(config) && + Option.isNone(config.value.preflightFailure) + ) { + yield* desktopWindow.ensureMain; + } + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); + yield* Effect.forkScoped((yield* DesktopWslBackend.DesktopWslBackend).reconcile); +}); const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; @@ -369,14 +395,20 @@ const startup = Effect.gen(function* () { }); } yield* appIdentity.configure; - yield* previewPasskeys.configure; - yield* applicationMenu.configure; + // Sets canInstallUpdates, which the backend bootstrap payload reads. yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; + const backendStarted = yield* startLocalBackend.pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); + yield* previewPasskeys.configure; + yield* applicationMenu.configure; // Portal identity is prepared pre-ready; MIME helpers must not gate the backend or window. yield* linuxUrlHandler.register.pipe(Effect.forkScoped); - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* bootstrap(backendStarted).pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); }).pipe(Effect.withSpan("desktop.startup")); const scopedProgram = Effect.scoped( diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8a8389082ce89..06a1f768f4bd4 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -378,6 +378,42 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("becomes ready from the server ready line while HTTP probes stall", () => + Effect.scoped( + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const readyLine = encodeDesktopTelemetryControl({ version: 1, type: "serverReady" }); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.fromEffect(Deferred.await(probeStarted)).pipe( + Stream.flatMap(() => Stream.encodeText(Stream.make(`${readyLine}\n`))), + ) + : Stream.empty, + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => + Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + }); + + yield* instance.start; + assert.isTrue(yield* instance.waitForReady(Duration.seconds(15))); + }), + ), + ); + it.effect("continues routing desktop telemetry control messages after an invalid line", () => Effect.gen(function* () { const handled = yield* Deferred.make(); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 89ebc9e862ecf..4acaa8a3c18b8 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,5 +1,6 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -484,21 +485,23 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); const outputFibers: Array> = []; + const serverReady = yield* Deferred.make(); yield* options.onStarted?.(handle.pid) ?? Effect.void; - if ( - options.bootstrap.desktopTelemetryControlFd !== undefined && - options.onDesktopTelemetryControl !== undefined - ) { + if (options.bootstrap.desktopTelemetryControlFd !== undefined) { const controlFd = options.bootstrap.desktopTelemetryControlFd; - const handleControl = options.onDesktopTelemetryControl; + const handleControl = options.onDesktopTelemetryControl ?? (() => Effect.void); yield* handle.getOutputFd(controlFd).pipe( Stream.decodeText(), Stream.splitLines, Stream.filter((line) => line.trim().length > 0), Stream.runForEach((line) => decodeDesktopTelemetryControlLine(line).pipe( - Effect.flatMap(handleControl), + Effect.flatMap((message) => + message.type === "serverReady" + ? Deferred.succeed(serverReady, undefined) + : handleControl(message), + ), Effect.catchCause((cause) => logBackendProcessWarning("ignored invalid desktop telemetry control message", { fd: controlFd, @@ -570,14 +573,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ); } // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. + // HTTP polling stays as the fallback for backends without the control fd, such as WSL. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => - waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( + Effect.raceFirst( + waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }), + Deferred.await(serverReady), + ).pipe( Effect.flatMap(() => options.onReady?.(handle.pid) ?? Effect.void), Effect.as(true), Effect.catchTags({ diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 0a2b10e08c5a8..415901878cafe 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -352,6 +352,8 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid); case "cancelDesktopUpdate": return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid); + case "serverReady": + return Effect.void; } }; const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts index 7063df5023377..eccf421dd7c18 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -363,6 +363,7 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { }).pipe( Effect.provide( Layer.mock(ProviderHost.ProviderHost)({ + awaitServerReady: Effect.void, paths: { cwd: f.directory, baseDir: f.directory, diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 6398210599b61..fed07efc035ee 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -4,11 +4,13 @@ import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as DocumentText from "./DocumentText.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; @@ -22,6 +24,10 @@ export const layer = Layer.effect( const secrets = yield* ServerSecretStore.ServerSecretStore; const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; + // Optional so standalone provider layers in tests probe at once. + const lifecycleEvents = yield* Effect.serviceOption( + ServerLifecycleEvents.ServerLifecycleEvents, + ); return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -37,6 +43,10 @@ export const layer = Layer.effect( subscribe: serverSettings.subscribeChanges, }, shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, + ...Option.match(lifecycleEvents, { + onNone: () => ({}), + onSome: (events) => ({ awaitServerReady: events.awaitReady }), + }), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), // DocumentText owns the path-resolution span. diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 7d9de87ecfd98..ca77c9e26220c 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -187,6 +187,8 @@ export class DesktopTelemetryReceiver extends Context.Service< readonly setDiagnosticsDemand: ( enabled: boolean, ) => Effect.Effect; + /** Tells the desktop parent that the server accepts requests, so it can skip readiness polling. */ + readonly announceServerReady: Effect.Effect; readonly requestDesktopUpdate: ( requestId: string, trackSwitch?: DesktopTrackSwitch, @@ -717,6 +719,9 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, + announceServerReady: sendControlMessage({ version: 1, type: "serverReady" }).pipe( + Effect.withSpan("DesktopTelemetryReceiver.announceServerReady"), + ), requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( function (requestId, trackSwitch) { return sendControlMessage({ @@ -787,6 +792,7 @@ export const layerTest = ( setDiagnosticsDemand: Effect.fn("DesktopTelemetryReceiver.setDiagnosticsDemand")(function () { return Effect.void; }), + announceServerReady: Effect.void, requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")(function () { return Effect.void; }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7498afd269257..dc2e1f21c1022 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -899,6 +899,17 @@ const layerMakeServer = Layer.unwrap( yield* startup.markHttpListening; }), ); + const layerDesktopReadySignal = Layer.effectDiscard( + Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + yield* startup.awaitCommandReady.pipe( + Effect.andThen(receiver.announceServerReady), + Effect.ignore, + Effect.forkScoped, + ); + }), + ); const layerRuntimeState = Layer.effectDiscard( Effect.gen(function* () { yield* Deferred.succeed(runtimeStateParked, undefined).pipe(Effect.orDie); @@ -1279,6 +1290,7 @@ const layerMakeServer = Layer.unwrap( const layerServerApplication = Layer.mergeAll( layerRoutes, layerHttpListening, + layerDesktopReadySignal, layerRuntimeState.pipe(Layer.provide(layerLauncher)), layerTailscaleServe, layerCloudDesiredLinkReconcile, diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 7d394e4dc9134..d90b7fa57bc35 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,5 +1,6 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -22,11 +23,14 @@ export class ServerLifecycleEvents extends Context.Service< readonly publish: (event: LifecycleEventInput) => Effect.Effect; readonly snapshot: Effect.Effect; readonly stream: Stream.Stream; + /** Completes once the first ready event is published. */ + readonly awaitReady: Effect.Effect; } >()("t3/serverLifecycleEvents") {} const make = Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); + const ready = yield* Deferred.make(); const state = yield* Ref.make({ sequence: 0, events: [], @@ -45,8 +49,14 @@ const make = Effect.gen(function* () { ...current.events.filter((entry) => entry.type !== nextEvent.type), ]; return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; - }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), + }).pipe( + Effect.tap((event) => PubSub.publish(pubsub, event)), + Effect.tap((event) => + event.type === "ready" ? Deferred.succeed(ready, undefined) : Effect.void, + ), + ), snapshot: Ref.get(state), + awaitReady: Deferred.await(ready), get stream() { return Stream.fromPubSub(pubsub); }, diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 5e7045515f1f4..15162a79a8bbd 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -383,7 +383,15 @@ export const DesktopTelemetryCancelDesktopUpdate = Schema.Struct({ }); export type DesktopTelemetryCancelDesktopUpdate = typeof DesktopTelemetryCancelDesktopUpdate.Type; +/** The server accepts requests; the desktop treats this as backend readiness. */ +export const DesktopTelemetryServerReady = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("serverReady"), +}); +export type DesktopTelemetryServerReady = typeof DesktopTelemetryServerReady.Type; + export const DesktopTelemetryControlMessage = Schema.Union([ + DesktopTelemetryServerReady, DesktopTelemetrySetDiagnosticsDemand, DesktopTelemetrySetHostPowerIntervals, DesktopTelemetryRequestDesktopUpdate, diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 298613bf13c91..804e8ff4cdda7 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -44,6 +44,8 @@ export class ProviderHost extends Context.Service< readonly subscribe: Effect.Effect, never, Scope.Scope>; }; readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; + /** Completes once server startup finishes; first provider checks wait for it. */ + readonly awaitServerReady?: Effect.Effect; /** Absolute attachment path, or null if the ID does not resolve inside attachmentsDir. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 67549e3a52638..f878b2f44ecca 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -100,6 +100,7 @@ const refreshedSnapshotSecond: ServerProvider = { function layerProviderHost(input: { readonly runBackgroundWork: boolean; readonly settings?: Pick["settings"]; + readonly awaitServerReady?: Effect.Effect; }) { return Layer.succeed( ProviderHost.ProviderHost, @@ -118,6 +119,7 @@ function layerProviderHost(input: { subscribe: Effect.succeed(Stream.empty), }, shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), + ...(input.awaitServerReady ? { awaitServerReady: input.awaitServerReady } : {}), resolveAttachmentPath: () => null, credentials: () => Effect.die("unused"), }), @@ -294,6 +296,49 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect("holds the first provider check until server startup finishes", () => + Effect.gen(function* () { + const serverReady = yield* Deferred.make(); + const checkStarted = yield* Deferred.make(); + const checkCalls = yield* Ref.make(0); + yield* Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(checkStarted, undefined)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + yield* TestClock.adjust("10 seconds"); + assert.strictEqual(yield* Ref.get(checkCalls), 0); + assert.deepStrictEqual(yield* provider.getSnapshot, initialSnapshot); + assert.isTrue(yield* provider.isAwaitingFirstCheck!); + + yield* Deferred.succeed(serverReady, undefined); + yield* Deferred.await(checkStarted); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + layerProviderHost({ + runBackgroundWork: false, + awaitServerReady: Deferred.await(serverReady), + }), + TestClock.layer(), + ), + ), + ); + }), + ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 9f3a5277afc1e..33a8273359d13 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -437,7 +437,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + // Probes spawn provider CLIs; starting them during server startup slows the first client load. + yield* (host.awaitServerReady ?? Effect.void).pipe( + Effect.andThen( + // Read under the lock, so a settings check in flight cannot be overwritten with older settings. + refreshSemaphore.withPermits(1)( + Ref.get(settingsRef).pipe( + Effect.flatMap((settings) => applySnapshotBase(settings, { forceRefresh: true })), + ), + ), + ), Effect.ignoreCause({ log: true }), Effect.forkScoped, ); From cf382b0c44eb619cfa7f14a8ec279fd8138c90f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:26:56 -0800 Subject: [PATCH 019/690] fix(web,mobile): explain missing file access for host videos (#24637) --- .../src/components/MediaVideoPlayer.tsx | 10 +++++-- .../ui/VideoPreviewModal.android.tsx | 7 +++++ .../features/threads/ThreadMarkdownVideo.tsx | 6 ++++ .../components/ChatMarkdown.browser.test.tsx | 28 +++++++++++++++++++ .../src/components/chat/ChatMarkdownMedia.tsx | 19 +++++++------ .../src/components/media/MediaVideoPlayer.tsx | 6 ++-- 6 files changed, 64 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ea901f2441331..8edd7b3d78ab1 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -138,6 +138,7 @@ interface MediaVideoPlayerProps { readonly thumbnailVisible?: boolean; readonly visible?: boolean; readonly unavailable?: boolean; + readonly unavailableDetail?: string | undefined; readonly expanded?: boolean; /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ readonly fitToVideo?: boolean; @@ -154,6 +155,9 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const unavailableText = props.unavailableDetail + ? `Video unavailable. ${props.unavailableDetail}` + : "Video unavailable"; const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; @@ -201,7 +205,9 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { 0 ? "Touch and hold for media actions" : undefined } @@ -223,7 +229,7 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { /> ) : null} {props.unavailable ? ( - Video unavailable + {unavailableText} ) : props.uri === null ? ( ) : ( diff --git a/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx index 862d17d60d067..7e961e2f91df2 100644 --- a/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx +++ b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { videoMimeType } from "@t3tools/shared/video"; import { Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -26,6 +27,7 @@ interface PlaybackState { readonly uri: string | null; readonly resolvePlaybackUri?: () => Promise; readonly unavailable: boolean; + readonly unavailableDetail?: string | undefined; readonly error: string | null; readonly actionsSource: MediaActionsSource | undefined; } @@ -46,6 +48,10 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { uri === null && environmentId !== null && (connection._tag === "None" || asset._tag === "Failure"), + unavailableDetail: + asset._tag === "Failure" && asset.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined, error: null, actionsSource: source.actionsSource, }; @@ -134,6 +140,7 @@ function OpenVideoPreviewModal(props: { name={props.name} thumbnailKey={props.thumbnailKey} unavailable={playback.unavailable} + unavailableDetail={playback.unavailableDetail} paused={mediaActions.sharing} expanded autoPlay diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index 457b7878ea5e7..5cf1c9b353297 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { createContext, useContext } from "react"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; @@ -43,6 +44,11 @@ export function ThreadMarkdownVideo(props: { visible={visible} onPlaybackStart={props.onPlaybackStart} unavailable={"resource" in source && asset._tag === "Failure"} + unavailableDetail={ + asset._tag === "Failure" && asset.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined + } actionsSource={source.actionsSource} borderRadius={props.borderRadius} /> diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 01b78afab079f..66dc5c150a651 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { type ReactElement } from "react"; @@ -74,6 +75,17 @@ vi.mock(import("../state/session"), async (original) => { readEnvironmentScope: hasScope as never, }; }); +const deniedEnvironmentId = vi.hoisted(() => "denied-environment"); +vi.mock(import("../state/filesystem"), async (original) => { + const actual = await original(); + return { + ...actual, + useFilesystemReadAccess: ((environmentId: EnvironmentId | null) => + environmentId === deniedEnvironmentId + ? { canReadFiles: false, isPending: false, error: null } + : actual.useFilesystemReadAccess(environmentId)) as never, + }; +}); vi.mock(import("../remoteOpen"), async (original) => ({ ...(await original()), useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, @@ -742,3 +754,19 @@ describe("ChatMarkdown Windows file links", () => { } }); }); + +describe("ChatMarkdown host media", () => { + it("explains a host video the connection cannot read instead of offering a retry", async () => { + setup(); + await using screen = await renderText("![Demo](/tmp/demo.mp4)", { + threadRef: { + environmentId: EnvironmentId.make(deniedEnvironmentId), + threadId: ThreadId.make("thread"), + }, + }); + await vi.waitFor(() => + expect(screen.container.textContent).toContain(MISSING_FILE_ACCESS_MESSAGE), + ); + expect(screen.container.textContent).not.toContain("Retry video"); + }); +}); diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index 6ccdd2b69b089..580de34b9d95b 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -297,6 +297,7 @@ export function ChatMarkdownVideo(props: { readonly copyMarkdown: string | undefined; readonly originalUrl?: string | undefined; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly style?: CSSProperties | undefined; readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; @@ -307,6 +308,7 @@ export function ChatMarkdownVideo(props: { key={props.mediaIdentity ?? props.copyMarkdown ?? props.src} src={props.src} sourceFailed={props.sourceFailed} + unavailableDetail={props.unavailableDetail} label={props.alt} originalUrl={props.originalUrl} style={props.style} @@ -413,6 +415,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const missingFileAccess = + assetUrl._tag === "Failure" && + assetUrl.reason === "missing-file-access" && + fallbackSrc === undefined; + const unavailableDetail = missingFileAccess ? MISSING_FILE_ACCESS_MESSAGE : undefined; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") @@ -444,12 +451,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); @@ -460,13 +469,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} src={src} sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined} - unavailableDetail={ - assetUrl._tag === "Failure" && - assetUrl.reason === "missing-file-access" && - fallbackSrc === undefined - ? MISSING_FILE_ACCESS_MESSAGE - : undefined - } + unavailableDetail={unavailableDetail} alt={props.alt} copyMarkdown={props.copyMarkdown} standalone={props.standalone ?? true} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 6faae7da2344f..18fb969e6003a 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -14,6 +14,7 @@ interface MediaVideoPlayerProps { readonly src: string | null; readonly label: string; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; @@ -32,6 +33,7 @@ export function MediaVideoPlayer({ src: latestSrc, label, sourceFailed = false, + unavailableDetail, originalUrl, revision = null, preload = "visible", @@ -136,9 +138,9 @@ export function MediaVideoPlayer({ stateClassName, )} > - + - Video unavailable{label ? ` · ${label}` : ""} + {["Video unavailable", label, unavailableDetail].filter(Boolean).join(" · ")} {latestSrc !== null || onRetry ? ( From 44fefbcbc478511666aed4b94b897ad45bceca82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:26:59 -0800 Subject: [PATCH 020/690] fix(scripts): iOS scenarios prepare the dev client before leasing a device (#36456) --- docs/operations/scenarios.md | 5 +++-- scripts/e2e.config.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index 22b61bf25301b..db4ea982cf0e0 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -178,8 +178,9 @@ test( ); ``` -`t3.project` and `t3.thread` are the same. `t3.open()` installs the dev client for the checkout's -native fingerprint, loads it from Metro, and pairs it with the scenario's server. `device` is +`t3.project` and `t3.thread` are the same. The run fetches or builds the dev client for the +checkout's native fingerprint before it leases a device; `t3.open()` installs it, loads it from +Metro, and pairs it with the scenario's server. `device` is e2e's [device fixture](https://e2e.com/docs/mobile#the-device-fixture) for system actions. The run leases an iPhone 18 Pro from the Simlock pool, so it needs `SIMLOCK_AGENT_DEVICE_URL` diff --git a/scripts/e2e.config.ts b/scripts/e2e.config.ts index 989d63326ac87..dc10e32f7abf8 100644 --- a/scripts/e2e.config.ts +++ b/scripts/e2e.config.ts @@ -86,6 +86,15 @@ const simlock = (device: string): DeviceProvider => { const daemon = yield* connect(request.env); const client = createAgentDeviceClient(daemon); const platform = request.platform === "android" ? "android" : "ios"; + // A cache miss builds the dev client, which outlasts a test and can outlast a lease. + request.log(`preparing the ${platform} dev client`); + yield* run( + resolved.node, + ["scripts/mobile-native-client.ts", "ensure", platform, "--prepare"], + { + cwd: APP_DIR, + }, + ); const scope = { tenant: "t3-scenarios", runId: `${request.runId}-${request.slot}` }; // Simlock takes the lease id from the caller: 32 hex characters. const leaseId = (yield* (yield* SecureRandom.SecureRandom).uuidV4()).replaceAll("-", ""); @@ -121,7 +130,10 @@ const simlock = (device: string): DeviceProvider => { return { name: "simlock", acquire: (request) => - acquire(request).pipe(Effect.provide(NodeCrypto.layer), Effect.runPromise), + acquire(request).pipe( + Effect.provide([NodeCrypto.layer, NodeServices.layer]), + Effect.runPromise, + ), release: (lease, context) => connect(context.env).pipe( Effect.flatMap((daemon) => From 4bab2f3ae71c1d255c507978e793695a78569615 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 13 Nov 2026 16:27:28 -0800 Subject: [PATCH 021/690] perf(server): a new thread's first turn starts sooner (#35710) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 56 ++++--- .../ProviderTurnStartService.test.ts | 5 +- .../ProviderTurnStartService.ts | 7 + .../RunExecutionService.test.ts | 92 ++++++++++++ .../orchestration-v2/RunExecutionService.ts | 58 +++++--- .../orchestration-v2/ThreadLaunchService.ts | 139 ++++++++++-------- 6 files changed, 254 insertions(+), 103 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f5d39bfd7d087..daf472ad6475d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -12130,8 +12130,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, + queryPolicy: Effect.Success>, ) { - const queryPolicy = yield* queryPolicyForTurn(turnInput); const mcpSession = yield* mcpSessions.read(turnInput.threadId); const mcpOverrides = claudeMcpQueryOverrides({ mcpSession, @@ -12208,18 +12208,31 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } // Read before closing the live process so a bad path leaves it running. - const appendSystemPromptFileText = yield* readClaudeAppendSystemPromptFile({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); + const [appendSystemPromptFileText, { launchArgSettings, toolSearchOverride }] = + yield* Effect.all( + [ + readClaudeAppendSystemPromptFile({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }), + Effect.gen(function* () { + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }); + const toolSearchOverride = yield* resolveToolSearchOverride( + turnInput.runtimePolicy.cwd, + launchArgSettings, + ); + return { launchArgSettings, toolSearchOverride }; + }), + ], + { concurrency: "unbounded" }, + ); const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; clearedNativeSessions.delete(nativeThreadId); if (existing !== null) { @@ -12238,10 +12251,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; - const toolSearchOverride = yield* resolveToolSearchOverride( - turnInput.runtimePolicy.cwd, - launchArgSettings, - ); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, @@ -12520,6 +12529,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const used = turnInput.providerThread.nativeMetadata?.claudeAttachmentBudget; pdfBudgets.set(nativeThreadId, makeClaudeAttachmentBudget(used)); } + const [skills, queryPolicy] = yield* Effect.all( + [ + promptUuid === null + ? Effect.undefined + : userInvocableSkills(turnInput.runtimePolicy.cwd), + queryPolicyForTurn(turnInput), + ], + { concurrency: "unbounded" }, + ); const pdfReservation = yield* reserveClaudePdfBudget( pdfBudgets.get(nativeThreadId)!, persistAttachmentBudget(nativeThreadId), @@ -12546,13 +12564,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( attachmentsDir, fileSystem, pdfReservation, - skills: yield* userInvocableSkills(turnInput.runtimePolicy.cwd), + ...(skills === undefined ? {} : { skills }), uuid: promptUuid, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); - const querySession = yield* openQuery(turnInput, nativeThreadId); + const querySession = yield* openQuery(turnInput, nativeThreadId, queryPolicy); const autoCompactWindow = yield* resolveAutoCompactWindow( querySession.settingsCwd, turnInput.modelSelection, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 4bb16d5c6d74f..690e1f9b73172 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1126,7 +1126,10 @@ function makeLocalCommandHarness(input: { ), }), Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), - Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({ + startRootRun, + forkBaseline: () => Effect.succeed(Effect.void), + }), Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { runStarted: () => Effect.void, turnCompleted: () => Effect.void, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index ddb9416682710..7cce02b46b0cb 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -697,6 +697,12 @@ export const layer: Layer.Layer< }); if (!repaired.committed) return; } + // Opening a session does not prompt the provider, so the workspace is still unchanged. + const baseline = yield* runExecution.forkBaseline({ + appThread: projection.thread, + run, + checkpointScope, + }); const sessionResult = yield* Effect.result( providerSessions.open({ threadId: projection.thread.id, @@ -1716,6 +1722,7 @@ export const layer: Layer.Layer< providerThread: runningProviderThread, attempt: runningAttempt, attemptId: attempt.id, + baseline, loadInheritedBackgroundTurnItems: runControls.loadInheritedBackgroundTurnItems, relatedThreadIds: routableSubagents.flatMap((subagent) => subagent.childThreadId === null ? [] : [subagent.childThreadId], diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index fc6a657765933..0e8898cf0623b 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1247,6 +1247,98 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); +it.effect("prompts the provider only after a forked baseline capture finishes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:run-execution-forked-baseline"); + const runId = RunId.make("run:run-execution-forked-baseline"); + const attemptId = RunAttemptId.make("attempt:run-execution-forked-baseline"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const checkpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-forked-baseline"), + } as OrchestrationV2CheckpointScope; + const run = { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run; + const order = yield* Ref.make>([]); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + PreviewAgentActivity.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => + Effect.sleep("1 second").pipe( + Effect.andThen(Ref.update(order, (current) => [...current, "baseline"])), + ), + }), + layerTurnItemPositionsTest, + mockEventSink({}), + IdAllocator.layer, + ThreadCommandExecutor.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + const baseline = yield* runExecution.forkBaseline({ + appThread: rootAppThread(threadId), + run, + checkpointScope, + }); + const started = yield* runExecution + .startRootRun({ + commandId: CommandId.make("command:run-execution-forked-baseline"), + appThread: rootAppThread(threadId), + providerSessionId: ProviderSessionId.make("session:run-execution-forked-baseline"), + session: { + events: Stream.never, + startTurn: () => Ref.update(order, (current) => [...current, "prompt"]), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run, + rootNode: { + id: NodeId.make("node:run-execution-forked-baseline"), + } as OrchestrationV2ExecutionNode, + checkpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-forked-baseline"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + baseline, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make("message:run-execution-forked-baseline"), + text: "Wait for the baseline.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(started); + }).pipe(Effect.provide(layerTest)); + + assert.deepEqual(yield* Ref.get(order), ["baseline", "prompt"]); + }), +); + it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( "handles %s before the provider turn starts", (scenario) => diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 8e7947d3147c6..10f10a98de0a4 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -578,6 +578,8 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly providerThread: OrchestrationV2ProviderThread; readonly attempt: OrchestrationV2RunAttempt; readonly attemptId: RunAttemptId; + /** Waits for a baseline started with `forkBaseline`; without it the run captures one itself. */ + readonly baseline?: Effect.Effect; readonly providerTurnOrdinal: number; readonly nativeThreadHasTurns?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< @@ -596,10 +598,20 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; } +type RunBaselineInput = Pick< + RunExecutionServiceV2StartRootRunInput, + "appThread" | "run" | "checkpointScope" +>; + export interface RunExecutionServiceV2Shape { readonly startRootRun: ( input: RunExecutionServiceV2StartRootRunInput, ) => Effect.Effect; + /** + * Starts the run's baseline capture on a child fiber so it overlaps provider session startup. + * Pass the returned effect to `startRootRun`, which awaits it before prompting the provider. + */ + readonly forkBaseline: (input: RunBaselineInput) => Effect.Effect>; } export class RunExecutionServiceV2 extends Context.Service< @@ -918,7 +930,34 @@ export const layer: Layer.Layer< }, ); + const captureRunBaseline = Effect.fn("RunExecutionService.captureBaseline")(function* ( + input: RunBaselineInput, + ) { + // Side chats share the parent's worktree; a snapshot would capture the parent's edits. + if (input.appThread.lineage.sideChat === true) return; + yield* checkpointService + .captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); + }); + return RunExecutionServiceV2.of({ + forkBaseline: (input) => + captureRunBaseline(input).pipe( + Effect.forkChild({ startImmediately: true }), + Effect.map(Fiber.join), + ), startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) { const rootTurnOrigin: ProviderUsageAnalytics.ProviderTurnOrigin = { createdBy: input.message.createdBy, @@ -966,24 +1005,7 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - // Side chats share the parent's worktree; a snapshot would capture the parent's edits. - yield* ( - input.appThread.lineage.sideChat === true - ? Effect.void - : checkpointService.captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - ).pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + yield* input.baseline ?? captureRunBaseline(input); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index f02e8810dcac8..67f3b5402de78 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -346,37 +346,54 @@ const make = Effect.gen(function* () { "ThreadLaunchService.checkWorktreeBase", )(function* (input) { const strategy = input.workspaceStrategy; - // launch() turns a Scratch worktree request into a root launch. - if (strategy.type !== "worktree" || (yield* managedFolders.isScratchProject(input.projectId))) - return; - if (input.commandId !== undefined) { - const receipt = yield* receipts - .getByCommandId(input.commandId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isSome(receipt) && receipt.value.status === "accepted") return; - } - const project = Option.getOrUndefined( - yield* projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + if (strategy.type !== "worktree") return; + const commandId = input.commandId; + const [isScratch, receipt, projectOption] = yield* Effect.all( + [ + managedFolders.isScratchProject(input.projectId), + commandId === undefined + ? Effect.succeedNone + : receipts.getByCommandId(commandId).pipe(Effect.orElseSucceed(() => Option.none())), + projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + ], + { concurrency: "unbounded" }, ); + // launch() turns a Scratch worktree request into a root launch. + if (isScratch) return; + if (Option.isSome(receipt) && receipt.value.status === "accepted") return; + const project = Option.getOrUndefined(projectOption); if (project === undefined) return; const cwd = project.workspaceRoot; - const repositoryRoot = yield* git - .repositoryRoot(cwd) - .pipe(Effect.orElseSucceed(() => undefined)); + const { baseRef, branch } = strategy; + const checksBranch = branch !== undefined && !isTemporaryWorktreeBranch(branch); + // Errors keep their order below: missing repository, then base ref, then branch conflict. + const [repositoryRoot, hasBaseCommit, localBranches] = yield* Effect.all( + [ + git.repositoryRoot(cwd).pipe(Effect.orElseSucceed(() => undefined)), + // `:/text` is a commit search, which cannot be asked for a commit type. + baseRef.startsWith(":") + ? Effect.succeed(true) + : // `git worktree add` reads `-` as the previous checkout. + git + .hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }) + .pipe(Effect.orElseSucceed(() => true)), + checksBranch + ? git + .listLocalBranchNames(cwd) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])) + : Effect.succeed>([]), + ], + { concurrency: "unbounded" }, + ); if (repositoryRoot === null) return yield* new ThreadLaunchNotRepositoryError({ projectId: project.id, projectTitle: project.title, workspaceRoot: cwd, }); - const { baseRef, branch } = strategy; const baseResolves = - // `:/text` is a commit search, which cannot be asked for a commit type. - baseRef.startsWith(":") || + hasBaseCommit || (yield* Effect.gen(function* () { - // `git worktree add` reads `-` as the previous checkout. - if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef })) - return true; if (strategy.startFromOrigin === true) { const remote = yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef }); // The remote may have deleted the branch; ask it rather than trusting the name. @@ -398,10 +415,8 @@ const make = Effect.gen(function* () { baseRef, }); // Provisioning flattens temporary names that collide, so only explicit names are refused. - if (branch === undefined || isTemporaryWorktreeBranch(branch)) return; - const conflictingBranch = (yield* git - .listLocalBranchNames(cwd) - .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).find( + if (!checksBranch) return; + const conflictingBranch = localBranches.find( (existing) => existing.startsWith(`${branch}/`) || branch.startsWith(`${existing}/`), ); if (conflictingBranch !== undefined) @@ -996,7 +1011,7 @@ const make = Effect.gen(function* () { return yield* prepare; } const afterSequence = yield* threads.getThreadEventSequence(threadId); - const projection = yield* threads.getThreadRecords(threadId, ["runs"]); + const projection = yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }); if (projection.runs.find((run) => run.id === runId)?.status !== "preparing") return; const interruption = threads .streamStoredEventsFrom({ threadId, afterSequence, eventType: "run.updated" }) @@ -1224,49 +1239,43 @@ const make = Effect.gen(function* () { } } + // Fork preparation before the full projection read; it is only part of the reply. + const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : true; + if (shouldSchedule && (yield* reservePreparation(input.commandId))) { + yield* Effect.gen(function* () { + const current = + runId === null + ? null + : yield* threads + .getThreadRecords(threadId, ["runs"], { runIds: [runId] }) + .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + if ( + current !== null && + !current.runs.some((run) => run.id === runId && run.status === "preparing") + ) { + return yield* releasePreparation(input.commandId); + } + const boundWorktreePath = current?.thread.worktreePath ?? null; + const preparationStrategy: ThreadLaunchWorkspaceStrategy = + Option.isSome(launchReceipt) && + workspaceStrategy.type === "root" && + boundWorktreePath !== null + ? { + type: "existing_worktree", + worktreePath: boundWorktreePath, + branch: workspaceStrategy.branch, + } + : workspaceStrategy; + yield* schedulePreparation( + { ...input, workspaceStrategy: preparationStrategy }, + threadId, + runId, + ); + }).pipe(Effect.onError(() => releasePreparation(input.commandId))); + } const projection = yield* threads .getThreadProjection(threadId) .pipe(Effect.mapError(mapError(input, "create-thread", threadId))); - const runIsPreparing = - runId !== null && - projection.runs.some((run) => run.id === runId && run.status === "preparing"); - const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; - const boundWorktreePath = projection.thread.worktreePath; - const preparationStrategy: ThreadLaunchWorkspaceStrategy = - Option.isSome(launchReceipt) && - workspaceStrategy.type === "root" && - boundWorktreePath !== null - ? { - type: "existing_worktree", - worktreePath: boundWorktreePath, - branch: workspaceStrategy.branch, - } - : workspaceStrategy; - if (shouldSchedule) { - const ownsPreparation = yield* reservePreparation(input.commandId); - if (ownsPreparation) { - yield* Effect.gen(function* () { - const preparationStillRequired = - runId === null - ? true - : yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }).pipe( - Effect.map((current) => - current.runs.some((run) => run.id === runId && run.status === "preparing"), - ), - Effect.mapError(mapError(input, "update-thread", threadId)), - ); - if (preparationStillRequired) { - yield* schedulePreparation( - { ...input, workspaceStrategy: preparationStrategy }, - threadId, - runId, - ); - } else { - yield* releasePreparation(input.commandId); - } - }).pipe(Effect.onError(() => releasePreparation(input.commandId))); - } - } return { threadId, From f5ae8088c354ec6e75fee258d716afa52a4cbac1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:34:45 -0800 Subject: [PATCH 022/690] fix(provider-pi): running shell output no longer stores a snapshot row per update (#36429) --- .../provider-pi/src/server/adapter.test.ts | 33 ++++++++++++++++--- packages/provider-pi/src/server/adapter.ts | 33 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index cde96ef359e98..e15eb960f6895 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -4078,6 +4078,23 @@ describe("PiAdapterV2", () => { running.turnItem.status === "running" && running.turnItem.input === "echo hello", ); + // Pi resends the whole output so far every 100 ms; each snapshot item is a stored row. + // Past its limit Pi sends only the tail, with the running total in details.truncation. + for (const [text, totalBytes] of [ + ["hel", undefined], + ["hello", undefined], + ["lo\n\u00e9!", 9], + ] as const) { + yield* fake.emit({ + type: "tool_execution_update", + toolCallId: "shell-call", + toolName, + partialResult: { + content: [{ type: "text", text }], + ...(totalBytes === undefined ? {} : { details: { truncation: { totalBytes } } }), + }, + }); + } yield* fake.emit({ type: "tool_execution_end", toolCallId: "shell-call", @@ -4089,10 +4106,18 @@ describe("PiAdapterV2", () => { ...(legacy ? {} : { structuredContent: { exit_code: exitCode } }), }, }); - const completed = yield* takeEvent( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); + const chunks: Array = []; + let completed: ProviderAdapter.ProviderAdapterV2Event; + while (true) { + completed = yield* takeEvent( + (event) => + event.type === "command_output.delta" || + (event.type === "turn_item.updated" && event.turnItem.type === "command_execution"), + ); + if (completed.type !== "command_output.delta") break; + chunks.push(completed.chunk); + } + assert.deepStrictEqual(chunks, ["hel", "lo", "\n\u00e9!"]); assert.isTrue( completed.type === "turn_item.updated" && completed.turnItem.type === "command_execution" && diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 2882a709a6847..6d6340154e297 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -359,6 +359,7 @@ interface ActivePiTurn { readonly streamItems: Map; readonly toolArgs: Map; readonly toolStartedAt: Map; + readonly commandOutputBytes: Map; interrupted: boolean; nativeAborted: boolean; sawAgentActivity: boolean; @@ -1193,6 +1194,33 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( { discard: true }, ); + // Pi resends the whole output tail on every update; stored snapshots grow quadratically. + const emitCommandOutputDelta = (turn: ActivePiTurn, toolCallId: string, result: unknown) => + Effect.suspend(() => { + const text = contentText(recordField(result, "content")); + const tail = Buffer.from(text, "utf8"); + const totalBytes = + recordNumber(recordField(recordField(result, "details"), "truncation"), "totalBytes") ?? + tail.length; + const previousBytes = turn.commandOutputBytes.get(toolCallId) ?? 0; + turn.commandOutputBytes.set(toolCallId, totalBytes); + if (totalBytes <= previousBytes) return Effect.void; + let start = Math.max(0, tail.length - (totalBytes - previousBytes)); + while (start < tail.length && (tail[start]! & 0xc0) === 0x80) start++; + const chunk = tail.subarray(start).toString("utf8"); + if (chunk.length === 0) return Effect.void; + return emit({ + type: "command_output.delta", + driver: PI_PROVIDER, + threadId: turn.turnInput.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: toolCallId, + }), + chunk, + }); + }); + const emitToolItem = Effect.fnUntraced(function* ( turn: ActivePiTurn, event: PiRpcRecord, @@ -1203,6 +1231,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // Pi tags every phase of a call made through ctx.executeTool, such as from a codemode script. const parentToolCallId = recordString(event, "parentToolCallId"); if (toolCallId === undefined) return; + if (phase === "update" && (toolName === "bash" || toolName === "powershell")) { + yield* emitCommandOutputDelta(turn, toolCallId, event["partialResult"]); + return; + } if (phase === "start") { turn.toolArgs.set(toolCallId, event["args"]); } @@ -2906,6 +2938,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( streamItems: new Map(), toolArgs: new Map(), toolStartedAt: new Map(), + commandOutputBytes: new Map(), interrupted: false, nativeAborted: false, sawAgentActivity: false, From 2a1caac43869f324bc3872c71fa086f5037ce525 Mon Sep 17 00:00:00 2001 From: nibarAhmed <61316519+nibarAhmed@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:38:31 +0100 Subject: [PATCH 023/690] fix(web): NVDA browse mode reads the current thread's branch, not the previous one (#34446) Co-authored-by: nibarAhmed Co-authored-by: Julius Marminge --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c7e6c9824789c..e685e623f2bc1 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -786,6 +786,8 @@ export function BranchToolbarBranchSelector({ } className="min-w-0 max-w-full active:scale-100" disabled={isInitialBranchesLoadPending || isBranchActionPending} + // NVDA browse mode re-reads a combobox when its attributes change, not when its text does. + aria-label={`Branch: ${triggerLabel}`} > - + From 99e10f05c8521445419cd0c09cedee0faa8f036b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:42:45 -0800 Subject: [PATCH 024/690] fix(server): T3 Connect clients are named "T3 Connect", not "T3 Connect connect" (#27887) --- apps/server/src/cloud/CloudLink.test.ts | 80 ++++++++++++++++++++++--- apps/server/src/cloud/CloudLink.ts | 2 +- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index 8de291e3fdac3..300a75ef4ed99 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -1,5 +1,9 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. +import * as NodeCrypto from "node:crypto"; import { EnvironmentId } from "@t3tools/contracts"; +import { RELAY_MINT_REQUEST_TYP, signRelayJwt } from "@t3tools/shared/relayJwt"; import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -7,7 +11,7 @@ import * as Option from "effect/Option"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import { HttpClient, HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as EnvironmentKeys from "./environmentKeys.ts"; @@ -22,6 +26,7 @@ import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, @@ -31,6 +36,11 @@ import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; const encode = (value: string) => new TextEncoder().encode(value); +const cloudMintKeys = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + const withService = ( options: { readonly failHoldWrite?: boolean; @@ -45,8 +55,10 @@ const withService = ( }, body: (input: { readonly preferences: { readonly update: CloudLink.CloudLink["Service"]["updatePreferences"] }; + readonly mintCredential: CloudLink.CloudLink["Service"]["mintCredential"]; readonly stored: Map; readonly relayCalls: Array; + readonly pairingLinkLabels: Array; }) => Effect.Effect, ) => Effect.gen(function* () { @@ -55,8 +67,11 @@ const withService = ( [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")], [HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("false")], [PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")], + [CLOUD_MINT_PUBLIC_KEY, encode(cloudMintKeys.publicKey)], + [CLOUD_LINKED_USER_ID, encode("cloud-user-1")], ]); const relayCalls: Array = []; + const pairingLinkLabels: Array = []; const fetch: typeof globalThis.fetch = Object.assign( (_input: Parameters[0], init?: RequestInit) => { const payload = JSON.parse(String(init?.body)) as { holdWebhooksWhileOffline: boolean }; @@ -93,6 +108,7 @@ const withService = ( }), ) : Effect.sync(() => void stored.set(name, value)), + create: (name, value) => Effect.sync(() => void stored.set(name, value)), }), Layer.mock(ServerEnvironment.ServerEnvironment)({ getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-1")), @@ -103,7 +119,20 @@ const withService = ( Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone, }), - Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + createPairingLink: (input) => + Effect.sync(() => { + pairingLinkLabels.push(input?.label); + return { + id: "pairing-1", + credential: "pairing-credential", + scopes: input?.scopes ?? [], + subject: input?.subject ?? "", + createdAt: DateTime.makeUnsafe(0), + expiresAt: DateTime.makeUnsafe(120_000), + }; + }), + }), Layer.mock(CliTokenManager.CloudCliTokenManager)({}), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), @@ -114,16 +143,27 @@ const withService = ( HttpClient.make(() => Effect.die("unused")), ), NodeServices.layer, - NodeCrypto.layer, + NodePlatformCrypto.layer, ); return yield* Effect.gen(function* () { const link = yield* CloudLink.CloudLink; - return yield* body({ preferences: { update: link.updatePreferences }, stored, relayCalls }); + return yield* body({ + preferences: { update: link.updatePreferences }, + mintCredential: link.mintCredential, + stored, + relayCalls, + pairingLinkLabels, + }); }).pipe( Effect.provide( CloudLink.layer.pipe( - // These tests never sign proofs; the mocked secret store cannot create a key pair. - Layer.provide(Layer.mock(EnvironmentKeys.EnvironmentSigningKey)({ publicKey: "" })), + // The mocked secret store cannot create a key pair, and no test reads the signature. + Layer.provide( + Layer.mock(EnvironmentKeys.EnvironmentSigningKey)({ + publicKey: "", + sign: () => Effect.succeed("environment-signed-proof"), + }), + ), Layer.provide(layerDependencies), ), ), @@ -257,6 +297,32 @@ it.effect("an update the client walks away from still finishes", () => { ); }); +it.effect('names T3 Connect sessions "T3 Connect" in authorized clients', () => + withService({}, ({ mintCredential, pairingLinkLabels }) => + Effect.gen(function* () { + const proof = yield* signRelayJwt({ + privateKey: cloudMintKeys.privateKey, + typ: RELAY_MINT_REQUEST_TYP, + payload: { + iss: "https://relay.test", + aud: "t3-env:environment-1", + sub: "cloud-user-1", + jti: "mint-jti-1", + iat: 0, + exp: 60, + environmentId: "environment-1", + clientProofKeyThumbprint: "client-jkt", + cnf: { jkt: "client-jkt" }, + nonce: "mint-nonce-1", + scope: ["environment:connect"], + }, + }).pipe(Effect.provide(NodePlatformCrypto.layerSignature)); + yield* mintCredential({ proof }); + assert.deepEqual(pairingLinkLabels, ["T3 Connect"]); + }), + ), +); + it.effect("unlinks on startup and refuses to link when the policy turns Connect off", () => Effect.gen(function* () { const stored = new Map([ @@ -297,7 +363,7 @@ it.effect("unlinks on startup and refuses to link when the policy turns Connect HttpClient.make(() => Effect.die("unused")), ), NodeServices.layer, - NodeCrypto.layer, + NodePlatformCrypto.layer, ); yield* Effect.gen(function* () { const link = yield* CloudLink.CloudLink; diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index b6afd5e8ea9e5..e29d0c6c5ad6e 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -1594,7 +1594,7 @@ const make = Effect.gen(function* () { scopes: AuthStandardClientScopes, subject: "cloud-connect", ttl: Duration.minutes(2), - label: "T3 Connect connect", + label: "T3 Connect", proofKeyThumbprint: proof.clientProofKeyThumbprint, }); const responsePayload = { From cbf0f0a83f2e107f0859c37b02cbbe32debe92d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:52:21 -0800 Subject: [PATCH 025/690] fix(web): screen readers read the Update everything machine list as a table (#36445) --- .../SidebarUpdatePill.browser.test.tsx | 31 +++++++++++++++++-- .../sidebar/UpdateEverythingDialog.tsx | 30 +++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 03bb2c72849e2..0f751bc52aef6 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -5,12 +5,14 @@ import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { act, render } from "../../../test/render"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; +import type { UpdateEverythingRow } from "./updateEverything.logic"; const mocks = vi.hoisted(() => { const listeners = new Set<() => void>(); return { update: null as DesktopUpdateState | null, hasUpdates: false, + rows: [] as ReadonlyArray, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); @@ -39,7 +41,7 @@ vi.mock("./useUpdateEverything", () => ({ summary: "Update everything", version: null, providerCount: 0, - rows: [], + rows: mocks.rows, restarts: [], }), })); @@ -69,9 +71,16 @@ const RELEASE_NOTES: DesktopUpdateState["releaseNotes"] = [ { version: "0.0.50-nightly.2", items: ["Faster sidebar."], totalItems: 1 }, ]; -function setup(update: DesktopUpdateState | null, { hasUpdates = false } = {}) { +function setup( + update: DesktopUpdateState | null, + { + hasUpdates = false, + rows = [], + }: { hasUpdates?: boolean; rows?: ReadonlyArray } = {}, +) { mocks.update = update; mocks.hasUpdates = hasUpdates; + mocks.rows = rows; } function publish(update: DesktopUpdateState | null) { @@ -140,4 +149,22 @@ describe("SidebarUpdatePill update everything", () => { await userEvent.keyboard("{Escape}"); await vi.waitFor(() => expect(document.activeElement).toBe(button.element())); }); + + it("exposes the machines as a table with each value in its column", async () => { + const label = "DESKTOP-WORKSTATION-UPSTAIRS"; + setup(null, { + hasUpdates: true, + rows: [{ environmentId: null, label, app: "this-app", skipped: null, providers: ["Claude"] }], + }); + await using screen = await render(); + await userEvent.click(screen.getByRole("button", { name: "Update everything" })); + + const table = page.getByRole("table"); + await expect.element(table.getByRole("columnheader", { name: "Machine" })).toBeVisible(); + await expect.element(table.getByRole("columnheader", { name: "T3 Code" })).toBeVisible(); + await expect.element(table.getByRole("columnheader", { name: "Providers" })).toBeVisible(); + const row = table.getByRole("row", { name: new RegExp(label) }); + await expect.element(row.getByRole("cell", { name: label, exact: true })).toBeVisible(); + await expect.element(row.getByRole("cell", { name: "Claude", exact: true })).toBeVisible(); + }); }); diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index 86c1fd2841c7b..586438bb4420e 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -157,22 +157,30 @@ function UpdateEverythingDialogContent({

))} {/* Machine names truncate first. Provider lists wrap between names, never inside one. */} -
-
- Machine - T3 Code - Providers - +
+
+ Machine + T3 Code + Providers +
{updating.map((row) => (
- - + + {row.label} + - + {row.app !== null ? APP_LABELS[row.app] : null} {row.app === null && row.skipped !== null ? ( Skipped @@ -181,7 +189,7 @@ function UpdateEverythingDialogContent({ restarts last ) : null} - + {row.providers.map((provider, index) => ( {provider} @@ -189,7 +197,7 @@ function UpdateEverythingDialogContent({ ))} - + {row.environmentId !== null && restarting.has(row.environmentId) ? ( ) : null} From 42057b82d097156f56f9b487887226b99f018bcb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 13 Nov 2026 16:59:58 -0800 Subject: [PATCH 026/690] feat: agents can restyle T3 Code with theme colors and CSS (#35247) Co-authored-by: maria-rcks Co-authored-by: Claude Fable 5.2 --- apps/server/src/environmentTheme.test.ts | 180 +++++++++++++- apps/server/src/environmentTheme.ts | 221 +++++++++++++++++- apps/server/src/mcp/McpHttpServer.ts | 7 + .../src/mcp/toolkits/theme/handlers.test.ts | 97 ++++++++ .../server/src/mcp/toolkits/theme/handlers.ts | 79 +++++++ apps/server/src/mcp/toolkits/theme/tools.ts | 69 ++++++ .../Adapters/ClaudeAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 1 + .../src/provider/AgentContextBudget.test.ts | 2 + .../src/provider/agentContextBudget.json | 2 +- apps/server/src/server.ts | 6 +- apps/web/src/components/ProjectMonogram.tsx | 1 + .../chat/TimelineUserMessageRow.tsx | 2 + .../components/settings/ThemeCssEditor.tsx | 124 ++++++++++ .../components/settings/ThemeEditorPanel.tsx | 43 +++- apps/web/src/hooks/useDefaultTheme.test.ts | 12 +- apps/web/src/hooks/useDefaultTheme.ts | 47 +++- apps/web/src/themeCss.browser.test.ts | 43 ++++ apps/web/src/themeCss.ts | 64 +++++ apps/web/src/themeCssHooks.test.ts | 28 +++ apps/web/src/themePalette.test.ts | 2 + apps/web/src/themePalette.ts | 24 +- docs/user/appearance.md | 27 ++- evals/mcp-tool-use/cases/themes.ndjson | 3 + evals/mcp-tool-use/run.ts | 2 + packages/client-runtime/src/t3ToolSummary.ts | 6 + .../src/work-log/presentation.ts | 1 + packages/contracts/src/rpc.ts | 4 +- packages/contracts/src/server.ts | 4 + packages/contracts/src/settings.test.ts | 14 ++ packages/contracts/src/settings.ts | 20 ++ packages/shared/package.json | 4 + packages/shared/src/t3McpToolPresentation.ts | 4 + packages/shared/src/themeCss.test.ts | 23 ++ packages/shared/src/themeCss.ts | 81 +++++++ packages/shared/src/themePalettes.ts | 2 + 36 files changed, 1231 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/theme/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/theme/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/theme/tools.ts create mode 100644 apps/web/src/components/settings/ThemeCssEditor.tsx create mode 100644 apps/web/src/themeCss.browser.test.ts create mode 100644 apps/web/src/themeCss.ts create mode 100644 apps/web/src/themeCssHooks.test.ts create mode 100644 evals/mcp-tool-use/cases/themes.ndjson create mode 100644 packages/shared/src/themeCss.test.ts create mode 100644 packages/shared/src/themeCss.ts diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e44f32e60f5ab..1e8b538a665ae 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentThemeFile } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentThemeFile, + ServerSettingsError, +} from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +16,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); @@ -30,6 +35,9 @@ const SHARED_THEME: EnvironmentThemeFile = { colors: { canvas: "#eff1f5", accent: "#1e66f5" }, }; +/** The settings mock refuses to make this theme the default, as a managed policy can. */ +const REFUSED_THEME_ID = "refused"; + const withEnvironmentThemes = ( seeds: Readonly>, body: Effect.Effect< @@ -41,6 +49,8 @@ const withEnvironmentThemes = ( | Path.Path | Scope.Scope >, + /** Receives each theme and appearance the service makes the environment default. */ + selected: Array<{ readonly theme?: string; readonly appearance?: string }> = [], ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -56,6 +66,27 @@ const withEnvironmentThemes = ( Effect.provide( EnvironmentTheme.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge( + Layer.mock(ServerSettingsService)({ + updateSettings: (patch) => + patch.defaultTheme === REFUSED_THEME_ID + ? Effect.fail( + new ServerSettingsError({ + settingsPath: "settings.json", + operation: "write-managed-setting", + }), + ) + : Effect.sync(() => { + selected.push({ + ...(patch.defaultTheme === undefined ? {} : { theme: patch.defaultTheme }), + ...(patch.defaultAppearance === undefined + ? {} + : { appearance: patch.defaultAppearance }), + }); + return DEFAULT_SERVER_SETTINGS; + }), + }), + ), ), ), ); @@ -144,6 +175,62 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("puts the previous theme back when the new one cannot be selected", () => + withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: { ...NIGHTFALL_THEME, name: "Changed" } }) + .pipe(Effect.flip); + assert.strictEqual(refused._tag, "EnvironmentThemeNotSelectedError"); + assert.deepEqual(yield* themes.current, [{ id: REFUSED_THEME_ID, ...NIGHTFALL_THEME }]); + }), + ), + ); + + it.effect("does not replace a theme file it could not put back", () => { + const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) }); + return withEnvironmentThemes( + { [`${REFUSED_THEME_ID}.json`]: oversized }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const refused = yield* themes + .apply({ themeId: REFUSED_THEME_ID, theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be backed up/); + assert.strictEqual( + yield* fs.readFileString(`${config.environmentThemesDir}/${REFUSED_THEME_ID}.json`), + oversized, + ); + }), + ); + }); + + it.effect.skipIf(!symlinksSupported)("does not write through a symlink at its staging path", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const outside = path.join(environmentThemesDir, "..", "outside.txt"); + yield* fs.writeFileString(outside, "keep"); + yield* fs.symlink( + outside, + path.join(environmentThemesDir, `look.json.staging-${process.pid}`), + ); + + yield* themes.apply({ themeId: "look", theme: NIGHTFALL_THEME }); + assert.strictEqual(yield* fs.readFileString(outside), "keep"); + assert.deepEqual(yield* themes.current, [{ id: "look", ...NIGHTFALL_THEME }]); + }), + ), + ); + it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () => withEnvironmentThemes( {}, @@ -159,6 +246,97 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("publishes and selects a theme, and refuses unsafe CSS and unknown ids", () => { + const selected: Array<{ readonly theme?: string; readonly appearance?: string }> = []; + return withEnvironmentThemes( + {}, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + // A comment may name an address. Only a rule that loads one is refused. + const css = + "/* from url(https://example.com/guide) */ [data-chat-header] { border-bottom: 1px solid red; }"; + yield* themes.apply({ themeId: "agent-look", theme: { ...NIGHTFALL_THEME, css } }); + assert.deepEqual(yield* themes.current, [{ id: "agent-look", ...NIGHTFALL_THEME, css }]); + + const leaky = yield* themes + .apply({ + themeId: "leaky", + theme: { + ...NIGHTFALL_THEME, + css: 'input[value^="a"] { background: url(https://example.com/a); }', + }, + }) + .pipe(Effect.flip); + assert.match(leaky.message, /loads files/); + const reserved = yield* themes + .apply({ themeId: "dark", theme: NIGHTFALL_THEME }) + .pipe(Effect.flip); + assert.match(reserved.message, /not a valid theme id/); + const unknown = yield* themes.apply({ themeId: "missing" }).pipe(Effect.flip); + assert.match(unknown.message, /not a built-in or published theme/); + yield* themes.apply({ themeId: "grove" }); + yield* themes.apply({ themeId: "grove", appearance: "dark" }); + yield* themes.apply({ appearance: "light" }); + + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + ["agent-look"], + ); + // Only a theme that was published or already exists becomes the environment theme. + assert.deepEqual(selected, [ + { theme: "agent-look" }, + { theme: "grove" }, + { theme: "grove", appearance: "dark" }, + { appearance: "light" }, + ]); + }), + selected, + ); + }); + + it.effect("keeps the served theme when an update does not fit the size limit", () => { + const large = (name: string): EnvironmentThemeFile => ({ + ...NIGHTFALL_THEME, + name, + css: `/* ${"x".repeat(19_900)} */`, + }); + return withEnvironmentThemes( + { + ...Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `aa-${index}.json`, + encodeThemeFile(large(`Large ${index}`)), + ]), + ), + "zz-look.json": encodeThemeFile(NIGHTFALL_THEME), + }, + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const served = Effect.map(themes.current, (all) => + all.find((theme) => theme.id === "zz-look"), + ); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + const refused = yield* themes + .apply({ themeId: "zz-look", theme: large("Too much") }) + .pipe(Effect.flip); + assert.match(refused.message, /could not be published/); + assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME }); + + // A new theme that sorts first fits, and the last large one no longer does. + const before = (yield* themes.current).map((theme) => theme.id); + const displacing = yield* themes + .apply({ themeId: "a0-first", theme: large("Displacing") }) + .pipe(Effect.flip); + assert.match(displacing.message, /could not be published/); + assert.deepEqual( + (yield* themes.current).map((theme) => theme.id), + before, + ); + }), + ); + }); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 44e50bd103639..b658870f2e960 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -3,20 +3,26 @@ import * as NodeFS from "node:fs"; import { + type DefaultAppearancePreference, EnvironmentTheme, + type ServerSettingsError, EnvironmentThemeFile, EnvironmentThemeId, environmentThemeFileHasColors, } from "@t3tools/contracts"; -import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { themeCssLoadsRemoteFiles } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -25,10 +31,14 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import * as ServerSettings from "./serverSettings.ts"; const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( Schema.fromJsonString(EnvironmentThemeFile), ); +const encodeEnvironmentThemeFileJson = Schema.encodeEffect( + fromJsonStringPretty(EnvironmentThemeFile), +); const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); const THEME_FILE_SUFFIX = ".json"; @@ -43,16 +53,86 @@ interface PublishedThemes { readonly themes: ReadonlyArray; } +const INVALID_ID = + "is not a valid theme id. Use lowercase letters, digits, and hyphens, and not a built-in name."; +const NOT_PUBLISHED = "could not be published. The themes directory may be full or not writable."; + +/** A theme the service will not publish or select. `problem` ends the sentence naming it. */ +export class EnvironmentThemeRefusedError extends Schema.TaggedError()( + "EnvironmentThemeRefusedError", + { + themeId: Schema.String, + problem: Schema.String, + known: Schema.optional(Schema.Array(Schema.String)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const known = this.known ? ` Available: ${this.known.join(", ")}.` : ""; + return `Theme "${this.themeId}" ${this.problem}${known}`; + } +} + +/** The settings write that makes a theme or mode the default failed. */ +export class EnvironmentThemeNotSelectedError extends Schema.TaggedError()( + "EnvironmentThemeNotSelectedError", + { + /** Absent when only the appearance was set. */ + themeId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const subject = this.themeId === undefined ? "The appearance" : `Theme "${this.themeId}"`; + return `${subject} was not applied, because the settings could not be saved.`; + } +} + export class EnvironmentThemeService extends Context.Service< EnvironmentThemeService, { readonly current: Effect.Effect>; + /** The default theme id and the published themes, plus the one named `themeId`. */ + readonly read: (themeId?: string) => Effect.Effect< + { + readonly current: string; + readonly themes: ReadonlyArray; + readonly theme?: EnvironmentTheme; + }, + EnvironmentThemeRefusedError | ServerSettingsError + >; + + /** + * Makes `themeId` the environment theme and `appearance` its mode, so connected clients + * switch to them. With `theme`, first publishes that file under the id, replacing one already + * there. + */ + readonly apply: ( + input: + | { + readonly themeId: string; + readonly theme?: EnvironmentThemeFile | undefined; + readonly appearance?: Exclude | undefined; + } + | { readonly appearance: Exclude }, + ) => Effect.Effect; + /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; } >()("t3/environmentTheme/EnvironmentThemeService") {} +/** True when anything, a dangling symlink included, is at the path. */ +const pathIsTaken = (filePath: string): boolean => { + try { + NodeFS.lstatSync(filePath); + return true; + } catch { + return false; + } +}; + /** Validates and reads one handle; rejects symlinked files, allows symlinked directories. * Uses non-blocking opens against FIFOs; Windows lacks these flags and checks symlinks with lstat. */ export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { @@ -156,6 +236,7 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); @@ -197,8 +278,146 @@ const make = Effect.gen(function* () { Effect.asVoid, ); + // One publish at a time: they share a staging path, and each one checks what is served after it. + const publishSemaphore = yield* Semaphore.make(1); + + /** Callers hold `publishSemaphore`. Succeeds with the effect that undoes this publish. */ + + const publish = Effect.fnUntraced(function* (themeId: string, file: EnvironmentThemeFile) { + if (UNPUBLISHABLE_THEME_IDS.has(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + if (!environmentThemeFileHasColors(file)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "has no colors. Give canvas and accent, or at least one color role.", + }); + } + if (file.css !== undefined && themeCssLoadsRemoteFiles(file.css)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: + "has CSS that loads files. Remove @import, image-set(), and url() that is not data: or #id.", + }); + } + const contents = yield* encodeEnvironmentThemeFileJson(file).pipe( + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + if (Buffer.byteLength(contents) > MAX_THEME_FILE_BYTES) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: `is larger than ${MAX_THEME_FILE_BYTES} bytes.`, + }); + } + + const destination = `${environmentThemesDir}/${themeId}${THEME_FILE_SUFFIX}`; + // The watcher reads only .json names, so it never sees a half-written file. + const staging = `${destination}.staging-${process.pid}`; + // Exclusive create: a link left at the staging path is removed, never written through. + const replace = (next: string) => + fs.remove(staging, { force: true }).pipe( + Effect.andThen(fs.writeFileString(staging, next, { flag: "wx" })), + Effect.andThen(fs.rename(staging, destination)), + Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore)), + ); + + // The guarded reader does not block on a FIFO and does not follow a symlink. + const previous = Option.fromNullOr(readThemeFileGuarded(destination, MAX_THEME_FILE_BYTES)); + // A file it cannot read back, such as one over the size limit, could not be restored. + if (Option.isNone(previous) && pathIsTaken(destination)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "would replace a file that could not be backed up. Use another theme id.", + }); + } + const servedBefore = (yield* refresh).themes.map((theme) => theme.id); + // Puts back what was there, so a failed apply does not take a working theme away. + const revert = Option.match(previous, { + onNone: () => fs.remove(destination, { force: true }), + onSome: replace, + }).pipe(Effect.ignore, Effect.andThen(refresh), Effect.asVoid); + + yield* fs.makeDirectory(environmentThemesDir, { recursive: true }).pipe( + Effect.andThen(replace(contents)), + Effect.mapError( + (cause) => new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED, cause }), + ), + ); + // The reader stops at its count and size limits, so this file can go unserved, or it can + // push another theme out. Both count as a failed publish. + const served = new Set((yield* refresh).themes.map((theme) => theme.id)); + if (served.has(themeId) && servedBefore.every((id) => served.has(id))) return revert; + yield* revert; + return yield* new EnvironmentThemeRefusedError({ themeId, problem: NOT_PUBLISHED }); + }); + + const apply: EnvironmentThemeService["Service"]["apply"] = Effect.fn( + "EnvironmentThemeService.apply", + )(function* (input) { + const themeId = "themeId" in input ? input.themeId : undefined; + const theme = "theme" in input ? input.theme : undefined; + const { appearance } = input; + if (themeId !== undefined && !isEnvironmentThemeId(themeId)) { + return yield* new EnvironmentThemeRefusedError({ themeId, problem: INVALID_ID }); + } + // Timestamps make clients apply a set again when its value did not change. Theme and + // appearance have one each, so setting one does not undo a later local choice of the other. + const select = Effect.gen(function* () { + const setAt = DateTime.formatIso(yield* DateTime.now); + yield* settings + .updateSettings({ + ...(themeId === undefined ? {} : { defaultTheme: themeId, defaultThemeSetAt: setAt }), + ...(appearance === undefined + ? {} + : { defaultAppearance: appearance, defaultAppearanceSetAt: setAt }), + }) + .pipe(Effect.mapError((cause) => new EnvironmentThemeNotSelectedError({ themeId, cause }))); + }); + // One at a time, so a selection never sees a file that a failed publish then takes back. + // If the theme cannot be selected, the file it replaced comes back. + yield* publishSemaphore.withPermits(1)( + Effect.gen(function* () { + if (themeId === undefined) return yield* select; + if (theme === undefined) { + const known = [...BUILT_IN_THEME_IDS, ...(yield* refresh).themes.map(({ id }) => id)]; + if (!known.includes(themeId)) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a built-in or published theme.", + known, + }); + } + return yield* select; + } + const revert = yield* publish(themeId, theme); + yield* select.pipe(Effect.tapError(() => revert)); + }), + ); + }); + + const read: EnvironmentThemeService["Service"]["read"] = Effect.fn( + "EnvironmentThemeService.read", + )(function* (themeId) { + const { themes } = yield* refresh; + const { defaultTheme } = yield* settings.getSettings; + if (themeId === undefined) return { current: defaultTheme, themes }; + const theme = themes.find((candidate) => candidate.id === themeId); + if (theme === undefined) { + return yield* new EnvironmentThemeRefusedError({ + themeId, + problem: "is not a published theme.", + known: themes.map(({ id }) => id), + }); + } + return { current: defaultTheme, themes, theme }; + }); + return { current: Effect.map(refresh, (state) => state.themes), + read, + apply, get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 597e78d810084..062c51ac6d7c2 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -53,6 +53,8 @@ import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; +import { ThemeToolkit } from "./toolkits/theme/tools.ts"; +import * as ThemeHandlers from "./toolkits/theme/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; @@ -1258,6 +1260,10 @@ const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); +const layerThemeToolkit = toolkitRegistration(ThemeToolkit, ThemeHandlers.layer).pipe( + Layer.provide(ThreadCommandExecutor.layer), +); + export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); export const layerAttachmentToolkit = toolkitRegistration( @@ -1323,6 +1329,7 @@ export const layerTools = Layer.mergeAll( layerAttachmentToolkit, layerProjectRegistration, layerEnvironmentToolkit, + layerThemeToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, diff --git a/apps/server/src/mcp/toolkits/theme/handlers.test.ts b/apps/server/src/mcp/toolkits/theme/handlers.test.ts new file mode 100644 index 0000000000000..4da9c8a15f421 --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type EnvironmentThemeFile, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { + EnvironmentThemeRefusedError, + EnvironmentThemeService, +} from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as ThemeHandlers from "./handlers.ts"; +import { ThemeToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:theme"); +const threadId = ThreadId.make("thread:theme"); +const AGENT_THEME = { + name: "Agent Look", + appearance: "dark", + canvas: "#101010", + accent: "#7aa2f7", + css: "[data-chat-header] { border-bottom: 1px solid red; }", +} satisfies EnvironmentThemeFile; + +it.effect("applies the theme through the service and reports a refusal as an invalid request", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:theme", + thread: { + threadId, + providerSessionId: "provider:theme", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(liveThreadShell(threadId)), + }), + Layer.mock(EnvironmentThemeService)({ + apply: (input) => + "themeId" in input && input.themeId === "missing" + ? Effect.fail( + new EnvironmentThemeRefusedError({ + themeId: input.themeId, + problem: "is not a built-in or published theme.", + }), + ) + : Effect.void, + }), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ThemeToolkit; + const call = (params: Parameters>[1]) => + toolkit.handle("t3_theme_set", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((parts) => parts.at(-1)?.result), + ); + + expect(yield* call({ themeId: "agent-look", theme: AGENT_THEME })).toEqual({ + themeId: "agent-look", + published: true, + }); + expect(yield* call({ themeId: "grove" })).toEqual({ themeId: "grove", published: false }); + expect(yield* call({ appearance: "dark" })).toEqual({ + appearance: "dark", + published: false, + }); + expect(yield* call({ theme: AGENT_THEME })).toMatchObject({ code: "invalid_request" }); + expect(yield* call({})).toMatchObject({ code: "invalid_request" }); + expect(yield* call({ themeId: "missing" })).toMatchObject({ + code: "invalid_request", + message: 'Theme "missing" is not a built-in or published theme.', + }); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ThemeHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/theme/handlers.ts b/apps/server/src/mcp/toolkits/theme/handlers.ts new file mode 100644 index 0000000000000..ad469095b0e0d --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/handlers.ts @@ -0,0 +1,79 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import { THEME_GUIDE } from "@t3tools/shared/themeCss"; +import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Effect from "effect/Effect"; +import * as Struct from "effect/Struct"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { unavailable } from "../../threadAccess.ts"; +import { ThemeToolkit } from "./tools.ts"; + +export const layer = McpToolAccess.toLayer(ThemeToolkit, { + t3_theme_read: McpToolAccess.reads(({ themeId }) => + Effect.gen(function* () { + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const { + current, + themes: published, + theme, + } = yield* themes.read(themeId).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + ServerSettingsError: () => Effect.fail(unavailable()), + }), + ); + return { + current, + builtIn: BUILT_IN_THEME_IDS, + published: published.map(({ id, name, css }) => ({ id, name, hasCss: Boolean(css) })), + ...(theme === undefined ? {} : { theme: Struct.omit(theme, ["id"]) }), + guide: THEME_GUIDE, + }; + }), + ), + t3_theme_set: McpToolAccess.writesEnvironment(({ themeId, theme, appearance }, check) => + Effect.gen(function* () { + const input = + themeId !== undefined + ? { themeId, theme, appearance } + : theme === undefined && appearance !== undefined + ? { appearance } + : undefined; + if (input === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Give `themeId`, `appearance`, or both. `theme` needs a `themeId`.", + }); + } + const scope = yield* McpInvocationContext.McpInvocationContext; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const themes = yield* EnvironmentTheme.EnvironmentThemeService; + const set = Effect.gen(function* () { + // The turn may have ended, or the thread's modes changed, while this waited for the lock. + yield* check; + yield* themes.apply(input).pipe( + Effect.catchTags({ + EnvironmentThemeRefusedError: (error) => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + EnvironmentThemeNotSelectedError: () => Effect.fail(unavailable()), + }), + ); + return { + ...(themeId === undefined ? {} : { themeId }), + ...(appearance === undefined ? {} : { appearance }), + published: theme !== undefined, + }; + }); + return yield* scope.thread === undefined + ? set + : executor.withLock(scope.thread.threadId, set); + }), + ), +}); diff --git a/apps/server/src/mcp/toolkits/theme/tools.ts b/apps/server/src/mcp/toolkits/theme/tools.ts new file mode 100644 index 0000000000000..291a34f6ecb6b --- /dev/null +++ b/apps/server/src/mcp/toolkits/theme/tools.ts @@ -0,0 +1,69 @@ +import { + DefaultThemePreference, + EnvironmentThemeFile, + EnvironmentThemeId, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import * as EnvironmentTheme from "../../../environmentTheme.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; + +const Appearance = Schema.Literals(["light", "dark", "system"]); + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + EnvironmentTheme.EnvironmentThemeService, + ThreadCommandExecutor.ThreadCommandExecutor, + ], +}; + +const ThemeReadTool = Tool.make("t3_theme_read", { + ...shared, + description: + "Read T3 Code's own themes and the guide to restyle it: color roles and CSS selectors. Call before `t3_theme_set`.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey( + EnvironmentThemeId.annotate({ description: "Also return this published theme's file." }), + ), + }), + success: Schema.Struct({ + current: DefaultThemePreference, + builtIn: Schema.Array(Schema.String), + published: Schema.Array( + Schema.Struct({ + id: EnvironmentThemeId, + name: Schema.String, + hasCss: Schema.Boolean, + }), + ), + theme: Schema.optionalKey(EnvironmentThemeFile), + guide: Schema.String, + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const ThemeSetTool = Tool.make("t3_theme_set", { + ...shared, + description: + "Restyle T3 Code itself, only when the user asks. With `theme`, publish it as `themeId` (the same id updates it) and switch this machine's web and desktop clients to it. Without `theme`, switch to a built-in or published `themeId`. `appearance` switches them to light, dark, or system mode, with or without a theme. Live full-access or default caller thread only.", + parameters: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + theme: Schema.optionalKey(EnvironmentThemeFile), + appearance: Schema.optionalKey(Appearance), + }), + success: Schema.Struct({ + themeId: Schema.optionalKey(EnvironmentThemeId), + appearance: Schema.optionalKey(Appearance), + published: Schema.Boolean, + }), +}).annotate(Tool.Destructive, true); + +export const ThemeToolkit = Toolkit.make(ThemeReadTool, ThemeSetTool); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 343675e2cfc40..dd350fb736216 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -92,6 +92,7 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; +import { ThemeToolkit } from "../../mcp/toolkits/theme/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; @@ -662,6 +663,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(WorktreeToolkit.tools), ...Object.values(ProjectToolkit.tools), ...Object.values(EnvironmentToolkit.tools), + ...Object.values(ThemeToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), ...Object.values(MessageToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index daf472ad6475d..f1ae8c58e7ae9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2275,6 +2275,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_environment_links", + "mcp__t3-code__t3_theme_read", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", "mcp__t3-code__send_to_user", diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 5f4cb5ed4b8ed..2a7d02fc28173 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -44,6 +44,7 @@ import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; import { DocumentText } from "./DocumentText.ts"; +import { EnvironmentThemeService } from "../environmentTheme.ts"; import { ProviderRegistry } from "./ProviderRegistry.ts"; import budget from "./agentContextBudget.json" with { type: "json" }; @@ -52,6 +53,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(AssetSigner)({}), Layer.mock(DeviceService)({}), Layer.mock(DocumentText)({}), + Layer.mock(EnvironmentThemeService)({}), Layer.mock(GitVcsDriver)({}), Layer.mock(GitWorkflowService)({}), Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index ae7da1e678118..0a354c1e7d560 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 48566 + "mcpToolListJsonBytes": 49806 } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dc2e1f21c1022..12eeb8cbda5f6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -707,15 +707,15 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(SecretCheck.layer), Layer.provideMerge(SecretResolver.layer), + // Above the settings it writes when a theme is applied. + Layer.provideMerge(EnvironmentTheme.layer), Layer.provideMerge(layerServerSettings), Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), Layer.provideMerge(layerPersistence), - Layer.provideMerge( - Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), - ), + Layer.provideMerge(Layer.mergeAll(Keybindings.layer, UsageLimitSources.layer)), Layer.provideMerge(ProviderRegistry.layer), Layer.provideMerge( ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(SqlitePersistence.layerConfig)), diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index 3d7e5520ac8c4..3fe69b8b16d1d 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -17,6 +17,7 @@ export function ProjectMonogram({ return (