From c6a48a2aec6423fb1ee9054139e670472de8da43 Mon Sep 17 00:00:00 2001 From: Adrian Cester Trallero Date: Tue, 6 Oct 2026 14:08:27 +0200 Subject: [PATCH 1/2] feat(profiles): route launches, status and Usage through the frozen session profile Part 3 of 3 of gentle-shell#1064 slice 3b-ii. Subagent launches, non-git writer admission, the footer profile label and the Usage provider scope resolve the session profile through resolveSessionProfile. The parent session_start freezes p -> P -> G; each launch from a frozen session shows one drift notice per distinct change of its own directory's defaults. Unpinned sessions now route through the profiles.json active profile instead of the materialized stores. Test fixtures pin GENTLE_PI_CONFIG_HOME to the scratch config home, reset the freeze between tests, write the writer-admission declaration before session_start, run the status polling test in follow mode, and switch the Usage profile through an explicit binding. --- extensions/gentle-agents.ts | 77 ++++++++++++++------ extensions/gentle-shell.ts | 53 +++++++------- tests/gentle-agents.test.ts | 137 ++++++++++++++++++++++++++++++++++-- tests/gentle-shell.test.ts | 71 ++++++++++++++++++- 4 files changed, 277 insertions(+), 61 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 559d16fce..20bda3224 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -27,7 +27,7 @@ import { resolveVisualSettings } from "../lib/visual-customization-policy.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts"; import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; -import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts"; +import { freezeSessionProfileAtStartup, inheritedProfileDriftNotice, resolveSessionProfile, sessionProfileRoutingAt } from "../lib/session-profile-freeze.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts"; @@ -60,7 +60,6 @@ import { CARD_TONE, renderCard } from "../lib/shell-card.ts"; import { openInExternalEditor } from "./gentle-shell.ts"; import { gentlePiConfigHome } from "../lib/agent-home.ts"; import { resolveAgentHomeDirectory } from "../lib/agent-model-resolution.ts"; -import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts"; import { allowedEditSurfaces, inheritAllowedEditSurfaces, isBoundedWriter, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts"; import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts"; import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts"; @@ -1552,21 +1551,26 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission."); let admittedModel: string | undefined; const surfaces = allowedEditSurfaces(prompt, context); - // gentle-shell#1064 slice 2: the binding is read once per task request, - // before admission, so the admitted model and the launch routing resolve - // the same session layer and can never disagree about it (#1558: a bound - // session used to kill its own non-git writer mid-preparation because - // admission still read only the pin/global layers). - const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId()); + // gentle-shell#1064 slice 2: the session profile is resolved once per task + // request, before admission, so the admitted model and the launch routing + // resolve the same session layer and can never disagree about it (#1558: a + // bound session used to kill its own non-git writer mid-preparation because + // admission still read only the pin/global layers). Slice 3b-ii: the same + // read covers the frozen inherited profile and `follow` mode. + const profileConfigHome = gentlePiConfigHome(deps.env); + const sessionProfile = resolveSessionProfile({ + sessionId: ctx.sessionManager.getSessionId(), + cwd: originalCwd, + configHome: profileConfigHome, + resolveWorktree: deps.resolveWorktree, + env: deps.env, + }); if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) { const root = safeBootstrapDirectory(originalCwd); if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root."); const config = withPinnedModelProfiles( loadAgentsConfig(roots(ctx)), - sessionOrPinModelProfiles( - sessionBinding?.modelProfiles, - resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles, - ), + sessionProfileRoutingAt(sessionProfile, { cwd: root, configHome: profileConfigHome, resolveWorktree: deps.resolveWorktree }), ); const model = resolveAgentProfile(agent, config).model ?? ctx.model; const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id); @@ -1612,22 +1616,34 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root ? () => parentIdentity : deps.resolveWorktree; - // gentle-shell#1064 slice 1: a parent-session profile binding outranks the - // pin layers for launches from that session (`session → p → P → global`), - // with the same wholesale-replacement contract as the pin. The binding is + // gentle-shell#1064: the session profile outranks the shared defaults for + // launches from that session (`session → frozen p → P → G`), with the same + // wholesale-replacement contract as the pin. Explicit and frozen profiles + // belong to the session and apply to every target, a foreign repository + // included; only `follow` reads the defaults of the target. The profile is // resolved here, at task-request creation, so queued and running children // keep the routing frozen into their requests even if the session rebinds. const config = withPinnedModelProfiles( loadAgentsConfig(roots(ctx)), - sessionOrPinModelProfiles( - sessionBinding?.modelProfiles, - resolveProfilePin({ - cwd: target ?? parentCwd, - configHome: gentlePiConfigHome(deps.env), - resolveWorktree: pinIdentity, - })?.modelProfiles, - ), + sessionProfileRoutingAt(sessionProfile, { + cwd: target ?? parentCwd, + configHome: profileConfigHome, + resolveWorktree: pinIdentity, + foreignRepository: foreign, + }), ); + // Drift is measured against the defaults of the session's own directory, + // where the profile was frozen, never against a delegated target. + if (ctx.hasUI) { + const drift = inheritedProfileDriftNotice({ + sessionId: ctx.sessionManager.getSessionId(), + cwd: parentCwd, + configHome: profileConfigHome, + resolveWorktree: deps.resolveWorktree, + env: deps.env, + }); + if (drift !== undefined) ctx.ui.notify(drift, "info"); + } const profile = resolveAgentProfile(agent, config); if (admittedModel !== undefined) { const model = profile.model ?? ctx.model; @@ -2222,6 +2238,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = publishActivity(); }); pi.on("session_start", async (event, ctx) => { + // gentle-shell#1064 slice 3b-ii: a parent session that has no explicit + // profile binding freezes `p → P → G` now. This handler is parent-only (a + // child returns before registering it) and runs for every reason Pi + // reports, including `startup` for --resume/--continue/--session. The + // freeze is in-memory and idempotent per session id: /reload keeps it, a + // new process freezes again until slice 3b-i persists it as an entry. + try { + freezeSessionProfileAtStartup({ + sessionId: ctx.sessionManager.getSessionId(), + cwd: ctx.sessionManager.getCwd(), + configHome: gentlePiConfigHome(deps.env), + resolveWorktree: deps.resolveWorktree, + env: deps.env, + }); + } catch { /* A launch freezes lazily through the same resolver. */ } stateCache.load(ctx.sessionManager); // A resumed, reloaded, or replaced session starts with an empty completion // queue so nothing pending from another session can replay here. diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index bfb7ac8f2..b0869488b 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -4,8 +4,7 @@ import { execFile, spawnSync } from "node:child_process"; import { realpathSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts"; -import { readSessionProfileBinding } from "../lib/session-profile-binding.ts"; -import { resolveProfilePin } from "../lib/agent-profile-pin.ts"; +import { resolveSessionProfile, sessionProfileLabel, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts"; import * as os from "node:os"; import { dirname, join, resolve } from "node:path"; import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts"; @@ -202,23 +201,18 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): const reader = (() => bound ? effective : global()) as ActiveProfileReader; reader.refresh = () => { if (!bound) return false; - // gentle-shell#1064 slice 1: a parent-session profile binding outranks - // both the pin layers and the global active profile, with the same - // "name (scope)" spelling the pin uses. The shared precedence rule lives - // in one place: session → p (local pin) → P (repo declaration) → global. - const session = sessionId === undefined ? undefined : readSessionProfileBinding(sessionId); - if (session !== undefined) { - const next = `${session.name} (session)`; - const changed = next !== effective; - effective = next; - return changed; - } - const pin = identity && cwd ? resolveProfilePin({ + // gentle-shell#1064: the status shows the profile launches use, through + // the same single rule (explicit Enter binding → profile frozen at + // startup → live p → P → G only in follow mode), with the existing + // spelling: "name (session)", "name (local)", "name (repo)", or the bare + // global name. + const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({ + sessionId, cwd, configHome: env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"), - resolveWorktree: () => identity!, - }) : undefined; - const next = pin ? `${pin.profile} (${pin.source})` : global(); + resolveWorktree: () => identity, + env, + })); const changed = next !== effective; effective = next; return changed; @@ -1547,19 +1541,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // store and the profile reader cannot drift onto two different stores. const usageConfigHome = gentlePiConfigHome(env); const usageFetchTimeout = usageFetchTimeoutMs(env); - // The subagent routing in force for this session: a session binding first - // (gentle-shell#1064 slice 1), then a repository pin, then the global active - // profile. Only the profile's own role entries count; the reserved - // orchestrator key is not a route. + // The subagent routing in force for this session, through the same single + // rule launches use (gentle-shell#1064): explicit Enter binding, then the + // profile frozen at startup, then the live p → P → G only in follow mode. + // Only the profile's own role entries count; the reserved orchestrator key + // is not a route. const activeRoutingModels = (ctx: ExtensionContext): Array => { - const session = readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles; - if (session) return [...profileRoleEntries(session).map(([, entry]) => entry.model)]; - const pin = resolveProfilePin({ cwd: ctx.cwd, configHome: usageConfigHome, resolveWorktree: deps.resolveWorktree }); - const config = pin ? pin.modelProfiles : undefined; - if (config) return [...profileRoleEntries(config).map(([, entry]) => entry.model)]; - const store = readProfilesFileResult(profilesFilePath(usageConfigHome)); - const active = store.status === "valid" && store.file.active !== undefined ? store.file.profiles[store.file.active] : undefined; - return active ? profileRoleEntries(active).map(([, entry]) => entry.model) : []; + const config = sessionProfileModelProfiles(resolveSessionProfile({ + sessionId: ctx.sessionManager?.getSessionId?.(), + cwd: ctx.cwd, + configHome: usageConfigHome, + resolveWorktree: deps.resolveWorktree, + env, + })); + return config ? profileRoleEntries(config).map(([, entry]) => entry.model) : []; }; // A bare model id names a provider only when exactly one provider in the // registry carries that id; anything else stays untargeted rather than guessed. diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 344942d42..cd3b930b1 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -30,6 +30,7 @@ import { fakeChild, type FakeChild } from "./agents-fake-child.ts"; import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts"; import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts"; import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; +import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts"; import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts"; import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts"; // The card style defaults to float; these assertions pin the outlined (neon) @@ -92,6 +93,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-"))); const activeSessionTeardowns = new Set<() => Promise>(); const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown())); afterEach(stopActiveSessions); +// Every fixture session is "s1": the inherited-profile freeze is process state +// keyed by session id, so each test starts from an unfrozen session. +afterEach(() => resetFrozenInheritedProfilesForTesting()); // subagent_run's default mode now reads the background-subagents policy // in-process (gentle-pi#background-subagents-default-mode), which falls // back to the real ~/.pi/gentle-ai/background-subagents.json when @@ -353,7 +357,9 @@ function deps(): { deps: Partial; children: FakeChild[]; spawned: st pi: { command: "pi", args: [] }, home, resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }), - env: { PATH: "/bin" }, + // The launch reads the global active profile from the config home; keep it + // on the scratch directory instead of the developer's own profiles.json. + env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME }, sessionTransport: inertSessionTransport, }, }; @@ -1223,7 +1229,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws" return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true, source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false }; } }; - const env: NodeJS.ProcessEnv = {}; + const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME }; const profile = join(root, `metrics-${boundary}`); mkdirSync(join(profile, "agents"), { recursive: true }); writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url))); @@ -2227,6 +2233,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c try { const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => { const h = fakePi(); + // The launch reads the global active profile; keep it on the scratch config home. + env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME }; gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport }); const { ctx } = fakeContext(); (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd; @@ -2258,7 +2266,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c assert.equal(captured[index]?.command, "/fixture/pi"); assert.deepEqual(captured[index]?.args, args); assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd); - assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }); + assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }); assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell"); assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console"); assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection"); @@ -2825,7 +2833,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } }); ctx.sessionManager.getCwd = () => project; ctx.sessionManager.getEntries = () => h.entries as never; - await h.fire("session_start", ctx); + // The repository declaration exists before the session starts: the session + // freezes it at session_start (gentle-shell#1064 slice 3b-ii). if (scenario === "profile-model" || scenario === "profile-valid") { mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true }); writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" })); @@ -2833,6 +2842,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a mkdirSync(config, { recursive: true }); writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } })); } + await h.fire("session_start", ctx); let calls = 0; const abort = new AbortController(); const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => { @@ -3094,8 +3104,8 @@ function pinFixture(name: string) { declarationPath, writePin: (profile: string) => writePinText(localPinPath, profile), writeDeclaration: (profile: string) => writePinText(declarationPath, profile), - writeStore: (profiles: Record) => { - writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`); + writeStore: (profiles: Record, active?: string) => { + writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`); }, }; } @@ -3221,6 +3231,121 @@ test("a queued launch keeps the session routing frozen across a rebind", async t } }); +// gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter +// binding freezes p → P → G at session_start; later default changes only show +// a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live +// resolution. +async function launchSession(base: ReturnType, env: Record = {}) { + const harness = deps(); + harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir }); + harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env }; + const { pi, tools, fire } = fakePi(); + gentleAgents(pi, {}, harness.deps); + const { ctx, dialogs } = fakeContext(); + await fire("session_start", ctx); + let count = 0; + return { + dialogs, + driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")), + launch: async () => { + await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx); + await tick(); + const args = harness.spawned[count]; + // Settle the child so the next launch is not held by the concurrency cap. + const child = harness.children[count++]!; + child.emit({ type: "agent_end", messages: [] }); + child.emit({ type: "agent_settled" }); + child.exit(0); + await tick(); + return args[args.indexOf("--model") + 1]; + }, + shutdown: async () => { + await fire("session_shutdown", ctx); + await tick(); + }, + }; +} + +const DRIFT_PROFILES = { + frontier: { explore: { model: "openai/alpha", thinking: "minimal" } }, + local: { explore: { model: "openai/beta" } }, +}; + +test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => { + const base = pinFixture("freeze-pin"); + base.writeStore(DRIFT_PROFILES); + base.writePin("frontier"); + const session = await launchSession(base); + t.after(session.shutdown); + assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch"); + base.writePin("local"); + assert.equal(await session.launch(), "openai/alpha:minimal"); + assert.equal(await session.launch(), "openai/alpha:minimal"); + assert.deepEqual(session.driftNotices(), [ + 'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.', + ], "one notice for one change, across two launches"); + base.writeStore(DRIFT_PROFILES, "local"); + base.writeDeclaration("local"); + rmSync(base.localPinPath); + assert.equal(await session.launch(), "openai/alpha:minimal"); + assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change"); +}); + +test("an unpinned session routes through the global active profile and freezes it", async t => { + const base = pinFixture("freeze-global"); + base.writeStore(DRIFT_PROFILES, "frontier"); + const session = await launchSession(base); + t.after(session.shutdown); + base.writeStore(DRIFT_PROFILES, "local"); + assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing"); + assert.equal(session.driftNotices().length, 1); + assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/); +}); + +test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => { + const base = pinFixture("freeze-none"); + base.writeStore(DRIFT_PROFILES); + const session = await launchSession(base); + t.after(session.shutdown); + assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing"); + base.writePin("frontier"); + assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low"); + assert.deepEqual(session.driftNotices(), [ + 'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.', + ]); +}); + +test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => { + t.after(() => resetSessionProfileBindingsForTesting()); + const base = pinFixture("freeze-explicit"); + base.writeStore(DRIFT_PROFILES); + base.writePin("frontier"); + const session = await launchSession(base); + t.after(session.shutdown); + bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } }); + base.writePin("local"); + base.writeStore(DRIFT_PROFILES, "frontier"); + rmSync(base.localPinPath); + assert.equal(await session.launch(), "openai/beta:high"); + assert.deepEqual(session.driftNotices(), []); +}); + +test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => { + t.after(() => resetSessionProfileBindingsForTesting()); + const base = pinFixture("follow"); + base.writeStore(DRIFT_PROFILES); + base.writePin("frontier"); + const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" }); + t.after(session.shutdown); + assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes"); + assert.equal(await session.launch(), "openai/alpha:minimal"); + base.writePin("local"); + assert.equal(await session.launch(), "openai/beta:high"); + bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } }); + assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following"); + assert.deepEqual(session.driftNotices(), []); +}); + test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => { assert.equal(agentsEnabled({}), true); assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false); diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 06edeca6c..c1e0d852c 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -4,12 +4,17 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import test, { after } from "node:test"; +import test, { after, afterEach } from "node:test"; import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent"; import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui"; import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts"; import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts"; import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; +import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts"; + +// Fixture sessions share ids across tests; the inherited-profile freeze is +// process state keyed by session id (gentle-shell#1064 slice 3b-ii). +afterEach(() => resetFrozenInheritedProfilesForTesting()); import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; @@ -748,7 +753,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; }); const { pi, handlers } = fakePi(); let resolutions = 0; - gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + // follow mode keeps the live p → P → G resolution, so a pin edit reaches the + // open session through the poll (gentle-shell#1064 slice 3b-ii). + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, { resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; }, }); const first = fakeContext(); @@ -5929,7 +5936,10 @@ test("after a profile switch the panel stops presenting the old profile's provid ui.closeOverlay?.(); await opened; - writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: {} }, "solo"); + // An open session changes profile only through its own Enter (gentle-shell#1064 + // slice 3b-ii): a new global default would leave its frozen routing in place. + bindSessionProfile("shell-session", "solo", {}); + t.after(() => resetSessionProfileBindingsForTesting()); const reopened = commands.get("gentle:usage")!.handler("", ctx); await settle(); assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope"); @@ -5937,6 +5947,61 @@ test("after a profile switch the panel stops presenting the old profile's provid await reopened; }); +test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => { + const home = mkdtempSync(join(tmpdir(), "shell-frozen-")); + const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-")); + t.after(() => { + rmSync(home, { recursive: true, force: true }); + rmSync(commonDir, { recursive: true, force: true }); + }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team"); + const { pi, handlers, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) }); + const { ctx, ui } = fakeContext({ token: JWT }); + await fire(handlers, "session_start", ctx); + assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start"); + writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo"); + writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" })); + const opened = commands.get("gentle:usage")!.handler("", ctx); + await settle(); + assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route"); + ui.closeOverlay?.(); + await opened; + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + try { + const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n"); + assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile"); + assert.doesNotMatch(status, /solo/); + } finally { + component.dispose(); + } +}); + +test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const commonDir = join(root, "git"); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } })); + const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile })); + pin("other"); + const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }); + frozen.bind(root, () => ({ root, commonDir }), "session-frozen"); + assert.equal(frozen(), "other (local)"); + const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" }); + follow.bind(root, () => ({ root, commonDir }), "session-follow"); + assert.equal(follow(), "other (local)"); + rmSync(join(commonDir, "gentle-ai", "profile-pin.json")); + assert.equal(frozen.refresh(), false, "the frozen label does not move"); + assert.equal(frozen(), "other (local)"); + assert.equal(follow.refresh(), true); + assert.equal(follow(), "team", "follow falls to the global active profile"); + assert.equal(readFrozenInheritedProfile("session-follow"), undefined); +}); + function openPanelLines(ui: FakeUi): string[] { return ui.overlayView!.render(90).map(stripAnsi); } From 9c3fdb7eeac02d0925f6652c2a7e7c876194d938 Mon Sep 17 00:00:00 2001 From: Adrian Cester Trallero Date: Tue, 6 Oct 2026 14:08:27 +0200 Subject: [PATCH 2/2] docs(profiles): document the session profile frozen at startup Describe the single precedence rule, the startup freeze, the behavior change for unpinned sessions, the drift notice, GENTLE_PI_PROFILE_FOLLOW=1, foreign repositories, children, and the in-memory limits until slice 3b-i persists the freeze. --- docs/gentle-shell.md | 6 +++--- docs/readme-reference.md | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index daa343d73..5af6254fd 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -117,11 +117,11 @@ The panel rows a provider reports its windows with: glm5.3-flash ▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 11% · resets in 12d 17h ``` -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. Codex windows require a finite positive duration reported by the provider; missing or invalid durations are ignored, never shown as `0m` or replaced with an assumed `5h` quota. A real `0%` remains visible when its window is valid, and a response with no valid header windows leaves the previous snapshot untouched. The same refresh covers every provider the session targets: the active model's own provider plus every provider the active profile's subagent routing names — a repository pin decides which profile that is, falling back to the global active profile when no pin applies. Each provider keeps its own 5-minute window, its own stale-source guard, and its own last good snapshot. Providers refresh concurrently, each inside its own bounded window (default 10s, `GENTLE_PI_SHELL_USAGE_TIMEOUT_MS`): the abort signal reaches the underlying fetch — composed with the caller's own signal when it carries one, never replacing it — a provider that outlives its window wears the generic failure note, and whatever it answers afterwards is discarded — a late answer never replaces what the timeout settled, exactly like the stale-source guard above. -- A routing entry names its provider with a qualified ref (`provider/model`); a bare model id is resolved through the model registry only when exactly one provider carries that id, and is left untargeted rather than guessed when none or several do. The targeted scope is resolved when a refresh runs — at session start, on each turn's throttled refresh, on `r` or reopening the panel, and when a usage source registers — so a profile switch is picked up by the next refresh rather than live per render. +- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. Codex windows require a finite positive duration reported by the provider; missing or invalid durations are ignored, never shown as `0m` or replaced with an assumed `5h` quota. A real `0%` remains visible when its window is valid, and a response with no valid header windows leaves the previous snapshot untouched. The same refresh covers every provider the session targets: the active model's own provider plus every provider the session profile's subagent routing names — the same profile subagent launches use: the profile selected in this session, otherwise the profile the session froze at startup from the local pin, the repository declaration, or the global active profile (see [Session profile frozen at startup](readme-reference.md#session-profile-frozen-at-startup)). Each provider keeps its own 5-minute window, its own stale-source guard, and its own last good snapshot. Providers refresh concurrently, each inside its own bounded window (default 10s, `GENTLE_PI_SHELL_USAGE_TIMEOUT_MS`): the abort signal reaches the underlying fetch — composed with the caller's own signal when it carries one, never replacing it — a provider that outlives its window wears the generic failure note, and whatever it answers afterwards is discarded — a late answer never replaces what the timeout settled, exactly like the stale-source guard above. +- A routing entry names its provider with a qualified ref (`provider/model`); a bare model id is resolved through the model registry only when exactly one provider carries that id, and is left untargeted rather than guessed when none or several do. The targeted scope is resolved when a refresh runs — at session start, on each turn's throttled refresh, on `r` or reopening the panel, and when a usage source registers — so a profile selected in the session (or a default change in `follow` mode) is picked up by the next refresh rather than live per render. - For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. - For NaN Cloud, usage comes from the quota endpoint the official dashboard reads, with the same API key pi already holds. Each metered model reports one allowance for the billing period, and that window carries no label: the model id names it in the bar and the reset text says what it is in the panel. A model that also reports a rolling window shows that one labeled next to it (`4h`), which today's payload does not send; percentages are tokens used over the allowance, exactly as the dashboard draws them, and the allowance is the full-period cap (`fullCap`) whenever the model reports a positive one, because `cap` alone is the prorated allowance of the period in progress. It is fetched under the same 5-minute rule as Codex, counted per provider so a switch fetches the provider it switched to, refuses redirects so the bearer cannot be replayed to another origin, and keeps no cached copy. The endpoint sits outside NaN's published OpenAPI, so the parser reads it defensively: a model that reports no allowance is skipped, as the dashboard skips it, while a metered model whose usage cannot be read fails the whole read, so a partial payload never replaces a complete snapshot with a cheaper-looking one. A session that already has a snapshot keeps the last valid one through a malformed payload or a failed fetch, and the pending note appears only while there is nothing to draw. -- Extensions can register a usage source for their own provider: gentle-shell has no built-in knowledge of it, but treats it exactly like Codex or NaN once registered. Emit `gentle-pi:usage-source/v1` on `pi.events` with `{ schema: "gentle-pi.usage-source/v1", provider, pendingNote?, fetch(apiKey, fetchFn, now) }`, where `fetch` resolves a `ProviderUsage` the same shape the built-in providers produce, or `undefined` when there is nothing to show yet. A malformed payload, a `fetch` that isn't a function, a provider id outside the safe id pattern, or a `fetch` call that throws or rejects is ignored rather than crashing the shell. The `fetchFn` a source receives is bounded the same way: once the provider's window expires it aborts, and a later resolution is discarded. Re-registering the same provider replaces its source, so emitting again at every `session_start` is safe and keeps load order irrelevant. Once registered, the provider shows `pendingNote` (or the same "no usage yet · r to fetch" default the built-ins use) until its first fetch, and a registration that arrives after the session already started, for any targeted provider (the session's own or a subagent route of the active profile), triggers one immediate refresh of that provider instead of waiting for the next turn or the 5-minute window. Example, using a neutral provider id: +- Extensions can register a usage source for their own provider: gentle-shell has no built-in knowledge of it, but treats it exactly like Codex or NaN once registered. Emit `gentle-pi:usage-source/v1` on `pi.events` with `{ schema: "gentle-pi.usage-source/v1", provider, pendingNote?, fetch(apiKey, fetchFn, now) }`, where `fetch` resolves a `ProviderUsage` the same shape the built-in providers produce, or `undefined` when there is nothing to show yet. A malformed payload, a `fetch` that isn't a function, a provider id outside the safe id pattern, or a `fetch` call that throws or rejects is ignored rather than crashing the shell. The `fetchFn` a source receives is bounded the same way: once the provider's window expires it aborts, and a later resolution is discarded. Re-registering the same provider replaces its source, so emitting again at every `session_start` is safe and keeps load order irrelevant. Once registered, the provider shows `pendingNote` (or the same "no usage yet · r to fetch" default the built-ins use) until its first fetch, and a registration that arrives after the session already started, for any targeted provider (the session's own or a subagent route of the session profile), triggers one immediate refresh of that provider instead of waiting for the next turn or the 5-minute window. Example, using a neutral provider id: ```ts pi.events.emit("gentle-pi:usage-source/v1", { diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 0c2098659..3077b1eaa 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -945,12 +945,14 @@ Both use the same shape, and both are a separate artifact from `profiles.json`: ``` The fullscreen shell header and Status → Project → Profile show the effective profile for the session -repository: `name (local)` for a clone-local pin, `name (repo)` for a repository declaration, or the -global active name without a suffix. Invalid or stale pins fall through to the next valid layer. -Changes made inside or outside the profiles panel appear within about two seconds while the UI -session is active; the indicator is omitted if no valid profile remains. +repository: `name (session)` for a profile selected in this session, `name (local)` for a clone-local +pin, `name (repo)` for a repository declaration, or the global active name without a suffix. Invalid +or stale pins fall through to the next valid layer. A session shows the profile it froze at startup +(see **Session profile frozen at startup** below); a selection in this session, or any default change +in `follow` mode, appears within about two seconds while the UI session is active. The indicator is +omitted if no valid profile remains. -For a given working directory the winner is the local pin, then the repository declaration, then no pin. With no pin at all the repository keeps the behavior described above and follows the globally active profile. `p` and `P` are toggles: pressing one on the profile that already holds that layer removes it, and either key pressed outside a Git worktree writes nothing and says so. +For a given working directory the winner is the local pin, then the repository declaration, then the globally active profile. A session resolves these layers once, when it starts, and keeps the result. `p` and `P` are toggles: pressing one on the profile that already holds that layer removes it, and either key pressed outside a Git worktree writes nothing and says so. In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `a` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so a global apply can never move another repository's routing. `enter` writes nothing at all: it binds the profile to the current session, which outranks the pin for this session's launches. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. @@ -972,6 +974,34 @@ The orchestrator sits deliberately outside the pin. Its `defaultProvider`, `defa One limitation is worth stating. When a pinned profile omits an agent, that agent's own frontmatter still applies, so a model that an earlier global apply materialized into a user agent's frontmatter can still be inherited. Frontmatter cannot be told apart from content an author wrote, so a pin does not clear it. +### Session profile frozen at startup + +Every parent session uses one profile for its subagent launches, the footer and Status profile, and the Usage provider scope. All three read the same rule: + +1. The profile selected in this session (`name (session)`). +2. Otherwise, the profile the session froze when it started. +3. Otherwise, only in `follow` mode, the current defaults. + +A session that never selected a profile resolves the shared defaults once, at startup: the local pin (`p`), then the repository declaration (`P`), then the globally active profile in `profiles.json`. It keeps that profile's name, routing, and origin (`local`, `repo`, or `global`). Changing a pin, the repository declaration, the active profile, or the profile's content afterwards affects new sessions only, the same way Pi's "set as default" for the orchestrator model leaves open sessions alone. Outside a Git worktree the repository declaration is still read, as non-Git writer admission does. + +**Behavior change for unpinned sessions.** Without a pin or declaration, launches now use the globally active profile from `profiles.json`, replacing the materialized `subagents.json` routing wholesale like a pin does. Before, an unpinned launch read the materialized stores. When nothing is pinned and no profile is active, the session freezes "no profile" and keeps routing through the materialized stores, as before; a pin added later does not change it. + +**Drift notice.** On each subagent launch, a session with a frozen profile compares it with the current defaults of its own directory, without applying them. When they differ, it shows one line: + +```text +el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it. +``` + +The notice appears once per distinct change: further launches stay quiet until the defaults change again. The same profile reached through another layer is not a change; an edit of the profile's content is. Sessions with a profile selected in the session, `follow` sessions, and subagent children never show it. + +**Foreign repositories.** A selected or frozen profile belongs to the session and also routes launches into a foreign `repository_root`. A session frozen without a profile leaves a foreign repository on its own local pin and repository declaration; the globally active profile does not apply there. + +**`follow` mode.** Start Pi with `GENTLE_PI_PROFILE_FOLLOW=1` (CI and headless runs) to skip the freeze: every launch resolves the current defaults of its target, and no drift notice is shown. Any other value, or no value, freezes. Selecting a profile with Enter still makes it the session's profile. Limitation: the orchestrator model comes from the startup profile, so after a later default change the orchestrator and the subagents can follow different profiles. + +**Subagent children** never freeze, compare, or warn: their model comes from the parent's launch. + +**Current limits.** The frozen profile is kept in memory for the life of the Pi process, so `/reload` keeps it. Resuming a session in a new process (`--resume`, `--continue`, `--session`), `/new`, and `/fork` freeze again from the defaults current at that moment, and the drift notice memory starts empty, so a pending change is announced once more. Persisting the frozen profile in the session file is planned. + ## Commands | Command | What it does |