pi-acp 0.0.33 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,10 @@ Expect some minor breaking changes.
25
25
  - Loads file-based slash commands compatible with pi’s conventions
26
26
  - Adds a small set of built-in commands for headless/editor usage
27
27
  - Supports skill commands (if enabled in pi settings, they appear as `/skill:skill-name` in the ACP client)
28
+ - Context window usage
29
+ - Reports pi's real context occupancy (`get_session_stats` → `contextUsage`) to the client as ACP `usage_update` after each turn, on `session/new` and `session/load`, and after a model switch
30
+ - Requires a pi version whose `get_session_stats` response includes `contextUsage`; otherwise no usage is reported
31
+ - Right after compaction pi may not have a trustworthy token count yet, so the client keeps the previous value until the next model response
28
32
  - Skills are loaded by pi directly and are available in ACP sessions
29
33
  - (Zed) `pi-acp` emits “startup info” block into the session (pi version, context, skills, prompts, extensions - similar to `pi` in the terminal). You can disable it by setting `quietStartup: true` in pi settings (`~/.pi/agent/settings.json` or `<project>/.pi/settings.json`). When `quietStartup` is enabled, `pi-acp` will still emit a 'New version available' message if the installed pi version is outdated.
30
34
  - (Zed) Session history is supported in Zed starting with [`v0.225.0`](https://zed.dev/releases/preview/0.225.0). Session loading / history maps to pi's session files. Sessions can be resumed both in `pi` and in the ACP client.
@@ -38,7 +42,7 @@ npm install -g @earendil-works/pi-coding-agent
38
42
  ```
39
43
 
40
44
  - Node.js 22+
41
- - `pi` v0.80.4+ installed and available on your `PATH` (the adapter runs the `pi` executable)
45
+ - `pi` v0.81.0+ installed and available on your `PATH` (the adapter runs the `pi` executable)
42
46
  - Configure `pi` separately for your model providers/API keys
43
47
 
44
48
  ## Install
@@ -155,7 +159,7 @@ Loaded from:
155
159
 
156
160
  Other built-in commands:
157
161
 
158
- - `/model` - maps to model selector in Zed
162
+ - `/model` - not implemented (use the model selector UI in Zed)
159
163
  - `/thinking` - maps to 'mode' selector in Zed
160
164
  - `/clear` - not implemented (use ACP client 'new' command)
161
165
 
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ import { isAbsolute, resolve as resolvePath } from "path";
54
54
  // src/pi-rpc/process.ts
55
55
  import { spawn } from "child_process";
56
56
  import * as readline from "readline";
57
+ import crossSpawn from "cross-spawn";
57
58
 
58
59
  // src/pi-rpc/command.ts
59
60
  import { platform } from "os";
@@ -89,6 +90,7 @@ var ANSI_ESCAPE_REGEX = new RegExp(
89
90
  function stripAnsi(s) {
90
91
  return s.replace(ANSI_ESCAPE_REGEX, "");
91
92
  }
93
+ var SESSION_STATS_TIMEOUT_MS = 1e3;
92
94
  var PiRpcProcess = class _PiRpcProcess {
93
95
  child;
94
96
  pending = /* @__PURE__ */ new Map();
@@ -109,14 +111,8 @@ var PiRpcProcess = class _PiRpcProcess {
109
111
  }
110
112
  if (msg?.type === "response") {
111
113
  const id = typeof msg.id === "string" ? msg.id : void 0;
112
- if (id) {
113
- const pending = this.pending.get(id);
114
- if (pending) {
115
- this.pending.delete(id);
116
- pending.resolve(msg);
117
- return;
118
- }
119
- }
114
+ if (id !== void 0) this.pending.get(id)?.resolve(msg);
115
+ return;
120
116
  }
121
117
  for (const h of this.eventHandlers) h(msg);
122
118
  });
@@ -134,11 +130,11 @@ var PiRpcProcess = class _PiRpcProcess {
134
130
  const cmd = getPiCommand(params.piCommand);
135
131
  const args = ["--mode", "rpc", "--no-themes"];
136
132
  if (params.sessionPath) args.push("--session", params.sessionPath);
137
- const child = spawn(cmd, args, {
133
+ const start = shouldUseShellForPiCommand(cmd) ? crossSpawn : spawn;
134
+ const child = start(cmd, args, {
138
135
  cwd: params.cwd,
139
136
  stdio: "pipe",
140
- env: process.env,
141
- shell: shouldUseShellForPiCommand(cmd)
137
+ env: process.env
142
138
  });
143
139
  try {
144
140
  await new Promise((resolve4, reject) => {
@@ -229,6 +225,17 @@ var PiRpcProcess = class _PiRpcProcess {
229
225
  if (!res.success) throw new Error(`pi set_model failed: ${res.error ?? JSON.stringify(res.data)}`);
230
226
  return res.data;
231
227
  }
228
+ async getAvailableThinkingLevels() {
229
+ const res = await this.request({ type: "get_available_thinking_levels" });
230
+ if (!res.success)
231
+ throw new Error(`pi get_available_thinking_levels failed: ${res.error ?? JSON.stringify(res.data)}`);
232
+ const data = res.data;
233
+ const levels = data && typeof data === "object" && "levels" in data ? data.levels : void 0;
234
+ if (!Array.isArray(levels) || levels.length === 0 || !levels.every((level) => typeof level === "string" && level.length > 0)) {
235
+ throw new Error("pi get_available_thinking_levels returned invalid levels");
236
+ }
237
+ return levels;
238
+ }
232
239
  async setThinkingLevel(level) {
233
240
  const res = await this.request({ type: "set_thinking_level", level });
234
241
  if (!res.success) throw new Error(`pi set_thinking_level failed: ${res.error ?? JSON.stringify(res.data)}`);
@@ -250,10 +257,10 @@ var PiRpcProcess = class _PiRpcProcess {
250
257
  const res = await this.request({ type: "set_auto_compaction", enabled });
251
258
  if (!res.success) throw new Error(`pi set_auto_compaction failed: ${res.error ?? JSON.stringify(res.data)}`);
252
259
  }
253
- async getSessionStats() {
254
- const res = await this.request({ type: "get_session_stats" });
260
+ async getSessionStats(timeoutMs) {
261
+ const res = await this.request({ type: "get_session_stats" }, { timeoutMs });
255
262
  if (!res.success) throw new Error(`pi get_session_stats failed: ${res.error ?? JSON.stringify(res.data)}`);
256
- return res.data;
263
+ return res.data ?? {};
257
264
  }
258
265
  async setSessionName(name) {
259
266
  const res = await this.request({ type: "set_session_name", name });
@@ -283,15 +290,41 @@ var PiRpcProcess = class _PiRpcProcess {
283
290
  await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })}
284
291
  `);
285
292
  }
286
- request(cmd) {
293
+ request(cmd, opts) {
287
294
  const id = crypto.randomUUID();
288
295
  const withId = { ...cmd, id };
296
+ const timeoutMs = opts?.timeoutMs;
289
297
  const line = `${JSON.stringify(withId)}
290
298
  `;
291
299
  return new Promise((resolve4, reject) => {
292
- this.pending.set(id, { resolve: resolve4, reject });
300
+ let timer;
301
+ const drop = () => {
302
+ if (timer !== void 0) {
303
+ clearTimeout(timer);
304
+ timer = void 0;
305
+ }
306
+ return this.pending.delete(id);
307
+ };
308
+ this.pending.set(id, {
309
+ resolve: (res) => {
310
+ drop();
311
+ resolve4(res);
312
+ },
313
+ reject: (error) => {
314
+ drop();
315
+ reject(error);
316
+ }
317
+ });
318
+ if (timeoutMs !== void 0) {
319
+ timer = setTimeout(() => {
320
+ timer = void 0;
321
+ if (!this.pending.delete(id)) return;
322
+ reject(new Error(`pi ${cmd.type} timed out after ${timeoutMs}ms`));
323
+ }, timeoutMs);
324
+ timer.unref?.();
325
+ }
293
326
  void this.writeLine(line).catch((error) => {
294
- this.pending.delete(id);
327
+ if (!drop()) return;
295
328
  reject(error);
296
329
  });
297
330
  });
@@ -610,6 +643,13 @@ var CONFIRM_PERMISSION_OPTIONS = [
610
643
  ];
611
644
  var EXTENSION_UI_RAW_INPUT_KEYS = ["title", "message", "options", "placeholder", "prefill"];
612
645
  var CHOICE_OPTION_PREFIX = "choice-";
646
+ function toUsageUpdate(stats) {
647
+ const used = stats?.contextUsage?.tokens;
648
+ const size = stats?.contextUsage?.contextWindow;
649
+ if (typeof used !== "number" || !Number.isSafeInteger(used) || used < 0) return null;
650
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size <= 0) return null;
651
+ return { sessionUpdate: "usage_update", used, size };
652
+ }
613
653
  function findUniqueLineNumber(text, needle) {
614
654
  if (!needle) return void 0;
615
655
  const first = text.indexOf(needle);
@@ -881,6 +921,41 @@ var PiAcpSession = class {
881
921
  async flushEmits() {
882
922
  await this.lastEmit;
883
923
  }
924
+ /**
925
+ * Best-effort: publish the real pi context-window occupancy as ACP `usage_update`.
926
+ * Queued updates are flushed even when the stats query fails or times out, so callers
927
+ * can await this before resolving `session/prompt`.
928
+ */
929
+ async publishContextUsage() {
930
+ try {
931
+ if (typeof this.proc.getSessionStats === "function") {
932
+ const update = toUsageUpdate(await this.proc.getSessionStats(SESSION_STATS_TIMEOUT_MS));
933
+ if (update) this.emit(update);
934
+ }
935
+ } catch {
936
+ }
937
+ await this.flushEmits();
938
+ }
939
+ async settleTurn() {
940
+ await this.publishContextUsage();
941
+ const reason = this.cancelRequested ? "cancelled" : "end_turn";
942
+ this.pendingTurn?.resolve(reason);
943
+ this.pendingTurn = null;
944
+ this.inAgentLoop = false;
945
+ const next = this.turnQueue.shift();
946
+ if (next) {
947
+ this.emit({
948
+ sessionUpdate: "agent_message_chunk",
949
+ content: { type: "text", text: `Starting queued message. (${this.turnQueue.length} remaining)` }
950
+ });
951
+ this.startTurn(next);
952
+ } else {
953
+ this.emit({
954
+ sessionUpdate: "session_info_update",
955
+ _meta: { piAcp: { queueDepth: 0, running: false } }
956
+ });
957
+ }
958
+ }
884
959
  emitBashToolCall(params) {
885
960
  this.bashToolCallIds.add(params.toolCallId);
886
961
  this.emit({
@@ -1210,25 +1285,7 @@ var PiAcpSession = class {
1210
1285
  break;
1211
1286
  }
1212
1287
  case "agent_settled": {
1213
- void this.flushEmits().finally(() => {
1214
- const reason = this.cancelRequested ? "cancelled" : "end_turn";
1215
- this.pendingTurn?.resolve(reason);
1216
- this.pendingTurn = null;
1217
- this.inAgentLoop = false;
1218
- const next = this.turnQueue.shift();
1219
- if (next) {
1220
- this.emit({
1221
- sessionUpdate: "agent_message_chunk",
1222
- content: { type: "text", text: `Starting queued message. (${this.turnQueue.length} remaining)` }
1223
- });
1224
- this.startTurn(next);
1225
- } else {
1226
- this.emit({
1227
- sessionUpdate: "session_info_update",
1228
- _meta: { piAcp: { queueDepth: 0, running: false } }
1229
- });
1230
- }
1231
- });
1288
+ void this.settleTurn();
1232
1289
  break;
1233
1290
  }
1234
1291
  default:
@@ -2004,10 +2061,15 @@ var PiAcpAgent = class {
2004
2061
  "Configure an API key or log in with an OAuth provider."
2005
2062
  );
2006
2063
  }
2007
- const { configOptions, models, modes } = await getSessionConfiguration(session.proc, {
2008
- state,
2009
- availableModels
2010
- });
2064
+ let configuration;
2065
+ try {
2066
+ if (stateErr) throw stateErr;
2067
+ configuration = await getSessionConfiguration(session.proc, { state, availableModels });
2068
+ } catch (err) {
2069
+ this.cleanupFailedNewSession(session.sessionId, state);
2070
+ throw maybeAuthRequiredError(err) ?? RequestError3.internalError({}, String(err?.message ?? err));
2071
+ }
2072
+ const { configOptions, models, modes } = configuration;
2011
2073
  const quietStartup = getQuietStartup(params.cwd);
2012
2074
  const updateNotice = buildUpdateNotice();
2013
2075
  const preludeText = quietStartup ? updateNotice ? updateNotice + "\n" : "" : buildStartupInfo({
@@ -2032,6 +2094,7 @@ var PiAcpAgent = class {
2032
2094
  if (preludeText) setTimeout(() => session.sendStartupInfoIfPending(), 0);
2033
2095
  setTimeout(() => {
2034
2096
  void (async () => {
2097
+ await session.publishContextUsage();
2035
2098
  try {
2036
2099
  const pi = await session.proc.getCommands();
2037
2100
  const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
@@ -2473,6 +2536,14 @@ ${JSON.stringify(stats, null, 2)}`;
2473
2536
  mcpServers: params.mcpServers
2474
2537
  });
2475
2538
  const proc = session.proc;
2539
+ let configuration;
2540
+ try {
2541
+ configuration = await getSessionConfiguration(proc);
2542
+ } catch (err) {
2543
+ this.sessions.close(session.sessionId);
2544
+ throw err;
2545
+ }
2546
+ const { configOptions, models, modes } = configuration;
2476
2547
  const fileCommands = loadSlashCommands(params.cwd);
2477
2548
  this.sessions.closeAllExcept?.(session.sessionId);
2478
2549
  this.store.upsert({
@@ -2566,7 +2637,6 @@ ${JSON.stringify(stats, null, 2)}`;
2566
2637
  });
2567
2638
  }
2568
2639
  }
2569
- const { configOptions, models, modes } = await getSessionConfiguration(proc);
2570
2640
  const response = {
2571
2641
  configOptions,
2572
2642
  models,
@@ -2579,6 +2649,7 @@ ${JSON.stringify(stats, null, 2)}`;
2579
2649
  };
2580
2650
  setTimeout(() => {
2581
2651
  void (async () => {
2652
+ await session.publishContextUsage();
2582
2653
  try {
2583
2654
  const pi = await proc.getCommands();
2584
2655
  const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
@@ -2626,66 +2697,48 @@ ${JSON.stringify(stats, null, 2)}`;
2626
2697
  const session = await this.restoreSession(params.sessionId);
2627
2698
  await setSessionModel(session.proc, params.modelId);
2628
2699
  await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2700
+ await session.publishContextUsage();
2629
2701
  }
2630
2702
  async setSessionMode(params) {
2631
2703
  const session = await this.restoreSession(params.sessionId);
2632
- const mode = String(params.modeId);
2633
- if (!isThinkingLevel(mode)) {
2634
- throw RequestError3.invalidParams(`Unknown modeId: ${mode}`);
2704
+ const mode = params.modeId;
2705
+ if (typeof mode !== "string" || mode.length === 0) {
2706
+ throw RequestError3.invalidParams("Expected nonempty string modeId");
2635
2707
  }
2636
2708
  await session.proc.setThinkingLevel(mode);
2637
- void this.conn.sessionUpdate({
2638
- sessionId: session.sessionId,
2639
- update: {
2640
- sessionUpdate: "current_mode_update",
2641
- currentModeId: mode
2642
- }
2643
- });
2644
2709
  await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2645
2710
  return {};
2646
2711
  }
2647
2712
  async setSessionConfigOption(params) {
2648
2713
  const session = await this.restoreSession(params.sessionId);
2649
2714
  const configId = String(params.configId);
2715
+ let modelChanged = false;
2650
2716
  if (typeof params.value !== "string") {
2651
2717
  throw RequestError3.invalidParams(`Expected string value for config option: ${configId}`);
2652
2718
  }
2653
2719
  if (configId === MODEL_CONFIG_ID) {
2654
2720
  await setSessionModel(session.proc, params.value);
2721
+ modelChanged = true;
2655
2722
  } else if (configId === THOUGHT_LEVEL_CONFIG_ID) {
2656
- if (!isThinkingLevel(params.value)) {
2657
- throw RequestError3.invalidParams(`Unknown thinking level: ${params.value}`);
2723
+ if (params.value.length === 0) {
2724
+ throw RequestError3.invalidParams("Expected nonempty thinking level");
2658
2725
  }
2659
2726
  await session.proc.setThinkingLevel(params.value);
2660
- void this.conn.sessionUpdate({
2661
- sessionId: session.sessionId,
2662
- update: {
2663
- sessionUpdate: "current_mode_update",
2664
- currentModeId: params.value
2665
- }
2666
- });
2667
2727
  } else {
2668
2728
  throw RequestError3.invalidParams(`Unknown config option: ${configId}`);
2669
2729
  }
2670
2730
  const configOptions = await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2731
+ if (modelChanged) await session.publishContextUsage();
2671
2732
  return { configOptions };
2672
2733
  }
2673
2734
  };
2674
- function isThinkingLevel(x) {
2675
- return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh";
2676
- }
2677
2735
  async function getThinkingState(proc, pre) {
2678
- let current = "medium";
2679
- const state = pre?.state ?? await (async () => {
2680
- try {
2681
- return await proc.getState();
2682
- } catch {
2683
- return null;
2684
- }
2685
- })();
2686
- const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null;
2687
- if (tl && isThinkingLevel(tl)) current = tl;
2688
- const available = ["off", "minimal", "low", "medium", "high", "xhigh"];
2736
+ const state = pre?.state ?? await proc.getState();
2737
+ const available = await proc.getAvailableThinkingLevels();
2738
+ const current = state && typeof state === "object" && "thinkingLevel" in state ? state.thinkingLevel : void 0;
2739
+ if (typeof current !== "string" || current.length === 0 || !available.includes(current)) {
2740
+ throw new Error("pi returned a thinking level absent from available levels");
2741
+ }
2689
2742
  return {
2690
2743
  currentModeId: current,
2691
2744
  availableModes: available.map((id) => ({
@@ -2696,7 +2749,8 @@ async function getThinkingState(proc, pre) {
2696
2749
  };
2697
2750
  }
2698
2751
  async function getSessionConfiguration(proc, pre) {
2699
- const [models, modes] = await Promise.all([getModelState(proc, pre), getThinkingState(proc, { state: pre?.state })]);
2752
+ const state = pre?.state ?? await proc.getState();
2753
+ const [models, modes] = await Promise.all([getModelState(proc, { ...pre, state }), getThinkingState(proc, { state })]);
2700
2754
  return {
2701
2755
  configOptions: buildConfigOptions({ models, modes }),
2702
2756
  models,
@@ -2779,7 +2833,11 @@ async function getModelState(proc, pre) {
2779
2833
  };
2780
2834
  }
2781
2835
  async function emitConfigOptionsUpdate(conn, sessionId, proc) {
2782
- const { configOptions } = await getSessionConfiguration(proc);
2836
+ const { configOptions, modes } = await getSessionConfiguration(proc);
2837
+ await conn.sessionUpdate({
2838
+ sessionId,
2839
+ update: { sessionUpdate: "current_mode_update", currentModeId: modes.currentModeId }
2840
+ });
2783
2841
  await conn.sessionUpdate({
2784
2842
  sessionId,
2785
2843
  update: {