@mevsgame/pi-model-alias-profiles 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: deterministic `tier/large`, `tier/medium`, and `tier/small`
6
+ aliases backed by explicitly switchable profiles.
7
+ - Manage profiles in Pi's terminal UI, with branch-local selection and
8
+ persistent profile definitions.
9
+ - Preserve physical model choice for continuations and retries; no classifier
10
+ or automatic fallback.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mevsgame
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # Pi Model Alias
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stable native Pi virtual models: `tier/large`, `tier/medium`, and `tier/small`.
4
+ Each resolves to exactly one configured physical model in the active profile.
5
+ No classifier, fallback, proxy, provider replacement, or core modifications.
6
+ Unlike classifier-based routers, the alias always uses the mapping you choose;
7
+ switch profiles explicitly to change those mappings. Other Pi model-alias
8
+ packages exist; this extension additionally provides switchable profiles.
9
+
10
+ ## Installation
11
+
12
+ Requires a Pi version supporting virtual models (tested with Pi 1.5.0).
13
+ Configure and authenticate each physical model's provider in Pi before
14
+ activating a profile. An alias does not supply provider credentials.
15
+
16
+ ```sh
17
+ pi install npm:@mevsgame/pi-model-alias-profiles
18
+ ```
19
+
20
+ ## First use
21
+
22
+ Run `/model-profiles`, choose **Create**, enter a name, and select a physical
23
+ model for each alias. The model picker searches readable names and exact
24
+ provider/model IDs, supports multiple search terms and pagination, and marks
25
+ authentication availability. Models from different providers can share a profile.
26
+ Choose **Save**, then **Activate**. No example model IDs are installed.
27
+
28
+ ```text
29
+ /model-profile work
30
+ /tier large
31
+ /model tier/medium
32
+ /model-profile personal
33
+ ```
34
+
35
+ Switching profiles never changes the selected alias. Requests beginning a new
36
+ user turn use the new mapping. Tool continuations keep their previous physical
37
+ model; retries retain the failed physical model. Thinking selection is preserved
38
+ and Pi clamps it to the physical model's capabilities.
39
+
40
+ ### Commands and keys
41
+
42
+ | Command | Action |
43
+ |---|---|
44
+ | `/model-profile` | Quick picker, active profile marked |
45
+ | `/model-profile NAME` | Activate immediately |
46
+ | `/model-profile next` | Cycle sorted profile names, wrap and skip invalid profiles |
47
+ | `/model-profile status` | Active profile and mappings |
48
+ | `/model-profiles` | Create, edit, duplicate, rename, delete, set default, explicit disk reload |
49
+ | `/tier [large\|medium\|small]` | Select alias, or open alias picker |
50
+
51
+ `Ctrl+Alt+P` opens the quick picker; `Ctrl+Alt+Shift+P` cycles profiles.
52
+ Dialogs support up/down, Enter to select, and Escape to cancel and restore the
53
+ editor. These bindings do not replace Pi's built-in model-selection shortcuts.
54
+ Modifier reporting varies by terminal: use `/hotkeys` to inspect conflicts, and
55
+ use the commands if your terminal cannot distinguish these combinations. No
56
+ global keybindings are modified.
57
+
58
+ Management uses nested native dialogs rather than a bespoke full-screen editor.
59
+ Edits remain private until Save. Cancelling creation/editing does not write.
60
+ Incomplete drafts may be saved but cannot activate. Active profiles must remain
61
+ valid when edited, and deleting the active profile requires a valid replacement
62
+ and confirmation. Profile names are case-sensitive, 1–64 ASCII letters/digits/
63
+ `._-`, starting with a letter/digit; `next`, `status`, and prototype names are
64
+ reserved.
65
+
66
+ Direct physical-model selection is unaffected; the profile status indicator is
67
+ hidden in that case. Named activation, cycling, status, and named `/tier`
68
+ selection work without a terminal. Management and profile pickers require TUI.
69
+
70
+ ## Persistence and safety
71
+
72
+ Configuration lives at `<agent-dir>/model-profiles.json`, resolved with Pi's
73
+ `getAgentDir()` (including `PI_CODING_AGENT_DIR`). Format:
74
+
75
+ ```json
76
+ {
77
+ "version": 1,
78
+ "defaultProfile": "work",
79
+ "lastActiveProfile": "work",
80
+ "profiles": {
81
+ "work": {
82
+ "large": "provider/exact-large-model-id",
83
+ "medium": "provider/exact-medium-model-id",
84
+ "small": "another-provider/exact-small-model-id"
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ These are illustrative placeholders, **not valid installed mappings**. Use the
91
+ in-Pi registry picker to choose actual configured models. No credentials are
92
+ stored here. Availability checks detect configured authentication, not whether a
93
+ remote server will accept an expired/revoked credential; provider errors remain
94
+ visible and never trigger fallback.
95
+
96
+ Global definitions and last-used selection are separate from branch-local
97
+ `model-profiles.selection` custom session entries (not sent to the LLM).
98
+ New sessions choose the valid last-used profile, then valid default; existing
99
+ branches restore their own selection, including an explicit no-profile state.
100
+ A missing/renamed saved profile is reported, not silently substituted: select a
101
+ replacement explicitly. Definitions remain global; editing them affects future
102
+ requests in sessions that load those definitions.
103
+
104
+ Writes use exclusive locks, content-based conflict detection, and atomic rename.
105
+ Failed writes do not update runtime selection. Malformed/unsupported config is
106
+ preserved and blocks writes/routing. Repair the file, then use management's
107
+ **Reload configuration from disk**. The same explicit reload accepts external
108
+ changes after a conflict; there is no automatic cross-process synchronization.
109
+ Other processes do not change this process's active profile. After a process
110
+ crash, a stale `.lock` may require removal after confirming no writer is active.
111
+ Local atomic-rename/lock semantics are assumed; network filesystems may differ.
112
+
113
+ ## Known limitations and troubleshooting
114
+
115
+ - Another extension registering `tier/large`, `tier/medium`, or `tier/small`
116
+ can replace these virtual models. The `/model-profile`, `/model-profiles`,
117
+ `/tier` commands and shortcuts may also conflict with other extensions.
118
+ - If activation fails, check all three mappings exist in Pi's model registry and
119
+ their providers are authenticated. Provider errors do not trigger fallback.
120
+ - If shortcuts do not work, inspect `/hotkeys` and use the slash commands; some
121
+ terminals cannot distinguish the required modifiers.
122
+ - Profile management requires Pi's interactive terminal UI. End-to-end paid
123
+ inference and some interactive scenarios remain unverified; see
124
+ [VERIFICATION.md](VERIFICATION.md).
125
+
126
+ ## Development and verification
127
+
128
+ ```sh
129
+ npm ci
130
+ npm test
131
+ npm run typecheck
132
+ ```
133
+
134
+ Tests cover configuration validation/conflicts/malformed data, atomic CRUD
135
+ cancellation, routing and authentication errors, sticky continuations/retries,
136
+ branch restoration, commands, shortcuts, and stable selection identity.
137
+ Installed Pi-Bolt smoke checks confirm extension loading, `--model tier/medium`,
138
+ and all three aliases in `--list-models tier` without sending model requests.
139
+ See `VERIFICATION.md` for exact results and remaining manual checks.
140
+
141
+ ## License and links
142
+
143
+ MIT. [Source and issues](https://github.com/mevsgame/pi-model-alias-profiles).
@@ -0,0 +1,56 @@
1
+ # Verification
2
+
3
+ Host: Pi 1.5.0, Pi-Bolt 1.1.3, Linux x64.
4
+ Development types: `@earendil-works/pi-coding-agent` and `pi-tui` 1.5.0.
5
+
6
+ - `npm test`: **23 passed**, 0 failed.
7
+ - `npm run typecheck`: passed.
8
+ - `npm pack --dry-run`: passed; extension entry point and all source modules included.
9
+ - Isolated temporary agent directory, explicit extension load:
10
+ `pi-bolt -ne --no-mcp -ns -np -nc --offline -e ./index.ts --model tier/medium --no-session -p '/model-profile status'`
11
+ selected the virtual model and printed unconfigured mappings without inference.
12
+ - `--list-models tier` listed large, medium, small.
13
+
14
+ Real interactive smoke used Python PTY/subprocess, temporary HOME and agent
15
+ directory, no credential environment, and a watchdog:
16
+
17
+ ```sh
18
+ pi-bolt -ne --no-mcp -ns -np -nc --offline \
19
+ -e /absolute/path/to/index.ts --model tier/large
20
+ ```
21
+
22
+ Confirmed:
23
+
24
+ - Extension starts, selects tier/large, shows first-run guidance.
25
+ - Ctrl+Alt+P encoded as `ESC[112;7u` invokes quick picker behavior and reports no
26
+ profiles; the next command works, confirming editor focus.
27
+ - `/model-profiles` → Create opens name input. Escape cancels creation, then
28
+ Escape closes management. No configuration file is written.
29
+ - Native `/model` lists all three tier models and marks large selected.
30
+ Filtering and Escape work.
31
+ - Child was terminated and reaped; no process remained.
32
+
33
+ The host printed catalog-refresh messages even with offline flags; absence of
34
+ network traffic was not independently traced.
35
+
36
+ ## Remaining manual checks
37
+
38
+ Automated tests exercise profile CRUD, activation, cycling, wraparound,
39
+ branch persistence, errors and routing through mocks. Actual paid inference,
40
+ authenticated Codex/Anthropic switching, live tool/retry sequences, real session
41
+ resume/tree navigation, and a populated quick picker/cycling shortcut were not
42
+ tested end-to-end. Other terminals' modifier encoding and user-defined shortcut
43
+ conflicts remain environment-dependent.
44
+
45
+ For final acceptance in your terminal:
46
+
47
+ 1. Create two authenticated profiles with all three mappings using management.
48
+ 2. Select tier/large through `/model`, switch with Ctrl+Alt+P, and send a prompt.
49
+ Verify the native footer dispatch changes but the selected alias does not.
50
+ 3. Repeat with medium/small and Ctrl+Alt+Shift+P; verify sorted wraparound.
51
+ 4. Switch during a tool sequence: continuations must keep the previous physical
52
+ model; the next user request must use the new profile.
53
+ 5. Save edits, duplicate/rename/delete, and cancel an edit.
54
+ 6. Restart, resume, and navigate session branches with different saved profiles.
55
+ 7. Remove credentials or invalidate a target and verify an explicit error with
56
+ no fallback. Restore valid configuration afterward.
package/index.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { ALIASES, ConfigStore, type Config, type Alias } from "./src/config.ts";
4
+ import { route, validateProfile } from "./src/profiles.ts";
5
+ import { branchProfile, STATE_ENTRY } from "./src/state.ts";
6
+ import { manage, quickPicker, type Controller } from "./src/ui.ts";
7
+
8
+ export default function modelProfiles(pi: ExtensionAPI) {
9
+ const store = new ConfigStore(join(getAgentDir(), "model-profiles.json"));
10
+ let config: Config = { version: 1, profiles: {} };
11
+ let active: string | undefined;
12
+ let loadError: unknown;
13
+ try { config = store.load(); } catch (error) { loadError = error; }
14
+ const report = (ctx: ExtensionContext, message: string, error = false) => {
15
+ if (ctx.hasUI) ctx.ui.notify(message, error ? "error" : "info");
16
+ else console.error(`[model-profiles] ${message}`);
17
+ };
18
+ const status = (ctx: ExtensionContext) => {
19
+ if (!ctx.hasUI) return;
20
+ const alias = ctx.model?.provider === "tier" ? ctx.model.id as Alias : undefined;
21
+ ctx.ui.setStatus("model-profiles", alias
22
+ ? `${active ?? "no profile"} · ${alias} → ${active && config.profiles[active]?.[alias] || "(unconfigured)"}`
23
+ : undefined);
24
+ };
25
+ const record = () => pi.appendEntry(STATE_ENTRY, { profile: active ?? null });
26
+ const controller: Controller = {
27
+ getConfig: () => config,
28
+ getActive: () => active,
29
+ activate(name, ctx) {
30
+ validateProfile(name, config, ctx.modelRegistry);
31
+ const next = { ...config, lastActiveProfile: name };
32
+ store.save(next);
33
+ config = next;
34
+ active = name;
35
+ record();
36
+ status(ctx);
37
+ const alias = ctx.model?.provider === "tier" ? ctx.model.id as Alias : undefined;
38
+ report(ctx, `Profile: ${name}${alias ? `\ntier/${alias} → ${config.profiles[name]![alias]}` : ""}`);
39
+ },
40
+ commit(next, selection, ctx) {
41
+ if (selection) validateProfile(selection, next, ctx.modelRegistry);
42
+ if (selection) next.lastActiveProfile = selection;
43
+ store.save(next);
44
+ const changed = active !== selection;
45
+ config = next;
46
+ active = selection;
47
+ if (changed) record();
48
+ status(ctx);
49
+ },
50
+ reload(ctx) {
51
+ config = store.load();
52
+ loadError = undefined;
53
+ status(ctx);
54
+ },
55
+ };
56
+ const restore = (ctx: ExtensionContext) => {
57
+ const saved = branchProfile(ctx.sessionManager.getBranch());
58
+ if (saved !== undefined) {
59
+ active = saved ?? undefined; // Never silently replace a missing session profile.
60
+ try { if (saved) validateProfile(saved, config, ctx.modelRegistry); }
61
+ catch (error) { report(ctx, String(error), true); }
62
+ } else {
63
+ active = undefined;
64
+ for (const name of [config.lastActiveProfile, config.defaultProfile]) {
65
+ if (!name) continue;
66
+ try { validateProfile(name, config, ctx.modelRegistry); active = name; break; } catch {}
67
+ }
68
+ record(); // Pin the initial selection to this branch, independent of future global changes.
69
+ }
70
+ status(ctx);
71
+ };
72
+ pi.on("session_start", (_event, ctx) => {
73
+ restore(ctx);
74
+ if (loadError) report(ctx, String(loadError), true);
75
+ else if (!Object.keys(config.profiles).length) report(ctx, "No model profiles configured. Run /model-profiles to create your first profile.");
76
+ });
77
+ pi.on("session_tree", (_event, ctx) => restore(ctx));
78
+ pi.on("model_select", (_event, ctx) => status(ctx));
79
+ for (const alias of ALIASES) pi.registerVirtualModel({
80
+ provider: "tier", id: alias, name: `Tier ${alias}`,
81
+ thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh"],
82
+ route(request, ctx) {
83
+ if (loadError) throw loadError;
84
+ return route(alias, active, config, ctx.modelRegistry, request);
85
+ },
86
+ });
87
+ const guarded = (fn: (args: string, ctx: ExtensionContext) => Promise<void> | void) =>
88
+ async (args: string, ctx: ExtensionContext) => {
89
+ try { await fn(args.trim(), ctx); } catch (error) { report(ctx, String(error), true); }
90
+ };
91
+ const cycle = (ctx: ExtensionContext) => {
92
+ const names = Object.keys(config.profiles).sort();
93
+ const start = names.indexOf(active ?? "");
94
+ for (let offset = 1; offset <= names.length; offset++) {
95
+ const name = names[(start + offset) % names.length]!;
96
+ try { validateProfile(name, config, ctx.modelRegistry); } catch { continue; }
97
+ controller.activate(name, ctx);
98
+ return;
99
+ }
100
+ throw new Error("No valid profiles to cycle to. Configure mappings and credentials using /model-profiles.");
101
+ };
102
+ pi.registerCommand("model-profile", {
103
+ description: "Pick, activate, cycle or inspect model profiles",
104
+ getArgumentCompletions: prefix => [...Object.keys(config.profiles).sort(), "next", "status"]
105
+ .filter(name => name.startsWith(prefix)).map(name => ({ value: name, label: name })),
106
+ handler: guarded(async (args, ctx) => {
107
+ if (!args) return quickPicker(ctx, controller);
108
+ if (args === "next") return cycle(ctx);
109
+ if (args === "status") {
110
+ report(ctx, `Profile: ${active ?? "(none)"}\n${ALIASES.map(alias => `tier/${alias} → ${active && config.profiles[active]?.[alias] || "(missing)"}`).join("\n")}`);
111
+ return;
112
+ }
113
+ controller.activate(args, ctx);
114
+ }),
115
+ });
116
+ pi.registerCommand("model-profiles", {
117
+ description: "Create, edit, duplicate, rename and delete model profiles",
118
+ handler: guarded((_args, ctx) => manage(ctx, controller)),
119
+ });
120
+ pi.registerCommand("tier", {
121
+ description: "Select a stable model alias",
122
+ handler: guarded(async (args, ctx) => {
123
+ if (!args) {
124
+ if (!ctx.hasUI) throw new Error("Use /tier large, medium or small in non-interactive modes.");
125
+ args = await ctx.ui.select("Select model alias", [...ALIASES]) ?? "";
126
+ if (!args) return;
127
+ }
128
+ if (!(ALIASES as readonly string[]).includes(args)) throw new Error(`Unknown alias "${args}". Choose large, medium or small.`);
129
+ const model = ctx.modelRegistry.find("tier", args);
130
+ if (!model || !await pi.setModel(model)) throw new Error(`Could not select tier/${args}.`);
131
+ status(ctx);
132
+ }),
133
+ });
134
+ pi.registerShortcut("ctrl+alt+p", {
135
+ description: "Switch model profile",
136
+ handler: ctx => quickPicker(ctx, controller),
137
+ });
138
+ pi.registerShortcut("ctrl+alt+shift+p", {
139
+ description: "Cycle model profile",
140
+ handler: ctx => guarded((_args, ctx) => cycle(ctx))("", ctx),
141
+ });
142
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@mevsgame/pi-model-alias-profiles",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Deterministic Pi model aliases with switchable, persistent profiles",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": ["pi-package", "pi-extension", "model-alias", "model-profiles", "pi"],
9
+ "repository": { "type": "git", "url": "https://github.com/mevsgame/pi-model-alias-profiles.git" },
10
+ "homepage": "https://github.com/mevsgame/pi-model-alias-profiles#readme",
11
+ "bugs": { "url": "https://github.com/mevsgame/pi-model-alias-profiles/issues" },
12
+ "publishConfig": { "access": "public" },
13
+ "pi": { "extensions": ["./index.ts"] },
14
+ "files": ["index.ts", "src", "README.md", "LICENSE", "CHANGELOG.md", "VERIFICATION.md"],
15
+ "scripts": { "test": "tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*"
18
+ },
19
+ "devDependencies": {
20
+ "@earendil-works/pi-coding-agent": "1.5.0",
21
+ "@earendil-works/pi-tui": "1.5.0",
22
+ "@types/node": "^22.0.0",
23
+ "tsx": "^4.20.0",
24
+ "typescript": "^5.9.0"
25
+ }
26
+ }
package/src/config.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export const ALIASES = ["large", "medium", "small"] as const;
6
+ export type Alias = (typeof ALIASES)[number];
7
+ export type Profile = Partial<Record<Alias, string>>;
8
+ export interface Config {
9
+ version: 1;
10
+ defaultProfile?: string;
11
+ lastActiveProfile?: string;
12
+ profiles: Record<string, Profile>;
13
+ }
14
+
15
+ const reserved = new Set(["next", "status", ...Object.getOwnPropertyNames(Object.prototype)].map((name) => name.toLowerCase()));
16
+
17
+ export function validateName(name: string): void {
18
+ if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name) || reserved.has(name.toLowerCase())) {
19
+ throw new Error(`Invalid or reserved profile name: ${JSON.stringify(name)}. Use 1–64 letters, digits, dots, underscores or hyphens, starting with a letter or digit.`);
20
+ }
21
+ }
22
+
23
+ function object(value: unknown): value is Record<string, unknown> {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ /** Validate without consulting the model registry: incomplete drafts are permitted. */
28
+ function validateConfig(value: unknown): asserts value is Config {
29
+ if (!object(value) || value.version !== 1 || !object(value.profiles)) {
30
+ throw new Error("Invalid model profiles configuration: expected version 1 and a profiles object.");
31
+ }
32
+ for (const key of Object.keys(value)) {
33
+ if (!["version", "profiles", "defaultProfile", "lastActiveProfile"].includes(key)) {
34
+ throw new Error(`Unknown configuration field: ${key}`);
35
+ }
36
+ }
37
+ for (const key of ["defaultProfile", "lastActiveProfile"]) {
38
+ if (value[key] !== undefined) validateName(value[key] as string);
39
+ }
40
+ for (const [name, profile] of Object.entries(value.profiles)) {
41
+ validateName(name);
42
+ if (!object(profile)) throw new Error(`Invalid profile "${name}": expected an object.`);
43
+ for (const [alias, target] of Object.entries(profile)) {
44
+ if (!(ALIASES as readonly string[]).includes(alias) || typeof target !== "string" || !/^[^/\s]+\/\S+$/.test(target)) {
45
+ throw new Error(`Invalid mapping "${name}.${alias}": expected a physical provider/modelId.`);
46
+ }
47
+ if (target.startsWith("tier/")) throw new Error(`Invalid mapping "${name}.${alias}": virtual tier models cannot be targets.`);
48
+ }
49
+ }
50
+ }
51
+
52
+ function readContent(path: string): string | undefined {
53
+ try {
54
+ return readFileSync(path, "utf8");
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ /** One store per process. Call load again to explicitly accept an external change. */
62
+ export class ConfigStore {
63
+ private baseline: string | undefined;
64
+ private loaded = false;
65
+ private blocked = false;
66
+
67
+ constructor(readonly path: string) {}
68
+
69
+ load(): Config {
70
+ try {
71
+ const content = readContent(this.path);
72
+ const config: unknown = content === undefined ? { version: 1, profiles: {} } : JSON.parse(content);
73
+ validateConfig(config);
74
+ this.baseline = content;
75
+ this.loaded = true;
76
+ this.blocked = false;
77
+ return config;
78
+ } catch (error) {
79
+ this.blocked = true;
80
+ throw new Error(`Cannot load ${this.path}; existing data was preserved: ${(error as Error).message}`, { cause: error });
81
+ }
82
+ }
83
+
84
+ save(config: Config): void {
85
+ if (this.blocked) throw new Error(`Cannot save ${this.path}: configuration load failed. Repair the file and load it again first.`);
86
+ if (!this.loaded) this.load();
87
+ validateConfig(config);
88
+ const content = `${JSON.stringify(config, null, 2)}\n`;
89
+ mkdirSync(dirname(this.path), { recursive: true });
90
+ const lockPath = `${this.path}.lock`;
91
+ let lock: number;
92
+ try {
93
+ lock = openSync(lockPath, "wx", 0o600);
94
+ } catch (error) {
95
+ throw new Error(`Cannot lock ${this.path}; another writer may be active. Retry after it finishes (inspect ${lockPath} if stale).`, { cause: error });
96
+ }
97
+ const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
98
+ let tempCreated = false;
99
+ try {
100
+ if (readContent(this.path) !== this.baseline) {
101
+ throw new Error(`Configuration conflict: ${this.path} changed externally. Reload before saving.`);
102
+ }
103
+ const fd = openSync(temporary, "wx", 0o600);
104
+ tempCreated = true;
105
+ try {
106
+ writeFileSync(fd, content, "utf8");
107
+ fsyncSync(fd);
108
+ } finally {
109
+ closeSync(fd);
110
+ }
111
+ renameSync(temporary, this.path);
112
+ tempCreated = false;
113
+ this.baseline = content;
114
+ } finally {
115
+ try {
116
+ if (tempCreated) unlinkSync(temporary);
117
+ } finally {
118
+ try { closeSync(lock); } finally { unlinkSync(lockPath); }
119
+ }
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExtensionContext, ModelRouteRequest, ModelRoute } from "@earendil-works/pi-coding-agent";
2
+ import { ALIASES, type Alias, type Config } from "./config.ts";
3
+
4
+ export type Registry = Pick<ExtensionContext["modelRegistry"], "find" | "hasConfiguredAuth">;
5
+
6
+ export function resolveTarget(name: string | undefined, alias: Alias, config: Config, registry: Registry) {
7
+ const target = name && config.profiles[name]?.[alias];
8
+ const prefix = `Profile "${name ?? "(none)"}", tier/${alias} → ${target || "(missing mapping)"}`;
9
+ if (!target) throw new Error(`${prefix}: configure this alias using /model-profiles.`);
10
+ const slash = target.indexOf("/");
11
+ const model = registry.find(target.slice(0, slash), target.slice(slash + 1));
12
+ if (!model) throw new Error(`${prefix}: physical model is no longer in Pi's catalog. Edit the mapping.`);
13
+ if (model.api === "pi-virtual") throw new Error(`${prefix}: virtual-to-virtual routing is unsupported.`);
14
+ if (!registry.hasConfiguredAuth(model)) throw new Error(`${prefix}: provider has no configured credentials. Use /login or configure provider authentication.`);
15
+ return model;
16
+ }
17
+
18
+ export function validateProfile(name: string, config: Config, registry: Registry): void {
19
+ if (!Object.hasOwn(config.profiles, name)) throw new Error(`Unknown profile "${name}". Use /model-profiles.`);
20
+ for (const alias of ALIASES) resolveTarget(name, alias, config, registry);
21
+ }
22
+
23
+ export function route(alias: Alias, name: string | undefined, config: Config, registry: Registry, request: ModelRouteRequest): ModelRoute {
24
+ const sticky = request.reason === "retry" ? request.failed ?? request.previous : request.previous;
25
+ if ((request.reason === "retry" || request.reason === "continuation") && sticky) {
26
+ return { model: sticky.model, thinkingLevel: sticky.thinkingLevel ?? request.thinkingLevel };
27
+ }
28
+ return { model: resolveTarget(name, alias, config, registry), thinkingLevel: request.thinkingLevel };
29
+ }
package/src/state.ts ADDED
@@ -0,0 +1,12 @@
1
+ export const STATE_ENTRY = "model-profiles.selection";
2
+ export function branchProfile(entries: readonly { type: string; customType?: string; data?: unknown }[]): string | null | undefined {
3
+ for (let i = entries.length - 1; i >= 0; i--) {
4
+ const entry = entries[i]!;
5
+ if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
6
+ const data = entry.data as { profile?: unknown } | undefined;
7
+ if (typeof data?.profile === "string") return data.profile;
8
+ if (data?.profile === null) return null;
9
+ }
10
+ }
11
+ return undefined;
12
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,179 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { ALIASES, type Config, type Profile, validateName } from "./config.ts";
3
+ import { validateProfile } from "./profiles.ts";
4
+
5
+ export interface Controller {
6
+ getConfig(): Config;
7
+ getActive(): string | undefined;
8
+ activate(name: string, ctx: ExtensionContext): void;
9
+ commit(config: Config, active: string | undefined, ctx: ExtensionContext): void;
10
+ reload(ctx: ExtensionContext): void;
11
+ }
12
+
13
+ function error(ctx: ExtensionContext, cause: unknown): void {
14
+ ctx.ui.notify(cause instanceof Error ? cause.message : String(cause), "error");
15
+ }
16
+
17
+ function interactive(ctx: ExtensionContext): boolean {
18
+ if (ctx.mode === "tui") return true;
19
+ throw new Error("Profile dialogs require an interactive TUI. Use /model-profile <name> instead.");
20
+ }
21
+
22
+ function names(config: Config): string[] {
23
+ return Object.keys(config.profiles).sort();
24
+ }
25
+
26
+ function valid(name: string, config: Config, ctx: ExtensionContext): boolean {
27
+ try { validateProfile(name, config, ctx.modelRegistry); return true; }
28
+ catch { return false; }
29
+ }
30
+
31
+ async function chooseProfile(ctx: ExtensionContext, config: Config, active: string | undefined, title: string, candidates = names(config)): Promise<string | undefined> {
32
+ const labels = candidates.map(name => `${name === active ? "●" : "○"} ${name}${valid(name, config, ctx) ? "" : " [incomplete/unavailable]"}`);
33
+ const picked = await ctx.ui.select(title, labels);
34
+ return picked === undefined ? undefined : candidates[labels.indexOf(picked)];
35
+ }
36
+
37
+ export async function quickPicker(ctx: ExtensionContext, controller: Controller): Promise<void> {
38
+ if (!interactive(ctx)) return;
39
+ try {
40
+ const config = controller.getConfig();
41
+ if (!names(config).length) {
42
+ ctx.ui.notify("No profiles yet. Use /model-profiles to create one.", "info");
43
+ return;
44
+ }
45
+ const name = await chooseProfile(ctx, config, controller.getActive(), "Switch model profile");
46
+ if (name !== undefined) {
47
+ validateProfile(name, config, ctx.modelRegistry);
48
+ controller.activate(name, ctx);
49
+ }
50
+ } catch (cause) { error(ctx, cause); }
51
+ }
52
+
53
+ async function newName(ctx: ExtensionContext, config: Config): Promise<string | undefined> {
54
+ for (;;) {
55
+ const name = await ctx.ui.input("Profile name (1–64 letters, digits, . _ -)");
56
+ if (name === undefined) return undefined;
57
+ try {
58
+ validateName(name);
59
+ if (Object.hasOwn(config.profiles, name)) throw new Error(`Profile "${name}" already exists.`);
60
+ return name;
61
+ } catch (cause) { error(ctx, cause); }
62
+ }
63
+ }
64
+
65
+ /** Built-in input + paged selection keeps even very large registries usable without a custom TUI. */
66
+ async function modelPicker(ctx: ExtensionContext): Promise<string | undefined> {
67
+ const models = ctx.modelRegistry.getAll()
68
+ .filter(model => model.api !== "pi-virtual" && model.provider !== "tier")
69
+ .sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
70
+ const available = new Set(ctx.modelRegistry.getAvailable().map(model => `${model.provider}/${model.id}`));
71
+ for (;;) {
72
+ const query = await ctx.ui.input("Search physical models by provider, name or ID (blank = all)");
73
+ if (query === undefined) return undefined;
74
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
75
+ const matches = models.filter(model => terms.every(term => `${model.provider}/${model.id} ${model.name}`.toLowerCase().includes(term)));
76
+ if (!matches.length) { ctx.ui.notify("No matching physical models. Try another search.", "info"); continue; }
77
+ let page = 0;
78
+ for (;;) {
79
+ const items = matches.slice(page * 15, (page + 1) * 15);
80
+ const labels = items.map(model => `${model.name} — ${model.provider}/${model.id} [${available.has(`${model.provider}/${model.id}`) ? "available/authenticated" : "unavailable/no credentials"}]`);
81
+ const options = [...labels, "Search again", ...(page > 0 ? ["Previous page"] : []), ...((page + 1) * 15 < matches.length ? ["Next page"] : [])];
82
+ const choice = await ctx.ui.select(`Physical models (${matches.length} matches, page ${page + 1})`, options);
83
+ if (choice === undefined) return undefined;
84
+ if (choice === "Search again") break;
85
+ if (choice === "Next page") { page++; continue; }
86
+ if (choice === "Previous page") { page--; continue; }
87
+ const model = items[labels.indexOf(choice)];
88
+ if (model) return `${model.provider}/${model.id}`;
89
+ }
90
+ }
91
+ }
92
+
93
+ async function edit(ctx: ExtensionContext, name: string, source: Profile): Promise<Profile | undefined> {
94
+ const draft = { ...source };
95
+ for (;;) {
96
+ const mappings = ALIASES.map(alias => `${alias} → ${draft[alias] ?? "(not set)"}`);
97
+ const choice = await ctx.ui.select(`Edit ${name} (unsaved)`, [...mappings, "Save", "Cancel"]);
98
+ if (choice === undefined || choice === "Cancel") return undefined;
99
+ if (choice === "Save") return draft;
100
+ const alias = ALIASES[mappings.indexOf(choice)];
101
+ if (alias) {
102
+ const target = await modelPicker(ctx);
103
+ if (target !== undefined) draft[alias] = target;
104
+ }
105
+ }
106
+ }
107
+
108
+ export async function manage(ctx: ExtensionContext, controller: Controller): Promise<void> {
109
+ if (!interactive(ctx)) return;
110
+ for (;;) {
111
+ try {
112
+ const config = structuredClone(controller.getConfig());
113
+ let active = controller.getActive();
114
+ const action = await ctx.ui.select(`Model profiles — Active: ${active ?? "(none)"}`, [
115
+ "Activate", "Create", "Edit", "Duplicate", "Rename", "Delete", "Set default", "Reload configuration from disk", "Close",
116
+ ]);
117
+ if (action === undefined || action === "Close") return;
118
+ if (action === "Reload configuration from disk") {
119
+ if (await ctx.ui.confirm("Reload configuration?", "Accept external file changes. This discards the current in-memory configuration.")) controller.reload(ctx);
120
+ continue;
121
+ }
122
+ if (action === "Create") {
123
+ const name = await newName(ctx, config);
124
+ if (name === undefined) continue;
125
+ const draft = await edit(ctx, name, {});
126
+ if (draft === undefined) continue;
127
+ config.profiles[name] = draft;
128
+ } else {
129
+ if (!names(config).length) { ctx.ui.notify("Create a profile first.", "info"); continue; }
130
+ const name = await chooseProfile(ctx, config, active, `${action} profile`);
131
+ if (name === undefined) continue;
132
+ if (action === "Activate") {
133
+ validateProfile(name, config, ctx.modelRegistry);
134
+ controller.activate(name, ctx);
135
+ continue;
136
+ }
137
+ if (action === "Edit") {
138
+ const draft = await edit(ctx, name, config.profiles[name]!);
139
+ if (draft === undefined) continue;
140
+ config.profiles[name] = draft;
141
+ if (active === name) validateProfile(name, config, ctx.modelRegistry);
142
+ } else if (action === "Duplicate" || action === "Rename") {
143
+ const next = await newName(ctx, config);
144
+ if (next === undefined) continue;
145
+ config.profiles[next] = { ...config.profiles[name] };
146
+ if (action === "Rename") {
147
+ delete config.profiles[name];
148
+ if (active === name) active = next;
149
+ if (config.defaultProfile === name) config.defaultProfile = next;
150
+ if (config.lastActiveProfile === name) config.lastActiveProfile = next;
151
+ }
152
+ } else if (action === "Delete") {
153
+ if (active === name) {
154
+ const candidates = names(config).filter(other => other !== name && valid(other, config, ctx));
155
+ if (!candidates.length) throw new Error("Cannot delete the active profile: create another valid profile first.");
156
+ const replacement = await chooseProfile(ctx, config, active, "Choose replacement active profile", candidates);
157
+ if (replacement === undefined) continue;
158
+ active = replacement;
159
+ }
160
+ if (!await ctx.ui.confirm(`Delete "${name}"?`, "This permanently removes the profile and its mappings.")) continue;
161
+ delete config.profiles[name];
162
+ if (config.defaultProfile === name) {
163
+ if (active) config.defaultProfile = active;
164
+ else delete config.defaultProfile;
165
+ }
166
+ if (config.lastActiveProfile === name) {
167
+ if (active) config.lastActiveProfile = active;
168
+ else delete config.lastActiveProfile;
169
+ }
170
+ } else if (action === "Set default") {
171
+ validateProfile(name, config, ctx.modelRegistry);
172
+ config.defaultProfile = name;
173
+ }
174
+ }
175
+ controller.commit(config, active, ctx);
176
+ ctx.ui.notify("Profile configuration saved.", "info");
177
+ } catch (cause) { error(ctx, cause); }
178
+ }
179
+ }