pi-acp 0.0.32 → 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` 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);
@@ -787,8 +827,9 @@ var PiAcpSession = class {
787
827
  // Some pi events can arrive out of order (e.g. late toolcall_* deltas after execution starts),
788
828
  // and clients may hide progress if we ever downgrade back to `pending`.
789
829
  currentToolCalls = /* @__PURE__ */ new Map();
790
- // pi can emit multiple `turn_end` events for a single user prompt (e.g. after tool_use).
791
- // The overall agent loop completes when `agent_end` is emitted.
830
+ // pi can emit multiple `turn_end` and `agent_end` events for a single user prompt
831
+ // when retry, compaction, or queued continuations run. The session-level prompt
832
+ // completes only when `agent_settled` is emitted.
792
833
  inAgentLoop = false;
793
834
  // For ACP diff support: capture file contents before edit/write mutations,
794
835
  // then emit ToolCallContent {type:"diff"}. Compatible structured edit/write
@@ -880,6 +921,41 @@ var PiAcpSession = class {
880
921
  async flushEmits() {
881
922
  await this.lastEmit;
882
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
+ }
883
959
  emitBashToolCall(params) {
884
960
  this.bashToolCallIds.add(params.toolCallId);
885
961
  this.emit({
@@ -1205,25 +1281,11 @@ var PiAcpSession = class {
1205
1281
  break;
1206
1282
  }
1207
1283
  case "agent_end": {
1208
- void this.flushEmits().finally(() => {
1209
- const reason = this.cancelRequested ? "cancelled" : "end_turn";
1210
- this.pendingTurn?.resolve(reason);
1211
- this.pendingTurn = null;
1212
- this.inAgentLoop = false;
1213
- const next = this.turnQueue.shift();
1214
- if (next) {
1215
- this.emit({
1216
- sessionUpdate: "agent_message_chunk",
1217
- content: { type: "text", text: `Starting queued message. (${this.turnQueue.length} remaining)` }
1218
- });
1219
- this.startTurn(next);
1220
- } else {
1221
- this.emit({
1222
- sessionUpdate: "session_info_update",
1223
- _meta: { piAcp: { queueDepth: 0, running: false } }
1224
- });
1225
- }
1226
- });
1284
+ this.inAgentLoop = false;
1285
+ break;
1286
+ }
1287
+ case "agent_settled": {
1288
+ void this.settleTurn();
1227
1289
  break;
1228
1290
  }
1229
1291
  default:
@@ -1999,10 +2061,15 @@ var PiAcpAgent = class {
1999
2061
  "Configure an API key or log in with an OAuth provider."
2000
2062
  );
2001
2063
  }
2002
- const { configOptions, models, modes } = await getSessionConfiguration(session.proc, {
2003
- state,
2004
- availableModels
2005
- });
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;
2006
2073
  const quietStartup = getQuietStartup(params.cwd);
2007
2074
  const updateNotice = buildUpdateNotice();
2008
2075
  const preludeText = quietStartup ? updateNotice ? updateNotice + "\n" : "" : buildStartupInfo({
@@ -2027,6 +2094,7 @@ var PiAcpAgent = class {
2027
2094
  if (preludeText) setTimeout(() => session.sendStartupInfoIfPending(), 0);
2028
2095
  setTimeout(() => {
2029
2096
  void (async () => {
2097
+ await session.publishContextUsage();
2030
2098
  try {
2031
2099
  const pi = await session.proc.getCommands();
2032
2100
  const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
@@ -2468,6 +2536,14 @@ ${JSON.stringify(stats, null, 2)}`;
2468
2536
  mcpServers: params.mcpServers
2469
2537
  });
2470
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;
2471
2547
  const fileCommands = loadSlashCommands(params.cwd);
2472
2548
  this.sessions.closeAllExcept?.(session.sessionId);
2473
2549
  this.store.upsert({
@@ -2561,7 +2637,6 @@ ${JSON.stringify(stats, null, 2)}`;
2561
2637
  });
2562
2638
  }
2563
2639
  }
2564
- const { configOptions, models, modes } = await getSessionConfiguration(proc);
2565
2640
  const response = {
2566
2641
  configOptions,
2567
2642
  models,
@@ -2574,6 +2649,7 @@ ${JSON.stringify(stats, null, 2)}`;
2574
2649
  };
2575
2650
  setTimeout(() => {
2576
2651
  void (async () => {
2652
+ await session.publishContextUsage();
2577
2653
  try {
2578
2654
  const pi = await proc.getCommands();
2579
2655
  const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
@@ -2621,66 +2697,48 @@ ${JSON.stringify(stats, null, 2)}`;
2621
2697
  const session = await this.restoreSession(params.sessionId);
2622
2698
  await setSessionModel(session.proc, params.modelId);
2623
2699
  await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2700
+ await session.publishContextUsage();
2624
2701
  }
2625
2702
  async setSessionMode(params) {
2626
2703
  const session = await this.restoreSession(params.sessionId);
2627
- const mode = String(params.modeId);
2628
- if (!isThinkingLevel(mode)) {
2629
- 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");
2630
2707
  }
2631
2708
  await session.proc.setThinkingLevel(mode);
2632
- void this.conn.sessionUpdate({
2633
- sessionId: session.sessionId,
2634
- update: {
2635
- sessionUpdate: "current_mode_update",
2636
- currentModeId: mode
2637
- }
2638
- });
2639
2709
  await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2640
2710
  return {};
2641
2711
  }
2642
2712
  async setSessionConfigOption(params) {
2643
2713
  const session = await this.restoreSession(params.sessionId);
2644
2714
  const configId = String(params.configId);
2715
+ let modelChanged = false;
2645
2716
  if (typeof params.value !== "string") {
2646
2717
  throw RequestError3.invalidParams(`Expected string value for config option: ${configId}`);
2647
2718
  }
2648
2719
  if (configId === MODEL_CONFIG_ID) {
2649
2720
  await setSessionModel(session.proc, params.value);
2721
+ modelChanged = true;
2650
2722
  } else if (configId === THOUGHT_LEVEL_CONFIG_ID) {
2651
- if (!isThinkingLevel(params.value)) {
2652
- throw RequestError3.invalidParams(`Unknown thinking level: ${params.value}`);
2723
+ if (params.value.length === 0) {
2724
+ throw RequestError3.invalidParams("Expected nonempty thinking level");
2653
2725
  }
2654
2726
  await session.proc.setThinkingLevel(params.value);
2655
- void this.conn.sessionUpdate({
2656
- sessionId: session.sessionId,
2657
- update: {
2658
- sessionUpdate: "current_mode_update",
2659
- currentModeId: params.value
2660
- }
2661
- });
2662
2727
  } else {
2663
2728
  throw RequestError3.invalidParams(`Unknown config option: ${configId}`);
2664
2729
  }
2665
2730
  const configOptions = await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
2731
+ if (modelChanged) await session.publishContextUsage();
2666
2732
  return { configOptions };
2667
2733
  }
2668
2734
  };
2669
- function isThinkingLevel(x) {
2670
- return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh";
2671
- }
2672
2735
  async function getThinkingState(proc, pre) {
2673
- let current = "medium";
2674
- const state = pre?.state ?? await (async () => {
2675
- try {
2676
- return await proc.getState();
2677
- } catch {
2678
- return null;
2679
- }
2680
- })();
2681
- const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null;
2682
- if (tl && isThinkingLevel(tl)) current = tl;
2683
- 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
+ }
2684
2742
  return {
2685
2743
  currentModeId: current,
2686
2744
  availableModes: available.map((id) => ({
@@ -2691,7 +2749,8 @@ async function getThinkingState(proc, pre) {
2691
2749
  };
2692
2750
  }
2693
2751
  async function getSessionConfiguration(proc, pre) {
2694
- 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 })]);
2695
2754
  return {
2696
2755
  configOptions: buildConfigOptions({ models, modes }),
2697
2756
  models,
@@ -2774,7 +2833,11 @@ async function getModelState(proc, pre) {
2774
2833
  };
2775
2834
  }
2776
2835
  async function emitConfigOptionsUpdate(conn, sessionId, proc) {
2777
- 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
+ });
2778
2841
  await conn.sessionUpdate({
2779
2842
  sessionId,
2780
2843
  update: {