From 4c83ae247845bebcd9fa2aa8e5b4bc7861a87e6a Mon Sep 17 00:00:00 2001
From: Ian Walter <122028+ianwalter@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:31:04 -0400
Subject: [PATCH] Fix web model picker scope, Auto Router entries, and
transcript overflow
Three managed-session defects, each verified end to end against a live
daemon:
Model picker ignored enabledModels for managed sessions. The managed
get_session_options path filtered by record.scopedModels, which only
TUI bridges ever populate (agent.hello / agent.scope), so web-created
sessions listed every authenticated model. Managed runtimes are spawned
without --models, so their scope is exactly the enabledModels patterns
in the shared settings file; the daemon now resolves those patterns
itself (web/server/modelScope.ts) with Pi's matching semantics: exact
provider/id or bare id, partial id/name fallback, minimatch-style globs
that never cross a slash, and an optional :thinking suffix. Bridge
records keep their forwarded resolved scope.
Auto Router entries never appeared because managed children silently
lost every package extension: the daemon spawned PATH-resolved pi
(global 0.84.2) while importing pi-coding-agent 0.84.1 itself, and the
version skew made the child drop the whole pi-kit package after the
startup switch_session. Managed runtimes now spawn the daemon's own
pinned @earendil-works/pi-coding-agent/rpc-entry under the daemon's Bun
binary (PI_WEB_RPC_BIN overrides for tests and wrappers).
The transcript grew a horizontal scrollbar: .semantic-edit-diff sized
itself to min-content with no clipping ancestor, so wide diff rows
pushed the whole column. It now scrolls internally (max-width: 100%,
overflow-x: auto) and the transcript scroller clips overflow-x as a
backstop, matching how pre/table already self-scroll.
---
tests/web-model-scope.test.ts | 124 ++++++++++++++++++++++++++++++
tests/web-server.test.ts | 5 ++
web/client/semantic-session.tsx | 2 +-
web/client/styles.css | 4 +-
web/server/commandRouter.ts | 46 ++++++-----
web/server/managed-rpc-session.ts | 42 +++++++++-
web/server/modelScope.ts | 123 +++++++++++++++++++++++++++++
7 files changed, 321 insertions(+), 25 deletions(-)
create mode 100644 tests/web-model-scope.test.ts
create mode 100644 web/server/modelScope.ts
diff --git a/tests/web-model-scope.test.ts b/tests/web-model-scope.test.ts
new file mode 100644
index 0000000..0f819c5
--- /dev/null
+++ b/tests/web-model-scope.test.ts
@@ -0,0 +1,124 @@
+import { afterEach, expect, test } from "bun:test";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ filterModelsByScopePatterns,
+ modelMatchesScopePattern,
+ readEnabledModelPatterns,
+ type ScopeFilterModel,
+} from "../web/server/modelScope";
+
+let directory: string | undefined;
+afterEach(async () => {
+ if (directory) await rm(directory, { recursive: true, force: true });
+ directory = undefined;
+});
+
+const catalog: ScopeFilterModel[] = [
+ { provider: "zai", id: "glm-5.3", name: "GLM 5.3" },
+ { provider: "openai-codex", id: "gpt-5.6-luna", name: "Luna" },
+ { provider: "opencode-go", id: "glm-5.2", name: "GLM 5.2 (opencode)" },
+ { provider: "auto", id: "auto", name: "Auto (auto)" },
+ { provider: "auto", id: "auto-low", name: "Auto (low)" },
+ { provider: "auto", id: "auto-max", name: "Auto (max)" },
+];
+
+test("empty pattern list keeps every model", () => {
+ expect(filterModelsByScopePatterns(catalog, [])).toEqual(catalog);
+});
+
+test("exact provider/id patterns scope the list, case-insensitively", () => {
+ const scoped = filterModelsByScopePatterns(catalog, [
+ "zai/GLM-5.3",
+ "openai-codex/gpt-5.6-luna",
+ ]);
+ expect(scoped.map((model) => `${model.provider}/${model.id}`)).toEqual([
+ "zai/glm-5.3",
+ "openai-codex/gpt-5.6-luna",
+ ]);
+});
+
+test("auto/* glob keeps every Auto Router entry and excludes real providers", () => {
+ const scoped = filterModelsByScopePatterns(catalog, ["auto/*"]);
+ expect(scoped.map((model) => model.id)).toEqual([
+ "auto",
+ "auto-low",
+ "auto-max",
+ ]);
+});
+
+test("globs match the bare id but never cross a slash", () => {
+ expect(
+ modelMatchesScopePattern("*luna*", "openai-codex", {
+ id: "gpt-5.6-luna",
+ }),
+ ).toBe(true);
+ // `*` cannot cross the provider boundary in the full form, and the bare id
+ // has no slash to protect it either way.
+ expect(
+ modelMatchesScopePattern("openai*", "openai-codex", { id: "gpt-5.6-luna" }),
+ ).toBe(false);
+ expect(
+ modelMatchesScopePattern("openai-codex/*", "openai-codex", {
+ id: "gpt-5.6-luna",
+ }),
+ ).toBe(true);
+});
+
+test("thinking-level suffix is stripped before matching", () => {
+ expect(
+ modelMatchesScopePattern("zai/glm-5.3:high", "zai", { id: "glm-5.3" }),
+ ).toBe(true);
+ // A colon that is not a thinking level stays part of the pattern.
+ expect(
+ modelMatchesScopePattern("zai/glm-5.3:not-a-level", "zai", {
+ id: "glm-5.3",
+ }),
+ ).toBe(false);
+});
+
+test("non-glob patterns fall back to partial id and name containment", () => {
+ expect(
+ modelMatchesScopePattern("luna", "openai-codex", {
+ id: "gpt-5.6-luna",
+ name: "Luna",
+ }),
+ ).toBe(true);
+ expect(
+ modelMatchesScopePattern("GLM 5", "zai", {
+ id: "glm-5.3",
+ name: "GLM 5.3",
+ }),
+ ).toBe(true);
+});
+
+test("readEnabledModelPatterns reads enabledModels from a settings file", async () => {
+ directory = await mkdtemp(join(tmpdir(), "pi-model-scope-"));
+ const settingsPath = join(directory, "settings.json");
+ await writeFile(
+ settingsPath,
+ JSON.stringify({
+ theme: "dark",
+ enabledModels: ["zai/glm-5.3", "auto/*", 42, " ", null],
+ }),
+ );
+ expect(await readEnabledModelPatterns(settingsPath)).toEqual([
+ "zai/glm-5.3",
+ "auto/*",
+ ]);
+});
+
+test("readEnabledModelPatterns returns no scope for missing, malformed, or unscoped settings", async () => {
+ directory = await mkdtemp(join(tmpdir(), "pi-model-scope-"));
+ const missing = join(directory, "missing.json");
+ expect(await readEnabledModelPatterns(missing)).toEqual([]);
+
+ const malformed = join(directory, "malformed.json");
+ await writeFile(malformed, "{not json");
+ expect(await readEnabledModelPatterns(malformed)).toEqual([]);
+
+ const unscoped = join(directory, "unscoped.json");
+ await writeFile(unscoped, JSON.stringify({ theme: "dark" }));
+ expect(await readEnabledModelPatterns(unscoped)).toEqual([]);
+});
diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts
index 5d8f7b1..fe5247f 100644
--- a/tests/web-server.test.ts
+++ b/tests/web-server.test.ts
@@ -2068,6 +2068,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
@@ -2711,6 +2712,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
@@ -2935,6 +2937,7 @@ test("managed RPC requests fail within the configured bound when Pi wedges", asy
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
@@ -3702,6 +3705,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
@@ -3783,6 +3787,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx
index 9f7dc11..4e06d77 100644
--- a/web/client/semantic-session.tsx
+++ b/web/client/semantic-session.tsx
@@ -2712,7 +2712,7 @@ export function SemanticSession({
{
scrollIntentRef.current = event.deltaY < 0 ? "up" : "down";
if (event.deltaY < 0) stopFollowing();
diff --git a/web/client/styles.css b/web/client/styles.css
index 1fad1d3..7609516 100644
--- a/web/client/styles.css
+++ b/web/client/styles.css
@@ -741,8 +741,8 @@ details[open] > summary .semantic-tool-chevron {
padding-top: 0.65rem;
}
.semantic-edit-diff {
- min-width: max-content;
- overflow: hidden;
+ max-width: 100%;
+ overflow-x: auto;
border-radius: 0.4rem;
background: rgb(9 9 11 / 0.7);
}
diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts
index b8657a9..7d9ae0c 100644
--- a/web/server/commandRouter.ts
+++ b/web/server/commandRouter.ts
@@ -16,6 +16,10 @@ import {
isUncertainRpcDeliveryCommand,
} from "./managed-rpc-session.js";
import type { ManagedSessionRefresh } from "./managedSessionRefresh.js";
+import {
+ filterModelsByScopePatterns,
+ readEnabledModelPatterns,
+} from "./modelScope.js";
import type { RpcSessionFactory } from "./rpcSessions.js";
import type {
ExternalPendingRequest,
@@ -441,27 +445,29 @@ export function createCommandRouter(options: {
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
- .filter((model) =>
- filterByScope
- ? scopedByKey.has(
- `${String(model.provider ?? "")}/${String(model.id ?? "")}`,
- )
- : true,
+ // Managed sessions have no bridge to forward a resolved scope, so
+ // their scope is whatever `enabledModels` says in the shared
+ // settings file (the daemon spawns them without --models). Bridge
+ // sessions instead carry their resolved scope on the record.
+ const scopePatterns = record.scopedModels
+ ? record.scopedModels.map(
+ (model) => `${model.provider}/${model.id}`,
)
- .map((model) => ({
- provider: String(model.provider ?? ""),
- id: String(model.id ?? ""),
- name: String(model.name ?? model.id ?? ""),
- reasoning: model.reasoning === true,
- thinkingLevels: levels,
- })),
+ : await readEnabledModelPatterns(options.config.settingsPath);
+ const modelOptions = models.map((model) => ({
+ provider: String(model.provider ?? ""),
+ id: String(model.id ?? ""),
+ name: String(model.name ?? model.id ?? ""),
+ reasoning: model.reasoning === true,
+ }));
+ return {
+ models: filterModelsByScopePatterns(
+ modelOptions,
+ scopePatterns,
+ ).map((model) => ({
+ ...model,
+ thinkingLevels: levels,
+ })),
thinkingLevels: levels,
commands: webCommands,
};
diff --git a/web/server/managed-rpc-session.ts b/web/server/managed-rpc-session.ts
index eed998b..b0d7145 100644
--- a/web/server/managed-rpc-session.ts
+++ b/web/server/managed-rpc-session.ts
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
+import { fileURLToPath } from "node:url";
import type { RpcSessionCommand } from "../protocol.js";
import { SerializedWriter } from "./serialized-writer.js";
@@ -65,6 +66,43 @@ export function rpcDeliveryError(command: string, message: string): Error {
: new Error(message);
}
+let cachedRpcCommand: string[] | undefined;
+
+/**
+ * Command that starts a managed RPC Pi runtime.
+ *
+ * The daemon must spawn the exact `@earendil-works/pi-coding-agent` build it
+ * itself imports (session-file formats and RPC behavior move together), not
+ * whatever `pi` happens to be first on PATH — an upgraded global `pi` can
+ * otherwise reject the daemon's session files or load extensions
+ * differently, which silently dropped every package extension (including the
+ * Auto Router provider) from managed web sessions. `rpc-entry` hardcodes
+ * `--mode rpc`, so the mode flag is intentionally absent from the rest of
+ * the command. Running it under this daemon's own Bun binary keeps
+ * TypeScript extension loading identical to the daemon's. `PI_WEB_RPC_BIN`
+ * overrides the executable for tests and wrapper setups.
+ */
+function rpcSessionCommand(): string[] {
+ if (cachedRpcCommand) return cachedRpcCommand;
+ const override = process.env.PI_WEB_RPC_BIN?.trim();
+ let command: string[];
+ if (override) {
+ command = [override, "--mode", "rpc"];
+ } else {
+ try {
+ const entry = fileURLToPath(
+ import.meta.resolve("@earendil-works/pi-coding-agent/rpc-entry"),
+ );
+ command = [process.execPath, entry];
+ } catch {
+ // Fall back to PATH resolution when the package entry cannot be resolved.
+ command = ["pi", "--mode", "rpc"];
+ }
+ }
+ cachedRpcCommand = command;
+ return command;
+}
+
export class ManagedRpcSession {
private readonly options: ManagedRpcSessionOptions;
private process: Bun.Subprocess | undefined;
@@ -121,11 +159,11 @@ export class ManagedRpcSession {
if (this.options.runtimeDirectory)
mkdirSync(this.options.runtimeDirectory, { recursive: true });
const env = { ...process.env, PI_WEB_MANAGED: "1" };
- const args = ["--mode", "rpc"];
+ const args: string[] = [];
if (this.options.noSession) args.push("--no-session");
if (this.options.name) args.push("--name", this.options.name);
const proc = Bun.spawn({
- cmd: ["pi", ...args],
+ cmd: [...rpcSessionCommand(), ...args],
cwd: this.options.cwd,
env,
stdin: "pipe",
diff --git a/web/server/modelScope.ts b/web/server/modelScope.ts
new file mode 100644
index 0000000..03f4c90
--- /dev/null
+++ b/web/server/modelScope.ts
@@ -0,0 +1,123 @@
+import { readFile } from "node:fs/promises";
+
+/**
+ * Model-scope filtering for managed web sessions.
+ *
+ * Pi's `/model` picker defaults to the session's `scopedModels` — resolved from
+ * `--models` or, absent that flag, the global `enabledModels` setting. TUI
+ * bridge sessions forward their resolved scope to the daemon via
+ * `agent.hello`/`agent.scope`, so `record.scopedModels` already mirrors the
+ * picker there. Managed RPC sessions have no such channel: the RPC protocol
+ * exposes no scope query, and the daemon spawns them without `--models`, so
+ * their scope is exactly the `enabledModels` patterns in the same settings
+ * file the daemon already reads. Re-resolving those patterns here keeps the
+ * web picker in sync with what the TUI would show.
+ */
+
+const THINKING_LEVEL_SUFFIXES = new Set([
+ "off",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+]);
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+/** Read `enabledModels` from a Pi settings file. Unreadable/absent/malformed means no scoping. */
+export async function readEnabledModelPatterns(
+ settingsPath: string,
+): Promise {
+ try {
+ const root: unknown = JSON.parse(await readFile(settingsPath, "utf8"));
+ if (!isRecord(root) || !Array.isArray(root.enabledModels)) return [];
+ return root.enabledModels.filter(
+ (pattern): pattern is string =>
+ typeof pattern === "string" && pattern.trim().length > 0,
+ );
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Convert a glob to a RegExp with minimatch's default semantics for the
+ * characters we support: `*` and `?` never cross `/`, and `[...]` character
+ * classes pass through. Everything else is literal.
+ */
+function globToRegExp(pattern: string): RegExp {
+ let source = "";
+ for (let index = 0; index < pattern.length; index++) {
+ const char = pattern[index];
+ if (char === "*") source += "[^/]*";
+ else if (char === "?") source += "[^/]";
+ else if (char === "\\") source += "\\\\";
+ else source += char.replace(/[.+^${}()|[\]]/g, "\\$&");
+ }
+ return new RegExp(`^${source}$`, "i");
+}
+
+function hasGlobCharacter(pattern: string): boolean {
+ return /[*?[]/.test(pattern);
+}
+
+/** Strip an optional `:` suffix (e.g. `zai/glm-5.3:high`), as Pi's scope resolver does. */
+function withoutThinkingSuffix(pattern: string): string {
+ const colonIndex = pattern.lastIndexOf(":");
+ if (colonIndex < 0) return pattern;
+ const suffix = pattern.slice(colonIndex + 1);
+ return THINKING_LEVEL_SUFFIXES.has(suffix)
+ ? pattern.slice(0, colonIndex)
+ : pattern;
+}
+
+/**
+ * Whether `provider`/`id` matches one scope pattern, following the same rules
+ * Pi applies to `enabledModels`: exact `provider/id` or bare-id equality
+ * (case-insensitive), a partial id/name containment fallback, or — when the
+ * pattern contains glob characters — minimatch-style matching against the
+ * full `provider/id` form or the bare id. The Auto Router's `auto/*` pattern
+ * relies on the glob path.
+ */
+export function modelMatchesScopePattern(
+ pattern: string,
+ provider: string,
+ model: { id: string; name?: string },
+): boolean {
+ const bare = withoutThinkingSuffix(pattern.trim());
+ if (bare.length === 0) return false;
+ const fullId = `${provider}/${model.id}`;
+ if (hasGlobCharacter(bare)) {
+ const expression = globToRegExp(bare);
+ return expression.test(fullId) || expression.test(model.id);
+ }
+ const lowered = bare.toLowerCase();
+ if (lowered === fullId.toLowerCase() || lowered === model.id.toLowerCase())
+ return true;
+ return (
+ model.id.toLowerCase().includes(lowered) ||
+ (model.name ? model.name.toLowerCase().includes(lowered) : false)
+ );
+}
+
+export type ScopeFilterModel = { provider: string; id: string; name?: string };
+
+/**
+ * Keep only the models allowed by `patterns`. An empty pattern list means no
+ * scoping is configured and every model passes through, matching Pi's picker.
+ */
+export function filterModelsByScopePatterns(
+ models: Model[],
+ patterns: readonly string[],
+): Model[] {
+ if (patterns.length === 0) return models;
+ return models.filter((model) =>
+ patterns.some((pattern) =>
+ modelMatchesScopePattern(pattern, model.provider, model),
+ ),
+ );
+}