Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/readme-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1030,11 +1030,11 @@ A project can still override the global default with:
╰──────────────────────────────────────────────────────────────────────────────╯
```

- **Tabs are profiles.** Click a tab or press `←` `→` to show another one; the screen redraws in place. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**.
- **Tabs are profiles.** Click a tab or press `←` `→` to show another one; the screen redraws in place. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. `◉` marks the profile this session's launches use: the one chosen with **Use in this session**, or the one it took at startup (see **Session profile frozen at startup**), so it can differ from `★` and `⌂` after they change. The screen opens on that profile. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**.
- **The table is that profile's routing.** The orchestrator is the first row; agents follow. Click a model or press `enter` to choose from a searchable list (the current one is marked `✓`; **Custom model id…** takes any `provider/model`). Click an effort or press `e` to pick from a single row of levels: only the levels that row's model supports, as its provider's catalog declares them (every level when the row inherits its model or the model is not in the catalog). Pi lands an unsupported level on the nearest supported one at launch, and the table says where: `max → high`. Click `Model ▾` or `Effort ▾`, or move the cursor to that header, to change every agent at once; the orchestrator is never part of that.
- **Three buttons decide where it applies.** **Use in this session** (`u`) binds what you see to this Pi session only and switches its orchestrator; nothing shared is written. **Make default** (`d`) makes it the default for new sessions through the guarded global apply (diff confirmation, pin honesty, rollback). **Save** (`s` or `ctrl+s`) writes what you see into the profile. Use and Make default save pending edits first and say so.
- **The profile menu**, `▾` on the active tab (`m`), holds **Rename**, **Duplicate**, **Use in this repo** (the clone pin; it reads **Stop using in this repo** when it already applies), and **Delete**. **+ New** (`n`) starts a profile as a copy of the current one, from what is running now, or empty.
- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), and routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one.
- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing), or edited there since (the table shows the saved routing, the launches keep the one they took, and Use in this session adopts the table). When the session profile record cannot be read, the screen says its launches are blocked, marks no tab with `◉`, and refuses From what's running now until Use in this session selects a profile.
- Edited values are highlighted until saved, and the footer counts them (`● 2 unsaved`). `esc` closes; with unsaved edits the first press warns and the second closes without saving. Every action reports on the line under the footer.
- Everything clickable highlights under the pointer, and a click runs one action. Keys: `←→` profile, `↑↓` agent, `enter` model, `e` effort, `s` save, `u` use, `d` default, `n` new, `m` the profile menu, `esc` close.

Expand Down Expand Up @@ -1115,7 +1115,7 @@ Outside a pinned repository, **Make default** writes `~/.pi/gentle-ai/models.jso

A profile also carries the orchestrator under the reserved routing key `orchestrator`. Outside a pinned repository, **Make default** on a profile that defines one writes `defaultProvider`, `defaultModel`, and `defaultThinkingLevel` to Pi's global `settings.json` (preserving every other key; an unreadable `settings.json` aborts that part and is reported instead of being overwritten) and switches the session you are in to that model and thinking level right away, so the orchestrator answers with the profile's model from the next turn. **Use in this session** skips the settings write and switches only the session you are in: same live move, nothing persisted. When the model is not in Pi's catalog or its provider has no authentication, **Make default** still records the default for new sessions and says this session kept its current model, while **Use in this session** simply reports the refusal. Applying or binding a profile without an `orchestrator` entry never moves the orchestrator, and **+ New › From what's running now** captures the orchestrator the session actually runs together with the routing. `orchestrator` is reserved: it is not a subagent name, is never written to `subagents.json`, and is not counted as a role.

The `current` seed and **From what's running now** read the routing in effect. A valid local pin or repository declaration wins first: inside a pinned repository they read the pinned profile's routing. **From what's running now** also prefers this session's binding when it has one. Otherwise they read `models.json` where it has an entry, and the `subagents.json` model profile or frontmatter routing the runtime actually resolves for agents it omits. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.
**From what's running now** captures what this session's launches use: its `◉` profile when it has one, otherwise the global routing below. A pin written after the session started does not reach its launches, so it is not captured. The `current` seed reads the routing in effect: a valid local pin or repository declaration wins first, so inside a pinned repository it reads the pinned profile's routing. Otherwise both read `models.json` where it has an entry, and the `subagents.json` model profile or frontmatter routing the runtime actually resolves for agents it omits. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.

Saved globally at:

Expand Down
93 changes: 80 additions & 13 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import { clearSessionProfileBinding, readSessionProfileBinding } from "../lib/se
import { createSessionProfileIntegration, hasPendingSessionProfileSelection, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts";
import { publishSessionProfileOutcome, readSessionProfileAuthority } from "../lib/session-profile-authority.ts";
import { isSessionProfileFamilyEntry } from "../lib/session-profile-persistence.ts";
import { resolveSessionProfile, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts";
import {
applyOrchestratorSettings,
readOrchestratorSettings,
Expand Down Expand Up @@ -2501,6 +2502,44 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
return effective;
}

/**
* The profile this session's launches resolve (gentle-shell#1064 slice 3c): the
* Enter binding, then the profile frozen at startup, through the same rule the
* launcher reads. It resolves from the session's directory, as the freeze and
* the launcher do. `undefined` means the session resolves no profile, so
* launches keep the global routing; a pin the session did not freeze never
* reaches them and is not read here. Without session profile authority the
* launches are blocked, so no profile is theirs.
*/
function sessionLaunchProfile(ctx: ExtensionContext, authorityAvailable: boolean): { name: string; modelProfiles: AgentModelConfig } | undefined {
if (!authorityAvailable) return undefined;
const resolution = resolveSessionProfile({
sessionId: ctx.sessionManager?.getSessionId?.(),
cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd,
configHome: gentleAiConfigHome(),
});
const name = resolution.kind === "explicit" ? resolution.name : resolution.profile?.name;
const routing = sessionProfileModelProfiles(resolution);
return name === undefined || routing === undefined ? undefined : { name, modelProfiles: cloneModelConfig(routing) };
}

/**
* Whether this session's launches may run, read as the launcher reads it (and,
* like it, clearing a binding it cannot corroborate). A manager without a
* branch has no session profile record to distrust.
*/
function sessionProfileAuthorityAvailable(ctx: ExtensionContext): boolean {
const manager = ctx.sessionManager;
if (typeof manager?.getSessionId !== "function" || typeof manager.getBranch !== "function") return true;
return readSessionProfileAuthority(manager).available;
}

/** The routing this session's launches use: its profile, or the global effective routing. */
async function readSessionLaunchModelConfigAsync(ctx: ExtensionContext, authorityAvailable: boolean): Promise<AgentModelConfig> {
return sessionLaunchProfile(ctx, authorityAvailable)?.modelProfiles
?? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, await readModelConfigAsync(ctx.cwd));
}

async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
const pinned = pinnedEffectiveModelConfig(cwd);
if (pinned) return pinned;
Expand Down Expand Up @@ -4496,15 +4535,29 @@ function routingScreenNotices(
file: AgentProfilesFile,
shownName: string,
sessionBoundName: string | undefined,
launchRouting?: AgentModelConfig,
authorityAvailable = true,
): string[] {
const notices: string[] = [];
if (!authorityAvailable) notices.push("Session profile authority is unavailable; affected launches are blocked. Use in this session selects one.");
const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles);
for (const issue of pins.invalid) {
notices.push(`The ${issue.source} pin file ${issue.path} is not a valid pin and is ignored. Fix or remove it.`);
}
for (const issue of pins.stale) {
notices.push(`The ${issue.source} pin names "${issue.profile}", which no profile defines, so it is ignored (${issue.path}).`);
}
if (sessionBoundName !== undefined && !hasOwnProfile(file.profiles, sessionBoundName)) {
notices.push(`This session launches with "${sessionBoundName}", which is no longer in profiles.json; its launches keep that routing.`);
}
// The table shows the saved profile; the session's launches keep the routing
// they took at the freeze or the binding. Both sides compare role routes only.
const roleRoutes = (config: unknown) => JSON.stringify(Object.entries(normalizeModelConfig(config) ?? {})
.filter(([key]) => !isProfileOrchestratorKey(key)).sort(([a], [b]) => a.localeCompare(b)));
if (shownName === sessionBoundName && launchRouting !== undefined && hasOwnProfile(file.profiles, shownName)
&& roleRoutes(file.profiles[shownName]) !== roleRoutes(launchRouting)) {
notices.push(`Saved "${shownName}" differs from this session's launches; Use in this session adopts it.`);
}
const roots = {
cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd,
home: homedir(),
Expand All @@ -4531,9 +4584,10 @@ function routingScreenNotices(
if (entry.suggestion !== undefined) notices.push(`Profile key "${entry.key}" matches no agent configured in gentle-shell; did you mean ${entry.suggestion}?`);
}
// Issue #2400: model_profiles keys in subagents.json that match nothing and
// suggest nothing. A session binding or a winning pin replaces those launches'
// routing, so the file's keys do not apply there.
if (sessionBoundName === undefined && pinnedEffectiveModelConfig(ctx.cwd) === undefined) {
// suggest nothing. When the session resolves a profile (its binding or the one
// frozen at startup), that profile replaces those launches' routing, so the
// file's keys do not apply there; a pin the session did not freeze does not.
if (sessionBoundName === undefined) {
const stray = unmatchedProfileKeys(loadAgentsConfig(roots).modelProfiles, discovered)
.filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key) && !profileKeys.includes(entry.key))
.map((entry) => entry.key);
Expand Down Expand Up @@ -4609,7 +4663,12 @@ async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promi
: read.status === "missing"
? ({ name: "current", source: "active" } as CurrentProfileTarget)
: undefined;
await openProfilesPanelLoop(ctx, live, target?.name);
// The screen opens on the profile this session launches with while the store
// still has it (gentle-shell#1064 slice 3c).
const authorityAvailable = sessionProfileAuthorityAvailable(ctx);
const launchName = sessionLaunchProfile(ctx, authorityAvailable)?.name;
const opensOnLaunch = read.status === "valid" && launchName !== undefined && hasOwnProfile(read.file.profiles, launchName);
await openProfilesPanelLoop(ctx, live, opensOnLaunch ? launchName : target?.name, authorityAvailable);
}

/** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */
Expand Down Expand Up @@ -5612,18 +5671,20 @@ async function openProfilesPanelLoop(
ctx: ExtensionContext,
live: LiveSession,
initialSelectedName?: string,
openingAuthority?: boolean,
): Promise<void> {
const store = await loadProfilesStore(ctx);
if (!store) return;
const { path } = store;
let file = store.file;
const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile;
let selectedName = initialSelectedName
?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name
?? Object.keys(file.profiles)[0];
let feedback: string | undefined;
let draftOverride: AgentModelConfig | undefined;
// Read once per visit: the opening read serves the first one.
let authorityAvailable = true;
const writeDraft = (name: string, config: AgentModelConfig): boolean => {
try {
const next = hasOwnProfile(file.profiles, name)
Expand All @@ -5645,12 +5706,13 @@ async function openProfilesPanelLoop(
return { error };
}
selectedName = name;
const boundName = sessionBoundName();
// The profile this session launches with, bound or frozen: the tab marked ◉.
const launch = sessionLaunchProfile(ctx, authorityAvailable);
return {
file,
sessionBoundName: boundName,
sessionBoundName: launch?.name,
pinWinner: pinWinner(),
notices: routingScreenNotices(ctx, file, name, boundName),
notices: routingScreenNotices(ctx, file, name, launch?.name, launch?.modelProfiles, authorityAvailable),
feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`,
};
};
Expand Down Expand Up @@ -5690,15 +5752,17 @@ async function openProfilesPanelLoop(
}
const shown = selectedName;
const stored = cloneModelConfig(normalizeModelConfig(file.profiles[shown]) ?? {});
const boundName = sessionBoundName();
authorityAvailable = openingAuthority ?? sessionProfileAuthorityAvailable(ctx);
openingAuthority = undefined;
const launch = sessionLaunchProfile(ctx, authorityAvailable);
const result = await step(() => showRoutingTablePanel(ctx, {
name: shown,
config: draftOverride ?? stored,
file,
sessionBoundName: boundName,
sessionBoundName: launch?.name,
pinWinner: pinWinner(),
feedback,
notices: routingScreenNotices(ctx, file, shown, boundName),
notices: routingScreenNotices(ctx, file, shown, launch?.name, launch?.modelProfiles, authorityAvailable),
switchProfile,
}));
draftOverride = undefined;
Expand Down Expand Up @@ -5772,6 +5836,10 @@ async function openProfilesPanelLoop(
continue;
}
case "create-live": {
if (!authorityAvailable) {
feedback = `${savedNote}Session profile authority is unavailable; affected launches are blocked. No profile created.`;
continue;
}
const answer = await step(() => ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot"));
const name = answer?.trim() ?? "";
if (name === "") {
Expand All @@ -5781,8 +5849,7 @@ async function openProfilesPanelLoop(
// Captures the routing this session's launches resolve and the
// orchestrator it actually runs, the way the old snapshot did.
const snapshot = profileSnapshotFrom(
readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
?? await step(() => readEffectiveModelConfigAsync(ctx.cwd)),
await step(() => readSessionLaunchModelConfigAsync(ctx, authorityAvailable)),
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
);
try {
Expand Down
Loading
Loading