From b20583e2d8c08b2e797e9408b34b0f4b62339c42 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 14 Oct 2026 01:09:38 +0200 Subject: [PATCH 01/26] fix(chrome-devtools): #511 followup - a launch whose first window fails also reports that it quit the browser, so the recording browser does not quit it again --- src/chrome-devtools/lib/launch.test.ts | 1 + src/chrome-devtools/lib/launch.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/chrome-devtools/lib/launch.test.ts b/src/chrome-devtools/lib/launch.test.ts index a29f42f068..890e4424e2 100644 --- a/src/chrome-devtools/lib/launch.test.ts +++ b/src/chrome-devtools/lib/launch.test.ts @@ -586,6 +586,7 @@ describe("background launch (the user keeps focus, macOS)", () => { name: "CdpLaunchError", stage: "window", logTail: logged ? "fixture browser diagnostic\n" : null, + childQuit: logged, }); expect(logsRead).toBe(logged ? 1 : 0); expect(requests).toContain("Target.createTarget"); diff --git a/src/chrome-devtools/lib/launch.ts b/src/chrome-devtools/lib/launch.ts index f700a9503b..af9a026b96 100644 --- a/src/chrome-devtools/lib/launch.ts +++ b/src/chrome-devtools/lib/launch.ts @@ -378,7 +378,7 @@ export async function launchCdpBrowser(opts: LaunchCdpOpts): Promise Date: Fri, 16 Oct 2026 20:39:53 +0200 Subject: [PATCH 02/26] fix(tmux, dev-dashboard): a new tmux pane drops test markers and agent identity a stale server's global environment holds, because dashboard terminals inherited GENESIS_TOOLS_TEST_RUNTIME and an agent's CLAUDECODE from the test run that founded the server --- src/utils/tmux/sessions.test.ts | 31 +++++++++++++++++++++---- src/utils/tmux/sessions.ts | 40 +++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/utils/tmux/sessions.test.ts b/src/utils/tmux/sessions.test.ts index caf4640531..e39ce0ea53 100644 --- a/src/utils/tmux/sessions.test.ts +++ b/src/utils/tmux/sessions.test.ts @@ -22,6 +22,7 @@ import { scrollTmuxToFraction, sessionExists, setTmuxSpawnSyncForTests, + TERMINAL_FOREIGN_ENV, TMUX_CHILD_DEADLINE_MS, TMUX_SPAWN_GUARD, tmuxPaneArgv, @@ -445,6 +446,26 @@ describe("tmux sessions", () => { ).toBe(true); }); + // A tmux server founded by a test run inside an agent handed every dashboard terminal that run's + // markers and identity (2026-10-16). The pane's `env` drops them, whatever the server holds. + test("a new pane drops test markers and agent identity, plus the caller's own list", () => { + const argv = tmuxPaneArgv("", { unsetEnv: ["EXTRA_ID"] }).argv; + const unset = argv.flatMap((word, i) => (argv[i - 1] === "-u" ? [word] : [])); + + for (const key of [ + "GENESIS_TOOLS_TEST_RUNTIME", + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CMUX_SURFACE_ID", + "EXTRA_ID", + ]) { + expect(unset).toContain(key); + } + + expect(unset).toEqual([...new Set(unset)]); + expect(argv[0]).toBe("/usr/bin/env"); + }); + test("a command line runs through the login shell, never as one argv word", () => { const shell = env.paths.getShell("/bin/zsh"); const pane = tmuxPaneArgv("echo hi; sleep 1"); @@ -467,12 +488,14 @@ describe("tmux sessions", () => { expect(tmuxPaneArgv("/bin/zsh").argv).not.toContain("-lic"); }); - test("named variables are unset in the pane, and nothing is unset unless asked", () => { - const pane = tmuxPaneArgv("/bin/zsh", { unsetEnv: ["CMUX_SURFACE_ID", "CMUX_WORKSPACE_ID"] }); + // Since 2026-10-16 every pane also drops TERMINAL_FOREIGN_ENV (test markers, agent identity), asked or not. + test("named variables are unset in the pane, each once, beside the always-dropped ones", () => { + const pane = tmuxPaneArgv("/bin/zsh", { unsetEnv: ["CMUX_SURFACE_ID", "MY_ID"] }); + const unset = pane.argv.flatMap((word, i) => (pane.argv[i - 1] === "-u" ? [word] : [])); - expect(pane.argv.slice(0, 5)).toEqual(["/usr/bin/env", "-u", "CMUX_SURFACE_ID", "-u", "CMUX_WORKSPACE_ID"]); + expect(pane.argv[0]).toBe("/usr/bin/env"); + expect(unset).toEqual([...TERMINAL_FOREIGN_ENV, "MY_ID"]); expect(pane.argv.at(-1)).toBe("/bin/zsh"); - expect(tmuxPaneArgv("/bin/zsh").argv).not.toContain("-u"); }); test("a command-line session keeps its pane and is not reported when its command died", async () => { diff --git a/src/utils/tmux/sessions.ts b/src/utils/tmux/sessions.ts index 3dad9b2d7d..00204d515d 100644 --- a/src/utils/tmux/sessions.ts +++ b/src/utils/tmux/sessions.ts @@ -59,13 +59,49 @@ function isBareExecutable(command: string): boolean { * parsing after `--`, so passing the line as one argv word made `env` look for a program named * `echo hi; sleep 300` and the pane died at once. `-i` loads `.zshrc`, where aliases live. */ +/** + * Variables no new terminal may inherit from the tmux SERVER's global environment, which keeps the + * environment of whatever process started the server. On 2026-10-16 a `bun test` run inside a Claude Code + * agent had founded it a day earlier, so every dev-dashboard terminal carried `GENESIS_TOOLS_TEST_RUNTIME=1` + * (tools refused the real home, the vault's keychain rung was off, `tools claude run` found no token) and + * that agent's `CLAUDECODE`, session id, teammate wrapper and cmux pane ids. The pane's login shell sets + * the user's own variables again, so dropping these loses nothing a terminal should have. + */ +export const TERMINAL_FOREIGN_ENV = [ + "GENESIS_TOOLS_TEST_RUNTIME", + "GENESIS_TEST_TMP_ROOT", + "CLAUDECODE", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_TEAMMATE_COMMAND", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_MESSAGING_TOKEN", + "CLAUDE_CODE_SESSION_ATTENDED", + "CLAUDE_PID", + "CODEX_COMPANION_SESSION_ID", + "CODEX_COMPANION_TRANSCRIPT_PATH", + "AI_AGENT", + "TOOLS_CLAUDE_ACCOUNT", + "TOOLS_CLAUDE_AUTH", + "CMUX_SURFACE_ID", + "CMUX_WORKSPACE_ID", + "CMUX_PANEL_ID", + "CMUX_TAB_ID", + "CMUX_TERMINAL_LIFECYCLE_ID", + "GHOSTTY_SURFACE_ID", +] as const; + export function tmuxPaneArgv( command: string, opts: { unsetEnv?: readonly string[] } = {} ): { argv: string[]; commandLine: boolean } { const env = buildTerminalSpawnEnv(); - // `env -u` drops a variable the tmux server's global environment would hand the pane (a caller's identity). - const argv: string[] = ["/usr/bin/env", ...(opts.unsetEnv ?? []).flatMap((key) => ["-u", key])]; + // `env -u` drops a variable the tmux server's global environment would hand the pane: the caller's own + // list, and always the test markers and agent identity a stale server may hold (TERMINAL_FOREIGN_ENV). + const unset = [...new Set([...TERMINAL_FOREIGN_ENV, ...(opts.unsetEnv ?? [])])]; + const argv: string[] = ["/usr/bin/env", ...unset.flatMap((key) => ["-u", key])]; for (const key of TMUX_SESSION_ENV_KEYS) { const value = env[key]; From 8fb5c8e7b900ce64b4238bee9c06e073b98b454a Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 17 Oct 2026 13:07:37 +0200 Subject: [PATCH 03/26] chore(tools): the rebased unified branch keeps the earlier three-way merge's files that master never changed 08dfb2f6a was dropped in the rebase because its other files conflicted with master's reviewed versions; these four files were untouched by master, so unified's content stands. --- src/chrome-devtools/lib/headless.test.ts | 9 +++++++++ src/chrome-devtools/lib/launch.test.ts | 1 - src/chrome-devtools/lib/launch.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/chrome-devtools/lib/headless.test.ts b/src/chrome-devtools/lib/headless.test.ts index 8eb86224af..907e1913b4 100644 --- a/src/chrome-devtools/lib/headless.test.ts +++ b/src/chrome-devtools/lib/headless.test.ts @@ -59,4 +59,13 @@ describe.skipIf(!HAS_CHROME)("launchHeadlessChrome", () => { await chrome.close(); } }, 30_000); + + test("closeBrowserOnPort quits a browser this process does not own", async () => { + const chrome = await launchHeadlessChrome(); + try { + expect(await closeBrowserOnPort({ port: chrome.port })).toMatchObject({ status: "closed" }); + } finally { + await chrome.close(); + } + }, 30_000); }); diff --git a/src/chrome-devtools/lib/launch.test.ts b/src/chrome-devtools/lib/launch.test.ts index 890e4424e2..a29f42f068 100644 --- a/src/chrome-devtools/lib/launch.test.ts +++ b/src/chrome-devtools/lib/launch.test.ts @@ -586,7 +586,6 @@ describe("background launch (the user keeps focus, macOS)", () => { name: "CdpLaunchError", stage: "window", logTail: logged ? "fixture browser diagnostic\n" : null, - childQuit: logged, }); expect(logsRead).toBe(logged ? 1 : 0); expect(requests).toContain("Target.createTarget"); diff --git a/src/chrome-devtools/lib/launch.ts b/src/chrome-devtools/lib/launch.ts index af9a026b96..f700a9503b 100644 --- a/src/chrome-devtools/lib/launch.ts +++ b/src/chrome-devtools/lib/launch.ts @@ -378,7 +378,7 @@ export async function launchCdpBrowser(opts: LaunchCdpOpts): Promise Date: Mon, 19 Oct 2026 14:49:40 +0200 Subject: [PATCH 04/26] fix(ai): a Claude model that no longer accepts sampling gets no temperature, so the SDK stops warning on every call --- src/utils/ai/core/call.ts | 19 +++++++++++++-- src/utils/ai/usage/core-emit.test.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/utils/ai/core/call.ts b/src/utils/ai/core/call.ts index ee9f5a6d4a..b3e7bfdbc5 100644 --- a/src/utils/ai/core/call.ts +++ b/src/utils/ai/core/call.ts @@ -1,4 +1,5 @@ import type { SharedV4ProviderMetadata } from "@ai-sdk/provider"; +import { anthropicRequestRules } from "@genesiscz/utils/ai/anthropic/models"; import type { ProviderChoice } from "@genesiscz/utils/ask/types"; import { getLanguageModel } from "@genesiscz/utils/ask/types/provider"; import { @@ -207,10 +208,23 @@ export function effectiveSystemPrompt(prefix: string | undefined, base: string | return applySystemPromptPrefix(prefix, base); } +/** + * The temperature a request may carry: none for a Claude model that no longer accepts sampling + * (Opus 4.7 on, Fable), where the SDK would only drop it with a warning on stderr. + */ +function acceptedTemperature(target: CallTarget, temperature: number | undefined): number | undefined { + if (temperature === undefined || !target.modelId) { + return temperature; + } + + return anthropicRequestRules(target.modelId).sampling ? temperature : undefined; +} + /** The single `streamText`/`generateText` call. Everything else in this file funnels here. */ export async function coreChat(options: CoreChatOptions): Promise { const { target, tools } = options; const hasTools = Boolean(tools && Object.keys(tools).length > 0); + const temperature = acceptedTemperature(target, options.temperature); const settings = { model: target.model, @@ -220,7 +234,7 @@ export async function coreChat(options: CoreChatOptions): Promise( target: CallTarget, options: CallLLMStructuredOptions ): Promise> { - const { schema, maxTokens, temperature, onPartial } = options; + const { schema, maxTokens, onPartial } = options; + const temperature = acceptedTemperature(target, options.temperature); const callArgs = { system: effectiveSystemPrompt(target.systemPromptPrefix, options.systemPrompt), diff --git a/src/utils/ai/usage/core-emit.test.ts b/src/utils/ai/usage/core-emit.test.ts index 93dc3598d7..e3dcd577c9 100644 --- a/src/utils/ai/usage/core-emit.test.ts +++ b/src/utils/ai/usage/core-emit.test.ts @@ -170,3 +170,39 @@ describe("coreChat → recordUsage", () => { expect(queryUsage(todayWindow()).total.events).toBe(0); }); }); + +describe("coreChat sampling", () => { + function capturingTarget(modelId: string): { target: CallTarget; seen: Array } { + const seen: Array = []; + const model = new MockLanguageModelV4({ + doGenerate: async (options): Promise => { + seen.push(options.temperature); + return { + content: [{ type: "text", text: "hello" }], + finishReason: STOP, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, + }, + warnings: [], + }; + }, + }) as unknown as LanguageModel; + + return { target: target({ model, modelId, label: `work/${modelId}` }), seen }; + } + + test("drops temperature for a Claude model that no longer accepts sampling", async () => { + const { target: opus, seen } = capturingTarget("claude-opus-5-5"); + await coreChat({ target: opus, prompt: "hi", temperature: 0.9 }); + + expect(seen).toEqual([undefined]); + }); + + test("keeps temperature for a model that accepts it", async () => { + const { target: older, seen } = capturingTarget("claude-opus-4-1-20250805"); + await coreChat({ target: older, prompt: "hi", temperature: 0.9 }); + + expect(seen).toEqual([0.9]); + }); +}); From 91ea1f64c7aca792e74884b86b5aa386e58dfa7f Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 16:08:17 +0200 Subject: [PATCH 05/26] fix(tools): domains --ai picks a subscription account before a paid one and warns when it must spend money --- src/domains/domains.test.ts | 70 ++++++++++++++++++++- src/domains/lib/suggest/online.ts | 101 +++++++++++++++++++++++------- 2 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/domains/domains.test.ts b/src/domains/domains.test.ts index 35fcb06233..b101fec751 100644 --- a/src/domains/domains.test.ts +++ b/src/domains/domains.test.ts @@ -1,4 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { type AccountEntry, type AiConfigData, CONFIG_VERSION } from "@genesiscz/utils/ai/config/schema"; +import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; import { fetchLanding, type LandingNetwork, marketplaceForUrl, parkingServiceFor } from "./lib/aftermarket"; import { mergeVerdicts } from "./lib/check"; import { parseDomain, seedWords } from "./lib/domain"; @@ -8,6 +15,7 @@ import { pronounceability, scoreCandidate } from "./lib/score"; import { effectivePrice, withinBudget } from "./lib/search"; import { blend, dropVowels, shortForms } from "./lib/suggest/basic"; import { findHacks, hackSources } from "./lib/suggest/hacks"; +import { brainstormTarget } from "./lib/suggest/online"; import { findVibeHits } from "./lib/vibe"; import { applyObservations, changeReasons, type WatchChange } from "./lib/watch"; @@ -522,3 +530,63 @@ describe("watch applyObservations", () => { expect(watch[0]).toEqual({ domain: "x.com", addedAt: "b" }); }); }); + +describe("AI suggestor account", () => { + const account = (id: string, overrides: Partial = {}): AccountEntry => ({ + id, + name: id, + provider: "anthropic-sub", + enabled: true, + billing: { mode: "subscription" }, + credentials: {}, + useEnvApiKey: false, + ...overrides, + }); + const paid = account("acc_paid", { provider: "xai", billing: { mode: "metered" } }); + const work = account("acc_work"); + + function writeConfig(config: Partial): void { + const home = mkdtempSync(join(tmpdir(), "gt-domains-ai-")); + env.testing.set("GENESIS_TOOLS_HOME", home); + mkdirSync(join(home, ".genesis-tools", "ai"), { recursive: true }); + const full: AiConfigData = { version: CONFIG_VERSION, accounts: [paid, work], defaults: {}, ...config }; + writeFileSync(join(home, ".genesis-tools", "ai", "config.json"), SafeJSON.stringify(full, null, 2)); + AiConfigStore.invalidate(); + } + + beforeEach(() => AiConfigStore.invalidate()); + afterEach(() => { + env.testing.unset("GENESIS_TOOLS_HOME"); + AiConfigStore.invalidate(); + }); + + it("prefers a subscription account over a paid ask default", async () => { + writeConfig({ defaults: { app: { ask: { chat: { model: "@account/acc_paid:grok-4-fast" } } } } }); + expect((await brainstormTarget(undefined)).account.id).toBe("acc_work"); + }); + + it("keeps a subscription ask default", async () => { + const side = account("acc_side"); + writeConfig({ + accounts: [paid, work, side], + defaults: { app: { ask: { chat: { model: "@account/acc_side" } } } }, + }); + expect((await brainstormTarget(undefined)).account.id).toBe("acc_side"); + }); + + it("honours a paid domains default and a paid --model, which the user chose", async () => { + writeConfig({ defaults: { app: { domains: { chat: { model: "@account/acc_paid:grok-4-fast" } } } } }); + expect((await brainstormTarget(undefined)).account.id).toBe("acc_paid"); + expect((await brainstormTarget("@account/acc_paid:grok-4-fast")).account.id).toBe("acc_paid"); + }); + + it("falls back to a paid ask default only when no subscription account exists", async () => { + writeConfig({ + accounts: [paid], + defaults: { app: { ask: { chat: { model: "@account/acc_paid:grok-4-fast" } } } }, + }); + const target = await brainstormTarget(undefined); + expect(target.account.id).toBe("acc_paid"); + expect(target.account.billing.mode).toBe("metered"); + }); +}); diff --git a/src/domains/lib/suggest/online.ts b/src/domains/lib/suggest/online.ts index f4eb69a85d..17f93bacec 100644 --- a/src/domains/lib/suggest/online.ts +++ b/src/domains/lib/suggest/online.ts @@ -1,11 +1,12 @@ import { isValidLabel } from "@app/domains/lib/domain"; import { request } from "@app/domains/lib/http"; import { combine, type SuggestContext, type Suggestor } from "@app/domains/lib/suggest/types"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; import { callLLMStructured } from "@genesiscz/utils/ai/core/call"; -import { ModelResolutionError, resolveModel } from "@genesiscz/utils/ai/core/resolve"; -import type { ResolvedBinding } from "@genesiscz/utils/ai/core/types"; +import { ModelResolutionError, resolveModelTarget } from "@genesiscz/utils/ai/core/resolve"; +import type { ResolvedBinding, ResolvedTarget } from "@genesiscz/utils/ai/core/types"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; -import { logger } from "@genesiscz/utils/logger"; +import { logger, out } from "@genesiscz/utils/logger"; import { z } from "zod"; interface DatamuseWord { @@ -84,34 +85,92 @@ const SYSTEM_PROMPT = "real TLD finishes the word (genesiste.ch). Avoid words that sound rude in Czech, German, Spanish or " + "English. No hyphens."; +async function optionalTarget( + resolve: () => Promise, + via: string +): Promise { + try { + return await resolve(); + } catch (error) { + // Only "nothing usable here" moves on; a credential or vault error is the real answer. + if (!(error instanceof ModelResolutionError)) { + throw error; + } + + logger.debug({ via, error }, "domains: no AI model here, trying the next"); + return undefined; + } +} + /** - * Resolve the model ourselves: an explicit `--model` ref, else the `domains` app's chat default, else the - * `ask` app's (the account you already chat with), so `--ai` works without extra setup. + * Pick the account without spending money the user did not ask to spend: + * an explicit `--model` ref or the `domains` app default (the user's own choice, honoured even when paid), + * else the `ask` default when it is a subscription or free account, + * else the first enabled subscription account that can chat, + * else the `ask` default after all (a paid account; the caller warns). + * Resolves only: no credential is read and nothing is bound. */ -async function resolveBrainstormModel(model: string | undefined): Promise { +export async function brainstormTarget(model: string | undefined): Promise { if (model) { - return resolveModel(model, { task: "chat", app: "domains" }); + return resolveModelTarget(model, { task: "chat", app: "domains" }); } - let lastError: unknown; - for (const app of ["domains", "ask"]) { - try { - return await resolveModel(undefined, { task: "chat", app }); - } catch (error) { - // Only "nothing configured" moves on; a credential or vault error is the real answer. - if (!(error instanceof ModelResolutionError)) { - throw error; - } + const store = await AiConfigStore.load(); + const domainsDefault = store.data().defaults.app?.domains?.chat; + + if (domainsDefault?.model || domainsDefault?.provider) { + return resolveModelTarget(undefined, { task: "chat", app: "domains", store }); + } - logger.debug({ app, error }, "domains: no AI model for this app, trying the next"); - lastError = error; + const askDefault = await optionalTarget( + () => resolveModelTarget(undefined, { task: "chat", app: "ask", store }), + "ask default" + ); + + if (askDefault && askDefault.account.billing.mode !== "metered") { + return askDefault; + } + + for (const account of store.accounts({ billing: "subscription", enabled: true })) { + const target = await optionalTarget( + () => resolveModelTarget(`@account/${account.id}`, { task: "chat", store }), + `subscription ${account.name}` + ); + + if (target) { + return target; } } + if (askDefault) { + return askDefault; + } + throw new Error( - `AI suggestor: no model. Pass --model or set one: ${toolCommand("ai config default set", "chat", "", "--app", "domains")}`, - { cause: lastError } + `AI suggestor: no model. Pass --model or set one: ${toolCommand("ai config default set", "chat", "", "--app", "domains")}` + ); +} + +const warnedPaidAccounts = new Set(); + +async function resolveBrainstormModel(model: string | undefined): Promise { + const target = await brainstormTarget(model); + logger.debug( + { account: target.account.name, billing: target.account.billing.mode, model: target.model.id, via: target.via }, + "domains: AI suggestor account" ); + + if (target.account.billing.mode === "metered" && !warnedPaidAccounts.has(target.account.id)) { + warnedPaidAccounts.add(target.account.id); + out.log.warn( + `AI suggestor uses "${target.account.name}" (${target.account.provider}), a paid API account: every round costs money. ` + + `For a subscription account, pass --model @account/ or run ` + + toolCommand("ai config default set", "chat", "@account/", "--app", "domains") + ); + } + + const binding = await target.plugin.bind({ account: target.account }); + return { ...target, binding }; } async function brainstorm(context: SuggestContext) { @@ -132,7 +191,7 @@ async function brainstorm(context: SuggestContext) { export const aiSuggestor: Suggestor = { id: "ai", - label: "LLM brainstorm (brandables, translations, hacks); uses your default AI account", + label: "LLM brainstorm (brandables, translations, hacks); a subscription account first, warns on a paid one", online: true, defaultOn: false, async suggest(context) { From f47f0446d1e104e4997a66e8d5e6f34d643b27cb Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 17:39:57 +0200 Subject: [PATCH 06/26] feat(ai, domains): auto account pick ranks subscription accounts by usage room, moves on when one refuses, and says which it used and why --- src/domains/commands/search.ts | 12 ++- src/domains/commands/suggest.ts | 10 +- src/domains/domains.test.ts | 60 ++++++----- src/domains/lib/suggest/index.ts | 2 + src/domains/lib/suggest/online.ts | 161 +++++++++++++++------------ src/domains/lib/suggest/types.ts | 2 + src/utils/ai/core/auto-account.ts | 173 ++++++++++++++++++++++++++++++ src/utils/ai/core/call.ts | 3 + src/utils/ai/core/resolve.test.ts | 119 ++++++++++++++++++++ 9 files changed, 448 insertions(+), 94 deletions(-) create mode 100644 src/utils/ai/core/auto-account.ts diff --git a/src/domains/commands/search.ts b/src/domains/commands/search.ts index c1881fd27e..1abc69e33e 100644 --- a/src/domains/commands/search.ts +++ b/src/domains/commands/search.ts @@ -15,9 +15,16 @@ export function createSearchCommand(): Command { .argument("", 'what the name is about, e.g. "Genesis Tech s.r.o."') .option("-t, --tlds ", "TLDs to combine with (comma-separated)") .option("-s, --suggestors ", `suggestors to run (${suggestorIds().join(", ")})`) - .option("--ai", "add the LLM brainstorm suggestor (uses your default AI account)") + .option("--ai", "add the LLM brainstorm suggestor (a subscription account; warns before a paid one)") .option("--about ", "one line about the business, for the AI suggestor") - .option("-m, --model ", "model for the AI suggestor (default: the `domains` app default)") + .option( + "-m, --model ", + "model for the AI suggestor: a bare id (sonnet) or a full ref (@account/:)" + ) + .option( + "--account ", + "AI account for the AI suggestor, or auto: the subscription account with the most room (default)" + ) .option("--rounds ", "AI rounds; each later round learns from what was taken (needs --ai)", "1") .option("--offline", "skip suggestors that need the network") .option("--max-price ", "hide names whose first year costs more, or whose price is unknown") @@ -49,6 +56,7 @@ export function createSearchCommand(): Command { offline: opts.offline, description: opts.about, model: opts.model, + account: opts.account, checkLimit: Number.parseInt(opts.checkLimit, 10) || 150, rounds: Number.parseInt(opts.rounds, 10) || 1, maxPrice: parseMoney(opts.maxPrice), diff --git a/src/domains/commands/suggest.ts b/src/domains/commands/suggest.ts index c7228201e7..b79ac44a6d 100644 --- a/src/domains/commands/suggest.ts +++ b/src/domains/commands/suggest.ts @@ -15,7 +15,14 @@ export function createSuggestCommand(): Command { .option("-s, --suggestors ", `suggestors to run (${suggestorIds().join(", ")})`) .option("--ai", "add the LLM brainstorm suggestor") .option("--about ", "one line about the business, for the AI suggestor") - .option("-m, --model ", "model for the AI suggestor (default: the `domains` app default)") + .option( + "-m, --model ", + "model for the AI suggestor: a bare id (sonnet) or a full ref (@account/:)" + ) + .option( + "--account ", + "AI account for the AI suggestor, or auto: the subscription account with the most room (default)" + ) .option("--offline", "skip suggestors that need the network") .option("--list", "list the suggestors and exit") .option("-n, --limit ", "rows to show", "60") @@ -53,6 +60,7 @@ export function createSuggestCommand(): Command { offline: opts.offline, description: opts.about, model: opts.model, + account: opts.account, }); const ranked = candidates .map((candidate) => ({ ...candidate, score: scoreCandidate(candidate) })) diff --git a/src/domains/domains.test.ts b/src/domains/domains.test.ts index b101fec751..c3852d8ce6 100644 --- a/src/domains/domains.test.ts +++ b/src/domains/domains.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; import { type AccountEntry, type AiConfigData, CONFIG_VERSION } from "@genesiscz/utils/ai/config/schema"; +import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/providers/account-features"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { fetchLanding, type LandingNetwork, marketplaceForUrl, parkingServiceFor } from "./lib/aftermarket"; @@ -15,7 +16,7 @@ import { pronounceability, scoreCandidate } from "./lib/score"; import { effectivePrice, withinBudget } from "./lib/search"; import { blend, dropVowels, shortForms } from "./lib/suggest/basic"; import { findHacks, hackSources } from "./lib/suggest/hacks"; -import { brainstormTarget } from "./lib/suggest/online"; +import { brainstormPlan } from "./lib/suggest/online"; import { findVibeHits } from "./lib/vibe"; import { applyObservations, changeReasons, type WatchChange } from "./lib/watch"; @@ -544,49 +545,60 @@ describe("AI suggestor account", () => { }); const paid = account("acc_paid", { provider: "xai", billing: { mode: "metered" } }); const work = account("acc_work"); + const side = account("acc_side"); + const usage = (accountId: string, percentUsed: number): AccountUsageSnapshot => ({ + provider: "anthropic-sub", + accountId, + accountName: accountId, + fetchedAt: "2026-10-19T12:00:00Z", + limits: [{ key: "five_hour", label: "5h", kind: "session", percentUsed }], + }); + const paidAsk = { app: { ask: { chat: { model: "@account/acc_paid:grok-4-fast" } } } }; function writeConfig(config: Partial): void { const home = mkdtempSync(join(tmpdir(), "gt-domains-ai-")); env.testing.set("GENESIS_TOOLS_HOME", home); mkdirSync(join(home, ".genesis-tools", "ai"), { recursive: true }); - const full: AiConfigData = { version: CONFIG_VERSION, accounts: [paid, work], defaults: {}, ...config }; + const full: AiConfigData = { version: CONFIG_VERSION, accounts: [paid, work, side], defaults: {}, ...config }; writeFileSync(join(home, ".genesis-tools", "ai", "config.json"), SafeJSON.stringify(full, null, 2)); AiConfigStore.invalidate(); } + async function picked(options: Parameters[0]): Promise { + const plan = await brainstormPlan(options); + return plan.kind === "fixed" + ? [plan.target.account.id] + : plan.candidates.map((candidate) => candidate.target.account.id); + } + beforeEach(() => AiConfigStore.invalidate()); afterEach(() => { env.testing.unset("GENESIS_TOOLS_HOME"); AiConfigStore.invalidate(); }); - it("prefers a subscription account over a paid ask default", async () => { - writeConfig({ defaults: { app: { ask: { chat: { model: "@account/acc_paid:grok-4-fast" } } } } }); - expect((await brainstormTarget(undefined)).account.id).toBe("acc_work"); - }); - - it("keeps a subscription ask default", async () => { - const side = account("acc_side"); - writeConfig({ - accounts: [paid, work, side], - defaults: { app: { ask: { chat: { model: "@account/acc_side" } } } }, - }); - expect((await brainstormTarget(undefined)).account.id).toBe("acc_side"); + it("auto ranks the subscription accounts by usage room and leaves the paid ask default out", async () => { + writeConfig({ defaults: paidAsk }); + const snapshots = [usage("acc_work", 100), usage("acc_side", 24)]; + expect(await picked({ snapshots })).toEqual(["acc_side", "acc_work"]); + expect(await picked({ account: "auto", snapshots })).toEqual(["acc_side", "acc_work"]); }); - it("honours a paid domains default and a paid --model, which the user chose", async () => { + it("uses a named account, a full --model ref, or the domains default, even a paid one", async () => { writeConfig({ defaults: { app: { domains: { chat: { model: "@account/acc_paid:grok-4-fast" } } } } }); - expect((await brainstormTarget(undefined)).account.id).toBe("acc_paid"); - expect((await brainstormTarget("@account/acc_paid:grok-4-fast")).account.id).toBe("acc_paid"); + expect(await picked({})).toEqual(["acc_paid"]); + expect(await picked({ account: "acc_side" })).toEqual(["acc_side"]); + expect(await picked({ model: "@account/acc_work" })).toEqual(["acc_work"]); }); it("falls back to a paid ask default only when no subscription account exists", async () => { - writeConfig({ - accounts: [paid], - defaults: { app: { ask: { chat: { model: "@account/acc_paid:grok-4-fast" } } } }, - }); - const target = await brainstormTarget(undefined); - expect(target.account.id).toBe("acc_paid"); - expect(target.account.billing.mode).toBe("metered"); + writeConfig({ accounts: [paid], defaults: paidAsk }); + const plan = await brainstormPlan({ snapshots: [] }); + expect(plan.kind === "fixed" && plan.target.account.billing.mode).toBe("metered"); + }); + + it("names the fix for an unknown --account", async () => { + writeConfig({}); + expect(brainstormPlan({ account: "nobody" })).rejects.toThrow("--account auto"); }); }); diff --git a/src/domains/lib/suggest/index.ts b/src/domains/lib/suggest/index.ts index 73f62ba48b..5f27c07b00 100644 --- a/src/domains/lib/suggest/index.ts +++ b/src/domains/lib/suggest/index.ts @@ -42,6 +42,7 @@ export interface SuggestOptions { avoid?: string[]; liked?: string[]; model?: string; + account?: string; } export function buildContext(options: SuggestOptions): SuggestContext { @@ -59,6 +60,7 @@ export function buildContext(options: SuggestOptions): SuggestContext { avoid: options.avoid, liked: options.liked, model: options.model, + account: options.account, }; } diff --git a/src/domains/lib/suggest/online.ts b/src/domains/lib/suggest/online.ts index 17f93bacec..ea96b30a1f 100644 --- a/src/domains/lib/suggest/online.ts +++ b/src/domains/lib/suggest/online.ts @@ -2,6 +2,12 @@ import { isValidLabel } from "@app/domains/lib/domain"; import { request } from "@app/domains/lib/http"; import { combine, type SuggestContext, type Suggestor } from "@app/domains/lib/suggest/types"; import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { + type AutoAccountOptions, + type AutoCandidate, + autoAccountCandidates, + runWithAutoAccount, +} from "@genesiscz/utils/ai/core/auto-account"; import { callLLMStructured } from "@genesiscz/utils/ai/core/call"; import { ModelResolutionError, resolveModelTarget } from "@genesiscz/utils/ai/core/resolve"; import type { ResolvedBinding, ResolvedTarget } from "@genesiscz/utils/ai/core/types"; @@ -85,113 +91,134 @@ const SYSTEM_PROMPT = "real TLD finishes the word (genesiste.ch). Avoid words that sound rude in Czech, German, Spanish or " + "English. No hyphens."; -async function optionalTarget( - resolve: () => Promise, - via: string -): Promise { - try { - return await resolve(); - } catch (error) { - // Only "nothing usable here" moves on; a credential or vault error is the real answer. - if (!(error instanceof ModelResolutionError)) { - throw error; - } +/** What the AI step will use: one account the user named, or the ranked subscription accounts. */ +export type BrainstormPlan = { kind: "fixed"; target: ResolvedTarget } | { kind: "auto"; candidates: AutoCandidate[] }; - logger.debug({ via, error }, "domains: no AI model here, trying the next"); - return undefined; - } +/** A full ref (`@account/...`, `provider/model`) names an account; a bare id (`sonnet`) only names the model. */ +function isFullRef(model: string): boolean { + return model.startsWith("@") || model.includes("/"); } /** - * Pick the account without spending money the user did not ask to spend: - * an explicit `--model` ref or the `domains` app default (the user's own choice, honoured even when paid), - * else the `ask` default when it is a subscription or free account, - * else the first enabled subscription account that can chat, - * else the `ask` default after all (a paid account; the caller warns). + * Decide which account pays for the brainstorm: + * `--account `, a full `--model` ref, or the `domains` app default: the user's own choice, even a paid one; + * else `auto` (also `--account auto`): every enabled subscription account, ranked by usage room; + * else, only when no subscription account exists, the `ask` default (a paid account; the caller warns). * Resolves only: no credential is read and nothing is bound. */ -export async function brainstormTarget(model: string | undefined): Promise { - if (model) { - return resolveModelTarget(model, { task: "chat", app: "domains" }); +export async function brainstormPlan(options: { + account?: string; + model?: string; + snapshots?: AutoAccountOptions["snapshots"]; +}): Promise { + const store = await AiConfigStore.load(); + const { account, model } = options; + const fixed = (ref: string | undefined, app: string) => + resolveModelTarget(ref, { task: "chat", app, store }).then((target) => ({ kind: "fixed", target }) as const); + + if (account && account !== "auto") { + const entry = store.account(account); + + if (!entry) { + throw new Error( + `No AI account "${account}". List them with: ${toolCommand("ai config account list")}, or pass --account auto` + ); + } + + const bare = model && !isFullRef(model) ? `:${model}` : ""; + return fixed(`@account/${entry.id}${bare}`, "domains"); + } + + if (!account && model && isFullRef(model)) { + return fixed(model, "domains"); } - const store = await AiConfigStore.load(); const domainsDefault = store.data().defaults.app?.domains?.chat; - if (domainsDefault?.model || domainsDefault?.provider) { - return resolveModelTarget(undefined, { task: "chat", app: "domains", store }); + if (!account && !model && (domainsDefault?.model || domainsDefault?.provider)) { + return fixed(undefined, "domains"); } - const askDefault = await optionalTarget( - () => resolveModelTarget(undefined, { task: "chat", app: "ask", store }), - "ask default" - ); + const candidates = await autoAccountCandidates({ + task: "chat", + store, + ...(model ? { model } : {}), + ...(options.snapshots ? { snapshots: options.snapshots } : {}), + }); - if (askDefault && askDefault.account.billing.mode !== "metered") { - return askDefault; + if (candidates.length > 0) { + return { kind: "auto", candidates }; } - for (const account of store.accounts({ billing: "subscription", enabled: true })) { - const target = await optionalTarget( - () => resolveModelTarget(`@account/${account.id}`, { task: "chat", store }), - `subscription ${account.name}` - ); - - if (target) { - return target; + try { + return await fixed(model, "ask"); + } catch (error) { + if (!(error instanceof ModelResolutionError)) { + throw error; } - } - if (askDefault) { - return askDefault; + throw new Error( + `AI suggestor: no subscription account and no default. Pass --account or set one: ${toolCommand("ai config default set", "chat", "", "--app", "domains")}`, + { cause: error } + ); } - - throw new Error( - `AI suggestor: no model. Pass --model or set one: ${toolCommand("ai config default set", "chat", "", "--app", "domains")}` - ); } const warnedPaidAccounts = new Set(); -async function resolveBrainstormModel(model: string | undefined): Promise { - const target = await brainstormTarget(model); - logger.debug( - { account: target.account.name, billing: target.account.billing.mode, model: target.model.id, via: target.via }, - "domains: AI suggestor account" - ); - - if (target.account.billing.mode === "metered" && !warnedPaidAccounts.has(target.account.id)) { - warnedPaidAccounts.add(target.account.id); - out.log.warn( - `AI suggestor uses "${target.account.name}" (${target.account.provider}), a paid API account: every round costs money. ` + - `For a subscription account, pass --model @account/ or run ` + - toolCommand("ai config default set", "chat", "@account/", "--app", "domains") - ); +function warnIfPaid(target: ResolvedTarget): void { + if (target.account.billing.mode !== "metered" || warnedPaidAccounts.has(target.account.id)) { + return; } - const binding = await target.plugin.bind({ account: target.account }); - return { ...target, binding }; + warnedPaidAccounts.add(target.account.id); + out.log.warn( + `AI suggestor uses "${target.account.name}" (${target.account.provider}), a paid API account: every round costs money. ` + + `For a subscription account, pass --account auto or --account , or run ` + + toolCommand("ai config default set", "chat", "@account/", "--app", "domains") + ); } async function brainstorm(context: SuggestContext) { - const resolved = await resolveBrainstormModel(context.model); - try { - return await callLLMStructured({ + const plan = await brainstormPlan({ account: context.account, model: context.model }); + const call = (resolved: ResolvedBinding, maxRetries?: number) => + callLLMStructured({ model: resolved, systemPrompt: SYSTEM_PROMPT, userPrompt: aiUserPrompt(context), schema: AiNames, app: "domains", temperature: 0.9, + ...(maxRetries !== undefined ? { maxRetries } : {}), }); + + if (plan.kind === "auto") { + // No SDK retries: a full account answers 429 for hours, and the next account is the better retry. + return runWithAutoAccount({ + candidates: plan.candidates, + app: "domains", + run: (resolved) => call(resolved, 0), + }); + } + + const { target } = plan; + logger.debug( + { account: target.account.name, billing: target.account.billing.mode, model: target.model.id, via: target.via }, + "domains: AI suggestor account" + ); + warnIfPaid(target); + + const binding = await target.plugin.bind({ account: target.account }); + try { + return await call({ ...target, binding }); } finally { - await resolved.binding.dispose?.(); + await binding.dispose?.(); } } export const aiSuggestor: Suggestor = { id: "ai", - label: "LLM brainstorm (brandables, translations, hacks); a subscription account first, warns on a paid one", + label: "LLM brainstorm (brandables, translations, hacks); the subscription account with the most room, warns on a paid one", online: true, defaultOn: false, async suggest(context) { diff --git a/src/domains/lib/suggest/types.ts b/src/domains/lib/suggest/types.ts index 647b7f6b76..c2df9e1dcc 100644 --- a/src/domains/lib/suggest/types.ts +++ b/src/domains/lib/suggest/types.ts @@ -16,6 +16,8 @@ export interface SuggestContext { liked?: string[]; /** ModelRef for the AI suggestor (`haiku`, `@account/:`); default: the `domains` app default. */ model?: string; + /** AI account name or id, or `auto` (the subscription account with the most usage room, the default). */ + account?: string; } export interface Suggestor { diff --git a/src/utils/ai/core/auto-account.ts b/src/utils/ai/core/auto-account.ts new file mode 100644 index 0000000000..b1e9f5925e --- /dev/null +++ b/src/utils/ai/core/auto-account.ts @@ -0,0 +1,173 @@ +import { logger } from "@genesiscz/utils/logger"; +import { APICallError, RetryError } from "ai"; +import { AiConfigStore } from "../config/AiConfigStore"; +import type { AccountEntry } from "../config/schema"; +import type { AccountUsageSnapshot, LimitWindow } from "../providers/account-features"; +import { pollAccounts } from "../usage-poll/poll"; +import { ModelResolutionError, resolveModelTarget } from "./resolve"; +import type { ResolvedBinding, ResolvedTarget, ResolveOptions } from "./types"; + +/** + * `--account auto`: pick the subscription account with the most room left, and move to the next one when an + * account refuses. Paid accounts never enter the list: spending money is something a user names, not something + * a ranking decides. + */ + +const { log } = logger.scoped("ai-auto"); + +/** A refusal another account can get past: rate or plan limit, expired login, an org that blocks the call. */ +const SWITCH_STATUS = new Set([401, 403, 429]); + +export interface AutoCandidate { + target: ResolvedTarget; + /** Highest used percent of the windows that apply to the model; undefined without usage data. */ + usedPct?: number; + /** One line: what the ranking saw for this account. */ + why: string; +} + +function appliesTo(window: LimitWindow, modelId: string): boolean { + if (window.kind === "credit") { + return false; + } + + return !window.scopeModel || modelId.toLowerCase().includes(window.scopeModel.toLowerCase()); +} + +function describe(windows: LimitWindow[]): string { + return windows.map((window) => `${window.label} ${Math.round(window.percentUsed)}%`).join(", "); +} + +/** + * Order subscription targets by usage room. Accounts with usage data come first (least used first), then the ones + * with none, then the ones whose window is full; ties keep config order. Pure, so a test needs no network. + */ +export function rankAutoAccounts( + targets: readonly ResolvedTarget[], + snapshots: readonly AccountUsageSnapshot[] +): AutoCandidate[] { + const byAccount = new Map(snapshots.map((snapshot) => [snapshot.accountId, snapshot])); + const candidates = targets.map((target, index) => { + const windows = (byAccount.get(target.account.id)?.limits ?? []).filter( + (window) => typeof window.percentUsed === "number" && appliesTo(window, target.model.id) + ); + + if (windows.length === 0) { + return { index, candidate: { target, why: "no usage data" } satisfies AutoCandidate }; + } + + const usedPct = Math.max(...windows.map((window) => window.percentUsed)); + const why = `${describe(windows)} used${usedPct >= 100 ? " (full)" : ""}`; + return { index, candidate: { target, usedPct, why } satisfies AutoCandidate }; + }); + const rank = (candidate: AutoCandidate): number => { + if (candidate.usedPct === undefined) { + return 100; + } + + return candidate.usedPct >= 100 ? 200 + candidate.usedPct : candidate.usedPct; + }; + + return candidates + .sort((a, b) => rank(a.candidate) - rank(b.candidate) || a.index - b.index) + .map((entry) => entry.candidate); +} + +export interface AutoAccountOptions { + task?: ResolveOptions["task"]; + /** A bare model (`sonnet`, `claude-opus-5-5`) every account should serve; omitted means each provider's default. */ + model?: string; + store?: AiConfigStore; + /** Injected usage, for tests. Omitted reads the shared usage cache (`tools ai usage`), polling when stale. */ + snapshots?: readonly AccountUsageSnapshot[]; +} + +async function readUsage(accounts: readonly AccountEntry[]): Promise { + try { + // probe: never rotate or spend a single-use refresh token just to rank. + return await pollAccounts({ + providers: [...new Set(accounts.map((account) => account.provider))], + accountFilter: accounts.map((account) => account.name), + probe: true, + }); + } catch (error) { + log.warn({ error }, "auto account: usage unavailable, keeping config order"); + return []; + } +} + +/** Every enabled subscription account that can serve the task, ranked. Resolves only: nothing is bound. */ +export async function autoAccountCandidates(options: AutoAccountOptions = {}): Promise { + const store = options.store ?? (await AiConfigStore.load()); + const task = options.task ?? "chat"; + const targets: ResolvedTarget[] = []; + + for (const account of store.accounts({ billing: "subscription", enabled: true })) { + const ref = options.model ? `@account/${account.id}:${options.model}` : `@account/${account.id}`; + + try { + targets.push(await resolveModelTarget(ref, { task, store })); + } catch (error) { + if (!(error instanceof ModelResolutionError)) { + throw error; + } + + log.debug({ account: account.name, error: error.message }, "auto account: cannot serve the task"); + } + } + + const snapshots = options.snapshots ?? (await readUsage(targets.map((target) => target.account))); + return rankAutoAccounts(targets, snapshots); +} + +/** A refusal that a different account may not get: 401, 403 or 429, also when the SDK wrapped it in retries. */ +export function isAccountRefusal(error: unknown): boolean { + const cause = RetryError.isInstance(error) ? error.lastError : error; + return APICallError.isInstance(cause) && cause.statusCode !== undefined && SWITCH_STATUS.has(cause.statusCode); +} + +function refusalText(error: unknown): string { + const cause = RetryError.isInstance(error) ? error.lastError : error; + return APICallError.isInstance(cause) ? `${cause.statusCode}: ${cause.message}` : String(error); +} + +export interface RunWithAutoAccountOptions { + candidates: readonly AutoCandidate[]; + /** Who asks, for the log line (`domains`). */ + app: string; + run: (resolved: ResolvedBinding) => Promise; +} + +/** + * Run `run` on the best candidate; when an account refuses (401, 403, 429), say so and try the next. Every pick + * and every switch is told on stderr and in the day log, with the reason. The last refusal is rethrown. + */ +export async function runWithAutoAccount(options: RunWithAutoAccountOptions): Promise { + const { candidates, app } = options; + let lastError: unknown; + + for (const [index, candidate] of candidates.entries()) { + const { account, model } = candidate.target; + const rank = index === 0 ? `most room of ${candidates.length} subscription accounts` : "next in line"; + log.tee.log.info(`${app}: AI account "${account.name}" (${model.id}): ${candidate.why}; ${rank}`); + + const binding = await candidate.target.plugin.bind({ account }); + + try { + return await options.run({ ...candidate.target, binding }); + } catch (error) { + if (!isAccountRefusal(error)) { + throw error; + } + + lastError = error; + log.tee.log.warn( + `${app}: AI account "${account.name}" refused (${refusalText(error)}); trying the next one` + ); + } finally { + await binding.dispose?.(); + } + } + + throw lastError ?? new ModelResolutionError(`${app}: no subscription account can serve this call`); +} diff --git a/src/utils/ai/core/call.ts b/src/utils/ai/core/call.ts index b3e7bfdbc5..bf3b37aab0 100644 --- a/src/utils/ai/core/call.ts +++ b/src/utils/ai/core/call.ts @@ -94,6 +94,8 @@ export interface CallLLMStructuredOptions { schema: z.ZodType; maxTokens?: number; temperature?: number; + /** The SDK's own retries; 0 lets a caller with another account to try fail at once. */ + maxRetries?: number; /** * When set, the call streams via `streamObject` and invokes this with each * best-effort partial object. Falls back silently to `generateObject` when @@ -547,6 +549,7 @@ async function runStructured( providerOptions: buildProviderOptions(target.providerType), ...(maxTokens ? { maxOutputTokens: maxTokens } : {}), ...(temperature !== undefined ? { temperature } : {}), + ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), }; if (onPartial) { diff --git a/src/utils/ai/core/resolve.test.ts b/src/utils/ai/core/resolve.test.ts index 89a5311e9e..cdd069b3b6 100644 --- a/src/utils/ai/core/resolve.test.ts +++ b/src/utils/ai/core/resolve.test.ts @@ -4,8 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; +import { APICallError, RetryError } from "ai"; import { AiConfigStore } from "../config/AiConfigStore"; import { type AccountEntry, type AiConfigData, CONFIG_VERSION } from "../config/schema"; +import type { AccountUsageSnapshot } from "../providers/account-features"; +import type { ProviderBinding } from "../providers/plugin-types"; +import { type AutoCandidate, autoAccountCandidates, isAccountRefusal, runWithAutoAccount } from "./auto-account"; import { ModelResolutionError, resolveModelTarget } from "./resolve"; import { isUnlisted } from "./types"; @@ -349,3 +353,118 @@ describe("resolveModel — errors", () => { expect(resolveModelTarget("@proxy/onlyslug")).rejects.toThrow("@proxy//"); }); }); + +describe("auto account", () => { + const usage = (accountId: string, limits: AccountUsageSnapshot["limits"]): AccountUsageSnapshot => ({ + provider: "anthropic-sub", + accountId, + accountName: accountId, + fetchedAt: "2026-10-19T12:00:00Z", + limits, + }); + const refusal = (statusCode: number) => + new APICallError({ + message: `status ${statusCode}`, + url: "https://api.example.test", + requestBodyValues: {}, + statusCode, + }); + + test("ranks subscription accounts by the room left in windows that apply, full ones last, paid ones never", async () => { + writeConfig({}); + const candidates = await autoAccountCandidates({ + snapshots: [ + usage("acc_max", [{ key: "five_hour", label: "5h", kind: "session", percentUsed: 100 }]), + usage("acc_max2", [ + { key: "five_hour", label: "5h", kind: "session", percentUsed: 30 }, + // A Sonnet-only window says nothing about the Opus default, and credit is money, not room. + { + key: "seven_day_sonnet", + label: "7d Sonnet", + kind: "scoped", + scopeModel: "sonnet", + percentUsed: 99, + }, + { key: "extra", label: "Extra", kind: "credit", percentUsed: 100 }, + ]), + ], + }); + const ids = candidates.map((candidate) => candidate.target.account.id); + + expect(ids).not.toContain("acc_xai"); + expect(ids.indexOf("acc_max2")).toBeLessThan(ids.indexOf("acc_max")); + expect(ids.at(-1)).toBe("acc_max"); + expect(candidates.find((c) => c.target.account.id === "acc_max2")?.why).toBe("5h 30% used"); + expect(candidates.find((c) => c.target.account.id === "acc_max")?.why).toBe("5h 100% used (full)"); + }); + + test("isAccountRefusal: 401, 403 and 429, also wrapped in SDK retries; never a server error", () => { + expect(isAccountRefusal(refusal(429))).toBe(true); + expect(isAccountRefusal(refusal(403))).toBe(true); + expect( + isAccountRefusal(new RetryError({ message: "x", reason: "maxRetriesExceeded", errors: [refusal(429)] })) + ).toBe(true); + expect(isAccountRefusal(refusal(500))).toBe(false); + expect(isAccountRefusal(new Error("boom"))).toBe(false); + }); + + async function fakeCandidates(): Promise<{ candidates: AutoCandidate[]; disposed: string[] }> { + writeConfig({}); + const disposed: string[] = []; + const candidates = await Promise.all( + ["acc_max", "acc_max2"].map(async (id) => { + const target = await resolveModelTarget(`@account/${id}`); + const binding: ProviderBinding = { + accountId: id, + providerId: target.account.provider, + billed: false, + language: () => { + throw new Error("not called"); + }, + dispose: () => { + disposed.push(id); + }, + }; + return { target: { ...target, plugin: { ...target.plugin, bind: async () => binding } }, why: "x" }; + }) + ); + return { candidates, disposed }; + } + + test("runWithAutoAccount moves to the next account when one refuses, and disposes each binding", async () => { + const { candidates, disposed } = await fakeCandidates(); + const asked: string[] = []; + const result = await runWithAutoAccount({ + candidates, + app: "test", + run: async (resolved) => { + asked.push(resolved.account.id); + if (resolved.account.id === "acc_max") { + throw new RetryError({ message: "x", reason: "maxRetriesExceeded", errors: [refusal(429)] }); + } + + return "ok"; + }, + }); + + expect(result).toBe("ok"); + expect(asked).toEqual(["acc_max", "acc_max2"]); + expect(disposed).toEqual(["acc_max", "acc_max2"]); + }); + + test("runWithAutoAccount rethrows an error another account would not fix, without trying the next", async () => { + const { candidates } = await fakeCandidates(); + const asked: string[] = []; + const outcome = await runWithAutoAccount({ + candidates, + app: "test", + run: async (resolved) => { + asked.push(resolved.account.id); + throw refusal(500); + }, + }).catch((error: unknown) => error); + + expect(APICallError.isInstance(outcome) && outcome.statusCode).toBe(500); + expect(asked).toEqual(["acc_max"]); + }); +}); From 1c9294be18292cb497742ed5305b658ff1ad584f Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 19 Oct 2026 21:53:27 +0200 Subject: [PATCH 07/26] feat(ai, domains): auto account pick prefers Claude, then Codex, then Grok while an account keeps over 10% weekly room --- src/domains/commands/search.ts | 2 +- src/domains/commands/suggest.ts | 2 +- src/domains/lib/suggest/online.ts | 4 +- src/utils/ai/core/auto-account.ts | 72 +++++++++++++++++++++++-------- src/utils/ai/core/resolve.test.ts | 37 ++++++++++++++++ 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/src/domains/commands/search.ts b/src/domains/commands/search.ts index 1abc69e33e..eb26c0347b 100644 --- a/src/domains/commands/search.ts +++ b/src/domains/commands/search.ts @@ -23,7 +23,7 @@ export function createSearchCommand(): Command { ) .option( "--account ", - "AI account for the AI suggestor, or auto: the subscription account with the most room (default)" + "AI account for the AI suggestor, or auto: Claude, then Codex, then Grok, with over 10% weekly room (default)" ) .option("--rounds ", "AI rounds; each later round learns from what was taken (needs --ai)", "1") .option("--offline", "skip suggestors that need the network") diff --git a/src/domains/commands/suggest.ts b/src/domains/commands/suggest.ts index b79ac44a6d..e0b77a385d 100644 --- a/src/domains/commands/suggest.ts +++ b/src/domains/commands/suggest.ts @@ -21,7 +21,7 @@ export function createSuggestCommand(): Command { ) .option( "--account ", - "AI account for the AI suggestor, or auto: the subscription account with the most room (default)" + "AI account for the AI suggestor, or auto: Claude, then Codex, then Grok, with over 10% weekly room (default)" ) .option("--offline", "skip suggestors that need the network") .option("--list", "list the suggestors and exit") diff --git a/src/domains/lib/suggest/online.ts b/src/domains/lib/suggest/online.ts index ea96b30a1f..ea22c4fcfe 100644 --- a/src/domains/lib/suggest/online.ts +++ b/src/domains/lib/suggest/online.ts @@ -102,7 +102,7 @@ function isFullRef(model: string): boolean { /** * Decide which account pays for the brainstorm: * `--account `, a full `--model` ref, or the `domains` app default: the user's own choice, even a paid one; - * else `auto` (also `--account auto`): every enabled subscription account, ranked by usage room; + * else `auto` (also `--account auto`): every enabled subscription account, ranked by provider and weekly room; * else, only when no subscription account exists, the `ask` default (a paid account; the caller warns). * Resolves only: no credential is read and nothing is bound. */ @@ -218,7 +218,7 @@ async function brainstorm(context: SuggestContext) { export const aiSuggestor: Suggestor = { id: "ai", - label: "LLM brainstorm (brandables, translations, hacks); the subscription account with the most room, warns on a paid one", + label: "LLM brainstorm (brandables, translations, hacks); a subscription account (Claude, Codex, Grok), warns on a paid one", online: true, defaultOn: false, async suggest(context) { diff --git a/src/utils/ai/core/auto-account.ts b/src/utils/ai/core/auto-account.ts index b1e9f5925e..24ff91c7b7 100644 --- a/src/utils/ai/core/auto-account.ts +++ b/src/utils/ai/core/auto-account.ts @@ -8,7 +8,8 @@ import { ModelResolutionError, resolveModelTarget } from "./resolve"; import type { ResolvedBinding, ResolvedTarget, ResolveOptions } from "./types"; /** - * `--account auto`: pick the subscription account with the most room left, and move to the next one when an + * `--account auto`: pick a subscription account by provider order (Claude, Codex, Grok) among those with weekly room + * left, and move to the next one when an * account refuses. Paid accounts never enter the list: spending money is something a user names, not something * a ranking decides. */ @@ -26,6 +27,20 @@ export interface AutoCandidate { why: string; } +/** Which subscriptions to spend first, and how much weekly room an account must keep to count as usable. */ +export interface AutoPolicy { + /** Plugin ids, best first; a provider not listed comes after the listed ones. */ + providerOrder: readonly string[]; + /** An account with this much weekly room or less waits until no other account is usable. */ + minWeeklyLeftPct: number; +} + +/** Martin, 2026-10-19: "claude better, then codex, then grok, whenever > 10% weekly left". */ +export const DEFAULT_AUTO_POLICY: AutoPolicy = { + providerOrder: ["anthropic-sub", "openai-sub", "grok-sub"], + minWeeklyLeftPct: 10, +}; + function appliesTo(window: LimitWindow, modelId: string): boolean { if (window.kind === "credit") { return false; @@ -38,38 +53,56 @@ function describe(windows: LimitWindow[]): string { return windows.map((window) => `${window.label} ${Math.round(window.percentUsed)}%`).join(", "); } +/** Usable first, then unknown, then low on weekly room, then full. */ +type Tier = 0 | 1 | 2 | 3; + /** - * Order subscription targets by usage room. Accounts with usage data come first (least used first), then the ones - * with none, then the ones whose window is full; ties keep config order. Pure, so a test needs no network. + * Order subscription targets. A usable account (usage data, no full window, more than `minWeeklyLeftPct` weekly + * room) comes first, by provider order and then by the least used window. Then accounts without usage data, then + * accounts low on weekly room, then full ones. Ties keep config order. Pure, so a test needs no network. */ export function rankAutoAccounts( targets: readonly ResolvedTarget[], - snapshots: readonly AccountUsageSnapshot[] + snapshots: readonly AccountUsageSnapshot[], + policy: AutoPolicy = DEFAULT_AUTO_POLICY ): AutoCandidate[] { const byAccount = new Map(snapshots.map((snapshot) => [snapshot.accountId, snapshot])); - const candidates = targets.map((target, index) => { + const providerRank = (provider: string): number => { + const index = policy.providerOrder.indexOf(provider); + return index === -1 ? policy.providerOrder.length : index; + }; + const ranked = targets.map((target, index) => { const windows = (byAccount.get(target.account.id)?.limits ?? []).filter( (window) => typeof window.percentUsed === "number" && appliesTo(window, target.model.id) ); if (windows.length === 0) { - return { index, candidate: { target, why: "no usage data" } satisfies AutoCandidate }; + return { index, tier: 1 as Tier, candidate: { target, why: "no usage data" } satisfies AutoCandidate }; } const usedPct = Math.max(...windows.map((window) => window.percentUsed)); - const why = `${describe(windows)} used${usedPct >= 100 ? " (full)" : ""}`; - return { index, candidate: { target, usedPct, why } satisfies AutoCandidate }; + const weekly = windows.filter((window) => window.kind === "weekly" || window.kind === "scoped"); + const weeklyLeftPct = 100 - Math.max(0, ...weekly.map((window) => window.percentUsed)); + const tier: Tier = usedPct >= 100 ? 3 : weeklyLeftPct <= policy.minWeeklyLeftPct ? 2 : 0; + const note = ["", "", ` (${policy.minWeeklyLeftPct}% weekly room or less)`, " (full)"][tier]; + return { + index, + tier, + candidate: { target, usedPct, why: `${describe(windows)} used${note}` } satisfies AutoCandidate, + }; }); - const rank = (candidate: AutoCandidate): number => { - if (candidate.usedPct === undefined) { - return 100; - } - - return candidate.usedPct >= 100 ? 200 + candidate.usedPct : candidate.usedPct; - }; - return candidates - .sort((a, b) => rank(a.candidate) - rank(b.candidate) || a.index - b.index) + return ranked + .sort( + (a, b) => + a.tier - b.tier || + (a.tier <= 1 + ? providerRank(a.candidate.target.account.provider) - + providerRank(b.candidate.target.account.provider) + : 0) || + (a.candidate.usedPct ?? 0) - (b.candidate.usedPct ?? 0) || + a.index - b.index + ) .map((entry) => entry.candidate); } @@ -80,6 +113,7 @@ export interface AutoAccountOptions { store?: AiConfigStore; /** Injected usage, for tests. Omitted reads the shared usage cache (`tools ai usage`), polling when stale. */ snapshots?: readonly AccountUsageSnapshot[]; + policy?: AutoPolicy; } async function readUsage(accounts: readonly AccountEntry[]): Promise { @@ -117,7 +151,7 @@ export async function autoAccountCandidates(options: AutoAccountOptions = {}): P } const snapshots = options.snapshots ?? (await readUsage(targets.map((target) => target.account))); - return rankAutoAccounts(targets, snapshots); + return rankAutoAccounts(targets, snapshots, options.policy); } /** A refusal that a different account may not get: 401, 403 or 429, also when the SDK wrapped it in retries. */ @@ -148,7 +182,7 @@ export async function runWithAutoAccount(options: RunWithAutoAccountOptions { expect(candidates.find((c) => c.target.account.id === "acc_max")?.why).toBe("5h 100% used (full)"); }); + test("prefers Claude, then Codex, then Grok while each keeps over 10% weekly room", async () => { + const sub = (id: string, provider: string) => account(id, id, { provider }); + writeConfig({ + accounts: [ + sub("acc_grok", "grok-sub"), + sub("acc_codex", "openai-sub"), + sub("acc_claude_low", "anthropic-sub"), + sub("acc_claude_busy", "anthropic-sub"), + sub("acc_claude_idle", "anthropic-sub"), + sub("acc_claude_unknown", "anthropic-sub"), + ], + }); + const weekly = (id: string, provider: string, percentUsed: number): AccountUsageSnapshot => ({ + ...usage(id, [{ key: "seven_day", label: "Weekly", kind: "weekly", percentUsed }]), + provider, + }); + const ranked = await autoAccountCandidates({ + snapshots: [ + weekly("acc_grok", "grok-sub", 0), + weekly("acc_codex", "openai-sub", 5), + weekly("acc_claude_low", "anthropic-sub", 90), + weekly("acc_claude_busy", "anthropic-sub", 70), + weekly("acc_claude_idle", "anthropic-sub", 20), + ], + }); + + expect(ranked.map((c) => c.target.account.id)).toEqual([ + "acc_claude_idle", + "acc_claude_busy", + "acc_codex", + "acc_grok", + "acc_claude_unknown", + "acc_claude_low", + ]); + expect(ranked.at(-1)?.why).toBe("Weekly 90% used (10% weekly room or less)"); + }); + test("isAccountRefusal: 401, 403 and 429, also wrapped in SDK retries; never a server error", () => { expect(isAccountRefusal(refusal(429))).toBe(true); expect(isAccountRefusal(refusal(403))).toBe(true); From d49b0a6fcebec556abd912372ff962e5b1c55ba0 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 05:32:50 +0200 Subject: [PATCH 08/26] fix(gitlab): review --receive and the skeleton list a plain MR comment someone else wrote (its own section, count and T id, reply only, never resolve), so they agree with comments list --unresolved --- src/gitlab/commands/fetch-review.ts | 27 +++++-- src/gitlab/commands/review-commands.test.ts | 78 ++++++++++++++++++++ src/gitlab/lib/judgements-check.ts | 17 ++++- src/gitlab/lib/judgements-post.test.ts | 20 ++++++ src/gitlab/lib/judgements-post.ts | 4 ++ src/gitlab/lib/review-items.ts | 7 +- src/gitlab/lib/review-render.ts | 77 ++++++++++++++++++-- src/gitlab/lib/review.test.ts | 79 ++++++++++++++++++--- 8 files changed, 281 insertions(+), 28 deletions(-) diff --git a/src/gitlab/commands/fetch-review.ts b/src/gitlab/commands/fetch-review.ts index 2ad9d44cda..00c98a876c 100644 --- a/src/gitlab/commands/fetch-review.ts +++ b/src/gitlab/commands/fetch-review.ts @@ -22,6 +22,7 @@ import { expandThreads, fetchAnchorViews, fetchTipViews, + firstHumanNote, locateMovedLines, receiveIndex, renderMarkdown, @@ -65,18 +66,28 @@ const status = { message: (msg: string) => (isTty() ? out.log.message(msg) : out.printlnErr(msg)), }; -/** Review ids for the MR's threads, from its stored map (new threads get new ids, the map is saved). */ -async function threadRefs(api: ProjectApi, iid: string, discussions: Discussion[]): Promise> { +/** + * Review ids for the MR's threads, from its stored map (new threads get new ids, the map is saved). The + * same discussions `reviewItems` numbers (every one a person wrote in, plain comments too), in the same + * order, so the report and the skeleton give a thread the same id. + */ +async function threadRefs( + api: ProjectApi, + iid: string, + discussions: Discussion[] +): Promise<{ refs: Map; me: string }> { const me = await currentUser(api); const path = idMapPath({ host: api.host, project: api.project, iid: Number(iid) }); const map = loadIdMap(path); - const threads = discussions - .filter((d) => !d.individual_note && d.id) - .map((d) => ({ id: d.id ?? "", author: d.notes?.[0]?.author?.username ?? "" })); + const threads = discussions.flatMap((d) => { + const first = firstHumanNote(d); + + return d.id && first ? [{ id: d.id, author: first.author?.username ?? "unknown" }] : []; + }); const refs = assignThreadRefs(map, threads, me.username); saveIdMap(path, map); - return refs; + return { refs, me: me.username }; } export async function runFetchReview(mrIid: string, opts: FetchReviewOptions): Promise { @@ -172,6 +183,7 @@ export async function runFetchReview(mrIid: string, opts: FetchReviewOptions): P `Fetched ${views.size}/${total} anchor view(s) (${gitHits} from local git, ${views.size - gitHits} from the API).` ); + const { refs, me } = await threadRefs(api, mrIid, discussions); const renderOpts = { mrIid, project: api.project, @@ -179,7 +191,8 @@ export async function runFetchReview(mrIid: string, opts: FetchReviewOptions): P contextLines, anchorViews: views, tip, - refs: await threadRefs(api, mrIid, discussions), + refs, + me, nextSteps: config.review.nextSteps, }; diff --git a/src/gitlab/commands/review-commands.test.ts b/src/gitlab/commands/review-commands.test.ts index af94f54bfe..9463d48278 100644 --- a/src/gitlab/commands/review-commands.test.ts +++ b/src/gitlab/commands/review-commands.test.ts @@ -1374,3 +1374,81 @@ describe("runStep", () => { expect(drafts.map((d) => d.id)).toEqual([900]); }); }); + +describe("plain MR comments in the receive flow", () => { + const PLAIN = "a66e4394ad7f00000000000000000000000000aa"; + + /** A diff thread and the shape GitLab gives a plain MR comment: one note, not resolvable, `type: null`. */ + function plainFake(iid: number): FakeGitLab { + const { fake } = reviewFake(iid); + fake.get(`${PROJECT_API}/merge_requests/${iid}/discussions`, [ + rawDiscussion(THREAD, { path: "src/a.ts", line: 3 }), + { + id: PLAIN, + individual_note: true, + notes: [ + { + id: 2000, + type: null, + author: { username: "dana" }, + body: "@me Remember to delete the migration skill before the merge", + system: false, + resolvable: false, + }, + ], + }, + ]); + + return fake; + } + + test("the skeleton gives every comment `comments list --unresolved` lists its own item, and a plain one cannot be resolved", async () => { + const fake = plainFake(97); + + const listed = await run(fake, "comments", "list", "97", "--unresolved", "--json", ...TARGET); + const skeleton = await run( + fake, + "review", + "skeleton", + "97", + "--receive", + "--format", + "json", + "--print", + ...TARGET + ); + const items = (SafeJSON.parse(skeleton.stdout) as { items: Array<{ id: string }> }).items; + const text = await run(fake, "review", "skeleton", "97", "--receive", "--print", ...TARGET); + + expect((SafeJSON.parse(listed.stdout) as unknown[]).length).toBe(2); + expect(items.map((item) => item.id)).toEqual(["T01", "T02"]); + expect(text.stdout).toContain( + "# T02 @me Remember to delete the migration skill before the merge · discussion a66e4394ad7f · plain comment" + ); + expect(text.stdout).toContain("- Action: reply | none\n"); + + const file = join(dir, "j.md"); + writeFileSync( + file, + [ + filled(RECEIVE_FILE, 97), + "# T02 Remember · discussion a66e4394ad7f · plain comment", + "- Verdict: Valid [90%]", + "- Proposal: Delete it before the merge.", + "- Rationale:", + " - The skill is only for this migration.", + "- Action: reply-resolve", + "- Proposed draft reply:", + "", + "```markdown", + "@dana Deleted before the merge.", + "```", + "", + ].join("\n") + ); + const checked = await run(fake, "review", "check", "97", "--receive", "--file", file, ...TARGET); + + expect(checked.stdout).toContain("T02 (line"); + expect(checked.stdout).toContain("a plain comment has nothing to resolve; use Action reply"); + }); +}); diff --git a/src/gitlab/lib/judgements-check.ts b/src/gitlab/lib/judgements-check.ts index 4d37c88820..98c8dadf49 100644 --- a/src/gitlab/lib/judgements-check.ts +++ b/src/gitlab/lib/judgements-check.ts @@ -35,6 +35,8 @@ export interface KnownItem { author: string; /** A D item whose draft was published: the draft id its heading still names. */ publishedFrom?: string; + /** For T and Y: false for a plain comment, which can be answered but never resolved. */ + resolvable?: boolean; } export interface SkeletonInput { @@ -55,6 +57,10 @@ function pairText(item: KnownItem): string { } function anchorText(item: KnownItem): string { + if (item.resolvable === false) { + return item.path ? `${item.path}:${item.line ?? 1} · plain comment` : "plain comment"; + } + return item.path ? `${item.path}:${item.line ?? 1}` : "top-level"; } @@ -65,7 +71,7 @@ function threadBlock(item: KnownItem): string[] { "- Proposal: ", "- Rationale:", " - ", - `- Action: ${ACTIONS[item.kind].join(" | ")}`, + `- Action: ${ACTIONS[item.kind].filter((action) => item.resolvable !== false || action !== "reply-resolve").join(" | ")}`, "- Proposed draft reply:", "", "```markdown", @@ -445,6 +451,15 @@ export function checkJudgements(input: CheckInput): CheckResult { continue; } + if (action === "reply-resolve" && input.known.find((known) => known.id === item.id)?.resolvable === false) { + errors.push({ + id: item.id, + line: item.line, + message: "a plain comment has nothing to resolve; use Action reply", + }); + continue; + } + const textField = actionTextField(item.kind, action); // A move without a rewording keeps the draft's text, so only the other actions need theirs. diff --git a/src/gitlab/lib/judgements-post.test.ts b/src/gitlab/lib/judgements-post.test.ts index 3b5566182e..046dc3ef7b 100644 --- a/src/gitlab/lib/judgements-post.test.ts +++ b/src/gitlab/lib/judgements-post.test.ts @@ -132,6 +132,26 @@ describe("comments post plan", () => { expect(describeStep(result.steps[0])).toBe('T01 reply+resolve thread aaaa1111 at src/a.ts:3 "Fixed."'); }); + test("a plain comment takes a reply but never a resolve", () => { + const plainKnown = KNOWN.map((item) => (item.id === "T01" ? { ...item, resolvable: false } : item)); + const run = (judgements: string) => + planPost({ + judgements: parseJudgements(judgements), + known: plainKnown, + ids: ["T01"], + answers: [], + agent: "Opus", + }); + + expect(run(FILE).errors).toEqual([ + { id: "T01", message: "a plain comment has nothing to resolve; use Action reply" }, + ]); + expect(run(FILE.replace("- Action: reply-resolve", "- Action: reply")).steps[0]).toMatchObject({ + kind: "reply", + resolve: false, + }); + }); + test("a published draft cannot be deleted any more, but its answer goes into the thread it became, signed", () => { expect(plan(["D03"]).errors).toEqual([ { id: "D03", message: "the draft was published; answer in its thread with --answers D03" }, diff --git a/src/gitlab/lib/judgements-post.ts b/src/gitlab/lib/judgements-post.ts index a7eaee2c0a..5f3c7a94c8 100644 --- a/src/gitlab/lib/judgements-post.ts +++ b/src/gitlab/lib/judgements-post.ts @@ -97,6 +97,10 @@ function stepFor( return { error: `Action ${action} has no text` }; } + if (action === "reply-resolve" && known.resolvable === false) { + return { error: "a plain comment has nothing to resolve; use Action reply" }; + } + return { id: item.id, kind: "reply", diff --git a/src/gitlab/lib/review-items.ts b/src/gitlab/lib/review-items.ts index ff07797014..cc72a8a338 100644 --- a/src/gitlab/lib/review-items.ts +++ b/src/gitlab/lib/review-items.ts @@ -72,6 +72,7 @@ export async function reviewItems( line: d.line, body: d.body, author: d.author, + resolvable: d.resolvable, }); const draftItems: KnownItem[] = drafts.map((draft, i) => ({ id: draftRefs[i], @@ -100,8 +101,10 @@ export async function reviewItems( const publishedThreads = new Set(publishedItems.map((item) => item.pair.value)); const known = mode === "receive" - ? // Top-level threads count too: a started discussion on the MR is as owed a verdict as a diff comment. - discussions.filter((d) => d.resolvable && !d.resolved && d.author !== me.username).map(threadItem) + ? // Every open discussion someone else started: diff threads, started discussions and plain comments + // (never resolved, as `comments list --unresolved` shows them). The receive report lists the same + // set (`plainComments` and the unresolved-thread filters in review-render.ts). + discussions.filter((d) => !d.resolved && d.author !== me.username).map(threadItem) : [ ...draftItems, ...publishedItems, diff --git a/src/gitlab/lib/review-render.ts b/src/gitlab/lib/review-render.ts index 903618ad12..88b0178094 100644 --- a/src/gitlab/lib/review-render.ts +++ b/src/gitlab/lib/review-render.ts @@ -51,6 +51,32 @@ export function unresolvedTopLevelThreads(discussions: Discussion[]): Discussion }); } +/** The first note a person wrote; a discussion of system notes only has none. */ +export function firstHumanNote(d: Discussion): Note | undefined { + return (d.notes ?? []).find((n) => !n.system); +} + +/** + * Plain comments (`individual_note`, or any discussion whose first note is not resolvable): nothing to + * resolve, so they are never resolved, and `comments list --unresolved` always lists them. The receive + * report and the skeleton (`reviewItems`) list those someone else wrote; the author's own are only counted. + */ +export function plainComments(discussions: Discussion[], me?: string): { others: Discussion[]; mine: number } { + const plain = discussions.filter((d) => { + const first = firstHumanNote(d); + + return first !== undefined && !first.resolvable; + }); + const isMine = (d: Discussion): boolean => Boolean(me) && firstHumanNote(d)?.author?.username === me; + + return { others: plain.filter((d) => !isMine(d)), mine: plain.filter(isMine).length }; +} + +/** The note after a plain-comment count when the reader's own were left out. */ +function plainHidden(mine: number): string { + return mine > 0 ? ` (${mine} of your own not listed: you wrote them)` : ""; +} + /** Diff-attached threads with at least one resolvable note still open. */ export function unresolvedThreads(discussions: Discussion[]): Discussion[] { return discussions.filter((d) => { @@ -212,6 +238,8 @@ export interface RenderMarkdownOpts { afterNotes?: (d: Discussion) => BlockInput; /** Extra bullets under "Next steps"; `{iid}` becomes the MR iid. */ nextSteps?: string[]; + /** The current user: their own plain comments are counted, not listed. */ + me?: string; } export interface RenderMarkdownResult { @@ -449,9 +477,10 @@ function threadBlocks(d: Discussion, idx: number, opts: RenderMarkdownOpts): Blo export function receiveIndex(discussions: Discussion[], opts: RenderMarkdownOpts, command: string): string { const threads = unresolvedThreads(discussions); const topLevel = unresolvedTopLevelThreads(discussions); + const plain = plainComments(discussions, opts.me); const lines = [ `=== GitLab MR review received: ${opts.project}!${opts.mrIid} ===`, - `Unresolved threads: ${threads.length + topLevel.length} of ${discussions.length} discussions (${topLevel.length} top-level)${opts.tip ? ` | MR tip ${shortSha(opts.tip.sha)}` : ""}`, + `Unresolved threads: ${threads.length + topLevel.length} of ${discussions.length} discussions (${topLevel.length} top-level) | Plain comments: ${plain.others.length}${plainHidden(plain.mine)}${opts.tip ? ` | MR tip ${shortSha(opts.tip.sha)}` : ""}`, "", ]; @@ -482,7 +511,19 @@ export function receiveIndex(discussions: Discussion[], opts: RenderMarkdownOpts ); }); - const first = threads[0] ?? topLevel[0]; + plain.others.forEach((d, idx) => { + const first = firstHumanNote(d); + const ref = (d.id && opts.refs?.get(d.id)) || `plain${idx + 1}`; + const body = String(first?.body ?? "") + .replace(/\s+/g, " ") + .trim(); + + lines.push( + ` ${ref} plain comment @${first?.author?.username ?? "?"} ${d.notes?.length ?? 0}n ${body.length > 60 ? `${body.slice(0, 59)}…` : body}` + ); + }); + + const first = threads[0] ?? topLevel[0] ?? plain.others[0]; const sample = first?.id ? (opts.refs?.get(first.id) ?? "T01") : "T01"; lines.push("", `Expand: ${command} --expand ${sample}`, `Markdown: ${command} --md`); @@ -491,7 +532,11 @@ export function receiveIndex(discussions: Discussion[], opts: RenderMarkdownOpts /** The full sections of the chosen threads, by review id (`T03`) or discussion id prefix. */ export function expandThreads(discussions: Discussion[], opts: RenderMarkdownOpts, ids: string[]): string { - const threads = [...unresolvedThreads(discussions), ...unresolvedTopLevelThreads(discussions)]; + const threads = [ + ...unresolvedThreads(discussions), + ...unresolvedTopLevelThreads(discussions), + ...plainComments(discussions, opts.me).others, + ]; const wanted = ids.map((id) => id.trim()).filter(Boolean); const blocks: BlockInput[] = wanted.map((id) => { const index = threads.findIndex( @@ -516,7 +561,9 @@ export function threadSections(discussions: Discussion[], opts: RenderMarkdownOp */ export function threadSectionsOf(threads: Discussion[], opts: RenderMarkdownOpts): BlockInput { return threads.map((d, idx) => { - if (d.notes?.[0]?.position) { + const plain = !firstHumanNote(d)?.resolvable; + + if (d.notes?.[0]?.position && !plain) { return threadBlocks(d, idx, opts); } @@ -524,8 +571,15 @@ export function threadSectionsOf(threads: Discussion[], opts: RenderMarkdownOpts const noteCount = d.notes?.length ?? 0; return [ - { h2: `${ref} — top-level` }, - { ul: [`**Discussion**: \`${d.id ?? "?"}\``] }, + { h2: `${ref} — ${plain ? "plain comment" : "top-level"}` }, + { + ul: [ + `**Discussion**: \`${d.id ?? "?"}\``, + ...(plain + ? ["**Not resolvable**: answer with a reply in this discussion; there is nothing to resolve."] + : []), + ], + }, { h3: `Discussion (${noteCount} note${noteCount === 1 ? "" : "s"}):` }, (d.notes ?? []).map(noteBlock), opts.afterNotes?.(d) ?? [], @@ -568,6 +622,7 @@ export async function collectThreadContext(options: { export function reviewBlocks(discussions: Discussion[], opts: RenderMarkdownOpts): BlockInput { const threads = unresolvedThreads(discussions); const topLevel = unresolvedTopLevelThreads(discussions); + const plain = plainComments(discussions, opts.me); const stats = threadStats(discussions); return [ @@ -578,6 +633,7 @@ export function reviewBlocks(discussions: Discussion[], opts: RenderMarkdownOpts `**Discussions total**: ${discussions.length}`, `**Unresolved diff-attached threads**: ${threads.length}`, `**Unresolved top-level threads**: ${topLevel.length}`, + `**Plain comments (not resolvable)**: ${plain.others.length}${plainHidden(plain.mine)}`, `**Files touched**: ${stats.files}`, `**Distinct head_shas**: ${stats.headShas} _(each comment may be anchored to a different commit — fetch / read at its own \`head_sha\`)_`, `**Local cwd**: \`${opts.cwd}\``, @@ -590,6 +646,9 @@ export function reviewBlocks(discussions: Discussion[], opts: RenderMarkdownOpts threads.map((d, idx) => threadBlocks(d, idx, opts)), threads.length === 0 ? "_No unresolved diff-attached threads._" : [], topLevel.length > 0 ? threadSectionsOf(topLevel, opts) : [], + plain.others.length > 0 + ? [{ h2: "Plain comments (not resolvable)" }, threadSectionsOf(plain.others, opts)] + : [], { h2: "Next steps" }, { ul: [ @@ -602,7 +661,11 @@ export function reviewBlocks(discussions: Discussion[], opts: RenderMarkdownOpts } export function renderMarkdown(discussions: Discussion[], opts: RenderMarkdownOpts): RenderMarkdownResult { - const threads = [...unresolvedThreads(discussions), ...unresolvedTopLevelThreads(discussions)]; + const threads = [ + ...unresolvedThreads(discussions), + ...unresolvedTopLevelThreads(discussions), + ...plainComments(discussions, opts.me).others, + ]; const stats = threadStats(discussions); return { diff --git a/src/gitlab/lib/review.test.ts b/src/gitlab/lib/review.test.ts index 549095802d..1095684f46 100644 --- a/src/gitlab/lib/review.test.ts +++ b/src/gitlab/lib/review.test.ts @@ -19,9 +19,12 @@ import { import { collectUnresolvedAnchorPairs, type Discussion, + expandThreads, fetchAnchorViews, fetchTipViews, fileLines, + plainComments, + receiveIndex, renderMarkdown, unresolvedThreads, unresolvedTopLevelThreads, @@ -694,7 +697,8 @@ describe("review render", () => { anchorViews: new Map([["a1b2c3d4e5f6a7b8:src/app.ts", ["one", "two", "three"]]]), }); - expect(threadCount).toBe(1); + // The open diff thread and the plain comment ("top-level chat"), as `comments list --unresolved` counts them. + expect(threadCount).toBe(2); expect(totalDiscussions).toBe(3); expect(md).toContain("## Thread 1 — `src/app.ts`:2"); expect(md).toContain("_(file not in current working tree)_"); @@ -820,6 +824,7 @@ describe("review render", () => { - **Discussions total**: 5 - **Unresolved diff-attached threads**: 3 - **Unresolved top-level threads**: 0 + - **Plain comments (not resolvable)**: 1 - **Files touched**: 3 - **Distinct head_shas**: 3 _(each comment may be anchored to a different commit — fetch / read at its own \`head_sha\`)_ - **Local cwd**: \`\` @@ -916,6 +921,20 @@ describe("review render", () => { --- + ## Plain comments (not resolvable) + + ## Thread 1 — plain comment + + - **Discussion**: \`chat\` + - **Not resolvable**: answer with a reply in this discussion; there is nothing to resolve. + + ### Discussion (1 note): + + **@(unknown)**: + > top-level chat + + --- + ## Next steps - Apply the fixes to the current working tree (not to the reviewer's frozen view). @@ -931,6 +950,7 @@ describe("review render", () => { - **Discussions total**: 0 - **Unresolved diff-attached threads**: 0 - **Unresolved top-level threads**: 0 + - **Plain comments (not resolvable)**: 0 - **Files touched**: 0 - **Distinct head_shas**: 0 _(each comment may be anchored to a different commit — fetch / read at its own \`head_sha\`)_ - **Local cwd**: \`/x\` @@ -1112,27 +1132,64 @@ describe("top-level threads in the receive report", () => { { resolvable: true, resolved: false, author: { username: "bob" }, body: "About the MR as a whole" }, ], }, + // The shape GitLab gives a plain MR comment: one note, not resolvable, never resolved. { id: "note1", individual_note: true, - notes: [{ resolvable: false, author: { username: "bob" }, body: "LGTM" }], + notes: [ + { + resolvable: false, + system: false, + author: { username: "dana" }, + body: "@me Remember to delete the migration skill before the merge", + }, + ], + }, + { + id: "note2", + individual_note: true, + notes: [{ resolvable: false, system: false, author: { username: "me" }, body: "Ready for review" }], }, + { id: "sys1", individual_note: true, notes: [{ system: true, body: "added 1 commit" }] }, { id: "top2", notes: [{ resolvable: true, resolved: true, author: { username: "bob" }, body: "Done" }] }, ]; + const opts = { mrIid: "42", project: "group/app", cwd: "/nonexistent-checkout", contextLines: 1, me: "me" }; - test("a started discussion without an anchor counts and is shown; a plain note and a resolved one are not", () => { + test("a started discussion without an anchor counts and is shown; a resolved one is not", () => { expect(unresolvedTopLevelThreads(discussions).map((d) => d.id)).toEqual(["top1"]); - const { md, threadCount } = renderMarkdown(discussions, { - mrIid: "42", - project: "group/app", - cwd: "/nonexistent-checkout", - contextLines: 1, - }); + const { md } = renderMarkdown(discussions, opts); - expect(threadCount).toBe(2); expect(md).toContain("**Unresolved top-level threads**: 1"); expect(md).toContain("About the MR as a whole"); - expect(md).not.toContain("LGTM"); + expect(md).not.toContain("Done"); + }); + + test("a plain comment someone else wrote gets its own section and count, as `comments list --unresolved` lists it", () => { + expect(plainComments(discussions, "me")).toEqual({ others: [discussions[2]], mine: 1 }); + + const { md, threadCount } = renderMarkdown(discussions, { + ...opts, + refs: new Map([["note1", "T03"]]), + }); + + expect(threadCount).toBe(3); + expect(md).toContain("**Plain comments (not resolvable)**: 1 (1 of your own not listed: you wrote them)"); + expect(md).toContain("## Plain comments (not resolvable)"); + expect(md).toContain("## T03 — plain comment"); + expect(md).toContain("Remember to delete the migration skill before the merge"); + expect(md).not.toContain("Ready for review"); + expect(md).not.toContain("added 1 commit"); + + const index = receiveIndex( + discussions, + { ...opts, refs: new Map([["note1", "T03"]]) }, + "tools gitlab pr 42 review" + ); + expect(index).toContain("Plain comments: 1 (1 of your own not listed: you wrote them)"); + expect(index).toContain( + " T03 plain comment @dana 1n @me Remember to delete the migration skill before the merge" + ); + expect(expandThreads(discussions, opts, ["T03", "note1"])).toContain("## Thread 1 — plain comment"); }); }); From fb9add683c997cca0367d8deefba3f2ab3470fc8 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 17:19:27 +0200 Subject: [PATCH 09/26] fix(ai): an embedder with an explicit provider (Ollama for mail search) no longer loads the AI config, which decrypted every account token and raised the vault-key approval --- src/utils/ai/tasks/Embedder.test.ts | 31 +++++++++++++++++++++++++++-- src/utils/ai/tasks/Embedder.ts | 6 ++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/utils/ai/tasks/Embedder.test.ts b/src/utils/ai/tasks/Embedder.test.ts index 7b7ba2f526..7c077345c9 100644 --- a/src/utils/ai/tasks/Embedder.test.ts +++ b/src/utils/ai/tasks/Embedder.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it, test } from "bun:test"; -import type { AIEmbeddingProvider, AITask, EmbeddingResult } from "../types"; +import { afterEach, describe, expect, it, spyOn, test } from "bun:test"; +import { AIConfig } from "../AIConfig"; +import * as providers from "../providers/index"; +import type { AIEmbeddingProvider, AIProvider, AITask, EmbeddingResult } from "../types"; import { Embedder } from "./Embedder"; const isDarwin = process.platform === "darwin"; @@ -98,6 +100,31 @@ describe("Embedder", () => { }); }); +describe("Embedder.create and the AI config", () => { + afterEach(() => { + spyOn(AIConfig, "load").mockRestore(); + spyOn(providers, "getProvider").mockRestore(); + spyOn(providers, "getProviderForTask").mockRestore(); + }); + + // Loading the config decrypts every account's secrets, which raises the vault-key approval. + test("an explicit provider never loads the config, and the default path still does", async () => { + const load = spyOn(AIConfig, "load").mockImplementation(() => { + throw new Error("AIConfig.load reached"); + }); + const mock = createSequentialMockProvider(8) as unknown as AIProvider; + spyOn(providers, "getProvider").mockReturnValue(mock); + spyOn(providers, "getProviderForTask").mockResolvedValue(mock); + + const embedder = await Embedder.create({ provider: "local-hf", model: "m" }); + expect(embedder.dimensions).toBe(8); + expect(load).not.toHaveBeenCalled(); + + await expect(Embedder.create()).rejects.toThrow("AIConfig.load reached"); + await expect(Embedder.create({ provider: "local-hf", persist: true })).rejects.toThrow("AIConfig.load reached"); + }); +}); + describe("Embedder batch logic (through Embedder class)", () => { // Helper to create Embedder from a provider (bypasses AIConfig) function createEmbedderFromProvider(provider: AIEmbeddingProvider): Embedder { diff --git a/src/utils/ai/tasks/Embedder.ts b/src/utils/ai/tasks/Embedder.ts index d9d542f5b9..38c9313942 100644 --- a/src/utils/ai/tasks/Embedder.ts +++ b/src/utils/ai/tasks/Embedder.ts @@ -33,8 +33,8 @@ export class Embedder { } static async create(options?: { provider?: string; model?: string; persist?: boolean }): Promise { - const config = await AIConfig.load(); - + // An explicit provider needs the config only to persist the choice. Loading it decrypts every account's + // secrets, so a local embedder (Ollama) would raise the vault-key approval for a token it never uses. if (options?.provider) { // User explicitly chose a provider — fail if unavailable, don't silently fall back const { getProvider } = await import("../providers/index"); @@ -67,6 +67,7 @@ export class Embedder { } if (options.persist) { + const config = await AIConfig.load(); await config.setTask("embed", { provider: options.provider as AIProviderType, model: options.model, @@ -76,6 +77,7 @@ export class Embedder { return new Embedder(explicit as AIEmbeddingProvider, options.model); } + const config = await AIConfig.load(); const provider = await getProviderForTask("embed", config); if (!("embed" in provider)) { From bfe1b8c2632efc6bd3a2c029ab06e0e0038a0832 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:07:17 +0200 Subject: [PATCH 10/26] fix(macos): mail index reads and saves its embedder choice through the plain AI config store, so it no longer decrypts every account token and raises the vault-key approval --- src/macos/commands/mail/index-cmd.ts | 31 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/macos/commands/mail/index-cmd.ts b/src/macos/commands/mail/index-cmd.ts index d34a5e752e..6cabf0fcba 100644 --- a/src/macos/commands/mail/index-cmd.ts +++ b/src/macos/commands/mail/index-cmd.ts @@ -4,6 +4,7 @@ import { MailSource } from "@app/indexer/lib/sources/mail-source"; import type { IndexConfig } from "@app/indexer/lib/types"; import { formatLocalDay, parseMailDate } from "@app/macos/lib/mail/command-helpers"; import * as p from "@clack/prompts"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; import { getDefaultModel, logProviderChoice, @@ -73,13 +74,14 @@ export function registerIndexCommand(program: Command): void { // Fall back to stored defaults from previous run if (!resolvedProvider && !opts.provider) { - const { AIConfig } = await import("@genesiscz/utils/ai/AIConfig"); - const aiConfig = await AIConfig.load(); - const stored = aiConfig.getAppDefaults("mail"); - - if (stored?.embeddingProvider) { - resolvedProvider = stored.embeddingProvider; - resolvedModel ??= stored.embeddingModel; + // The plain store, not AIConfig: AIConfig decrypts every account's tokens, which raised the + // vault-key approval for a choice that holds no secret. + const store = await AiConfigStore.load(); + const stored = store.data().defaults.app?.mail?.embed; + + if (stored?.provider) { + resolvedProvider = stored.provider; + resolvedModel ??= stored.model; } } @@ -219,11 +221,16 @@ export function registerIndexCommand(program: Command): void { } // Persist chosen provider/model for next run if (resolvedProvider) { - const { AIConfig } = await import("@genesiscz/utils/ai/AIConfig"); - const aiConfig = await AIConfig.load(); - await aiConfig.setAppDefaults("mail", { - embeddingProvider: resolvedProvider, - embeddingModel: resolvedModel ?? getDefaultModel(resolvedProvider, "mail"), + const embed = { + provider: resolvedProvider, + model: resolvedModel ?? getDefaultModel(resolvedProvider, "mail"), + }; + const store = await AiConfigStore.load(); + await store.mutate((data) => { + data.defaults.app = { + ...data.defaults.app, + mail: { ...data.defaults.app?.mail, embed }, + }; }); } } finally { From 676aca0e1a8d06f54af5b21cb69dee63ecbc348c Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 21:41:55 +0200 Subject: [PATCH 11/26] fix(azure-devops): history activity accepts a time on --from (WIQL asked with timePrecision), so the last hour of activity can be listed --- src/azure-devops/api.ts | 13 +++- src/azure-devops/commands/history-activity.ts | 61 ++++++++++++++----- src/azure-devops/lib/activity-days.test.ts | 51 ++++++++++++++++ src/azure-devops/lib/activity-days.ts | 50 +++++++++++++++ src/azure-devops/wiql-builder.test.ts | 26 +++++++- src/azure-devops/wiql-builder.ts | 17 ++++++ 6 files changed, 199 insertions(+), 19 deletions(-) diff --git a/src/azure-devops/api.ts b/src/azure-devops/api.ts index 01da7b6d03..16f7ad946e 100644 --- a/src/azure-devops/api.ts +++ b/src/azure-devops/api.ts @@ -53,6 +53,7 @@ import type { WorkItemTypeDefinition, WorkItemUpdate, } from "@app/azure-devops/types"; +import { wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder"; import { concurrentMap } from "@genesiscz/utils/async"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -1178,8 +1179,16 @@ export class Api { * Pass `team` to execute in a team context, which is required by macros such * as `@CurrentIteration`. */ - async runWiql(wiql: string, options?: { top?: number; team?: string }): Promise { - const queryParams = { $top: options?.top ? String(options.top) : undefined }; + async runWiql( + wiql: string, + options?: { top?: number; team?: string; timePrecision?: boolean } + ): Promise { + // A clock in a date literal is rejected unless the request opts into time precision. + // Date-only queries keep the default, so a bare day still means that whole day. + const queryParams = { + $top: options?.top ? String(options.top) : undefined, + timePrecision: wiqlTimePrecisionParam(wiql, options?.timePrecision), + }; const url = options?.team ? Api.teamApiUrl(this.config, options.team, ["wit", "wiql"], queryParams) : Api.witUrl(this.config, "wiql", queryParams); diff --git a/src/azure-devops/commands/history-activity.ts b/src/azure-devops/commands/history-activity.ts index 57bbf37d86..c9df8313fc 100644 --- a/src/azure-devops/commands/history-activity.ts +++ b/src/azure-devops/commands/history-activity.ts @@ -18,9 +18,10 @@ import { } from "@app/azure-devops/cache"; import { buildWorkItemHistory, userMatches } from "@app/azure-devops/history"; import { + formatWindowBound, groupEventsByDay, + historyCoversChange, isInvertedWindow, - localDay, parseDayBoundary, realDayCount, resolveUpdateDate, @@ -226,8 +227,13 @@ async function scanCachedActivity( // ============= Discovery ============= -/** Discover work items changed by user but not in cache, sync their history + comments */ -async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDate?: Date): Promise { +/** Discover work items the user changed in the window and sync any history the cache does not already cover. */ +async function discoverAndSync( + api: Api, + userName: string, + fromDate?: Date, + toDate?: Date +): Promise<{ syncedIds: number[]; changedCount: number }> { // Find existing cached IDs const cacheFiles = await storage.listCacheFiles(false); const cachedIds = new Set(); @@ -243,10 +249,11 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa const userValue = isMeMacro ? "@Me" : `'${escapeWiqlValue(userName)}'`; let wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.ChangedBy] = ${userValue}`; - // The bounds carry their time, for the reason `findMentions` names: a bare `YYYY-MM-DD` means - // MIDNIGHT to WIQL, so `<= ''` dropped everything changed on the --to day. The day part - // alone was also the wrong day, because these bounds are local moments and `toISOString()` - // reports them in UTC. + // The bounds carry their time. A bare `YYYY-MM-DD` means midnight to WIQL, so `<= ''` + // dropped everything changed on the --to day, and the day part alone was also the wrong day: + // these bounds are local moments and `toISOString()` reports them in UTC. The clock is legal + // only with time precision, which `runWiql` turns on for a literal that contains one. Date + // precision rejects it: "You cannot supply a time with the date". if (fromDate) { wiql += ` AND [System.ChangedDate] >= '${fromDate.toISOString()}'`; } @@ -257,22 +264,31 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa const response = await api.runWiql(wiql, { top: 500 }); const serverIds = response.workItems.map((wi) => wi.id); + const changedDates: Map> = serverIds.length > 0 + ? await api.getWorkItemFields(serverIds, ["System.ChangedDate"]) + : new Map(); - // Find items that need history sync (not cached OR no history section) + // A history section only means we synced this item once. WIQL already named it as changed + // inside the window, so skip it only when the cached updates reach its current ChangedDate. const needSync: number[] = []; for (const id of serverIds) { + const changed = changedDates.get(id)?.["System.ChangedDate"]; + const changedDate = typeof changed === "string" ? changed : undefined; + if (!cachedIds.has(id)) { needSync.push(id); continue; } + const cached = await loadWorkItemCache(id); - if (!cached?.history) { + + if (!historyCoversChange(cached?.history?.updates, changedDate)) { needSync.push(id); } } if (needSync.length === 0) { - return []; + return { syncedIds: [], changedCount: serverIds.length }; } // Sync history for discovered items @@ -299,7 +315,7 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa // comments" and `isCommentsFresh` suppressed the retry for seven days. `batchGetComments` logs // each failure; leaving the section unwritten is what lets the next run try again. - return needSync; + return { syncedIds: needSync, changedCount: serverIds.length }; } // ============= Fetch Missing Comments ============= @@ -488,7 +504,10 @@ export async function handleHistoryActivity(options: ActivityOptions): Promise 0 - ? `Discovered and synced ${newIds.length} new work items` - : "No new work items to discover" + discovered.syncedIds.length > 0 + ? `Synced history for ${discovered.syncedIds.length} work items` + : discovered.changedCount > 0 + ? `${discovered.changedCount} changed work items already cached` + : "No work items changed in this window" ); } diff --git a/src/azure-devops/lib/activity-days.test.ts b/src/azure-devops/lib/activity-days.test.ts index a1453b45f5..3815ab1e2c 100644 --- a/src/azure-devops/lib/activity-days.test.ts +++ b/src/azure-devops/lib/activity-days.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { + formatWindowBound, groupEventsByDay, + historyCoversChange, isInvertedWindow, isSentinelDate, localDay, @@ -25,6 +27,38 @@ function update(overrides: Partial): WorkItemUpdate { }; } +describe("historyCoversChange", () => { + test("an empty cache does not cover a change", () => { + expect(historyCoversChange(undefined, "2026-10-15T02:12:15.000Z")).toBe(false); + expect(historyCoversChange([], "2026-10-15T02:12:15.000Z")).toBe(false); + }); + + test("a missing ChangedDate is not proof the cache is current", () => { + expect(historyCoversChange([update({})], undefined)).toBe(false); + }); + + test("covers the change when the newest update is that ChangedDate", () => { + const updates = [ + update({ + revisedDate: "9999-01-01T00:00:00Z", + fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-15T02:12:15.000Z" } }, + }), + ]; + + expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(true); + }); + + test("does not cover a later edit than the newest cached update", () => { + const updates = [ + update({ + fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-14T08:00:00.000Z" } }, + }), + ]; + + expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(false); + }); +}); + describe("resolveUpdateDate", () => { test("uses revisedDate only when the update carries no changed date", () => { expect(resolveUpdateDate(update({}))).toBe("2026-03-04T09:10:11Z"); @@ -265,6 +299,23 @@ describe("localDay", () => { expect(localDay(parseDayBoundary("2026-01-02"))).toBe("2026-01-02"); }); + test("a clock stays on the status line, in local time", () => { + withTz("Europe/Prague", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-15 04:12"); + }); + + withTz("America/Chicago", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-14 21:12"); + }); + }); + + test("midnight and end of day stay a calendar day", () => { + withTz("Europe/Prague", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15"))).toBe("2026-10-15"); + expect(formatWindowBound(parseDayBoundary("2026-10-15", { endOfDay: true }))).toBe("2026-10-15"); + }); + }); + test("covers a day either side of a UTC boundary, which is where the old form broke", () => { withTz("Europe/Prague", () => { expect(localDay(parseDayBoundary("2026-12-31"))).toBe("2026-12-31"); diff --git a/src/azure-devops/lib/activity-days.ts b/src/azure-devops/lib/activity-days.ts index 4e53e718b3..7ab5e21d03 100644 --- a/src/azure-devops/lib/activity-days.ts +++ b/src/azure-devops/lib/activity-days.ts @@ -38,6 +38,31 @@ export function resolveUpdateDate(update: WorkItemUpdate): string { return ""; } +/** + * Whether these updates already contain the work item's current ChangedDate. + * A cached history section is not proof of that: discovery used to skip any item that had one, + * so a later edit in the requested window never reached the scan. + */ +export function historyCoversChange(updates: WorkItemUpdate[] | undefined, changedDate: string | undefined): boolean { + const changedAt = changedDate ? new Date(changedDate).getTime() : Number.NaN; + + if (!updates || updates.length === 0 || Number.isNaN(changedAt)) { + return false; + } + + let latest = Number.NEGATIVE_INFINITY; + + for (const update of updates) { + const at = new Date(resolveUpdateDate(update)).getTime(); + + if (!Number.isNaN(at) && at > latest) { + latest = at; + } + } + + return Number.isFinite(latest) && latest >= changedAt; +} + /** * One end of a `--from` / `--to` window, as a moment. * @@ -131,6 +156,31 @@ export function localDay(date: Date): string { return `${date.getFullYear()}-${month}-${day}`; } +/** + * A window bound for the status line. Midnight and end-of-day stay a calendar day. + * A bound that carries a clock shows that clock in local time, so an hour ago + * does not read as the whole day. + */ +export function formatWindowBound(date: Date): string { + const day = localDay(date); + const startOfDay = + date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0; + const endOfDay = + date.getHours() === 23 && + date.getMinutes() === 59 && + date.getSeconds() === 59 && + date.getMilliseconds() === 999; + + if (startOfDay || endOfDay) { + return day; + } + + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + return `${day} ${hours}:${minutes}`; +} + export interface DatedEvent { date: string; } diff --git a/src/azure-devops/wiql-builder.test.ts b/src/azure-devops/wiql-builder.test.ts index 42a882f699..f05ed5d27d 100644 --- a/src/azure-devops/wiql-builder.test.ts +++ b/src/azure-devops/wiql-builder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildCombinedQuery } from "@app/azure-devops/wiql-builder"; +import { buildCombinedQuery, wiqlRequestsTimePrecision, wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder"; describe("buildCombinedQuery", () => { test("scopes to the configured project and matches the current assignee with =", () => { @@ -54,3 +54,27 @@ describe("buildCombinedQuery", () => { expect(wiql).toContain("CONTAINS 'O''Neil'"); }); }); + +describe("wiql time precision", () => { + test("a clock in a ChangedDate literal asks for time precision", () => { + const wiql = "AND [System.ChangedDate] >= '2026-10-15T02:12:15.000Z'"; + + expect(wiqlRequestsTimePrecision(wiql)).toBe(true); + expect(wiqlTimePrecisionParam(wiql)).toBe("true"); + }); + + test("a space-separated clock asks for time precision too", () => { + expect(wiqlRequestsTimePrecision("AND [System.ChangedDate] >= '2026-10-15 02:12:15'")).toBe(true); + }); + + test("a bare day stays on date precision", () => { + const wiql = "AND [System.ChangedDate] >= '2026-10-15' ORDER BY [System.ChangedDate] DESC"; + + expect(wiqlRequestsTimePrecision(wiql)).toBe(false); + expect(wiqlTimePrecisionParam(wiql)).toBeUndefined(); + }); + + test("an explicit false keeps date precision even when the literal has a clock", () => { + expect(wiqlTimePrecisionParam("[System.ChangedDate] >= '2026-10-15T02:12:15.000Z'", false)).toBeUndefined(); + }); +}); diff --git a/src/azure-devops/wiql-builder.ts b/src/azure-devops/wiql-builder.ts index 40d92e6e4f..4bcc5e42e7 100644 --- a/src/azure-devops/wiql-builder.ts +++ b/src/azure-devops/wiql-builder.ts @@ -28,6 +28,23 @@ export function escapeWiqlValue(value: string): string { return value.replace(/'/g, "''"); } +/** + * Whether a WIQL statement contains a clock. The query endpoint runs in date + * precision unless `timePrecision=true`, and a clock then fails with + * "You cannot supply a time with the date when running a query using date precision." + * A bare `YYYY-MM-DD` stays on date precision: that mode is what makes the day mean the whole day. + */ +export function wiqlRequestsTimePrecision(wiql: string): boolean { + return /'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(wiql); +} + +/** `true` when this call must set the WIQL `timePrecision` query flag. An explicit false wins. */ +export function wiqlTimePrecisionParam(wiql: string, explicit?: boolean): "true" | undefined { + const on = explicit ?? wiqlRequestsTimePrecision(wiql); + + return on ? "true" : undefined; +} + function toWiqlList(csv: string): string | null { const values = csv .split(",") From 7a6cdbb19faaa27b171069000cd0fe25a7552318 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 23:34:14 +0200 Subject: [PATCH 12/26] fix(cmux, ai): cmux launch reads the default Claude account name from config metadata, so a minted link no longer asks for the vault key before the new surface does --- src/cmux/commands/launch.ts | 13 ++++--- src/utils/ai/config/store-bridge.test.ts | 26 ++++++++++++- src/utils/ai/config/store-bridge.ts | 49 ++++++++++++++++-------- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/cmux/commands/launch.ts b/src/cmux/commands/launch.ts index 4dbe81d7b2..bdc2904ee2 100644 --- a/src/cmux/commands/launch.ts +++ b/src/cmux/commands/launch.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import * as p from "@clack/prompts"; -import { AIConfig } from "@genesiscz/utils/ai/AIConfig"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { defaultAccountName } from "@genesiscz/utils/ai/config/store-bridge"; import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { focusedPlace, launchTarget, openCommandAt } from "@genesiscz/utils/cmux/open-command"; @@ -159,17 +160,19 @@ export function assertLauncherExists(agent: string | true | undefined): void { /** * The account a link that names none launches under: the default Claude account, the same one * `tools claude` picks. A minted handoff link carries no account, so the click resolves it here. + * Only the name is needed, so this reads config metadata: `AIConfig.load()` resolves every secret, + * which asked GenesisTools.app for the vault key on each click before the new surface asked again. */ async function defaultClaudeAccount(): Promise { - const config = await AIConfig.load(); - const account = config.getDefaultAccount("claude"); + const store = await AiConfigStore.load(); + const account = defaultAccountName(store.data(), "claude"); if (!account) { throw new Error("no default Claude account; pass --account "); } - logger.debug({ account: account.name }, "cmux launch: using the default Claude account"); - return account.name; + logger.debug({ account }, "cmux launch: using the default Claude account"); + return account; } /** diff --git a/src/utils/ai/config/store-bridge.test.ts b/src/utils/ai/config/store-bridge.test.ts index 271ee72fdd..e0e6d6fd2d 100644 --- a/src/utils/ai/config/store-bridge.test.ts +++ b/src/utils/ai/config/store-bridge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { type AccountEntry, type AiConfigData, CONFIG_VERSION } from "./schema"; -import { projectToV3, syncV3IntoStore } from "./store-bridge"; +import { defaultAccountName, projectToV3, syncV3IntoStore } from "./store-bridge"; function account(provider: string): AccountEntry { return { @@ -99,3 +99,27 @@ test("explicitly switching the legacy account drops the old account's model pin" await syncV3IntoStore(store, v3); expect(store.defaults.app.fixture?.chat?.model).toBe("@account/acc_openai-sub"); }); + +describe("defaultAccountName reads the default without the vault", () => { + test("it names the same account as the projection, by task, by app, then the first account", () => { + const store = config(["anthropic-sub", "openai-sub"]); + store.accounts[1].name = "personal"; + store.defaults.account = { claude: "@account/acc_openai-sub" }; + store.defaults.app = { fixture: { chat: { model: "@account/acc_openai-sub:fixture-model" } } }; + const v3 = projectToV3(store); + + expect(defaultAccountName(store, "claude")).toBe("personal"); + expect(v3.defaultAccounts.claude).toBe("personal"); + expect(defaultAccountName(store, "fixture")).toBe("personal"); + expect(v3.defaultAccounts.fixture).toBe("personal"); + expect(defaultAccountName(store, "ask")).toBe("work"); + }); + + test("a default naming a removed account falls back to the first account, and no accounts is none", () => { + const store = config(["anthropic-sub"]); + store.defaults.account = { claude: "@account/acc_gone" }; + + expect(defaultAccountName(store, "claude")).toBe("work"); + expect(defaultAccountName(config([]), "claude")).toBeUndefined(); + }); +}); diff --git a/src/utils/ai/config/store-bridge.ts b/src/utils/ai/config/store-bridge.ts index dd7af61796..26f4d4c12a 100644 --- a/src/utils/ai/config/store-bridge.ts +++ b/src/utils/ai/config/store-bridge.ts @@ -2,7 +2,7 @@ import type { AIConfigData as V3ConfigData } from "@genesiscz/utils/config/ai.ty import { logger } from "@genesiscz/utils/logger"; import { secretSnapshotResolver } from "@genesiscz/utils/security/SecretStore"; import { slugifyAccountId } from "./migrations/2026-08-configV4"; -import { type AccountRef, accountRef, accountRefIn, isAccountRef, refToId } from "./refs"; +import { accountRef, accountRefIn, isAccountRef, refToId } from "./refs"; import { type AccountEntry, type AiConfigData, isTaskName } from "./schema"; import { applyV3Secondary, applyV3Tokens, appsFor, toV3Account } from "./v3-adapter"; @@ -12,20 +12,44 @@ import { applyV3Secondary, applyV3Tokens, appsFor, toV3Account } from "./v3-adap * the facade can be swapped underneath without touching its 30-odd methods. */ -export function projectToV3(config: AiConfigData): V3ConfigData { - const defaultAccounts: Record = {}; +/** + * v3's `defaultAccounts`: the account name per task, then per app (an app's chat model that names an account). + * Metadata only, so a caller that needs just a name never unlocks the vault the way `AIConfig.load()` does. + */ +export function defaultAccountNames(config: AiConfigData): Record { + const names: Record = {}; + const nameOf = (ref: unknown) => { + if (!isAccountRef(ref)) { + return undefined; + } + + return config.accounts.find((entry) => entry.id === refToId(ref))?.name; + }; for (const [task, ref] of Object.entries(config.defaults.account ?? {})) { - if (!isAccountRef(ref)) { - continue; + const name = nameOf(ref); + if (name) { + names[task] = name; } + } - const account = config.accounts.find((entry) => entry.id === refToId(ref)); - if (account) { - defaultAccounts[task] = account.name; + for (const [app, appDefaults] of Object.entries(config.defaults.app ?? {})) { + const name = nameOf(appDefaults?.chat?.model?.split(":")[0]); + if (name) { + names[app] = name; } } + return names; +} + +/** `AIConfig.getDefaultAccount(context)?.name` without the vault: the context's default, else the first account. */ +export function defaultAccountName(config: AiConfigData, context: string): string | undefined { + return defaultAccountNames(config)[context] ?? config.accounts[0]?.name; +} + +export function projectToV3(config: AiConfigData): V3ConfigData { + const defaultAccounts = defaultAccountNames(config); const apps: V3ConfigData["apps"] = {}; for (const [app, appDefaults] of Object.entries(config.defaults.app ?? {})) { @@ -35,16 +59,7 @@ export function projectToV3(config: AiConfigData): V3ConfigData { const { chat, embed, temperature, maxTokens, streaming } = appDefaults; - // An app default that names an account is v3's defaultAccounts entry. const chatModel = chat?.model; - const chatAccountRef = chatModel?.split(":")[0]; - if (isAccountRef(chatAccountRef)) { - const account = config.accounts.find((entry) => entry.id === refToId(chatAccountRef as AccountRef)); - if (account) { - defaultAccounts[app] = account.name; - } - } - const defaults = { ...(chat?.provider ? { provider: chat.provider } : {}), ...(chatModel && !chatModel.startsWith("@account/") ? { model: chatModel } : {}), From dfd8b8eafbead9eda97cabaae13c28b2e9c2a82d Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 20 Oct 2026 23:53:38 +0200 Subject: [PATCH 13/26] feat(claude, cmux, handoff): tools claude run auto picks the account with headroom, and a handoff run link is minted with account=auto so a spent default account no longer stops the click --- .claude-plugin/marketplace.json | 4 ++-- plugins/genesis-tools/.claude-plugin/plugin.json | 2 +- .../genesis-tools/skills/browser-router/SKILL.md | 2 +- src/claude/commands/start.ts | 12 ++++++++++-- src/claude/lib/usage/smart-alias.test.ts | 12 +++++++++++- src/claude/lib/usage/smart-alias.ts | 8 ++++++++ src/cmux/commands/launch.ts | 5 ++++- src/cmux/lib/launchers/claudeLauncher.test.ts | 13 +++++++++++++ src/genesis-tools-mcp/lib/tools/handoff.ts | 4 ++-- src/handoff/executor.test.ts | 3 +++ src/handoff/executor.ts | 3 ++- 11 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bfb33e71b1..b2e07a2a35 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.113", + "version": "1.0.114", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.113", + "version": "1.0.114", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index 5bb0e9df88..68d79199bf 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.113", + "version": "1.0.114", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/browser-router/SKILL.md b/plugins/genesis-tools/skills/browser-router/SKILL.md index b600720024..3fe893a658 100644 --- a/plugins/genesis-tools/skills/browser-router/SKILL.md +++ b/plugins/genesis-tools/skills/browser-router/SKILL.md @@ -58,7 +58,7 @@ A link carries only the session id, the number and one letter. Anything else doe ## Run Claude in a new cmux surface -`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once. +`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account; `--account auto` runs `tools claude run auto`, which picks the account with the most headroom when the surface starts. A handoff's run link is minted with `account=auto`. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once. ```bash tools browser-router route 'http://127.0.0.1:8787/add/:id' --run --arg add --arg '{id}' --arg --qty --arg '{qty}' --notify 'Added' --approval allow diff --git a/src/claude/commands/start.ts b/src/claude/commands/start.ts index f4ade74a8e..d9019d7c37 100644 --- a/src/claude/commands/start.ts +++ b/src/claude/commands/start.ts @@ -21,7 +21,7 @@ import { weeklyStatusForAccount, } from "@app/claude/lib/usage/fable-guard"; import { getSharedAccountsUsage, peekSharedUsage } from "@app/claude/lib/usage/shared-cache"; -import { pickSmart, type SmartAlias, smartAliasOf } from "@app/claude/lib/usage/smart-alias"; +import { isAutoAccount, pickSmart, type SmartAlias, smartAliasOf } from "@app/claude/lib/usage/smart-alias"; import { tableSelectAccount } from "@app/claude/lib/usage/table-select"; import { TIER_BADGE } from "@app/claude/lib/usage/usage-table"; import * as p from "@clack/prompts"; @@ -918,6 +918,13 @@ async function main(nameArg: string | undefined, opts: StartOptions, passthrough accountName = (await resolveSmartAlias(alias, withToken, modelId)) ?? (await pickAccount(withToken, opts, modelId, aiConfig)); + } else if ( + isAutoAccount( + nameArg, + withToken.map((a) => a.name) + ) + ) { + accountName = await pickAccount(withToken, { ...opts, autopick: true }, modelId, aiConfig); } else if (nameArg) { accountName = await resolveAccountName(nameArg, withToken, opts, modelId, aiConfig); } else { @@ -1213,7 +1220,8 @@ export function registerStartCommand(program: Command): void { .command("start [name]") .description( "Launch Claude Code using a saved long-lived token (CLAUDE_CODE_OAUTH_TOKEN). " + - "[name] matches account names by exact or substring (TTY prompts when ambiguous). " + + "[name] matches account names by exact or substring (TTY prompts when ambiguous); " + + '"auto" picks the account with the most headroom, like --autopick. ' + "Args after -- are passed through to claude." ) .allowExcessArguments(true) diff --git a/src/claude/lib/usage/smart-alias.test.ts b/src/claude/lib/usage/smart-alias.test.ts index 2fa55ee0ac..1497e32fb3 100644 --- a/src/claude/lib/usage/smart-alias.test.ts +++ b/src/claude/lib/usage/smart-alias.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { scoreAccounts } from "./account-picker"; import type { AccountUsage } from "./api"; -import { pickFable, pickOpus, smartAliasOf } from "./smart-alias"; +import { isAutoAccount, pickFable, pickOpus, smartAliasOf } from "./smart-alias"; const NOW = new Date("2026-07-18T12:00:00Z"); const WEEK_LATER = "2026-07-24T12:00:00Z"; @@ -173,3 +173,13 @@ describe("smartAliasOf", () => { expect(smartAliasOf(undefined, ["genesis"])).toBeNull(); }); }); + +describe("isAutoAccount", () => { + test("auto asks for the headroom pick, case-insensitively, unless a real account is called auto", () => { + expect(isAutoAccount("auto", ["work"])).toBe(true); + expect(isAutoAccount("Auto", ["work"])).toBe(true); + expect(isAutoAccount("auto", ["work", "AUTO"])).toBe(false); + expect(isAutoAccount("work", ["work"])).toBe(false); + expect(isAutoAccount(undefined, ["work"])).toBe(false); + }); +}); diff --git a/src/claude/lib/usage/smart-alias.ts b/src/claude/lib/usage/smart-alias.ts index e850771f2f..3fc0115983 100644 --- a/src/claude/lib/usage/smart-alias.ts +++ b/src/claude/lib/usage/smart-alias.ts @@ -192,3 +192,11 @@ export function smartAliasOf(nameArg: string | undefined, accountNames: string[] return alias; } + +/** + * `tools claude run auto`: the same pick as `--autopick`, as an account name, so a link or a launcher that + * only fills the account slot can ask for it. A real account named "auto" still wins. + */ +export function isAutoAccount(nameArg: string | undefined, accountNames: string[]): boolean { + return nameArg?.toLowerCase() === "auto" && !accountNames.some((name) => name.toLowerCase() === "auto"); +} diff --git a/src/cmux/commands/launch.ts b/src/cmux/commands/launch.ts index bdc2904ee2..3b7900a105 100644 --- a/src/cmux/commands/launch.ts +++ b/src/cmux/commands/launch.ts @@ -41,7 +41,10 @@ export function registerLaunchCommand(program: Command): void { .command("launch") .description("Print the quoted command that opens Claude in a new cmux surface, or run it there with --open") .argument("[claudeArgs...]", "Extra claude arguments, after --. Same destination as --claude-arg.") - .option("--account ", "Saved Claude account. Empty or omitted: the default Claude account.") + .option( + "--account ", + 'Saved Claude account. "auto": the one with the most headroom when the surface starts. Empty or omitted: the default Claude account.' + ) .option("--prompt ") .option("--prompt-file ", "Read the prompt from a file. Not subject to the 8 KB URL cap.") .option("--name ") diff --git a/src/cmux/lib/launchers/claudeLauncher.test.ts b/src/cmux/lib/launchers/claudeLauncher.test.ts index 4a04c36eb4..8449e5628c 100644 --- a/src/cmux/lib/launchers/claudeLauncher.test.ts +++ b/src/cmux/lib/launchers/claudeLauncher.test.ts @@ -31,6 +31,19 @@ describe("cmux claude launcher", () => { expect(command).not.toContain(" && $HOME"); }); + test("account auto reaches tools claude run as the account word, which picks by headroom there", () => { + expect(buildClaudeArgv({ account: "auto", name: "handoff", prompt: "go" })).toEqual([ + "tools", + "claude", + "run", + "auto", + "--", + "-n", + "handoff", + "go", + ]); + }); + test("resume and model stay in front of --, and extra args pass through", () => { expect( buildClaudeArgv({ diff --git a/src/genesis-tools-mcp/lib/tools/handoff.ts b/src/genesis-tools-mcp/lib/tools/handoff.ts index ec85de948f..da2762c371 100644 --- a/src/genesis-tools-mcp/lib/tools/handoff.ts +++ b/src/genesis-tools-mcp/lib/tools/handoff.ts @@ -61,8 +61,8 @@ const RUN_LINK_INFO = "Show paste.runLink.markdown to the user so they can open /** * `postHandoff` is synchronous and the token file is written under an async lock, so the link id is - * chosen during the post and the token is saved right after it (`settleRunLinks`). The account is left - * out: `tools cmux launch` resolves the default Claude account when the link is clicked. + * chosen during the post and the token is saved right after it (`settleRunLinks`). The handoff passes + * account "auto", so `tools claude run auto` picks the account with headroom when the link is clicked. */ function cmuxLaunchDeps(planned: PlannedLink[]): HandoffDeps { const status = routerStatus(); diff --git a/src/handoff/executor.test.ts b/src/handoff/executor.test.ts index c8047a1e98..f1042a79d8 100644 --- a/src/handoff/executor.test.ts +++ b/src/handoff/executor.test.ts @@ -42,6 +42,7 @@ describe("handoff_post (§8.1)", () => { test("the launch link opens in the resolved actor's repo when no actor is injected", () => { const env = freshEnv(); const seen: Array = []; + const accounts: Array = []; const link = { url: "https://links.example.test/cmux/claude/run", markdown: "cmux-claude" }; postHandoff( @@ -53,6 +54,7 @@ describe("handoff_post (§8.1)", () => { linkFor: () => link, mintLaunchLink: (input) => { seen.push(input.cwd); + accounts.push(input.account); return link; }, } @@ -60,6 +62,7 @@ describe("handoff_post (§8.1)", () => { expect(seen).toHaveLength(1); expect(typeof seen[0]).toBe("string"); + expect(accounts).toEqual(["auto"]); }); test("a Claude launch link is offered only when Claude may work the handoff", () => { diff --git a/src/handoff/executor.ts b/src/handoff/executor.ts index 246cb01e50..9aaa8d0703 100644 --- a/src/handoff/executor.ts +++ b/src/handoff/executor.ts @@ -335,7 +335,8 @@ function mintHandoffLaunch({ name: `handoff ${name}`, prompt, cwd, - account: null, + // Picked when the link is clicked: `tools claude run auto` takes the account with the most headroom. + account: "auto", }) ?? null ); } From 7bec97e65f810b5081be7ce37f493961d43c9877 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 21 Oct 2026 17:40:50 +0200 Subject: [PATCH 14/26] fix(tools): tools scripts servers is read-only, since a registry load now mints the gateway token and writes its cache only when the caller sets persist, so listing no longer asks for the vault key --- src/scripts/commands/call.ts | 4 ++-- src/scripts/commands/describe.ts | 2 +- src/scripts/commands/doctor.ts | 2 +- src/scripts/commands/regen.ts | 2 +- src/scripts/commands/tools.ts | 2 +- src/scripts/lib/discover.ts | 6 +++--- src/scripts/lib/kit.ts | 4 ++-- src/scripts/lib/registry.test.ts | 4 ++-- src/scripts/lib/registry.ts | 8 ++++---- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/scripts/commands/call.ts b/src/scripts/commands/call.ts index 1c85339112..7b057bc295 100644 --- a/src/scripts/commands/call.ts +++ b/src/scripts/commands/call.ts @@ -50,7 +50,7 @@ export function registerCall(program: Command): void { argsJson: string | undefined, opts: { argsFile?: string; json?: boolean; timeout: string } ) => { - const registry = await loadRegistry(); + const registry = await loadRegistry({ persist: true }); const available = enabledServers(registry).map((s) => s.name); const selector = parseSelector(ref, available); @@ -65,7 +65,7 @@ export function registerCall(program: Command): void { } const args = await resolveArgs(argsJson, opts.argsFile); - const kit = await createKit({ servers: [selector.server], timeoutMs }); + const kit = await createKit({ servers: [selector.server], timeoutMs, persist: true }); try { const result = await kit.call(selector.server, selector.tool, args); diff --git a/src/scripts/commands/describe.ts b/src/scripts/commands/describe.ts index 7571b0b5b6..ff4f705861 100644 --- a/src/scripts/commands/describe.ts +++ b/src/scripts/commands/describe.ts @@ -11,7 +11,7 @@ export function registerDescribe(program: Command): void { .option("--json", "Machine-readable output") .option("--refresh", "Re-probe the server instead of using the tool cache") .action(async (ref: string, opts: { json?: boolean; refresh?: boolean }) => { - const { matched, errors } = await resolveSelectors([ref], { refresh: opts.refresh }); + const { matched, errors } = await resolveSelectors([ref], { refresh: opts.refresh, persist: true }); if (matched.length === 0) { const detail = diff --git a/src/scripts/commands/doctor.ts b/src/scripts/commands/doctor.ts index d97e99c73b..0f57d502fd 100644 --- a/src/scripts/commands/doctor.ts +++ b/src/scripts/commands/doctor.ts @@ -130,7 +130,7 @@ export function registerDoctor(program: Command): void { } } - const registry = await loadRegistry({ persist: false }); + const registry = await loadRegistry(); const known = new Set(registry.servers.map((s) => s.name)); for (const entry of journal.scripts) { diff --git a/src/scripts/commands/regen.ts b/src/scripts/commands/regen.ts index 6127f71c80..8d074ccfce 100644 --- a/src/scripts/commands/regen.ts +++ b/src/scripts/commands/regen.ts @@ -22,7 +22,7 @@ export function registerRegen(program: Command): void { throw new Error(`'${name}' has no imported selectors, so there are no bindings to regenerate.`); } - const { matched, errors } = await resolveSelectors(entry.imports, { refresh: true }); + const { matched, errors } = await resolveSelectors(entry.imports, { refresh: true, persist: true }); if (matched.length === 0) { throw new Error(`No tools matched ${entry.imports.join(", ")} any more. Bindings left untouched.`); diff --git a/src/scripts/commands/tools.ts b/src/scripts/commands/tools.ts index fadcb5f441..cb8052527a 100644 --- a/src/scripts/commands/tools.ts +++ b/src/scripts/commands/tools.ts @@ -23,7 +23,7 @@ export function registerTools(program: Command): void { opts: { json?: boolean; schema?: boolean; names?: boolean; grep?: string; refresh?: boolean } ) => { const wanted = selectors.length > 0 ? selectors : ["*.*"]; - const resolved = await resolveSelectors(wanted, { refresh: opts.refresh }); + const resolved = await resolveSelectors(wanted, { refresh: opts.refresh, persist: true }); const needle = opts.grep?.toLowerCase(); const matched = needle ? resolved.matched.filter( diff --git a/src/scripts/lib/discover.ts b/src/scripts/lib/discover.ts index 2b635096a5..197eb32682 100644 --- a/src/scripts/lib/discover.ts +++ b/src/scripts/lib/discover.ts @@ -94,7 +94,7 @@ export interface DiscoverResult { export interface DiscoverOptions { /** Re-probe servers instead of using the tool cache. */ refresh?: boolean; - /** Skip cache writes. `--dry-run` promises "write nothing", which includes caches. */ + /** Write the tool cache (and the registry cache and gateway token). Default false: reads and `--dry-run` write nothing. */ persist?: boolean; /** Operation-scoped snapshot already used to match selectors. */ registry?: Registry; @@ -117,7 +117,7 @@ export async function discoverTools(servers: string[], options: DiscoverOptions if (stale.length > 0) { ui.dim(`probing ${stale.length} server(s): ${stale.join(", ")}`); - logger.debug({ stale, persist: options.persist !== false }, "probing mcp servers"); + logger.debug({ stale, persist: options.persist === true }, "probing mcp servers"); const kit = await createKit({ servers: stale, refresh: options.refresh, @@ -153,7 +153,7 @@ export async function discoverTools(servers: string[], options: DiscoverOptions await kit.close(); } - if (options.persist !== false) { + if (options.persist === true) { await saveToolCache(cache); } } diff --git a/src/scripts/lib/kit.ts b/src/scripts/lib/kit.ts index 66bc56d97a..eaec2d73ca 100644 --- a/src/scripts/lib/kit.ts +++ b/src/scripts/lib/kit.ts @@ -40,7 +40,7 @@ export interface KitOptions { servers?: string[]; /** Re-read the registry from the provider configs instead of the cache. */ refresh?: boolean; - /** Skip registry cache writes; dry-run and diagnostic callers must not mutate durable state. */ + /** Write the registry cache and mint the gateway token. Default false; a script that dials gateway servers sets it. */ persist?: boolean; /** Never start an interactive OAuth flow. Default true: scripts are headless. */ disableOAuth?: boolean; @@ -154,7 +154,7 @@ export async function createKit(options: KitOptions = {}): Promise { /** Creates a kit, runs fn, always closes. Use this, not createKit, in scripts. */ export async function withKit(fn: (kit: Kit) => Promise, options: KitOptions = {}): Promise { - const kit = await createKit(options); + const kit = await createKit({ persist: true, ...options }); try { return await fn(kit); diff --git a/src/scripts/lib/registry.test.ts b/src/scripts/lib/registry.test.ts index d408b63327..10dba03db8 100644 --- a/src/scripts/lib/registry.test.ts +++ b/src/scripts/lib/registry.test.ts @@ -111,8 +111,8 @@ describe("registry cache versioning", () => { } ); - // persist:false so this stays a read and cannot mint a gateway token. - const loaded = await loadRegistry({ persist: false }); + // persist defaults to false, so this stays a read and cannot mint a gateway token. + const loaded = await loadRegistry(); // Rebuilt from the providers, so it carries the current schema and NOT the // sentinel that only the stale file contained. diff --git a/src/scripts/lib/registry.ts b/src/scripts/lib/registry.ts index d8c8b365b5..af5b84d0bc 100644 --- a/src/scripts/lib/registry.ts +++ b/src/scripts/lib/registry.ts @@ -92,7 +92,7 @@ function writeJson(path: string, value: unknown): void { export interface LoadRegistryOptions { /** Re-scan the provider configs instead of reading the cache. */ refresh?: boolean; - /** Skip the cache write. Diagnostics (`doctor`) and `--dry-run` paths must not mutate durable state. */ + /** Write the cache and mint the gateway token. Default false: a load that does not ask for it must not mutate durable state or touch the vault. */ persist?: boolean; } @@ -136,7 +136,7 @@ export async function loadRegistry(options: LoadRegistryOptions = {}): Promise Date: Wed, 21 Oct 2026 18:48:05 +0200 Subject: [PATCH 15/26] fix(browser-extension): the popup hides sections that have nothing to show, lays out calmer cards with a full-width primary action, and an update names the commits it carries --- src/browser-extension/extension/popup.html | 27 ++++++++++-- src/browser-extension/extension/popup.ts | 17 ++++++- .../extension/shared/freshness.ts | 8 +++- .../extension/shared/page.ts | 1 + src/browser-extension/lib/build.ts | 41 ++++++++++++++++- src/browser-extension/lib/features.test.ts | 38 +++++++++++++++- src/browser-extension/lib/host/dispatch.ts | 11 +++-- src/utils/browser-extension/build-info.ts | 44 +++++++++++++++++-- .../browser-extension/runtime/freshness.ts | 41 +++++++++++++++++ 9 files changed, 211 insertions(+), 17 deletions(-) diff --git a/src/browser-extension/extension/popup.html b/src/browser-extension/extension/popup.html index 65bc7fb822..3208e9f0cd 100644 --- a/src/browser-extension/extension/popup.html +++ b/src/browser-extension/extension/popup.html @@ -6,21 +6,39 @@ GenesisTools
-
+

GT GenesisTools

host -
+ +