diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index dcd6358cb52c..af977d567f2b 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -183,6 +183,14 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell |
return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
}
+/** The thread as `useThread` returns it, read outside React. */
+export function readThread(ref: ScopedThreadRef): EnvironmentThread | null {
+ return mergeEnvironmentThread(
+ appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)),
+ readThreadShell(ref),
+ );
+}
+
/** Whether the environment's server understands thread.settle/unsettle.
False for pre-settlement servers (capability defaults false on decode),
so clients under version skew fall back instead of erroring. */
diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx
index e01478e6a71a..c80b642b76b7 100644
--- a/apps/web/src/state/usage.test.tsx
+++ b/apps/web/src/state/usage.test.tsx
@@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
label: id,
isPending: cost === null,
error: null,
+ needsCursorKeychainAccess: false,
summary:
cost === null
? null
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts
index 617ac93e4b7c..f61bb9060384 100644
--- a/apps/web/src/state/usage.ts
+++ b/apps/web/src/state/usage.ts
@@ -13,7 +13,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 * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useMemo } from "react";
@@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus {
readonly isPending: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
+ readonly needsCursorKeychainAccess: boolean;
}
/**
@@ -46,12 +47,17 @@ 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,
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;
diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts
index 3c466378ba8b..5cb2ad915827 100644
--- a/apps/web/src/terminal-links.test.ts
+++ b/apps/web/src/terminal-links.test.ts
@@ -97,6 +97,24 @@ describe("extractTerminalLinks", () => {
},
]);
});
+
+ it("keeps a trailing colon on URLs", () => {
+ expect(extractTerminalLinks("GET https://example.test/items/foo:")).toEqual([
+ { kind: "url", text: "https://example.test/items/foo:", start: 4, end: 35 },
+ ]);
+ });
+
+ it.each([
+ ["./main.go:10:5: undefined: x", "./main.go:10:5"],
+ ["/home/dev/app/src/main.c:10:5: error: expected ';'", "/home/dev/app/src/main.c:10:5"],
+ ["C:\\dev\\app\\src\\main.c:10:5: error: expected ';'", "C:\\dev\\app\\src\\main.c:10:5"],
+ ["wrote ./out/report.txt:", "./out/report.txt"],
+ ])("drops the colon that ends a compiler diagnostic location in %s", (line, text) => {
+ const start = line.indexOf(text);
+ expect(extractTerminalLinks(line)).toEqual([
+ { kind: "path", text, start, end: start + text.length },
+ ]);
+ });
});
describe("collectWrappedTerminalLinkLine", () => {
@@ -173,6 +191,13 @@ describe("resolvePathLinkTarget", () => {
resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"),
).toBe("C:/Users/julius/project/src/main.ts:12");
});
+
+ it("keeps the line and column of a compiler diagnostic", () => {
+ const [link] = extractTerminalLinks("/Users/julius/project/main.c:10:5: error: expected ';'");
+ expect(resolvePathLinkTarget(link?.text ?? "", "/Users/julius/project")).toBe(
+ "/Users/julius/project/main.c:10:5",
+ );
+ });
});
describe("isTerminalLinkActivation", () => {
diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts
index 59e2082a7359..0f253588b700 100644
--- a/apps/web/src/terminal-links.ts
+++ b/apps/web/src/terminal-links.ts
@@ -35,9 +35,14 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
const FILE_PATH_PATTERN =
/(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
-
-function trimClosingDelimiters(value: string): string {
- let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
+// Paths also drop a trailing colon: compilers end `file:line:col:` with one.
+const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
+
+function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string {
+ let output = value.replace(
+ kind === "path" ? TRAILING_PATH_PUNCTUATION_PATTERN : TRAILING_PUNCTUATION_PATTERN,
+ "",
+ );
if (output.length === 0) return output;
const trimUnbalanced = (open: string, close: string) => {
@@ -73,7 +78,7 @@ function collectMatches(
const start = rawMatch.index ?? -1;
if (start < 0 || raw.length === 0) continue;
- const trimmed = trimClosingDelimiters(raw);
+ const trimmed = trimClosingDelimiters(raw, kind);
if (trimmed.length === 0) continue;
if (kind === "path" && isTerminalUrl(trimmed)) continue;
diff --git a/docs/operations/observability.md b/docs/operations/observability.md
index 86a65702c270..910bd018bcc7 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -62,6 +62,21 @@ far in the future for the environment server's allowed window. It can point to
a date or time problem on either device, but it can also result from a delayed
request.
+#### Summarize the trace file
+
+`t3 trace summary` reads the trace file and its rotated backups directly, so it works while the
+server is stalled or stopped. It prints counts, rates, and latency percentiles per span name. Use
+it to measure background work or to compare two builds.
+
+```bash
+t3 trace summary --since 30m --limit 40
+```
+
+It reads `T3CODE_TRACE_FILE` if set, else `
/userdata/logs/server.trace.ndjson` for
+`--base-dir` or `T3CODE_HOME`, plus the `T3CODE_TRACE_MAX_FILES` rotated backups. For a dev run or
+a copied file, set `T3CODE_TRACE_FILE`. `--since 30m` keeps spans that ended in the last 30
+minutes. The rate is per minute between the first and last span end.
+
### Metrics
Metrics are not written to a local file.
@@ -72,6 +87,38 @@ Metrics are not written to a local file.
If OTLP is not configured, metrics still exist in-process, but you will not have a local artifact to inspect.
+### Event Loop Stalls
+
+`apps/server/src/observability/EventLoopMonitor.ts` samples the server's event loop every 30 s. When
+the loop stalled for more than 2 s since the previous sample, it records a root
+`server.eventLoop.stall` span with a warning. The span has trace level `Warn`, so it stays when
+`T3CODE_TRACE_MIN_LEVEL` is `Warn`. The warning shows in Settings > Diagnostics unless OTLP logs are
+on. The span time is when the sample ran, not when the stall happened.
+
+Some delay is not recorded:
+
+- `delayMaxMs` is the longest stall, and can undercount it by up to 1 s. The 2 s threshold applies to
+ this value, so a stall over 3 s is normally recorded, and a shorter one can be missed. A stall
+ that ends just as a sample runs can be missed too.
+- Time the computer spends asleep reads as delay on macOS and Windows. So a sample only counts when
+ the loop was busy, not waiting for events, for at least `delayMaxMs`. Busy time covers the whole
+ window, so a short sleep in an otherwise busy window can still record a false stall. The span then
+ shows CPU time far below `delayMaxMs`.
+- The first sample after launch is skipped. Startup work such as migrations and projection bootstrap
+ can block the loop for seconds on a large database.
+
+CPU times and page faults cover the whole process over the whole window since the previous sample.
+The window is nominally 30 s, but a long stall delays the sample and makes the window longer. Other
+work in the window can hide a wait, so only CPU time far below `delayMaxMs` proves the thread was
+waiting. Read CPU together with page faults:
+
+- High `cpuSystemMs` with many page faults means memory pressure. Major faults are reads from disk or swap.
+ On macOS, reads from compressed memory are minor faults plus system CPU.
+- High `cpuUserMs` with few page faults means JavaScript work or garbage collection.
+- Low CPU with few major page faults points at synchronous disk I/O, such as SQLite reads or trace
+ file writes.
+- Many `involuntaryContextSwitches` mean other processes were competing for the CPU.
+
### Related Artifacts
Provider event NDJSON files still exist for provider runtime streams. Those are separate from the main server trace file.
@@ -584,6 +631,11 @@ machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual
OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not.
Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning.
+`OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, or `OTEL_LOGS_EXPORTER` set to `none` turns off
+just that signal, overriding an OTEL endpoint and the Settings endpoint. A `T3CODE_OTLP_*_URL` still
+wins for its signal. `otlp` is the default, and any other exporter name, such as `console` or
+`prometheus`, is ignored with a startup warning.
+
### What Is Instrumented Today
Current high-value span and metric boundaries include:
@@ -597,11 +649,47 @@ Current high-value span and metric boundaries include:
- git command execution and git hook events
- terminal session lifecycle
- sqlite query execution
+- event loop stalls (`server.eventLoop.stall`)
### Current Constraints
- logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still
captured in its launcher log
- metrics are not snapshotted locally
-- the old `serverLogPath` still exists in config for compatibility, but the trace file is the primary
- structured persisted artifact
+
+## Heap Snapshots
+
+To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8 heap
+snapshot to its logs dir and logs the path. This works for desktop, `npx t3`, and service installs
+on macOS and Linux. Windows has no `SIGUSR2`.
+
+Send the signal to the server pid in `server-runtime.json`, which sits in the server's state dir
+next to the `logs` dir. For a dev server or a `--home-dir` launch, use that server's state dir from
+[Traces](#traces). Do not send it to the desktop app or the service launcher: a process without the
+handler exits on `SIGUSR2`. After a crash the file can keep a stale pid that now belongs to a
+different process, so check the pid first.
+
+```bash
+pid="$(jq .pid "${T3CODE_HOME:-$HOME/.t3}/userdata/server-runtime.json")"
+ps -p "$pid" -o command=
+```
+
+If `ps` shows the T3 Code server, send the signal:
+
+```bash
+kill -USR2 "$pid"
+```
+
+The file is `/server--.heapsnapshot`, next to `server.trace.ndjson`. To
+open it, use the Memory tab in Chrome DevTools and select Load.
+
+Before you take one:
+
+- The server stops while it writes the file. For a large heap this can take a minute or more.
+ Connected clients can reconnect during the pause, and an event loop monitor, if the server has
+ one, records the pause as a stall. Send the signal once. A second signal sent during a write
+ takes another snapshot after the first one finishes.
+- The write needs about as much free memory as the heap uses. On a machine that is already
+ swapping, it can make the problem worse or crash the server.
+- The file contains everything in server memory, including tokens, secrets, and thread content. Do
+ not share it publicly. Delete it when you are done, because storage cleanup does not remove it.
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 049cf5c61c68..4d870c86c89e 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -19,8 +19,7 @@ Attach up to 100 files per message. Each image can be up to 10 MiB, with at most
50 MiB each, subject to the environment's upload support and limit. The agent
receives them on the environment's machine. Provider and model limits still
apply, including images already in the conversation. A video attachment gives
-the agent a file path; it does not enable native video input. Antigravity does
-not accept video attachments.
+the agent a file path; it does not enable native video input.
Uploads begin when you add an attachment. All uploads must finish before the
message can send. Retry or remove a failed upload. On web and desktop, reloading
@@ -37,7 +36,7 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin
On web and desktop, a message sent during a running turn waits at the end of the conversation as a
dashed bubble. It goes out on its own when the agent finishes its next tool
-call, or when the turn ends. Use the arrow under the bubble to send it right
+call, or when the turn ends, even while you have another thread open. Use the arrow under the bubble to send it right
away, or the X to move it back into the composer. Stop returns every queued
message to the composer.
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md
index ad1b157bbb75..2525fed64d94 100644
--- a/docs/user/keybindings.md
+++ b/docs/user/keybindings.md
@@ -84,7 +84,7 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`.
## When conditions
Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`,
-`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`.
+`previewOpen`, `modelPickerOpen`, `usagePageOpen`, `editableFocus`, `isWeb`, and `isDesktop`.
`editableFocus` is true while a text field, the composer, or another editor has
the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the
desktop app. Unknown keys evaluate to `false`.
diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md
index fe5010abf1f4..bdf4548cf29f 100644
--- a/docs/user/providers-antigravity.md
+++ b/docs/user/providers-antigravity.md
@@ -94,9 +94,12 @@ so a skill there only appears when the project itself is your home directory.
Antigravity accepts images, PDFs, text files, and supported audio formats directly.
Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and
-50 MiB total attachments per message. Unsupported formats are rejected. These
-limits can be lower than the general upload limit; uploading a file does not
-mean this provider can use it.
+50 MiB total native attachments per message. PDFs, text, and audio that exceed
+their native limits or the remaining native budget are passed as file paths
+for the agent to inspect with tools. ZIP archives and videos also use file paths.
+These files do not count toward the native attachment budget or enable native video input.
+Unsupported image formats are rejected. The native limits can be lower than
+the general upload limit.
### Subagents
diff --git a/docs/user/usage.md b/docs/user/usage.md
index ab77f70a6b3a..64f392d6e941 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings
environment paths depend on each project's working directory and cannot be reliably discovered
by Usage. Accounts sharing a history directory count once.
+When your app and server support different providers, usage totals may cover only the providers
+your app understands. Update the app to include newly supported providers.
+
On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
environments are selected by default. The dropdown shows which environments are still scanning;
results appear as each one responds.
@@ -120,3 +123,12 @@ Add **Subscription usage** from your iOS or Android widget gallery to see remain
Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session,
Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget
requires Android 12L or later.
+
+## Keyboard shortcuts
+
+On web and desktop, open Usage from the command palette. While on Usage,
+press `C`, `T`, or `L` for Cost, Tokens, or Limits while not typing in a field.
+Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past
+24 hours, 7 days, 30 days, or 90 days. Period shortcuts do nothing on Limits.
+Press `Escape` to return to the previous page. Customize these shortcuts in
+**Settings → Keybindings**.
diff --git a/knip.jsonc b/knip.jsonc
index a6e4f94143a0..2c7b76acfae0 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -53,6 +53,8 @@
"ignoreDependencies": ["playwright-core", "electron-builder"],
},
"apps/web": {
+ // Vitest loads this through the per-file @vitest-environment directive.
+ "ignoreDependencies": ["jsdom"],
// Worktree setup (scripts/setup-worktree.ts) runs this directly.
"entry": ["scripts/warm-dep-cache.ts"],
// UI component modules are copied and adapted as cohesive sets. Keep their
diff --git a/packages/client-runtime/src/platform/persistence.test.ts b/packages/client-runtime/src/platform/persistence.test.ts
new file mode 100644
index 000000000000..3e856e847976
--- /dev/null
+++ b/packages/client-runtime/src/platform/persistence.test.ts
@@ -0,0 +1,55 @@
+import {
+ OrchestrationProjectShell,
+ OrchestrationShellSnapshot,
+ OrchestrationThreadShell,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Arr from "effect/Array";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary";
+
+import { encodeShellSnapshotForCache } from "./persistence.ts";
+
+// Generated values can hold untrimmed strings, which a decoded value never
+// has. One encode and decode gives a value a client can hold; values that
+// fail are dropped. Size 30 makes the generator fill optional fields.
+const sampleDecoded = (schema: S) =>
+ Effect.gen(function* () {
+ const encode = Schema.encodeEffect(schema);
+ const decode = Schema.decodeEffect(schema);
+ const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), {
+ count: 1000,
+ size: 30,
+ });
+ const decoded = yield* Effect.forEach(generated, (value) =>
+ encode(value).pipe(Effect.flatMap(decode), Effect.option),
+ );
+ return Arr.getSomes(decoded);
+ });
+const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot);
+
+describe("encodeShellSnapshotForCache", () => {
+ it.effect("matches the Schema encoding of a generated snapshot", () =>
+ Effect.gen(function* () {
+ const threads = yield* sampleDecoded(OrchestrationThreadShell);
+ const projects = yield* sampleDecoded(OrchestrationProjectShell);
+ const snapshot: OrchestrationShellSnapshot = {
+ snapshotSequence: 1,
+ // The generator rarely makes monogram icons, and they are the one
+ // project field whose encoding differs from the decoded value.
+ projects: projects.map((project, index) =>
+ index % 2 === 0
+ ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } }
+ : project,
+ ),
+ threads,
+ updatedAt: "2026-09-25T00:00:00.000Z",
+ };
+
+ expect(threads.length).toBeGreaterThan(0);
+ expect(projects.length).toBeGreaterThan(0);
+ expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot));
+ }),
+ );
+});
diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts
index 99469081dc10..b2bc18222250 100644
--- a/packages/client-runtime/src/platform/persistence.ts
+++ b/packages/client-runtime/src/platform/persistence.ts
@@ -1,5 +1,6 @@
import {
type EnvironmentId,
+ OrchestrationProjectShell,
type OrchestrationShellSnapshot,
type OrchestrationThreadDetailSnapshot,
type ServerConfig,
@@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service<
}
>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {}
+const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell));
+
+/**
+ * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result
+ * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does
+ * not change. Walking thousands of threads through Schema blocks the UI
+ * thread, and a decoded thread shell is already in its encoded form, so only
+ * the projects go through Schema: their icon has a real encode transform.
+ */
+export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) =>
+ Effect.map(
+ encodeProjectShells(snapshot.projects),
+ (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded,
+ );
+
export class EnvironmentOwnedDataCleanup extends Context.Reference<{
readonly clear: (environmentId: EnvironmentId) => Effect.Effect;
}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", {
diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts
index 5787bd0e60b7..15848d95d965 100644
--- a/packages/client-runtime/src/state/session.ts
+++ b/packages/client-runtime/src/state/session.ts
@@ -30,8 +30,8 @@ function initialConfigOption(
);
}
-// Bounded like the snapshot fetches: a wedged environment must not pin the
-// permissions check (and with it the settings UI) in a loading state for long.
+// Bounded so a wedged environment cannot pin the permissions check (and with it
+// the settings UI) in a loading state for long.
const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
/**
diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts
index 84ab1a3f1d4b..fe5a3d5fcec1 100644
--- a/packages/client-runtime/src/state/shellSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts
@@ -12,9 +12,13 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";
import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
-// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
-// fallback for long. The cached shell renders while this runs.
-const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 6_000;
+// Long enough for a slow but alive server to finish. On timeout the socket asks
+// the same server for the same full snapshot, so a short deadline only throws
+// the first build away. The socket fallback is for setups where /api fails but
+// /ws works, such as a proxy that blocks /api. A dead server is caught by the
+// socket ping, which drops the session and interrupts this load. The cached
+// shell renders while this runs.
+const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 20_000;
/**
* Load the environment shell snapshot (projects + thread shells) over HTTP
diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts
index 9582ad30567b..da1156cc83e0 100644
--- a/packages/client-runtime/src/state/threadSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts
@@ -13,10 +13,13 @@ import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
-// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
-// fallback for long. The cached thread renders while this runs, so the wait only
-// delays the transition to live data on the first open, not the initial paint.
-const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
+// Long enough for a slow but alive server to finish. On a cold open a timeout
+// makes the socket ask the same server for the same snapshot again, and older
+// turn pages have no fallback, so a short deadline only drops work. The socket
+// fallback is for setups where /api fails but /ws works, such as a proxy that
+// blocks /api. A dead server drops the socket session, which interrupts a
+// cold-open load. Older turn pages wait for this deadline.
+const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 20_000;
/**
* Load a thread's detail snapshot over HTTP instead of embedding it in the
diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts
index 0e1fba746cf3..9fe8df570499 100644
--- a/packages/client-runtime/src/state/threadSort.test.ts
+++ b/packages/client-runtime/src/state/threadSort.test.ts
@@ -1,4 +1,4 @@
-import { ProjectId } from "@t3tools/contracts";
+import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import {
@@ -10,7 +10,9 @@ import {
resolveSettledThreadTimestamp,
sortActiveThreadsByOrderKey,
sortPinnedThreadsByOrderKey,
+ sortSettledThreads,
sortThreads,
+ type SettledThreadTimestampInput,
type ThreadSortInput,
} from "./threadSort.ts";
@@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => {
});
});
+describe("sortSettledThreads", () => {
+ const settled = (input: {
+ id: string;
+ settledAt?: string | null;
+ latestUserMessageAt?: string | null;
+ latestTurn?: OrchestrationLatestTurn | null;
+ updatedAt?: string;
+ }) => ({
+ id: input.id,
+ settledAt: input.settledAt ?? null,
+ latestUserMessageAt: input.latestUserMessageAt ?? null,
+ latestTurn: input.latestTurn ?? null,
+ updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
+ });
+
+ it("orders by settle time, most recently settled first", () => {
+ const sorted = sortSettledThreads([
+ settled({
+ id: "settled-first",
+ settledAt: "2026-03-09T10:00:00.000Z",
+ // Created/active later than the other thread: settle time must win.
+ latestUserMessageAt: "2026-03-09T09:59:00.000Z",
+ }),
+ settled({
+ id: "settled-last",
+ settledAt: "2026-03-09T12:00:00.000Z",
+ latestUserMessageAt: "2026-03-09T08:00:00.000Z",
+ }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]);
+ });
+
+ it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
+ const sorted = sortSettledThreads([
+ settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
+ settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
+ settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]);
+ });
+
+ it("counts a turn completion as activity for auto-settled threads", () => {
+ // The message came in before the other thread's, but its turn finished
+ // after: completion time is the real "work ended" moment.
+ const sorted = sortSettledThreads([
+ settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
+ settled({
+ id: "completed-later",
+ latestUserMessageAt: "2026-03-09T10:00:00.000Z",
+ latestTurn: {
+ turnId: TurnId.make("turn-1"),
+ state: "completed",
+ assistantMessageId: null,
+ requestedAt: "2026-03-09T10:00:00.000Z",
+ startedAt: "2026-03-09T10:00:00.000Z",
+ completedAt: "2026-03-09T10:30:00.000Z",
+ },
+ }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
+ });
+
+ it("breaks timestamp ties by id so the order is stable", () => {
+ const sorted = sortSettledThreads([
+ settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
+ settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
+ });
+
+ it("matches the per-comparison order on a shuffled list with ties", () => {
+ const stamps = [
+ { settledAt: "2026-03-09T10:00:00.000Z" },
+ { settledAt: "invalid", latestUserMessageAt: "2026-03-09T10:00:00.000Z" },
+ { latestUserMessageAt: "2026-03-09T11:00:00.000Z" },
+ { updatedAt: "2026-03-09T09:00:00.000Z" },
+ { updatedAt: "invalid" },
+ ];
+ // Ids repeat every 3 rows and stamps every 5, so rows tie on the time,
+ // on the id, and on both. (index * 7) % 30 scrambles the input order.
+ const threads = Array.from({ length: 30 }, (_, index) => {
+ const row = (index * 7) % 30;
+ return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row };
+ });
+ // The comparator this sort replaced: it resolved both keys on every call.
+ const timestampMs = (thread: SettledThreadTimestampInput) => {
+ const timestamp = resolveSettledThreadTimestamp(thread);
+ return timestamp === null ? 0 : Date.parse(timestamp);
+ };
+ const expected = threads.toSorted(
+ (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id),
+ );
+
+ expect(sortSettledThreads(threads).map((thread) => thread.row)).toEqual(
+ expected.map((thread) => thread.row),
+ );
+ });
+});
+
describe("sortThreads", () => {
it.each(["created_at", "updated_at"] as const)(
"preserves references, input order and descending id ties for %s",
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index 93878310839f..b6a7264f3052 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -47,6 +47,24 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu
return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
}
+/** Settled rows are history, so they order by when the work ENDED, newest
+ first, with an id tiebreak. Each key resolves once per sort, not once
+ per comparison. Shared by web and mobile so both render the same order. */
+export function sortSettledThreads(
+ threads: readonly T[],
+): T[] {
+ return threads
+ .map((thread) => {
+ const timestamp = resolveSettledThreadTimestamp(thread);
+ return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) };
+ })
+ .sort(
+ (left, right) =>
+ right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id),
+ )
+ .map(({ thread }) => thread);
+}
+
function getFirstSortableTimestamp(...values: Array): number | null {
for (const value of values) {
const timestamp = toSortableTimestamp(value ?? undefined);
diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts
index 75977d20de74..86914c768e3b 100644
--- a/packages/client-runtime/src/state/usage.test.ts
+++ b/packages/client-runtime/src/state/usage.test.ts
@@ -1,7 +1,10 @@
import {
EnvironmentId,
+ ProviderDriverKind,
+ ProviderInstanceId,
UsageDay,
USAGE_CONTRACT_VERSION,
+ type ServerProvider,
type UsageSummary,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
@@ -10,7 +13,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import type { EnvironmentPresentation } from "../connection/presentation.ts";
import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
-import { refreshUsage, refreshUsageLimits } from "./usage.ts";
+import {
+ cursorKeychainAccessEnvironments,
+ needsCursorKeychainAccess,
+ refreshUsage,
+ refreshUsageLimits,
+} from "./usage.ts";
const input = {
sinceDay: UsageDay.make("2026-09-05"),
@@ -238,3 +246,78 @@ describe("limits refresh cooldown", () => {
}
});
});
+
+describe("needsCursorKeychainAccess", () => {
+ const cursorPrompt: UsageSummary = {
+ ...summary,
+ sources: [
+ {
+ fingerprint: {
+ hostId: "host",
+ provider: "cursor",
+ resolvedHomePath: "/Users/me/.cursor/auth.json",
+ volumeId: "volume",
+ },
+ status: "ok",
+ scannedFiles: 0,
+ skippedFiles: 0,
+ malformedRecords: 0,
+ distinctSessions: 0,
+ message: "Cursor account usage is off on this environment.",
+ action: "enableCursorKeychain",
+ },
+ ],
+ };
+ const cursor = (status: ServerProvider["status"]): ServerProvider => ({
+ instanceId: ProviderInstanceId.make("cursor"),
+ driver: ProviderDriverKind.make("cursor"),
+ enabled: status !== "disabled",
+ installed: status === "ready",
+ version: null,
+ status,
+ auth: { status: "unknown" },
+ checkedAt: "2026-09-05T12:00:00.000Z",
+ models: [],
+ slashCommands: [],
+ skills: [],
+ });
+
+ it("offers access only when Cursor is ready on that environment", () => {
+ expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true);
+ expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false);
+ expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false);
+ expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false);
+ expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false);
+ expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false);
+ });
+
+ it("stops offering access once any environment reads the Cursor account", () => {
+ const off = { summary: cursorPrompt, needsCursorKeychainAccess: true };
+ const account: UsageSummary = {
+ ...summary,
+ sources: [
+ {
+ fingerprint: {
+ hostId: "cursor.com",
+ provider: "cursor",
+ resolvedHomePath: "cursor-account:abc",
+ volumeId: "abc",
+ },
+ status: "ok",
+ scannedFiles: 1,
+ skippedFiles: 0,
+ malformedRecords: 0,
+ distinctSessions: 1,
+ message: null,
+ },
+ ],
+ };
+ expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]);
+ expect(
+ cursorKeychainAccessEnvironments([
+ off,
+ { summary: account, needsCursorKeychainAccess: false },
+ ]),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts
index 11cfd26fbe6c..1f2377a89f42 100644
--- a/packages/client-runtime/src/state/usage.ts
+++ b/packages/client-runtime/src/state/usage.ts
@@ -1,4 +1,9 @@
-import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts";
+import type {
+ EnvironmentId,
+ ServerProvider,
+ UsageSummary,
+ UsageSummaryInput,
+} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import type { AtomRegistry } from "effect/unstable/reactivity";
@@ -9,6 +14,39 @@ import type { createServerEnvironmentAtoms } from "./server.ts";
const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);
+/** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */
+export function needsCursorKeychainAccess(
+ summary: UsageSummary | null,
+ providers: readonly ServerProvider[] | null,
+): boolean {
+ return (
+ summary?.sources.some((source) => source.action === "enableCursorKeychain") === true &&
+ providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") ===
+ true
+ );
+}
+
+/**
+ * Environments to offer the Cursor Keychain prompt in the usage summary.
+ *
+ * Any environment reading a Cursor account already reports that account's
+ * history from every machine, so the prompt only adds duplicates there. A
+ * different account on another machine stays reachable from provider settings.
+ */
+export function cursorKeychainAccessEnvironments<
+ E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean },
+>(environments: readonly E[]): readonly E[] {
+ const hasCursorAccount = environments.some((environment) =>
+ environment.summary?.sources.some(
+ (source) =>
+ source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com",
+ ),
+ );
+ return hasCursorAccount
+ ? []
+ : environments.filter((environment) => environment.needsCursorKeychainAccess);
+}
+
const limitsRefreshAfter = new Map();
const limitsRefreshes = new Map>();
diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts
index 88028b796991..55f16e1e0124 100644
--- a/packages/contracts/src/editor.ts
+++ b/packages/contracts/src/editor.ts
@@ -59,7 +59,14 @@ export const EDITORS = [
launchStyle: "direct-path",
remoteScheme: "zed",
},
- { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
+ {
+ id: "antigravity",
+ label: "Antigravity",
+ // `agy` is the standalone Antigravity CLI, not the IDE. The IDE bundle
+ // ships `antigravity-ide`, so it comes first for install-folder lookups.
+ commands: ["antigravity-ide", "agy-ide"],
+ launchStyle: "goto",
+ },
{ id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
{ id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" },
{ id: "clion", label: "CLion", commands: ["clion"], launchStyle: "line-column" },
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index cb5768420306..239f9905c534 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -92,6 +92,13 @@ export const STATIC_KEYBINDING_COMMANDS = [
"chat.new",
"chat.newLocal",
"editor.openFavorite",
+ "usage.cost",
+ "usage.tokens",
+ "usage.limits",
+ "usage.period.day",
+ "usage.period.week",
+ "usage.period.month",
+ "usage.period.quarter",
...MODEL_PICKER_KEYBINDING_COMMANDS,
...THREAD_KEYBINDING_COMMANDS,
] as const;
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 16b9418ab7cc..1331d2dcb30d 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -301,6 +301,23 @@ describe("ClientSettings diff colors", () => {
});
});
+describe("ClientSettings chat width", () => {
+ it("keeps the comfortable width for existing settings without a saved width", () => {
+ expect(decodeClientSettings({}).chatWidth).toBe("comfortable");
+ });
+
+ it.each(["comfortable", "wide", "full"])("round-trips the %s width", (chatWidth) => {
+ const settings = decodeClientSettings({ chatWidth });
+ expect(encodeClientSettings(settings).chatWidth).toBe(chatWidth);
+ expect(decodeClientSettingsPatch({ chatWidth }).chatWidth).toBe(chatWidth);
+ });
+
+ it("rejects unsupported widths", () => {
+ expect(() => decodeClientSettings({ chatWidth: "huge" })).toThrow();
+ expect(() => decodeClientSettingsPatch({ chatWidth: "huge" })).toThrow();
+ });
+});
+
describe("ClientSettings load balancing", () => {
it("requires opt-in when settings are new or omit load balancing", () => {
expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 73ae483e39de..76d4ec4e8c7b 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -291,6 +291,10 @@ export const LoadBalancingWeights = Schema.Record(
export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
+/** Maximum width of the chat timeline and composer on wide screens. */
+export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
+export type ChatWidth = typeof ChatWidth.Type;
+
export const ClientSettingsSchema = Schema.Struct({
notificationMode: NotificationMode.pipe(
Schema.withDecodingDefault(Effect.succeed("off" as const)),
@@ -299,6 +303,7 @@ export const ClientSettingsSchema = Schema.Struct({
diffColorScheme: DiffColorScheme.pipe(
Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
),
+ chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))),
loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
appearanceContrast: AppearanceContrast.pipe(
@@ -1575,6 +1580,7 @@ export const ClientSettingsPatch = Schema.Struct({
notificationMode: Schema.optionalKey(NotificationMode),
inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
diffColorScheme: Schema.optionalKey(DiffColorScheme),
+ chatWidth: Schema.optionalKey(ChatWidth),
loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
appearanceContrast: Schema.optionalKey(AppearanceContrast),
diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts
index a6431330558a..97db8594379a 100644
--- a/packages/contracts/src/usage.ts
+++ b/packages/contracts/src/usage.ts
@@ -11,12 +11,14 @@
*/
import * as Schema from "effect/Schema";
-import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
+import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
/**
* Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The
* client renders partial coverage when an environment reports an older version
* rather than failing the whole page.
+ * Adding providers or other array-element variants is additive: unknown
+ * entries are skipped on decode and do not require a version bump.
*/
export const USAGE_CONTRACT_VERSION = 6 as const;
@@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({
timeZone: TrimmedNonEmptyString,
sinceDay: UsageDay,
untilDay: UsageDay,
- buckets: Schema.Array(UsageBucket),
- sources: Schema.Array(UsageSource),
+ buckets: ForwardCompatibleArray(UsageBucket),
+ sources: ForwardCompatibleArray(UsageSource),
pricing: UsagePricing,
/** Wall-clock cost of the scan, surfaced in diagnostics. */
scanDurationMs: NonNegativeInt,
diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts
index cc6def9d01e9..1584f3a201d9 100644
--- a/packages/shared/src/editor.ts
+++ b/packages/shared/src/editor.ts
@@ -12,6 +12,8 @@ type Editor = (typeof EDITORS)[number];
const installNames: Partial>> = {
vscode: ["Visual Studio Code"],
"vscode-insiders": ["Visual Studio Code - Insiders"],
+ // `Antigravity.app` is the separate Hub, not the IDE.
+ antigravity: ["Antigravity IDE"],
idea: ["IntelliJ IDEA", "IntelliJ IDEA CE", "IntelliJ IDEA Ultimate"],
pycharm: ["PyCharm", "PyCharm CE"],
rider: ["Rider", "JetBrains Rider"],
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index 28fa05be85b8..8029be96f939 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -76,6 +76,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
command,
when: "modelPickerOpen && isDesktop",
})),
+ { key: "c", command: "usage.cost", when: "usagePageOpen" },
+ { key: "t", command: "usage.tokens", when: "usagePageOpen" },
+ { key: "l", command: "usage.limits", when: "usagePageOpen" },
+ { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" },
+ { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" },
+ { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" },
+ { key: "mod+shift+4", command: "usage.period.quarter", when: "usagePageOpen" },
];
function normalizeKeyToken(token: string): string {
diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts
index 081291f34b86..b9bf4ef1375d 100644
--- a/packages/shared/src/observability.test.ts
+++ b/packages/shared/src/observability.test.ts
@@ -16,6 +16,7 @@ import * as Tracer from "effect/Tracer";
import {
causeErrorTag,
compactTraceAttributes,
+ decodeOtlpTraceRecords,
errorTag,
makeLocalFileTracer,
makeTraceSink,
@@ -138,6 +139,53 @@ describe("truncateTraceAttributes", () => {
});
});
+describe("decodeOtlpTraceRecords", () => {
+ it("clamps oversized renderer span and event attributes", () => {
+ const long = "x".repeat(2_000);
+ const clamped = `${"x".repeat(500)}…[truncated]`;
+ const [record] = decodeOtlpTraceRecords({
+ resourceSpans: [
+ {
+ resource: { attributes: [], droppedAttributesCount: 0 },
+ scopeSpans: [
+ {
+ scope: { name: "effect" },
+ spans: [
+ {
+ traceId: "11111111111111111111111111111111",
+ spanId: "2222222222222222",
+ parentSpanId: undefined,
+ name: "client.span",
+ kind: 1,
+ startTimeUnixNano: "1000000",
+ endTimeUnixNano: "2000000",
+ attributes: [{ key: "payload", value: { stringValue: long } }],
+ droppedAttributesCount: 0,
+ events: [
+ {
+ name: "log",
+ timeUnixNano: "1500000",
+ attributes: [{ key: "effect.cause", value: { stringValue: long } }],
+ droppedAttributesCount: 0,
+ },
+ ],
+ droppedEventsCount: 0,
+ status: { code: 1 },
+ links: [],
+ droppedLinksCount: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ assert.equal(record?.attributes["payload"], clamped);
+ assert.equal(record?.events[0]?.attributes["effect.cause"], clamped);
+ });
+});
+
describe("observability", () => {
it("normalizes circular arrays, maps, and sets without recursing forever", () => {
const array: Array = ["alpha"];
diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts
index 6393ed0879db..b4dbdf88651a 100644
--- a/packages/shared/src/observability.ts
+++ b/packages/shared/src/observability.ts
@@ -668,7 +668,7 @@ function decodeAttributes(
entries[attribute.key] = decodeValue(attribute.value);
}
- return compactTraceAttributes(entries);
+ return truncateTraceAttributes(compactTraceAttributes(entries));
}
function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts
index cd4e683faa6b..e436c64c6360 100644
--- a/packages/shared/src/otelEnvironment.test.ts
+++ b/packages/shared/src/otelEnvironment.test.ts
@@ -204,6 +204,73 @@ describe("OtelEnvironment", () => {
logs: "Unset",
warnings: [T3_OFF],
},
+ {
+ name: "an exporter of none turns off only its signal",
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
+ OTEL_LOGS_EXPORTER: "none",
+ },
+ traces: "https://collector:4318/v1/traces",
+ metrics: "https://collector:4318/v1/metrics",
+ logs: "Off",
+ warnings: [],
+ },
+ {
+ name: "an exporter of none turns its signal off with no endpoint named",
+ env: { OTEL_METRICS_EXPORTER: " NONE " },
+ traces: "Unset",
+ metrics: "Off",
+ logs: "Unset",
+ warnings: [],
+ },
+ {
+ name: "an exporter of none leaves its signal's endpoint unread",
+ env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" },
+ traces: "Off",
+ metrics: "Unset",
+ logs: "Unset",
+ warnings: [],
+ },
+ {
+ name: "an exporter of otlp or blank is the default",
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
+ OTEL_TRACES_EXPORTER: "OTLP",
+ OTEL_LOGS_EXPORTER: " ",
+ },
+ traces: "https://collector:4318/v1/traces",
+ metrics: "https://collector:4318/v1/metrics",
+ logs: "https://collector:4318/v1/logs",
+ warnings: [],
+ },
+ {
+ name: "an exporter T3 Code does not have is ignored with a warning",
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
+ OTEL_METRICS_EXPORTER: "prometheus",
+ },
+ traces: "https://collector:4318/v1/traces",
+ metrics: "https://collector:4318/v1/metrics",
+ logs: "https://collector:4318/v1/logs",
+ warnings: [
+ "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored",
+ ],
+ },
+ {
+ name: "a list honors none and otlp and names what it ignored",
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
+ OTEL_TRACES_EXPORTER: "console,none",
+ OTEL_LOGS_EXPORTER: "otlp,console,otlpp",
+ },
+ traces: "Off",
+ metrics: "https://collector:4318/v1/metrics",
+ logs: "https://collector:4318/v1/logs",
+ warnings: [
+ "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored",
+ "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored",
+ ],
+ },
])("$name", ({ env, traces, metrics, logs, warnings }) =>
Effect.gen(function* () {
const resolved = yield* load(env);
@@ -396,6 +463,25 @@ describe("OtelEnvironment", () => {
});
});
+ it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () =>
+ Effect.gen(function* () {
+ const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" });
+ const t3 = {
+ url: undefined,
+ export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 },
+ } as const;
+ assert.strictEqual(
+ OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"),
+ undefined,
+ );
+ assert.strictEqual(
+ OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces")
+ ?.url,
+ "http://settings:4318/v1/traces",
+ );
+ }),
+ );
+
describe("layerResourceAttributes", () => {
it.effect.each([
{ name: "a list that does not decode", raw: "team=%zz", attributes: [] },
diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts
index 05a787dcfede..c2496bd28a52 100644
--- a/packages/shared/src/otelEnvironment.ts
+++ b/packages/shared/src/otelEnvironment.ts
@@ -1,5 +1,5 @@
/**
- * otelEnvironment: the OpenTelemetry kill switch and endpoint variables,
+ * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables,
* shared by the server and the desktop main process so both agree on what
* turns export off and where it goes.
*
@@ -23,8 +23,9 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS";
/**
* What the OTEL variables say about one signal. `Off` is a signal they
- * claimed with an endpoint, protocol, or headers that do not read, so it is
- * exported nowhere rather than to the bootstrap or Settings collector.
+ * claimed with an endpoint, protocol, or headers that do not read, or turned
+ * off with `OTEL__EXPORTER=none`, so it is exported nowhere rather
+ * than to the bootstrap or Settings collector.
*/
export type OtelSignal = Data.TaggedEnum<{
Unset: {};
@@ -179,6 +180,38 @@ const headers = (name: string) =>
`${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`,
);
+type Exporter = "otlp" | "none";
+
+const EXPORTERS: ReadonlySet = new Set(["otlp", "none"]);
+
+const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry);
+
+/**
+ * `OTEL__EXPORTER`, a case-insensitive list whose default is `otlp`.
+ * Entries T3 Code has no exporter for are named in a warning and dropped, and
+ * a list left with nothing to honor reads as unset, as the specification asks
+ * of any enum value an implementation does not recognize.
+ */
+const exporter = (name: string): Config.Config> =>
+ Config.String(name).pipe(
+ Config.option,
+ Config.map((option): Setting => {
+ const entries = (Option.getOrUndefined(option) ?? "")
+ .split(",")
+ .map((entry) => entry.trim().toLowerCase())
+ .filter((entry) => entry !== "");
+ const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))];
+ const known = new Set(entries.filter(isExporter));
+ const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined;
+ return ignored.length === 0
+ ? { value }
+ : {
+ value,
+ warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`,
+ };
+ }),
+ );
+
interface Settings {
readonly endpoint: Setting;
readonly protocol: Setting;
@@ -215,9 +248,23 @@ interface ResolvedSignal {
/**
* A signal whose endpoint, protocol, or headers do not read is not exported
* rather than sent somewhere, in a format, or without the credentials its
- * collector expects.
+ * collector expects. `none` turns the signal off before any of those are read,
+ * whether or not an endpoint was named.
*/
-const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
+const signal = (
+ name: OtlpSignalName,
+ exporter: Setting,
+ own: Settings,
+ generic: Settings,
+): ResolvedSignal => {
+ if (exporter.value === "none") {
+ return { signal: OtelSignal.Off(), used: [exporter] };
+ }
+ const resolved = endpointSignal(name, own, generic);
+ return { signal: resolved.signal, used: [exporter, ...resolved.used] };
+};
+
+const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
const ownEndpoint = isClaimed(own.endpoint);
const endpoint = ownEndpoint ? own.endpoint : generic.endpoint;
if (endpoint.value === undefined) {
@@ -263,16 +310,21 @@ export const load: Effect.Effect = Config.all({
traces: settings("OTEL_EXPORTER_OTLP_TRACES_"),
metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"),
logs: settings("OTEL_EXPORTER_OTLP_LOGS_"),
+ exporters: Config.all({
+ traces: exporter("OTEL_TRACES_EXPORTER"),
+ metrics: exporter("OTEL_METRICS_EXPORTER"),
+ logs: exporter("OTEL_LOGS_EXPORTER"),
+ }),
}).pipe(
- Effect.map(({ t3, spec, resource, generic, ...own }) => {
+ Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => {
const disabled = t3.value ?? spec.value ?? false;
// The kill switch wins outright, so the signals say nothing once it is set.
const signals = disabled
? undefined
: {
- traces: signal("TRACES", own.traces, generic),
- metrics: signal("METRICS", own.metrics, generic),
- logs: signal("LOGS", own.logs, generic),
+ traces: signal("TRACES", exporters.traces, own.traces, generic),
+ metrics: signal("METRICS", exporters.metrics, own.metrics, generic),
+ logs: signal("LOGS", exporters.logs, own.logs, generic),
};
// A generic variable read by several signals warns once.
const used = new Set(
diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts
index 621fe49b3087..6c89e49d017f 100644
--- a/packages/shared/src/shell.test.ts
+++ b/packages/shared/src/shell.test.ts
@@ -25,6 +25,7 @@ import {
resolveWindowsEnvironment,
SpawnExecutableResolution,
WindowsShellEnvironment,
+ withPathDirectoryListings,
type WindowsShellEnvironmentReader,
} from "./shell.ts";
@@ -473,6 +474,40 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4);
}),
);
+
+ it.effect("probes only listed PATH names and relists a directory that changes", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const first = yield* fs.makeTempDirectoryScoped();
+ const second = yield* fs.makeTempDirectoryScoped();
+ yield* fs.writeFileString(path.join(first, "cursor.CMD"), "");
+ yield* fs.writeFileString(path.join(second, "cursor.EXE"), "");
+ const env = { PATH: `${first};${second}`, PATHEXT: ".EXE;.CMD" };
+ const probed: Array = [];
+ yield* Effect.gen(function* () {
+ expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD"));
+ expect(yield* isCommandAvailable("absent", { env })).toBe(false);
+ yield* fs.writeFileString(path.join(second, "late.EXE"), "");
+ yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); // seconds: 2100-01-01
+ expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE"));
+ }).pipe(
+ withPathDirectoryListings,
+ Effect.provideService(FileSystem.FileSystem, {
+ ...fs,
+ stat: (file) => {
+ // Record candidate probes, not the per-lookup directory mtime checks.
+ if (file !== first && file !== second) probed.push(file);
+ return fs.stat(file);
+ },
+ }),
+ );
+ expect(probed).toEqual([path.join(first, "cursor.CMD"), path.join(second, "late.EXE")]);
+ }).pipe(
+ Effect.provideService(HostProcessPlatform, "win32"),
+ Effect.provideService(CommandResolutionCache, new Map()),
+ ),
+ );
});
effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts
index 11a45907cc1d..6cb08d2890be 100644
--- a/packages/shared/src/shell.ts
+++ b/packages/shared/src/shell.ts
@@ -3,10 +3,12 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
+import * as Cache from "effect/Cache";
import * as Clock from "effect/Clock";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
import * as Path from "effect/Path";
import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
@@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference