localpi 0.5.0 → 0.6.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.
Files changed (32) hide show
  1. package/README.md +389 -11
  2. package/dist/src/cli/cli.js +115 -6
  3. package/dist/src/cli/main.js +0 -0
  4. package/dist/src/llm/openai.js +65 -4
  5. package/dist/src/localpi/acp.js +118 -0
  6. package/dist/src/localpi/catalog.js +79 -7
  7. package/dist/src/localpi/catppuccin.js +64 -0
  8. package/dist/src/localpi/llama-server.js +72 -39
  9. package/dist/src/localpi/model-profile.js +4 -0
  10. package/dist/src/localpi/options.js +129 -9
  11. package/dist/src/localpi/provider-registry.js +51 -3
  12. package/dist/src/localpi/runtime-connection.js +11 -8
  13. package/dist/src/localpi/runtime.js +12 -7
  14. package/dist/src/localpi/settings-state.js +13 -3
  15. package/dist/src/pi/app.js +13 -6
  16. package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
  17. package/dist/src/pi/extension-sources/settings-file.js +31 -0
  18. package/dist/src/pi/extension-sources/status-line.js +424 -0
  19. package/dist/src/pi/extension-sources/thinking-control.js +4 -47
  20. package/dist/src/pi/extension-sources/token-status.js +545 -116
  21. package/dist/src/pi/extension-sources/tool-approval.js +155 -14
  22. package/dist/src/pi/extensions.js +55 -12
  23. package/dist/src/pi/skills.js +24 -0
  24. package/dist/src/pi/theme.js +107 -0
  25. package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +39 -11
  26. package/docs/2026-09-23-acp-mode-plan.md +111 -0
  27. package/docs/2026-09-24-continue-on-truncation-plan.md +115 -0
  28. package/docs/design-principles.md +114 -0
  29. package/docs/implementation-plan.md +33 -0
  30. package/docs/runtime-specification.md +98 -4
  31. package/package.json +11 -7
  32. package/dist/src/pi/extension-sources/demo-mode.js +0 -110
package/README.md CHANGED
@@ -1,16 +1,29 @@
1
1
  # localpi
2
2
 
3
- Localpi is a Swiss army knife for running Pi with local inference engines.
3
+ <p align="center">
4
+ <img src="assets/cover.svg" alt="localpi: an unopinionated Pi distribution that makes it easy to test and work with small local models on constrained systems" width="880">
5
+ </p>
6
+
7
+ Localpi is an unopinionated Pi distribution that makes it easy to test and work with small local models on constrained systems.
4
8
 
5
9
  By default, Localpi discovers available local providers, lets you choose when more than one model is loaded, points Pi at the selected model, and writes Pi config for the other discovered models so `/model` can switch among them during the session.
6
10
 
7
- Localpi is meant to be the practical bridge from Pi to local inference stacks such as llama.cpp/`llama-server`, vLLM, SGLang, LM Studio, Ollama, and custom provider endpoints.
11
+ Localpi is meant to be the practical bridge from Pi to local inference stacks such as llama.cpp/`llama-server`, vLLM, SGLang, LM Studio, Ollama, and custom provider endpoints. llama.cpp is the default engine: Localpi probes a running llama.cpp server first and prefers its loaded models.
8
12
 
9
13
  Localpi is intentionally generic. It does not contain classifier prompts, dataset workflows, GitHub routing logic, or final-schema output machinery. Structured classifier runs belong in caller tools such as `localpager-agent`.
10
14
 
15
+ A Localpi session keeps its context light. Localpi appends three sentences to Pi's own system prompt, adds no datasets, prompt packs, or memory files, and loads only its own skills directory, so a session starts with about 2.9k tokens of baseline context on the default tool set and a small context window still has room for real work.
16
+
11
17
  See:
12
18
 
13
19
  - [Runtime Specification](docs/runtime-specification.md)
20
+ - [Design Principles](docs/design-principles.md)
21
+
22
+ ## Requirements
23
+
24
+ - Node.js 22.19.0 or newer, which Pi requires.
25
+ - A running inference server: llama.cpp/`llama-server`, LM Studio, vLLM, SGLang, or any OpenAI-compatible endpoint. Localpi never starts, stops, or unloads a server it did not start itself.
26
+ - A terminal that Pi supports.
14
27
 
15
28
  ## Install
16
29
 
@@ -18,6 +31,12 @@ See:
18
31
  npm install -g localpi
19
32
  ```
20
33
 
34
+ Or the latest GitHub release directly:
35
+
36
+ ```bash
37
+ npm install -g https://github.com/osolmaz/localpi/releases/latest/download/localpi.tgz
38
+ ```
39
+
21
40
  During development:
22
41
 
23
42
  ```bash
@@ -30,6 +49,22 @@ After build:
30
49
  node dist/src/cli/main.js --status
31
50
  ```
32
51
 
52
+ ## Pi and pi-factory Versions
53
+
54
+ Localpi launches Pi through `npx -y @earendil-works/pi-coding-agent@latest`, so a normal launch
55
+ runs the newest Pi release. Point it somewhere else with `--pi-command`, or `LOCALPI_PI_CMD`, as a
56
+ program plus arguments:
57
+
58
+ ```bash
59
+ localpi --pi-command "node /opt/pi/bin/pi"
60
+ ```
61
+
62
+ Localpi builds its Pi launch on [pi-factory](https://github.com/osolmaz/pi-factory) and keeps that
63
+ dependency current. The `@earendil-works/pi-coding-agent` devDependency pins the Pi release that
64
+ localpi is written against, and `tests/generated-extension-types.test.ts` typechecks every
65
+ generated extension against those types, so Pi extension API drift fails `npm test` instead of
66
+ failing at launch.
67
+
33
68
  ## Runtime Model
34
69
 
35
70
  Target default:
@@ -66,14 +101,305 @@ Localpi launches Pi with:
66
101
 
67
102
  - default tools: `read,bash,edit,write,grep,find,ls`
68
103
  - a system prompt that explains local tool approval and local-model limits
69
- - an approval gate before every tool call
70
- - token speed and token count status while responses stream
104
+ - an approval gate before every tool call, which you can turn off for the session with `/approval`
105
+ - token speed, prefill progress, and context usage while responses stream
106
+ - a Catppuccin Mocha theme for the Pi session, written into `<state-dir>/pi-themes/`
107
+ - skills from `<state-dir>/pi-skills/` only, so shared skill directories stay out of the session
71
108
  - bounded Gemma/llama-server reasoning controlled by `--thinking`
72
- - an in-session `/thinking` command for changing Pi's active thinking level
109
+ - in-session `/thinking` (Pi's own command) and `/approval` (localpi's) for changing session settings
73
110
  - local state under `~/.local/state/localpi`
74
111
 
75
112
  The approval gate makes failed or denied tool calls explicit to the model so the model does not claim that a blocked command ran.
76
113
 
114
+ ## Skills
115
+
116
+ Pi discovers skills from shared directories such as `~/.agents/skills` and `~/.pi/agent/skills`. A
117
+ localpi session does not use those. It launches Pi with `--no-skills` and loads only
118
+ `<state-dir>/pi-skills/`, so a small local model does not carry skill lists it cannot use.
119
+
120
+ Put your own localpi skills there, one directory with a `SKILL.md` per skill:
121
+
122
+ ```bash
123
+ mkdir -p ~/.local/state/localpi/pi-skills/my-skill
124
+ $EDITOR ~/.local/state/localpi/pi-skills/my-skill/SKILL.md
125
+ ```
126
+
127
+ Pick the source with `--skills` or `LOCALPI_SKILLS`:
128
+
129
+ - `own` (default): `--no-skills` plus `<state-dir>/pi-skills/`
130
+ - `ambient`: Pi's normal discovery, including `~/.agents/skills` and project `.agents/skills`
131
+ - `off`: `--no-skills` and nothing else
132
+
133
+ An explicit `--skill <path>` still works in every mode, because Pi loads explicit paths even with
134
+ `--no-skills`.
135
+
136
+ ## Diffusion Canvas Visualizer
137
+
138
+ The live diffusion canvas visualizer (watching DiffusionGemma denoise its
139
+ answer in the TUI) lives in its own project now:
140
+ [diffusionpi](https://github.com/osolmaz/diffusionpi). It is a pi-factory app
141
+ bundle plus a standalone Pi widget package; the widget also installs into any
142
+ Pi session via `pi install`.
143
+
144
+ ## Tool Approval
145
+
146
+ Approval is on by default. Every tool call opens a dialog that shows the tool name and its input,
147
+ with three choices:
148
+
149
+ ```text
150
+ Allow once # run this call and keep asking for the next one
151
+ Allow all tools for this session # run every tool call in this session without asking
152
+ Deny and stop # block the call and end the turn
153
+ ```
154
+
155
+ A denied call does not run, the model is told that the call was blocked, and the turn stops instead
156
+ of letting the model try another way. Pressing escape in the dialog counts as a deny, so one key
157
+ ends the turn. Pi stops the turn when every blocked result in the current tool batch asks for it,
158
+ so a batch that also contains an allowed read-only call finishes that call first.
159
+
160
+ Read-only tools (`read`, `grep`, `find`, `ls`) run without a dialog, because they cannot change the
161
+ workspace. `bash` still asks, because a bash command can write. An unknown tool also asks, because
162
+ localpi cannot know what it does. Use `--approve-read-tools`, or `LOCALPI_APPROVE_READ_TOOLS=1`, to
163
+ put read-only tools behind the gate too.
164
+
165
+ The dialog choice lasts for this session only. Use the permission setting to change what new
166
+ sessions do:
167
+
168
+ ```text
169
+ /approval allow # do not ask in new sessions either
170
+ /approval ask # ask before each tool call (the default)
171
+ /approval off # alias for /approval allow
172
+ /approval on # alias for /approval ask
173
+ /approval # pick ask or allow from a list, with the current value marked
174
+ ```
175
+
176
+ The permission setting lives in `<state-dir>/settings.json` as `"permission": "ask" | "allow"`.
177
+ Startup reads, in order: `--no-approval` or `LOCALPI_APPROVAL=0`, the saved setting, then the
178
+ default `ask`. Use `--no-approval` to start one session without changing the setting.
179
+
180
+ While approval is off, Pi shows `permission: allow` in the status area, so the state stays visible.
181
+
182
+ In a non-interactive launch, approval stays on and no dialog is possible, so every tool call is
183
+ blocked. That keeps scripted runs from executing tools without a person watching.
184
+
185
+ ## ACP Mode
186
+
187
+ `--acp` and `LOCALPI_ACP=1` serve the Agent Client Protocol on stdio, so an ACP client such as an
188
+ editor can drive the same Pi session that a normal launch runs.
189
+
190
+ ```bash
191
+ localpi --acp --model gemma-e4b
192
+ LOCALPI_ACP=1 localpi --model gemma-e4b
193
+ ```
194
+
195
+ ACP mode:
196
+
197
+ - resolves the runtime and writes the same Pi configuration a normal launch writes, including
198
+ `<state-dir>/pi-config-runtime/models.json` and `settings.json`
199
+ - starts the pinned `pi-acp` adapter from `node_modules` as a child process with inherited stdio
200
+ - writes a launcher script to `<state-dir>/acp/pi-launcher.sh` and points `PI_ACP_PI_COMMAND` at it,
201
+ because the adapter starts Pi itself and passes only its own arguments. The script quotes the Pi
202
+ program and every argument, so a path with a space survives, and execs Pi with the full launch line
203
+ a normal launch uses, so the extensions, the system prompt, the theme, and the tool flags stay the
204
+ same
205
+ - passes the environment a normal launch uses, and sets `LOCALPI_ACP=0` for the child
206
+ - requires an explicit `--model` or `LOCALPI_MODEL`, because there is no terminal for the startup
207
+ model picker
208
+ - keeps stdout for protocol bytes only, and writes diagnostics and warnings to stderr
209
+ - refuses `--demo`, a forwarded Pi `--mode`, Pi session flags, and forwarded prompts, because the
210
+ adapter owns the session
211
+ - refuses `--status`, `--stop`, and `--list` together with `--acp` on the command line, and lets a
212
+ command-line immediate command win over an environment-set `LOCALPI_ACP=1`, as `LOCALPI_DEMO` does
213
+ - refuses to start localpi as the adapter's Pi command, so a child cannot re-enter ACP mode
214
+
215
+ Approval still works: the adapter forwards Pi's extension dialogs to the ACP client, so the client
216
+ asks before a tool call runs. The adapter does not pass `--no-extensions`, so Pi extension discovery
217
+ stays on.
218
+
219
+ Pi has no ACP mode of its own. ACP support comes from `pi-acp` (MIT), which `package.json` pins to
220
+ an exact version, and localpi never vendors its source. Set `LOCALPI_ACP_ADAPTER` to a path to run a
221
+ different adapter build.
222
+
223
+ ## Continue On Truncation
224
+
225
+ `--continue-on-truncation <n>` and `LOCALPI_CONTINUE_ON_TRUNCATION=<n>` continue a reply that the
226
+ model cut off at the declared output limit, so a run finishes its answer instead of ending with a
227
+ half-written one.
228
+
229
+ ```bash
230
+ localpi --model gemma-e4b --max-tokens 4096 --continue-on-truncation 2
231
+ LOCALPI_CONTINUE_ON_TRUNCATION=2 localpi --model gemma-e4b
232
+ ```
233
+
234
+ `n` is the maximum number of extra continuations. Pi reports a turn that reached the output cap as a
235
+ length stop, and localpi sends one follow-up message that asks the model to continue where it
236
+ stopped without repeating earlier text. The guard:
237
+
238
+ - is off by default. With no flag and no environment variable, a normal launch, an ACP launch, and
239
+ demo mode behave exactly as they do today
240
+ - takes the flag first, then `LOCALPI_CONTINUE_ON_TRUNCATION`, then off
241
+ - treats `LOCALPI_CONTINUE_ON_TRUNCATION=0` as off, so an inherited value can be disabled without
242
+ dropping the variable, and rejects any other value that is not a positive integer with exit code 2
243
+ - reacts only to the length stop, never to a normal stop, an error, an abort, or a tool call
244
+ - leaves a truncated turn that already asked for a tool alone, because Pi runs the tool and keeps
245
+ going on its own
246
+ - counts continuations per session, stops at the limit, and prints one line to stderr when the reply
247
+ was still cut off
248
+ - writes its notes to stderr only, so stdout stays free for a batch run
249
+ - changes no served limit. `--max-tokens` and `--context-window` keep their meaning, and the guard
250
+ only reacts to the stop reason Pi reports
251
+
252
+ The guard installs as the `continue-on-truncation.ts` Pi extension. It is not a default extension,
253
+ so a normal session writes it only when you ask for it. It works in ACP mode too, because both
254
+ launch paths share the same Pi configuration and extension bundle.
255
+
256
+ ## llama.cpp (Default Engine)
257
+
258
+ llama.cpp is Localpi's default local engine.
259
+
260
+ ```bash
261
+ localpi
262
+ ```
263
+
264
+ With the default `auto` runtime, Localpi probes a running llama.cpp server at `http://127.0.0.1:8080/v1` first. A loaded llama.cpp model wins automatic selection ahead of LM Studio, vLLM, and the managed `llama-server` fallback. Localpi reads llama.cpp `/v1/models` status: a model is usable when it is loaded or the server does not report status, and an unloaded model is offered only when `/props` reports `models_autoload`. Localpi never starts, stops, or unloads an external llama.cpp server.
265
+
266
+ Run explicitly against llama.cpp:
267
+
268
+ ```bash
269
+ localpi --runtime llama-cpp
270
+ localpi --runtime llama-cpp --base-url http://127.0.0.1:8080/v1 --model ternary-bonsai-2-27b-pq2_0
271
+ ```
272
+
273
+ Point at a llama.cpp server on another port with a provider registry entry:
274
+
275
+ ```json
276
+ {
277
+ "providers": {
278
+ "llama-cpp": {
279
+ "type": "llama-cpp",
280
+ "baseUrl": "http://127.0.0.1:9931/v1",
281
+ "discover": true
282
+ }
283
+ }
284
+ }
285
+ ```
286
+
287
+ ### Image input
288
+
289
+ When a llama.cpp model takes images, Localpi says so in the Pi model config, so the `read` tool and `@file` attachments can send a picture to that model. Localpi reads the fact from the server: an entry in `/v1/models` that lists `image` in `architecture.input_modalities` gets `input: ["text", "image"]`. Every other model stays text-only. No model name is used to guess this.
290
+
291
+ An engine that reports nothing can be described with a model profile:
292
+
293
+ ```json
294
+ {
295
+ "id": "qwen3-vl-8b",
296
+ "model": "qwen3-vl-8b",
297
+ "capabilities": { "image": true }
298
+ }
299
+ ```
300
+
301
+ The profile wins over the server in both directions, so `"image": false` also turns image input off for a model that reports it.
302
+
303
+ The server must load a multimodal projector for the model, which a llama.cpp router does on its own from the model directory. Check it with one real image request before you rely on it.
304
+
305
+ ## Status Display
306
+
307
+ The status display answers one question: is this machine keeping up? It shows elapsed time, output
308
+ tokens, token rate, and context use. Pick a mode with `--stats`:
309
+
310
+ | Mode | Status line | Live line | Transcript entry |
311
+ | ---------------- | ----------- | --------- | ---------------- |
312
+ | `off` | Pi's own | no | no |
313
+ | `line` | localpi | yes | no |
314
+ | `full` (default) | localpi | yes | yes |
315
+
316
+ The status line is one row. Localpi replaces Pi's two-row footer with one line that carries the same
317
+ facts plus the engine label next to the model:
318
+
319
+ ```text
320
+ ~/repos/localpi (main) · ↑3.2k ↓100 R3.2k CH99.5% · 43.8 tok/s · 9.8%/33k (llama.cpp) ternary-bonsai-2-27b-pq2_0
321
+ ```
322
+
323
+ The line holds, from left to right:
324
+
325
+ - the working directory and the git branch,
326
+ - the token totals, the cache read, and the cache hit rate of the session,
327
+ - the token rate of the last finished turn,
328
+ - the context use as a percentage of the window,
329
+ - the engine that serves the model, then the model itself.
330
+
331
+ The engine label comes from the provider localpi built the catalog with, so it is read, not guessed. A
332
+ model from a provider with no known engine shows no label. When the model reports reasoning, the line
333
+ also shows the thinking level, the same way Pi does.
334
+
335
+ One number never appears twice at the same time. While the model runs, the live line owns the token
336
+ rate and the context use, and the status line shows the rest. When the session is idle, the rate of
337
+ the last finished turn and the context return to the status line, so the speed stays visible after
338
+ the answer ends. The status line drops the rate first when the row is too narrow.
339
+
340
+ The live line replaces Pi's plain `Working` text while the model runs:
341
+
342
+ ```text
343
+ Working (1.8s · 100 out · 55.6 tok/s · ctx 34k/131k (26%))
344
+ ```
345
+
346
+ During prefill, the same line reports progress through the prompt:
347
+
348
+ ```text
349
+ Working (prefill 25% · 5k/20k tok · 3.2s · ctx 20k/33k (61%))
350
+ ```
351
+
352
+ Prefill progress needs a llama.cpp server, because it reads the server's `/slots` endpoint. Localpi
353
+ polls that endpoint only for llama.cpp runtimes. When the endpoint is missing or slow, localpi stops
354
+ polling and shows elapsed prefill time instead.
355
+
356
+ In `full` mode, each finished turn also adds one dim transcript line:
357
+
358
+ ```text
359
+ 10s · 438 out · 43.8 tok/s · 8.4k in · prefill 0.4s · ctx 34k/131k (26%)
360
+ ```
361
+
362
+ Context colors use the active theme: normal below 80 percent, warning from 80 percent, error from
363
+ 95 percent. When Pi reports no token counts, the live line falls back to the context percentage
364
+ alone.
365
+
366
+ Localpi shows the engine label in `line` and `full` mode. In `off` mode localpi leaves the footer and
367
+ the working line to Pi.
368
+
369
+ Change the mode during a session with `/stats`:
370
+
371
+ ```text
372
+ /stats # pick a mode from a list
373
+ /stats line # or pass the mode directly
374
+ ```
375
+
376
+ `/stats` saves the choice to `<state-dir>/settings.json`, so the next launch keeps it. Pass
377
+ `--stats <mode>` or set `LOCALPI_STATS` to override the saved value for one launch.
378
+
379
+ ## Catppuccin Theme
380
+
381
+ Localpi gives each Pi session the Catppuccin Mocha palette. It writes its own copy of the theme to
382
+ `<state-dir>/pi-themes/catppuccin-mocha.json`, loads it into Pi, and selects it for the session. The
383
+ theme covers the full Pi TUI: messages, tool cards, diffs, syntax highlighting, and the localpi
384
+ status display.
385
+
386
+ Localpi writes its own copy for a good reason. A localpi session uses a Pi config directory inside
387
+ the localpi state directory, so Pi does not load the themes from your global Pi setup.
388
+
389
+ The theme belongs to the session only. Localpi never edits your global Pi themes or settings.
390
+
391
+ Localpi's own output uses the same palette: labels are `overlay1`, warnings are `peach`, and errors
392
+ are `red`. Colors are truecolor and appear only on a terminal. Piped output stays plain, and
393
+ `NO_COLOR` turns color off.
394
+
395
+ Escape hatches:
396
+
397
+ - `localpi --no-themes` starts the session with no theme at all.
398
+ - `localpi --use-theme onur-dark` selects your own theme instead. The Catppuccin file is still
399
+ loaded, so `/settings` can offer it.
400
+ - `localpi --theme <path>` adds another theme file, as Pi does in a normal session.
401
+ - `FORCE_COLOR=1` forces color in localpi's own output; `NO_COLOR=1` removes it.
402
+
77
403
  ## LM Studio Alternative
78
404
 
79
405
  LM Studio exposes an OpenAI-compatible endpoint, usually:
@@ -115,7 +441,7 @@ Run an endless TUI demo:
115
441
  localpi --demo --model gemma-e4b
116
442
  ```
117
443
 
118
- Demo mode requires an explicit model, opens the normal Pi TUI, and keeps one live Pi session so followup prompts continue from the first prompt while Pi owns streaming, tok/s status, slash commands, and exit behavior.
444
+ Demo mode requires an explicit model, opens the normal Pi TUI, and keeps one live Pi session so followup prompts continue from the first prompt while Pi owns streaming, tok/s status, slash commands, and exit behavior. Under the hood it loads the shared [pi-demo-mode](https://github.com/osolmaz/pi-demo-mode) extension, configured through the `--demo-*` flags below.
119
445
 
120
446
  Override the demo prompts:
121
447
 
@@ -135,7 +461,9 @@ Use a bounded reasoning budget with managed `llama-server`:
135
461
  localpi --model gemma-12b --thinking low -p "classify this item"
136
462
  ```
137
463
 
138
- In an interactive session, use `/thinking` to pick a level or `/thinking high` to set one directly. This changes Pi's active thinking level for later turns and saves it for the next localpi launch. For managed `llama-server`, the server-side reasoning budget is still chosen at startup because changing it requires restarting the local server process.
464
+ In an interactive session, use Pi's own `/thinking` command to pick a level or to set one directly. This changes Pi's active thinking level for later turns. Localpi remembers the level Pi selected and starts the next localpi launch from it. For managed `llama-server`, the server-side reasoning budget is still chosen at startup because changing it requires restarting the local server process.
465
+
466
+ Localpi does not register its own `/thinking` command, because Pi already owns that name.
139
467
 
140
468
  For managed `llama-server`, thinking levels map to server-side reasoning:
141
469
 
@@ -174,13 +502,26 @@ Stop the managed `llama-server` runtime:
174
502
  localpi --stop
175
503
  ```
176
504
 
505
+ ## Demo Grid and Recording
506
+
507
+ The `localpi grid` and `localpi record` subcommands moved to
508
+ [demowall](https://github.com/osolmaz/demowall), a standalone tool that runs
509
+ N copies of any command in a tiled tmux wall and records tmux sessions to
510
+ video. A wall of localpi demo sessions is:
511
+
512
+ ```bash
513
+ demowall grid --concurrency 4 --start -- localpi --demo --model gemma-e4b
514
+ demowall record --session demowall-<timestamp> --out demo.mp4 --seconds 60
515
+ ```
516
+
177
517
  ## Options
178
518
 
179
- - `--runtime <auto|llama-server|lmstudio|vllm|openai-compatible>`: runtime backend. Default: `auto`
519
+ - `--runtime <auto|llama-server|llama-cpp|lmstudio|vllm|openai-compatible>`: runtime backend. Default: `auto`, which prefers llama.cpp
180
520
  - `--provider <id>`: catalog provider id to use, for example `lmstudio` or `vllm`
181
521
  - `--model <alias|id|path|auto>`: model alias, model id, or GGUF path
182
522
  - `--ctx <n>` / `--context-window <n>`: model context window
183
523
  - `--max-tokens <n>`: generated model max output tokens
524
+ - `--continue-on-truncation <n>`: continue a reply cut off by the output limit, up to `n` times. Off by default, and `LOCALPI_CONTINUE_ON_TRUNCATION=<n>` sets the same limit
184
525
  - `--base-url <url>`: OpenAI-compatible endpoint for LM Studio or custom endpoints
185
526
  - `--server-command <path>`: `llama-server` executable path
186
527
  - `--llama-server <path>`: alias for `--server-command`
@@ -191,24 +532,50 @@ localpi --stop
191
532
  - `--chat-template <path>`: optional llama.cpp chat template file
192
533
  - `--state-dir <path>`: runtime state directory. Default: `~/.local/state/localpi`
193
534
  - `--session-dir <path>`: Pi session directory. Default: `<state-dir>/sessions`
194
- - `--pi-command <command>`: Pi launch command
535
+ - `--pi-command <command>`: Pi launch command as a program and its arguments. Quotes group words, and the command runs without a shell. Default: `npx -y @earendil-works/pi-coding-agent@latest`, so a normal launch runs the newest Pi release
195
536
  - `--providers-file <path>`: provider registry JSON
196
537
  - `--model-profile <path>`: local model capability profile JSON
197
538
  - `--model-reasoning <bool>`: override generated Pi reasoning capability
198
539
  - `--model-thinking-format <deepseek|qwen-chat-template>`: override generated Pi thinking format
199
540
  - `--tools <list>`: Pi tools allow list. Default: `read,bash,edit,write,grep,find,ls`
200
541
  - `--thinking <off|minimal|low|medium|high|xhigh>`: Pi thinking level and managed `llama-server` reasoning budget. Default: last saved level, then `medium`
542
+ - `--thinking-budget <n>`: managed `llama-server` thinking cap in tokens. `-1` leaves thinking unrestricted, and a positive value replaces the budget of the thinking level
543
+ - `--thinking-budget-message <text>`: text the server injects before the end-of-thinking tag when the budget runs out. An empty value passes no message. Default: `Reasoning budget reached. Stop thinking and answer now.`
201
544
  - `--demo`: endlessly run Pi prompts inside the normal Pi TUI until interrupted or Pi exits; requires an explicit non-`auto` model
202
545
  - `--demo-initial-prompt <text>`: first demo prompt
203
546
  - `--demo-followup-prompt <text>`: repeated demo prompt after the first run
204
547
  - `--demo-initial-prompt-file <path>`: UTF-8 file for the first demo prompt
205
548
  - `--demo-followup-prompt-file <path>`: UTF-8 file for repeated demo prompts
206
- - `--no-approval`: disable the tool approval gate
207
- - `--no-token-status`: disable the token status extension
549
+ - `--acp`: serve the Agent Client Protocol on stdio through the pinned `pi-acp` adapter; requires an explicit non-`auto` model
550
+ - `--no-approval`: start with the tool approval gate off for the session
551
+ - `--approve-read-tools`: also ask before read-only tools (`read`, `grep`, `find`, `ls`)
552
+ - `--stats <off|line|full>`: status detail level. Default: `full`, or the last saved `/stats` choice
553
+ - `--skills <own|ambient|off>`: skill sources. Default: `own`, which loads only `<state-dir>/pi-skills/` and turns off shared discovery such as `~/.agents/skills`. `ambient` keeps Pi's own discovery, and `off` loads no skills
554
+ - `--no-skills`: load no skills. Alias for `--skills off`
555
+ - `--no-token-status`: disable the token status extension. Alias for `--stats off`
208
556
  - `--status`: print runtime, model, and Pi config status
209
557
  - `--stop`: stop the managed `llama-server` process
210
558
  - `--list`: list configured model aliases
211
559
 
560
+ ## Pi Helper Tools
561
+
562
+ Pi manages two helper binaries, `fd` and `rg`, and downloads them into its own bin directory when
563
+ they are missing from `PATH`. Localpi starts Pi in offline mode, so Pi skips that download. A start
564
+ that shows
565
+
566
+ ```
567
+ Warning: fd not found. Offline mode enabled, skipping download.
568
+ ```
569
+
570
+ means `fd` is not installed, and Pi falls back to a slower file search. Fix it in either way:
571
+
572
+ - Install `fd`, for example with `brew install fd`. Pi uses the `fd` that is in `PATH`.
573
+ - Run `PI_OFFLINE=0 localpi` once, and let Pi download `fd` into its own bin directory.
574
+
575
+ Localpi keeps offline mode on by default, because a local-model session should not make network
576
+ calls without a reason. `PI_OFFLINE` is pi-factory's default and passes straight through, so an
577
+ explicit `PI_OFFLINE=0` or `PI_OFFLINE=1` always wins.
578
+
212
579
  ## Environment
213
580
 
214
581
  - `LOCALPI_RUNTIME`
@@ -224,6 +591,7 @@ localpi --stop
224
591
  - `LOCALPI_PI_CMD`
225
592
  - `LOCALPI_CONTEXT_WINDOW`
226
593
  - `LOCALPI_MAX_TOKENS`
594
+ - `LOCALPI_CONTINUE_ON_TRUNCATION`
227
595
  - `LOCALPI_LLAMA_SERVER`
228
596
  - `LOCALPI_HOST`
229
597
  - `LOCALPI_PORT`
@@ -232,6 +600,13 @@ localpi --stop
232
600
  - `LOCALPI_CHAT_TEMPLATE`
233
601
  - `LOCALPI_TOOLS`
234
602
  - `LOCALPI_THINKING`
603
+ - `LOCALPI_THINKING_BUDGET`
604
+ - `LOCALPI_THINKING_BUDGET_MESSAGE`
605
+ - `LOCALPI_STATS`
606
+ - `LOCALPI_SKILLS`
607
+ - `LOCALPI_APPROVE_READ_TOOLS`
608
+ - `LOCALPI_ACP`
609
+ - `LOCALPI_ACP_ADAPTER`
235
610
  - `LOCALPI_DEMO`
236
611
  - `LOCALPI_DEMO_INITIAL_PROMPT`
237
612
  - `LOCALPI_DEMO_FOLLOWUP_PROMPT`
@@ -304,3 +679,6 @@ npm test
304
679
  npm run build
305
680
  npm run check
306
681
  ```
682
+
683
+ `npm run check` is the default gate. It does not run mutation testing, because the mutation run takes
684
+ minutes. Run `npm run mutate` by hand once in a while; the `mutation` workflow runs it weekly.
@@ -1,10 +1,15 @@
1
- import { runPiApp } from "@dutifuldev/pi-factory";
1
+ import { runPiApp } from "@osolmaz/pi-factory";
2
+ import { paint } from "../localpi/catppuccin.js";
2
3
  import { errorMessage, fail, ok } from "../common/result.js";
4
+ import { runAcpApp } from "../localpi/acp.js";
3
5
  import { parseLocalpiArgs, usage } from "../localpi/options.js";
6
+ import { engineEntries, providerConfigs } from "../localpi/provider-registry.js";
4
7
  import { aliasListOutput, connectionStatus, resolveRuntime, statusOutput, stopRuntime } from "../localpi/runtime.js";
5
8
  import { applyRememberedSettings } from "../localpi/settings-state.js";
6
9
  import { createLocalpiAppDefinition } from "../pi/app.js";
10
+ import { writeLocalpiTheme } from "../pi/theme.js";
7
11
  import { writeDefaultExtensions } from "../pi/extensions.js";
12
+ import { ensureLocalpiSkillsDir } from "../pi/skills.js";
8
13
  export async function run(args) {
9
14
  try {
10
15
  let options = parseLocalpiArgs(args);
@@ -12,22 +17,36 @@ export async function run(args) {
12
17
  if (helpResult !== undefined) {
13
18
  return helpResult;
14
19
  }
20
+ validateAcpOptions(options);
15
21
  validateDemoOptions(options);
16
22
  const commandResult = await immediateCommandResult(options);
17
23
  if (commandResult !== undefined) {
18
24
  return commandResult;
19
25
  }
20
26
  options = await applyRememberedSettings(options, {
21
- thinking: hasExplicitThinkingOverride(args)
27
+ thinking: hasExplicitThinkingOverride(args),
28
+ stats: hasExplicitStatsOverride(args),
29
+ permission: hasExplicitPermissionOverride(args)
22
30
  });
23
31
  const connection = await resolveRuntime(options);
24
32
  const selectorOptions = startupModelSelectorOptions(options, connection);
25
- const extensions = await writeDefaultExtensions(options, selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions });
26
- const app = createLocalpiAppDefinition(options, connection, extensions);
27
- return await launchResolvedRuntime(app, connection);
33
+ const extensions = await writeDefaultExtensions(options, {
34
+ ...(selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions }),
35
+ runtime: {
36
+ providerId: connection.providerId,
37
+ baseUrl: connection.baseUrl,
38
+ model: connection.model
39
+ },
40
+ engines: engineEntries(await providerConfigs(options))
41
+ });
42
+ await ensureLocalpiSkillsDir(options);
43
+ const app = createLocalpiAppDefinition(options, connection, extensions, await writeLocalpiTheme(options.stateDir, options.forwardedArgs));
44
+ return options.acp
45
+ ? await launchAcpRuntime(app, connection)
46
+ : await launchResolvedRuntime(app, connection);
28
47
  }
29
48
  catch (error) {
30
- return fail(`localpi: ${errorMessage(error)}`);
49
+ return fail(`${paint("localpi:", "red")} ${errorMessage(error)}`);
31
50
  }
32
51
  }
33
52
  function validateDemoOptions(options) {
@@ -74,6 +93,52 @@ function validateDemoTty() {
74
93
  throw new Error("--demo requires an interactive TTY on stdin and stdout; run it directly in a terminal");
75
94
  }
76
95
  }
96
+ function validateAcpOptions(options) {
97
+ if (!options.acp) {
98
+ return;
99
+ }
100
+ // A command-line immediate command wins over an environment-set LOCALPI_ACP, as it does for demo
101
+ // mode, so an exported mode switch does not take the status, stop, and list commands away.
102
+ if (!options.acpFromCli && hasImmediateCommand(options)) {
103
+ return;
104
+ }
105
+ if (options.demo) {
106
+ throw new Error("--acp cannot be used with --demo; ACP mode owns the terminal");
107
+ }
108
+ validateAcpImmediateOptions(options);
109
+ validateAcpModel(options);
110
+ validateForwardedAcpOptions(options.forwardedArgs);
111
+ }
112
+ function validateAcpImmediateOptions(options) {
113
+ if (options.status) {
114
+ throw new Error("--acp cannot be used with --status");
115
+ }
116
+ if (options.stop) {
117
+ throw new Error("--acp cannot be used with --stop");
118
+ }
119
+ if (options.list) {
120
+ throw new Error("--acp cannot be used with --list");
121
+ }
122
+ }
123
+ function validateAcpModel(options) {
124
+ if (options.model === undefined || options.model === "auto") {
125
+ throw new Error("--acp requires an explicit --model <alias|id|path> or LOCALPI_MODEL value; ACP mode has no terminal for model selection");
126
+ }
127
+ }
128
+ function validateForwardedAcpOptions(args) {
129
+ const incompatibleMode = forwardedIncompatibleMode(args);
130
+ if (incompatibleMode !== undefined) {
131
+ throw new Error(`--acp cannot be used with forwarded Pi mode ${incompatibleMode}; the ACP adapter starts Pi in rpc mode`);
132
+ }
133
+ const sessionFlag = forwardedSessionFlag(args);
134
+ if (sessionFlag !== undefined) {
135
+ throw new Error(`--acp cannot be used with forwarded Pi session flag ${sessionFlag}; the ACP client manages sessions`);
136
+ }
137
+ const promptInput = forwardedPromptInput(args);
138
+ if (promptInput !== undefined) {
139
+ throw new Error(`--acp cannot be used with forwarded Pi prompt input ${promptInput}; ACP clients send prompts over the protocol`);
140
+ }
141
+ }
77
142
  function forwardedIncompatibleMode(args) {
78
143
  for (let index = 0; index < args.length; index += 1) {
79
144
  const arg = args[index];
@@ -244,6 +309,16 @@ async function launchResolvedRuntime(app, connection) {
244
309
  }
245
310
  return ok(connection.warnings.length === 0 ? "" : connectionStatus(connection));
246
311
  }
312
+ /**
313
+ * ACP mode keeps stdout for the protocol only, so localpi reports its own diagnostics on stderr and
314
+ * returns the adapter's exit code unchanged.
315
+ */
316
+ async function launchAcpRuntime(app, connection) {
317
+ const code = await runAcpApp(app, {
318
+ diagnostics: connection.warnings.length === 0 ? [] : [connectionStatus(connection)]
319
+ });
320
+ return { code, stdout: "", stderr: "" };
321
+ }
247
322
  async function immediateCommandResult(options) {
248
323
  if (options.list) {
249
324
  return ok(`${await aliasListOutput()}\n`);
@@ -258,6 +333,37 @@ function helpCommandResult(options) {
258
333
  ? ok(usage())
259
334
  : undefined;
260
335
  }
336
+ function hasExplicitStatsOverride(args) {
337
+ if (process.env["LOCALPI_STATS"] !== undefined) {
338
+ return true;
339
+ }
340
+ if (process.env["LOCALPI_TOKEN_STATUS"] !== undefined) {
341
+ return true;
342
+ }
343
+ for (const arg of args) {
344
+ if (arg === "--") {
345
+ return false;
346
+ }
347
+ if (arg === "--stats" || arg === "--no-token-status") {
348
+ return true;
349
+ }
350
+ }
351
+ return false;
352
+ }
353
+ function hasExplicitPermissionOverride(args) {
354
+ if (process.env["LOCALPI_APPROVAL"] !== undefined) {
355
+ return true;
356
+ }
357
+ for (const arg of args) {
358
+ if (arg === "--") {
359
+ return false;
360
+ }
361
+ if (arg === "--no-approval") {
362
+ return true;
363
+ }
364
+ }
365
+ return false;
366
+ }
261
367
  function hasExplicitThinkingOverride(args) {
262
368
  if (process.env["LOCALPI_THINKING"] !== undefined) {
263
369
  return true;
@@ -273,6 +379,9 @@ function hasExplicitThinkingOverride(args) {
273
379
  return false;
274
380
  }
275
381
  function startupModelSelectorOptions(options, connection) {
382
+ if (options.acp) {
383
+ return undefined;
384
+ }
276
385
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
277
386
  return undefined;
278
387
  }
File without changes