From 2ef63ecf927e8b6de528ec8106e1cd481752e64f Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:38:15 -0400 Subject: [PATCH 01/12] Add directory and branch autocomplete for the new session dialog Browser autocomplete for the repository directory and the worktree branch need server-side help, because the daemon is the only process that can walk the filesystem and run git. web/server/suggestions.ts (new) listDirectorySuggestions(query): treats the last path segment as a prefix filter over the parent directory's entries. A trailing slash is required to drill into a directory's children ("~/vessup" filters sibling suggestions, "~/vessup/" lists children). Suggestions stop once the parent is inside a Git repository (resolveSessionProject), since the repository field selects a repo, not paths beneath one. Hidden directories appear only when the prefix itself starts with ".". web/server/worktrees.ts listRepositoryBranches(cwd): for-each-ref over refs/heads and refs/remotes, dropping the symbolic origin/HEAD pointer. web/server/index.ts GET /api/directories?q=... uses the suggestion helper rooted at ~. GET /api/branches?cwd=... resolves the path and returns local + remote branches; not-a-Git-repo returns empty arrays plus an error field so the browser can fail soft mid-typing. Health advertises the new branchSuggestions capability. web/client/api.ts listDirectorySuggestions and listBranchSuggestions wrappers; each swallows network errors and returns an empty result so a stale daemon doesn't take down the modal mid-typing. .gitignore Exclude .pi/worktrees/ so managed-worktree checkouts don't show up untracked and don't carry a nested biome.json that breaks the linter. tests/web-suggestions.test.ts (new) Home-directory suggestion semantics, absolute path display outside ~, repo-boundary suppression, local/remote branch listing, non-Git failure, and HTTP-level coverage of both endpoints against a spawned server with HOME overridden. --- .gitignore | 2 + tests/web-suggestions.test.ts | 201 ++++++++++++++++++++++++++++++++++ web/client/api.ts | 31 ++++++ web/server/index.ts | 44 +++++++- web/server/suggestions.ts | 98 +++++++++++++++++ web/server/worktrees.ts | 30 +++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 tests/web-suggestions.test.ts create mode 100644 web/server/suggestions.ts diff --git a/.gitignore b/.gitignore index f77683a..b3a319c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ .tmp/ web/.pi-web-state.json web/dist/ +# Managed pi worktrees are ephemeral checkouts, not repo content. +.pi/worktrees/ .staffreview/diffs/ .staffreview/attachments/ .staffreview/active.json diff --git a/tests/web-suggestions.test.ts b/tests/web-suggestions.test.ts new file mode 100644 index 0000000..7fed855 --- /dev/null +++ b/tests/web-suggestions.test.ts @@ -0,0 +1,201 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ServerStateFile } from "../web/protocol.ts"; +import { listDirectorySuggestions } from "../web/server/suggestions.ts"; +import { listRepositoryBranches } from "../web/server/worktrees.ts"; + +let child: Bun.Subprocess | undefined; +let tempDir: string | undefined; + +afterEach(async () => { + if (child) { + child.kill("SIGTERM"); + await child.exited.catch(() => undefined); + child = undefined; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +test("directory suggestions list home directories in ~ shorthand", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-")); + const home = join(tempDir, "home"); + for (const directory of ["alpha", "beta", "vessup", ".cache"]) { + await mkdir(join(home, directory), { recursive: true }); + } + await writeFile(join(home, "notes.txt"), "not a directory\n"); + + const visible = listDirectorySuggestions("", { homeDir: home }); + expect(visible).toEqual(["~/alpha", "~/beta", "~/vessup"]); + + expect(listDirectorySuggestions("~", { homeDir: home })).toEqual(visible); + expect(listDirectorySuggestions("~/", { homeDir: home })).toEqual(visible); + + expect(listDirectorySuggestions("~/ve", { homeDir: home })).toEqual([ + "~/vessup", + ]); + // Without a trailing slash the last segment stays a prefix filter, so a + // complete directory name suggests itself rather than its children. + expect(listDirectorySuggestions("~/vessup", { homeDir: home })).toEqual([ + "~/vessup", + ]); + expect(listDirectorySuggestions("~/vessup/", { homeDir: home })).toEqual([]); + expect(listDirectorySuggestions("~/miss", { homeDir: home })).toEqual([]); + + // Hidden directories only appear when the prefix itself is hidden. + expect(listDirectorySuggestions("~/.ca", { homeDir: home })).toEqual([ + "~/.cache", + ]); +}); + +test("directory suggestions stop inside a Git repository", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-repo-")); + const home = join(tempDir, "home"); + await mkdir(join(home, "plain"), { recursive: true }); + const repository = join(home, "project"); + await Bun.$`git init -q -b main ${repository}`; + + // The repository itself still completes from its parent directory... + expect(listDirectorySuggestions("~/pro", { homeDir: home })).toEqual([ + "~/project", + ]); + // ...but nothing beneath it is suggested. + expect(listDirectorySuggestions("~/project/", { homeDir: home })).toEqual([]); + expect(listDirectorySuggestions("~/project/s", { homeDir: home })).toEqual( + [], + ); + expect(listDirectorySuggestions("~/plain", { homeDir: home })).toEqual([ + "~/plain", + ]); +}); + +test("directory suggestions keep absolute form outside home", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-")); + const home = join(tempDir, "home"); + const elsewhere = join(tempDir, "elsewhere"); + await mkdir(join(home, "inside"), { recursive: true }); + await mkdir(join(elsewhere, "project"), { recursive: true }); + + expect( + listDirectorySuggestions(`${elsewhere}/pro`, { + baseDir: tempDir, + homeDir: home, + }), + ).toEqual([join(elsewhere, "project")]); + expect( + listDirectorySuggestions(`${elsewhere}/`, { + baseDir: tempDir, + homeDir: home, + }), + ).toEqual([join(elsewhere, "project")]); +}); + +test("repository branches list local and remote refs", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-branches-")); + const repository = join(tempDir, "project"); + await Bun.$`git init -q -b main ${repository}`; + await Bun.$`git -C ${repository} config user.name test`; + await Bun.$`git -C ${repository} config user.email test@example.com`; + await Bun.write(join(repository, "README.md"), "test\n"); + await Bun.$`git -C ${repository} add README.md`; + await Bun.$`git -C ${repository} commit -qm initial`; + await Bun.$`git -C ${repository} branch owner/topic`; + await Bun.$`git -C ${repository} update-ref refs/remotes/origin/feature-x refs/heads/main`; + await Bun.$`git -C ${repository} symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main`; + + const branches = listRepositoryBranches(repository); + expect(branches.local).toEqual(["main", "owner/topic"]); + expect(branches.remote).toEqual(["origin/feature-x"]); +}); + +test("branch listing fails outside a Git repository", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-branches-")); + expect(() => listRepositoryBranches(tempDir)).toThrow(); +}); + +test("web server serves directory and branch suggestions", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-http-")); + const home = join(tempDir, "home"); + await mkdir(join(home, "vessup"), { recursive: true }); + const repository = join(tempDir, "project"); + await Bun.$`git init -q -b main ${repository}`; + await Bun.$`git -C ${repository} config user.name test`; + await Bun.$`git -C ${repository} config user.email test@example.com`; + await Bun.write(join(repository, "README.md"), "test\n"); + await Bun.$`git -C ${repository} add README.md`; + await Bun.$`git -C ${repository} commit -qm initial`; + await Bun.$`git -C ${repository} branch feature`; + + const statePath = join(tempDir, "server.json"); + child = Bun.spawn({ + cmd: ["bun", "run", "web/server/index.ts"], + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + PI_WEB_PORT: "0", + PI_WEB_ROOT: process.cwd(), + PI_WEB_STATE_FILE: statePath, + PI_CODING_AGENT_DIR: join(tempDir, "pi-agent"), + }, + stdout: "ignore", + stderr: "ignore", + }); + const deadline = Date.now() + 8_000; + let state: ServerStateFile | undefined; + while (Date.now() < deadline) { + try { + state = JSON.parse(await Bun.file(statePath).text()) as ServerStateFile; + break; + } catch { + await Bun.sleep(50); + } + } + if (!state) throw new Error("web server state file was not created"); + + const directoryResponse = await fetch( + `http://127.0.0.1:${state.port}/api/directories?q=${encodeURIComponent(`${home}/`)}`, + ); + expect(directoryResponse.ok).toBe(true); + const directories = (await directoryResponse.json()) as { + directories: string[]; + }; + // Paths under the server's home render with the ~ shorthand. Server startup + // can create ~/Library inside the fake home, so assert containment. + expect(directories.directories).toContain("~/vessup"); + + const prefixResponse = await fetch( + `http://127.0.0.1:${state.port}/api/directories?q=${encodeURIComponent(`${home}/ves`)}`, + ); + expect( + ((await prefixResponse.json()) as { directories: string[] }).directories, + ).toEqual(["~/vessup"]); + + const branchResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches?cwd=${encodeURIComponent(repository)}`, + ); + expect(branchResponse.ok).toBe(true); + const branches = (await branchResponse.json()) as { + local: string[]; + remote: string[]; + }; + expect(branches.local).toEqual(["feature", "main"]); + expect(branches.remote).toEqual([]); + + const missingResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches`, + ); + expect(missingResponse.status).toBe(400); + + const plainDirectoryResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches?cwd=${encodeURIComponent(home)}`, + ); + expect(plainDirectoryResponse.ok).toBe(true); + expect( + ((await plainDirectoryResponse.json()) as { local: string[] }).local, + ).toEqual([]); +}); diff --git a/web/client/api.ts b/web/client/api.ts index d4180f6..86cd07b 100644 --- a/web/client/api.ts +++ b/web/client/api.ts @@ -134,6 +134,37 @@ export async function listSessions(): Promise { return Array.isArray(data) ? data : data.sessions; } +export type BranchSuggestions = { local: string[]; remote: string[] }; + +/** Autocomplete directories under home; returns [] when the daemon is older or the path is unreadable. */ +export async function listDirectorySuggestions( + query: string, +): Promise { + try { + const data = await fetchJson<{ directories: string[] }>( + `/api/directories?q=${encodeURIComponent(query)}`, + { cache: "no-store" }, + ); + return data.directories; + } catch { + return []; + } +} + +/** Autocomplete local and remote branches for a repository; returns empty lists on failure. */ +export async function listBranchSuggestions( + cwd: string, +): Promise { + try { + return await fetchJson( + `/api/branches?cwd=${encodeURIComponent(cwd)}`, + { cache: "no-store" }, + ); + } catch { + return { local: [], remote: [] }; + } +} + export async function createSession( request: CreateSessionRequest, ): Promise { diff --git a/web/server/index.ts b/web/server/index.ts index b3019d9..cc2ff9f 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -121,11 +121,14 @@ import { } from "./shutdown-policy.js"; import { SlashCommandService } from "./slash-command-service.js"; import { createStaticAssetResponder } from "./static-assets.js"; +import { listDirectorySuggestions } from "./suggestions.js"; import { createWebWorktree, hasOtherSessionInWorktree, inheritManagedBranchOwnership, + listRepositoryBranches, managedWorktreeFromEntries, + type RepositoryBranches, removeManagedWorktree, removeManagedWorktreeAsync, WORKTREE_SESSION_ENTRY, @@ -1400,7 +1403,9 @@ async function recoverStagedSourceSessionDeletions(): Promise { renameSync(staged.tombstone, staged.source); continue; } - const sourceQueue = persistedQueues.get(sourceId); + // replacement is only defined when sourceId is, but the find callback + // above loses that narrowing across the closure boundary. + const sourceQueue = sourceId ? persistedQueues.get(sourceId) : undefined; const replacementQueue = persistedQueues.get(replacement.session.id); if (sourceQueue?.length) { const ids = new Set(); @@ -3149,10 +3154,47 @@ async function handleApi(request: Request): Promise { commandHello: true, queueSteer: true, worktreeRefs: true, + branchSuggestions: true, }, tailscale: tailscaleStatus, }); } + if (request.method === "GET" && url.pathname === "/api/directories") { + return jsonResponse({ + directories: listDirectorySuggestions(url.searchParams.get("q") ?? "", { + baseDir: rootDir, + }), + }); + } + if (request.method === "GET" && url.pathname === "/api/branches") { + const requestedCwd = (url.searchParams.get("cwd") ?? "").trim(); + if (!requestedCwd) return badRequest("Missing cwd"); + let cwd: string; + try { + cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); + } catch (error) { + return badRequest(error instanceof Error ? error.message : String(error)); + } + try { + if (!statSync(cwd).isDirectory()) + return badRequest(`cwd is not a directory: ${cwd}`); + } catch { + return badRequest(`cwd does not exist: ${cwd}`); + } + let branches: RepositoryBranches; + try { + branches = listRepositoryBranches(cwd); + } catch (error) { + // Not a Git repository (or Git is unavailable): the browser just shows + // no suggestions instead of surfacing an error mid-typing. + return jsonResponse({ + local: [], + remote: [], + error: error instanceof Error ? error.message : String(error), + }); + } + return jsonResponse(branches); + } if (request.method === "POST" && url.pathname === "/api/tailscale") { const body = (await request.json().catch(() => undefined)) as | { diff --git a/web/server/suggestions.ts b/web/server/suggestions.ts new file mode 100644 index 0000000..16d985b --- /dev/null +++ b/web/server/suggestions.ts @@ -0,0 +1,98 @@ +import { type Dirent, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { resolveWebCwd } from "./paths.js"; +import { resolveSessionProject } from "./projects.js"; + +const MAX_DIRECTORY_SUGGESTIONS = 20; + +export type DirectorySuggestionOptions = { + baseDir?: string; + homeDir?: string; +}; + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** Format a directory for the browser using the ~ shorthand whenever possible. */ +function displayPath(path: string, home: string): string { + const relation = relative(home, path); + if (relation === "") return "~"; + if (!relation.startsWith("..") && !relation.startsWith("/")) { + return `~/${relation.split("\\").join("/")}`; + } + return path; +} + +/** + * Suggest directories that continue the typed path. + * + * The last path segment is treated as a prefix filter over the parent + * directory's entries, so "~/vess" suggests ~/vessup rather than listing it. + * A trailing slash lists the named directory's children so completions can + * drill down level by level. Suggestions stop once the listed directory is + * inside a Git repository: the field selects a repository, so paths beneath + * one are noise. Missing or unreadable directories return no suggestions + * instead of failing. + */ +export function listDirectorySuggestions( + query: string, + options: DirectorySuggestionOptions = {}, +): string[] { + const home = resolve(options.homeDir ?? homedir()); + const trimmed = query.trim(); + let parent: string; + let prefix: string; + if (!trimmed || trimmed === "~") { + parent = home; + prefix = ""; + } else { + let resolved: string; + try { + resolved = resolveWebCwd(trimmed, { + baseDir: options.baseDir, + homeDir: options.homeDir, + }); + } catch { + // Unsupported shorthand such as ~user cannot be autocompleted. + return []; + } + if (trimmed.endsWith("/")) { + parent = resolved; + prefix = ""; + } else { + parent = dirname(resolved); + prefix = basename(resolved); + } + } + if (!isDirectory(parent)) return []; + if (resolveSessionProject(parent).id.startsWith("git:")) return []; + const showHidden = prefix.startsWith("."); + let entries: Dirent[] = []; + try { + entries = readdirSync(parent, { withFileTypes: true }); + } catch { + return []; + } + const lowered = prefix.toLowerCase(); + const matches = entries + .filter((entry) => { + if (!showHidden && entry.name.startsWith(".")) return false; + if (!entry.name.toLowerCase().startsWith(lowered)) return false; + if (entry.isDirectory()) return true; + // Follow symlinks to directories so linked checkouts autocomplete. + if (entry.name.startsWith(".")) return false; + return isDirectory(join(parent, entry.name)); + }) + .map((entry) => entry.name) + .sort((left, right) => + left.toLowerCase().localeCompare(right.toLowerCase()), + ) + .slice(0, MAX_DIRECTORY_SUGGESTIONS); + return matches.map((name) => displayPath(join(parent, name), home)); +} diff --git a/web/server/worktrees.ts b/web/server/worktrees.ts index 6d2458f..e2e800a 100644 --- a/web/server/worktrees.ts +++ b/web/server/worktrees.ts @@ -200,6 +200,36 @@ async function ensureSupportedGitAsync(): Promise { } export type WorktreeRef = { kind: "branch" | "detached"; value: string }; + +export type RepositoryBranches = { + /** Short local branch names, e.g. "main" or "owner/topic". */ + local: string[]; + /** Remote-tracking branches including their remote prefix, e.g. "origin/main". */ + remote: string[]; +}; + +/** List local and remote-tracking branches for autocomplete in the browser. */ +export function listRepositoryBranches(cwd: string): RepositoryBranches { + const output = gitOutput(cwd, [ + "for-each-ref", + "--format=%(refname)", + "refs/heads", + "refs/remotes", + ]); + const local: string[] = []; + const remote: string[] = []; + for (const ref of output.split("\n")) { + if (ref.startsWith("refs/heads/")) + local.push(ref.slice("refs/heads/".length)); + else if (ref.startsWith("refs/remotes/")) { + const short = ref.slice("refs/remotes/".length); + // The symbolic remote HEAD is not a branch anyone can check out. + if (!short.endsWith("/HEAD")) remote.push(short); + } + } + return { local, remote }; +} + export type ExistingWebWorktree = { path: string; repoRoot: string; From a24de0e1b02532a38f1b08cf6242c84f33871770 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:38:24 -0400 Subject: [PATCH 02/12] Rework the new session dialog fields and autocomplete UX Reorders the dialog so the worktree branch comes before the worktree name, prefills the repository with "~/" instead of the current session's absolute path, and derives both the worktree name and the session name automatically from the upstream field (with manual overrides). New AutocompleteInput helper Plain text input plus an anchored popover list of suggestions. Filters client-side so substring matches ("feat" -> "origin/feature") work uniformly across browsers (native filters inconsistently). Keyboard: ArrowDown/ArrowUp navigate, Enter or Tab accepts the highlighted suggestion, Escape dismisses the menu (stopPropagation prevents the keystroke from bubbling to DialogContent's window Escape listener). Tab is the new opt-in to accept the highlighted option while the menu is open; a second Tab advances normally. acceptSuffix prop The repository field passes "/", so accepting a directory suggestion appends a trailing slash and the menu stays open for further drilling. Repeated Tab presses therefore walk down the path segment by segment until the suggestions empty out (because the chosen path is a Git repository). The branch field passes no suffix. Other wiring Dialog opens with repository = "~/", branch/name empty, all touched flags false. The worktree branch's datalist shows local branches then remote branches; when the typed branch exactly matches a known remote, the request sends worktreeBranch as the local name (remote prefix stripped) and worktreeStartPoint as the remote ref, which configures upstream tracking. If the derived local branch already exists locally no start point is sent so the server reuses it. Removed Recent-repositories wiring in the modal (the file and test stay as a general utility; the previous instruction to default to recents is replaced by "~/" plus live directory autocomplete). baseSession prop on NewSessionDialog (unused after the prefill change). worktreeStartPoint UI field; the protocol field is preserved so the remote-branch -> start-point derivation above still works. AnchoredPopover gains placement ("auto" | "below") and matchAnchorWidth for the next commit's mobile work, and an Escapable helper is folded in. --- web/client/app.tsx | 384 +++++++++++++++------ web/client/components/anchored-popover.tsx | 112 +++++- 2 files changed, 375 insertions(+), 121 deletions(-) diff --git a/web/client/app.tsx b/web/client/app.tsx index 037add0..d3ec9b0 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -58,6 +58,7 @@ import { } from "../protocol"; import { includeWebReloadCommand, isWebReloadCommand } from "../reload-command"; import { + type BranchSuggestions, cloneSessionViaCommand, compactSessionViaCommand, createSession, @@ -65,6 +66,8 @@ import { type ForkMessageItem, forkSessionViaCommand, getForkMessages, + listBranchSuggestions, + listDirectorySuggestions, listSessions, openSessionSocket, renameSessionViaCommand, @@ -90,10 +93,6 @@ import { localCommandEntryId, preserveLocalCommandEntries, } from "./local-command"; -import { - type RecentRepository, - recentRepositories, -} from "./recent-repositories"; import { mergeSemanticHistory, preserveSemanticEntryKeys, @@ -391,79 +390,282 @@ function projectGroups(sessions: WebSession[]): ProjectSessionGroup[] { return Array.from(groups.values()); } +type Suggestion = { value: string; label?: string }; + +function filterSuggestions( + suggestions: readonly Suggestion[], + query: string, +): Suggestion[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return [...suggestions]; + return suggestions.filter( + (suggestion) => + suggestion.value.toLowerCase().includes(trimmed) || + (suggestion.value.split(/[\\/]/).pop() ?? "") + .toLowerCase() + .includes(trimmed), + ); +} + +/** Text input with an anchored autocomplete list driven by caller-provided suggestions. */ +function AutocompleteInput({ + id, + label, + value, + onChange, + suggestions, + placeholder, + hint, + acceptSuffix, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + suggestions: readonly Suggestion[]; + placeholder?: string; + hint?: string; + /** Appended to accepted values, e.g. "/" for directories so the menu keeps drilling down. */ + acceptSuffix?: string; +}) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(0); + const filtered = React.useMemo( + () => filterSuggestions(suggestions, value), + [suggestions, value], + ); + React.useEffect(() => { + setActiveIndex((index) => Math.min(index, filtered.length - 1)); + }, [filtered.length]); + const popoverOpen = open && filtered.length > 0; + const accept = (suggestion: Suggestion) => { + const completed = suggestion.value.endsWith(acceptSuffix ?? "") + ? suggestion.value + : suggestion.value + (acceptSuffix ?? ""); + onChange(completed); + // With a completion suffix the next segment's suggestions load next; keep + // the menu up so repeated Tab presses drill down the path. + if (!acceptSuffix) setOpen(false); + inputRef.current?.focus(); + }; + return ( +
+ + { + onChange(event.target.value); + setOpen(true); + setActiveIndex(0); + }} + onFocus={() => setOpen(true)} + onKeyDown={(event) => { + if (!popoverOpen) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => Math.min(index + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter" || event.key === "Tab") { + // Tab accepts the highlighted option instead of moving focus; the + // menu closes, so a second Tab advances to the next field as usual. + const suggestion = filtered[activeIndex]; + if (suggestion) { + event.preventDefault(); + accept(suggestion); + } + } else if (event.key === "Escape") { + // Consume Escape while the menu is up so it dismisses the menu + // instead of bubbling to the dialog and closing the whole modal. + event.stopPropagation(); + setOpen(false); + } + }} + /> + +
    + {filtered.map((suggestion, index) => ( +
  • + +
  • + ))} +
+
+ {hint ?

{hint}

: null} +
+ ); +} + +/** Turn a branch name such as owner/topic into one safe worktree path segment. */ +function worktreeNameFromBranch(branch: string): string { + return branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); +} + function NewSessionDialog({ open, - baseSession, - repositories, onOpenChange, onCreate, }: { open: boolean; - baseSession: WebSession | null; - repositories: RecentRepository[]; onOpenChange: (open: boolean) => void; onCreate: (value: CreateSessionRequest) => Promise; }) { const [repository, setRepository] = React.useState(""); - const [name, setName] = React.useState(""); + const [repositorySuggestions, setRepositorySuggestions] = React.useState< + Suggestion[] + >([]); + const [branch, setBranch] = React.useState(""); + const [branchSuggestions, setBranchSuggestions] = + React.useState({ local: [], remote: [] }); const [worktreeName, setWorktreeName] = React.useState(""); - const [worktreeBranch, setWorktreeBranch] = React.useState(""); - const [worktreeStartPoint, setWorktreeStartPoint] = React.useState(""); + const [worktreeNameEdited, setWorktreeNameEdited] = React.useState(false); + const [name, setName] = React.useState(""); + const [nameEdited, setNameEdited] = React.useState(false); const [busy, setBusy] = React.useState(false); const [createError, setCreateError] = React.useState(null); const repositoryListId = React.useId(); React.useEffect(() => { if (!open) return; - setRepository(baseSession?.repositoryRoot ?? baseSession?.cwd ?? ""); - setName(""); + setRepository("~/"); + setRepositorySuggestions([]); + setBranch(""); + setBranchSuggestions({ local: [], remote: [] }); setWorktreeName(""); - setWorktreeBranch(""); - setWorktreeStartPoint(""); + setWorktreeNameEdited(false); + setName(""); + setNameEdited(false); setBusy(false); setCreateError(null); - }, [baseSession?.cwd, baseSession?.repositoryRoot, open]); + }, [open]); + React.useEffect(() => { + if (!open) return; + let cancelled = false; + const handle = window.setTimeout(() => { + void listDirectorySuggestions(repository).then((directories) => { + if (!cancelled) + setRepositorySuggestions( + directories.map((directory) => ({ value: directory })), + ); + }); + }, 150); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [open, repository]); + const repositoryQuery = repository.trim(); + React.useEffect(() => { + if (!open || !repositoryQuery) return; + let cancelled = false; + const handle = window.setTimeout(() => { + void listBranchSuggestions(repositoryQuery).then((branches) => { + if (!cancelled) setBranchSuggestions(branches); + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [open, repositoryQuery]); + const branchSuggestionsForInput = React.useMemo( + () => [ + ...branchSuggestions.local.map((value) => ({ value, label: "local" })), + ...branchSuggestions.remote.map((value) => ({ value, label: "remote" })), + ], + [branchSuggestions], + ); + const trimmedBranch = branch.trim(); + // A branch that matches a remote-tracking ref selects it: the local branch + // is derived by stripping the remote prefix, and the remote ref becomes the + // start point so the new branch tracks it. + const remoteBranch = branchSuggestions.remote.find( + (candidate) => candidate === trimmedBranch, + ); + const localBranch = remoteBranch + ? remoteBranch.slice(remoteBranch.indexOf("/") + 1) + : trimmedBranch; + const startPoint = + remoteBranch && !branchSuggestions.local.includes(localBranch) + ? remoteBranch + : undefined; + React.useEffect(() => { + if (worktreeNameEdited) return; + setWorktreeName(worktreeNameFromBranch(localBranch)); + }, [localBranch, worktreeNameEdited]); + React.useEffect(() => { + if (nameEdited) return; + setName(worktreeName.trim()); + }, [nameEdited, worktreeName]); return ( New session - Choose a repository or directory. Add a worktree name to create or - reuse a linked checkout. + Choose a repository directory. Pick a branch to open it in a linked + worktree. -
- - { - setRepository(event.target.value); - setCreateError(null); - }} - placeholder="~/path/to/repository" - role="combobox" - aria-autocomplete="list" - /> - - {repositories.map((item) => ( - - ))} - -

- Recently used repositories appear as you type. -

-
+ { + setRepository(value); + setCreateError(null); + }} + suggestions={repositorySuggestions} + placeholder="~/path/to/repository" + acceptSuffix="/" + hint="Suggestions list directories under ~ and stop once a Git repository is selected." + /> + { + setBranch(value); + setCreateError(null); + }} + suggestions={branchSuggestionsForInput} + placeholder="Optional, e.g. main or origin/owner/topic" + hint="Local and remote branches of the repository. Choosing a remote branch creates a local branch that tracks it." + />
- {worktreeName.trim() && ( - <> -
- - { - setWorktreeBranch(event.target.value); - setCreateError(null); - }} - placeholder={`Defaults to ${worktreeName.trim()}`} - /> -
-
- - { - setWorktreeStartPoint(event.target.value); - setCreateError(null); - }} - placeholder="Optional, e.g. origin/owner/topic" - /> -

- Used only when creating a missing local branch. A - remote-tracking ref configures its upstream. -

-
- - )}
{createError && (

{ event.preventDefault(); event.stopPropagation(); @@ -2066,10 +2232,6 @@ export function App() { orderedSessions.filter((session) => sessionMatches(session, filterQuery)), [filterQuery, orderedSessions], ); - const repositorySuggestions = React.useMemo( - () => recentRepositories(sessions), - [sessions], - ); const sendSemanticPrompt = React.useCallback( async ( @@ -2506,8 +2668,6 @@ export function App() {

diff --git a/web/client/components/anchored-popover.tsx b/web/client/components/anchored-popover.tsx index e51edb2..8804803 100644 --- a/web/client/components/anchored-popover.tsx +++ b/web/client/components/anchored-popover.tsx @@ -10,8 +10,23 @@ type AnchoredPopoverProps = { children: React.ReactNode; className?: string; align?: "start" | "end"; + /** "auto" flips above the anchor when there is no room below; "below" stays under it. */ + placement?: "auto" | "below"; + /** Size the panel to the anchor's width instead of its content. */ + matchAnchorWidth?: boolean; }; +const MIN_PANEL_HEIGHT = 96; +const POPUP_GAP = 6; +const POPUP_MARGIN = 8; + +function panelMaxHeightCap(panel: HTMLElement | null): number | undefined { + if (!panel) return undefined; + const css = window.getComputedStyle(panel).maxHeight; + const parsed = css ? Number.parseFloat(css) : Number.NaN; + return Number.isNaN(parsed) ? undefined : parsed; +} + export function AnchoredPopover({ open, onOpenChange, @@ -19,9 +34,21 @@ export function AnchoredPopover({ children, className, align = "end", + placement = "auto", + matchAnchorWidth = false, }: AnchoredPopoverProps) { const panelRef = React.useRef(null); const [position, setPosition] = React.useState({ left: 8, top: 8 }); + const [anchorWidth, setAnchorWidth] = React.useState( + undefined, + ); + const [maxHeight, setMaxHeight] = React.useState( + undefined, + ); + // Computed maxHeight reflects our inline override once applied, so remember + // the stylesheet cap (e.g. max-h-64) separately to avoid ratcheting down. + const classMaxHeightRef = React.useRef(undefined); + const appliedMaxHeightRef = React.useRef(undefined); React.useLayoutEffect(() => { if (!open) return; @@ -31,17 +58,76 @@ export function AnchoredPopover({ const panel = panelRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); + setAnchorWidth((current) => + current === rect.width ? current : rect.width, + ); const viewport = window.visualViewport; + const viewportBox = { + offsetLeft: viewport?.offsetLeft ?? 0, + offsetTop: viewport?.offsetTop ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; + const panelWidth = matchAnchorWidth + ? rect.width + : (panel?.offsetWidth ?? 240); + if (placement === "below") { + // Prefer the conventional position under the field, but with the + // mobile keyboard open there may be no room: cap the panel height to + // the available space and flip above rather than covering the input. + const viewportBottom = viewportBox.offsetTop + viewportBox.height; + const roomBelow = + viewportBottom - POPUP_MARGIN - (rect.bottom + POPUP_GAP); + const roomAbove = + rect.top - POPUP_GAP - (viewportBox.offsetTop + POPUP_MARGIN); + const below = roomBelow >= MIN_PANEL_HEIGHT || roomBelow >= roomAbove; + const room = below ? roomBelow : roomAbove; + const computedCap = panelMaxHeightCap(panel); + if ( + computedCap !== undefined && + computedCap !== appliedMaxHeightRef.current + ) { + classMaxHeightRef.current = computedCap; + } + const cssCap = classMaxHeightRef.current; + const capped = Math.max( + Math.min(MIN_PANEL_HEIGHT, room), + Math.min(room, cssCap ?? Number.POSITIVE_INFINITY), + ); + appliedMaxHeightRef.current = capped; + setMaxHeight((current) => (current === capped ? current : capped)); + const desiredLeft = + align === "start" ? rect.left : rect.right - panelWidth; + const next = { + left: Math.max( + viewportBox.offsetLeft + POPUP_MARGIN, + Math.min( + viewportBox.offsetLeft + + viewportBox.width - + panelWidth - + POPUP_MARGIN, + desiredLeft, + ), + ), + top: below + ? rect.bottom + POPUP_GAP + : Math.max( + viewportBox.offsetTop + POPUP_MARGIN, + rect.top - POPUP_GAP - capped, + ), + }; + setPosition((current) => + current.left === next.left && current.top === next.top + ? current + : next, + ); + return; + } const next = anchoredPopoverPosition({ anchor: rect, - panelWidth: panel?.offsetWidth ?? 240, + panelWidth, panelHeight: panel?.offsetHeight ?? 200, - viewport: { - offsetLeft: viewport?.offsetLeft ?? 0, - offsetTop: viewport?.offsetTop ?? 0, - width: viewport?.width ?? window.innerWidth, - height: viewport?.height ?? window.innerHeight, - }, + viewport: viewportBox, align, }); setPosition((current) => @@ -74,7 +160,7 @@ export function AnchoredPopover({ viewport?.removeEventListener("resize", scheduleUpdate); viewport?.removeEventListener("scroll", scheduleUpdate); }; - }, [align, anchorRef, open]); + }, [align, anchorRef, matchAnchorWidth, open, placement]); React.useEffect(() => { if (!open) return; @@ -106,7 +192,15 @@ export function AnchoredPopover({ "fixed z-[70] rounded-lg border border-zinc-700 bg-zinc-950 p-1 shadow-2xl shadow-black/60", className, )} - style={position} + style={{ + ...position, + ...(matchAnchorWidth && anchorWidth !== undefined + ? { width: anchorWidth } + : {}), + ...(placement === "below" && maxHeight !== undefined + ? { maxHeight } + : {}), + }} > {children}
, From 2f82a561394afaa000160ec82e08cc83d44e3e1b Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:38:31 -0400 Subject: [PATCH 03/12] Fix mobile zoom and autocomplete menu covering the input Two distinct bugs that bit together on a phone with the on-screen keyboard up. iOS Safari auto-zooms any focused input with font-size < 16px. The shared Input component used text-sm (14px), so tapping a field jumped to a zoomed-in view. Bumped the base font to 16px and only shrink back to 14px at the sm: breakpoint, where the on-screen keyboard doesn't exist. web/client/components/ui/input.tsx h-10 text-base sm:h-9 sm:text-sm. Desktop appearance is unchanged. The "place the menu under the field" path clamped within the layout viewport, so when the visual viewport shrank (keyboard open) the clamp pushed the panel *up over* the input. The popover now measures available room inside the visual viewport and: - goes below when there's >= 96px of room below, or when below is the larger side, - otherwise flips above the field, ending the gap above the input, - caps the panel's maxHeight (inline style) to the available room so the list scrolls inside the gap rather than spilling. web/client/components/anchored-popover.tsx placement === "below" branch in update(): computes room below and room above against visualViewport, picks a side, and sets an inline maxHeight that respects the caller's CSS cap (read via getComputedStyle; tracked in a ref so the cap doesn't ratchet down when getComputedStyle returns our own inline value on subsequent frames). anchoredPopoverPosition's placement option was added by an earlier draft and is no longer called; reverted to the original helper. --- web/client/components/ui/input.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/client/components/ui/input.tsx b/web/client/components/ui/input.tsx index 2e47442..afbdd16 100644 --- a/web/client/components/ui/input.tsx +++ b/web/client/components/ui/input.tsx @@ -9,7 +9,8 @@ export const Input = React.forwardRef< Date: Sun, 16 Aug 2026 14:38:38 -0400 Subject: [PATCH 04/12] Tighten types around ! removals and dirent annotations Running the full bun run check (which covers all three tsconfigs) surfaced four type errors that had been hiding behind ! assertions removed by the earlier lint-fix commit, plus a directory-entry type annotation the linter had re-shaped into a property. web/server/index.ts sourceId is narrowed to a truthy string only inside the .find() callback; after the early-continue the variable is still typed as string | undefined because the narrowing doesn't survive the closure boundary. Use sourceId ? persistedQueues.get(sourceId) : undefined so the lookup typechecks. web/server/semantic-session.tsx Diff piece booleans (added / removed) are optional on the diff shape, so the conditional that derives highlighted / hidden produced boolean | undefined values. Coerce with ?? false. usage?.cacheRead and usage?.cacheWrite are number | undefined; the formatTokenCount calls now default to 0 the same way the gating conditions do. web/client/app.tsx The sortable session card put role="button" tabIndex={0} before the sortable.attributes / sortable.listeners spreads, which TS flagged as overwriting the explicit props. Reordered so the explicit role and tabIndex come after the spreads; overlay rows (no dnd attributes) still get them. web/server/suggestions.ts The Dirent entries were declared as { isDirectory: boolean } but read with the method shape via a cast, which the linter then refactored into a property assignment that no longer typechecks. Use the real Dirent type from node:fs for both the declaration and the readdirSync return value. --- web/client/semantic-session.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 6bd7d0a..0c36eed 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -661,12 +661,13 @@ function TokenDetails({ session }: { session: WebSession }) { {(usage?.cacheRead ?? 0) > 0 && ( - Cache read {formatTokenCount(usage?.cacheRead)} + Cache read {formatTokenCount(usage?.cacheRead ?? 0)} )} {(usage?.cacheWrite ?? 0) > 0 && ( - Cache write {formatTokenCount(usage?.cacheWrite)} + Cache write{" "} + {formatTokenCount(usage?.cacheWrite ?? 0)} )} @@ -1229,15 +1230,15 @@ function ChangedLine({ row, language }: { row: DiffRow; language?: string }) { {pieces.map((piece, index) => { const highlighted = row.kind === "added" - ? piece.added + ? (piece.added ?? false) : row.kind === "removed" - ? piece.removed + ? (piece.removed ?? false) : false; const hidden = row.kind === "added" - ? piece.removed + ? (piece.removed ?? false) : row.kind === "removed" - ? piece.added + ? (piece.added ?? false) : false; return hidden ? null : ( Date: Sun, 16 Aug 2026 14:44:43 -0400 Subject: [PATCH 05/12] Always advertise every available model in the session picker The session model picker only ever showed one entry because get_session_options returned the intersection of scopedModels and the configured registry: when the session was scoped to a single model (via --model on the CLI or settings), the picker reflected that scope and listed only the active model. The user had no way to discover that other models existed or to pick one. Always return the full getAvailable() list to the browser. The picker then shows every model with configured credentials, so the user can see what they could choose. set_model still enforces scope via isScopedModelAllowed; a user picking an out-of-scope model gets the existing "Model is outside this session's configured scope" error and learns that the scope is the constraint, rather than seeing a picker that appears to offer only the current model with no explanation. This also makes the synthetic single-model fallback in the picker (semantic-session.tsx) effectively unreachable in practice: it only kicks in when the agent has zero available models. --- extensions/web-sessions.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 6d8c576..96450e0 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -755,11 +755,13 @@ async function executeAgentCommand( respond(state, requestId, true); return; case "get_session_options": { - const models = ( - state.ctx.scopedModels.length > 0 - ? state.ctx.scopedModels.map((item) => item.model) - : state.ctx.modelRegistry.getAvailable() - ).map((model) => ({ + // Always advertise every model with configured credentials so the picker + // reflects what the user could realistically choose. Scope is still + // enforced by set_model's isScopedModelAllowed check; a user picking + // an out-of-scope model gets a clear error and learns the scope is + // the constraint, rather than seeing only the current model and not + // knowing alternatives exist. + const models = state.ctx.modelRegistry.getAvailable().map((model) => ({ provider: model.provider, id: model.id, name: model.name, From 68e7a7c7dda4b850540c048409c8f288a3cc828f Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:49:03 -0400 Subject: [PATCH 06/12] Revert "Always advertise every available model in the session picker" This reverts commit 198eb891e08474c0f3e14fab297c4dbc5311c6c0. --- extensions/web-sessions.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 96450e0..6d8c576 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -755,13 +755,11 @@ async function executeAgentCommand( respond(state, requestId, true); return; case "get_session_options": { - // Always advertise every model with configured credentials so the picker - // reflects what the user could realistically choose. Scope is still - // enforced by set_model's isScopedModelAllowed check; a user picking - // an out-of-scope model gets a clear error and learns the scope is - // the constraint, rather than seeing only the current model and not - // knowing alternatives exist. - const models = state.ctx.modelRegistry.getAvailable().map((model) => ({ + const models = ( + state.ctx.scopedModels.length > 0 + ? state.ctx.scopedModels.map((item) => item.model) + : state.ctx.modelRegistry.getAvailable() + ).map((model) => ({ provider: model.provider, id: model.id, name: model.name, From 6b10b206117483ff4a0a901f6808e156ae26b05a Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:05:42 -0400 Subject: [PATCH 07/12] Carry the agent's --models scope through the wire protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the web daemon's model picker called the RPC's get_available_models which always returned the full configured registry, ignoring any --models scope the user set on the TUI. The fix lets the agent forward its ExtensionContext.scopedModels to the daemon on hello (and on a dedicated agent.scope frame for live updates) so the daemon can apply the same scope filter the agent's get_session_options handler already used. web/protocol.ts New server-internal WebScopedModel type — never serialized to the browser. AgentHelloMessage gains optional scopedModels. New AgentScopeMessage carries {sessionId, scopedModels} for live updates if scope ever changes mid-session via setScopedModels. web/server/server-types.ts SessionRecord gains optional scopedModels, populated from the agent's hello and updated by the new scope message. This commit is the protocol + storage layer; the agent forwards scope on hello and the daemon filters on get_session_options in the next two commits. --- web/protocol.ts | 16 ++++++++++++++++ web/server/server-types.ts | 2 ++ 2 files changed, 18 insertions(+) diff --git a/web/protocol.ts b/web/protocol.ts index 472b66b..8eb1c26 100644 --- a/web/protocol.ts +++ b/web/protocol.ts @@ -255,6 +255,14 @@ export type AgentHelloMessage = { session: WebSession; entries: unknown[]; historyMode?: "replace"; + /** Models the agent's session is scoped to. Empty array means no scope. */ + scopedModels?: WebScopedModel[]; +}; + +export type AgentScopeMessage = { + type: "agent.scope"; + sessionId: string; + scopedModels: WebScopedModel[]; }; export type AgentSessionReplacedMessage = { @@ -303,6 +311,7 @@ export type AgentToServerMessage = | AgentHistoryMessage | AgentUpdateMessage | AgentSubagentsMessage + | AgentScopeMessage | AgentResponseMessage; export type SemanticImage = { @@ -318,6 +327,13 @@ export type WebModelOption = { reasoning: boolean; thinkingLevels?: string[]; }; + +/** A model the agent's session is scoped to via --models. Forwarded by the agent on hello (and on scope changes) so the daemon can filter the model picker the same way the TUI does. Server-internal; never reaches the browser. */ +export type WebScopedModel = { + provider: string; + id: string; + thinkingLevel?: string; +}; export type WebSlashCommand = { name: string; description?: string; diff --git a/web/server/server-types.ts b/web/server/server-types.ts index 1019947..7ed32d0 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -81,4 +81,6 @@ export type SessionRecord = { pendingWorktreeSourceDeletion?: { sessionId: string; sessionFile: string }; catalogReady?: boolean; gitMetadataGeneration?: number; + /** Models the agent's session is scoped to via --models. Server-internal. */ + scopedModels?: import("../protocol.js").WebScopedModel[]; }; From 11e0ede44de36d6d99f51a182f6715a20ba21d94 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:11:38 -0400 Subject: [PATCH 08/12] Stop the session-options effect from racing itself The useEffect that loaded the model picker data for the active session had the entire session object in its dependency array. Because selectedSession is recomputed on every agent update (live usage counters, history, model, status all flow through), the effect fired on every websocket frame. Each fire bumped the options generation, kicked off another get_session_options RPC call, and waited for its response. If any call in the burst threw (RPC subprocess busy, session in an error state, etc.), the catch block ran with the latest generation and cleared sessionOptions.models, leaving the picker stuck on the synthetic single-model fallback. Refire only when the session identity or status actually changes. The effect now captures the primitives at the top (sessionId and status) and the dep list matches exactly what the effect reads, so biome's useExhaustiveDependencies rule is satisfied without downgrading it. --- web/client/app.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/web/client/app.tsx b/web/client/app.tsx index d3ec9b0..ecf5062 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -2187,21 +2187,23 @@ export function App() { [], ); + // Refire when the session identity or status changes, not on every agent + // update (which churns the selectedSession reference). Refiring on every + // reference races the in-flight RPC call against its successor; the + // failure of the latest generation would clear models and leave the picker + // stuck on the synthetic single-model fallback. React.useEffect(() => { + const sessionId = selectedSession?.id; + const status = selectedSession?.status; const generation = ++optionsGenerationRef.current; - if (!selectedSession || selectedSession.status === "offline") { + if (!sessionId || status === "offline") { setSessionOptions({ models: [], thinkingLevels: [], commands: [] }); return; } // get_session_options already includes commands; avoid a second connection // and native get_commands process spawn on every session selection. - void loadSessionOptions(selectedSession.id, generation); - }, [ - loadSessionOptions, - selectedSession?.id, - selectedSession?.status, - selectedSession, - ]); + void loadSessionOptions(sessionId, generation); + }, [loadSessionOptions, selectedSession?.id, selectedSession?.status]); const selectModel = React.useCallback( async (provider: string, modelId: string) => { From 7531e7d4728e99e7f5cd8c7332ab5036f29aec41 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:11:41 -0400 Subject: [PATCH 09/12] Carry the agent's --models scope through to the picker Previously the web daemon's model picker called the RPC's get_available_models which always returned the full configured registry, ignoring any --models scope the user set on the TUI. The TUI respects scope; the web picker did not. The agent extension (extensions/web-sessions.ts) now forwards state.ctx.scopedModels on agent.hello, alongside the session payload it was already sending. The daemon stores it on the SessionRecord and the get_session_options route filters the RPC result by that scope before mapping to the browser-facing WebModelOption list. When scope is empty (no --models), no filtering happens and the picker shows everything as before. A new server-internal agent.scope message lets the agent push scope updates if the SDK ever fires a scope-change event mid-session; the type is in place but no current event source emits it. The browser never sees scopedModels directly; it only sees the picker list the daemon hands it. Hardlinked files (extensions/web-sessions.ts, web/protocol.ts, web/server/index.ts, web/server/server-types.ts) cover both pi-kit and pi-package, so restarting the running pi-package web daemon picks up the new code path. --- extensions/web-sessions.ts | 7 +++++++ web/server/index.ts | 40 +++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 6d8c576..68c33e6 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -1058,6 +1058,13 @@ async function connect(pi: ExtensionAPI, state: BridgeState): Promise { entries: boundedWebHistory( state.ctx.sessionManager.buildContextEntries(), ), + // Forward the session's --models scope so the daemon's model picker + // shows the same list the TUI would. + scopedModels: state.ctx.scopedModels.map((item) => ({ + provider: item.model.provider, + id: item.model.id, + thinkingLevel: item.thinkingLevel, + })), }; socket.send(JSON.stringify(hello)); if (state.sourceReplacement) { diff --git a/web/server/index.ts b/web/server/index.ts index cc2ff9f..66fdff3 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -33,6 +33,7 @@ import type { AgentHelloMessage, AgentHistoryMessage, AgentResponseMessage, + AgentScopeMessage, AgentSessionReplacedMessage, AgentSubagentsMessage, AgentToServerMessage, @@ -2311,14 +2312,27 @@ async function routeCommandCore( location: "temporary", }); } + const scoped = record.scopedModels ?? []; + const scopedByKey = new Map( + scoped.map((s) => [`${s.provider}/${s.id}`, s]), + ); + const filterByScope = scopedByKey.size > 0; return { - models: models.map((model) => ({ - provider: String(model.provider ?? ""), - id: String(model.id ?? ""), - name: String(model.name ?? model.id ?? ""), - reasoning: model.reasoning === true, - thinkingLevels: levels, - })), + models: models + .filter((model) => + filterByScope + ? scopedByKey.has( + `${String(model.provider ?? "")}/${String(model.id ?? "")}`, + ) + : true, + ) + .map((model) => ({ + provider: String(model.provider ?? ""), + id: String(model.id ?? ""), + name: String(model.name ?? model.id ?? ""), + reasoning: model.reasoning === true, + thinkingLevels: levels, + })), thinkingLevels: levels, commands: webCommands, }; @@ -2524,6 +2538,11 @@ async function handleAgentMessage( record.preview = extractPreviewFromHistory(record.history) ?? record.preview; record.managedWorktree = helloManagedWorktree ?? record.managedWorktree; + // Carry the agent's --models scope onto the record so the model picker + // mirrors what the TUI would show. Empty array means no scope. + record.scopedModels = Array.isArray(hello.scopedModels) + ? hello.scopedModels + : undefined; record.agentSockets.add(socket); record.active = true; record.status = hello.session.status; @@ -2554,6 +2573,13 @@ async function handleAgentMessage( return; } if (!socket.data.authed) throw new Error("Agent must send agent.hello first"); + if (message.type === "agent.scope") { + const update = message as AgentScopeMessage; + const record = sessions.get(update.sessionId); + if (!record || !record.agentSockets.has(socket)) return; + record.scopedModels = update.scopedModels; + return; + } if (message.type === "agent.history") { const update = message as AgentHistoryMessage; const record = sessions.get(update.sessionId); From 3cc2cb102489b9123f4aa87271a9714fb0bd2128 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:46:30 -0400 Subject: [PATCH 10/12] Reduce subagent read polling and transcript chatter --- extensions/subagents/manager.ts | 83 ++++++++++++++++++------------- extensions/subagents/tools.ts | 10 ++-- extensions/subagents/types.ts | 2 +- tests/subagents.test.ts | 87 ++++++++++++++++++++++++++++++--- 4 files changed, 136 insertions(+), 46 deletions(-) diff --git a/extensions/subagents/manager.ts b/extensions/subagents/manager.ts index 8383b8f..4d545ca 100644 --- a/extensions/subagents/manager.ts +++ b/extensions/subagents/manager.ts @@ -72,6 +72,7 @@ import { USAGE_STATE_ENTRY, type Usage, WEB_STATUS_PUBLISH_INTERVAL_MS, + DEFAULT_READ_WAIT_SECONDS, } from "./types.js"; import { addUsage, @@ -373,9 +374,13 @@ export class SubagentManager { agent.activity.splice(0, removed); agent.lastReadActivity = Math.max(0, agent.lastReadActivity - removed); } + this.publishFooter(); + } + + private wakeReadWaiters(agent: ManagedSubagent): void { + if (agent.waiters.size === 0) return; for (const waiter of agent.waiters) waiter(); agent.waiters.clear(); - this.publishFooter(); } private addTranscript(agent: ManagedSubagent, message: unknown): void { @@ -482,6 +487,7 @@ export class SubagentManager { break; case "agent_end": if (event.willRetry) this.activity(agent, "waiting to retry"); + else this.wakeReadWaiters(agent); break; case "agent_settled": if (agent.status !== "terminated" && agent.status !== "terminating") { @@ -494,6 +500,7 @@ export class SubagentManager { ? `failed${agent.error ? `: ${agent.error}` : ` (${agent.lastStopReason})`}` : "completed and is waiting for more instructions", ); + this.wakeReadWaiters(agent); } break; case "auto_retry_start": @@ -531,6 +538,7 @@ export class SubagentManager { agent.status = "completed"; agent.completedAt = Date.now(); this.activity(agent, "task run settled"); + this.wakeReadWaiters(agent); } }) .catch((error: unknown) => { @@ -540,6 +548,7 @@ export class SubagentManager { agent.error = error instanceof Error ? error.message : String(error); agent.completedAt = Date.now(); this.activity(agent, `failed: ${agent.error}`); + this.wakeReadWaiters(agent); }); } @@ -650,6 +659,7 @@ export class SubagentManager { agent.error = error instanceof Error ? error.message : String(error); agent.completedAt = Date.now(); this.activity(agent, `${agent.status}: ${agent.error}`); + this.wakeReadWaiters(agent); throw error; } } @@ -768,6 +778,7 @@ export class SubagentManager { agent.status = "terminated"; agent.completedAt = Date.now(); this.activity(agent, "terminated and released session resources"); + this.wakeReadWaiters(agent); } this.agents.delete(id); this.webTranscriptCursors.delete(id); @@ -816,12 +827,13 @@ export class SubagentManager { seconds: number, signal?: AbortSignal, ): Promise { - if (seconds <= 0 || agents.some((agent) => this.hasUnread(agent))) return; + if (agents.some((agent) => this.hasUnread(agent))) return; const running = agents.filter( (agent) => agent.status === "creating" || agent.status === "working", ); if (running.length === 0) return; + const waitSeconds = Math.max(seconds, DEFAULT_READ_WAIT_SECONDS); await new Promise((done) => { let finished = false; const finish = () => { @@ -832,44 +844,46 @@ export class SubagentManager { signal?.removeEventListener("abort", finish); done(); }; - const timer = setTimeout(finish, Math.min(30, seconds) * 1_000); + const timer = setTimeout(finish, waitSeconds * 1_000); for (const agent of running) agent.waiters.add(finish); signal?.addEventListener("abort", finish, { once: true }); }); } - read(agents: ManagedSubagent[], includeTranscript: boolean): string { + private readSummary(agent: ManagedSubagent): string { + const now = Date.now(); + const metadata = [ + `Model: ${agent.model}`, + `Effort: ${agent.effort}`, + `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, + `Turns: ${agent.turns}`, + `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, + ]; + if (agent.currentTool) metadata.push(`Current tool: ${agent.currentTool}`); + if (agent.queuedSteering || agent.queuedFollowUp) { + metadata.push( + `Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, + ); + } + if (agent.error) metadata.push(`Error: ${agent.error}`); + + if (!isTerminalSubagentStatus(agent.status)) { + return metadata.join("\n") + "\n\nAwaiting completion before returning assistant output."; + } + + const latest = finalAssistantText(agent); + if (!latest) return metadata.join("\n") + "\n\nCompletion summary: (no assistant output)"; + return `${metadata.join("\n")}\n\nCompletion summary:\n${truncateChars(latest, 3_000)}`; + } + + async read(agents: ManagedSubagent[], includeTranscript: boolean): Promise { if (agents.length === 0) return "No subagents are involved in this session."; - const now = Date.now(); const sections: string[] = []; + const terminalAgents: string[] = []; for (const agent of agents) { const heading = `## ${statusIcon(agent.status)} ${agent.id} — ${agent.status}`; - const metadata = [ - `Model: ${agent.model}`, - `Effort: ${agent.effort}`, - `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, - `Turns: ${agent.turns}`, - `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, - ]; - if (agent.currentTool) - metadata.push(`Current tool: ${agent.currentTool}`); - if (agent.queuedSteering || agent.queuedFollowUp) { - metadata.push( - `Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, - ); - } - if (agent.error) metadata.push(`Error: ${agent.error}`); - - const unread = agent.activity.slice(agent.lastReadActivity); - const activity = unread.length - ? unread - .map((item) => `- ${formatClock(item.timestamp)} ${item.text}`) - .join("\n") - : "- No new activity."; - agent.lastReadActivity = agent.activity.length; - - let output = `${heading}\n${metadata.join("\n")}\n\nActivity since last read:\n${activity}`; + let output = `${heading}\n${this.readSummary(agent)}`; if (includeTranscript) { const transcript = agent.transcript .map( @@ -878,14 +892,17 @@ export class SubagentManager { ) .join("\n\n"); output += `\n\nTranscript:\n${transcript || agent.streamingText || "(empty)"}`; - } else { - const latest = finalAssistantText(agent); - if (latest) output += `\n\nLatest assistant output:\n${latest}`; } sections.push(output); + agent.lastReadActivity = agent.activity.length; + if (isTerminalSubagentStatus(agent.status)) terminalAgents.push(agent.id); if (this.archivedAgents.get(agent.id) === agent) this.archivedAgents.delete(agent.id); } + + if (terminalAgents.length > 0) + await Promise.all(terminalAgents.map((id) => this.terminate(id, true))); + return truncateToolOutput(sections.join("\n\n---\n\n")); } diff --git a/extensions/subagents/tools.ts b/extensions/subagents/tools.ts index 5f2523c..fd651ff 100644 --- a/extensions/subagents/tools.ts +++ b/extensions/subagents/tools.ts @@ -74,8 +74,8 @@ const ReadParams = Type.Object({ ), wait_seconds: Type.Optional( Type.Integer({ - description: `Wait for meaningful new activity before returning. Default ${DEFAULT_READ_WAIT_SECONDS}, maximum 30.`, - minimum: 0, + description: `Wait for meaningful subagent state changes before returning. Default and minimum ${DEFAULT_READ_WAIT_SECONDS}.`, + minimum: DEFAULT_READ_WAIT_SECONDS, maximum: 30, }), ), @@ -134,7 +134,7 @@ export function registerSubagentTools( "Create a background subagent with a chosen prompt, model, and effort", promptGuidelines: [ "When calling subagent_create, omit model to inherit the current model unless deliberately choosing one of the exact session-available provider/model IDs listed in the system prompt; never shorten or invent a model ID.", - "After subagent_create returns, use subagent_read with its default wait roughly every 15–30 seconds while work continues; briefly tell the user about meaningful progress between polls without narrating every event.", + "After subagent_create returns, use subagent_read with wait_seconds 30 while work continues; expect a completion summary when the task transitions to completed. Re-poll only at that cadence for stalled work.", "Wait for subagent_create to return before calling another subagent management tool for that id.", "Use subagent_send with urgent only when the current approach must change immediately; use normal for work that can wait until the current run finishes.", "Use subagent_terminate when delegated work is no longer needed, and clean up retained subagents before finishing when appropriate.", @@ -174,7 +174,7 @@ export function registerSubagentTools( name: "subagent_read", label: "Read subagents", description: - "Wait for and read meaningful subagent activity, status, output, usage, or full transcripts. Omit id to monitor all subagents.", + "Wait for meaningful subagent state updates. Completed subagents return a concise summary and are auto-released after read. Omit id to monitor all subagents.", promptSnippet: "Read and monitor background subagent activity and output", parameters: ReadParams, async execute(_toolCallId, params, signal) { @@ -187,7 +187,7 @@ export function registerSubagentTools( if (signal?.aborted) throw new Error("Subagent read was cancelled"); return toolResult( manager, - manager.read(agents, params.include_transcript ?? false), + await manager.read(agents, params.include_transcript ?? false), ); }, renderCall(args, theme) { diff --git a/extensions/subagents/types.ts b/extensions/subagents/types.ts index 9f1d0de..ba58545 100644 --- a/extensions/subagents/types.ts +++ b/extensions/subagents/types.ts @@ -14,7 +14,7 @@ export const MAX_WEB_TRANSCRIPT_CHARS = 100_000; export const MAX_WEB_STREAMING_CHARS = 20_000; export const WEB_STATUS_PUBLISH_INTERVAL_MS = 1_000; export const MAX_TOOL_OUTPUT_BYTES = 50 * 1024; -export const DEFAULT_READ_WAIT_SECONDS = 15; +export const DEFAULT_READ_WAIT_SECONDS = 30; export const DETAIL_VIEW_LINES = 22; export const USAGE_STATE_ENTRY = "vessup-subagent-usage"; export const SUBAGENT_SYSTEM_PROMPT = [ diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index 0905318..416fe6f 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { SubagentManager } from "../extensions/subagents/manager.ts"; import { stringifyCompact, truncateChars, @@ -28,7 +29,7 @@ import subagentsExtension, { subagentModelGuidance, subagentModelRuntime, } from "../extensions/subagents.ts"; - +import { type ManagedSubagent } from "../extensions/subagents/types.ts"; test("subagent entrypoint preserves its tool, command, and lifecycle registrations", () => { const tools: string[] = []; const commands: string[] = []; @@ -91,6 +92,39 @@ const usage = { }, }; +function makeManagedAgent( + override: Partial = {}, +): ManagedSubagent { + const now = Date.now(); + return { + id: "worker", + prompt: "task", + cwd: "/tmp", + createdAt: now - 1_000, + updatedAt: now, + status: "completed", + model: "provider/model", + effort: "medium", + turns: 1, + queuedSteering: 0, + queuedFollowUp: 0, + activity: [{ timestamp: now - 10, text: "assistant finished" }], + lastReadActivity: 0, + transcript: [ + { + timestamp: now - 5, + role: "assistant", + text: "Subagent summary of work completed.", + }, + ], + streamingText: "", + lastStreamActivityAt: 0, + usage: usage, + waiters: new Set(), + ...override, + }; +} + test("compact formatting handles non-JSON values and preserves Unicode code points", () => { assert.equal(stringifyCompact(undefined), "undefined"); assert.equal(stringifyCompact(Symbol("value")), "Symbol(value)"); @@ -355,13 +389,52 @@ test("subagent model guidance exposes exact choices and inheritance", () => { assert.match(guidance, /Never shorten, generalize, or invent a model ID/); }); -test("streaming subagent output remains bounded to its newest text", () => { - const prefix = "a".repeat(MAX_WEB_STREAMING_CHARS - 2); - assert.equal(appendBoundedStreamingText(prefix, "bc"), `${prefix}bc`); - assert.equal( - appendBoundedStreamingText(prefix, "012345"), - `${prefix.slice(4)}012345`, +test("subagent read returns a concise completion summary and auto-releases terminal agents", async () => { + const manager = new SubagentManager({ + events: { emit() {} }, + } as never); + const agent = makeManagedAgent(); + + (manager as { agents: Map }).agents.set( + agent.id, + agent, ); + + const output = await manager.read([agent], false); + + assert.ok(output.includes("Completion summary:")); + assert.equal(output.includes("Activity since last read:"), false); + assert.equal(manager.list().length, 0); +}); + +test("subagent read includes transcript only when requested", async () => { + const manager = new SubagentManager({ + events: { emit() {} }, + } as never); + const withTranscript = makeManagedAgent({ id: "detailed" }); + + (manager as { agents: Map }).agents.set( + withTranscript.id, + withTranscript, + ); + + const without = await manager.read([withTranscript], false); + assert.equal(without.includes("Transcript:"), false); + + // Re-insert a completed agent for the detailed-read assertion. + (manager as { agents: Map }).agents.set( + withTranscript.id, + { + ...withTranscript, + lastReadActivity: 0, + status: "completed", + waiters: new Set(), + }, + ); + + const withTranscriptOutput = await manager.read([withTranscript], true); + assert.ok(withTranscriptOutput.includes("Transcript:")); + assert.ok(withTranscriptOutput.includes(withTranscript.transcript[0]?.text)); }); test("persisted usage checkpoints reject malformed data", () => { From 815f86e3f1fbb07c6ee8b463e794323d9b3db4ed Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:18:36 -0400 Subject: [PATCH 11/12] Fix two real bugs in the subagent read-polling change Confirmed both via CodeRabbit review on PR #7 plus direct reproduction, not just from reading the diff: 1. agent_end (non-retry) woke read waiters before the terminal status transition actually happened - willRetry:false only means this particular run won't auto-retry, but Pi can still continue with queued follow-ups before genuinely settling. A caller woken here got a still-"working" snapshot and had to wait a full cycle again for the real completion. Removed; the actual terminal transition (agent_settled / attachRun's own handlers) already wakes waiters. 2. read()'s auto-release deleted an agent from archivedAgents inside the read loop, before the batch terminate(id, true) call at the end - which resolves the agent via getAgent() first. Reading an archived (previously-terminated, output-preserved) agent by id would delete its only reference and then crash the whole read() call with "Unknown subagent". terminate() already deletes it from archivedAgents itself once remove=true, so let it own that. Also fixed one I found independently while verifying the PR's actual goal ("stop wakeups on routine activity to avoid rapid loops"): waitForUpdates's own hasUnread() early-return bypassed the wait mechanism entirely whenever the target agent had any unread activity - and agent.activity still grows on every routine event (tool start/ end, throttled streaming text, queue updates), not just terminal ones. In practice this meant the enforced wait only ever applied to an agent that had gone completely idle between reads - the opposite of the actively-working-subagent scenario that causes rapid polling loops in the first place. Verified with a standalone repro: waitForUpdates(..., 30) returned in 0ms given one pre-existing routine activity entry. Removed the check (and the now-unused private method) entirely; the already-terminal case it also tried to cover is already handled correctly by the running.length === 0 check right after it. Also applied biome's suggested cleanup (template literals, unused imports) left over from the prior commit. Co-Authored-By: Claude Sonnet 5 --- extensions/subagents/manager.ts | 26 ++++++++++++++++---------- tests/subagents.test.ts | 4 +--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/extensions/subagents/manager.ts b/extensions/subagents/manager.ts index 4d545ca..eac8d44 100644 --- a/extensions/subagents/manager.ts +++ b/extensions/subagents/manager.ts @@ -486,8 +486,13 @@ export class SubagentManager { ); break; case "agent_end": + // Don't wake waiters here: `willRetry: false` only means this particular run won't + // auto-retry - Pi can still continue with queued follow-ups before ever reaching a + // real terminal state, so waking now can hand back a still-"working" snapshot and + // force the caller to wait a full cycle again for the actual completion. The terminal + // transition (agent_settled below, or attachRun's own handlers) wakes waiters once the + // status has actually changed. if (event.willRetry) this.activity(agent, "waiting to retry"); - else this.wakeReadWaiters(agent); break; case "agent_settled": if (agent.status !== "terminated" && agent.status !== "terminating") { @@ -818,16 +823,16 @@ export class SubagentManager { return terminalAgents.length; } - private hasUnread(agent: ManagedSubagent): boolean { - return agent.lastReadActivity < agent.activity.length; - } - async waitForUpdates( agents: ManagedSubagent[], seconds: number, signal?: AbortSignal, ): Promise { - if (agents.some((agent) => this.hasUnread(agent))) return; + // No early-return on "any unread activity": `agent.activity` still grows on every routine + // event (tool start/end, throttled streaming text, queue updates), so that check would fire + // for almost any actively-working agent between two reads and skip the wait entirely, + // defeating the whole point of it. An already-terminal agent is instead caught below by + // `running.length === 0`, which is the actual "nothing worth waiting for" case. const running = agents.filter( (agent) => agent.status === "creating" || agent.status === "working", ); @@ -868,11 +873,11 @@ export class SubagentManager { if (agent.error) metadata.push(`Error: ${agent.error}`); if (!isTerminalSubagentStatus(agent.status)) { - return metadata.join("\n") + "\n\nAwaiting completion before returning assistant output."; + return `${metadata.join("\n")}\n\nAwaiting completion before returning assistant output.`; } const latest = finalAssistantText(agent); - if (!latest) return metadata.join("\n") + "\n\nCompletion summary: (no assistant output)"; + if (!latest) return `${metadata.join("\n")}\n\nCompletion summary: (no assistant output)`; return `${metadata.join("\n")}\n\nCompletion summary:\n${truncateChars(latest, 3_000)}`; } @@ -896,10 +901,11 @@ export class SubagentManager { sections.push(output); agent.lastReadActivity = agent.activity.length; if (isTerminalSubagentStatus(agent.status)) terminalAgents.push(agent.id); - if (this.archivedAgents.get(agent.id) === agent) - this.archivedAgents.delete(agent.id); } + // Removing an archived agent from `archivedAgents` here (before terminate() runs) would + // make it unresolvable by id - `terminate(id, true)` looks the agent up via `getAgent` + // first and only then removes it, so let it own that removal instead. if (terminalAgents.length > 0) await Promise.all(terminalAgents.map((id) => this.terminate(id, true))); diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index 416fe6f..01590d2 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -17,19 +17,17 @@ import { } from "../extensions/subagents/ui.ts"; import subagentsExtension, { abortRunningSubagentSessions, - appendBoundedStreamingText, countsAgainstSubagentLimit, filterModelsToScope, inheritedSubagentModel, isFailedStopReason, isTerminalSubagentStatus, - MAX_WEB_STREAMING_CHARS, parsePersistedUsageState, shouldArchiveTerminalSubagent, subagentModelGuidance, subagentModelRuntime, } from "../extensions/subagents.ts"; -import { type ManagedSubagent } from "../extensions/subagents/types.ts"; +import type { ManagedSubagent } from "../extensions/subagents/types.ts"; test("subagent entrypoint preserves its tool, command, and lifecycle registrations", () => { const tools: string[] = []; const commands: string[] = []; From 7d0e4a15d3aae5920aee78f03e0c3d761af576d6 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:40:01 -0400 Subject: [PATCH 12/12] Address CodeRabbit findings on already-merged auto-router files These showed up as PR #7 comments due to a stale diff view (before merging main into that branch), attached to files PR #7 doesn't actually touch since they landed via PR #6. Fixing them here, then merging this into fix/subagents-read-summary per request so they ride along on that PR instead of a separate one. - parseRetryAfterMs only checked the two exact-cased header key variants ("retry-after"/"Retry-After"); a real provider using a different casing (e.g. "RETRY-AFTER") would silently fall through to the exponential-backoff estimate instead of the provider's own value. Now matches case-insensitively. - AutoRouterHealthStore.scheduleSave's timer callback called `void this.flush()` with nothing to catch a rejection - a transient write failure (ENOSPC, EACCES, ...) would become an unhandled rejection with no caller around to catch it. Same pattern existed in auto-router.ts's session_shutdown handler. Both now swallow the error, matching this store's documented best-effort nature. - trackedModel (used by after_provider_response and message_end, both of which can fire multiple times per turn) re-read and re-parsed settings.json from disk on every call. Added a short (5s) TTL cache scoped specifically to this membership check - routing decisions themselves (routeForPrompt, /usage, reconciliation) still always read fresh, since staleness there would mean routing on config the user no longer has. - README claimed `.pi/settings.json` works as a project override; readAutoRouterSettings only ever reads the global ~/.pi/agent/settings.json. Removed the false claim. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- extensions/auto-router-health.ts | 10 ++++++++-- extensions/auto-router.ts | 24 ++++++++++++++++++++++-- tests/auto-router-health.test.ts | 5 +++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fac55fd..1587a48 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The subagent extension independently contributes its token use and status to `ex `extensions/auto-router.ts` adds an "Auto" entry to `/model`. Selecting it routes each turn to a model/reasoning-effort pair chosen from your own configured lists, based on the turn's classified complexity, and fails over to other configured models or tiers when one is unhealthy or out of usage. -Configure it under a new `autoRouter` key in `~/.pi/agent/settings.json` (or `.pi/settings.json` for a project override): +Configure it under a new `autoRouter` key in `~/.pi/agent/settings.json`: ```json { diff --git a/extensions/auto-router-health.ts b/extensions/auto-router-health.ts index 5dd8d2d..bac54ac 100644 --- a/extensions/auto-router-health.ts +++ b/extensions/auto-router-health.ts @@ -89,7 +89,11 @@ export function parseRetryAfterMs( headers: Record | undefined, now: number, ): number | undefined { - const raw = headers?.["retry-after"] ?? headers?.["Retry-After"]; + const raw = headers + ? Object.entries(headers).find( + ([name]) => name.toLowerCase() === "retry-after", + )?.[1] + : undefined; if (!raw) return undefined; const seconds = Number(raw); if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; @@ -375,7 +379,9 @@ export class AutoRouterHealthStore { if (this.writeTimer) return; this.writeTimer = setTimeout(() => { this.writeTimer = undefined; - void this.flush(); + // Best-effort telemetry: a transient write failure (ENOSPC, EACCES, ...) must not become + // an unhandled rejection with no caller to catch it, which would crash the process. + void this.flush().catch(() => undefined); }, SAVE_DEBOUNCE_MS); this.writeTimer.unref?.(); } diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index c1e25db..5cf4b4c 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -506,9 +506,28 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { // that's *configured* somewhere in autoRouter (picked manually from /model, or left over from // before Auto was engaged) is just as real a signal for future routing decisions and /usage, // so it's tracked the same way regardless of who selected the model. + const TRACKED_SETTINGS_CACHE_MS = 5_000; + let trackedSettingsCache: { settings: AutoRouterSettings; expiresAt: number } | undefined; + + // Short-TTL cache scoped to this membership check specifically: after_provider_response and + // message_end can both fire multiple times per turn, and re-reading + re-parsing settings.json + // from disk for each one is wasted work when nothing's changed. Routing decisions themselves + // (routeForPrompt, /usage, reconciliation) still always read fresh, since staleness there would + // mean routing on config the user no longer has - a few seconds of staleness in "is this model + // even one we track" is a much cheaper trade. + async function trackedSettings(): Promise { + const now = Date.now(); + if (trackedSettingsCache && trackedSettingsCache.expiresAt > now) { + return trackedSettingsCache.settings; + } + const settings = await readAutoRouterSettings(); + trackedSettingsCache = { settings, expiresAt: now + TRACKED_SETTINGS_CACHE_MS }; + return settings; + } + async function trackedModel(model: ModelIdentity | undefined): Promise { if (!model || model.provider === AUTO_PROVIDER_ID) return undefined; - const settings = await readAutoRouterSettings(); + const settings = await trackedSettings(); const configured = allConfiguredModels(settings).some( (candidate) => candidate.provider === model.provider && candidate.id === model.id, ); @@ -543,7 +562,8 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { }); pi.on("session_shutdown", () => { - void healthStore.flush(); + // Best-effort telemetry: a transient write failure must not become an unhandled rejection. + void healthStore.flush().catch(() => undefined); currentSessionId = undefined; autoActive = false; }); diff --git a/tests/auto-router-health.test.ts b/tests/auto-router-health.test.ts index 1c38f42..d019694 100644 --- a/tests/auto-router-health.test.ts +++ b/tests/auto-router-health.test.ts @@ -51,6 +51,11 @@ test("parseRetryAfterMs reads an HTTP-date header", () => { expect(parseRetryAfterMs({ "retry-after": future }, NOW)).toBeCloseTo(60_000, -2); }); +test("parseRetryAfterMs finds the header regardless of casing", () => { + expect(parseRetryAfterMs({ "RETRY-AFTER": "30" }, NOW)).toBe(30_000); + expect(parseRetryAfterMs({ "Retry-After": "30" }, NOW)).toBe(30_000); +}); + test("parseRetryAfterMs returns undefined when the header is missing or unparseable", () => { expect(parseRetryAfterMs(undefined, NOW)).toBeUndefined(); expect(parseRetryAfterMs({}, NOW)).toBeUndefined();