pi-acp 0.0.21 → 0.0.23

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
@@ -2,16 +2,21 @@
2
2
 
3
3
  ACP ([Agent Client Protocol](https://agentclientprotocol.com/overview/introduction)) adapter for [`pi`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) coding agent (fka shitty coding agent).
4
4
 
5
- `pi-acp` communicates **ACP JSON-RPC 2.0 over stdio** to an ACP client (e.g. an editor) and spawns `pi --mode rpc`, bridging requests/events between the two.
5
+ `pi-acp` communicates **ACP JSON-RPC 2.0 over stdio** to an ACP client (e.g. Zed editor) and spawns `pi --mode rpc`, bridging requests/events between the two.
6
6
 
7
7
  ## Status
8
8
 
9
9
  This is an MVP-style adapter intended to be useful today and easy to iterate on. Some ACP features may be not implemented or are not supported (see [Limitations](#limitations)). Development is centered around [Zed](https://zed.dev) editor support, other clients may have varying levels of compatibility.
10
10
 
11
+ Expect some minor breaking changes.
12
+
11
13
  ## Features
12
14
 
13
15
  - Streams assistant output as ACP `agent_message_chunk`
14
16
  - Maps pi tool execution to ACP `tool_call` / `tool_call_update`
17
+ - Tool call locations are surfaced when available for ACP clients that support opening the referenced file/context
18
+ - Relative file paths from pi are resolved against the session cwd before being emitted as ACP tool locations, which enables follow-along features in clients like Zed
19
+ - For `edit`, `pi-acp` attempts to infer a 1-based line number from a unique `oldText` match in the pre-edit file snapshot and includes it in the emitted tool location when possible
15
20
  - For `edit`, `pi-acp` snapshots the file before the tool runs and emits an ACP **structured diff** (`oldText`/`newText`) on completion when possible
16
21
  - Session persistence
17
22
  - pi stores its own sessions in `~/.pi/agent/sessions/...`
@@ -21,7 +26,7 @@ This is an MVP-style adapter intended to be useful today and easy to iterate on.
21
26
  - Adds a small set of built-in commands for headless/editor usage
22
27
  - Supports skill commands (if enabled in pi settings, they appear as `/skill:skill-name` in the ACP client)
23
28
  - Skills are loaded by pi directly and are available in ACP sessions
24
- - (Zed) `pi-acp` emits a short markdown “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.
29
+ - (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.
25
30
  - (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.
26
31
 
27
32
  ## Prerequisites
@@ -40,10 +45,22 @@ npm install -g @mariozechner/pi-coding-agent
40
45
 
41
46
  ### Add pi-acp to your ACP client, e.g. [Zed](https://zed.dev/docs/agents/external-agents/)
42
47
 
43
- Add the following to your Zed `settngs.json`:
48
+ #### Using ACP Registry in Zed or other clients that support it:
49
+
50
+ In Zed launch the registry with `zed: acp registry` command and select `pi ACP` adapter from the list. This will automatically add the agent server configuration to your `settings.json` and keep it up to date:
51
+
52
+ ```json
53
+ "agent_servers": {
54
+ "pi-acp": {
55
+ "type": "registry",
56
+ },
57
+ }
58
+ ```
44
59
 
45
60
  #### Using with `npx` (no global install needed, always loads the latest version):
46
61
 
62
+ Add the following to your Zed `settings.json`:
63
+
47
64
  ```json
48
65
  "agent_servers": {
49
66
  "pi": {
@@ -96,14 +113,12 @@ Point your ACP client to the built `dist/index.js`:
96
113
 
97
114
  `pi-acp` supports slash commands:
98
115
 
99
- #### 1) File-based commands (compatible with pi)
116
+ #### 1) File-based commands (aka prompts)
100
117
 
101
118
  Loaded from:
102
119
 
103
- - User commands: `~/.pi/agent/commands/**/*.md`
104
- - Project commands: `<cwd>/.pi/commands/**/*.md`
105
-
106
- These are expanded adapter-side (pi RPC mode doesn’t expand them).
120
+ - User commands: `~/.pi/agent/prompts/**/*.md`
121
+ - Project commands: `<cwd>/.pi/prompts/**/*.md`
107
122
 
108
123
  #### 2) Built-in commands
109
124
 
package/dist/index.js CHANGED
@@ -84,6 +84,7 @@ import { isAbsolute, resolve as resolvePath } from "path";
84
84
  // src/pi-rpc/process.ts
85
85
  import { spawn } from "child_process";
86
86
  import * as readline from "readline";
87
+ import { platform } from "os";
87
88
  var PiRpcSpawnError = class extends Error {
88
89
  /** Underlying spawn error code, e.g. ENOENT, EACCES */
89
90
  code;
@@ -145,8 +146,9 @@ var PiRpcProcess = class _PiRpcProcess {
145
146
  });
146
147
  }
147
148
  static async spawn(params) {
148
- const cmd = params.piCommand ?? "pi";
149
- const args = ["--mode", "rpc"];
149
+ const isWindows = platform() === "win32";
150
+ const cmd = params.piCommand ?? (isWindows ? "pi.cmd" : "pi");
151
+ const args = ["--mode", "rpc", "--no-themes"];
150
152
  if (params.sessionPath) args.push("--session", params.sessionPath);
151
153
  const child = spawn(cmd, args, {
152
154
  cwd: params.cwd,
@@ -524,9 +526,31 @@ function expandSlashCommand(text, fileCommands) {
524
526
  }
525
527
 
526
528
  // src/acp/session.ts
529
+ function findUniqueLineNumber(text, needle) {
530
+ if (!needle) return void 0;
531
+ const first = text.indexOf(needle);
532
+ if (first < 0) return void 0;
533
+ const second = text.indexOf(needle, first + needle.length);
534
+ if (second >= 0) return void 0;
535
+ let line = 1;
536
+ for (let i = 0; i < first; i += 1) {
537
+ if (text.charCodeAt(i) === 10) line += 1;
538
+ }
539
+ return line;
540
+ }
541
+ function toToolCallLocations(args, cwd, line) {
542
+ const path = typeof args?.path === "string" ? args.path : void 0;
543
+ if (!path) return void 0;
544
+ const resolvedPath = isAbsolute(path) ? path : resolvePath(cwd, path);
545
+ return [{ path: resolvedPath, ...typeof line === "number" ? { line } : {} }];
546
+ }
527
547
  var SessionManager = class {
528
548
  sessions = /* @__PURE__ */ new Map();
529
549
  store = new SessionStore();
550
+ /** Dispose all sessions and their underlying pi subprocesses. */
551
+ disposeAll() {
552
+ for (const [id] of this.sessions) this.close(id);
553
+ }
530
554
  /** Get a registered session if it exists (no throw). */
531
555
  maybeGet(sessionId) {
532
556
  return this.sessions.get(sessionId);
@@ -544,6 +568,13 @@ var SessionManager = class {
544
568
  }
545
569
  this.sessions.delete(sessionId);
546
570
  }
571
+ /** Close all sessions except the one with `keepSessionId`. */
572
+ closeAllExcept(keepSessionId) {
573
+ for (const [id] of this.sessions) {
574
+ if (id === keepSessionId) continue;
575
+ this.close(id);
576
+ }
577
+ }
547
578
  async create(params) {
548
579
  let proc;
549
580
  try {
@@ -658,13 +689,6 @@ var PiAcpSession = class {
658
689
  });
659
690
  }
660
691
  async prompt(message, images = []) {
661
- if (!this.startupInfoSent && this.startupInfo) {
662
- this.startupInfoSent = true;
663
- this.emit({
664
- sessionUpdate: "agent_message_chunk",
665
- content: { type: "text", text: this.startupInfo }
666
- });
667
- }
668
692
  const expandedMessage = expandSlashCommand(message, this.fileCommands);
669
693
  const turnPromise = new Promise((resolve3, reject) => {
670
694
  const queued = { message: expandedMessage, images, resolve: resolve3, reject };
@@ -757,6 +781,13 @@ var PiAcpSession = class {
757
781
  });
758
782
  break;
759
783
  }
784
+ if (ame?.type === "thinking_delta" && typeof ame.delta === "string") {
785
+ this.emit({
786
+ sessionUpdate: "agent_thought_chunk",
787
+ content: { type: "text", text: ame.delta }
788
+ });
789
+ break;
790
+ }
760
791
  if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") {
761
792
  const toolCall = (
762
793
  // pi sometimes includes the tool call directly on the event
@@ -775,6 +806,7 @@ var PiAcpSession = class {
775
806
  return { partialArgs: s };
776
807
  }
777
808
  })();
809
+ const locations = toToolCallLocations(rawInput, this.cwd);
778
810
  const existingStatus = this.currentToolCalls.get(toolCallId);
779
811
  const status = existingStatus ?? "pending";
780
812
  if (!existingStatus) {
@@ -785,6 +817,7 @@ var PiAcpSession = class {
785
817
  title: toolName,
786
818
  kind: toToolKind(toolName),
787
819
  status,
820
+ locations,
788
821
  rawInput
789
822
  });
790
823
  } else {
@@ -792,6 +825,7 @@ var PiAcpSession = class {
792
825
  sessionUpdate: "tool_call_update",
793
826
  toolCallId,
794
827
  status,
828
+ locations,
795
829
  rawInput
796
830
  });
797
831
  }
@@ -804,6 +838,7 @@ var PiAcpSession = class {
804
838
  const toolCallId = String(ev.toolCallId ?? crypto.randomUUID());
805
839
  const toolName = String(ev.toolName ?? "tool");
806
840
  const args = ev.args;
841
+ let line;
807
842
  if (toolName === "edit") {
808
843
  const p = typeof args?.path === "string" ? args.path : void 0;
809
844
  if (p) {
@@ -811,10 +846,13 @@ var PiAcpSession = class {
811
846
  const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p);
812
847
  const oldText = readFileSync3(abs, "utf8");
813
848
  this.editSnapshots.set(toolCallId, { path: p, oldText });
849
+ const needle = typeof args?.oldText === "string" ? args.oldText : "";
850
+ line = findUniqueLineNumber(oldText, needle);
814
851
  } catch {
815
852
  }
816
853
  }
817
854
  }
855
+ const locations = toToolCallLocations(args, this.cwd, line);
818
856
  if (!this.currentToolCalls.has(toolCallId)) {
819
857
  this.currentToolCalls.set(toolCallId, "in_progress");
820
858
  this.emit({
@@ -823,6 +861,7 @@ var PiAcpSession = class {
823
861
  title: toolName,
824
862
  kind: toToolKind(toolName),
825
863
  status: "in_progress",
864
+ locations,
826
865
  rawInput: args
827
866
  });
828
867
  } else {
@@ -831,6 +870,7 @@ var PiAcpSession = class {
831
870
  sessionUpdate: "tool_call_update",
832
871
  toolCallId,
833
872
  status: "in_progress",
873
+ locations,
834
874
  rawInput: args
835
875
  });
836
876
  }
@@ -1457,6 +1497,9 @@ var PiAcpAgent = class {
1457
1497
  conn;
1458
1498
  sessions = new SessionManager();
1459
1499
  store = new SessionStore();
1500
+ dispose() {
1501
+ this.sessions.disposeAll();
1502
+ }
1460
1503
  // Remember recent session cwd and use it as the default filter.
1461
1504
  lastSessionCwd = null;
1462
1505
  constructor(conn, _config) {
@@ -1514,12 +1557,21 @@ var PiAcpAgent = class {
1514
1557
  fileCommands,
1515
1558
  piCommand: process.env.PI_ACP_PI_COMMAND
1516
1559
  });
1517
- let rawModelsCount = 0;
1518
- try {
1519
- const data = await session.proc.getAvailableModels();
1520
- rawModelsCount = Array.isArray(data?.models) ? data.models.length : 0;
1521
- } catch {
1522
- }
1560
+ let state = null;
1561
+ let availableModels = null;
1562
+ await Promise.all([
1563
+ session.proc.getState().then((s) => {
1564
+ state = s;
1565
+ }).catch(() => {
1566
+ state = null;
1567
+ }),
1568
+ session.proc.getAvailableModels().then((m) => {
1569
+ availableModels = m;
1570
+ }).catch(() => {
1571
+ availableModels = null;
1572
+ })
1573
+ ]);
1574
+ const rawModelsCount = Array.isArray(availableModels?.models) ? availableModels.models.length : 0;
1523
1575
  if (rawModelsCount === 0) {
1524
1576
  try {
1525
1577
  session.proc.dispose?.();
@@ -1530,8 +1582,8 @@ var PiAcpAgent = class {
1530
1582
  "Configure an API key or log in with an OAuth provider."
1531
1583
  );
1532
1584
  }
1533
- const models = await getModelState(session.proc);
1534
- const thinking = await getThinkingState(session.proc);
1585
+ const models = await getModelState(session.proc, { state, availableModels });
1586
+ const thinking = await getThinkingState(session.proc, { state });
1535
1587
  const quietStartup = getQuietStartup(params.cwd);
1536
1588
  const updateNotice = buildUpdateNotice();
1537
1589
  const preludeText = quietStartup ? updateNotice ? updateNotice + "\n" : "" : buildStartupInfo({
@@ -1539,7 +1591,9 @@ var PiAcpAgent = class {
1539
1591
  fileCommands,
1540
1592
  updateNotice
1541
1593
  });
1542
- if (preludeText) session.setStartupInfo(preludeText);
1594
+ if (preludeText)
1595
+ session.setStartupInfo(preludeText);
1596
+ this.sessions.closeAllExcept?.(session.sessionId);
1543
1597
  const response = {
1544
1598
  sessionId: session.sessionId,
1545
1599
  models,
@@ -2010,6 +2064,7 @@ ${JSON.stringify(stats, null, 2)}`;
2010
2064
  proc,
2011
2065
  fileCommands
2012
2066
  });
2067
+ this.sessions.closeAllExcept?.(session.sessionId);
2013
2068
  this.store.upsert({
2014
2069
  sessionId: params.sessionId,
2015
2070
  cwd: params.cwd,
@@ -2157,14 +2212,17 @@ ${JSON.stringify(stats, null, 2)}`;
2157
2212
  function isThinkingLevel(x) {
2158
2213
  return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh";
2159
2214
  }
2160
- async function getThinkingState(proc) {
2215
+ async function getThinkingState(proc, pre) {
2161
2216
  let current = "medium";
2162
- try {
2163
- const state = await proc.getState();
2164
- const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null;
2165
- if (tl && isThinkingLevel(tl)) current = tl;
2166
- } catch {
2167
- }
2217
+ const state = pre?.state ?? await (async () => {
2218
+ try {
2219
+ return await proc.getState();
2220
+ } catch {
2221
+ return null;
2222
+ }
2223
+ })();
2224
+ const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null;
2225
+ if (tl && isThinkingLevel(tl)) current = tl;
2168
2226
  const available = ["off", "minimal", "low", "medium", "high", "xhigh"];
2169
2227
  return {
2170
2228
  currentModeId: current,
@@ -2175,34 +2233,40 @@ async function getThinkingState(proc) {
2175
2233
  }))
2176
2234
  };
2177
2235
  }
2178
- async function getModelState(proc) {
2236
+ async function getModelState(proc, pre) {
2179
2237
  let availableModels = [];
2180
- try {
2181
- const data = await proc.getAvailableModels();
2182
- const models = Array.isArray(data?.models) ? data.models : [];
2183
- availableModels = models.map((m) => {
2184
- const provider = String(m?.provider ?? "").trim();
2185
- const id = String(m?.id ?? "").trim();
2186
- if (!provider || !id) return null;
2187
- const name = String(m?.name ?? id);
2188
- return {
2189
- modelId: `${provider}/${id}`,
2190
- name: `${provider}/${name}`,
2191
- description: null
2192
- };
2193
- }).filter(Boolean);
2194
- } catch {
2195
- }
2238
+ const data = pre?.availableModels ?? await (async () => {
2239
+ try {
2240
+ return await proc.getAvailableModels();
2241
+ } catch {
2242
+ return null;
2243
+ }
2244
+ })();
2245
+ const models = Array.isArray(data?.models) ? data.models : [];
2246
+ availableModels = models.map((m) => {
2247
+ const provider = String(m?.provider ?? "").trim();
2248
+ const id = String(m?.id ?? "").trim();
2249
+ if (!provider || !id) return null;
2250
+ const name = String(m?.name ?? id);
2251
+ return {
2252
+ modelId: `${provider}/${id}`,
2253
+ name: `${provider}/${name}`,
2254
+ description: null
2255
+ };
2256
+ }).filter(Boolean);
2196
2257
  let currentModelId = null;
2197
- try {
2198
- const state = await proc.getState();
2199
- const model = state?.model;
2200
- if (model && typeof model === "object") {
2201
- const provider = String(model.provider ?? "").trim();
2202
- const id = String(model.id ?? "").trim();
2203
- if (provider && id) currentModelId = `${provider}/${id}`;
2258
+ const state = pre?.state ?? await (async () => {
2259
+ try {
2260
+ return await proc.getState();
2261
+ } catch {
2262
+ return null;
2204
2263
  }
2205
- } catch {
2264
+ })();
2265
+ const model = state?.model;
2266
+ if (model && typeof model === "object") {
2267
+ const provider = String(model.provider ?? "").trim();
2268
+ const id = String(model.id ?? "").trim();
2269
+ if (provider && id) currentModelId = `${provider}/${id}`;
2206
2270
  }
2207
2271
  if (!availableModels.length && !currentModelId) return null;
2208
2272
  if (!currentModelId) currentModelId = availableModels[0]?.modelId ?? "default";
@@ -2369,9 +2433,11 @@ function readNearestPackageJson(metaUrl) {
2369
2433
  }
2370
2434
 
2371
2435
  // src/index.ts
2436
+ import { platform as platform2 } from "os";
2372
2437
  if (process.argv.includes("--terminal-login")) {
2373
2438
  const { spawnSync: spawnSync2 } = await import("child_process");
2374
- const cmd = process.env.PI_ACP_PI_COMMAND ?? "pi";
2439
+ const isWindows = platform2() === "win32";
2440
+ const cmd = process.env.PI_ACP_PI_COMMAND ?? (isWindows ? "pi.cmd" : "pi");
2375
2441
  const res = spawnSync2(cmd, [], { stdio: "inherit", env: process.env });
2376
2442
  if (res.error && res.error.code === "ENOENT") {
2377
2443
  process.stderr.write(
@@ -2405,10 +2471,23 @@ var output = new ReadableStream({
2405
2471
  }
2406
2472
  });
2407
2473
  var stream = ndJsonStream(input, output);
2408
- new AgentSideConnection((conn) => new PiAcpAgent(conn), stream);
2474
+ var agent = new AgentSideConnection((conn) => new PiAcpAgent(conn), stream);
2475
+ function shutdown() {
2476
+ try {
2477
+ ;
2478
+ agent?.agent?.dispose?.();
2479
+ } catch {
2480
+ }
2481
+ try {
2482
+ process.exit(0);
2483
+ } catch {
2484
+ }
2485
+ }
2486
+ process.stdin.on("end", shutdown);
2487
+ process.stdin.on("close", shutdown);
2409
2488
  process.stdin.resume();
2410
- process.on("SIGINT", () => process.exit(0));
2411
- process.on("SIGTERM", () => process.exit(0));
2489
+ process.on("SIGINT", shutdown);
2490
+ process.on("SIGTERM", shutdown);
2412
2491
  process.stdout.on("error", () => {
2413
2492
  try {
2414
2493
  process.exit(0);