Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
13 changes: 11 additions & 2 deletions docs/readme-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,7 @@ Profiles are named, switchable snapshots of the global agent-model routing from

| Key | Action |
| ------- | ---------------------------------------------------------------------- |
| `enter` | Apply the selected profile to this session only: switch its live orchestrator when defined and bind its subagent and reviewer routing snapshot. Shared defaults and pins are untouched. |
| `enter` | Apply the selected profile to this session only: append its session record, bind its subagent and reviewer routing snapshot, then switch its live orchestrator when defined. Shared defaults and pins are untouched. |
| `a` | Explicitly set the selected profile as the global default and materialize routing. Inside a pinned repository it updates the clone-local pin instead and leaves the orchestrator unchanged; see **Per-repository pins** below. |
| `c` | Create a new, empty profile. |
| `s` | Snapshot the current routing into the selected profile (including the orchestrator currently set in `settings.json`); live routing is unchanged. |
Expand All @@ -830,7 +830,16 @@ Profiles are named, switchable snapshots of the global agent-model routing from
| `pgup`/`pgdn`, `ctrl+j`/`ctrl+k` | Scroll the detail pane by a page. |
| `esc` | Close. |

Selecting a profile with Enter does not write `profiles.json`, `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. The binding lasts in the current process (including `/reload`); resuming in another process requires selecting it again.
Selecting a profile with Enter does not write `profiles.json`, `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. Already queued or running requests keep their frozen routing.

| Outcome | What happens |
| --- | --- |
| Persisted | The selected record is corroborated on disk before routing is published. |
| Not yet persisted | Enter explicitly reports a preflush selection. It is usable now, but detach/reload does not restore it without later disk corroboration. |
| Append failure | No success or live-model effect. Keep only returned disk-corroborated state; otherwise block affected launches. |
| Invalid, future or unavailable authority | Do not fall through to shared routing; affected subagent launches are blocked. An explicit successful selection or fresh corroboration can restore availability. |

Start, resume, reload, fork and tree navigation restore only the current corroborated branch state. Restoration never applies the profile's orchestrator. Fork capture is read-only until a confirmed transition; cancellation/rejection preserves current authority. Pending Enter live operations settle before fork-copy, tree movement or detach.

Explicitly setting the global default with `a` writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. A profile is a complete snapshot: every discoverable agent it omits returns to inherit, so routing materialized by a previous profile, by `/gentle:models`, or by a migration never survives a switch silently. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked.

Expand Down
57 changes: 57 additions & 0 deletions docs/session-profile-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Session profile record format

`lib/session-profile-persistence.ts` defines the optional session profile v1
codec and pure replay contract. Adding this module does not change Enter,
startup, routing, shared defaults, or live orchestrator behavior.

## Payloads

A custom entry uses `customType: "gentle-pi.session-profile/v1"` and one payload:

```json
{"kind":"bind","origin":"user","name":"work","modelProfiles":{"worker":{"model":"provider/model"},"orchestrator":{"thinking":"high"}}}
{"kind":"clear"}
```

The encoder creates only explicit `user` selections. The decoder accepts the
closed origin set `user`, `local`, `repo`, and `global`; this does not implement
inherited startup persistence or follow mode. A bound empty snapshot is not a
clear. Snapshots are detached, known invalid fields reject the whole binding,
and unknown extra fields carry no routing meaning. Existing route normalization
supports legacy model strings and `effort`; valid `thinking` takes precedence.

## Replay

`replaySessionProfileBranch` requires entries already corroborated on disk,
ordered oldest to newest on the active branch. Supplying `getBranch()` alone
does not establish persistence. The newest profile-family entry is terminal:

| Result | Meaning |
| --- | --- |
| `absent` | No profile-family entry on the supplied branch. |
| `bound` | A detached, validated binding, including an empty snapshot. |
| `cleared` | An explicit clear; fallback is a later consumer's decision. |
| `invalid` | Invalid v1 data; never resurrect an earlier binding. |
| `unsupported` | Unknown profile-family identifier, regardless of payload. |

Replay neither appends nor publishes bindings and never applies a model or
thinking level. A subsequent disk-reader slice supplies corroboration; this
codec alone cannot establish it.

## Disk corroboration

`readSessionProfileDisk(source)` in `lib/session-profile-disk-reader.ts` is the
stateless synchronous check that supplies the corroboration replay requires.
It reads the session's public active branch and its JSONL file, selects the
newest profile-family entry on the branch (excluding caller-supplied known
failed append IDs), and admits it only when the record on disk is identical.
It performs no writes, fallback policy, caching, ancestry repair or fsync, and
a missing file is conservative: it never restores a profile found only in
memory.

The result is the decoder result plus `entryIndex` and `lineNumber`, or
`indeterminate` with one reason: `missing-source`, `invalid-candidate-metadata`,
`unreadable-file`, `source-changed`, `invalid-json`, `invalid-record`,
`session-header-mismatch`, `duplicate-id`, `missing-header`,
`selected-record-missing`, `selected-record-mismatch`, or
`unserializable-source`. Reasons never carry record contents.
9 changes: 7 additions & 2 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ 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 { sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
import { requireSessionProfileAuthority } from "../lib/session-profile-authority.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";
Expand Down Expand Up @@ -1542,7 +1543,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
// 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());
const sessionBinding = requireSessionProfileAuthority(ctx.sessionManager).binding;
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.");
Expand All @@ -1559,6 +1560,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed.");
}
requireSessionProfileAuthority(originalManager);
const parentCwd = ctx.sessionManager.getCwd();
// An explicit target is validated before any queue or session-dir writes.
const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
Expand Down Expand Up @@ -1602,6 +1604,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
// with the same wholesale-replacement contract as the pin. The binding is
// resolved here, at task-request creation, so queued and running children
// keep the routing frozen into their requests even if the session rebinds.
requireSessionProfileAuthority(originalManager);
const config = withPinnedModelProfiles(
loadAgentsConfig(roots(ctx)),
sessionOrPinModelProfiles(
Expand Down Expand Up @@ -2037,6 +2040,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
},
},
async (params, ctx, signal) => {
requireSessionProfileAuthority(ctx.sessionManager);
const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined;
const manager = ctx.sessionManager, sessionId = manager.getSessionId();
if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label.");
Expand Down Expand Up @@ -2122,6 +2126,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
"Resume a finished subagent task in its own session with a follow-up prompt.",
{ required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } },
async (params, ctx, signal) => {
requireSessionProfileAuthority(ctx.sessionManager);
if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
const previous = await resolveTask(String(params.task_id));
if (!previous) return unknownTask(params.task_id, ctx);
Expand Down
98 changes: 83 additions & 15 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ import {
type ProfilePinSource,
type ProfilePinStatus,
} from "../lib/agent-profile-pin.ts";
import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts";
import { clearSessionProfileBinding, readSessionProfileBinding } from "../lib/session-profile-binding.ts";
import { createSessionProfileIntegration, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts";
import { publishSessionProfileOutcome, readSessionProfileAuthority } from "../lib/session-profile-authority.ts";
import {
applyOrchestratorSettings,
readOrchestratorSettings,
Expand Down Expand Up @@ -4243,9 +4245,10 @@ async function runProfilesPanelAction(
switch (result.type) {
case "apply": {
if (!hasOwnProfile(file.profiles, result.name)) return file;
// Enter applies a complete routing snapshot to this parent session and
// switches only its live orchestrator. No shared defaults, materialized
// stores, agent frontmatter, pins, or declarations are written.
// Enter persists a complete routing snapshot for this parent session,
// publishes only the returned state, then switches its live orchestrator.
// No shared defaults, materialized stores, agent frontmatter, pins, or
// declarations are written.
const sessionId = ctx.sessionManager?.getSessionId?.();
if (typeof sessionId !== "string" || sessionId.length === 0) {
ctx.ui.notify(
Expand All @@ -4254,14 +4257,44 @@ async function runProfilesPanelAction(
);
return file;
}
const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {};
bindSessionProfile(sessionId, result.name, snapshot);
const orchestrator = readProfileOrchestrator(snapshot);
const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator);
ctx.ui.notify(
`el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`,
"info",
);
const selected = await runCurrentSessionProfileSelection(ctx.sessionManager, async (attachment, owned) => {
const outcome = attachment.bind(result.name, normalizeModelConfig(file.profiles[result.name]) ?? {});
if (!owned()) return false;
const authority = publishSessionProfileOutcome(ctx.sessionManager, outcome);
if ((outcome.status !== "persisted" && outcome.status !== "not-persisted") || outcome.state.status !== "bound" || !authority.available) {
ctx.ui.notify(
`el Gentleman could not bind profile "${result.name}". ${authority.available ? "The last disk-corroborated state was preserved." : "Session profile authority is unavailable; affected launches are blocked."} No live model or thinking change was applied.`,
"warning",
);
return false;
}
const snapshot = outcome.state.binding;
// The live switch applies only while this exact snapshot is still current.
const current = () => {
if (!owned()) return false;
const state = readCurrentSessionProfileOutcome(ctx.sessionManager)?.state;
return state?.status === "bound" && JSON.stringify(state.binding) === JSON.stringify(snapshot);
};
const orchestrator = readProfileOrchestrator(snapshot.modelProfiles);
const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, {
setModel: (model) => current() ? live.setModel(model) : Promise.resolve(false),
setThinkingLevel: (level) => { if (current()) live.setThinkingLevel(level); },
getThinkingLevel: () => live.getThinkingLevel(),
}, orchestrator);
if (current())
ctx.ui.notify(
`el Gentleman bound profile "${snapshot.name}" to this session — shown as "${snapshot.name} (session)"; ${outcome.status === "persisted" ? "persisted in this session" : "not yet persisted; active only until detach or disk corroboration"}. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`,
"info",
);
return true;
});
// A selection skipped by a reload or session replacement leaves a stale
// ctx; only a still-active command reports the missing attachment.
if (selected === undefined && isCommandContextActive(ctx))
ctx.ui.notify(
`el Gentleman cannot bind profile "${result.name}": the current session attachment is unavailable. No profile or live model change was applied.`,
"warning",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return file;
}
case "apply-global": {
Expand Down Expand Up @@ -4869,11 +4902,16 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
while (result.type !== "close") {
const report: ProfilesPanelReport = {};
file = await runProfilesPanelAction(ctx, live, path, file, result, report);
// A reload or session replacement while an action or read awaited makes
// this command ctx stale; the panel closes instead of touching it again.
if (!isCommandContextActive(ctx)) return;
selectedName = report.selectedName ?? ("name" in result ? result.name : undefined);
const routing = await currentRoutingForPanel();
if (!isCommandContextActive(ctx)) return;
result = await showProfilesPanel(
ctx,
file,
await currentRoutingForPanel(),
routing,
selectedName,
saveSnapshot,
report.status,
Expand All @@ -4882,6 +4920,16 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
}
}

/** Pi's runner throws on any access to a ctx made stale by reload or session replacement. */
function isCommandContextActive(ctx: ExtensionContext): boolean {
try {
void ctx.sessionManager;
return true;
} catch {
return false;
}
}

async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
const current = readPersonaMode(ctx.cwd);
const selected = await ctx.ui.select(
Expand Down Expand Up @@ -9447,12 +9495,28 @@ function createGentleAiExtensionForTesting(
};

const reviewSidebar = createReviewSidebarPublisher(pi);
pi.on("session_tree", (_event, ctx) => reviewSidebar.reset(ctx));
const sessionProfiles = createSessionProfileIntegration();
pi.on("session_before_fork", async (event, ctx) => {
await waitCurrentSessionProfileSelection(ctx.sessionManager);
sessionProfiles.beforeFork(ctx.sessionManager, { entryId: event.entryId, position: event.position });
});
pi.on("session_before_tree", async (_event, ctx) => {
await waitCurrentSessionProfileSelection(ctx.sessionManager);
});
pi.on("session_tree", (_event, ctx) => {
reviewSidebar.reset(ctx);
readSessionProfileAuthority(ctx.sessionManager);
});
let reminderSessionActive = true;
let reminderEpoch = 0;
let reminderManager: ExtensionContext["sessionManager"] | undefined;
let unbindPreparation: (() => void) | undefined;
pi.on("session_shutdown", (event, context) => {
pi.on("session_shutdown", async (event, context) => {
await waitCurrentSessionProfileSelection(context.sessionManager);
if (sessionProfiles.isCurrent(context.sessionManager)) {
clearSessionProfileBinding(context.sessionManager.getSessionId());
sessionProfiles.shutdown(context.sessionManager, event);
}
yolo.reset(context);
reviewSidebar.reset();
reminderSessionActive = false;
Expand Down Expand Up @@ -9829,6 +9893,10 @@ function createGentleAiExtensionForTesting(
}));

pi.on("session_start", async (event, ctx) => {
const profileSessionId = ctx.sessionManager?.getSessionId?.();
if (profileSessionId) clearSessionProfileBinding(profileSessionId);
sessionProfiles.start(ctx.sessionManager, (type, data) => pi.appendEntry(type, data), event);
readSessionProfileAuthority(ctx.sessionManager);
yolo.reset(ctx);
reviewSidebar.reset(ctx);
elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi);
Expand Down
Loading