gentle-pi 2.6.3 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/gentle-shell.md +30 -18
- package/docs/readme-reference.md +8 -8
- package/extensions/gentle-agents.ts +8 -0
- package/extensions/gentle-ai.ts +174 -8
- package/extensions/gentle-shell.ts +52 -70
- package/lib/agents-runner.ts +7 -2
- package/lib/native-review-cli.ts +9 -0
- package/lib/session-change-capture.ts +87 -0
- package/lib/session-changes.ts +140 -0
- package/lib/shell-bar.ts +6 -1
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +4 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +14 -0
- package/tests/gentle-agents.test.ts +34 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +97 -1
- package/tests/gentle-shell.test.ts +136 -196
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/session-change-capture.test.ts +68 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +103 -0
- package/tests/shell-bar.test.ts +14 -0
package/README.md
CHANGED
|
@@ -140,8 +140,8 @@ Review the exact change, not a moving target. Native review keeps one candidate
|
|
|
140
140
|
|
|
141
141
|
The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) brings a more persistent, inspectable Pi workspace:
|
|
142
142
|
|
|
143
|
-
- **Shell:**
|
|
144
|
-
- **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles.
|
|
143
|
+
- **Shell:** `/gentle:changes` groups captured write/edit changes from the current agent session and its subagents, without startup repository scans; fullscreen navigation, sidebars, and mouse support stay available. See the [capture limits and shell-command coverage](docs/gentle-shell.md#what-appears-in-changes).
|
|
144
|
+
- **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles; applying one replaces the routing of every agent, and the panel shows the routing the runtime actually uses even when `models.json` is sparse.
|
|
145
145
|
- **Control and recovery:** native SDD requires parent-confirmed preflight; native review supports intended-untracked selection, consent, and provider continuations. Subsystems install with explicit recovery guidance when npm lifecycle scripts were skipped; Pi Git installs are recognized globally; custom ask responses are opt-in. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
|
|
146
146
|
|
|
147
147
|
---
|
package/docs/gentle-shell.md
CHANGED
|
@@ -13,7 +13,7 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases
|
|
|
13
13
|
- The Agents List and Details views preserve the orchestrator/session hierarchy and completion, abort, and lost-exit history. Parent-child queries and notifications have an explicit handoff path, while model, effort, and usage stay observable per task.
|
|
14
14
|
- Named `/gentle:profiles` atomically route the orchestrator separately from packaged and review roles; see the [technical reference](readme-reference.md#agent-model-profiles) for the profile model.
|
|
15
15
|
|
|
16
|
-
The source checkout currently prepares `gentle-pi` `2.
|
|
16
|
+
The source checkout currently prepares `gentle-pi` `2.7.0` with a package-local Gentle AI `v2.9.1` pin; this is not a claim that `2.7.0` is published.
|
|
17
17
|
|
|
18
18
|
## Shell interactions and runtime behavior
|
|
19
19
|
|
|
@@ -23,6 +23,8 @@ In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi
|
|
|
23
23
|
|
|
24
24
|
The rail reuses its last frame until something it paints changes, so silent frames stay cheap and live session state still lands on the next frame: a model switch, a new thinking level, context growth, session cost, session name and extension statuses all refresh the Status card without a redraw of the rest of the sidebar.
|
|
25
25
|
|
|
26
|
+
The sidebar Status card also shows `Profile` in its Model section when the profiles store has a valid active marker. It follows profile changes on the next render. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar is unchanged.
|
|
27
|
+
|
|
26
28
|
The status bar replaces pi's three-line footer with a single line of segments:
|
|
27
29
|
|
|
28
30
|
```text
|
|
@@ -47,28 +49,38 @@ The prompt wraps pi's editor in a rounded frame with a petal that shows what the
|
|
|
47
49
|
- The hint appears only while the editor is empty.
|
|
48
50
|
- If another extension already installed a custom editor, Gentle Shell leaves it alone.
|
|
49
51
|
|
|
50
|
-
Changes
|
|
52
|
+
Changes shows **captured write/edit operations from this agent session and its owned subagents**. It does not scan the repository on startup, read all untracked files, or poll live files in the background. Fullscreen, the sidebar, and mouse interaction are unchanged.
|
|
51
53
|
|
|
52
54
|
```text
|
|
53
55
|
✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
58
|
+
### What appears in Changes
|
|
59
|
+
|
|
60
|
+
- A worktree appears only after a captured successful mutation. Reading a file, opening a directory, registering a worktree, or launching a child is not mutation evidence.
|
|
61
|
+
- Diffs compare the content observed before the agent's first captured operation with its latest captured result, not with HEAD. Consecutive agent edits combine; an agent revert removes its net change.
|
|
62
|
+
- Edits from your editor or other sessions do not update these captured diffs. If an external or unobserved edit breaks continuity before the next agent operation on the same file, the file is marked **diff unavailable**, rather than mixing ownership.
|
|
63
|
+
- Only worktrees in the coordinating session's Git clone are accepted. Child evidence is accepted only from an owned task with paired successful write/edit events and a matching target.
|
|
64
|
+
- **Coverage is deliberately limited to write/edit tools.** Shell commands, custom mutation tools, failed/interrupted outcomes and children without the capture extension provide no attributed diff. A missing row does not mean the repository is clean or that no other changes occurred.
|
|
65
|
+
|
|
66
|
+
### Bounds and session lifetime
|
|
67
|
+
|
|
68
|
+
Capture reads only the named target, up to 64 KiB and 2,000 text lines. Binary, oversized, nonregular and unverifiable snapshots show unavailable counts, never fabricated zero-count proof. At most 256 operation identities and 4 MiB of serialized evidence are retained per session; reaching the limit produces a warning.
|
|
69
|
+
|
|
70
|
+
Snapshots are stored locally in Pi custom entries (`gentle-pi.session-change/v1`), including bounded before/after source text. Exit/resume and reload restore captures only for the exact same session UUID. New sessions and forks do not inherit attribution from another UUID. Ephemeral `--no-session` runs do not persist after exit. Capturing remains active in headless children and when the visual shell is disabled.
|
|
71
|
+
|
|
72
|
+
The separate `session_worktree_register` tool still registers canonical same-clone roots for coordination, but registration alone never adds files to Changes. Existing `gentle-pi.session-worktree/v1` entries do not establish file-level attribution.
|
|
73
|
+
|
|
74
|
+
### Browse captured diffs
|
|
75
|
+
|
|
76
|
+
`/gentle:changes` or `alt+g` opens the two-pane viewer. Worktrees are accordion groups on the left; selecting a file displays its captured diff on the right.
|
|
77
|
+
|
|
78
|
+
- `j`/`k` or arrows navigate. On a group, Enter, Space or Right expands it; Left returns to its parent or collapses it. `ctrl+j/k` or Page Up/Down scroll the diff; Escape or `q` closes.
|
|
79
|
+
- Fullscreen left-click selects files; mouse wheels scroll the file list and diff independently. Hover does not open files.
|
|
80
|
+
- Opening, pressing `r`, and the overlay's refresh cadence consult only the captured session model. They never rescan Git or load the current file contents. Same-line-count edits invalidate the diff preview by content revision.
|
|
81
|
+
- On a file, `o` or Enter opens the actual current file in `$VISUAL` or `$EDITOR`, with its worktree as cwd. Edits made there are external and are not attributed to the agent.
|
|
82
|
+
- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut; `off` disables it. `GENTLE_PI_SHELL_CHANGES_POLL_MS` controls only the open overlay's in-memory refresh. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` no longer enables filesystem polling.
|
|
83
|
+
- No captured changes means no widget and an informational notice; it does not assert that the working tree is clean.
|
|
72
84
|
|
|
73
85
|
Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider:
|
|
74
86
|
|
package/docs/readme-reference.md
CHANGED
|
@@ -30,7 +30,7 @@ This reference preserves the detailed installation, configuration, SDD/OpenSpec,
|
|
|
30
30
|
| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. |
|
|
31
31
|
| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. |
|
|
32
32
|
| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. |
|
|
33
|
-
| **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.9.
|
|
33
|
+
| **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.9.1 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. |
|
|
34
34
|
| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. |
|
|
35
35
|
|
|
36
36
|
## Native pointer regions
|
|
@@ -67,7 +67,7 @@ The stable release is [`v2.6.0`](https://github.com/Gentleman-Programming/gentle
|
|
|
67
67
|
|
|
68
68
|
### Source checkout
|
|
69
69
|
|
|
70
|
-
This checkout prepares `gentle-pi` `2.
|
|
70
|
+
This checkout prepares `gentle-pi` `2.7.0`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v2.9.1`, distinct from the published `v2.6.0` pairing.
|
|
71
71
|
|
|
72
72
|
### Pi compatibility
|
|
73
73
|
|
|
@@ -94,7 +94,7 @@ pi install npm:gentle-pi@2.6.0
|
|
|
94
94
|
|
|
95
95
|
RDD remains opt-in. Enable it only through an explicit user decision with `/gentle:review-mode enable`; `status` lets you inspect the mode without changing it.
|
|
96
96
|
|
|
97
|
-
The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.9.
|
|
97
|
+
The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.9.1`; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.9.1` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error. To recover explicitly, if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before changing to the installed `gentle-pi` package directory. Then run `node scripts/install-gentle-ai.mjs`. This invokes the package-owned installer without relying on a global binary or npm configuration change. A missing binary can result from skipped lifecycle scripts, but does not prove that lifecycle scripts were disabled.
|
|
98
98
|
|
|
99
99
|
Recommended companion packages:
|
|
100
100
|
|
|
@@ -250,13 +250,13 @@ flowchart TD
|
|
|
250
250
|
|
|
251
251
|
VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent.
|
|
252
252
|
|
|
253
|
-
For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.9.
|
|
253
|
+
For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.9.1 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates.
|
|
254
254
|
|
|
255
255
|
Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing.
|
|
256
256
|
|
|
257
257
|
Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action.
|
|
258
258
|
|
|
259
|
-
Once the source checkout's pinned gentle-ai runtime (currently v2.9.
|
|
259
|
+
Once the source checkout's pinned gentle-ai runtime (currently v2.9.1) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path.
|
|
260
260
|
|
|
261
261
|
### FINALIZE wrapper input
|
|
262
262
|
|
|
@@ -600,7 +600,7 @@ Profiles are named, switchable snapshots of the global agent-model routing from
|
|
|
600
600
|
|
|
601
601
|
| Key | Action |
|
|
602
602
|
| ------- | ---------------------------------------------------------------------- |
|
|
603
|
-
| `enter` | Apply the selected profile live (writes `models.json`,
|
|
603
|
+
| `enter` | Apply the selected profile live (writes `models.json`, replaces the routing of every agent, sets the orchestrator when the profile defines one). |
|
|
604
604
|
| `c` | Create a new, empty profile. |
|
|
605
605
|
| `s` | Update the selected profile from the current routing (including the orchestrator currently set in `settings.json`). |
|
|
606
606
|
| `d` | Duplicate the selected profile. |
|
|
@@ -612,11 +612,11 @@ Profiles are named, switchable snapshots of the global agent-model routing from
|
|
|
612
612
|
| `pgup`/`pgdn`, `ctrl+j`/`ctrl+k` | Scroll the detail pane by a page. |
|
|
613
613
|
| `esc` | Close. |
|
|
614
614
|
|
|
615
|
-
Applying a profile writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. 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.
|
|
615
|
+
Applying a profile 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.
|
|
616
616
|
|
|
617
617
|
A profile also carries the orchestrator under the reserved routing key `orchestrator`. Applying a profile that defines it 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). Applying a profile without an `orchestrator` entry never moves the orchestrator, and `s` snapshots the currently effective orchestrator 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.
|
|
618
618
|
|
|
619
|
-
When `profiles.json` is missing, the command seeds one profile named `current` captured from
|
|
619
|
+
The panel's current routing, the `current` seed, and `s` all read the routing in effect: `models.json` where it has an entry, and otherwise the `subagents.json` model profile or frontmatter routing the runtime actually resolves for that agent. 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.
|
|
620
620
|
|
|
621
621
|
Saved globally at:
|
|
622
622
|
|
|
@@ -3,6 +3,7 @@ import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isPare
|
|
|
3
3
|
import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddAttemptResult, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
6
|
+
import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
|
|
6
7
|
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
7
8
|
import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
|
|
8
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -885,6 +886,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
885
886
|
const root = deps.resolveWorktree(tool.path, task.cwd)?.root;
|
|
886
887
|
const childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
|
|
887
888
|
if (!root || root !== childRoot || !worktrees.roots().includes(root)) return;
|
|
889
|
+
if (tool.evidence?.root === root) {
|
|
890
|
+
try {
|
|
891
|
+
let path = tool.path.replace(/^@/, "");
|
|
892
|
+
if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
|
|
893
|
+
if (realpathSync(resolve(task.cwd, path)) === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
|
|
894
|
+
} catch { /* Missing or mismatched targets cannot supply session diffs. */ }
|
|
895
|
+
}
|
|
888
896
|
recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
|
|
889
897
|
},
|
|
890
898
|
onFinish: (task, observations) => {
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -2212,6 +2212,145 @@ function updateFrontmatterRouting(
|
|
|
2212
2212
|
return `---\n${lines.join("\n")}${body}`;
|
|
2213
2213
|
}
|
|
2214
2214
|
|
|
2215
|
+
/**
|
|
2216
|
+
* The routing an agent file currently carries, read the same way
|
|
2217
|
+
* `updateFrontmatterRouting` writes it: top-level `model:` and `thinking:`
|
|
2218
|
+
* frontmatter lines. Anything else is "no routing", not an error.
|
|
2219
|
+
*/
|
|
2220
|
+
function readFrontmatterRouting(content: string): AgentRoutingEntry | undefined {
|
|
2221
|
+
if (!content.startsWith("---\n")) return undefined;
|
|
2222
|
+
const endIndex = content.indexOf("\n---", 4);
|
|
2223
|
+
if (endIndex === -1) return undefined;
|
|
2224
|
+
const raw: Record<string, string> = {};
|
|
2225
|
+
for (const line of content.slice(4, endIndex).split("\n")) {
|
|
2226
|
+
if (line.startsWith("model:")) raw.model = line.slice("model:".length).trim();
|
|
2227
|
+
else if (line.startsWith("thinking:")) raw.thinking = line.slice("thinking:".length).trim();
|
|
2228
|
+
}
|
|
2229
|
+
if (raw.model === undefined && raw.thinking === undefined) return undefined;
|
|
2230
|
+
const entry = normalizeRoutingEntry(raw);
|
|
2231
|
+
return entry && !isClearRoutingEntry(entry) ? entry : undefined;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function routingEntryFromModelProfile(value: unknown): AgentRoutingEntry | undefined {
|
|
2235
|
+
if (!isRecord(value)) return undefined;
|
|
2236
|
+
const entry = normalizeRoutingEntry({ model: value.model, thinking: value.effort });
|
|
2237
|
+
return entry && !isClearRoutingEntry(entry) ? entry : undefined;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function readSubagentModelProfiles(path: string): Record<string, unknown> {
|
|
2241
|
+
if (!existsSync(path)) return {};
|
|
2242
|
+
try {
|
|
2243
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
2244
|
+
return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
|
|
2245
|
+
} catch {
|
|
2246
|
+
return {};
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
async function readSubagentModelProfilesAsync(path: string): Promise<Record<string, unknown>> {
|
|
2251
|
+
if (!(await pathExists(path))) return {};
|
|
2252
|
+
try {
|
|
2253
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
2254
|
+
return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
|
|
2255
|
+
} catch {
|
|
2256
|
+
return {};
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* The routing an agent is materialized with — what subagent launches actually
|
|
2262
|
+
* resolve — regardless of what `models.json` records: the runtime reads
|
|
2263
|
+
* `subagents.json` model profiles first and the agent frontmatter otherwise.
|
|
2264
|
+
*/
|
|
2265
|
+
function readMaterializedRoutingEntry(
|
|
2266
|
+
cwd: string,
|
|
2267
|
+
agent: AgentEntry,
|
|
2268
|
+
profilesByPath: Map<string, Record<string, unknown>>,
|
|
2269
|
+
): AgentRoutingEntry | undefined {
|
|
2270
|
+
const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
|
|
2271
|
+
let profiles = profilesByPath.get(profilesPath);
|
|
2272
|
+
if (!profiles) {
|
|
2273
|
+
profiles = readSubagentModelProfiles(profilesPath);
|
|
2274
|
+
profilesByPath.set(profilesPath, profiles);
|
|
2275
|
+
}
|
|
2276
|
+
const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
|
|
2277
|
+
if (fromProfile) return fromProfile;
|
|
2278
|
+
if (!agent.filePath || !existsSync(agent.filePath)) return undefined;
|
|
2279
|
+
try {
|
|
2280
|
+
return readFrontmatterRouting(readFileSync(agent.filePath, "utf8"));
|
|
2281
|
+
} catch {
|
|
2282
|
+
return undefined;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
async function readMaterializedRoutingEntryAsync(
|
|
2287
|
+
cwd: string,
|
|
2288
|
+
agent: AgentEntry,
|
|
2289
|
+
profilesByPath: Map<string, Record<string, unknown>>,
|
|
2290
|
+
): Promise<AgentRoutingEntry | undefined> {
|
|
2291
|
+
const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
|
|
2292
|
+
let profiles = profilesByPath.get(profilesPath);
|
|
2293
|
+
if (!profiles) {
|
|
2294
|
+
profiles = await readSubagentModelProfilesAsync(profilesPath);
|
|
2295
|
+
profilesByPath.set(profilesPath, profiles);
|
|
2296
|
+
}
|
|
2297
|
+
const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
|
|
2298
|
+
if (fromProfile) return fromProfile;
|
|
2299
|
+
if (!agent.filePath || !(await pathExists(agent.filePath))) return undefined;
|
|
2300
|
+
try {
|
|
2301
|
+
return readFrontmatterRouting(await readFile(agent.filePath, "utf8"));
|
|
2302
|
+
} catch {
|
|
2303
|
+
return undefined;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* The routing in effect: `models.json` where it speaks, and the materialized
|
|
2309
|
+
* stores the runtime resolves from for every discoverable agent it is silent
|
|
2310
|
+
* about. A sparse `models.json` therefore never hides routing that is still
|
|
2311
|
+
* live (#1012). Reading never writes.
|
|
2312
|
+
*/
|
|
2313
|
+
function readEffectiveModelConfig(cwd: string): AgentModelConfig {
|
|
2314
|
+
const effective = cloneModelConfig(readModelConfig(cwd));
|
|
2315
|
+
const profilesByPath = new Map<string, Record<string, unknown>>();
|
|
2316
|
+
for (const agent of listDiscoverableAgents(cwd)) {
|
|
2317
|
+
if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
|
|
2318
|
+
const entry = readMaterializedRoutingEntry(cwd, agent, profilesByPath);
|
|
2319
|
+
if (entry) effective[agent.name] = entry;
|
|
2320
|
+
}
|
|
2321
|
+
return effective;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
|
|
2325
|
+
const effective = cloneModelConfig(await readModelConfigAsync(cwd));
|
|
2326
|
+
const profilesByPath = new Map<string, Record<string, unknown>>();
|
|
2327
|
+
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2328
|
+
if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
|
|
2329
|
+
const entry = await readMaterializedRoutingEntryAsync(cwd, agent, profilesByPath);
|
|
2330
|
+
if (entry) effective[agent.name] = entry;
|
|
2331
|
+
}
|
|
2332
|
+
return effective;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* A profile is a complete routing snapshot: applying it must leave every
|
|
2337
|
+
* discoverable agent it omits on inherit, not on whatever was materialized
|
|
2338
|
+
* before. Padding the omitted agents with clear entries makes
|
|
2339
|
+
* `applyModelConfig` remove their model profiles and frontmatter routing, the
|
|
2340
|
+
* same way `/gentle:models` clears an agent set to inherit.
|
|
2341
|
+
*/
|
|
2342
|
+
async function withOmittedAgentsClearedAsync(
|
|
2343
|
+
cwd: string,
|
|
2344
|
+
config: AgentModelConfig,
|
|
2345
|
+
): Promise<AgentModelConfig> {
|
|
2346
|
+
const completed = cloneModelConfig(config);
|
|
2347
|
+
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2348
|
+
if (isProviderReviewRole(agent.name) || agent.name in completed) continue;
|
|
2349
|
+
completed[agent.name] = {};
|
|
2350
|
+
}
|
|
2351
|
+
return completed;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2215
2354
|
function parseAgentName(filePath: string): string | undefined {
|
|
2216
2355
|
let content: string;
|
|
2217
2356
|
try {
|
|
@@ -2457,6 +2596,9 @@ function updateSubagentModelProfileAtPath(
|
|
|
2457
2596
|
? { ...config.model_profiles }
|
|
2458
2597
|
: {};
|
|
2459
2598
|
const profile = modelProfileForRoutingEntry(entry);
|
|
2599
|
+
// A write that would leave the profile as it is (including removing a
|
|
2600
|
+
// profile that was never there) is not an update and touches no file.
|
|
2601
|
+
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
|
|
2460
2602
|
if (profile) {
|
|
2461
2603
|
if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
|
|
2462
2604
|
modelProfiles[name] = profile;
|
|
@@ -2487,6 +2629,9 @@ async function updateSubagentModelProfileAtPathAsync(
|
|
|
2487
2629
|
? { ...config.model_profiles }
|
|
2488
2630
|
: {};
|
|
2489
2631
|
const profile = modelProfileForRoutingEntry(entry);
|
|
2632
|
+
// A write that would leave the profile as it is (including removing a
|
|
2633
|
+
// profile that was never there) is not an update and touches no file.
|
|
2634
|
+
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
|
|
2490
2635
|
if (profile) {
|
|
2491
2636
|
if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
|
|
2492
2637
|
modelProfiles[name] = profile;
|
|
@@ -3660,7 +3805,7 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3660
3805
|
this.renderLine("Profile routing", width, "accent"),
|
|
3661
3806
|
...this.indentLines(this.routingLines(profileRows, widths), width),
|
|
3662
3807
|
"",
|
|
3663
|
-
this.renderLine("Current routing (
|
|
3808
|
+
this.renderLine("Current routing (effective)", width, "accent"),
|
|
3664
3809
|
...this.indentLines(this.routingLines(currentRows, widths), width),
|
|
3665
3810
|
];
|
|
3666
3811
|
}
|
|
@@ -3819,6 +3964,13 @@ async function runProfilesPanelAction(
|
|
|
3819
3964
|
if (routingWritten && previousActiveConfig !== undefined) {
|
|
3820
3965
|
try {
|
|
3821
3966
|
await writeModelConfigAsync(ctx.cwd, previousActiveConfig);
|
|
3967
|
+
// Materialize the previous profile again with the same
|
|
3968
|
+
// replacement semantics, so the failed profile's routes do not
|
|
3969
|
+
// linger in subagents.json or the agent frontmatter.
|
|
3970
|
+
await applyModelConfigAsync(
|
|
3971
|
+
ctx.cwd,
|
|
3972
|
+
await withOmittedAgentsClearedAsync(ctx.cwd, previousActiveConfig),
|
|
3973
|
+
);
|
|
3822
3974
|
} catch {
|
|
3823
3975
|
restored = "";
|
|
3824
3976
|
}
|
|
@@ -3856,8 +4008,20 @@ async function runProfilesPanelAction(
|
|
|
3856
4008
|
);
|
|
3857
4009
|
return revertClaim(false);
|
|
3858
4010
|
}
|
|
3859
|
-
|
|
3860
|
-
|
|
4011
|
+
// models.json holds the profile as written; the padding with clear
|
|
4012
|
+
// entries only drives materialization, so agents the profile omits
|
|
4013
|
+
// return to inherit instead of keeping a previously materialized route.
|
|
4014
|
+
let applyResult: { updated: number; skipped: number };
|
|
4015
|
+
try {
|
|
4016
|
+
applyResult = await applyModelConfigAsync(
|
|
4017
|
+
ctx.cwd,
|
|
4018
|
+
await withOmittedAgentsClearedAsync(ctx.cwd, normalized),
|
|
4019
|
+
);
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
ctx.ui.notify(
|
|
4022
|
+
`el Gentleman could not materialize profile "${result.name}": ${profilesErrorMessage(error)}`,
|
|
4023
|
+
"warning",
|
|
4024
|
+
);
|
|
3861
4025
|
return revertClaim(true);
|
|
3862
4026
|
}
|
|
3863
4027
|
let orchestratorNote = "";
|
|
@@ -3899,7 +4063,7 @@ async function runProfilesPanelAction(
|
|
|
3899
4063
|
}
|
|
3900
4064
|
}
|
|
3901
4065
|
case "update": {
|
|
3902
|
-
const current = await
|
|
4066
|
+
const current = await readEffectiveModelConfigAsync(ctx.cwd);
|
|
3903
4067
|
// A profile is a complete snapshot, so capturing the current routing also
|
|
3904
4068
|
// captures the orchestrator the routing is running under. A settings file
|
|
3905
4069
|
// that cannot be read leaves the snapshot without an orchestrator entry
|
|
@@ -4035,7 +4199,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
4035
4199
|
}
|
|
4036
4200
|
let file: AgentProfilesFile;
|
|
4037
4201
|
if (read.status === "missing") {
|
|
4038
|
-
file = bootstrapProfilesFile(await
|
|
4202
|
+
file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
|
|
4039
4203
|
try {
|
|
4040
4204
|
writeProfilesFileSync(path, file);
|
|
4041
4205
|
} catch (error) {
|
|
@@ -4045,17 +4209,17 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
4045
4209
|
);
|
|
4046
4210
|
return;
|
|
4047
4211
|
}
|
|
4048
|
-
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the
|
|
4212
|
+
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
|
|
4049
4213
|
} else {
|
|
4050
4214
|
file = read.file;
|
|
4051
4215
|
reportProfilesDrops(ctx, path, read.drops);
|
|
4052
4216
|
}
|
|
4053
4217
|
let selectedName: string | undefined;
|
|
4054
|
-
let result = await showProfilesPanel(ctx, file, await
|
|
4218
|
+
let result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
|
|
4055
4219
|
while (result.type !== "close") {
|
|
4056
4220
|
selectedName = "name" in result ? result.name : undefined;
|
|
4057
4221
|
file = await runProfilesPanelAction(ctx, path, file, result);
|
|
4058
|
-
result = await showProfilesPanel(ctx, file, await
|
|
4222
|
+
result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
|
|
4059
4223
|
}
|
|
4060
4224
|
}
|
|
4061
4225
|
|
|
@@ -7927,6 +8091,8 @@ async function executeReviewControllerOperation(
|
|
|
7927
8091
|
/** @internal */
|
|
7928
8092
|
export const __testing = {
|
|
7929
8093
|
resolveReviewModeGate,
|
|
8094
|
+
readEffectiveModelConfig,
|
|
8095
|
+
readEffectiveModelConfigAsync,
|
|
7930
8096
|
listAgentsFromDir,
|
|
7931
8097
|
listAgentsFromDirAsync,
|
|
7932
8098
|
listDiscoverableAgents,
|