diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index 5026a90e13b9a..32b66ddb918d0 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -396,6 +396,34 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
+
+ write({ nadirRouting: value })}
+ />
+ write({ nadirCompaction: value })}
+ />
+
0} />
>
) : null}
diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts
index 64fe62d8a15f7..dd3feb2f63d1a 100644
--- a/apps/mobile/src/state/thread-outbox-model.ts
+++ b/apps/mobile/src/state/thread-outbox-model.ts
@@ -194,6 +194,22 @@ export function threadOutboxRetryDelayMs(attempt: number): number {
export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
+/**
+ * Whether a delivered send hands the composer's model pick back to the thread: only a new turn,
+ * which Nadir may route. An "auto" send into an active run steers it and keeps the model.
+ */
+export function releasesComposerModelPick(input: {
+ readonly nadirRouting: boolean;
+ readonly dispatchMode: ComposerDispatchMode | undefined;
+ readonly activeRunId: string | null;
+}): boolean {
+ return (
+ input.nadirRouting &&
+ (input.dispatchMode === undefined || input.dispatchMode === "auto") &&
+ input.activeRunId === null
+ );
+}
+
export function resolveThreadOutboxDeliveryAction(input: {
readonly isCreation: boolean;
readonly threadExists: boolean;
diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts
index 95333fc5925d2..410f339a4304a 100644
--- a/apps/mobile/src/state/thread-outbox.test.ts
+++ b/apps/mobile/src/state/thread-outbox.test.ts
@@ -79,6 +79,7 @@ import {
decodeQueuedThreadMessage,
encodeQueuedThreadMessage,
isQueuedThreadCreationSendable,
+ releasesComposerModelPick,
resolveThreadOutboxDeliveryAction,
resolveThreadOutboxDispatchStep,
resolveThreadOutboxFailureAction,
@@ -1132,6 +1133,17 @@ describe("thread outbox", () => {
).toEqual({ step: "send" });
});
+ it("hands the model pick back only after a new turn Nadir may route", () => {
+ const newTurn = { nadirRouting: true, dispatchMode: "auto", activeRunId: null } as const;
+ expect(releasesComposerModelPick(newTurn)).toBe(true);
+ expect(releasesComposerModelPick({ ...newTurn, dispatchMode: undefined })).toBe(true);
+ // An "auto" send into an active run steers it, and the pick is still the next turn's.
+ expect(releasesComposerModelPick({ ...newTurn, activeRunId: "run-active" })).toBe(false);
+ expect(releasesComposerModelPick({ ...newTurn, dispatchMode: "queue" })).toBe(false);
+ expect(releasesComposerModelPick({ ...newTurn, dispatchMode: "steer" })).toBe(false);
+ expect(releasesComposerModelPick({ ...newTurn, nadirRouting: false })).toBe(false);
+ });
+
it("only removes a missing-thread message after shell synchronization is live", () => {
expect(
resolveThreadOutboxDeliveryAction({
diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts
index d037ac106a025..551255a11ccf0 100644
--- a/apps/mobile/src/state/thread-task-permissions.test.ts
+++ b/apps/mobile/src/state/thread-task-permissions.test.ts
@@ -172,6 +172,7 @@ vi.mock("./use-composer-drafts", async () => {
appendComposerDraftText: vi.fn(),
mergeComposerDraftContent: vi.fn(),
removeComposerDraftAttachment: vi.fn(),
+ rememberSentComposerPick: () => {},
useComposerDraft: () => state.draft,
useComposerDraftSettings: () => state.draft,
questionAttachmentDraftsAtom: () => Atom.make({}),
diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts
index 309bb6a2b0a76..6b694d634208a 100644
--- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts
+++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts
@@ -153,6 +153,7 @@ vi.mock("./use-composer-drafts", async () => {
}),
replaceComposerDraftAttachments: vi.fn(),
setFreshSessionPending: vi.fn(),
+ takeSentComposerPick: () => undefined,
undoComposerDraftMerge: vi.fn(),
updateComposerDraftSettings: vi.fn(),
waitForComposerDraftsLoaded: vi.fn(),
diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts
index c64215ed26cd3..f3459f297b313 100644
--- a/apps/mobile/src/state/threadOutboxDelivery.ts
+++ b/apps/mobile/src/state/threadOutboxDelivery.ts
@@ -59,6 +59,7 @@ import {
import { removeThreadOutboxMessage } from "./thread-outbox-removal";
import {
isQueuedThreadCreationSendable,
+ releasesComposerModelPick,
resolveThreadOutboxDeliveryAction,
resolveThreadOutboxDispatchStep,
resolveThreadOutboxFailureAction,
@@ -78,8 +79,10 @@ import {
type ComposerDraft,
getComposerDraftSnapshot,
mergeComposerDraftContent,
+ releaseComposerDraftModelSelection,
replaceComposerDraftAttachments,
removeDeliveredCloudQueuedMessage,
+ takeSentComposerPick,
undoComposerDraftMerge,
updateComposerDraftSettings,
waitForComposerDraftsLoaded,
@@ -964,6 +967,21 @@ export function createThreadOutboxDelivery(): {
return restoreQueuedMessage(persistedMessage, failure.message);
}
acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId);
+ // Nadir may run a new turn on another model; the picker then shows the one that ran.
+ const sentPick = takeSentComposerPick(queuedMessage.commandId);
+ if (
+ sentPick !== undefined &&
+ releasesComposerModelPick({
+ nadirRouting: currentConfig.settings?.nadirRouting === true,
+ dispatchMode: queuedMessage.dispatchMode,
+ activeRunId: thread.runtime?.activeRunId ?? null,
+ })
+ ) {
+ releaseComposerDraftModelSelection(
+ scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId),
+ sentPick,
+ );
+ }
const delivered =
(await completeQueuedMessageDelivery(
persistedMessage,
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
index f7fe9ebbcf57c..9f80b59b0a85e 100644
--- a/apps/mobile/src/state/use-composer-drafts.test.ts
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -210,10 +210,13 @@ import {
insertComposerDraftContext,
insertComposerDraftText,
rememberComposerDraftSelection,
+ rememberSentComposerPick,
+ releaseComposerDraftModelSelection,
setComposerDraftAttachmentUpload,
waitForComposerDraftsLoaded,
setStickyComposerModelSelection,
stickyComposerModelSelectionAtom,
+ takeSentComposerPick,
undoComposerDraftMergeState,
updateComposerDraftSettings,
} from "./use-composer-drafts";
@@ -587,6 +590,23 @@ describe("mobile composer drafts", () => {
expect(appAtomRegistry.get(composerDraftsAtom)[key]).toBeUndefined();
});
+ it("releases only the pick a queued send used", () => {
+ const key = "environment-1:thread-picks";
+ const sol = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-sol" };
+ // A draft with no pick sends the thread's model, so a pick made while it waits stays.
+ rememberSentComposerPick("send-1", getComposerDraftSnapshot(key).modelSelection);
+ updateComposerDraftSettings(key, { modelSelection: sol });
+ expect(takeSentComposerPick("send-1")).toBeUndefined();
+ // Picking the sent model again is a new pick, so releasing the sent one leaves it.
+ rememberSentComposerPick("send-2", getComposerDraftSnapshot(key).modelSelection);
+ updateComposerDraftSettings(key, { modelSelection: { ...sol } });
+ const sent = takeSentComposerPick("send-2");
+ expect(sent).toBe(sol);
+ releaseComposerDraftModelSelection(key, sent!);
+ expect(getComposerDraftSnapshot(key).modelSelection).toEqual(sol);
+ expect(takeSentComposerPick("send-2")).toBeUndefined();
+ });
+
it("drops chips and records for attachments a replace no longer keeps", () => {
const key = "new-task:draft-1";
const kept = {
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index 2e4e53ef6dd45..817955f9f9203 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -1328,6 +1328,22 @@ function updateComposerDrafts(
schedulePersistComposerState();
}
+// The draft pick each queued send used, by command id. A send from a draft with no pick has no
+// entry, so a pick made while it was queued stays. After a restart the entry is gone and the
+// picker keeps the pick. ponytail: a send that never delivers leaves its entry until then.
+const sentComposerPicks = new Map();
+
+export function rememberSentComposerPick(commandId: string, pick: ModelSelection | undefined) {
+ if (pick !== undefined) sentComposerPicks.set(commandId, pick);
+}
+
+/** The draft pick a queued send used, if it had one; forgotten once read. */
+export function takeSentComposerPick(commandId: string): ModelSelection | undefined {
+ const pick = sentComposerPicks.get(commandId);
+ sentComposerPicks.delete(commandId);
+ return pick;
+}
+
export function setStickyComposerModelSelection(modelSelection: ModelSelection): void {
appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection);
schedulePersistComposerState();
diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts
index a3a191d54cbd1..0f5518305dc0a 100644
--- a/apps/mobile/src/state/use-thread-composer-state.ts
+++ b/apps/mobile/src/state/use-thread-composer-state.ts
@@ -82,6 +82,7 @@ import {
getComposerDraftSnapshot,
isFreshSessionPending,
mergeComposerDraftContent,
+ rememberSentComposerPick,
removeComposerDraftAttachment,
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
@@ -799,6 +800,7 @@ export function useThreadComposerState(input: {
const messageId = MessageId.make(metadata.messageId);
onWillEnqueue?.();
clearThreadComposerError(threadKey);
+ rememberSentComposerPick(metadata.commandId, draft.modelSelection);
const enqueuePromise = enqueueThreadOutboxMessage({
environmentId: selectedThreadShell.environmentId,
threadId: selectedThreadShell.id,
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 12372ded44b03..870e0c1e35b5b 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -96,6 +96,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
+import * as ServerSettings from "../../serverSettings.ts";
import {
BUNDLED_CLAUDE_MODEL_CATALOG,
type ClaudeModelCatalog,
@@ -386,13 +387,25 @@ describe("ClaudeAdapterV2 runtime query policy", () => {
nativeThreadId: "native-tool-search",
resume: false,
cwd: "/workspace",
- sdkSettings: { env: { OTHER: "kept" } },
+ sdkSettings: {
+ env: { OTHER: "kept" },
+ hooks: {
+ PostToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "user-hook" }] }],
+ },
+ },
toolSearchOverride: "true",
+ extraHooks: {
+ PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command: "x" }] }],
+ },
});
assert.deepEqual((options.settings as { env?: Record }).env, {
OTHER: "kept",
ENABLE_TOOL_SEARCH: "true",
});
+ assert.deepEqual((options.settings as { hooks?: Record }).hooks?.PostToolUse, [
+ { matcher: "Write", hooks: [{ type: "command", command: "user-hook" }] },
+ { matcher: "Read", hooks: [{ type: "command", command: "x" }] },
+ ]);
});
it("sends the auto-compact window as the env var Claude Code prefers", () => {
@@ -4668,6 +4681,58 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect("skips Nadir compaction when a custom home's settings already run it", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-nadir-" });
+ const hostHome = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-home-" });
+ const script = path.join(home, "nadir", "context_session.py");
+ yield* fileSystem.makeDirectory(path.dirname(script), { recursive: true });
+ yield* fileSystem.writeFileString(script, "");
+ const injectsNadir = Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarnessWithOptions({
+ settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: home },
+ });
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("attempt-nadir-custom-home"),
+ text: "Run git status.",
+ attachments: [],
+ }),
+ );
+ const settings = harness.getOpenedOptions()?.settings as ClaudeSdk.Settings | undefined;
+ return (settings?.hooks?.PostToolUse ?? []).some((matcher) =>
+ matcher.hooks.some((hook) => "args" in hook && hook.args?.[0] === script),
+ );
+ }),
+ ).pipe(
+ Effect.provide(ServerSettings.layerTest({ nadirCompaction: true })),
+ Effect.provideService(HostProcess.HomeDirectory, hostHome),
+ Effect.provideService(SpawnExecutableResolution, () => "/usr/bin/python3"),
+ );
+ assert.isTrue(yield* injectsNadir);
+ yield* fileSystem.writeFileString(
+ path.join(home, "settings.json"),
+ JSON.stringify({ enabledPlugins: { "nadir-route@nadir": true } }),
+ );
+ assert.isFalse(yield* injectsNadir);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
// regression: #21806 only a missing custom settings file is benign; bad policy fails
it.effect(
"distinguishes missing custom settings from unreadable, malformed, or unresolved policy",
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 020b2feca2126..c01bf9682f21a 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -83,6 +83,7 @@ import {
} from "@t3tools/shared/mcpApp";
import { AgentScope } from "@t3tools/shared/AgentScope";
import * as HostProcess from "@t3tools/shared/HostProcess";
+import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
import { compareSemverVersions } from "@t3tools/shared/semver";
import {
@@ -191,6 +192,11 @@ import {
mergeClaudeProcessEnvironment,
resolveClaudeHomePath,
} from "../../provider/Drivers/ClaudeHome.ts";
+import {
+ findNadirCompactionScript,
+ nadirCompactionHooks,
+ withExtraHooks,
+} from "../NadirCompaction.ts";
import {
BUNDLED_CLAUDE_MODEL_CATALOG,
resolveClaudeCatalogContextWindowTokens,
@@ -1969,6 +1975,8 @@ export function makeClaudeQueryOptions(input: {
readonly appendSystemPromptFileText?: string;
// Flag settings, because a user's settings.json env beats the process env in Claude Code.
readonly toolSearchOverride?: "true";
+ /** Hooks to run beside the user's own, such as Nadir compaction. */
+ readonly extraHooks?: ClaudeSdkSettings["hooks"];
}): ClaudeAgentSdkQueryOptions {
const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog);
const {
@@ -2029,7 +2037,7 @@ export function makeClaudeQueryOptions(input: {
}
: undefined;
// A settings file path cannot take extra keys, so it goes through unchanged.
- const effectiveQuerySettings =
+ const envQuerySettings =
(input.toolSearchOverride === undefined &&
aliasOverrideEnv === undefined &&
autoCompactWindow === undefined) ||
@@ -2049,6 +2057,10 @@ export function makeClaudeQueryOptions(input: {
: { ENABLE_TOOL_SEARCH: input.toolSearchOverride }),
},
} as ClaudeSdkSettings);
+ const effectiveQuerySettings =
+ input.extraHooks === undefined || typeof envQuerySettings === "string"
+ ? envQuerySettings
+ : withExtraHooks(envQuerySettings, input.extraHooks);
const deviceInstructions = input.mcpServers !== undefined && input.deviceToolsAvailable === true;
const options: ClaudeAgentSdkQueryOptions = {
model: compiledSelection.apiModelId,
@@ -5264,6 +5276,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
).pipe(Effect.provideService(Path.Path, path));
const hostEnvironment = yield* HostProcess.Environment;
const hostPlatform = yield* HostProcess.Platform;
+ const hostHome = yield* HostProcess.HomeDirectory;
+ const nadirStateDir = path.join(hostHome, ".nadir", "context");
const customHome =
adapterOptions.settings.homePath.trim().length > 0 ||
adapterOptions.environment.CLAUDE_CONFIG_DIR !== hostEnvironment.CLAUDE_CONFIG_DIR;
@@ -5377,6 +5391,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (path.dirname(directory) === directory) return cwd;
}
});
+ const resolveNadirCompactionHooks = Effect.fn("ClaudeAdapterV2.resolveNadirCompactionHooks")(
+ function* (cwd: string | null, launchSettings: ClaudeSdkSettings | undefined) {
+ if (Option.isNone(serverSettings)) return undefined;
+ const enabled = yield* serverSettings.value.getSettings.pipe(
+ Effect.map((settings) => settings.nadirCompaction),
+ Effect.orElseSucceed(() => false),
+ );
+ if (!enabled) return undefined;
+ // The query loads every source, and a custom home's settings.json is its user settings.
+ const script = yield* findNadirCompactionScript({
+ fileSystem,
+ path,
+ claudeHome: homePath,
+ hostHome,
+ settingsFiles: yield* settingsFilesByPrecedence(cwd),
+ launchSettings,
+ });
+ if (script === undefined) {
+ yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it");
+ return undefined;
+ }
+ // Every hook would fail on a host without python3, so skip rather than add them.
+ const resolveExecutable = yield* SpawnExecutableResolution;
+ if (resolveExecutable("python3", hostPlatform, adapterOptions.environment) === undefined) {
+ yield* Effect.logDebug("Nadir compaction skipped: python3 is not on PATH");
+ return undefined;
+ }
+ return nadirCompactionHooks(script, nadirStateDir);
+ },
+ );
+
const settingsFilesByPrecedence = Effect.fn("ClaudeAdapterV2.settingsFilesByPrecedence")(
function* (cwd: string | null) {
const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd);
@@ -12105,6 +12150,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
turnInput.runtimePolicy.cwd,
launchArgSettings,
);
+ const extraHooks = yield* resolveNadirCompactionHooks(
+ turnInput.runtimePolicy.cwd,
+ launchArgSettings,
+ );
const queryOptions = makeClaudeQueryOptions({
modelSelection: turnInput.modelSelection,
modelCatalog,
@@ -12149,6 +12198,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
supportedDialogKinds: ["resume_return"],
...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }),
...(toolSearchOverride === undefined ? {} : { toolSearchOverride }),
+ ...(extraHooks === undefined ? {} : { extraHooks }),
});
const resolvedTurnCwd = yield* fileSystem
.realPath(turnInput.runtimePolicy.cwd ?? ".")
diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts
new file mode 100644
index 0000000000000..2b6ed5a300235
--- /dev/null
+++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts
@@ -0,0 +1,156 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import {
+ findNadirCompactionScript,
+ nadirCompactionHooks,
+ withExtraHooks,
+} from "./NadirCompaction.ts";
+
+it.layer(NodeServices.layer)("NadirCompaction", (it) => {
+ describe("findNadirCompactionScript", () => {
+ it.effect("finds an installed script unless the loaded Claude settings already run it", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const claudeHome = yield* fileSystem.makeTempDirectoryScoped();
+ const hostHome = yield* fileSystem.makeTempDirectoryScoped();
+ const userSettings = path.join(claudeHome, "settings.json");
+ const find = (
+ settingsFiles: ReadonlyArray = [userSettings],
+ launchSettings?: unknown,
+ ) =>
+ findNadirCompactionScript({
+ fileSystem,
+ path,
+ claudeHome,
+ hostHome,
+ settingsFiles,
+ launchSettings,
+ });
+ const touch = (file: string) =>
+ fileSystem
+ .makeDirectory(path.dirname(file), { recursive: true })
+ .pipe(Effect.andThen(fileSystem.writeFileString(file, "")));
+ const writeJson = (file: string, value: unknown) =>
+ fileSystem.writeFileString(file, JSON.stringify(value));
+ expect(yield* find()).toBeUndefined();
+ const labsScript = path.join(claudeHome, "nadir", "context_session.py");
+ yield* fileSystem.makeDirectory(labsScript, { recursive: true });
+ expect(yield* find()).toBeUndefined();
+ yield* fileSystem.remove(labsScript, { recursive: true });
+
+ const installPath = path.join(claudeHome, "plugins", "cache", "nadir-route", "1.0.0");
+ const pluginScript = path.join(installPath, "scripts", "context_session.py");
+ yield* touch(pluginScript);
+ yield* writeJson(path.join(claudeHome, "plugins", "installed_plugins.json"), {
+ plugins: { "nadir-route@nadir": [{ scope: "user", installPath }] },
+ });
+ expect(yield* find()).toBe(pluginScript);
+
+ // The host's own nadir-labs install outranks a plugin, and a malformed entry is ignored.
+ const hostLabsScript = path.join(hostHome, ".claude", "nadir", "context_session.py");
+ yield* touch(hostLabsScript);
+ expect(yield* find()).toBe(hostLabsScript);
+ yield* writeJson(path.join(claudeHome, "plugins", "installed_plugins.json"), {
+ plugins: { "nadir-route@nadir": [{ installPath: 42 }] },
+ });
+ expect(yield* find()).toBe(hostLabsScript);
+ yield* fileSystem.remove(hostLabsScript);
+
+ // npx nadir-labs installs the script beside the user's Claude settings.
+ yield* touch(labsScript);
+ expect(yield* find()).toBe(labsScript);
+
+ yield* writeJson(path.join(claudeHome, "settings.json"), {
+ hooks: {
+ PostToolUse: [
+ {
+ matcher: "Read|Bash",
+ hooks: [{ type: "command", command: `python3 ${labsScript}` }],
+ },
+ ],
+ },
+ });
+ expect(yield* find()).toBeUndefined();
+ // With no loaded settings running it, T3 adds the hooks itself.
+ expect(yield* find([])).toBe(labsScript);
+ // A repo can enable Nadir too. The highest file naming the plugin decides it.
+ const localSettings = path.join(hostHome, "repo", ".claude", "settings.local.json");
+ const projectSettings = path.join(hostHome, "repo", ".claude", "settings.json");
+ yield* touch(localSettings);
+ yield* writeJson(localSettings, { enabledPlugins: { "nadir-route@nadir": true } });
+ expect(yield* find([localSettings])).toBeUndefined();
+ yield* writeJson(localSettings, { enabledPlugins: { "nadir-route@nadir": false } });
+ yield* writeJson(projectSettings, { enabledPlugins: { "nadir-route@nadir": true } });
+ expect(yield* find([localSettings, projectSettings])).toBe(labsScript);
+ expect(yield* find([projectSettings, localSettings])).toBeUndefined();
+ // A disabled plugin does not cancel hooks a lower file wires.
+ expect(yield* find([localSettings, userSettings])).toBeUndefined();
+ // The --settings launch argument outranks every file.
+ const launchHooks = {
+ hooks: { PostToolUse: [{ hooks: [{ command: `python3 ${labsScript}` }] }] },
+ };
+ expect(yield* find([], launchHooks)).toBeUndefined();
+ const launchOff = { enabledPlugins: { "nadir-route@nadir": false } };
+ expect(yield* find([projectSettings], launchOff)).toBe(labsScript);
+
+ // A plugin installed only under the host's own Claude home is found from a separate one.
+ const separateHome = yield* fileSystem.makeTempDirectoryScoped();
+ const hostPluginPath = path.join(hostHome, ".claude", "plugins", "cache", "nadir-route");
+ const hostPluginScript = path.join(hostPluginPath, "scripts", "context_session.py");
+ yield* touch(hostPluginScript);
+ // The first install is stale; the second still has the script.
+ yield* writeJson(path.join(hostHome, ".claude", "plugins", "installed_plugins.json"), {
+ plugins: {
+ "nadir-route@nadir": [
+ { scope: "user", installPath: path.join(hostHome, "gone") },
+ { scope: "project", installPath: hostPluginPath },
+ ],
+ },
+ });
+ expect(
+ yield* findNadirCompactionScript({
+ fileSystem,
+ path,
+ claudeHome: separateHome,
+ hostHome,
+ settingsFiles: [],
+ }),
+ ).toBe(hostPluginScript);
+
+ yield* writeJson(userSettings, {
+ enabledPlugins: { "nadir-route@nadir": true },
+ });
+ expect(yield* find()).toBeUndefined();
+ }).pipe(Effect.scoped),
+ );
+ });
+
+ it("adds Nadir's hooks after the user's own", () => {
+ const own = { type: "command" as const, command: "own-hook" };
+ const settings = withExtraHooks(
+ { env: { KEPT: "1" }, hooks: { PostToolUse: [{ matcher: "Write", hooks: [own] }] } },
+ nadirCompactionHooks("/plugin/scripts/context_session.py", "/home/.nadir/context"),
+ );
+ expect(settings.env).toEqual({ KEPT: "1" });
+ expect(settings.hooks?.PostToolUse?.map((entry) => entry.matcher)).toEqual([
+ "Write",
+ "Read|Bash",
+ ]);
+ expect(settings.hooks?.SessionStart?.[0]?.hooks[0]).toMatchObject({
+ command: "python3",
+ args: [
+ "/plugin/scripts/context_session.py",
+ "--state-dir",
+ "/home/.nadir/context",
+ "--max-chars",
+ "8000",
+ "--no-policy",
+ ],
+ });
+ });
+});
diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts
new file mode 100644
index 0000000000000..9b10b162057b7
--- /dev/null
+++ b/apps/server/src/orchestration-v2/NadirCompaction.ts
@@ -0,0 +1,126 @@
+import type { Settings as ClaudeSdkSettings } from "@anthropic-ai/claude-agent-sdk";
+import * as Effect from "effect/Effect";
+import type * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import type * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+// Nadir compaction: Claude turns run an installed Nadir's compaction hooks, which shrink bulky
+// Read and Bash output locally and keep an exact archive. Its routing hooks never run from here.
+// The script pins at session start, so resumed sessions stay untouched.
+
+const PLUGIN_ID = "nadir-route@nadir";
+
+type ClaudeHooks = NonNullable;
+
+const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
+
+const readJson = (fileSystem: FileSystem.FileSystem, file: string) =>
+ fileSystem.readFileString(file).pipe(
+ Effect.map((text) => Option.getOrUndefined(decodeJson(text))),
+ Effect.orElseSucceed(() => undefined),
+ );
+
+const field = (value: unknown, key: string): unknown =>
+ typeof value === "object" && value !== null ? (value as Record)[key] : undefined;
+
+const SCRIPT = "context_session.py";
+
+/** Whether a settings file wires Nadir's compaction script, as `npx nadir-labs` installs it. */
+function wiresNadirScript(settings: unknown): boolean {
+ const hooks = field(settings, "hooks");
+ return (
+ typeof hooks === "object" &&
+ hooks !== null &&
+ Object.values(hooks)
+ .flat()
+ .some((matcher) => JSON.stringify(field(matcher, "hooks") ?? []).includes(SCRIPT))
+ );
+}
+
+/** Whether settings files, highest precedence first, already run Nadir's compaction. */
+function runsNadirCompaction(settingsByPrecedence: ReadonlyArray): boolean {
+ // The highest file that names the plugin decides it; hooks from every file run.
+ const plugin = settingsByPrecedence
+ .map((settings) => field(field(settings, "enabledPlugins"), PLUGIN_ID))
+ .find((enabled) => enabled !== undefined);
+ return plugin === true || settingsByPrecedence.some(wiresNadirScript);
+}
+
+/**
+ * The compaction script to run, or undefined when Nadir is not installed or Claude Code already
+ * runs it from a settings file it loads, so the hooks never run twice. Looks in the
+ * `npx nadir-labs` install first, then the `nadir-route` plugin.
+ */
+export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript")(function* (input: {
+ readonly fileSystem: FileSystem.FileSystem;
+ readonly path: Path.Path;
+ readonly claudeHome: string;
+ readonly hostHome: string;
+ /** The settings files Claude Code loads for this session, highest precedence first. */
+ readonly settingsFiles: ReadonlyArray;
+ /** The `--settings` launch argument, which outranks every file. */
+ readonly launchSettings?: unknown;
+}) {
+ const { fileSystem, path, claudeHome } = input;
+ const settings = [
+ input.launchSettings,
+ ...(yield* Effect.forEach(input.settingsFiles, (file) => readJson(fileSystem, file))),
+ ];
+ if (runsNadirCompaction(settings)) return undefined;
+ const homes = [claudeHome, path.join(input.hostHome, ".claude")];
+ const pluginScripts: string[] = [];
+ for (const home of homes) {
+ const installed = yield* readJson(
+ fileSystem,
+ path.join(home, "plugins", "installed_plugins.json"),
+ );
+ const entries = field(field(installed, "plugins"), PLUGIN_ID);
+ // Every install counts: an earlier one can be stale.
+ for (const entry of Array.isArray(entries) ? entries : []) {
+ const pluginPath = field(entry, "installPath");
+ if (typeof pluginPath === "string")
+ pluginScripts.push(path.join(pluginPath, "scripts", SCRIPT));
+ }
+ }
+ const candidates = [...homes.map((home) => path.join(home, "nadir", SCRIPT)), ...pluginScripts];
+ for (const script of candidates) {
+ const isFile = yield* fileSystem.stat(script).pipe(
+ Effect.map((info) => info.type === "File"),
+ Effect.orElseSucceed(() => false),
+ );
+ if (isFile) return script;
+ }
+ return undefined;
+});
+
+/** The plugin's own compaction hook entries, in exec form so no shell parses the paths. */
+export function nadirCompactionHooks(script: string, stateDir: string): ClaudeHooks {
+ const hooks = [
+ {
+ type: "command" as const,
+ command: "python3",
+ args: [script, "--state-dir", stateDir, "--max-chars", "8000", "--no-policy"],
+ timeout: 5,
+ },
+ ];
+ return {
+ SessionStart: [{ matcher: "startup|clear|resume|compact", hooks }],
+ UserPromptSubmit: [{ hooks }],
+ SubagentStart: [{ matcher: ".*", hooks }],
+ PreToolUse: [{ matcher: "Read", hooks }],
+ PostToolUse: [{ matcher: "Read|Bash", hooks }],
+ };
+}
+
+/** Adds hook entries after any the settings already carry. */
+export function withExtraHooks(
+ settings: ClaudeSdkSettings | undefined,
+ extra: ClaudeHooks,
+): ClaudeSdkSettings {
+ const hooks: ClaudeHooks = { ...settings?.hooks };
+ for (const [event, matchers] of Object.entries(extra)) {
+ hooks[event] = [...(hooks[event] ?? []), ...matchers];
+ }
+ return { ...settings, hooks };
+}
diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts
new file mode 100644
index 0000000000000..42427b0460617
--- /dev/null
+++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts
@@ -0,0 +1,300 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as KeyValueStore from "effect/persistence/KeyValueStore";
+import * as TestClock from "effect/testing/TestClock";
+import {
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ModelSelection,
+ type ServerProvider,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
+
+import {
+ baselineFor,
+ layerCeilingsAt,
+ layerCeilingsFromStore,
+ layerCeilingsMemory,
+ NadirCeilings,
+ nadirRequestBody,
+ pickModel,
+ rememberChoice,
+ routedSelection,
+} from "./NadirRouting.ts";
+
+const menu = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
+const context = { baseline: "claude-opus-4-6", current: "claude-opus-4-6", menu };
+const routed = (model: string, extra: Record = {}) => ({
+ selected_model: model,
+ coverage_held: false,
+ input_truncated: false,
+ policy: { advised_model: model, suppressed: false },
+ ...extra,
+});
+
+describe("pickModel", () => {
+ it("takes Nadir's qualified pick from the menu", () => {
+ expect(pickModel(routed("claude-haiku-4-5"), context)).toBe("claude-haiku-4-5");
+ });
+
+ it.each([
+ ["no answer", null],
+ ["a model outside the menu", routed("gpt-5.5")],
+ [
+ "a suppressed saving",
+ routed("claude-haiku-4-5", {
+ policy: { advised_model: "claude-haiku-4-5", suppressed: true },
+ }),
+ ],
+ [
+ "advice that disagrees with the pick",
+ routed("claude-haiku-4-5", {
+ policy: { advised_model: "claude-sonnet-4-6", suppressed: false },
+ }),
+ ],
+ ["a truncated prompt", routed("claude-haiku-4-5", { input_truncated: true })],
+ ["held coverage", routed("claude-haiku-4-5", { coverage_held: true })],
+ [
+ "a warm cache worth keeping",
+ routed("claude-haiku-4-5", { cache_advice: { decision: "stay_warm" } }),
+ ],
+ ])("keeps the user's pick on %s", (_label, answer) => {
+ expect(pickModel(answer, context)).toBe("claude-opus-4-6");
+ });
+
+ it("keeps a cheaper model that already holds the cache when Nadir picks it again", () => {
+ const answer = routed("claude-haiku-4-5", {
+ policy: { advised_model: "claude-haiku-4-5", suppressed: true },
+ });
+ expect(pickModel(answer, { ...context, current: "claude-haiku-4-5" })).toBe("claude-haiku-4-5");
+ });
+
+ it("follows an organization's governance model when the menu has it", () => {
+ expect(pickModel({ governance_model: "claude-sonnet-4-6" }, context)).toBe("claude-sonnet-4-6");
+ expect(pickModel({ governance_model: "gpt-5.5" }, context)).toBe("claude-opus-4-6");
+ });
+});
+
+describe("baselineFor", () => {
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const opus: ModelSelection = { instanceId, model: "claude-opus-4-6", options: [] };
+ const haiku: ModelSelection = { instanceId, model: "claude-haiku-4-5" };
+
+ it.effect("keeps the user's pick as the ceiling after Nadir routes down", () =>
+ Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ yield* rememberChoice(ceilings, "thread-1", opus, haiku);
+ // The picker now shows Haiku; sending again must not make Haiku the ceiling.
+ expect(yield* baselineFor(ceilings, "thread-1", haiku)).toEqual(opus);
+ // Kept only while the thread runs below its ceiling.
+ yield* rememberChoice(ceilings, "thread-1", opus, opus);
+ expect(Option.isNone(yield* ceilings.get("thread-1"))).toBe(true);
+ }).pipe(Effect.provide(layerCeilingsMemory)),
+ );
+
+ it.effect("puts back the record a refused send replaced, unless a later turn saved one", () =>
+ Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" };
+ yield* rememberChoice(ceilings, "thread-1", opus, haiku);
+ // Routing back to the ceiling clears the record, then the server refuses the send.
+ const backToOpus = { threadId: "thread-1", baseline: opus, chosen: opus };
+ yield* ceilings.decide({ ...backToOpus, commandId: "send-1" });
+ expect(Option.isNone(yield* ceilings.get("thread-1"))).toBe(true);
+ yield* ceilings.undo("send-1");
+ expect(yield* ceilings.get("thread-1")).toEqual(
+ Option.some({ baseline: opus, chosen: haiku }),
+ );
+ // A retry of the refused command asks Nadir again.
+ expect(ceilings.decided("send-1")).toBeUndefined();
+ yield* ceilings.decide({ ...backToOpus, commandId: "send-2" });
+ yield* rememberChoice(ceilings, "thread-1", opus, sonnet);
+ yield* ceilings.undo("send-2");
+ expect(yield* ceilings.get("thread-1")).toEqual(
+ Option.some({ baseline: opus, chosen: sonnet }),
+ );
+ }).pipe(Effect.provide(layerCeilingsMemory)),
+ );
+
+ it.effect("keeps the ceiling across a server restart", () =>
+ Effect.gen(function* () {
+ const directory = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
+ yield* Effect.gen(function* () {
+ yield* rememberChoice(yield* NadirCeilings, "thread-1", opus, haiku);
+ }).pipe(Effect.provide(layerCeilingsAt(directory)));
+ // A fresh store on the same directory is what the next server process opens.
+ const restarted = yield* Effect.gen(function* () {
+ return yield* baselineFor(yield* NadirCeilings, "thread-1", haiku);
+ }).pipe(Effect.provide(layerCeilingsAt(directory)));
+ expect(restarted).toEqual(opus);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("keeps the ceiling in memory when the disk cannot save it", () => {
+ let failedSaves = 0;
+ const layerFailingSaves = Layer.effect(
+ KeyValueStore.KeyValueStore,
+ Effect.gen(function* () {
+ const memory = yield* KeyValueStore.KeyValueStore;
+ return KeyValueStore.make({
+ get: memory.get,
+ getUint8Array: memory.getUint8Array,
+ remove: memory.remove,
+ clear: memory.clear,
+ size: memory.size,
+ keys: memory.keys,
+ set: (key) =>
+ Effect.sync(() => (failedSaves += 1)).pipe(
+ Effect.andThen(
+ Effect.fail(
+ new KeyValueStore.KeyValueStoreError({ method: "set", key, message: "full" }),
+ ),
+ ),
+ ),
+ });
+ }),
+ ).pipe(Layer.provide(KeyValueStore.layerMemory));
+ return Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ yield* rememberChoice(ceilings, "thread-1", opus, haiku);
+ expect(failedSaves).toBe(1);
+ expect(yield* baselineFor(ceilings, "thread-1", haiku)).toEqual(opus);
+ }).pipe(Effect.provide(layerCeilingsFromStore.pipe(Layer.provide(layerFailingSaves))));
+ });
+
+ it.effect("runs one routing turn at a time for each thread", () =>
+ Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ const order: string[] = [];
+ const first = yield* ceilings
+ .lock("thread-1")
+ .withPermits(1)(
+ Effect.sync(() => order.push("first started")).pipe(
+ Effect.andThen(Effect.sleep("1 second")),
+ Effect.andThen(Effect.sync(() => order.push("first finished"))),
+ ),
+ )
+ .pipe(Effect.forkChild);
+ const second = yield* ceilings
+ .lock("thread-1")
+ .withPermits(1)(Effect.sync(() => order.push("second")))
+ .pipe(Effect.forkChild);
+ const other = yield* ceilings
+ .lock("thread-2")
+ .withPermits(1)(Effect.sync(() => order.push("other thread")))
+ .pipe(Effect.forkChild);
+ yield* TestClock.adjust("1 second");
+ yield* Fiber.join(first);
+ yield* Fiber.join(second);
+ yield* Fiber.join(other);
+ expect(order).toEqual(["first started", "other thread", "first finished", "second"]);
+ }).pipe(Effect.provide(layerCeilingsMemory)),
+ );
+
+ it.effect("treats a model the user picks by hand as the new ceiling", () =>
+ Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ yield* rememberChoice(ceilings, "thread-1", opus, haiku);
+ const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" };
+ expect(yield* baselineFor(ceilings, "thread-1", sonnet)).toBe(sonnet);
+ expect(yield* baselineFor(ceilings, "thread-2", haiku)).toBe(haiku);
+ // Changing only the effort of the model Nadir chose is a pick too.
+ const haikuHigh: ModelSelection = { ...haiku, options: [{ id: "effort", value: "high" }] };
+ expect(yield* baselineFor(ceilings, "thread-1", haikuHigh)).toBe(haikuHigh);
+ }).pipe(Effect.provide(layerCeilingsMemory)),
+ );
+});
+
+describe("routedSelection", () => {
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const model = (slug: string, efforts: string[], defaultEffort: string, fast = false) =>
+ ({
+ slug,
+ name: slug,
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Effort",
+ type: "select",
+ options: efforts.map((id) => ({ id, label: id, isDefault: id === defaultEffort })),
+ },
+ ...(fast ? [{ id: "fastMode", label: "Fast", type: "boolean" as const }] : []),
+ ],
+ },
+ }) satisfies ServerProviderModel;
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: true,
+ installed: true,
+ version: "0.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-11-06T00:00:00.000Z",
+ models: [
+ model("opus", ["low", "medium", "high", "max"], "medium", true),
+ model("sonnet", ["medium", "high", "max"], "high"),
+ model("haiku", ["low", "medium", "high"], "medium"),
+ ],
+ slashCommands: [],
+ skills: [],
+ };
+
+ it.each([
+ [
+ "the effort the picker showed, not the routed model's default",
+ undefined,
+ "sonnet",
+ [{ id: "effort", value: "medium" }],
+ ],
+ [
+ "a stated effort, dropping options the routed model lacks",
+ [
+ { id: "effort", value: "max" },
+ { id: "fastMode", value: true },
+ ],
+ "sonnet",
+ [{ id: "effort", value: "max" }],
+ ],
+ [
+ "the routed model's default when it lacks the level",
+ [{ id: "effort", value: "max" }],
+ "haiku",
+ [{ id: "effort", value: "medium" }],
+ ],
+ ])("keeps %s", (_label, options, target, expected) => {
+ const baseline: ModelSelection = { instanceId, model: "opus", ...(options ? { options } : {}) };
+ expect(routedSelection(baseline, target, provider)).toEqual({
+ instanceId,
+ model: target,
+ options: expected,
+ });
+ });
+});
+
+describe("nadirRequestBody", () => {
+ it("never lets Nadir route above the user's pick and prices the warm cache", () => {
+ const body = nadirRequestBody({
+ text: "Rename foo to bar in format.ts",
+ baseline: "claude-opus-4-6",
+ menu,
+ driver: "claudeAgent",
+ threadId: "thread-1",
+ cache: { model: "claude-opus-4-6", contextTokens: 120_000 },
+ });
+ expect(body).toMatchObject({
+ requested_model: "claude-opus-4-6",
+ menu,
+ policy: { allow_route_up: false },
+ agent_session: { client: "claude-code", session_id: "thread-1" },
+ cache: { model: "claude-opus-4-6", current_input_tokens: 120_000 },
+ });
+ });
+});
diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts
new file mode 100644
index 0000000000000..321e479604877
--- /dev/null
+++ b/apps/server/src/orchestration-v2/NadirRouting.ts
@@ -0,0 +1,436 @@
+import { ModelSelection, type ServerProvider } from "@t3tools/contracts";
+import {
+ buildExplicitProviderOptionSelectionsFromDescriptors,
+ buildProviderOptionSelectionsFromDescriptors,
+ createModelSelection,
+ draftModelPickMatchesThread,
+ getProviderOptionDescriptors,
+} from "@t3tools/shared/model";
+import * as Config from "effect/Config";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as KeyValueStore from "effect/persistence/KeyValueStore";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+
+import * as ServerConfig from "../config.ts";
+
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ServerSettings from "../serverSettings.ts";
+
+/**
+ * Nadir routing: before a user's turn starts, ask Nadir (getnadir.com) which model from the
+ * selected provider instance's menu should run it, and run that one. The model the user picked
+ * stays the ceiling: Nadir never routes above it unless the user's Nadir organization pins a
+ * model. Any failure keeps the user's pick.
+ */
+
+const NADIR_BUCKET_URL = "https://api.getnadir.com/v1/bucket";
+const MAX_PROMPT_CHARS = 40_000;
+// A short follow-up ("yes", "do it") depends on context Nadir does not see.
+const MIN_PROMPT_WORDS = 4;
+
+/** A bounded label for a failure; its message can carry arbitrary text. */
+const failureKind = (cause: unknown): string =>
+ typeof cause === "object" && cause !== null && "_tag" in cause && typeof cause._tag === "string"
+ ? cause._tag
+ : "unknown";
+
+// A retry arrives within moments of its first attempt; older decisions are not needed.
+const MAX_REMEMBERED_DECISIONS = 1_000;
+
+const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection });
+type Ceiling = typeof Ceiling.Type;
+
+/**
+ * A thread's routed turn: the user's pick (the ceiling) and the model Nadir ran instead. It
+ * lives on disk so a restart cannot turn a routed-down model into the thread's ceiling.
+ */
+export class NadirCeilings extends Context.Service<
+ NadirCeilings,
+ {
+ readonly get: (threadId: string) => Effect.Effect>;
+ /** Undefined clears the thread's record. */
+ readonly save: (threadId: string, ceiling: Ceiling | undefined) => Effect.Effect;
+ /** Holds one turn's read, Nadir call and write at a time for each thread. */
+ readonly lock: (threadId: string) => Semaphore.Semaphore;
+ /** The selection a command already got, so a retry of it gets the same one. */
+ readonly decided: (commandId: string) => ModelSelection | undefined;
+ /** Saves a turn's ceiling, remembering the record it replaced until the send is settled. */
+ readonly decide: (input: {
+ readonly commandId: string | undefined;
+ readonly threadId: string;
+ readonly baseline: ModelSelection;
+ readonly chosen: ModelSelection;
+ }) => Effect.Effect;
+ /** Puts back the record a refused command replaced, unless a later turn saved one since. */
+ readonly undo: (commandId: string) => Effect.Effect;
+ }
+>()("t3/orchestration-v2/NadirRouting/NadirCeilings") {}
+
+// ponytail: the memory copy and the locks hold one small entry per routed thread for the life of
+// the process. The memory copy keeps a ceiling the disk failed to save.
+export const layerCeilingsFromStore = Layer.effect(
+ NadirCeilings,
+ Effect.gen(function* () {
+ const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling);
+ const memory = new Map();
+ // Counts each thread's saves, so an undo can tell whether a later turn saved since.
+ const saves = new Map();
+ const locks = new Map();
+ const decisions = new Map<
+ string,
+ {
+ readonly threadId: string;
+ readonly chosen: ModelSelection;
+ readonly replaced: Ceiling | undefined;
+ readonly save: number;
+ }
+ >();
+ const get = Effect.fn("NadirCeilings.get")(function* (threadId: string) {
+ if (memory.has(threadId)) return Option.fromUndefinedOr(memory.get(threadId));
+ return yield* store.get(threadId).pipe(
+ Effect.tap((found) =>
+ Effect.sync(() => memory.set(threadId, Option.getOrUndefined(found))),
+ ),
+ Effect.orElseSucceed(() => Option.none()),
+ );
+ });
+ const save = Effect.fn("NadirCeilings.save")(function* (
+ threadId: string,
+ ceiling: Ceiling | undefined,
+ ) {
+ memory.set(threadId, ceiling);
+ saves.set(threadId, (saves.get(threadId) ?? 0) + 1);
+ yield* (ceiling === undefined ? store.remove(threadId) : store.set(threadId, ceiling)).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("Nadir ceiling kept in memory only", { reason: failureKind(cause) }),
+ ),
+ );
+ });
+ const lock = (threadId: string) => {
+ let found = locks.get(threadId);
+ if (found === undefined) {
+ found = Semaphore.makeUnsafe(1);
+ locks.set(threadId, found);
+ }
+ return found;
+ };
+ return {
+ get,
+ save,
+ lock,
+ decided: (commandId) => decisions.get(commandId)?.chosen,
+ decide: Effect.fn("NadirCeilings.decide")(function* ({
+ commandId,
+ threadId,
+ baseline,
+ chosen,
+ }) {
+ const replaced = Option.getOrUndefined(yield* get(threadId));
+ yield* save(threadId, baseline === chosen ? undefined : { baseline, chosen });
+ if (commandId === undefined) return;
+ decisions.set(commandId, { threadId, chosen, replaced, save: saves.get(threadId) ?? 0 });
+ if (decisions.size > MAX_REMEMBERED_DECISIONS) {
+ const oldest = decisions.keys().next().value;
+ if (oldest !== undefined) decisions.delete(oldest);
+ }
+ }),
+ undo: Effect.fn("NadirCeilings.undo")(function* (commandId: string) {
+ const decision = decisions.get(commandId);
+ if (decision === undefined) return;
+ yield* lock(decision.threadId).withPermits(1)(
+ Effect.gen(function* () {
+ // A retry of the refused command asks Nadir again.
+ decisions.delete(commandId);
+ if (saves.get(decision.threadId) === decision.save) {
+ yield* save(decision.threadId, decision.replaced);
+ }
+ }),
+ );
+ }),
+ };
+ }),
+);
+
+export const layerCeilingsMemory = layerCeilingsFromStore.pipe(
+ Layer.provide(KeyValueStore.layerMemory),
+);
+
+export const layerCeilingsAt = (directory: string) =>
+ layerCeilingsFromStore.pipe(Layer.provide(KeyValueStore.layerFileSystem(directory)));
+
+// ponytail: one small file per thread while it runs below its ceiling; a thread deleted in that
+// state leaves its file. Prune with thread deletion if the directory ever grows.
+export const layerCeilings = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const path = yield* Path.Path;
+ return layerCeilingsAt(path.join(config.stateDir, "nadir-ceilings")).pipe(
+ Layer.catch(() =>
+ Layer.effectDiscard(
+ Effect.logWarning("Nadir ceiling directory unavailable; ceilings reset on restart"),
+ ).pipe(Layer.provideMerge(layerCeilingsMemory)),
+ ),
+ );
+ }),
+);
+
+/** The user's pick: the incoming selection, unless it is what Nadir chose last turn. */
+export const baselineFor = (
+ ceilings: NadirCeilings["Service"],
+ threadId: string,
+ incoming: ModelSelection,
+) =>
+ ceilings
+ .get(threadId)
+ .pipe(
+ Effect.map((prior) =>
+ Option.isSome(prior) && draftModelPickMatchesThread(incoming, prior.value.chosen)
+ ? prior.value.baseline
+ : incoming,
+ ),
+ );
+
+/** Keeps the ceiling only while the thread runs below it. */
+export const rememberChoice = (
+ ceilings: NadirCeilings["Service"],
+ threadId: string,
+ baseline: ModelSelection,
+ chosen: ModelSelection,
+) => ceilings.decide({ commandId: undefined, threadId, baseline, chosen });
+
+/** A send the server refused leaves the thread's ceiling as it was before routing. */
+export const undoChoice = Effect.fn("NadirRouting.undoChoice")(function* (commandId: string) {
+ const ceilings = yield* Effect.serviceOption(NadirCeilings);
+ if (Option.isSome(ceilings)) yield* ceilings.value.undo(commandId);
+});
+
+/** Models the user can pick for this instance, the menu Nadir chooses from. */
+function modelMenu(provider: ServerProvider | undefined): ReadonlyArray {
+ return (
+ provider?.models.filter((model) => model.isLegacy !== true).map((model) => model.slug) ?? []
+ );
+}
+
+/**
+ * The routed model keeps the options the user's pick ran with, defaults included, so the effort
+ * the picker showed still applies. An option the routed model lacks takes its own default.
+ */
+export function routedSelection(
+ baseline: ModelSelection,
+ model: string,
+ provider: ServerProvider | undefined,
+): ModelSelection {
+ const capabilitiesOf = (slug: string) =>
+ provider?.models.find((candidate) => candidate.slug === slug)?.capabilities ?? undefined;
+ const from = capabilitiesOf(baseline.model);
+ const to = capabilitiesOf(model);
+ if (to === undefined) return createModelSelection(baseline.instanceId, model);
+ const picked =
+ from === undefined
+ ? baseline.options
+ : buildProviderOptionSelectionsFromDescriptors(
+ getProviderOptionDescriptors({ caps: from, selections: baseline.options }),
+ );
+ const options = buildExplicitProviderOptionSelectionsFromDescriptors(
+ getProviderOptionDescriptors({ caps: to, selections: picked }),
+ picked,
+ );
+ return createModelSelection(baseline.instanceId, model, options);
+}
+
+export interface NadirCacheState {
+ /** The model holding the thread's warm prompt cache. */
+ readonly model: string;
+ /** Tokens in the thread's current context window. */
+ readonly contextTokens?: number | undefined;
+}
+
+export function nadirRequestBody(input: {
+ readonly text: string;
+ readonly baseline: string;
+ readonly menu: ReadonlyArray;
+ readonly driver: string | undefined;
+ readonly threadId: string;
+ readonly cache?: NadirCacheState | undefined;
+}) {
+ const client =
+ input.driver === "codex" ? "codex" : input.driver === "claudeAgent" ? "claude-code" : null;
+ return {
+ prompt: input.text,
+ source: "t3code",
+ role: "main",
+ requested_model: input.baseline,
+ menu: input.menu,
+ axes: ["coding", "agentic"],
+ agent_policy: { main: "auto" },
+ policy: { allow_route_up: false, min_savings_pct: 0.05 },
+ ...(client === null ? {} : { agent_session: { client, session_id: input.threadId } }),
+ // Omitting cached_tokens makes Nadir assume the whole context is cached: the costly reading.
+ ...(input.cache === undefined
+ ? {}
+ : {
+ cache: {
+ model: input.cache.model,
+ expected_remaining_turns: 1,
+ ...(input.cache.contextTokens === undefined
+ ? {}
+ : { current_input_tokens: input.cache.contextTokens }),
+ },
+ }),
+ };
+}
+
+const record = (value: unknown): Record | undefined =>
+ typeof value === "object" && value !== null ? (value as Record) : undefined;
+
+/** The model to run, from Nadir's answer. Anything incomplete or unqualified keeps the baseline. */
+export function pickModel(
+ response: unknown,
+ context: {
+ readonly baseline: string;
+ readonly current: string;
+ readonly menu: ReadonlyArray;
+ },
+): string {
+ const answer = record(response);
+ if (answer === undefined) return context.baseline;
+ // An organization policy on the account names the model to use.
+ // An organization's pinned model outranks the user's pick; the settings text says so.
+ const governed = answer.governance_model;
+ if (typeof governed === "string") {
+ return context.menu.includes(governed) ? governed : context.baseline;
+ }
+ const selected = answer.selected_model;
+ const policy = record(answer.policy);
+ if (
+ typeof selected !== "string" ||
+ !context.menu.includes(selected) ||
+ answer.coverage_held === true ||
+ answer.input_truncated === true ||
+ policy?.advised_model !== selected
+ ) {
+ return context.baseline;
+ }
+ if (selected === context.baseline || selected === context.current) return selected;
+ if (policy.suppressed !== false) return context.baseline;
+ // A warm cache can make the cheaper model the costlier turn; never stay on a cheaper last-turn
+ // model for a task Nadir rates harder, so fall back to the user's pick.
+ if (record(answer.cache_advice)?.decision === "stay_warm") return context.baseline;
+ return selected;
+}
+
+/** Whether a prompt is worth asking Nadir about. Short follow-ups and slash commands are not. */
+export function isJudgeable(text: string): boolean {
+ const trimmed = text.trim();
+ return (
+ trimmed.length <= MAX_PROMPT_CHARS &&
+ !trimmed.startsWith("/") &&
+ trimmed.split(/\s+/).length >= MIN_PROMPT_WORDS
+ );
+}
+
+/** True when Nadir routing is on in Settings. */
+export const isEnabled = ServerSettings.ServerSettingsService.pipe(
+ Effect.flatMap((service) => service.getSettings),
+ Effect.map((settings) => settings.nadirRouting),
+ Effect.orElseSucceed(() => false),
+);
+
+const askNadir = Effect.fn("NadirRouting.askNadir")(function* (
+ client: HttpClient.HttpClient,
+ body: unknown,
+) {
+ // Optional: without a key Nadir answers on its free anonymous tier.
+ const apiKey = (yield* Config.String("NADIR_API_KEY").pipe(Config.withDefault(""))).trim();
+ const request = HttpClientRequest.post(NADIR_BUCKET_URL).pipe(
+ apiKey ? HttpClientRequest.setHeader("X-API-Key", apiKey) : (request) => request,
+ HttpClientRequest.bodyJsonUnsafe(body),
+ );
+ return yield* client.execute(request).pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap((response) => response.json),
+ Effect.timeout("2500 millis"),
+ );
+});
+
+/**
+ * The selection a user's turn should run on. Returns `selection` unchanged when routing is off or
+ * the prompt is too short or long to judge. When Nadir is slow, down or unsure, the turn runs on
+ * the user's pick.
+ */
+export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection")(
+ function* (input: {
+ readonly threadId: string;
+ /** A retried command reuses its first decision instead of asking Nadir again. */
+ readonly commandId?: string | undefined;
+ readonly text: string;
+ readonly selection: ModelSelection;
+ readonly cache?: NadirCacheState | undefined;
+ }) {
+ // A short follow-up or a slash command continues on the thread's model; the ceiling stays.
+ if (!isJudgeable(input.text)) return input.selection;
+ // Optional so callers outside the client intake (MCP tools, tests) need not provide them.
+ const client = yield* Effect.serviceOption(HttpClient.HttpClient);
+ const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry);
+ const ceilings = yield* Effect.serviceOption(NadirCeilings);
+ if (
+ Option.isNone(client) ||
+ Option.isNone(registry) ||
+ Option.isNone(ceilings) ||
+ !(yield* isEnabled)
+ ) {
+ return input.selection;
+ }
+ const store = ceilings.value;
+ return yield* store.lock(input.threadId).withPermits(1)(
+ Effect.gen(function* () {
+ const earlier = input.commandId === undefined ? undefined : store.decided(input.commandId);
+ if (earlier !== undefined) return earlier;
+ const baseline = yield* baselineFor(store, input.threadId, input.selection);
+ const text = input.text.trim();
+ const providers = yield* registry.value.getProviders;
+ const provider = providers.find(
+ (candidate) => candidate.instanceId === baseline.instanceId,
+ );
+ const menu = modelMenu(provider);
+ if (!menu.includes(baseline.model)) return input.selection;
+ const current = input.cache?.model ?? input.selection.model;
+ const answer = yield* askNadir(
+ client.value,
+ nadirRequestBody({
+ text,
+ baseline: baseline.model,
+ menu,
+ driver: provider?.driver,
+ threadId: input.threadId,
+ cache: input.cache,
+ }),
+ ).pipe(
+ Effect.tapError((cause) =>
+ Effect.logDebug("Nadir routing skipped", { reason: failureKind(cause) }),
+ ),
+ Effect.orElseSucceed(() => null),
+ );
+ const model = pickModel(answer, { baseline: baseline.model, current, menu });
+ const chosen =
+ model === baseline.model ? baseline : routedSelection(baseline, model, provider);
+ yield* store.decide({
+ commandId: input.commandId,
+ threadId: input.threadId,
+ baseline,
+ chosen,
+ });
+ if (model !== baseline.model) {
+ yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model });
+ }
+ return chosen;
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
index bec9c60c7a014..cc12888cf26da 100644
--- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
@@ -3,37 +3,51 @@ import * as DocumentText from "../provider/DocumentText.ts";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { expect, it } from "@effect/vitest";
+import { describe, expect, it } from "@effect/vitest";
import {
ChatAttachmentId,
CommandId,
EventId,
GuestMessageId,
MessageId,
+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ RunId,
RuntimeRequestId,
+ ScheduledTaskId,
ThreadId,
TurnItemId,
type OrchestrationV2Command,
type OrchestrationV2ServerCommand,
type OrchestrationV2StoredEvent,
+ type ServerProvider,
type UserInputAttachments,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
+import { HttpClient, HttpClientResponse } from "effect/http";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import {
createAttachmentId,
createPendingAttachmentId,
resolveAttachmentPath,
} from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
+import type * as RuntimeLayer from "./runtimeLayer.ts";
+import { layerCeilingsMemory, NadirCeilings, rememberChoice } from "./NadirRouting.ts";
import {
OrchestratorCommandPreviouslyRejectedError,
OrchestratorDispatchError,
} from "./Orchestrator.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";
-import { dispatchCommand } from "./ThreadMessageIntake.ts";
+import * as ThreadLaunch from "./ThreadLaunchService.ts";
+import { dispatchCommand, launchThread } from "./ThreadMessageIntake.ts";
const layerIntakeTest = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-question-intake-",
@@ -426,3 +440,339 @@ it.effect("sends a guest message with the guest's files, never ones the client n
expect(command.attachments).toEqual([guestFile]);
}).pipe(Effect.provide(layerIntakeTest)),
);
+
+describe("Nadir routing at intake", () => {
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const opus = { instanceId, model: "claude-opus-5-5" };
+ const guestMessageId = GuestMessageId.make("guest-message:routed");
+ const prompt = "Rename the helper and update every caller";
+ const send = (
+ overrides: Partial> = {},
+ ): OrchestrationV2Command => ({
+ type: "message.dispatch",
+ commandId: CommandId.make("send-routed"),
+ threadId: ThreadId.make("thread-routed"),
+ messageId: MessageId.make("message-routed"),
+ text: prompt,
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ ...overrides,
+ });
+ // Only the fields the launch path reads; the launch service is a stub.
+ const launch = (overrides: Record = {}) =>
+ ({
+ commandId: CommandId.make("launch-routed"),
+ threadId: ThreadId.make("thread-launched"),
+ modelSelection: opus,
+ initialMessage: { text: prompt, attachments: [] },
+ ...overrides,
+ }) as unknown as ThreadLaunch.ThreadLaunchInput;
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: true,
+ installed: true,
+ version: "0.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-11-06T00:00:00.000Z",
+ models: ["claude-opus-5-5", "claude-haiku-5-5"].map((slug) => ({
+ slug,
+ name: slug,
+ isCustom: false,
+ capabilities: null,
+ })),
+ slashCommands: [],
+ skills: [],
+ };
+
+ // Counts the thread reads routing does, so a test can show the cheap checks skip them.
+ let shellReads = 0;
+
+ const layerMockReceipts = Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({
+ getByCommandId: (commandId) =>
+ commandId === "send-lookup-fails"
+ ? Effect.fail({} as never)
+ : Effect.succeed(commandId === "send-replayed" ? Option.some({} as never) : Option.none()),
+ });
+
+ const run = (
+ effect: Effect.Effect,
+ activeRunId: RunId | null,
+ // Layer.empty leaves the receipt store to the caller's context.
+ layerReceipts: typeof layerMockReceipts | typeof Layer.empty = layerMockReceipts,
+ layerCeilings: typeof layerCeilingsMemory | typeof Layer.empty = layerCeilingsMemory,
+ ) =>
+ Effect.gen(function* () {
+ let requests = 0;
+ const dispatched: Array = [];
+ const record = (model: string | undefined) => {
+ dispatched.push(model);
+ return Effect.fail(
+ new OrchestratorDispatchError({
+ commandId: CommandId.make("stop"),
+ commandType: "message.dispatch",
+ cause: "Stop after intake",
+ }),
+ );
+ };
+ yield* effect.pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ dispatch: (command) =>
+ command.commandId === "send-refused"
+ ? Effect.fail(
+ new OrchestratorCommandPreviouslyRejectedError({
+ commandId: command.commandId,
+ commandType: command.type,
+ detail: "refused",
+ }),
+ )
+ : record(
+ command.type === "message.dispatch"
+ ? command.modelSelection?.model
+ : undefined,
+ ),
+ // Only these shell and snapshot fields are read.
+ getThreadShell: () =>
+ Effect.sync(() => {
+ shellReads += 1;
+ }).pipe(
+ Effect.as({
+ activeRunId,
+ modelSelection: opus,
+ activeProviderThreadId: null,
+ } as unknown as Effect.Success<
+ ReturnType<
+ ThreadManagementService.ThreadManagementServiceShape["getThreadShell"]
+ >
+ >),
+ ),
+ getThreadSnapshotWindow: () =>
+ Effect.succeed({
+ projection: {
+ providerThreads: [],
+ thread: {
+ guestShare: {
+ messages: [{ id: guestMessageId, status: "pending", attachments: [] }],
+ },
+ },
+ },
+ } as unknown as Effect.Success<
+ ReturnType<
+ ThreadManagementService.ThreadManagementServiceShape["getThreadSnapshotWindow"]
+ >
+ >),
+ }),
+ Layer.mock(ThreadLaunch.ThreadLaunchService)({
+ launch: (input) => record(input.modelSelection.model) as never,
+ }),
+ Layer.mock(ProviderRegistry.ProviderRegistry)({
+ getProviders: Effect.succeed([provider]),
+ }),
+ layerReceipts,
+ Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ requests += 1;
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ Response.json({
+ selected_model: "claude-haiku-5-5",
+ policy: { advised_model: "claude-haiku-5-5", suppressed: false },
+ coverage_held: false,
+ input_truncated: false,
+ }),
+ ),
+ );
+ }),
+ ),
+ layerCeilings,
+ ServerSettings.layerTest({ nadirRouting: true }),
+ ),
+ ),
+ Effect.result,
+ );
+ return { requests, dispatched };
+ });
+
+ it.effect("routes a user's new turn and leaves every other send alone", () =>
+ Effect.gen(function* () {
+ expect(yield* run(dispatchCommand(send()), null)).toEqual({
+ requests: 1,
+ dispatched: ["claude-haiku-5-5"],
+ });
+ // Each case differs from the routed send by one field, and still reaches dispatch, so the
+ // gate under test is what skips Nadir.
+ const unrouted: ReadonlyArray = [
+ ["a queued send", send({ dispatchMode: { type: "queue_after_active" } }), null],
+ ["a steer", send({ deliveryIntent: "steer" }), null],
+ ["a restart", send({ deliveryIntent: "restart" }), null],
+ ["a guest message", send({ guestMessageId }), null],
+ ["a scheduled task", send({ scheduledTaskId: ScheduledTaskId.make("task-1") }), null],
+ ["a delegated send", send({ senderThreadId: ThreadId.make("thread-parent") }), null],
+ [
+ "a delegated completion",
+ send({
+ delegatedCompletion: {
+ parentRunId: RunId.make("run-parent"),
+ generation: 1 as never,
+ taskIds: [],
+ },
+ }),
+ null,
+ ],
+ ["an idle compaction", send({ idleCompaction: "onReturn" }), null],
+ [
+ "a restart continuation",
+ send({ restartContinuationOfRunId: RunId.make("run-earlier") }),
+ null,
+ ],
+ [
+ "a usage-limit continuation",
+ send({ usageLimitContinuationOfRunId: RunId.make("run-limited") }),
+ null,
+ ],
+ [
+ "a manual continuation",
+ send({ manualContinuationOfRunId: RunId.make("run-manual") }),
+ null,
+ ],
+ ["a retried command", send({ commandId: CommandId.make("send-replayed") }), null],
+ [
+ "a receipt lookup that fails",
+ send({ commandId: CommandId.make("send-lookup-fails") }),
+ null,
+ ],
+ ["a send during a run", send(), RunId.make("run-active")],
+ ];
+ for (const [label, command, activeRunId] of unrouted) {
+ const result = yield* run(dispatchCommand(command), activeRunId);
+ expect(result, label).toEqual({ requests: 0, dispatched: [undefined] });
+ }
+ }).pipe(Effect.provide(layerIntakeTest)),
+ );
+
+ it.effect("does not route a command the server's receipt store already accepted", () =>
+ Effect.gen(function* () {
+ // The server runs layerProduction; intake can only see receipts if it exposes the store.
+ const productionExposesReceipts: CommandReceiptStore.CommandReceiptStoreV2 extends Layer.Success<
+ typeof RuntimeLayer.layerProduction
+ >
+ ? true
+ : false = true;
+ expect(productionExposesReceipts).toBe(true);
+ const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
+ yield* receipts.insertIfAbsent({
+ commandId: CommandId.make("send-routed"),
+ threadId: ThreadId.make("thread-routed"),
+ commandType: "message.dispatch",
+ acceptedAt: NOW,
+ resultSequence: 1,
+ status: "accepted",
+ error: null,
+ });
+ // The thread last ran routed down from Opus to Sonnet. Routing this send would record
+ // Haiku instead, so an unchanged record shows the replay never reached Nadir.
+ const ceilings = yield* NadirCeilings;
+ const ceiling = { baseline: opus, chosen: { instanceId, model: "claude-sonnet-5-5" } };
+ yield* rememberChoice(ceilings, "thread-routed", ceiling.baseline, ceiling.chosen);
+ expect(yield* run(dispatchCommand(send()), null, Layer.empty, Layer.empty)).toEqual({
+ requests: 0,
+ dispatched: [undefined],
+ });
+ expect(yield* ceilings.get("thread-routed")).toEqual(Option.some(ceiling));
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
+ layerCeilingsMemory,
+ layerIntakeTest,
+ ),
+ ),
+ ),
+ );
+
+ it.effect("puts the thread's ceiling back when the server refuses a routed send", () =>
+ Effect.gen(function* () {
+ const ceilings = yield* NadirCeilings;
+ const sonnet = { instanceId, model: "claude-sonnet-5-5" };
+ const models = (
+ found: Option.Option<{ baseline: { model: string }; chosen: { model: string } }>,
+ ) => Option.map(found, ({ baseline, chosen }) => [baseline.model, chosen.model]);
+ yield* rememberChoice(ceilings, "thread-routed", opus, sonnet);
+ const refused = send({ commandId: CommandId.make("send-refused") });
+ expect(yield* run(dispatchCommand(refused), null, layerMockReceipts, Layer.empty)).toEqual({
+ requests: 1,
+ dispatched: [],
+ });
+ expect(models(yield* ceilings.get("thread-routed"))).toEqual(
+ Option.some(["claude-opus-5-5", "claude-sonnet-5-5"]),
+ );
+ // A send that may have been accepted keeps its decision.
+ yield* run(dispatchCommand(send()), null, layerMockReceipts, Layer.empty);
+ expect(models(yield* ceilings.get("thread-routed"))).toEqual(
+ Option.some(["claude-opus-5-5", "claude-haiku-5-5"]),
+ );
+ }).pipe(Effect.provide(Layer.mergeAll(layerCeilingsMemory, layerIntakeTest))),
+ );
+
+ it.effect("asks Nadir once for a command retried while its first attempt routes", () =>
+ Effect.gen(function* () {
+ const twice = Effect.all([dispatchCommand(send()), dispatchCommand(send())], {
+ concurrency: "unbounded",
+ mode: "result",
+ });
+ expect(yield* run(twice, null)).toEqual({
+ requests: 1,
+ dispatched: ["claude-haiku-5-5", "claude-haiku-5-5"],
+ });
+ }).pipe(Effect.provide(layerIntakeTest)),
+ );
+
+ it.effect("routes a new thread's first message but not imported or delegated launches", () =>
+ Effect.gen(function* () {
+ expect(yield* run(launchThread(launch()), null)).toEqual({
+ requests: 1,
+ dispatched: ["claude-haiku-5-5"],
+ });
+ for (const [label, input] of [
+ ["an imported thread", launch({ importedNativeThread: {} })],
+ ["a delegated thread", launch({ delegatedFrom: {} })],
+ // The launch service replays a retried launch's first receipt.
+ ["a retried launch", launch({ commandId: CommandId.make("send-replayed") })],
+ ["a receipt lookup that fails", launch({ commandId: CommandId.make("send-lookup-fails") })],
+ ] as const) {
+ const result = yield* run(launchThread(input), null);
+ expect(result, label).toEqual({ requests: 0, dispatched: ["claude-opus-5-5"] });
+ }
+ // A launch over the attachment limit is rejected before its text reaches Nadir.
+ const overLimit = launch({
+ initialMessage: {
+ text: prompt,
+ attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, () => ({
+ type: "file",
+ })),
+ },
+ });
+ expect(yield* run(launchThread(overLimit), null)).toEqual({ requests: 0, dispatched: [] });
+ }).pipe(Effect.provide(layerIntakeTest)),
+ );
+
+ it.effect("skips the thread reads for a follow-up too short to judge", () =>
+ Effect.gen(function* () {
+ shellReads = 0;
+ expect(yield* run(dispatchCommand(send({ text: "thanks, ship it" })), null)).toEqual({
+ requests: 0,
+ dispatched: [undefined],
+ });
+ expect(shellReads).toBe(0);
+ yield* run(dispatchCommand(send()), null);
+ expect(shellReads).toBe(1);
+ }).pipe(Effect.provide(layerIntakeTest)),
+ );
+});
diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
index a48cc3ca7cd90..51fba739d2199 100644
--- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
+++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
@@ -2,12 +2,15 @@ import { remapComposerContextAttachments } from "@t3tools/shared/composerContext
import { appendUserInputAttachmentPaths } from "../provider/userInputAttachments.ts";
import type { ChatAttachment, OrchestrationV2Command } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { resolveAttachmentPath } from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as AttachmentClaims from "./AttachmentClaims.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
+import * as NadirRouting from "./NadirRouting.ts";
import * as ThreadLaunch from "./ThreadLaunchService.ts";
import * as ThreadManagement from "./ThreadManagementService.ts";
@@ -83,11 +86,82 @@ const withGuestAttachments = Effect.fn("ThreadMessageIntake.withGuestAttachments
return { ...command, attachments: message?.attachments ?? [] };
});
+/** A retried command replays its first receipt; routing again would record a model it never ran. */
+const hasReceipt = Effect.fn("ThreadMessageIntake.hasReceipt")(function* (
+ commandId: ThreadLaunch.ThreadLaunchInput["commandId"],
+) {
+ const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2);
+ if (Option.isNone(receipts)) return false;
+ // A lookup that fails cannot rule a replay out, so the turn keeps its model.
+ return yield* receipts.value.getByCommandId(commandId).pipe(
+ Effect.map(Option.isSome),
+ Effect.orElseSucceed(() => true),
+ );
+});
+
+/**
+ * A user's own new turn gets Nadir's model pick. Steering into a running turn, continuations,
+ * compactions, and scheduled, delegated or guest sends keep the model they came with.
+ */
+const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* (
+ command: Extract,
+) {
+ const plainUserTurn =
+ command.guestMessageId === undefined &&
+ command.scheduledTaskId === undefined &&
+ command.senderThreadId === undefined &&
+ command.delegatedCompletion === undefined &&
+ command.idleCompaction === undefined &&
+ command.restartContinuationOfRunId === undefined &&
+ command.usageLimitContinuationOfRunId === undefined &&
+ command.manualContinuationOfRunId === undefined &&
+ command.deliveryIntent !== "steer" &&
+ command.deliveryIntent !== "restart" &&
+ command.dispatchMode.type === "start_immediately";
+ // The cheap checks go first, so most short follow-ups skip the reads below.
+ if (
+ !plainUserTurn ||
+ !NadirRouting.isJudgeable(command.text) ||
+ !(yield* NadirRouting.isEnabled) ||
+ (yield* hasReceipt(command.commandId))
+ ) {
+ return command;
+ }
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ const shell = yield* threads
+ .getThreadShell(command.threadId)
+ .pipe(Effect.orElseSucceed(() => null));
+ // A message sent during a run steers it, and a run cannot change model mid-turn.
+ if (shell === null || shell.activeRunId !== null) return command;
+ const usage = yield* threads.getThreadSnapshotWindow(command.threadId, { rowLimit: 1 }).pipe(
+ Effect.map(
+ ({ projection }) =>
+ projection.providerThreads.find((thread) => thread.id === shell.activeProviderThreadId)
+ ?.contextUsage ?? null,
+ ),
+ Effect.orElseSucceed(() => null),
+ );
+ const selection = command.modelSelection ?? shell.modelSelection;
+ const chosen = yield* NadirRouting.chooseModelSelection({
+ threadId: command.threadId,
+ commandId: command.commandId,
+ text: command.text,
+ selection,
+ cache:
+ usage === null
+ ? undefined
+ : { model: shell.modelSelection.model, contextTokens: usage.usedTokens },
+ });
+ return chosen === selection ? command : { ...command, modelSelection: chosen };
+});
+
export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(function* (
incoming: OrchestrationV2Command,
) {
const command =
- incoming.type === "message.dispatch" ? yield* withGuestAttachments(incoming) : incoming;
+ incoming.type === "message.dispatch"
+ ? yield* withGuestAttachments(incoming).pipe(Effect.flatMap(withNadirModel))
+ : incoming;
const threads = yield* ThreadManagement.ThreadManagementService;
if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) {
const config = yield* ServerConfig.ServerConfig;
@@ -158,10 +232,11 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(
(command.type !== "queued-run.edit" || command.attachments === undefined)
)
return yield* threads.dispatch(command);
+ // A send the server refused leaves the thread's Nadir ceiling as it was.
const claimed = yield* AttachmentClaims.claimPendingAttachments({
threadId: command.threadId,
attachments: command.attachments ?? [],
- });
+ }).pipe(Effect.tapError(() => NadirRouting.undoChoice(command.commandId)));
return yield* threads
.dispatch({
...command,
@@ -187,7 +262,9 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(
),
Effect.tapError((error) =>
dispatchWasNotAccepted(error)
- ? AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths)
+ ? AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths).pipe(
+ Effect.andThen(NadirRouting.undoChoice(command.commandId)),
+ )
: Effect.void,
),
);
@@ -208,11 +285,39 @@ export const sendToThread = Effect.fn("ThreadMessageIntake.sendToThread")(functi
);
});
-export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(function* (
+/** A new thread has no warm cache, so its first turn is the cheapest place to pick a model. */
+const withNadirLaunchModel = Effect.fn("ThreadMessageIntake.withNadirLaunchModel")(function* (
input: ThreadLaunch.ThreadLaunchInput,
) {
+ const message = input.initialMessage;
+ const threadId = input.threadId;
+ if (
+ message === undefined ||
+ threadId === undefined ||
+ input.importedNativeThread !== undefined ||
+ input.delegatedFrom !== undefined ||
+ !NadirRouting.isJudgeable(message.text) ||
+ !(yield* NadirRouting.isEnabled) ||
+ (yield* hasReceipt(input.commandId))
+ ) {
+ return input;
+ }
+ const modelSelection = yield* NadirRouting.chooseModelSelection({
+ threadId,
+ commandId: input.commandId,
+ text: message.text,
+ selection: input.modelSelection,
+ });
+ return modelSelection === input.modelSelection ? input : { ...input, modelSelection };
+});
+
+export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(function* (
+ launchInput: ThreadLaunch.ThreadLaunchInput,
+) {
+ // A launch these limits reject never sends its text to Nadir.
+ yield* AttachmentClaims.validateAttachmentLimits(launchInput.initialMessage?.attachments ?? []);
+ const input = yield* withNadirLaunchModel(launchInput);
const launches = yield* ThreadLaunch.ThreadLaunchService;
- yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []);
if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) {
return yield* launches.launch(input);
}
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts
index d83f0603d7f7c..f2a1a84acb7cb 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.ts
@@ -384,6 +384,8 @@ export const layer = Layer.mergeAll(
);
export const layerProduction = Layer.mergeAll(
+ // Intake reads receipts so a retried command is not routed again.
+ layerCommandReceiptStoreProvided,
WorktreeBranchSync.layer.pipe(
Layer.provide(Layer.merge(layerThreadManagementProvided, ProjectionStore.layer)),
),
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index dae95787afe82..20f6aaa9fe650 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -6,6 +6,7 @@ import * as AssetSigner from "./assets/AssetSigner.ts";
import * as DatabaseStorage from "./persistence/DatabaseStorage.ts";
import * as StorageCleanup from "./storageCleanup.ts";
import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
+import * as NadirRouting from "./orchestration-v2/NadirRouting.ts";
import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts";
import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts";
// Effect wraps this raw Node HTTP server after its startup and write-error guards attach.
@@ -1293,6 +1294,7 @@ const layerMakeServer = Layer.unwrap(
Layer.provideMerge(layerHttpServer),
Layer.provide(layerApplicationObservability),
Layer.provideMerge(FetchHttpClient.layer),
+ Layer.provideMerge(NadirRouting.layerCeilings),
Layer.provide(VcsProcess.layer),
Layer.provideMerge(AgentScopeLive.layer),
Layer.provideMerge(layerPlatformServices),
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 8223e03540405..e0c9875ab5f2a 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -4,6 +4,7 @@ import {
buildExpiredTerminalContextToastCopy,
mergeDirectPreviewAnnotation,
planComposerTurn,
+ releasesModelPickAfterSend,
snapshotComposerSend,
withMessageContext,
deriveAvailableRevertTurnCounts,
@@ -1105,6 +1106,18 @@ describe("snapshotComposerSend", () => {
});
});
+describe("releasesModelPickAfterSend", () => {
+ it("hands the pick back only after a new turn Nadir may route", () => {
+ const newTurn = { nadirRouting: true, turnDispatchMode: "auto", phase: "ready" } as const;
+ expect(releasesModelPickAfterSend(newTurn)).toBe(true);
+ // A queued or steered send keeps the pick for the turn it waits for.
+ expect(releasesModelPickAfterSend({ ...newTurn, turnDispatchMode: "queue" })).toBe(false);
+ expect(releasesModelPickAfterSend({ ...newTurn, turnDispatchMode: "steer" })).toBe(false);
+ expect(releasesModelPickAfterSend({ ...newTurn, phase: "running" })).toBe(false);
+ expect(releasesModelPickAfterSend({ ...newTurn, nadirRouting: false })).toBe(false);
+ });
+});
+
describe("planComposerTurn", () => {
const base = {
messageText: "hello",
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 0848d27c05bdb..09f544b4f12bc 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -1292,6 +1292,18 @@ export function snapshotComposerSend(contents: ComposerSendContents) {
export type ComposerSendSnapshot = ReturnType;
/** How a composer send dispatches: where it runs and whether it waits behind other work. */
+/**
+ * Whether a sent message hands the composer's model pick back to the thread: only a new turn,
+ * which Nadir may route. A queued or steered send keeps the pick for the turn it waits for.
+ */
+export function releasesModelPickAfterSend(input: {
+ readonly nadirRouting: boolean;
+ readonly turnDispatchMode: ComposerDispatchMode;
+ readonly phase: SessionPhase;
+}): boolean {
+ return input.nadirRouting && input.turnDispatchMode === "auto" && input.phase !== "running";
+}
+
export function planComposerTurn(input: {
readonly messageText: string;
readonly sendQueuesBehindSetup: boolean;
diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts
index 648b2fd62bf05..4dfd8b927d609 100644
--- a/apps/web/src/components/chat/useComposerSend.ts
+++ b/apps/web/src/components/chat/useComposerSend.ts
@@ -10,6 +10,7 @@ import {
formatOutgoingPrompt,
optimisticMessageAttachments,
planComposerTurn,
+ releasesModelPickAfterSend,
sendTitleFallbackLabels,
snapshotComposerSend,
withMessageContext,
@@ -26,6 +27,7 @@ import {
type ThreadId,
ProviderInteractionMode,
RuntimeMode,
+ type UnifiedSettings,
} from "@t3tools/contracts";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
@@ -72,6 +74,7 @@ import {
restoreFailedDraftThread,
checkoutPullRequestUrlToLink,
composerDraftHasUserContent,
+ draftModelPick,
type ComposerFileAttachment,
type ComposerImageAttachment,
type DraftThreadEnvMode,
@@ -95,7 +98,7 @@ import {
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { runMobileComposerTransition } from "./draftHeroTransition";
-import { useClientSettingsHydrated } from "../../hooks/useSettings";
+import { useClientSettingsHydrated, useEnvironmentSettings } from "../../hooks/useSettings";
import { environmentPresentations } from "../../state/presentation";
import {
approvalRedirectNotice,
@@ -150,6 +153,8 @@ import type { useWorktreeSetup } from "./useWorktreeSetup";
const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3;
+const selectNadirRouting = (settings: UnifiedSettings) => settings.nadirRouting;
+
function readEnvironmentPresentation(environmentId: EnvironmentId) {
return appAtomRegistry.get(environmentPresentations.presentationsAtom).get(environmentId);
}
@@ -340,6 +345,8 @@ export function useComposerSend(input: ComposerSendInput) {
const handleNewThread = useNewThreadHandler();
const navigate = useNavigate();
const clientSettingsHydrated = useClientSettingsHydrated();
+ const nadirRouting = useEnvironmentSettings(environmentId, selectNadirRouting);
+ const releaseModelPick = useComposerDraftStore((store) => store.releaseModelPick);
const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
reportFailure: false,
});
@@ -763,6 +770,9 @@ export function useComposerSend(input: ComposerSendInput) {
return;
}
const threadIdForSend = activeThread.id;
+ const modelPickAtSend = draftModelPick(
+ useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
+ );
const sendSnapshot = snapshotComposerSend({
prompt: promptForSend,
images: composerImages,
@@ -1339,6 +1349,10 @@ export function useComposerSend(input: ComposerSendInput) {
failure = startResult;
} else {
turnStartSucceeded = true;
+ // Nadir may run the turn on another model; the picker then shows the one that ran.
+ if (releasesModelPickAfterSend({ nadirRouting, turnDispatchMode, phase })) {
+ releaseModelPick(scopeThreadRef(environmentId, threadIdForSend), modelPickAtSend);
+ }
if (checkoutPullRequestUrl) {
linkCheckoutPullRequest(
scopeThreadRef(environmentId, threadIdForSend),
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index aca6e5eb56fc5..435843c14d362 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -104,7 +104,7 @@ import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSectio
import { readCodexSetupMode } from "./CodexSetupSection.logic";
import { providerClients } from "./providerDriverMeta";
import { searchableSetting } from "./settingsSearch";
-import { CompactIdleThreadsRow } from "./SettingsPanels";
+import { CompactIdleThreadsRow, NadirCompactionRow, NadirRoutingRow } from "./SettingsPanels";
import {
backgroundActivityOverrideSettings,
buildProviderInstanceUpdatePatch,
@@ -1169,6 +1169,8 @@ export function EnvironmentProviderSettings({
}
/>
+
+
{isAddInstanceDialogOpen && !readOnly ? (
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 31234fb6be51a..d501f942b8517 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1641,6 +1641,66 @@ export function DiffsSettingsPanel() {
);
}
+const NADIR_SETTING_ROWS = {
+ nadirRouting: {
+ label: "Nadir model routing",
+ searchId: "nadir-routing",
+ description:
+ "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose unless your Nadir organization pins a model, and the model picker shows what ran. Sends the message text to getnadir.com, and a message can wait up to 2.5 seconds for its answer. Set NADIR_API_KEY on the host to use your Nadir account.",
+ },
+ nadirCompaction: {
+ label: "Nadir compaction",
+ searchId: "nadir-compaction",
+ description:
+ "New Claude threads shrink bulky file reads and command output with Nadir's compaction, keeping an exact copy the agent can read back. Runs on this machine with no network calls. Needs Nadir installed (npx nadir-labs); when Claude Code already runs it, T3 leaves it to Claude Code.",
+ },
+} as const;
+
+export function NadirRoutingRow() {
+ return useOptionalSettingsScope() === null ? null : (
+
+ );
+}
+
+export function NadirCompactionRow() {
+ return useOptionalSettingsScope() === null ? null : (
+
+ );
+}
+
+function ScopedNadirSettingRow({ settingKey }: { settingKey: keyof typeof NADIR_SETTING_ROWS }) {
+ const { label, searchId, description } = NADIR_SETTING_ROWS[settingKey];
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
+ const mixed = useScopedSettingsMixed([settingKey]);
+
+ return (
+ updateSettings({ [settingKey]: DEFAULT_UNIFIED_SETTINGS[settingKey] })}
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ [settingKey]: Boolean(checked) })}
+ aria-label={label}
+ />
+ }
+ />
+ );
+}
+
/** The Providers panel can render without a settings scope, as its tests do; this row needs one. */
export function CompactIdleThreadsRow() {
return useOptionalSettingsScope() === null ? null : ;
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 85e4878be73dd..4f7cd9a4b7f79 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -652,6 +652,20 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "environment-defaults",
searchTerms: ["claude context compaction idle cache tokens usage summary resume"],
},
+ {
+ id: "nadir-routing",
+ title: "Nadir model routing",
+ to: "/settings/providers",
+ scope: "environment-defaults",
+ searchTerms: ["auto model picker cheaper haiku sonnet opus router cost savings getnadir"],
+ },
+ {
+ id: "nadir-compaction",
+ title: "Nadir compaction",
+ to: "/settings/providers",
+ scope: "environment-defaults",
+ searchTerms: ["context tool output read bash shrink tokens claude plugin getnadir"],
+ },
{
id: "auto-settle-merged-threads",
title: "Auto-settle merged threads",
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index c698f96e92e39..95ebca40616ef 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -91,6 +91,7 @@ import {
type ComposerFileAttachment,
type ComposerImageAttachment,
composerFileNeedsReattach,
+ draftModelPick,
partializeComposerDraftStoreState,
useComposerDraftStore,
DraftId,
@@ -1616,6 +1617,22 @@ describe("composerDraftStore modelSelection", () => {
expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
});
+ it("releases the pick a send used but keeps one made while it was in flight", () => {
+ const store = useComposerDraftStore.getState();
+ const pickAtSend = () => draftModelPick(draftFor(threadId, TEST_ENVIRONMENT_ID));
+ store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.5"), { explicit: true });
+ const sent = pickAtSend();
+ store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { explicit: true });
+ store.releaseModelPick(threadRef, sent);
+ expect(pickAtSend()?.model).toBe("gpt-5.4");
+ // Picking the sent model again is a new pick too.
+ store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.5"), { explicit: true });
+ store.releaseModelPick(threadRef, sent);
+ expect(pickAtSend()?.model).toBe("gpt-5.5");
+ store.releaseModelPick(threadRef, pickAtSend());
+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
+ });
+
it("keeps a pick matching the old model while an earlier pick is still in flight", () => {
const h = modelReconciliationHarness(TEST_ENVIRONMENT_ID, threadId);
const store = useComposerDraftStore.getState();
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index f719780357a2f..1a5eb07f04b2f 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -754,6 +754,11 @@ interface ComposerDraftStoreState {
attachments: PersistedComposerImageAttachment[],
) => Promise;
clearComposerContent: (threadRef: ComposerThreadTarget) => void;
+ /**
+ * Drops the draft's model pick so the picker follows the thread's model again, unless the
+ * pick changed from `sentPick` while the send was in flight.
+ */
+ releaseModelPick: (threadRef: ComposerThreadTarget, sentPick: ModelSelection | undefined) => void;
/** Clears text and attachments, preserving terminal/element contexts, preview annotations, and review comments. */
clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
/** Keeps attachments over destination limits, other environments' uploads, terminal/element contexts, annotations, and review comments in source. */
@@ -1518,6 +1523,16 @@ function normalizeComposerTarget(
return target;
}
+/** The model the draft's picker holds for its active provider, if the user picked one. */
+export function draftModelPick(
+ draft:
+ | Pick
+ | null
+ | undefined,
+): ModelSelection | undefined {
+ return draft?.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined;
+}
+
export function resolveComposerDraftKey(
state: ComposerThreadLookupState,
target: ComposerThreadTarget,
@@ -4240,6 +4255,22 @@ const composerDraftStore = create()(
await Promise.resolve();
verifyPersistedAttachments(threadKey, attachments, set);
},
+ releaseModelPick: (threadRef, sentPick) => {
+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
+ set((state) => {
+ const current = state.draftsByThreadKey[threadKey];
+ // A newer pick, even of the same model, is still waiting for its own reply.
+ if (!current || sentPick === undefined || draftModelPick(current) !== sentPick) {
+ return state;
+ }
+ const { modelSelectionExplicit: _explicit, ...retained } = current;
+ const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} };
+ const drafts = { ...state.draftsByThreadKey };
+ if (shouldRemoveDraft(draft)) delete drafts[threadKey];
+ else drafts[threadKey] = draft;
+ return { draftsByThreadKey: drafts };
+ });
+ },
clearComposerContent: (threadRef) => {
const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
if (threadKey.length === 0) {
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 49470541d90be..223854d1e8fbd 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1375,6 +1375,10 @@ export const ServerSettings = Schema.Struct({
continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(true)),
),
+ /** Before each user turn, Nadir picks the model from the selected instance's menu, never above the user's pick. */
+ nadirRouting: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ /** New Claude sessions run the compaction hooks of Nadir's installed Claude Code plugin. */
+ nadirCompaction: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** Holds an OS sleep inhibitor while any run is active; closing a laptop lid still sleeps it. */
keepAwakeWhileAgentsWork: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
@@ -1828,6 +1832,8 @@ export const ServerSettingsPatch = Schema.Struct({
responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
+ nadirRouting: Schema.optionalKey(Schema.Boolean),
+ nadirCompaction: Schema.optionalKey(Schema.Boolean),
keepAwakeWhileAgentsWork: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
cloudSpeechCredentials: Schema.optionalKey(