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 da020ac05153..c6537f6eac71 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -87,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.
@@ -612,9 +644,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
+
+## 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 98097af6c733..f6f4cba67027 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 ece109b0da0b..cfa75e780cb0 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -39,6 +39,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.
@@ -129,3 +132,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..1f7dfc3bf143
--- /dev/null
+++ b/packages/client-runtime/src/platform/persistence.test.ts
@@ -0,0 +1,57 @@
+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));
+ }),
+ // Generating 2000 samples outlasts the default 5s on 4-vCPU CI runners.
+ 30_000,
+ );
+});
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 2c60c87db732..2152deb85aa7 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,7 @@ 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 { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts";
const input = {
sinceDay: UsageDay.make("2026-09-05"),
@@ -238,3 +241,48 @@ 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);
+ });
+});
diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts
index 7da1b611384c..91fe9f4c194a 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,18 @@ 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
+ );
+}
+
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 6a38cbeefc32..c70ed1764b76 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -94,6 +94,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 3c3c1ebe67e4..576418645116 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 226784abc523..994c9fd7f3e7 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -300,6 +300,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)),
@@ -308,6 +312,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({}))),
/**
@@ -1646,6 +1651,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),
customBackgrounds: Schema.optionalKey(CustomBackgroundRecords),
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 0b7296ffce0e..e8c25b37f25f 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -81,6 +81,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/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