Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c216ba4
perf(server): stop remapping every thread on each thread event (#13720)
t3dotgg Sep 26, 2026
5660ab5
Remove unused items tracking from Claude adapter state (#13718)
t3dotgg Sep 26, 2026
20885ce
feat(observability): name the command on subprocess spans (#13701)
t3dotgg Sep 26, 2026
7c901a3
fix(cli): t3 triage points agents at log files that exist (#13685)
t3dotgg Sep 26, 2026
8aa5be2
fix(server): the SQLite WAL file shrinks back after large writes (#13…
t3dotgg Sep 26, 2026
75bf92e
feat(cli): summarize the server trace file from the command line (#13…
t3dotgg Sep 26, 2026
6530de0
perf(server): pull request sync reads only threads with linked pull r…
t3dotgg Sep 26, 2026
1dc8cbe
feat(observability): write a server heap snapshot on SIGUSR2 (#13694)
t3dotgg Sep 26, 2026
574b180
perf(server): shutdown no longer rewrites every stopped session row (…
t3dotgg Sep 26, 2026
b6eefc9
perf(server): build the thread list snapshot without decoding it twic…
t3dotgg Sep 26, 2026
b2577d6
fix(client): slow servers finish loading the thread list instead of l…
t3dotgg Sep 26, 2026
6989856
perf(web): hidden terminal drawers no longer keep full thread history…
t3dotgg Sep 26, 2026
3b0a495
perf(server): per-thread settlement and PR checks no longer rebuild t…
t3dotgg Sep 26, 2026
ecd3237
fix(mobile): running threads open at the latest message (#13530)
AKolenda Sep 26, 2026
ee18e56
feat(observability): record event loop stalls in the server trace (#1…
t3dotgg Sep 26, 2026
999161e
perf(server): stop re-running git for every project each minute (#13689)
t3dotgg Sep 26, 2026
94c4216
fix(usage): hide the Cursor keychain prompt when Cursor isn't set up …
Gigioxx Sep 26, 2026
04c15f3
feat(web): add chat width setting for wide screens (#11594)
otavio Sep 26, 2026
4408bf5
fix(opencode): accept v2 serve ready line when spawning server (#13651)
shirishpothi Sep 26, 2026
1e844a3
fix(editors): stop treating the agy CLI as the Antigravity IDE (#7079)
ishaanko Sep 26, 2026
eeea71a
fix(web): make the empty workspace draggable on desktop (#13713)
otavio Sep 26, 2026
dd8332d
fix(server): installed editors no longer vanish when discovery is slo…
bfowler Sep 26, 2026
29a3266
fix(git): exclude SSH ports from provider URLs (#12537)
GaMeRaM Sep 26, 2026
5d707bb
fix(web): Mod+B bolds on non-Latin layouts (#13409)
ValeraZSD Sep 26, 2026
a21b42c
fix(server): prune expired replay-protection files from the secrets d…
t3dotgg Sep 26, 2026
8bc9b78
fix(web): terminal links drop a trailing colon (#13408)
ValeraZSD Sep 26, 2026
4923ff4
fix(server): bump node-pty to 1.2.0-beta.15 for linux-arm64 prebuild …
Ephraim-9 Sep 26, 2026
92f0af2
Show a focus ring on sidebar thread and draft rows (#13344)
ryanilano Sep 26, 2026
dab9561
fix(mobile): keep composer within folded screen after resume (#13310)
PixPMusic Sep 26, 2026
2679d27
fix(server): let OpenCode generate session titles (#13368)
macodev00 Sep 26, 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
5 changes: 3 additions & 2 deletions .github/triage/PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ different code depending on it:

Then work from evidence, not assumption. In rough order of value:

- The server log and the trace file (`server.trace.ndjson`) around the time of the
problem. Recent failures usually leave a trail here.
- The trace file (`server.trace.ndjson`) around the time of the problem, plus the
service log or desktop backend logs from the context file if they exist. Recent
failures usually leave a trail here.
- The provider event log, for problems with claude/codex/cursor sessions.
- The SQLite database. Read it freely, but only write when a write is necessary
to fix the problem the user described, and get their explicit permission
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,10 +679,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const isSplitLayout = layoutVariant === "split";
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const workspaceContentWidth = useWorkspaceContentWidth();
// Clearing animated width can retain the unfolded width after Android resumes folded.
// Assign both layouts explicitly so the dock always follows its current parent.
const composerWidthStyle = useAnimatedStyle(() =>
isSplitLayout && workspaceContentWidth !== null
? { width: workspaceContentWidth.value, right: undefined }
: { width: undefined, right: 0 },
? { width: workspaceContentWidth.value }
: { width: "100%" },
);
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
Expand Down Expand Up @@ -964,7 +966,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
<Animated.View
layout={COMPOSER_LAYOUT_TRANSITION}
pointerEvents="box-none"
style={[{ position: "absolute", bottom: 0, left: 0, right: 0 }, composerWidthStyle]}
style={[{ position: "absolute", bottom: 0, left: 0 }, composerWidthStyle]}
>
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2477,7 +2477,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
// on Android the declarative contentInset floor covers this same window.
const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`;
// The thinking row a running thread shows while its messages load is not
// content: the list must still remount, and so open at the end, when they
// arrive.
const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`;
useLayoutEffect(() => {
const bottom = props.contentInsetEndAdjustment.value;
if (bottom > 0) {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ export function UsageRouteScreen() {
);
const isFocused = useIsFocused();
const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
const cursorAccessEnvironments = selectedEnvironments.filter((environment) =>
environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"),
const cursorAccessEnvironments = selectedEnvironments.filter(
(environment) => environment.needsCursorKeychainAccess,
);
const refreshAfterCursorEnable = () => {
void refresh();
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type UsageSummary,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { refreshUsage } from "@t3tools/client-runtime/state/usage";
import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
Expand All @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {
readonly isConnected: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
readonly needsCursorKeychainAccess: boolean;
}

/**
Expand All @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
const statuses: EnvironmentUsageStatus[] = [];
for (const [environmentId, presentation] of presentations) {
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
const summary = Option.getOrNull(AsyncResult.value(result));
statuses.push({
environmentId,
label: presentation.entry.target.label,
isPending: result.waiting,
isConnected: presentation.connection.phase === "connected",
error: result._tag === "Failure" ? "This environment could not report usage." : null,
summary: Option.getOrNull(AsyncResult.value(result)),
summary,
needsCursorKeychainAccess: needsCursorKeychainAccess(
summary,
get(serverEnvironment.providersValueAtom(environmentId)),
),
});
}
return statuses;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@opencode-ai/sdk": "^1.3.15",
"diff": "8.0.3",
"effect": "catalog:",
"node-pty": "^1.1.0",
"node-pty": "^1.2.0-beta.15",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"yaml": "catalog:",
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/auth/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
} from "./EnvironmentAuth.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";

/** Secret store name prefix of DPoP replay markers. The server prunes expired ones. */
export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-";

export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => {
switch (code) {
case "time_window":
Expand Down Expand Up @@ -96,7 +99,7 @@ export const verifyRequestDpopProof = (input: {
);
yield* secretStore
.create(
`dpop-proof-${replayKey}`,
`${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`,
new TextEncoder().encode(
[
`thumbprint=${result.thumbprint}`,
Expand Down
92 changes: 92 additions & 0 deletions apps/server/src/auth/replayMarkers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as TestClock from "effect/testing/TestClock";

import * as ServerConfig from "../config.ts";
import { pruneExpiredReplayMarkers, REPLAY_MARKER_MAX_AGE } from "./replayMarkers.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";

// Every secret name the server stores today. The last three stand for names
// built from an id at runtime.
const REAL_SECRET_NAMES = [
"server-signing-key",
"asset-access-signing-key",
"cloud-cli-oauth-token",
"cloud-cli-desired-link",
"cloud-link-ed25519-key-pair",
"cloud-link-ed25519-private-key",
"cloud-link-ed25519-public-key",
"cloud-mint-ed25519-public-key",
"cloud-endpoint-runtime-config",
"cloud-endpoint-confirmed-origin",
"cloud-linked-user-id",
"cloud-relay-url",
"cloud-relay-issuer",
"cloud-relay-environment-credential",
"cloud-publish-agent-activity",
"provider-env-Y29kZXg-T1BFTkFJX0FQSV9LRVk",
"provider-auth-0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
"usage-limit-source-aHVi",
];

it.layer(NodeServices.layer)("replayMarkers", (it) => {
it.effect("prunes only replay markers older than the max age", () =>
Effect.gen(function* () {
const secretStore = yield* ServerSecretStore.ServerSecretStore;
const { secretsDir } = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const now = DateTime.makeUnsafe("2026-01-01T00:00:00Z");
const setAge = (fileName: string, age: Duration.Duration) => {
const mtime = DateTime.toDateUtc(DateTime.subtractDuration(now, age));
return fileSystem.utimes(path.join(secretsDir, fileName), mtime, mtime);
};
const writeAged = (name: string, age: Duration.Duration) =>
secretStore
.create(name, Uint8Array.from([1]))
.pipe(Effect.andThen(setAge(`${name}.bin`, age)));

const justExpired = Duration.sum(REPLAY_MARKER_MAX_AGE, Duration.seconds(1));
const expiredMarkers = [
"dpop-proof-old",
"cloud-mint-jti-old",
"cloud-mint-nonce-old",
"cloud-health-jti-old",
"cloud-health-nonce-old",
];
for (const name of expiredMarkers) yield* writeAged(name, justExpired);
yield* writeAged("dpop-proof-at-max-age", REPLAY_MARKER_MAX_AGE);
for (const name of REAL_SECRET_NAMES) yield* writeAged(name, Duration.days(30));
const pendingSetFile = "dpop-proof-pending.bin.0000.tmp";
yield* fileSystem.writeFile(path.join(secretsDir, pendingSetFile), Uint8Array.from([1]));
yield* setAge(pendingSetFile, Duration.days(30));
yield* TestClock.setTime(DateTime.toEpochMillis(now));

yield* pruneExpiredReplayMarkers();

const remaining = yield* fileSystem.readDirectory(secretsDir);
assert.deepStrictEqual(
remaining.toSorted(),
[
...REAL_SECRET_NAMES.map((name) => `${name}.bin`),
"dpop-proof-at-max-age.bin",
pendingSetFile,
].toSorted(),
);
}).pipe(
Effect.provide(
ServerSecretStore.layer.pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-replay-markers-test-" }),
),
),
),
),
);
});
77 changes: 77 additions & 0 deletions apps/server/src/auth/replayMarkers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
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 * as Path from "effect/Path";
import * as Schedule from "effect/Schedule";

import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/http.ts";
import * as ServerConfig from "../config.ts";
import { forkParked } from "../serverActivation.ts";
import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts";

const REPLAY_MARKER_PREFIXES = [DPOP_REPLAY_MARKER_PREFIX, ...CLOUD_REPLAY_MARKER_PREFIXES];

/**
* How long a replay marker stays on disk. A marker only matters while its proof
* can pass the time check (about 5 minutes for DPoP, 7 for cloud proofs). After
* that, the time check rejects a replay by itself. The sweep and the time check
* both use the wall clock, so a pruned marker can let a replay through only if
* the clock moves back by almost a day, or if the filesystem stamps mtimes almost
* a day behind. Markers are files, so a restart does not reset them.
*/
export const REPLAY_MARKER_MAX_AGE = Duration.days(1);

/**
* Deletes replay markers whose mtime is older than `REPLAY_MARKER_MAX_AGE`.
* `ServerSecretStore` saves each secret as `<name>.bin`, so only
* `<marker prefix>*.bin` names match. Other secrets and the `*.bin.<uuid>.tmp`
* files that `set` writes are never touched.
*/
export const pruneExpiredReplayMarkers = Effect.fn("replayMarkers.pruneExpired")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { secretsDir } = yield* ServerConfig.ServerConfig;
const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(REPLAY_MARKER_MAX_AGE);
const markers = (yield* fileSystem.readDirectory(secretsDir)).filter(
(name) =>
name.endsWith(".bin") && REPLAY_MARKER_PREFIXES.some((prefix) => name.startsWith(prefix)),
);
// `partition` visits every marker, so one locked file does not stop the sweep.
const [failures, removed] = yield* Effect.partition(markers, (name) => {
const markerPath = path.join(secretsDir, name);
return fileSystem.stat(markerPath).pipe(
Effect.flatMap((info) =>
Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff)
? fileSystem.remove(markerPath).pipe(Effect.as(true))
: Effect.succeed(false),
),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
);
});
yield* Effect.annotateCurrentSpan({
"replay_markers.matched": markers.length,
"replay_markers.removed": removed.filter(Boolean).length,
"replay_markers.failed": failures.length,
});
if (failures.length > 0) {
yield* Effect.logWarning("Failed to prune some replay markers", {
failed: failures.length,
cause: failures[0],
});
}
});

/** Prunes expired replay markers after server activation, then every hour. */
export const layer = Layer.effectDiscard(
forkParked(
pruneExpiredReplayMarkers().pipe(
Effect.catch((cause) =>
Effect.logWarning("Failed to prune expired replay markers", { cause }),
),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
),
),
);
2 changes: 2 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
import { servicePreflightCommand } from "./cli/servicePreflight.ts";
import { sshHelperCommand } from "./cli/sshHelper.ts";
import { themeCommand } from "./cli/theme.ts";
import { traceCommand } from "./cli/trace.ts";
import { triageCommand } from "./cli/triage.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
Expand Down Expand Up @@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
servicePreflightCommand,
sshHelperCommand,
themeCommand,
traceCommand,
triageCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
]),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe("CheckpointDiffQuery.layer", () => {
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getDeletedWorktreeThreads: () => Effect.die("unused"),
listThreadsWithPullRequests: () => Effect.die("unused"),
getArchivedShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
Expand Down Expand Up @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => {
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getDeletedWorktreeThreads: () => Effect.die("unused"),
listThreadsWithPullRequests: () => Effect.die("unused"),
getArchivedShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
Expand Down Expand Up @@ -293,6 +295,7 @@ describe("CheckpointDiffQuery.layer", () => {
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getDeletedWorktreeThreads: () => Effect.die("unused"),
listThreadsWithPullRequests: () => Effect.die("unused"),
getArchivedShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
Expand Down Expand Up @@ -370,6 +373,7 @@ describe("CheckpointDiffQuery.layer", () => {
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getDeletedWorktreeThreads: () => Effect.die("unused"),
listThreadsWithPullRequests: () => Effect.die("unused"),
getArchivedShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
Expand Down Expand Up @@ -432,6 +436,7 @@ describe("CheckpointDiffQuery.layer", () => {
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getDeletedWorktreeThreads: () => Effect.die("unused"),
listThreadsWithPullRequests: () => Effect.die("unused"),
getArchivedShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
resolved.terminalLogsDir,
resolved.attachmentsDir,
resolved.worktreesDir,
path.dirname(resolved.serverLogPath),
path.dirname(resolved.serverTracePath),
]) {
expect(yield* fs.exists(directory)).toBe(true);
Expand Down
16 changes: 11 additions & 5 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,22 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
Flag.optional,
);

// Trace file location, shared by the server and `t3 trace summary`.
export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe(
Config.option,
Config.map(Option.getOrUndefined),
);
export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe(
Config.withDefault(10),
);

const EnvServerConfig = Config.all({
logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")),
traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)),
traceFile: Config.String("T3CODE_TRACE_FILE").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
traceFile: traceFileConfig,
traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
traceMaxFiles: Config.Int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)),
traceMaxFiles: traceMaxFilesConfig,
traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)),
otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe(
Config.option,
Expand Down
Loading
Loading