Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
812c0a7
feat(devices): support Android EAS Simulator sessions (#26797)
SchroederNathan Oct 29, 2026
cba3059
fix(web,mobile): image-only messages show the image, not the placehol…
maria-rcks Oct 30, 2026
b42adff
ci(mobile): production deploys use a Release Manager Expo token (#27285)
juliusmarminge Oct 30, 2026
f58fad0
ci(mobile): maintainers can deploy EAS previews for reviewed fork com…
juliusmarminge Oct 30, 2026
d7b6cf7
fix(server): agents yield CPU, tasks, and reclaim to the server under…
juliusmarminge Oct 30, 2026
44d508f
feat(server): `t3 service protect` keeps the server alive when agents…
juliusmarminge Oct 30, 2026
8ab098a
fix(server): held queues release agent and scheduled messages once th…
juliusmarminge Oct 30, 2026
36dac68
fix(server): restart continuations retry failed starts and respect a …
juliusmarminge Oct 30, 2026
6d12bb1
fix(server): Stop during a restart no longer leaves the thread stuck …
t3dotgg Oct 30, 2026
8970a32
fix(scripts): bound check:changed concurrency and step time (#27227)
juliusmarminge Oct 30, 2026
c8a606c
fix(shared): durations under 10s drop a trailing .0 (#27316)
maria-rcks Oct 30, 2026
f48fb51
feat(web): switch the composer between rich text and Markdown source …
maria-rcks Oct 30, 2026
a773dc3
fix(server): t3_thread_organize rejects unknown arguments instead of …
juliusmarminge Oct 30, 2026
aa11ef6
fix(web): composer Markdown source button shows the pilcrow again (#2…
maria-rcks Oct 30, 2026
3dd62dc
revert(web): remove the composer rich text toggle button and mod+/ sh…
maria-rcks Oct 30, 2026
2ba2426
fix(server): HTML preview browsers quit when done instead of waiting …
juliusmarminge Oct 30, 2026
05f0d9b
fix(terminal): retain and pace slow fallback process queries (#24640)
juliusmarminge Oct 30, 2026
f3b0194
fix(server): show readable provider runtime failure banners (#24614)
juliusmarminge Oct 30, 2026
2c119bd
fix(gitlab): share merge request reads across detail and checks (#24392)
juliusmarminge Oct 30, 2026
adba9a3
fix(usage): count Claude advisor calls under the advisor model (#24139)
fixfon Oct 30, 2026
d5536f6
fix(web): name the sidebar logo link T3 Code home (#24209)
shivamhwp Oct 30, 2026
0a455d7
fix(server): fail Claude turns waiting for unsupported mod input (#24…
juliusmarminge Oct 30, 2026
6b1245c
fix(server): Stop always completes, and an agent that ignores it can …
juliusmarminge Oct 30, 2026
35c1210
fix: guest links can load earlier turns (#27354)
juliusmarminge Oct 30, 2026
da8cc59
fix(ci): repair main after merge burst
juliusmarminge Oct 30, 2026
fa4e467
fix(server): let slow worktree checkouts finish (#23802)
juliusmarminge Oct 30, 2026
97e35bc
feat(settings): point provider environment variables at a secret prov…
yordis Oct 30, 2026
476590d
feat(desktop): back/forward buttons and trackpad swipe navigation (#2…
juliusmarminge Oct 30, 2026
5ef5685
feat(web): review local commits in the Diff pane (#19941)
Bil0000 Oct 30, 2026
305a90d
fix(relay): show the account's real tunnel cap in T3 Connect settings…
juliusmarminge Oct 30, 2026
5cbc207
fix(web): streaming replies no longer re-render every visible timelin…
juliusmarminge Oct 30, 2026
d8c926c
perf(web): streaming replies re-render only their last Markdown block…
juliusmarminge Oct 30, 2026
aaee774
perf(web): the git actions panel no longer re-renders on every stream…
juliusmarminge Oct 30, 2026
3cbc982
perf(web): streamed assistant text re-renders only its own row (#27419)
juliusmarminge Oct 30, 2026
32ecd71
fix(server): `t3 service protect` can ask for your sudo password (#27…
juliusmarminge Oct 30, 2026
783c246
Merge upstream/main 32ecd71726 into coder-only
henrychu04 Oct 30, 2026
6a4613d
Mock readThreadShell and readProject in openPullRequestLink.react.test
henrychu04 Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/coderEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const CODER_ENVIRONMENT_CAPABILITIES = {
projectSettingsOverrides: true,
projectProviderInstanceOverrides: true,
threadSnooze: true,
threadForceStop: true,
environmentThemes: true,
providerWorkspaceSnapshotsOnDemand: true,
threadPinning: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
],
})
.annotate(Tool.Title, "Organize a thread")
.annotate(Tool.Strict, true)
.annotate(Tool.Destructive, true);

const commandTool = {
Expand Down
166 changes: 166 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9035,6 +9035,172 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

// regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it
it.effect.each([
{ case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true },
{ case: "keeps a turn running beside a passive mod pane", focus: false },
] as const)("$case", ({ focus }) =>
Effect.scoped(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>();
const harness = yield* makeWakeHarnessWithOptions({
close: () => Deferred.succeed(closed, undefined),
});
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"),
text: "Run rm -r build",
attachments: [],
}),
);
// Captured from Claude 2.1.303 with the blast-radius mod holding a Bash
// call: the mod waits for keyboard input in its pane and nothing else
// arrives until it gives up.
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_panes",
panes: [
{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 },
],
shown_id: "blast-radius",
focused_id: null,
focus_requested_id: focus ? "blast-radius" : null,
uuid: "00000000-0000-4000-8000-000000000795",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_invalidate",
event: "ui.render",
uuid: "00000000-0000-4000-8000-000000000796",
session_id: WAKE_NATIVE_SESSION,
}),
);
// Frames are handled in order, so a later notice proves the pane frame
// was handled without the turn ending.
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "informational",
content: "Processing barrier.",
level: "warning",
uuid: "00000000-0000-4000-8000-000000000797",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* harness.until(
() =>
harness.terminalEvents().length > 0 ||
harness.events.some(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "system_notice",
),
);
const [terminal] = harness.terminalEvents();
if (!focus) {
assert.equal(terminal, undefined);
return;
}
assert.equal(terminal?.status, "failed");
if (terminal?.status !== "failed") return;
assert.include(terminal.failure.message, "blast-radius");
// The held tool call is abandoned by closing the query, never answered.
yield* Deferred.await(closed);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

// regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process
it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () =>
Effect.scoped(
Effect.gen(function* () {
const releaseClose = yield* Deferred.make<void>();
const replacementClosedOld = yield* Deferred.make<void>();
let oldCloses = 0;
const harness = yield* makeWakeHarnessWithOptions({
// The held CLI never answers an interrupt; closure must not need one.
interrupt: Effect.never,
close: (sdkMessages) =>
Effect.suspend(() =>
++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void,
).pipe(
Effect.andThen(Deferred.await(releaseClose)),
Effect.andThen(Queue.shutdown(sdkMessages)),
),
freshQueueOnReopen: true,
});
// Added after the harness so it runs first: a failed assertion must not
// leave teardown waiting on the held close.
yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined));
const now = yield* DateTime.now;
const turnInput = makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"),
text: "Run rm -r build",
attachments: [],
});
yield* harness.runtime.startTurn(turnInput);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_panes",
panes: [
{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 },
],
shown_id: "blast-radius",
focused_id: null,
focus_requested_id: "blast-radius",
uuid: "00000000-0000-4000-8000-000000000798",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed");

const followUp = yield* harness.runtime
.startTurn({
...turnInput,
attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"),
providerTurnOrdinal: 2,
})
.pipe(Effect.forkScoped);
// Reusing the closing process would offer the prompt to it instead.
yield* Effect.raceFirst(
Deferred.await(replacementClosedOld),
harness.until(() => harness.offeredMessages.length >= 2),
);
assert.isTrue(yield* Deferred.isDone(replacementClosedOld));
yield* Deferred.succeed(releaseClose, undefined);
yield* Fiber.join(followUp);
assert.equal(harness.processQueues.length, 2);
yield* harness.offerAndWait(
makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

// regression: #16897 a prompt whose uuid the session already holds must not hang or end early
it.effect("keeps a started prompt running until its result", () =>
Effect.scoped(
Expand Down
84 changes: 58 additions & 26 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500;
// A steer waits at most this long, from its request, for its foreground
// subagents to move to the background before it is sent.
const BACKGROUND_MOVE_TIMEOUT_MS = 5_000;
// How long a Stop waits for the CLI to exit before the user is offered Force stop.
const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000;

const CLAUDE_GROUP_WATCH_SCRIPT = [
"{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } </dev/null >/dev/null 2>&1 &",
Expand Down Expand Up @@ -4090,6 +4092,20 @@ function claudeTaskString(value: unknown, key: string): string | undefined {
return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined;
}

// A Claude Code mod can hold a tool call until the user acts in its terminal
// pane. T3 cannot provide the requested pane interaction.
// Passive panes never request focus and keep working.
function claudeFocusRequestedPanePlugin(message: SDKMessage): string | null {
if (message.type !== "system" || claudeTaskField(message, "subtype") !== "ui_panes") return null;
const paneId = claudeTaskString(message, "focus_requested_id");
if (paneId === undefined) return null;
const panes = claudeTaskField(message, "panes");
const pane = Array.isArray(panes)
? panes.find((entry) => claudeTaskField(entry, "id") === paneId)
: undefined;
return claudeTaskString(pane, "plugin") ?? paneId;
}

function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined {
switch (value) {
case "pending":
Expand Down Expand Up @@ -8069,6 +8085,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
return;
}
const focusedPlugin = claudeFocusRequestedPanePlugin(message);
if (focusedPlugin !== null && context === (yield* Ref.get(activeTurn))) {
yield* Effect.logWarning("orchestration-v2.claude-mod-pane-focus-unsupported", {
providerTurnId: context.providerTurnId,
plugin: focusedPlugin,
});
liveQuery.stopping = true;
yield* finalizeActiveTurn({
context,
status: "failed",
completedAt: yield* DateTime.now,
failure: makeProviderFailure({
class: "provider_error",
code: "claude_mod_ui_unsupported",
message: `The Claude Code mod "${focusedPlugin}" is waiting for input in its terminal pane, which T3 Code cannot show. Disable the mod for sessions run from T3 Code, or run the task in the Claude Code terminal.`,
}),
});
// Closing ends the CLI process and abandons the held tool call
// without answering it. Not interrupt: the held CLI may never ack it.
yield* liveQuery.query.close.pipe(Effect.ignore);
return;
}
yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message });

if (message.type === "stream_event" && !message.parent_tool_use_id) {
Expand Down Expand Up @@ -9704,6 +9742,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (
existing !== null &&
existing.nativeThreadId === nativeThreadId &&
!existing.stopping &&
isClaudeProviderContinuationTurn(turnInput)
) {
return existing;
Expand Down Expand Up @@ -9735,6 +9774,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
existing !== null &&
existing.nativeThreadId === nativeThreadId &&
existing.queryPolicyKey === queryPolicyKey &&
!existing.stopping &&
existing.selection.queryIdentity === compiledSelection.queryIdentity
) {
// A denied ExitPlanMode leaves Claude in plan mode. Restore only from plan:
Expand Down Expand Up @@ -10320,44 +10360,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* existing.query.interrupt;
yield* existing.query.close.pipe(Effect.ignore);
const closed = yield* Deferred.await(existing.closed).pipe(
Effect.timeoutOption("10 seconds"),
Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS),
);
if (Option.isSome(closed)) {
return;
}

const completedAt = yield* DateTime.now;
// The turn stays open: the CLI may still be working, and the user decides
// whether to Force stop it.
yield* Effect.logWarning("orchestration-v2.claude-query-interrupt-timeout", {
providerSessionId: input.providerSessionId,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
});
const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
current?.query === existing.query ? [true, null] : [false, current],
);
if (ownedLiveQuery) {
existing.stopping = true;
yield* finalizeActiveTurnAfterQueryExit(existing);
yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
} else {
yield* finalizeActiveTurn({
context: currentTurn,
status: "interrupted",
completedAt,
});
}
yield* Deferred.succeed(existing.closed, undefined);
return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
timeoutMs: CLAUDE_STOP_CONFIRM_TIMEOUT_MS,
});
},
(effect, turnInput) =>
effect.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapter.ProviderAdapterInterruptError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
cause,
}),
Effect.mapError((cause) =>
cause._tag === "ProviderAdapterInterruptTimeoutError"
? cause
: new ProviderAdapter.ProviderAdapterInterruptError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
cause,
}),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import {
ProviderReplayEntry,
ProviderInstanceId,
type ProviderInstanceEnvironment,
type ResolvedProviderInstanceEnvironment,
type ProviderReplayTranscript,
type ProviderReplayEntry as ProviderReplayEntryType,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -363,7 +363,7 @@ export function layer<E, R>(input: {
readonly spawner: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner, E, R>;
readonly binaryPath: string;
readonly launchArgs: string;
readonly environment?: ProviderInstanceEnvironment;
readonly environment?: ResolvedProviderInstanceEnvironment;
}) {
return ProviderAdapterRegistry.layerFromAdaptersEffect(
PiAdapterV2Driver.create({
Expand Down
Loading