localpi 0.5.2 → 0.6.1

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 (31) hide show
  1. package/README.md +377 -10
  2. package/dist/src/cli/cli.js +112 -5
  3. package/dist/src/llm/openai.js +65 -4
  4. package/dist/src/localpi/acp.js +118 -0
  5. package/dist/src/localpi/catalog.js +79 -7
  6. package/dist/src/localpi/catppuccin.js +64 -0
  7. package/dist/src/localpi/llama-server.js +72 -39
  8. package/dist/src/localpi/model-profile.js +4 -0
  9. package/dist/src/localpi/options.js +129 -9
  10. package/dist/src/localpi/provider-registry.js +51 -3
  11. package/dist/src/localpi/runtime-connection.js +11 -8
  12. package/dist/src/localpi/runtime.js +12 -7
  13. package/dist/src/localpi/settings-state.js +13 -3
  14. package/dist/src/pi/app.js +11 -5
  15. package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
  16. package/dist/src/pi/extension-sources/settings-file.js +31 -0
  17. package/dist/src/pi/extension-sources/startup-model-selector.js +18 -23
  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 +544 -119
  21. package/dist/src/pi/extension-sources/tool-approval.js +155 -14
  22. package/dist/src/pi/extensions.js +37 -10
  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 +4 -2
@@ -1,45 +1,186 @@
1
- export function approvalExtensionSource() {
2
- return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { settingsFileSource } from "./settings-file.js";
2
+ export function approvalExtensionSource(config) {
3
+ const initialEnabledSource = JSON.stringify(config.enabled);
4
+ const approveReadToolsSource = JSON.stringify(config.approveReadTools);
5
+ return `import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ type PermissionMode = "ask" | "allow";
10
+
11
+ type CommandContext = {
12
+ readonly hasUI: boolean;
13
+ readonly ui: {
14
+ setStatus(key: string, text: string | undefined): void;
15
+ notify(message: string, type?: "info" | "warning" | "error"): void;
16
+ select(title: string, options: string[]): Promise<string | undefined>;
17
+ };
18
+ };
19
+
20
+ const approvalRule =
21
+ "\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran.";
22
+ const modes: readonly PermissionMode[] = ["ask", "allow"];
23
+ const aliases: readonly string[] = ["on", "off"];
24
+ // Read-only Pi tools cannot change the workspace, so the gate lets them run. bash is not in this
25
+ // list, because a bash command can write. An unknown tool also stays behind the gate.
26
+ const readOnlyTools: readonly string[] = ["read", "grep", "find", "ls"];
27
+ const statusKey = "localpi-approval";
28
+ const allowOnce = "Allow once";
29
+ const allowSession = "Allow all tools for this session";
30
+ const deny = "Deny and stop";
31
+ const blockedReason = "Tool call was blocked by the user and did not run.";
32
+ const noUiReason = " was blocked and did not run because interactive approval is required.";
33
+ // A deny stops the turn instead of letting the model try again. Pi terminates the turn when every
34
+ // blocked result in the tool batch asks for it, and a cancelled dialog counts as a deny.
35
+ // The permission setting is the launch default for new sessions. The /approval command writes it;
36
+ // the session-only choice in the tool call dialog never writes it.
37
+ ${settingsFileSource(config.settingsPath)}
38
+ const initialEnabled: boolean = ${initialEnabledSource};
39
+ const gateReadTools: boolean = ${approveReadToolsSource};
3
40
 
4
41
  export default function localpiToolApproval(pi: ExtensionAPI): void {
42
+ let enabled = initialEnabled;
43
+
5
44
  pi.on("before_agent_start", (event) => ({
6
- systemPrompt:
7
- event.systemPrompt +
8
- "\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran."
45
+ systemPrompt: event.systemPrompt + approvalRule
9
46
  }));
10
47
 
48
+ pi.on("session_start", (_event, ctx) => {
49
+ showStatus(ctx);
50
+ });
51
+
52
+ pi.registerCommand("approval", {
53
+ description: "Choose whether tool calls ask for approval",
54
+ getArgumentCompletions: (prefix: string) => {
55
+ const trimmed = prefix.trim().toLowerCase();
56
+ const names = [...modes, ...aliases];
57
+ const matches = names.filter((name) => name.startsWith(trimmed));
58
+ return matches.length === 0 ? null : matches.map((name) => ({ value: name, label: name }));
59
+ },
60
+ handler: async (args: string, ctx: CommandContext) => {
61
+ const requested = parseMode(args);
62
+ if (requested !== undefined) {
63
+ await apply(requested, ctx, true);
64
+ return;
65
+ }
66
+ if (!ctx.hasUI) {
67
+ ctx.ui.notify(
68
+ "permission: " + label(enabled) + "; run localpi in a terminal to change it",
69
+ "info"
70
+ );
71
+ return;
72
+ }
73
+ const selected = await promptMode(enabled, ctx);
74
+ if (selected === undefined) {
75
+ return;
76
+ }
77
+ await apply(selected, ctx, true);
78
+ }
79
+ });
80
+
11
81
  pi.on("tool_call", async (event, ctx) => {
12
- const input = formatInput(event.input);
82
+ if (!enabled) {
83
+ return undefined;
84
+ }
85
+
86
+ if (!gateReadTools && readOnlyTools.includes(event.toolName)) {
87
+ return undefined;
88
+ }
13
89
 
14
90
  if (!ctx.hasUI) {
15
91
  return {
16
92
  block: true,
17
- reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\`
93
+ reason: 'Tool call "' + event.toolName + '"' + noUiReason,
94
+ terminate: true
18
95
  };
19
96
  }
20
97
 
21
- const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input);
22
- if (!ok) {
23
- return { block: true, reason: "Tool call was blocked by the user and did not run." };
98
+ const choice = await ctx.ui.select(
99
+ "Allow tool call: " + event.toolName + "?\\n" + previewInput(event.input),
100
+ [allowOnce, allowSession, deny]
101
+ );
102
+
103
+ if (choice === allowSession) {
104
+ await apply("allow", ctx, false);
105
+ return undefined;
106
+ }
107
+
108
+ if (choice !== allowOnce) {
109
+ return { block: true, reason: blockedReason, terminate: true };
24
110
  }
25
111
 
26
112
  return undefined;
27
113
  });
114
+
115
+ function showStatus(ctx: CommandContext): void {
116
+ if (ctx.hasUI) {
117
+ ctx.ui.setStatus(statusKey, enabled ? undefined : "permission: allow");
118
+ }
119
+ }
120
+
121
+ async function apply(mode: PermissionMode, ctx: CommandContext, save: boolean): Promise<void> {
122
+ enabled = mode === "ask";
123
+ showStatus(ctx);
124
+ ctx.ui.notify(notification(mode, save), enabled ? "info" : "warning");
125
+ if (save) {
126
+ await persist(mode);
127
+ }
128
+ }
129
+ }
130
+
131
+ function notification(mode: PermissionMode, save: boolean): string {
132
+ const scope = save ? ", saved for new sessions" : " for this session";
133
+ return mode === "ask"
134
+ ? "permission: ask" + scope + "; tool calls ask before they run"
135
+ : "permission: allow" + scope + "; tool calls run without asking";
136
+ }
137
+
138
+ async function persist(mode: PermissionMode): Promise<void> {
139
+ const settings = await readSettings();
140
+ settings["permission"] = mode;
141
+ await writeSettings(settings);
142
+ }
143
+
144
+ function label(enabled: boolean): PermissionMode {
145
+ return enabled ? "ask" : "allow";
146
+ }
147
+
148
+ function parseMode(value: string): PermissionMode | undefined {
149
+ const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
150
+ if (normalized === "on") {
151
+ return "ask";
152
+ }
153
+ if (normalized === "off") {
154
+ return "allow";
155
+ }
156
+ return modes.find((mode) => mode === normalized);
157
+ }
158
+
159
+ async function promptMode(
160
+ current: boolean,
161
+ ctx: CommandContext
162
+ ): Promise<PermissionMode | undefined> {
163
+ const currentMode = label(current);
164
+ const selected = await ctx.ui.select(
165
+ "Tool approval",
166
+ modes.map((mode) => (mode === currentMode ? mode + " (current)" : mode))
167
+ );
168
+ return selected === undefined ? undefined : parseMode(selected);
28
169
  }
29
170
 
30
- function formatInput(input: unknown): string {
171
+ function previewInput(input: unknown): string {
31
172
  let text: string;
32
173
  try {
33
- text = JSON.stringify(input, null, 2);
174
+ text = JSON.stringify(input, null, 2) ?? String(input);
34
175
  } catch {
35
176
  text = String(input);
36
177
  }
37
178
 
38
- const maxLength = 4000;
179
+ const maxLength = 1200;
39
180
  if (text.length <= maxLength) {
40
181
  return text;
41
182
  }
42
- return \`\${text.slice(0, maxLength)}\\n... truncated ...\`;
183
+ return text.slice(0, maxLength) + "\\n... truncated ...";
43
184
  }
44
185
  `;
45
186
  }
@@ -3,7 +3,9 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { localpiSettingsPath } from "../localpi/settings-state.js";
5
5
  import { resolveDemoPrompts } from "./demo.js";
6
+ import { continueOnTruncationExtensionSource } from "./extension-sources/continue-on-truncation.js";
6
7
  import { startupModelSelectorExtensionSource } from "./extension-sources/startup-model-selector.js";
8
+ import { statusLineExtensionSource } from "./extension-sources/status-line.js";
7
9
  import { thinkingControlExtensionSource } from "./extension-sources/thinking-control.js";
8
10
  import { tokenStatusExtensionSource } from "./extension-sources/token-status.js";
9
11
  import { approvalExtensionSource } from "./extension-sources/tool-approval.js";
@@ -26,16 +28,22 @@ export async function writeDefaultExtensions(options, extensionOptions = {}) {
26
28
  };
27
29
  }
28
30
  paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(localpiSettingsPath(options))));
29
- if (options.approval) {
30
- paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource()));
31
+ paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource({
32
+ enabled: options.approval,
33
+ settingsPath: localpiSettingsPath(options),
34
+ approveReadTools: options.approveReadTools
35
+ })));
36
+ if (options.continueOnTruncation > 0) {
37
+ paths.push(await writeExtension(extensionDir, "continue-on-truncation.ts", continueOnTruncationExtensionSource(options.continueOnTruncation)));
31
38
  }
32
- if (options.tokenStatus) {
33
- paths.push(await writeExtension(extensionDir, "token-status.ts", tokenStatusExtensionSource({ includeContext: !options.demo })));
39
+ if (options.stats !== "off") {
40
+ paths.push(await writeExtension(extensionDir, "token-status.ts", tokenStatusExtensionSource(tokenStatusConfig(options, extensionOptions.runtime))));
41
+ paths.push(await writeExtension(extensionDir, "status-line.ts", statusLineExtensionSource({ engines: extensionOptions.engines ?? [] })));
34
42
  }
35
43
  return {
36
44
  paths,
37
45
  env,
38
- systemPrompt: localpiSystemPrompt(options.approval)
46
+ systemPrompt: localpiSystemPrompt()
39
47
  };
40
48
  }
41
49
  // Demo mode is the shared pi-demo-mode package (a git dependency), loaded
@@ -50,13 +58,32 @@ async function writeExtension(extensionDir, name, source) {
50
58
  await writeFile(extensionPath, source, "utf8");
51
59
  return extensionPath;
52
60
  }
53
- function localpiSystemPrompt(approval) {
61
+ function tokenStatusConfig(options, runtime) {
62
+ return {
63
+ settingsPath: localpiSettingsPath(options),
64
+ mode: options.stats,
65
+ ...runtimeConfig(runtime)
66
+ };
67
+ }
68
+ // The managed llama-server and the built-in llama.cpp provider are both llama.cpp, and both
69
+ // expose live prefill progress on /slots. Other engines fall back to elapsed-time prefill display.
70
+ function runtimeConfig(runtime) {
71
+ if (runtime === undefined) {
72
+ return {};
73
+ }
74
+ const llamaCpp = runtime.providerId === "llama-cpp" || runtime.providerId === "llama-server";
75
+ return {
76
+ ...(llamaCpp ? { engine: "llama-cpp" } : {}),
77
+ baseUrl: runtime.baseUrl,
78
+ modelId: runtime.model
79
+ };
80
+ }
81
+ // The tool approval gate appends its own detailed rule. This base prompt keeps the same warning
82
+ // when a user disables localpi extensions, and stays true whether approval is on or off.
83
+ function localpiSystemPrompt() {
54
84
  return [
55
85
  "You are running through localpi, a local Pi launcher for local models.",
56
- approval
57
- ? "Tool calls require user approval. If a tool result says it was blocked, denied, or requires approval, the tool did not run."
58
- : "Tool approval is disabled for this session.",
59
- "Do not claim that a blocked tool call ran.",
86
+ "Tool calls may require user approval. Never claim that a tool call ran when its result says it was blocked or denied.",
60
87
  "Prefer answering directly when tools are not needed."
61
88
  ].join("\n");
62
89
  }
@@ -0,0 +1,24 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ // Pi loads skills from shared directories such as ~/.agents/skills. A localpi session keeps its own
4
+ // skills instead, so a small local model does not carry skill lists it cannot use.
5
+ export function localpiSkillsDir(stateDir) {
6
+ return path.join(stateDir, "pi-skills");
7
+ }
8
+ export async function ensureLocalpiSkillsDir(options) {
9
+ const dir = localpiSkillsDir(options.stateDir);
10
+ if (options.skills === "own") {
11
+ await mkdir(dir, { recursive: true });
12
+ }
13
+ return dir;
14
+ }
15
+ export function localpiSkillsArgs(mode, stateDir) {
16
+ switch (mode) {
17
+ case "ambient":
18
+ return [];
19
+ case "off":
20
+ return ["--no-skills"];
21
+ case "own":
22
+ return ["--no-skills", "--skill", localpiSkillsDir(stateDir)];
23
+ }
24
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { catppuccinMocha } from "../localpi/catppuccin.js";
4
+ export const localpiThemeName = "catppuccin-mocha";
5
+ const themeSchema = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json";
6
+ // Tool status cards need a dark surface with a visible success or failure cast. Both values are the
7
+ // Catppuccin base surface tinted with green and red.
8
+ const toolSuccessSurface = "#24352f";
9
+ const toolErrorSurface = "#3b2633";
10
+ export function localpiThemePath(stateDir) {
11
+ return path.join(stateDir, "pi-themes", `${localpiThemeName}.json`);
12
+ }
13
+ export function localpiThemeArgs(themePath, forwardedArgs) {
14
+ if (themePath === undefined) {
15
+ return [];
16
+ }
17
+ // The theme file is always loaded, so /settings can offer it. Catppuccin is only selected when
18
+ // the user did not ask for another theme in the same command line.
19
+ return hasForwardedFlag(forwardedArgs, "--use-theme")
20
+ ? ["--theme", themePath]
21
+ : ["--theme", themePath, "--use-theme", localpiThemeName];
22
+ }
23
+ export async function writeLocalpiTheme(stateDir, forwardedArgs) {
24
+ if (hasForwardedFlag(forwardedArgs, "--no-themes")) {
25
+ return undefined;
26
+ }
27
+ const themePath = localpiThemePath(stateDir);
28
+ await mkdir(path.dirname(themePath), { recursive: true });
29
+ await writeFile(themePath, catppuccinThemeSource(), "utf8");
30
+ return themePath;
31
+ }
32
+ export function catppuccinThemeSource() {
33
+ return `${JSON.stringify(catppuccinTheme(), null, 2)}\n`;
34
+ }
35
+ export function catppuccinTheme() {
36
+ return {
37
+ $schema: themeSchema,
38
+ name: localpiThemeName,
39
+ vars: { ...catppuccinMocha },
40
+ colors: {
41
+ accent: "lavender",
42
+ border: "surface2",
43
+ borderAccent: "blue",
44
+ borderMuted: "surface1",
45
+ success: "green",
46
+ error: "red",
47
+ warning: "peach",
48
+ muted: "subtext0",
49
+ dim: "overlay1",
50
+ text: "text",
51
+ thinkingText: "overlay2",
52
+ scrollbarTrack: "surface0",
53
+ scrollbarThumb: "overlay1",
54
+ selectedBg: "surface1",
55
+ searchMatchBg: "surface2",
56
+ searchMatchText: "text",
57
+ userMessageBg: "surface0",
58
+ userMessageText: "text",
59
+ customMessageBg: "mantle",
60
+ customMessageText: "text",
61
+ customMessageLabel: "mauve",
62
+ toolPendingBg: "mantle",
63
+ toolSuccessBg: toolSuccessSurface,
64
+ toolErrorBg: toolErrorSurface,
65
+ toolTitle: "blue",
66
+ toolOutput: "subtext0",
67
+ mdHeading: "peach",
68
+ mdLink: "blue",
69
+ mdLinkUrl: "overlay1",
70
+ mdCode: "teal",
71
+ mdCodeBlock: "green",
72
+ mdCodeBlockBorder: "surface2",
73
+ mdQuote: "subtext0",
74
+ mdQuoteBorder: "mauve",
75
+ mdHr: "surface2",
76
+ mdListBullet: "peach",
77
+ toolDiffAdded: "green",
78
+ toolDiffRemoved: "red",
79
+ toolDiffContext: "overlay2",
80
+ syntaxComment: "overlay1",
81
+ syntaxKeyword: "mauve",
82
+ syntaxFunction: "blue",
83
+ syntaxVariable: "lavender",
84
+ syntaxString: "green",
85
+ syntaxNumber: "peach",
86
+ syntaxType: "yellow",
87
+ syntaxOperator: "sky",
88
+ syntaxPunctuation: "overlay2",
89
+ thinkingOff: "overlay0",
90
+ thinkingMinimal: "overlay1",
91
+ thinkingLow: "sapphire",
92
+ thinkingMedium: "blue",
93
+ thinkingHigh: "mauve",
94
+ thinkingXhigh: "pink",
95
+ thinkingMax: "red",
96
+ bashMode: "green"
97
+ },
98
+ export: {
99
+ pageBg: "#11111b",
100
+ cardBg: "#1e1e2e",
101
+ infoBg: "#313244"
102
+ }
103
+ };
104
+ }
105
+ function hasForwardedFlag(args, flag) {
106
+ return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
107
+ }
@@ -93,16 +93,39 @@ Startup defaults:
93
93
 
94
94
  In-session control:
95
95
 
96
- - Localpi provides a Pi extension command named `/thinking`.
97
- - `/thinking` opens Pi's selector UI.
98
- - `/thinking high` sets the level directly.
99
- - The extension calls Pi's thinking API, so Pi owns runtime mutation.
100
- - The extension saves the actual Pi thinking level to localpi state for the next launch.
101
-
102
- Managed `llama-server` caveat:
103
-
104
- - Server-side reasoning budget is still chosen at startup.
105
- - Changing the server-side budget later would require restarting the local server process.
96
+ - Pi provides the `/thinking` command. Pi owns runtime mutation of the thinking level.
97
+ - The localpi extension saves the actual Pi thinking level to localpi state for the next launch.
98
+
99
+ Superseded: localpi first registered its own `/thinking` extension command. Pi already owns that
100
+ name, so Pi skipped the localpi command in autocomplete. Localpi now keeps only the persistence
101
+ hooks (`thinking_level_select` and `session_shutdown`) and leaves the command to Pi.
102
+
103
+ Managed `llama-server` thinking budget:
104
+
105
+ A reasoning model can loop in its thinking and never answer. The managed server bounds that by
106
+ cutting the thinking at a token budget and forcing the end-of-thinking tag, so the turn still
107
+ produces an answer.
108
+
109
+ - Server-side reasoning budget is still chosen at startup. Changing it later restarts the
110
+ localpi-owned server process.
111
+ - Thinking levels map to token budgets: `off` passes `--reasoning off` and no budget, `minimal` 32,
112
+ `low` 128, `medium` 512, `high` 2048, and `xhigh` 16384.
113
+ - `--thinking-budget <n>` and `LOCALPI_THINKING_BUDGET` set the budget directly. `-1` means
114
+ unrestricted, and a positive value replaces the budget of the level table. Any other value fails
115
+ with a clear message.
116
+ - When the budget is finite, localpi passes a default message that the server injects before the
117
+ end-of-thinking tag, so the model knows why the thinking stopped. `--thinking-budget-message
118
+ <text>` and `LOCALPI_THINKING_BUDGET_MESSAGE` reword that default, and an empty value passes no
119
+ message flag at all.
120
+ - The message override is optional text with no validation, so the default still has its escape
121
+ hatch and a model that needs different wording can get it.
122
+ - The engine detects the thinking tags from the model template. Localpi does not hardcode a tag, and
123
+ it does not pass reasoning effort levels that the template rejects.
124
+ - The managed server metadata records the reasoning mode, the budget, and the message. A changed
125
+ value restarts the owned server, as the budget check already does.
126
+ - The reasoning translation stays in the `llama-server` adapter. There is no engine-agnostic
127
+ reasoning layer, because localpi starts only the managed `llama-server`; LM Studio and vLLM are
128
+ external servers that localpi only connects to.
106
129
 
107
130
  ## Implementation Checklist
108
131
 
@@ -112,10 +135,15 @@ Managed `llama-server` caveat:
112
135
  - [x] Pass the launch-time catalog into generated Pi model config.
113
136
  - [x] Keep `/model` owned by Pi.
114
137
  - [x] Add reasoning and thinking-format metadata for known local reasoning models.
115
- - [x] Add `/thinking` as a Pi extension command.
138
+ - [x] Add `/thinking` as a Pi extension command. Superseded: Pi owns the name, so the extension now only remembers the level.
116
139
  - [x] Keep startup thinking non-interactive.
117
140
  - [x] Keep `--thinking` and `LOCALPI_THINKING` as automation-safe startup controls.
118
141
  - [x] Remember the last Pi thinking level for future localpi launches.
142
+ - [x] Add `--thinking-budget` and `LOCALPI_THINKING_BUDGET` for the managed `llama-server`.
143
+ - [x] Inject a default message before the end-of-thinking tag when the budget is finite.
144
+ - [x] Add `--thinking-budget-message` and `LOCALPI_THINKING_BUDGET_MESSAGE` to reword the message, or to pass none.
145
+ - [x] Record the reasoning mode, the budget, and the message in the managed server metadata.
146
+ - [x] Test the level table, the budget override, an invalid value, and the restart trigger.
119
147
  - [ ] Manually verify model picker behavior in an interactive terminal with multiple loaded providers.
120
148
  - [ ] Manually verify Pi `/model` can switch among generated catalog entries.
121
149
  - [ ] Manually verify Pi `/thinking` picker and direct `/thinking <level>` command.
@@ -0,0 +1,111 @@
1
+ # Plan: ACP mode for localpi
2
+
3
+ Date: 2026-09-23
4
+ Status: selected for implementation
5
+
6
+ ## Goal
7
+
8
+ Make localpi able to act as an ACP agent. Add `localpi --acp` (and
9
+ `LOCALPI_ACP=1`) that resolves and writes the same Pi configuration a normal
10
+ launch writes, then starts the ACP server on stdio and gets out of the way. Pin
11
+ `pi-acp` as a dependency instead of vendoring its source.
12
+
13
+ ## Why
14
+
15
+ `pi-acp` is the maintained ACP adapter for Pi (owner `svkozak`, MIT, npm
16
+ `pi-acp@0.0.33`, published with provenance). localpi already owns model
17
+ resolution, Pi configuration, and launching. Adding ACP on top of that turns
18
+ localpi into an agent that ACP clients can drive, including the Harbor harness,
19
+ so a benchmark can measure this launcher instead of a purpose-built adapter.
20
+
21
+ ## Verified facts
22
+
23
+ These were read from the pinned sources, not assumed.
24
+
25
+ - `pi-acp` spawns Pi as `pi --mode rpc --no-themes`, plus `--session <path>`
26
+ when a session path exists, and passes the current environment to the child.
27
+ - `pi-acp` uses `PI_ACP_PI_COMMAND` as the executable name, so localpi can point
28
+ it at the Pi it resolved.
29
+ - `pi-acp` reads settings from `$PI_CODING_AGENT_DIR/settings.json` and from
30
+ `<cwd>/.pi/settings.json`, and it does not pass `--no-extensions`, so Pi
31
+ extension discovery stays enabled.
32
+ - localpi is at version 0.5.2, is a strict TypeScript CLI, and declares
33
+ `@osolmaz/pi-factory` and `pi-demo-mode` as its only dependencies. Its bin is
34
+ `localpi` mapped to `dist/src/cli/main.js`.
35
+ - localpi has no ACP support today. A search for ACP over its source, docs,
36
+ README, and manifest returns only `llamaCpp` substrings.
37
+ - localpi already forwards `--mode` to Pi and only rejects it together with
38
+ `--demo`.
39
+ - Pi has no ACP mode of its own, so the ACP server must be a separate process.
40
+
41
+ ## Design
42
+
43
+ 1. Pin, do not vendor. Add `pi-acp` to `dependencies` at a fixed version with a
44
+ lockfile entry. Do not copy its source into this repository.
45
+ 2. One new entry path. `localpi --acp` and `LOCALPI_ACP=1` start the ACP server.
46
+ Follow the existing precedence: flag, environment, saved setting, default.
47
+ The default stays a normal launch, so nothing changes for current users.
48
+ 3. Configure first, then hand off. Run the existing resolution and Pi config
49
+ writing, then start the pinned `pi-acp` with inherited stdio. Reuse the
50
+ existing resolution path. Do not duplicate discovery or config writing.
51
+ 4. No interactive selection in ACP mode. There is no TTY, so require an explicit
52
+ model from the flag, environment, or model profile, and fail with a clear
53
+ message when none is given. Never print a picker.
54
+ 5. Keep stdout pure. In ACP mode, stdout carries protocol bytes only. Send
55
+ diagnostics, warnings, and startup notes to stderr. Add a test that fails if
56
+ anything else reaches stdout.
57
+ 6. Point the adapter at the resolved Pi. Set `PI_ACP_PI_COMMAND` to the Pi
58
+ executable localpi would launch, and pass the same environment the normal
59
+ launch uses: the Pi config directory, provider base URL, the API key name,
60
+ the thinking level, and the session directory.
61
+ 7. Do not select ACP again inside the child. The ACP path must set the adapter
62
+ command to Pi itself, never back to localpi, so a spawned child cannot
63
+ re-enter ACP mode. Add a test for that.
64
+ 8. Keep the existing rules. Settings stay in `<state-dir>/settings.json`. Model
65
+ profile limits (`client.context_window`, `client.max_tokens`) and the managed
66
+ llama-server thinking budget keep their current meaning. Never invent a
67
+ smaller reply cap than the declared one.
68
+
69
+ ## Deliverables
70
+
71
+ - `localpi --acp` and `LOCALPI_ACP=1`, implemented in the existing launcher
72
+ structure with the ACP start kept in its own module.
73
+ - `pi-acp` pinned in `package.json` and the lockfile.
74
+ - Unit tests with a fake ACP child: correct command and environment, pure
75
+ stdout, a clear failure when no explicit model is given, exit-code
76
+ propagation, and no re-entry into ACP mode.
77
+ - A README section for ACP mode and an entry in
78
+ `docs/runtime-specification.md`.
79
+
80
+ ## Acceptance checks
81
+
82
+ 1. `npm run check` passes, including the generated-extension typecheck test.
83
+ 2. `localpi --acp` with a fake adapter child starts it with the expected
84
+ environment, and stdout carries only protocol bytes.
85
+ 3. `localpi --acp` without an explicit model fails fast with one clear message
86
+ and no picker.
87
+ 4. A normal launch, without the ACP flag, behaves exactly as before.
88
+ 5. No new state file appears; every new setting lives in the settings object.
89
+
90
+ ## Out of scope
91
+
92
+ - Harbor manifests, harness wheels, and benchmark arms. Those belong to
93
+ `osolmaz/harbor-custom-harnesses`.
94
+ - Vendoring `pi-acp` source, or building anything from source.
95
+ - Publishing to npm, merging, or any release. Those need a separate instruction.
96
+ - Real model inference in tests.
97
+
98
+ ## Authority
99
+
100
+ - Repository: `osolmaz/localpi` only.
101
+ - Allowed: edit, test, commit, push, open a pull request.
102
+ - Not allowed: merge without an explicit instruction, publish a package or a
103
+ release, install or copy credentials, run paid remote work, or push to another
104
+ repository.
105
+
106
+ ## Risks
107
+
108
+ - Two Pi processes could blink in and out if the adapter command ever points
109
+ back at localpi. The re-entry test covers this.
110
+ - Any startup chatter on stdout breaks the protocol handshake, so the purity
111
+ test is part of the gate, not an afterthought.