pi-pignon 0.1.3 → 0.1.4

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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.4 — 2026-09-24
6
+
7
+ - `/pignon login` saves the Jev (TypeSafe) API key, so you no longer have to
8
+ export `TYPESAFE_API_KEY`. It can store a command that prints the key
9
+ (Keychain, 1Password, …), so the key never touches the disk, or the key
10
+ itself in `~/.pi/agent/pignon/credentials.json` (0600; refused when others
11
+ can read it). The environment variable still wins. `/pignon logout` removes
12
+ the key. `/pignon init` and the no-config default pick Jev when a key was
13
+ saved.
14
+ - README: a "Choosing a decider" comparison (latency, cost, privacy, setup),
15
+ setup steps in the order they run in Pi, and what `/pignon doctor` reports.
16
+ The configuration reference explains where Jev's key is looked up and how to
17
+ reach Jev through OpenRouter.
18
+ - The internal `docs/PLAN-*.md` notes are no longer shipped in the package.
19
+
20
+ ## 0.1.3 — 2026-09-24
21
+
22
+ - No changes from 0.1.2: the same documentation, released from `main`.
23
+
24
+ ## 0.1.2 — 2026-09-24
25
+
26
+ - The repository moved to `siiick/pi-pignon`, after the npm package. Links and
27
+ the config's `$schema` URL point there (the old URLs redirect).
28
+ - The README is shorter: the configuration reference moved to
29
+ `docs/CONFIGURATION.md` and the design notes to `docs/DESIGN.md`, both
30
+ shipped in the package.
31
+
3
32
  ## 0.1.1 — 2026-09-23
4
33
 
5
34
  - Published on npm: `pi install npm:pi-pignon`.
package/README.md CHANGED
@@ -38,12 +38,25 @@ pi install npm:pi-pignon
38
38
  ```
39
39
 
40
40
  `pi update --extensions` keeps it up to date. To pin a version:
41
- `pi install npm:pi-pignon@0.1.1`. To try unreleased changes:
41
+ `pi install npm:pi-pignon@0.1.4`. To try unreleased changes:
42
42
  `pi install git:github.com/siiick/pi-pignon`.
43
43
 
44
- ### 1. Give it a decision model
44
+ ### Choosing a decider
45
45
 
46
- pignon needs a local **Laya server**, a **Jev API key**, or both.
46
+ pignon needs a decision model: a local **Laya server**, **TypeSafe's Jev**, or both.
47
+
48
+ | | Laya (`laya-serve`) | Jev |
49
+ |---|---|---|
50
+ | Runs | On your machine (NVIDIA GPU, Apple Silicon or CPU) | TypeSafe's API |
51
+ | Latency | ~75 ms on Apple Silicon | ~70–500 ms |
52
+ | Cost | Free | Paid per decision; shown on each card and in `/pignon-stats` |
53
+ | Privacy | Prompts stay on your machine | The first 4 000 characters of each routed prompt are sent to TypeSafe |
54
+ | Setup | Install and run a server | An API key |
55
+
56
+ **Both:** Laya first, and Jev only when Laya is down or unsure, with the
57
+ [`sequential` strategy](docs/CONFIGURATION.md#using-several-deciders).
58
+
59
+ ### 1. Start Laya, or save a Jev key
47
60
 
48
61
  **Local: Laya with `laya-serve`**
49
62
 
@@ -55,35 +68,56 @@ LAYA_HOST=127.0.0.1 laya-serve # http://127.0.0.1:8000
55
68
  - Always set `LAYA_HOST=127.0.0.1` (default listens on all interfaces)
56
69
  - Loads the best available device (NVIDIA GPU → Apple Silicon → CPU)
57
70
  - First start downloads checkpoints and may take a while; later starts take 2–3 s
58
- - While loading or down, prompts are **not routed** — they keep the current model
71
+ - While loading or down, prompts are **not routed**: they keep the current model
72
+ - To start it at login on macOS, see [the launchd recipe](docs/CONFIGURATION.md#start-laya-serve-at-login-macos)
59
73
 
60
74
  **Remote: Jev**
61
75
 
62
- Get a key from [TypeSafe](https://typesafe.ai) and export it:
76
+ Get a key from [TypeSafe](https://typesafe.ai), start `pi`, and run:
63
77
 
64
78
  ```bash
65
- export TYPESAFE_API_KEY="sk-..."
79
+ /pignon login
66
80
  ```
67
81
 
68
- To use OpenRouter instead, see the [configuration reference](docs/CONFIGURATION.md#deciders).
82
+ Pick where the key comes from:
69
83
 
70
- ### 2. Restart Pi
84
+ - **A command** that prints it, e.g. `security find-generic-password -ws typesafe`
85
+ (macOS Keychain) or `op read op://Private/TypeSafe/credential` (1Password).
86
+ pignon runs it once per session; the key is never written to disk. Recommended.
87
+ - **Paste the key.** It is saved in `~/.pi/agent/pignon/credentials.json`, which
88
+ only you can read. pignon refuses the file if other users can read it.
71
89
 
72
- ```bash
73
- pi # or /reload
74
- ```
90
+ `/pignon logout` removes the saved key. In CI, or if you prefer, export
91
+ `TYPESAFE_API_KEY` before starting Pi instead; it takes precedence over a saved
92
+ key. To reach Jev through OpenRouter, see
93
+ [the configuration reference](docs/CONFIGURATION.md#jev-through-openrouter).
94
+
95
+ ### 2. Initialize and check
75
96
 
76
- ### 3. Initialize and go live
97
+ In Pi:
77
98
 
78
99
  ```bash
79
- /pignon init # writes ~/.pi/agent/pignon.json
80
- /pignon doctor # checks config, deciders and models
81
- /pignon live # start routing (default is shadow mode)
100
+ /pignon init # writes ~/.pi/agent/pignon.json with the deciders it finds
101
+ /reload # loads the config (and a key saved with /pignon login)
102
+ /pignon doctor # checks config, deciders and models, with one test decision
82
103
  ```
83
104
 
84
105
  `/pignon init anthropic` (or `openai`, `openrouter`) picks a preset explicitly.
85
106
  `init` never overwrites an existing file.
86
107
 
108
+ `/pignon doctor` says what is wrong with a decider: laya-serve not running, no
109
+ Jev key, a key rejected by the API, a key command that fails, or a credentials
110
+ file others can read.
111
+
112
+ ### 3. Go live
113
+
114
+ pignon starts in shadow mode: it shows what it would do on each prompt without
115
+ switching models. When the decisions look right:
116
+
117
+ ```bash
118
+ /pignon live
119
+ ```
120
+
87
121
  ## Commands
88
122
 
89
123
  | Command | Description |
@@ -98,6 +132,7 @@ pi # or /reload
98
132
  | `/pignon config migrate` | Convert a laya-router config to pignon format |
99
133
  | `/pignon init [preset]` | Write a starter `pignon.json` |
100
134
  | `/pignon doctor` | Check config, deciders and models |
135
+ | `/pignon login` / `logout` | Save or remove the Jev API key |
101
136
  | `/pignon-stats` | Show tier × form × confidence histogram |
102
137
  | `/pignon-stats compare` | Compare two deciders side-by-side |
103
138
  | `/pignon-stats export [path]` | Export decisions as JSON lines |
@@ -154,7 +189,7 @@ Run `/pignon config` to see the resolved table currently in use.
154
189
  | Variable | Default | Description |
155
190
  |----------|---------|-------------|
156
191
  | `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Config file path |
157
- | `TYPESAFE_API_KEY` | *(unset)* | Jev API key |
192
+ | `TYPESAFE_API_KEY` | *(unset)* | Jev API key (instead of `/pignon login`) |
158
193
  | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root |
159
194
  | `LAYA_HOST`, `LAYA_PORT`, `LAYA_MODELS` | *(varies)* | `laya-serve` startup options |
160
195
 
@@ -165,6 +200,9 @@ Full list: [docs/CONFIGURATION.md](docs/CONFIGURATION.md#environment-variables).
165
200
  - **Local prompts stay local.** With `laya-serve` on this machine, prompts never
166
201
  leave it. Jev (or remote laya-serve) receives the first 4 000 characters;
167
202
  cards are marked `☁`.
203
+ - **Keys stay out of the config.** The Jev key comes from `TYPESAFE_API_KEY`
204
+ or `/pignon login`, never from `pignon.json`, and a saved key is only sent
205
+ to TypeSafe.
168
206
  - **Fail-open.** If a decider is unreachable or fails, the prompt is not routed
169
207
  and keeps the current model (a few milliseconds of delay).
170
208
  - **Switch cost.** Changing models discards the prompt cache. Downgrades must
@@ -187,7 +225,7 @@ pi install ./ # load the clone in place
187
225
  npm run typecheck # Type check
188
226
  npm test # Unit tests
189
227
  npm run test:worker # Python worker tests
190
- npm run test:live # Real decider calls (needs API key)
228
+ npm run test:live # Real decider calls (needs TYPESAFE_API_KEY and/or laya-serve)
191
229
  npm run schema # Regenerate JSON Schema
192
230
  npm run check # typecheck + tests + worker tests
193
231
  ```
@@ -18,8 +18,8 @@ Run `/pignon config` to see the resolved table currently in use.
18
18
  | Variable | Default | Description |
19
19
  |----------|---------|-------------|
20
20
  | `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Path of the optional config file |
21
- | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config and exports there |
22
- | `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`) |
21
+ | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config, exports and saved key (`pignon/credentials.json`) there |
22
+ | `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`). Takes precedence over a key saved with `/pignon login` |
23
23
  | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root, when `baseURL` is not set |
24
24
  | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Jev model, when `model` is not set |
25
25
  | `LAYA_ROUTER_CONFIG` | `~/.pi/agent/laya-router.json` | **Legacy:** read only when there is no pignon config |
@@ -28,7 +28,7 @@ laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`, `LAYA_MOD
28
28
 
29
29
  ## Deciders
30
30
 
31
- `deciders` picks the decision model. `/pignon init` writes it for you. Without it, pignon uses the experimental worker when installed, else Jev when `TYPESAFE_API_KEY` is set; it does not look for laya-serve on its own.
31
+ `deciders` picks the decision model. `/pignon init` writes it for you. Without it, pignon uses the experimental worker when installed, else Jev when `TYPESAFE_API_KEY` is set or a key was saved with `/pignon login`; it does not look for laya-serve on its own.
32
32
 
33
33
  ```json
34
34
  {
@@ -46,11 +46,34 @@ laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`, `LAYA_MOD
46
46
  | | `timeoutMs` | `1500` | Timeout for one decision |
47
47
  | `laya-local` | | | [Experimental worker](#experimental-pignons-mlx-worker), see its section |
48
48
  | `jev` | `model` | `jev-latest` | Jev version to pin. Confidences are calibrated per version, so pinning keeps your thresholds valid |
49
- | | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. Keys are never read from the config file |
50
- | | `baseURL` | TypeSafe | `https://openrouter.ai/api` to go through OpenRouter (with `"apiKeyEnv": "OPENROUTER_API_KEY"`) |
49
+ | | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. When it is unset, the key saved with `/pignon login` is used, but only when neither `apiKeyEnv` nor `baseURL` is set, so the TypeSafe key never goes elsewhere. Keys are never read from the config file |
50
+ | | `baseURL` | TypeSafe | Another API root, e.g. [OpenRouter](#jev-through-openrouter) |
51
51
  | | `timeoutMs` | `1500` | Timeout for one decision |
52
52
  | | `maxRetries` | `0` | Retries after a failed call; each gets the full timeout |
53
53
 
54
+ ### Jev's API key
55
+
56
+ pignon looks for the key in this order:
57
+
58
+ 1. the environment variable named by `apiKeyEnv` (`TYPESAFE_API_KEY` by default);
59
+ 2. the key saved with `/pignon login`, in `<Pi config dir>/pignon/credentials.json`: either the key itself, or a `!command` that prints it (run once per session, e.g. to read the macOS Keychain or 1Password). The file must be readable by you only (`chmod 600`).
60
+
61
+ `/pignon login` and `/pignon logout` take effect after `/reload`. A saved key is only used for TypeSafe's own API: never with a `baseURL` or `apiKeyEnv` of your own, and never for laya-serve.
62
+
63
+ ### Jev through OpenRouter
64
+
65
+ Jev is also reachable through OpenRouter. `/pignon login` does not apply there: export your OpenRouter key and name it in the config:
66
+
67
+ ```json
68
+ {
69
+ "deciders": [
70
+ { "type": "jev", "baseURL": "https://openrouter.ai/api", "apiKeyEnv": "OPENROUTER_API_KEY" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ Run `/pignon doctor` to check that it answers.
76
+
54
77
  ### Using several deciders
55
78
 
56
79
  List more than one and `strategy` says how they work together:
package/docs/DESIGN.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  With `laya-serve` on this machine (`127.0.0.1` or `localhost`) or `laya-local`, prompts never leave it. With `jev`, or a laya-serve on another host, the first 4 000 characters of each routed prompt are sent over the network, and decision cards are marked ☁. The SDK's own logging is capped at `warn` and kept in `/pignon log`, so prompts are never logged, even with `TYPESAFE_LOG_LEVEL=debug`.
6
6
 
7
+ The Jev key is never read from `pignon.json`, which people share. `/pignon login` saves it in its own file, `<Pi config dir>/pignon/credentials.json`, not in Pi's `auth.json`, which Pi writes under a lock extensions cannot take. The file is written 0600 in a 0700 directory, through a temporary file and a rename, and refused when group or others can read it. It can hold a `!command` instead of the key (Keychain, 1Password…), so the key never touches the disk; the command runs once per session, and its stderr is never shown, since it could echo the key. The environment variable takes precedence, and a saved key is only given to a `jev` decider on TypeSafe's own endpoint, never to a custom `baseURL`, a custom `apiKeyEnv`, or laya-serve.
8
+
7
9
  ## Fail-open
8
10
 
9
11
  If a decider cannot be reached or a decision fails, the decision is `null` and the extension keeps the current model. A laya-serve that is down refuses the connection at once, so the prompt waits a few milliseconds, not a timeout. The experimental worker loads its model in the background from `session_start`; prompts sent before it is ready are not routed (status shows `model loading — prompt not routed`) rather than held. It stays warm for the session, is reloaded in the background if it crashes, and is stopped on `session_shutdown`. A worker that is not ready within 5 minutes is killed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pignon",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Pi coding agent extension that shifts to the right LLM for each prompt, using a local (Laya) or remote (Jev) decision model to judge task difficulty",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,7 +27,9 @@
27
27
  "src",
28
28
  "schema",
29
29
  "examples",
30
- "docs",
30
+ "docs/CONFIGURATION.md",
31
+ "docs/DESIGN.md",
32
+ "docs/assets",
31
33
  "CHANGELOG.md"
32
34
  ],
33
35
  "engines": {
@@ -40,7 +42,7 @@
40
42
  "clean": "rm -rf dist node_modules/.vitest",
41
43
  "build": "echo 'nothing to build (TypeScript runs via jiti)'",
42
44
  "check": "tsc --noEmit && vitest run && npm run test:worker",
43
- "test:worker": "cd worker && .venv/bin/python -m unittest",
45
+ "test:worker": "cd worker && ${PYTHON:-.venv/bin/python} -m unittest",
44
46
  "schema": "UPDATE_SCHEMA=1 vitest run tests/schema.test.ts",
45
47
  "test:live": "PIGNON_LIVE=1 vitest run tests/live",
46
48
  "prepublishOnly": "npm run check"
@@ -61,9 +63,9 @@
61
63
  "devDependencies": {
62
64
  "@earendil-works/pi-coding-agent": "^0.87.1",
63
65
  "@earendil-works/pi-tui": "^0.87.1",
64
- "@types/node": "^22.0.0",
65
- "typescript": "^5.6.0",
66
- "vitest": "^2.1.0",
66
+ "@types/node": "^26.6.2",
67
+ "typescript": "^7.0.2",
68
+ "vitest": "^5.0.1",
67
69
  "typebox": "^1.3.34"
68
70
  }
69
71
  }
@@ -109,7 +109,7 @@
109
109
  "apiKeyEnv": {
110
110
  "type": "string",
111
111
  "minLength": 1,
112
- "description": "Environment variable holding the API key. Default: TYPESAFE_API_KEY."
112
+ "description": "Environment variable holding the API key. Default: TYPESAFE_API_KEY, else the key saved with /pignon login (only when neither apiKeyEnv nor baseURL is set)."
113
113
  },
114
114
  "timeoutMs": {
115
115
  "type": "number",
@@ -129,7 +129,7 @@
129
129
  },
130
130
  "minItems": 1,
131
131
  "maxItems": 4,
132
- "description": "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set. /pignon init adds laya-serve when it is running."
132
+ "description": "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set or a key was saved with /pignon login. /pignon init adds laya-serve when it is running."
133
133
  },
134
134
  "strategy": {
135
135
  "type": "object",
@@ -147,7 +147,7 @@ export const JevDeciderSchema = Type.Object(
147
147
  Type.String({ minLength: 1, description: "API root. `https://openrouter.ai/api` goes through OpenRouter. Default: TypeSafe." }),
148
148
  ),
149
149
  apiKeyEnv: Type.Optional(
150
- Type.String({ minLength: 1, description: "Environment variable holding the API key. Default: TYPESAFE_API_KEY." }),
150
+ Type.String({ minLength: 1, description: "Environment variable holding the API key. Default: TYPESAFE_API_KEY, else the key saved with /pignon login (only when neither apiKeyEnv nor baseURL is set)." }),
151
151
  ),
152
152
  timeoutMs: timeoutMs("Timeout for one decision, in milliseconds. Default: 1500."),
153
153
  maxRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 3, description: "Retries after a failed attempt. Default: 0." })),
@@ -165,7 +165,7 @@ export const DecidersSchema = Type.Array(Type.Union([LayaServeDeciderSchema, Lay
165
165
  minItems: 1,
166
166
  maxItems: 4,
167
167
  description:
168
- "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set. /pignon init adds laya-serve when it is running.",
168
+ "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set or a key was saved with /pignon login. /pignon init adds laya-serve when it is running.",
169
169
  });
170
170
 
171
171
  export const StrategySchema = Type.Object(
@@ -0,0 +1,101 @@
1
+ /**
2
+ * API keys stored by `/pignon login`, for users who would rather not export an
3
+ * environment variable.
4
+ *
5
+ * They live in their own file, `<agent dir>/pignon/credentials.json`, never in
6
+ * pignon.json (which people share) nor in Pi's auth.json (which Pi writes
7
+ * under a lock pignon cannot take). The file is created 0600 in a 0700
8
+ * directory, and refused, like ssh does, when group or others can read it.
9
+ *
10
+ * A stored key is either the key itself or, like in Pi's auth.json, a command
11
+ * prefixed with `!` whose output is the key (`!security find-generic-password
12
+ * -ws typesafe`): with a password manager, the key never touches the disk.
13
+ *
14
+ * { "typesafe": { "key": "sk-..." } }
15
+ */
16
+
17
+ import { exec } from "node:child_process";
18
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ import { agentDir } from "./config/load.js";
22
+
23
+ /** Name of the TypeSafe (Jev) key in the credentials file. */
24
+ export const TYPESAFE_CREDENTIAL = "typesafe";
25
+
26
+ /** A key command gets this long, e.g. to wait for a Keychain or 1Password prompt. */
27
+ const COMMAND_TIMEOUT_MS = 30_000;
28
+
29
+ export function credentialsPath(env: NodeJS.ProcessEnv = process.env): string {
30
+ return join(agentDir(env), "pignon", "credentials.json");
31
+ }
32
+
33
+ export type StoredKey =
34
+ | { kind: "missing" }
35
+ | { kind: "error"; error: string }
36
+ /** `value` is the key, or a command (starting with `!`) that prints it. */
37
+ | { kind: "ok"; value: string };
38
+
39
+ /** Read one stored key without resolving it. */
40
+ export function readStoredKey(name: string, path: string = credentialsPath()): StoredKey {
41
+ let text: string;
42
+ try {
43
+ const mode = statSync(path).mode;
44
+ if (process.platform !== "win32" && (mode & 0o077) !== 0) {
45
+ return { kind: "error", error: `${path} can be read by other users; run chmod 600 on it` };
46
+ }
47
+ text = readFileSync(path, "utf8");
48
+ } catch (err) {
49
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
50
+ return { kind: "error", error: `cannot read ${path}: ${(err as Error).message}` };
51
+ }
52
+ let entry: unknown;
53
+ try {
54
+ entry = (JSON.parse(text) as Record<string, unknown>)[name];
55
+ } catch {
56
+ return { kind: "error", error: `${path} is not valid JSON` };
57
+ }
58
+ if (entry === undefined) return { kind: "missing" };
59
+ const value = (entry as { key?: unknown } | null)?.key;
60
+ if (typeof value !== "string" || !value.trim()) return { kind: "error", error: `${path}: ${name}.key must be a non-empty string` };
61
+ return { kind: "ok", value: value.trim() };
62
+ }
63
+
64
+ /** Store (or, with `undefined`, remove) one key, leaving the others. */
65
+ export function writeStoredKey(name: string, value: string | undefined, path: string = credentialsPath()): void {
66
+ let data: Record<string, unknown> = {};
67
+ try {
68
+ data = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
69
+ } catch (err) {
70
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
71
+ }
72
+ if (value === undefined) delete data[name];
73
+ else data[name] = { key: value };
74
+
75
+ if (Object.keys(data).length === 0) {
76
+ rmSync(path, { force: true });
77
+ return;
78
+ }
79
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
80
+ // Written aside then renamed: a crash never leaves a half-written file.
81
+ const temp = `${path}.${process.pid}.tmp`;
82
+ writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
83
+ chmodSync(temp, 0o600);
84
+ renameSync(temp, path);
85
+ }
86
+
87
+ /** The key a stored value stands for: the value itself, or what its `!command` prints. */
88
+ export async function resolveStoredKey(value: string): Promise<string> {
89
+ if (!value.startsWith("!")) return value;
90
+ const command = value.slice(1).trim();
91
+ const output = await new Promise<string>((resolve, reject) => {
92
+ exec(command, { timeout: COMMAND_TIMEOUT_MS, encoding: "utf8" }, (err, stdout) => {
93
+ // The command's stderr is not quoted: it could echo the key.
94
+ if (err) reject(new Error(`key command failed (${err.killed ? "timed out" : `exit ${err.code ?? "?"}`})`));
95
+ else resolve(stdout);
96
+ });
97
+ });
98
+ const key = output.trim();
99
+ if (!key) throw new Error("key command printed nothing");
100
+ return key;
101
+ }
@@ -8,6 +8,7 @@
8
8
  * returns an `UnavailableDecider` whose warmup error says what to install.
9
9
  */
10
10
 
11
+ import { type StoredKey, TYPESAFE_CREDENTIAL, credentialsPath, readStoredKey } from "../credentials.js";
11
12
  import type { DeciderSpec, RouterConfig } from "../types.js";
12
13
  import { DEFAULT_API_KEY_ENV, JevDecider } from "./jev.js";
13
14
  import { LayaWorker, layaRuntimeStatus } from "./laya-local.js";
@@ -26,10 +27,13 @@ export interface CreateDeciderDeps {
26
27
  env?: NodeJS.ProcessEnv;
27
28
  /** Whether the local worker can run here (tests replace the platform check). */
28
29
  layaStatus?: typeof layaRuntimeStatus;
30
+ /** Reads a key saved by `/pignon login`. Defaults to the credentials file. */
31
+ storedKey?: (name: string) => StoredKey;
29
32
  }
30
33
 
31
34
  export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}): CreatedDecider {
32
35
  const env = deps.env ?? process.env;
36
+ const storedKey = deps.storedKey ?? ((name: string) => readStoredKey(name, credentialsPath(env)));
33
37
  const notes: string[] = [];
34
38
  let specs = config.deciders;
35
39
 
@@ -37,19 +41,19 @@ export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}
37
41
  const laya = (deps.layaStatus ?? layaRuntimeStatus)(env);
38
42
  if (laya.ok) {
39
43
  specs = [{ type: "laya-local" }];
40
- } else if (env[DEFAULT_API_KEY_ENV]?.trim()) {
44
+ } else if (env[DEFAULT_API_KEY_ENV]?.trim() || storedKey(TYPESAFE_CREDENTIAL).kind !== "missing") {
41
45
  specs = [{ type: "jev" }];
42
46
  } else {
43
47
  return {
44
48
  decider: new UnavailableDecider(
45
- `no decider configured: start laya-serve (see pignon's README) and run /pignon init, or set ${DEFAULT_API_KEY_ENV} for Jev`,
49
+ `no decider configured: start laya-serve (see pignon's README) and run /pignon init, or run /pignon login (or set ${DEFAULT_API_KEY_ENV}) for Jev`,
46
50
  ),
47
51
  notes,
48
52
  };
49
53
  }
50
54
  }
51
55
 
52
- const deciders = specs.map((spec) => build(spec, config, env));
56
+ const deciders = specs.map((spec) => build(spec, config, env, storedKey));
53
57
  if (deciders.length === 1) return { decider: deciders[0]!, notes };
54
58
  return {
55
59
  decider: new StrategyDecider(deciders, config.strategy, (answers, latencyMs) => parseDecision(answers, latencyMs, config)),
@@ -57,7 +61,7 @@ export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}
57
61
  };
58
62
  }
59
63
 
60
- function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv): Decider {
64
+ function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv, storedKey: (name: string) => StoredKey): Decider {
61
65
  switch (spec.type) {
62
66
  case "laya-serve":
63
67
  return createLayaServeDecider(spec, env);
@@ -69,6 +73,10 @@ function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv):
69
73
  case "jev":
70
74
  return new JevDecider({
71
75
  env,
76
+ // The stored key is TypeSafe's: only for TypeSafe's own endpoint and variable.
77
+ ...(spec.apiKeyEnv === undefined && spec.baseURL === undefined
78
+ ? { storedKey: () => storedKey(TYPESAFE_CREDENTIAL) }
79
+ : {}),
72
80
  ...(spec.model !== undefined ? { model: spec.model } : {}),
73
81
  ...(spec.baseURL !== undefined ? { baseURL: spec.baseURL } : {}),
74
82
  ...(spec.apiKeyEnv !== undefined ? { apiKeyEnv: spec.apiKeyEnv } : {}),
@@ -28,6 +28,7 @@ import {
28
28
  TypeSafeError,
29
29
  } from "@typesafe-ai/sdk";
30
30
 
31
+ import { type StoredKey, resolveStoredKey } from "../credentials.js";
31
32
  import type { LayaQuestion } from "../types.js";
32
33
  import { type Decider, type DeciderResult, type DecisionRequest, DeciderError } from "./types.js";
33
34
 
@@ -59,6 +60,12 @@ export interface JevDeciderOptions {
59
60
  timeoutMs?: number;
60
61
  /** Retries after a failed attempt. Each gets the full timeout. */
61
62
  maxRetries?: number;
63
+ /**
64
+ * The key saved by `/pignon login`, used when the environment variable is
65
+ * unset. Only for TypeSafe itself: never set it with another `baseURL` or
66
+ * `apiKeyEnv`, which would send the TypeSafe key elsewhere.
67
+ */
68
+ storedKey?: () => StoredKey;
62
69
  /** Where to read the API key and SDK settings. Defaults to `process.env`. */
63
70
  env?: NodeJS.ProcessEnv;
64
71
  /** HTTP implementation (tests). */
@@ -78,6 +85,8 @@ export class JevDecider implements Decider {
78
85
  private readonly timeoutMs: number;
79
86
  private readonly logLines: string[] = [];
80
87
  private client?: TypeSafeClient;
88
+ /** A stored key command runs once per session, failed or not: it may prompt the user. */
89
+ private storedKeyResolution?: Promise<string>;
81
90
  private lastModel?: string;
82
91
  private stopped = false;
83
92
 
@@ -89,9 +98,12 @@ export class JevDecider implements Decider {
89
98
  this.timeoutMs = options.timeoutMs ?? DEFAULT_JEV_TIMEOUT_MS;
90
99
  }
91
100
 
92
- /** Ready as soon as an API key is available (or none is needed); there is nothing to load. */
101
+ /**
102
+ * Ready as soon as an API key is available (or none is needed); there is
103
+ * nothing to load. A stored key is ready once `warmup` has resolved it.
104
+ */
93
105
  get isReady(): boolean {
94
- return !this.stopped && (this.apiKey() !== undefined || this.options.requireApiKey === false);
106
+ return !this.stopped && (this.client !== undefined || this.envApiKey() !== undefined || this.options.requireApiKey === false);
95
107
  }
96
108
 
97
109
  /** The model that last answered, else the one requests will name. */
@@ -107,11 +119,11 @@ export class JevDecider implements Decider {
107
119
 
108
120
  /** Check the API key and build the client. No network call. */
109
121
  async warmup(): Promise<void> {
110
- this.ensureClient();
122
+ await this.ensureClient();
111
123
  }
112
124
 
113
125
  async decide(request: DecisionRequest, signal?: AbortSignal): Promise<DeciderResult> {
114
- const client = this.ensureClient();
126
+ const client = await this.ensureClient();
115
127
  const started = Date.now();
116
128
  try {
117
129
  const result = await client.systemOne(
@@ -147,16 +159,37 @@ export class JevDecider implements Decider {
147
159
  return this.options.env ?? process.env;
148
160
  }
149
161
 
150
- private apiKey(): string | undefined {
162
+ private envApiKey(): string | undefined {
151
163
  const key = this.env()[this.apiKeyEnv]?.trim();
152
164
  return key ? key : undefined;
153
165
  }
154
166
 
155
- private ensureClient(): TypeSafeClient {
167
+ /** The environment variable first (CI, existing setups), then the stored key. */
168
+ private async apiKey(): Promise<string | undefined> {
169
+ const fromEnv = this.envApiKey();
170
+ if (fromEnv || !this.options.storedKey) return fromEnv;
171
+ const stored = this.options.storedKey();
172
+ if (stored.kind === "missing") return undefined;
173
+ if (stored.kind === "error") throw new DeciderError(`${this.id}: ${stored.error}`);
174
+ this.storedKeyResolution ??= resolveStoredKey(stored.value);
175
+ try {
176
+ return await this.storedKeyResolution;
177
+ } catch (err) {
178
+ throw new DeciderError(`${this.id}: ${(err as Error).message}; fix it with /pignon login, then /reload`, err);
179
+ }
180
+ }
181
+
182
+ private async ensureClient(): Promise<TypeSafeClient> {
183
+ if (this.stopped) throw new DeciderError(`${this.id}: decider is stopped`);
184
+ if (this.client) return this.client;
185
+ const apiKey = (await this.apiKey()) ?? (this.options.requireApiKey === false ? NO_API_KEY : undefined);
186
+ if (!apiKey) {
187
+ const hint = this.options.storedKey ? ` (or run /pignon login)` : "";
188
+ throw new DeciderError(`${this.id}: ${this.apiKeyEnv} is not set${hint}`);
189
+ }
190
+ // The key may have taken a while to resolve.
156
191
  if (this.stopped) throw new DeciderError(`${this.id}: decider is stopped`);
157
192
  if (this.client) return this.client;
158
- const apiKey = this.apiKey() ?? (this.options.requireApiKey === false ? NO_API_KEY : undefined);
159
- if (!apiKey) throw new DeciderError(`${this.id}: ${this.apiKeyEnv} is not set`);
160
193
 
161
194
  const env = this.env();
162
195
  try {
package/src/extension.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * pignon — Pi agent extension.
3
3
  *
4
- * Shifts to the right model for each prompt by asking a decider (today: the
5
- * local Laya System-1 model, via a stdio worker it spawns and supervises) how
6
- * hard the prompt is, then looking the answer up in the routing table.
4
+ * Shifts to the right model for each prompt by asking a decider (a local Laya
5
+ * model through laya-serve, TypeSafe's hosted Jev, or the experimental worker)
6
+ * how hard the prompt is, then looking the answer up in the routing table.
7
7
  *
8
8
  * /pignon -> show current mode
9
9
  * /pignon live -> apply decisions
@@ -15,6 +15,8 @@
15
15
  * /pignon config migrate -> convert a laya-router config file
16
16
  * /pignon init [preset] -> write a starter config file
17
17
  * /pignon doctor -> check deciders, models and config
18
+ * /pignon login -> save the TypeSafe (Jev) API key
19
+ * /pignon logout -> remove it
18
20
  * /pignon-stats -> session statistics
19
21
  * /pignon-stats compare -> how two deciders agree (parallel strategy)
20
22
  * /pignon-stats export [path] -> decisions as JSON lines
@@ -46,6 +48,7 @@ import { showReport } from "./report.js";
46
48
  import { buildStatsLines } from "./stats.js";
47
49
  import type { DeciderSpec, RouterConfig, RouterLogEntry, RouterMode } from "./types.js";
48
50
  import { hideDeciding, renderDecisionCard, showDeciding } from "./ui.js";
51
+ import { login, logout } from "./login.js";
49
52
  import {
50
53
  type ModelLookup,
51
54
  choosePreset,
@@ -75,6 +78,8 @@ const SUBCOMMANDS = [
75
78
  "init",
76
79
  ...PRESET_NAMES.map((name) => `init ${name}`),
77
80
  "doctor",
81
+ "login",
82
+ "logout",
78
83
  ];
79
84
 
80
85
  type PiModel = Parameters<ExtensionAPI["setModel"]>[0];
@@ -337,6 +342,17 @@ export function createExtension(options: ExtensionOptions = {}): (pi: ExtensionA
337
342
  await showReport(ctx, "pignon doctor", report.then(([, ...body]) => body));
338
343
  return;
339
344
  }
345
+ if (arg === "login") {
346
+ if (!ctx.hasUI) return;
347
+ const result = await login(ctx.ui);
348
+ if (result) notify(ctx, `pignon: ${result.message}`, result.level);
349
+ return;
350
+ }
351
+ if (arg === "logout") {
352
+ const result = logout();
353
+ notify(ctx, `pignon: ${result.message}`, result.level);
354
+ return;
355
+ }
340
356
  if (arg === "config migrate") {
341
357
  if (!loaded.legacy) {
342
358
  notify(ctx, "pignon: config is already in the pignon format");
@@ -411,7 +427,7 @@ export function createExtension(options: ExtensionOptions = {}): (pi: ExtensionA
411
427
  SUBCOMMANDS.filter((v) => v.startsWith(prefix)).map((v) => ({ value: v, label: v }));
412
428
 
413
429
  pi.registerCommand("pignon", {
414
- description: "pignon mode (shadow | live | off | unpin | log | config)",
430
+ description: "pignon mode and setup (shadow | live | off | unpin | log | config | init | doctor | login | logout)",
415
431
  getArgumentCompletions: completions,
416
432
  handler: modeCommand,
417
433
  });
package/src/login.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `/pignon login` and `/pignon logout`: save or remove the TypeSafe (Jev) API
3
+ * key in pignon's credentials file (see `credentials.ts`).
4
+ *
5
+ * Pi-free: the dialogs are reached through `LoginUI`.
6
+ */
7
+
8
+ import {
9
+ TYPESAFE_CREDENTIAL,
10
+ credentialsPath,
11
+ readStoredKey,
12
+ resolveStoredKey,
13
+ writeStoredKey,
14
+ } from "./credentials.js";
15
+ import { DEFAULT_API_KEY_ENV } from "./deciders/jev.js";
16
+
17
+ /** The part of Pi's UI the login dialogs use. */
18
+ export interface LoginUI {
19
+ select(title: string, options: string[]): Promise<string | undefined>;
20
+ input(title: string, placeholder?: string): Promise<string | undefined>;
21
+ }
22
+
23
+ export type LoginResult = { level: "info" | "warning" | "error"; message: string };
24
+
25
+ const FROM_COMMAND = "Read it from a command (password manager, Keychain): the key is not written to disk";
26
+ const PASTE = "Paste the key: saved in pignon's credentials file (readable by you only)";
27
+
28
+ const EXAMPLE_COMMAND =
29
+ process.platform === "darwin" ? "security find-generic-password -ws typesafe" : "op read op://Private/TypeSafe/credential";
30
+
31
+ export async function login(
32
+ ui: LoginUI,
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ path: string = credentialsPath(env),
35
+ ): Promise<LoginResult | undefined> {
36
+ const how = await ui.select("How should pignon get your TypeSafe API key?", [FROM_COMMAND, PASTE]);
37
+ if (how === undefined) return undefined;
38
+
39
+ let value: string;
40
+ if (how === FROM_COMMAND) {
41
+ const command = (await ui.input("Command that prints the key", EXAMPLE_COMMAND))?.trim().replace(/^!\s*/, "");
42
+ if (!command) return undefined;
43
+ value = `!${command}`;
44
+ try {
45
+ await resolveStoredKey(value);
46
+ } catch (err) {
47
+ return { level: "error", message: `${(err as Error).message}; nothing saved` };
48
+ }
49
+ } else {
50
+ const key = (await ui.input("TypeSafe API key", "paste the key"))?.trim();
51
+ if (!key) return undefined;
52
+ if (/\s/.test(key)) return { level: "error", message: "an API key has no spaces; nothing saved" };
53
+ value = key;
54
+ }
55
+
56
+ try {
57
+ writeStoredKey(TYPESAFE_CREDENTIAL, value, path);
58
+ } catch (err) {
59
+ return { level: "error", message: `could not write ${path}: ${(err as Error).message}` };
60
+ }
61
+ const shadowed = env[DEFAULT_API_KEY_ENV]?.trim() ? ` ${DEFAULT_API_KEY_ENV} is set and is used first.` : "";
62
+ return { level: shadowed ? "warning" : "info", message: `saved the TypeSafe key in ${path}; /reload to use it.${shadowed}` };
63
+ }
64
+
65
+ export function logout(env: NodeJS.ProcessEnv = process.env, path: string = credentialsPath(env)): LoginResult {
66
+ if (readStoredKey(TYPESAFE_CREDENTIAL, path).kind === "missing") {
67
+ return { level: "info", message: "no TypeSafe key saved" };
68
+ }
69
+ try {
70
+ writeStoredKey(TYPESAFE_CREDENTIAL, undefined, path);
71
+ } catch (err) {
72
+ return { level: "error", message: `could not write ${path}: ${(err as Error).message}` };
73
+ }
74
+ const still = env[DEFAULT_API_KEY_ENV]?.trim() ? ` ${DEFAULT_API_KEY_ENV} is still set.` : "";
75
+ return { level: "info", message: `removed the saved TypeSafe key; /reload to apply.${still}` };
76
+ }
package/src/onboarding.ts CHANGED
@@ -10,6 +10,7 @@ import { dirname } from "node:path";
10
10
 
11
11
  import { type PresetName, PRESETS, PRESET_NAMES } from "./config/presets.js";
12
12
  import { CONFIG_SCHEMA_URL } from "./config/schema.js";
13
+ import { type StoredKey, TYPESAFE_CREDENTIAL, credentialsPath, readStoredKey } from "./credentials.js";
13
14
  import { DEFAULT_API_KEY_ENV } from "./deciders/jev.js";
14
15
  import { type WorkerLaunch, layaRuntimeStatus } from "./deciders/laya-local.js";
15
16
  import { probeLayaServe } from "./deciders/laya-serve.js";
@@ -49,16 +50,17 @@ export function choosePreset<M>(lookup: ModelLookup<M>): PresetName {
49
50
  /**
50
51
  * The decider to start with, local first: a running laya-serve on its default
51
52
  * address, else the experimental worker when installed, else Jev when its key
52
- * is set.
53
+ * is set or saved by `/pignon login`.
53
54
  */
54
55
  export async function detectDeciders(
55
56
  env: NodeJS.ProcessEnv = process.env,
56
57
  layaStatus: typeof layaRuntimeStatus = layaRuntimeStatus,
57
58
  probe: () => Promise<boolean> = () => probeLayaServe(),
59
+ storedKey: (name: string) => StoredKey = (name) => readStoredKey(name, credentialsPath(env)),
58
60
  ): Promise<DeciderSpec[]> {
59
61
  if (await probe()) return [{ type: "laya-serve" }];
60
62
  if (layaStatus(env).ok) return [{ type: "laya-local" }];
61
- if (env[DEFAULT_API_KEY_ENV]?.trim()) return [{ type: "jev" }];
63
+ if (env[DEFAULT_API_KEY_ENV]?.trim() || storedKey(TYPESAFE_CREDENTIAL).kind !== "missing") return [{ type: "jev" }];
62
64
  return [];
63
65
  }
64
66
 
@@ -1,161 +0,0 @@
1
- # Plan (pignon): command output that is not truncated and does not persist
2
-
3
- Status: implemented · 2026-09-23 (see *Changes from the proposal*)
4
-
5
- ## Problem
6
-
7
- `/pignon doctor` (and `config`, `log`, `stats`) render through `ctx.ui.setWidget(key, string[])`.
8
- Two consequences, both bad UX:
9
-
10
- 1. **Truncated.** Pi keeps only the first 10 lines of a string-array widget and appends
11
- `... (widget truncated)`. Doctor emits ~15–25 lines, so the model checks — the part the
12
- user ran the command for — are silently dropped.
13
- 2. **Persists.** A widget is a persistent slot: it stays above the editor through every
14
- following prompt until `setWidget(key, undefined)`, i.e. until `/pignon doctor clear`.
15
-
16
- ## Why it happens (verified in the Pi source)
17
-
18
- `@earendil-works/pi-coding-agent` 0.87.1, `InteractiveMode` in
19
- `dist/bundle/chunks/chunk-OJP47DM6.js`:
20
-
21
- ```js
22
- if (Array.isArray(content)) {
23
- let container = new Container;
24
- for (let line of content.slice(0, _InteractiveMode.MAX_WIDGET_LINES))
25
- container.addChild(new Text(line, 1, 0));
26
- content.length > _InteractiveMode.MAX_WIDGET_LINES &&
27
- container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
28
- component = container;
29
- } else component = content(this.ui, theme); // factory overload: no cap
30
- ```
31
-
32
- with `static MAX_WIDGET_LINES = 10`.
33
-
34
- The cap applies **only** to the `string[]` overload. The component-factory overload is
35
- uncapped — `src/ui.ts` already relies on that for the `pignon-deciding` spinner.
36
-
37
- Corroborated by a third-party extension (`itayinbarr/little-coder`,
38
- `.pi/extensions/extensions-info/manifest.ts`):
39
-
40
- > pi slices a string-array widget to MAX_WIDGET_LINES (10) and appends
41
- > "... (widget truncated)", so anything past that is silently lost.
42
-
43
- Persistence is by design, not a bug: `docs/tui.md` lists `ctx.ui.setWidget()` under
44
- *"Persistent content near the editor"*. The fix is therefore to stop using a widget for
45
- one-shot reports, not to tune the widget.
46
-
47
- ## What each command emits today
48
-
49
- | command | lines | vs the 10-line cap |
50
- | --- | --- | --- |
51
- | `doctor` | ~15–25: header, `config` + 1–2, `deciders` + 1–3, `models` + up to 8 | truncated |
52
- | `log` | 30 (`LOG_WIDGET_LINES`) | truncated to 10 — worst case |
53
- | `stats` | ~12+: header, bucket header, 3 tiers × 2 forms, summary | truncated |
54
- | `config` | ~8: header, column header, N tiers, footer, hint | ok at 3 tiers, truncates at 4+ |
55
-
56
- ## Decision
57
-
58
- Render one-shot reports in a **dismissible overlay** (`ctx.ui.custom({ overlay: true })`)
59
- that scrolls itself: no line cap, scrollable, disposed on close so nothing persists.
60
- Fallback to `notify` in RPC mode and to stderr in print/JSON mode.
61
-
62
- Apply it to **all four** commands through one shared helper.
63
-
64
- ## Design
65
-
66
- ### `src/report.ts` (new)
67
-
68
- ```ts
69
- export async function showReport(
70
- ctx: ExtensionContext,
71
- title: string,
72
- lines: string[] | Promise<string[]>,
73
- ): Promise<void>
74
- ```
75
-
76
- Three tiers, following `docs/extensions.md` (*"Guard terminal-only behavior with
77
- `ctx.mode === "tui"` and use `ctx.hasUI` for interactions supported by interactive and RPC
78
- clients"*):
79
-
80
- | mode | behaviour |
81
- | --- | --- |
82
- | `ctx.mode === "tui"` | `ctx.ui.custom(..., { overlay: true })` — `ReportOverlay` below |
83
- | RPC (`ctx.hasUI`, not tui) | `ctx.ui.notify(lines.join("\n"), "info")` — RPC forwards `notify`, not custom components |
84
- | print / JSON (`!ctx.hasUI`) | `process.stderr.write(lines.join("\n") + "\n")` — stdout stays parseable in JSON mode |
85
-
86
- The `Promise<string[]>` overload matters for `doctor`, which can block ~15 s on the decider
87
- probe: open the overlay immediately with `running checks…`, then `setLines()` →
88
- `invalidate()` + `tui.requestRender()` when it resolves. The editor is never left looking
89
- frozen.
90
-
91
- ### `ReportOverlay`
92
-
93
- A `Component` (`render(width)` / `handleInput(data)` / `invalidate()` / `dispose()`):
94
-
95
- - Keeps its own line offset and renders only the visible slice, framed by a
96
- rounded border: title in the top rule; hint and `a–b/n` position in the bottom rule
97
- - Body height: 70% of `tui.terminal.rows` (read on each render) minus the 2 border rows
98
- - Overlay options: `{ width: <widest line + frame>, minWidth: 40, anchor: "center", margin: 1 }`;
99
- `90%` while doctor's lines are still pending (Pi resolves overlay options once, at open)
100
- - Keys: ↑↓ / `j` `k` (line), PgUp / PgDn / space (page), Home / End / `g` `G`,
101
- `Esc` / `q` / `Enter` → `done()`
102
- - Every line passes through `truncateToWidth(…, pad)` — see Risks
103
-
104
- ## Files
105
-
106
- | file | change |
107
- | --- | --- |
108
- | `src/report.ts` | **new** — `showReport()` + `ReportOverlay` |
109
- | `src/extension.ts` | replace the 4 report `showWidget(...)` calls with `showReport(...)`; drop the `"(/pignon X clear to hide)"` trailers; raise `LOG_WIDGET_LINES` 30 → 200 (the overlay scrolls) |
110
- | `src/config/describe.ts` | drop its internal `"(/pignon config clear to hide)"` line |
111
- | `src/ui.ts` | unchanged — `showDeciding` is *meant* to persist, and already uses the uncapped factory overload |
112
- | `src/onboarding.ts` | unchanged — already returns plain `string[]` |
113
- | `tests/extension.test.ts`, `tests/extension-routing.test.ts` | add `custom` and `mode` to the mocks; update the `ctx.ui.setWidget` assertions |
114
- | `tests/report.test.ts` | **new** — overlay line rendering and key handling (pure, no terminal) |
115
- | `tests/helpers/fake-report.ts` | **new** — a `ctx.ui.custom` fake that keeps the overlay for assertions |
116
- | `src/stats.ts`, `src/compare.ts` | drop their `"(/pignon-stats clear to hide)"` lines |
117
- | `README.md`, `CHANGELOG.md` | the four commands no longer need `clear`; keep the subcommands as no-ops for back-compat |
118
-
119
- ## Steps
120
-
121
- 1. Write `src/report.ts`: `ReportOverlay` (Box + ScrollView + Text) and `showReport()`.
122
- 2. Rewire `doctor`, `config`, `log`, `stats` in `src/extension.ts`; remove the "clear to
123
- hide" trailers.
124
- 3. Extend the test mocks with `custom` and `mode`, then update and add tests.
125
- 4. Update README and CHANGELOG.
126
-
127
- ## Risks
128
-
129
- - **Width overflow.** pi-tui throws on over-wide lines (reported upstream as issue #48 by
130
- `little-coder`). `truncateToWidth()` on every line is load-bearing, not cosmetic.
131
- - **Modal.** `doctor` now needs `Esc` before the user can type again. Accepted: it is a
132
- diagnostic, and it is the cost of nothing persisting.
133
- - **Key collision.** `matchesKey(data, "q")` is safe here only because the overlay has no
134
- text input. Any future search/filter field inside it must handle `q` as text first.
135
- - **Back-compat.** `doctor clear` / `config clear` / `stats clear` / `log clear` stay
136
- accepted and keep clearing the (now unused) widget key so a stale widget from an older
137
- session cannot linger.
138
-
139
- ## Changes from the proposal
140
-
141
- - **No `ScrollView`.** Pi composites overlays in `TUI.compositeOverlays()`
142
- (`pi-tui/dist/tui.js`): it calls `component.render(width)` and slices the result to
143
- `maxHeight`. The `[LAYOUT_NODE]` / `updateLayout` path runs only in
144
- `renderLayoutFrame()` for the alt-screen root, never for overlays, and
145
- `ScrollView.render()` returns all of its child's lines. A `ScrollView` in an overlay
146
- would therefore never scroll, and `maxHeight: "70%"` would truncate it silently: the
147
- same bug with a border around it. `ReportOverlay` scrolls itself instead.
148
- - **Overlay options go under `overlayOptions`**, not at the top level of the `custom()`
149
- options.
150
- - **Width fits the content** instead of a fixed 80%, which truncated the config table's
151
- last column at 100 columns.
152
- - **Five call sites, not four**: `/pignon-stats compare` also used the widget. `stats.ts`
153
- and `compare.ts` had their own "clear to hide" lines too.
154
- - **Titles move into the border.** Each report's first line (`pignon doctor`,
155
- `pignon config · <source>`, …) becomes the overlay title. The producers are unchanged,
156
- so RPC and stderr output still start with it.
157
- - `clear` subcommands are no longer offered in completion, but are still accepted.
158
- - A rejected `lines` promise shows `✗ <message>` in the overlay instead of leaving
159
- `running checks…` forever.
160
- - Verified in Pi 0.87.1 through a pty: config and doctor render whole, doctor scrolls on a
161
- 14-row terminal, and Esc leaves nothing above the editor.
@@ -1,327 +0,0 @@
1
- # Plan (pignon): pluggable deciders (local Laya + remote Jev) and a user-defined routing table
2
-
3
- Status: proposal · 2026-09-23
4
-
5
- ## Goals
6
-
7
- 1. **Remote decider.** Route with TypeSafe's Jev (hosted, via `@typesafe-ai/sdk`) as well
8
- as the local Laya worker. Jev is used as a *fallback*: when Laya is unavailable, errors,
9
- or answers below a confidence floor.
10
- 2. **User-owned routing table.** Move the model map and the difficulty tiers out of
11
- `types.ts` into config. Users name their own models and write their own ordered list of
12
- difficulty tiers (2..N, with the criterion text sent to the decider).
13
- 3. **Publishable.** Easy setup, a schema-checked config, docs, tests, and modules small
14
- enough to read one at a time.
15
-
16
- Non-goals (for now): fine-tuning Laya, per-project config, and asking questions other than
17
- tier and form.
18
-
19
- ## What stays the same
20
-
21
- - The routing policy (`policy.ts`) keeps its algorithm: confidence gates, cooldown, payback
22
- and cache guard, fail-open.
23
- - Pi wiring: modes (shadow/live/off), manual pin, decision cards, `/pignon-stats` (renamed from `/laya*`, kept as aliases for one release).
24
- - The Laya worker protocol and the Python worker. Only the TS client moves.
25
- - Prompt privacy on the local path (the hash and length are logged, never the text).
26
-
27
- ## Facts that constrain the design (from the SDK v0.6.0 `.d.ts`)
28
-
29
- | Fact | Consequence |
30
- |---|---|
31
- | `TypeSafeClient({ apiKey, baseURL, defaultModel, timeout, retry, logger, logLevel, fetch })` | `fetch` can be injected, so tests need no network. `baseURL: "https://openrouter.ai/api"` + an OpenRouter key also works. |
32
- | Env fallbacks: `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL` | Nothing to configure for the common case. |
33
- | `timeout` applies **per attempt**, default 2 retries, no total budget | Set `retry.maxRetries: 0` (or 1) and bound the whole call with our own `AbortSignal.timeout`. |
34
- | Default logger is `console`, and `debug` logs request **bodies** | Pass our own logger (into the `/pignon log` ring buffer), cap `logLevel` at `warn`, so nothing draws over the TUI and prompts never reach logs. |
35
- | `ChoiceResponse` = `{ choice, confidence, probabilities }`, **no `type` field** | The shared parser must not require `type === "choice"` (the local worker's `choiceOf` does today). |
36
- | `usage` has tokens; cost comes back at runtime but is not typed | Record `costUsd` when present and show it in stats. |
37
- | 70–500 ms latency, 32k-token state limit, input-billed | Separate timeout per decider (Jev ≈ 1500 ms). Keep the 4 000-char prompt cap for both deciders. |
38
- | Error classes: `AuthenticationError`, `RateLimitError`, `APITimeoutError`, `APIUserAbortError`… | Map them to short status texts ("jev: bad API key", "jev: rate limited"). |
39
-
40
- ## Architecture
41
-
42
- ```
43
- ┌──────────────── extension.ts (Pi wiring only) ────────────────┐
44
- prompt ───► │ router.ts: routePrompt() │
45
- │ deciders/* ──► RoutingDecision ──► policy.decide() ──► setModel
46
- └───────────────────────────────────────────────────────────────┘
47
-
48
- deciders/
49
- types.ts Decider interface, DecisionQuestions, RawAnswers
50
- questions.ts build the tier/form questions from config (single source)
51
- parse.ts RawAnswers → RoutingDecision (shared, tolerant)
52
- laya-local.ts LayaWorker (today's laya-worker.ts) implementing Decider
53
- jev.ts TypeSafeClient adapter implementing Decider
54
- fallback.ts FallbackDecider: tries deciders in order, escalating on low confidence
55
- create.ts factory: config → Decider
56
- ```
57
-
58
- ### The `Decider` seam
59
-
60
- ```ts
61
- export interface Decider {
62
- readonly id: string; // "laya-local" | "jev" | "fallback(laya-local→jev)"
63
- readonly label: string; // shown on the spinner and card, e.g. "jev-1.13.0"
64
- readonly isReady: boolean; // local: model warm; remote: key present
65
- readonly remote: boolean; // prompt leaves the machine → shown on the card
66
- warmup(signal?: AbortSignal): Promise<void>;
67
- decide(q: DecisionQuestions, text: string, signal: AbortSignal): Promise<DeciderResult>;
68
- stop(): void;
69
- readonly recentLogs: readonly string[];
70
- }
71
-
72
- export interface DeciderResult {
73
- answers: RawAnswers; // { reasoning_demand: {choice, confidence}, needs_exploration: … }
74
- deciderId: string; // which decider actually answered
75
- model: string; // checkpoint / jev model version
76
- latencyMs: number;
77
- costUsd?: number;
78
- attempts: Attempt[]; // one per decider tried (for the card and stats)
79
- }
80
- ```
81
-
82
- `parse.ts` turns `answers` into `RoutingDecision` against the **configured** tier ids, so the
83
- policy never sees decider-specific shapes.
84
-
85
- ### Fallback semantics (`FallbackDecider`)
86
-
87
- Config order is the try order. For each decider:
88
-
89
- 1. Skip it if it is not ready (e.g. Laya still loading) → record `skipped: not ready`.
90
- 2. Call it with its own timeout, under the caller's signal.
91
- 3. Accept the answer if `tierConfidence >= escalateBelow` **or** it is the last decider.
92
- 4. Otherwise escalate: record the attempt and try the next one.
93
-
94
- If every decider fails, rethrow → the policy fails open (as today). When the last decider
95
- answers with *lower* confidence than an earlier one, keep the most confident answer. An
96
- overall `budgetMs` (default 3000) caps total latency, so a slow Laya plus a slow Jev cannot
97
- stall a prompt.
98
-
99
- Defaults: `escalateBelow: 0.75`, and escalate on error and on not-ready.
100
-
101
- ## Configuration (v2)
102
-
103
- One JSON file, as today (`~/.pi/agent/pignon.json` or `$PIGNON_CONFIG`; the old `laya-router.json` is read as a fallback with a rename hint). Every
104
- key is optional; defaults reproduce today's behavior exactly.
105
-
106
- ```jsonc
107
- {
108
- "$schema": "https://unpkg.com/pignon/schema/config.schema.json",
109
- "version": 2,
110
-
111
- // 1. Deciders, in fallback order
112
- "deciders": [
113
- { "type": "laya-local", "timeoutMs": 2500 }, // optional: model, python, workerDir…
114
- { "type": "jev", "timeoutMs": 1500, "model": "jev-1.13.0" } // apiKey from TYPESAFE_API_KEY
115
- ],
116
- "strategy": { "mode": "sequential", "escalateBelow": 0.75 }, // see "Decision strategies"
117
-
118
- // 2. Model aliases: define once, reference by name
119
- "models": {
120
- "flash": { "provider": "openrouter", "modelId": "deepseek/deepseek-v4-flash-0731", "thinking": "off" },
121
- "flash-41": { "provider": "openrouter", "modelId": "deepseek/deepseek-v4.1-flash", "thinking": "low" },
122
- "glm": { "provider": "openrouter", "modelId": "z-ai/glm-5.3", "thinking": "high" },
123
- "hy4": { "provider": "openrouter", "modelId": "tencent/hy4-preview", "thinking": "low" }
124
- },
125
-
126
- // 3. Difficulty tiers, easiest first. The criterion is what the decider reads.
127
- "tiers": [
128
- { "id": "trivial", "criterion": "Mechanical edit, rename, formatting, or a single factual lookup",
129
- "model": "flash", "explorationAllowed": false },
130
- { "id": "standard", "criterion": "Localized change across a few files with clear intent",
131
- "model": "flash-41" },
132
- { "id": "hard", "criterion": "Multi-step investigation, debugging with unclear cause, or cross-cutting design",
133
- "direct": "glm", "exploration": "hy4" }
134
- ],
135
-
136
- // Optional wording overrides; bump questionsVersion whenever you edit criteria.
137
- "questions": { "version": "q1", "tierInstructions": "…", "explorationInstructions": "…" },
138
-
139
- "thresholds": { "minConfidenceDowngrade": 0.85 }
140
- }
141
- ```
142
-
143
- Rules:
144
-
145
- - A tier gives either `model` (both forms) or `direct` + `exploration`. A value can be an
146
- alias or an inline `{ provider, modelId, thinking }`.
147
- - `explorationAllowed: false` generalizes today's hard-coded "exploration forbids trivial"
148
- rule: an exploration task at that tier goes up to the next tier that allows it.
149
- - Need 2..8 tiers with unique ids. Unknown alias → error naming the tier.
150
- - `version` missing + the old `tiers: { hard: { direct: … } }` object shape → migrate in
151
- memory and notify once ("config uses v1 format; run `/pignon config migrate`").
152
- - Invalid config: same contract as today. Report it on session start, fall back per
153
- section, never block loading.
154
- - **Secrets never go in the file.** `jev.apiKeyEnv` (default `TYPESAFE_API_KEY`) names the
155
- variable. A literal `apiKey` is rejected with a message.
156
-
157
- ### Validation
158
-
159
- Use TypeBox (see "Validation with TypeBox" below). The schema module is the single source
160
- for the TS types, the runtime checks, and `schema/config.schema.json`.
161
-
162
- ### Presets and onboarding
163
-
164
- - `presets/openrouter.json` (today's table), `presets/anthropic.json`, `presets/openai.json`.
165
- Config can say `"extends": "openrouter"` and override only what differs.
166
- - `/pignon init [preset]` writes a starter config (it refuses to overwrite an existing file),
167
- lists the models from `ctx.modelRegistry` that resolve, and flags those that do not.
168
- - `/pignon doctor` checks each decider (worker venv present? Apple Silicon? key set? one test
169
- `decide` round-trip) and each table model (in registry? auth?), with one ✓/✗ line per check.
170
- - `/pignon config` shows the resolved table (tier × form → model · thinking) and its source.
171
-
172
- ## Code changes, file by file
173
-
174
- | File | Change |
175
- |---|---|
176
- | `src/types.ts` | Drop `Tier` union, `DEFAULT_TIERS`, `TIER_ORDER`; keep protocol + policy types. `Profile.tier: TierId` (string). Add `DeciderAttempt`, extend `RouterLogEntry` with `decider`, `escalated`, `attempts`, `costUsd`, `questionsVersion`. |
177
- | `src/config/schema.ts` | TypeBox schema, v1→v2 migration. |
178
- | `src/config/load.ts` | Today's `loadConfig` (file read, error collection) on top of the schema; resolves `extends` and aliases into a `ResolvedConfig` whose `table` is an ordered array. |
179
- | `src/config/defaults.ts` | Default models, tiers, and deciders (`[laya-local]` so behavior does not change on upgrade). |
180
- | `src/deciders/*` | See the architecture section. `laya-worker.ts` moves to `deciders/laya-local.ts` with its public API unchanged, plus a thin `Decider` adapter. |
181
- | `src/policy.ts` | Tier order comes from `config.table` (index = rank). The trivial/exploration rule reads `explorationAllowed`. `profileFromModel` takes the table. |
182
- | `src/router.ts` | New: `routePrompt` + `buildLogEntry` pulled out of `extension.ts` (Pi-free except for a small `RouterHost` interface: `findModel`, `setModel`, `setThinking`, `contextTokens`), so it can be tested without Pi mocks. |
183
- | `src/extension.ts` | Wiring only: build the decider from config, register hooks and commands. Spinner/status text use `decider.label`. |
184
- | `src/ui.ts` | Card head shows the decider that answered (`laya` / `jev ☁`), escalation (`laya 0.62 → jev 0.91`), and cost. Stats grid rows come from the config tiers. |
185
- | `package.json` | `@typesafe-ai/sdk` and `typebox` in `dependencies`; `files`, `license`, `repository`, `keywords: ["pi-package", "pi-extension"]`; drop `private`. |
186
-
187
- The old `~/.pi/agent/extensions/jev-router` becomes redundant: its behavior equals
188
- `deciders: [{ "type": "jev" }]`. Remove it after migration so two routers don't both fire.
189
-
190
- ## Tests
191
-
192
- | Suite | Covers |
193
- |---|---|
194
- | `deciders/contract.test.ts` | One shared suite run against every `Decider` (Laya via a fake spawn, Jev via an injected `fetch`): ready/not-ready, abort, timeout, malformed answers, `stop()`. |
195
- | `deciders/jev.test.ts` | Request body (questions from config, 4 000-char cap, model pin); SDK errors → short messages; `maxRetries` 0; no console output; cost parsing; missing key → `isReady=false`, never throws at construction. |
196
- | `deciders/fallback.test.ts` | Escalation on low confidence, error, and not-ready; keeps the best answer; overall budget; caller abort stops the chain; `attempts` recorded. |
197
- | `config/*.test.ts` | Schema accept/reject tables, alias resolution, `extends`, v1 migration, literal-apiKey rejection, per-section fallback, JSON Schema file up to date. |
198
- | `policy.test.ts` | Existing 38 cases kept, parameterized over a 2-tier and a 4-tier table; `explorationAllowed`. |
199
- | `router.test.ts` | End-to-end through `RouterHost` fakes (moves most of `extension-routing.test.ts`). |
200
- | `extension.test.ts` | Commands incl. `init`, `doctor`, `config`. |
201
- | `live.test.ts` | Skipped unless `TYPESAFE_API_KEY` is set (`npm run test:live`): one real Jev call checks the response shape still matches the parser. |
202
-
203
- `npm run check` = typecheck + unit + worker tests + schema freshness. No network in `check`.
204
-
205
- ## Docs
206
-
207
- - `README.md`, restructured: 60-second quickstart (Jev only: set the key, `/pignon init`,
208
- `/pignon live`), then "Add the local Laya model (Apple Silicon)", then Configuration
209
- reference, Commands, How routing decides, Privacy, Troubleshooting (`/pignon doctor`).
210
- - **Privacy section:** local Laya keeps prompts on the machine. Jev sends the first 4 000
211
- characters of each routed prompt to TypeSafe (or OpenRouter). The card marks remote
212
- decisions with ☁.
213
- - `docs/configuration.md`: every key, generated tables from the schema descriptions.
214
- - `docs/writing-tiers.md`: how to write criteria, why to bump `questions.version`, and how
215
- to calibrate thresholds from `/laya-stats` in shadow mode.
216
- - `CHANGELOG.md`, `LICENSE`, and `examples/*.json`.
217
-
218
- ## Phases (each ends green on `npm run check`)
219
-
220
- 1. ✅ **Seam, no behavior change.** `git init`; add the `Decider` interface; wrap `LayaWorker`;
221
- move parsing to `parse.ts`; extract `router.ts`. Existing tests keep passing.
222
- 2. ✅ **Configurable table.** TypeBox schema, aliases, ordered tiers, questions built from config,
223
- v1 migration, dynamic tiers in policy/UI/stats.
224
- 3. ✅ **Jev decider.** SDK adapter, error mapping, logger/retry hardening, contract + unit tests.
225
- 4. ✅ **Strategies.** sequential + parallel, budget, `/pignon-stats compare|export`, attempts on card and log, stats
226
- per decider (escalation rate, cost).
227
- 5. ✅ **Onboarding.** Presets + `extends`, `/pignon init|doctor|config`, JSON Schema generation.
228
- 6. ✅ **Publish.** ~~PyPI `pignon-laya` + uvx launcher~~ (dropped, see below) + protocol check, docs, package metadata, live test,
229
- `npm pack` dry-run, and a test install into a clean `~/.pi` via `pi install`/symlink. Retire `jev-router`.
230
- Released as git tag `v0.1.0` (`pi install git:github.com/siiick/pignon@v0.1.0`); npm is deferred, the package
231
- is ready for it (only the Pi package gallery needs npm).
232
- 7. ✅ **laya-serve.** `laya-serve` decider over the Jev client (no key, local when on loopback), detected by
233
- `/pignon init`, documented as the way to run Laya locally; `laya-local` becomes experimental.
234
-
235
- ## Decision (2026-09-23): laya-serve instead of publishing pignon-laya
236
-
237
- The official `laya` package already ships `laya-serve`, a server speaking Jev's
238
- `POST /v1/systemone`. Benchmarked through pignon's own deciders on 12 prompts × 5 rounds:
239
- same tier and exploration answers on 12/12 (probabilities equal to ~0.001), p50 75 ms
240
- against 61 ms for the MLX worker, 2–3 s restart (18 s on the very first run), 712 MB
241
- installed against 258 MB. A stopped server refuses connections within milliseconds, so
242
- prompts are never held. Publishing our own package would duplicate it for ~14 ms, so
243
- `pignon-laya` stays in the repository, unpublished (PyPI's `Private :: Do Not Upload`
244
- classifier), and the uvx launcher is removed. A `serve` command upstream in laya-mlx
245
- would bring MLX speed to everyone; to propose there.
246
-
247
- ## Decisions (2026-09-23)
248
-
249
- 1. **Default deciders.** Laya if its runtime can start, else Jev if a key is set, else a
250
- status that says "no decider, run `/pignon doctor`". Resolved once per session, and
251
- `/pignon config` shows which one was picked.
252
- 2. **Name: `pignon`** (the sprocket on a bike cassette: the router changes sprockets between tiers). npm `pignon`, PyPI `pignon-laya`, commands `/pignon` and `/pignon-stats`, config `~/.pi/agent/pignon.json`, env prefix `PIGNON_` (`LAYA_*` stays for the worker itself).
253
- 3. **Validation: TypeBox**, not zod (see below).
254
- 4. **Both strategies**, `sequential` and `parallel`, with a comparison view for benchmarking.
255
-
256
- ## Validation with TypeBox (replaces the zod section)
257
-
258
- TypeBox 1.x (`typebox` on npm, 1.3.x; Pi itself depends on 1.3.27) builds schemas that
259
- *are* JSON Schema objects:
260
-
261
- - Types: `Static<typeof ConfigSchema>`.
262
- - Checking: `Compile(ConfigSchema)` from `typebox/compile`. Its `.Errors(value)` gives
263
- instance paths for the notify message.
264
- - JSON Schema: `npm run schema` writes `JSON.stringify(ConfigSchema, null, 2)` to
265
- `schema/config.schema.json`. No converter needed. A test fails if the file is stale.
266
- - Add `typebox` as our own `dependency` (range `^1.3.27`). It is not hoisted from Pi.
267
-
268
- ## Decision strategies
269
-
270
- ```jsonc
271
- "deciders": [ { "type": "laya-local" }, { "type": "jev" } ],
272
- "strategy": {
273
- "mode": "sequential", // or "parallel"
274
- "escalateBelow": 0.75, // sequential: try the next decider below this confidence
275
- "pick": "most-confident", // parallel: "most-confident" | "first" (list order wins when it answers)
276
- "budgetMs": 3000 // both modes: total wall time
277
- }
278
- ```
279
-
280
- - **sequential** (default): the fallback behavior above. Cheapest; Jev is only called
281
- when needed.
282
- - **parallel**: every ready decider runs at once under `budgetMs`. The router then uses
283
- one answer, chosen by `pick`. With `pick: "first"`, Laya stays authoritative and Jev
284
- only runs alongside it for comparison: this is the benchmarking setup. Note that every
285
- routed prompt then costs a Jev call and sends the prompt out.
286
- - Every attempt is logged (`decider`, `model`, tier, confidences, latency, cost, error), so
287
- the data is the same in both modes.
288
- - `/pignon-stats compare` shows, over the session's parallel decisions, the tier agreement rate
289
- and a Laya × Jev confusion matrix, the form agreement rate, mean confidence per decider,
290
- p50/p95 latency, and total Jev cost. `/pignon-stats export` writes the attempts as JSONL
291
- (hashes, not prompts) for offline analysis or future fine-tuning labels.
292
-
293
- ## Distributing the optional Laya runtime
294
-
295
- Laya needs Python, `laya-mlx` (MLX), and Apple Silicon. Most users of a published
296
- extension will have none of these. So the runtime must be **opt-in**, **outside the npm
297
- package**, and it **must survive extension updates**.
298
-
299
- | Option | Verdict |
300
- |---|---|
301
- | Ship `worker/` in the npm package; user runs `uv sync` inside `node_modules/…` | ✗ The venv is hidden, is wiped on every update, and the absolute paths break (already an issue today with `rsync`). |
302
- | Prebuilt binary (PyInstaller) as an optional npm dependency | ✗ MLX + Metal make it large, it needs code signing and notarization, and it rebuilds on every laya-mlx release. |
303
- | Docker | ✗ No Metal GPU in containers on macOS. |
304
- | **Separate PyPI package, launched with `uvx`** | ✓ Recommended. |
305
-
306
- **Recommended design:**
307
-
308
- - Publish `worker/` to PyPI as `pignon-laya` (`[project.scripts] pignon-laya = "laya_worker:main"`,
309
- dependency `laya-mlx`). It is a separate release artifact with its own version, and it
310
- lives in the same repo.
311
- - The npm package does **not** include `worker/` (`files` whitelist).
312
- - Launch order in the `laya-local` decider:
313
- 1. `command` in config (dev: `["uv", "run", "--project", "./worker", "pignon-laya"]`);
314
- 2. `pignon-laya` on `PATH` (for `uv tool install pignon-laya` or `pipx`);
315
- 3. `uvx --from pignon-laya==<compatible range> pignon-laya`. It installs nothing up
316
- front, uv caches the environment outside the extension, and it survives npm updates;
317
- 4. none of these → `isReady = false` with a clear reason; the strategy skips it.
318
- - **Protocol handshake:** the `ready` line already carries `PROTOCOL_VERSION` (0.3.0).
319
- The extension declares the major version it accepts and refuses a mismatch with an
320
- "upgrade with `uv tool upgrade pignon-laya`" message instead of misparsing.
321
- - **Platform gate:** on anything other than `darwin`/`arm64`, the decider reports
322
- "unsupported platform" without trying to spawn anything.
323
- - **Onboarding:** `/pignon laya install` runs `uv tool install pignon-laya` and then a warmup
324
- (the first checkpoint download is about 850 MB; progress goes to the log widget). It
325
- asks first and needs `uv` (doctor links to the uv installer).
326
- - **Worker tests** stay in the Python package. `npm run check` still runs them in the repo.
327
- - The existing env allowlist, the model pinning, and the stderr capture are unchanged.