@victor-software-house/pi-acp 0.13.1 → 0.14.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.
@@ -8,7 +8,7 @@ import { isAbsolute, join, resolve } from "node:path";
8
8
  import { Hono } from "hono";
9
9
  import { AgentSideConnection, RequestError, ndJsonStream } from "@agentclientprotocol/sdk";
10
10
  import { randomUUID } from "node:crypto";
11
- import { DefaultResourceLoader, SessionManager, createAgentSession, createReadToolDefinition, getAgentDir } from "@earendil-works/pi-coding-agent";
11
+ import { DefaultResourceLoader, SessionManager, createAgentSession, createBashToolDefinition, createReadToolDefinition, getAgentDir } from "@earendil-works/pi-coding-agent";
12
12
  import * as z from "zod";
13
13
  import { parse } from "yaml";
14
14
  import { $ } from "bun";
@@ -184,6 +184,78 @@ function resolveIdleMs() {
184
184
  return n * 1e3;
185
185
  }
186
186
  //#endregion
187
+ //#region src/acp/acp-bash-operations.ts
188
+ const POLL_INTERVAL_MS = 100;
189
+ const SHELL_PATH = "/bin/sh";
190
+ function createAcpBashOperations(deps) {
191
+ const { conn, getSessionId } = deps;
192
+ return { async exec(command, cwd, options) {
193
+ const sessionId = getSessionId();
194
+ if (sessionId === "") throw new Error("pi-acp acp-bash: sessionId not yet bound");
195
+ const env = options.env !== void 0 ? Object.entries(options.env).filter(([, v]) => v !== void 0).map(([name, value]) => ({
196
+ name,
197
+ value: String(value)
198
+ })) : [];
199
+ const createParams = {
200
+ sessionId,
201
+ command: SHELL_PATH,
202
+ args: ["-c", command],
203
+ cwd,
204
+ env
205
+ };
206
+ const terminal = await conn.createTerminal(createParams);
207
+ let lastOutputLen = 0;
208
+ let cancelled = false;
209
+ const abortHandler = () => {
210
+ cancelled = true;
211
+ terminal.kill().catch(() => {});
212
+ };
213
+ options.signal?.addEventListener("abort", abortHandler);
214
+ const pollLoop = async () => {
215
+ while (!cancelled) {
216
+ try {
217
+ const snap = await terminal.currentOutput();
218
+ if (snap.output.length > lastOutputLen) {
219
+ const delta = snap.output.slice(lastOutputLen);
220
+ lastOutputLen = snap.output.length;
221
+ options.onData(Buffer.from(delta, "utf8"));
222
+ }
223
+ if (snap.exitStatus !== null && snap.exitStatus !== void 0) return;
224
+ } catch {
225
+ return;
226
+ }
227
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
228
+ }
229
+ };
230
+ const timeoutPromise = options.timeout !== void 0 && options.timeout > 0 ? new Promise((resolve) => setTimeout(() => resolve({ timedOut: true }), options.timeout)) : null;
231
+ try {
232
+ const pollPromise = pollLoop();
233
+ const exitPromise = terminal.waitForExit();
234
+ const winner = timeoutPromise !== null ? await Promise.race([exitPromise, timeoutPromise]) : await exitPromise;
235
+ let exitCode;
236
+ if ("timedOut" in winner) {
237
+ await terminal.kill().catch(() => {});
238
+ exitCode = (await terminal.waitForExit()).exitCode ?? null;
239
+ } else exitCode = winner.exitCode ?? null;
240
+ cancelled = true;
241
+ await pollPromise;
242
+ try {
243
+ const final = await terminal.currentOutput();
244
+ if (final.output.length > lastOutputLen) {
245
+ const delta = final.output.slice(lastOutputLen);
246
+ options.onData(Buffer.from(delta, "utf8"));
247
+ }
248
+ } catch {}
249
+ return { exitCode };
250
+ } finally {
251
+ options.signal?.removeEventListener("abort", abortHandler);
252
+ try {
253
+ await terminal.release();
254
+ } catch {}
255
+ }
256
+ } };
257
+ }
258
+ //#endregion
187
259
  //#region src/acp/acp-read-operations.ts
188
260
  function createAcpReadOperations(deps) {
189
261
  const { conn, getSessionId } = deps;
@@ -263,41 +335,66 @@ function detectAuthError(err) {
263
335
  }
264
336
  //#endregion
265
337
  //#region src/acp/client-capabilities.ts
266
- /**
267
- * Extract well-known capability flags from ACP `ClientCapabilities`.
268
- *
269
- * Reads from:
270
- * - `_meta.terminal_output` (terminal output rendering)
271
- * - `_meta.terminal-auth` (terminal auth with command metadata)
272
- * - `auth._meta.gateway` (gateway auth, future use)
273
- */
274
338
  function parseClientCapabilities(caps) {
275
- if (caps === void 0 || caps === null) return {
276
- terminalOutput: false,
277
- terminalAuth: false,
278
- gatewayAuth: false,
279
- fsReadTextFile: false
280
- };
281
- const meta = caps._meta;
282
- const terminalOutput = typeof meta === "object" && meta !== null && meta["terminal_output"] === true;
283
- const terminalAuth = typeof meta === "object" && meta !== null && meta["terminal-auth"] === true;
284
- let gatewayAuth = false;
285
- if ("auth" in caps) {
286
- const auth = caps.auth;
287
- if (typeof auth === "object" && auth !== null && "_meta" in auth) {
288
- const authMeta = auth._meta;
289
- if (typeof authMeta === "object" && authMeta !== null && "gateway" in authMeta) gatewayAuth = authMeta["gateway"] === true;
290
- }
291
- }
292
- const fsReadTextFile = caps.fs?.readTextFile === true;
339
+ const safe = caps ?? {};
340
+ const meta = safe._meta;
341
+ const metaIsObject = typeof meta === "object" && meta !== null;
342
+ const authMeta = "auth" in safe && typeof safe.auth === "object" && safe.auth !== null && "_meta" in safe.auth ? safe.auth._meta : void 0;
343
+ const authMetaIsObject = typeof authMeta === "object" && authMeta !== null;
293
344
  return {
294
- terminalOutput,
295
- terminalAuth,
296
- gatewayAuth,
297
- fsReadTextFile
345
+ terminalOutput: metaIsObject && meta["terminal_output"] === true,
346
+ terminalAuth: metaIsObject && meta["terminal-auth"] === true,
347
+ gatewayAuth: authMetaIsObject && authMeta["gateway"] === true,
348
+ fsReadTextFile: safe.fs?.readTextFile === true,
349
+ terminal: safe.terminal === true
298
350
  };
299
351
  }
300
352
  //#endregion
353
+ //#region src/acp/ext-methods.ts
354
+ /**
355
+ * ACP `extMethod` / `extNotification` dispatcher.
356
+ *
357
+ * ACP spec recommends prefixing extension method names with a unique
358
+ * identifier (e.g., a domain name). pi-acp uses the `pi-acp/` prefix for
359
+ * its built-ins; client-defined methods can also be routed here by
360
+ * registering handlers via `register()`.
361
+ *
362
+ * Unknown request methods throw `RequestError.methodNotFound`. Unknown
363
+ * notification methods are silently ignored per JSON-RPC 2.0 semantics —
364
+ * notifications have no response channel, so erroring is meaningless.
365
+ */
366
+ var ExtMethodDispatcher = class {
367
+ requestHandlers = /* @__PURE__ */ new Map();
368
+ notificationHandlers = /* @__PURE__ */ new Map();
369
+ constructor(deps) {
370
+ this.requestHandlers.set("pi-acp/ping", () => ({
371
+ ok: true,
372
+ ts: Date.now()
373
+ }));
374
+ this.requestHandlers.set("pi-acp/runtime-info", () => ({
375
+ version: deps.version,
376
+ uptimeMs: Date.now() - deps.startedAt,
377
+ sessionCount: deps.sessionCount()
378
+ }));
379
+ }
380
+ register(method, handler) {
381
+ this.requestHandlers.set(method, handler);
382
+ }
383
+ registerNotification(method, handler) {
384
+ this.notificationHandlers.set(method, handler);
385
+ }
386
+ async handleRequest(method, params) {
387
+ const handler = this.requestHandlers.get(method);
388
+ if (handler === void 0) throw RequestError.methodNotFound(method);
389
+ return await handler(params);
390
+ }
391
+ async handleNotification(method, params) {
392
+ const handler = this.notificationHandlers.get(method);
393
+ if (handler === void 0) return;
394
+ await handler(params);
395
+ }
396
+ };
397
+ //#endregion
301
398
  //#region src/acp/model-alias.ts
302
399
  /**
303
400
  * Tokenize a string: split on non-alphanumeric, lowercase, strip "claude".
@@ -856,6 +953,17 @@ var SessionManager$1 = class {
856
953
  if (!s) throw RequestError.invalidParams(`Unknown sessionId: ${sessionId}`);
857
954
  return s;
858
955
  }
956
+ size() {
957
+ return this.sessions.size;
958
+ }
959
+ values() {
960
+ return this.sessions.values();
961
+ }
962
+ /** First registered session, or undefined. Order = insertion order. */
963
+ first() {
964
+ const it = this.sessions.values().next();
965
+ return it.done === true ? void 0 : it.value;
966
+ }
859
967
  };
860
968
  var PiAcpSession = class {
861
969
  sessionId;
@@ -2007,7 +2115,7 @@ var SshBackend = class {
2007
2115
  //#endregion
2008
2116
  //#region package.json
2009
2117
  var name = "@victor-software-house/pi-acp";
2010
- var version = "0.13.1";
2118
+ var version = "0.14.0";
2011
2119
  //#endregion
2012
2120
  //#region src/acp/agent.ts
2013
2121
  /** Builtin ACP slash commands handled directly by the adapter. */
@@ -2097,11 +2205,14 @@ var PiAcpAgent = class {
2097
2205
  terminalOutput: false,
2098
2206
  terminalAuth: false,
2099
2207
  gatewayAuth: false,
2100
- fsReadTextFile: false
2208
+ fsReadTextFile: false,
2209
+ terminal: false
2101
2210
  };
2102
2211
  daemonContext;
2103
2212
  /** Unique ID for this ACP connection. Used as the ownership key in the daemon SessionRegistry. */
2104
2213
  connectionId = randomUUID();
2214
+ extMethods;
2215
+ startedAt = Date.now();
2105
2216
  dispose() {
2106
2217
  if (this.daemonContext !== void 0) {
2107
2218
  const registry = this.daemonContext.sessionRegistry;
@@ -2114,6 +2225,17 @@ var PiAcpAgent = class {
2114
2225
  constructor(conn, daemonContext) {
2115
2226
  this.conn = conn;
2116
2227
  this.daemonContext = daemonContext;
2228
+ this.extMethods = new ExtMethodDispatcher({
2229
+ version,
2230
+ startedAt: this.startedAt,
2231
+ sessionCount: () => this.sessions.size()
2232
+ });
2233
+ }
2234
+ async extMethod(method, params) {
2235
+ return this.extMethods.handleRequest(method, params);
2236
+ }
2237
+ async extNotification(method, params) {
2238
+ await this.extMethods.handleNotification(method, params);
2117
2239
  }
2118
2240
  registerWithDaemon(input) {
2119
2241
  if (this.daemonContext === void 0) return;
@@ -2213,26 +2335,43 @@ var PiAcpAgent = class {
2213
2335
  };
2214
2336
  }
2215
2337
  /**
2216
- * PRD-002 §FR-6 — `read` tool ACP-FS delegation overlay.
2338
+ * PRD-002 §FR-6 + §FR-6.5 — tool overrides for ACP-FS read + ACP terminal bash.
2217
2339
  *
2218
- * When the client advertises `fs.readTextFile`, we override pi's
2219
- * built-in `read` with a custom `read` tool that proxies to
2220
- * `connection.fs.readTextFile`. The allowlist MUST include "read" so
2221
- * pi's customTool registration loop (which filters by name) can
2222
- * register the override; the override then shadows the builtin via
2223
- * the tool-definition `Map.set` path inside AgentSession.
2340
+ * For each tool we override, the allowlist MUST include the original
2341
+ * tool name so pi's customTool registration loop (which filters by
2342
+ * name) can register the override; the override then shadows the
2343
+ * builtin via the tool-definition `Map.set` path inside AgentSession
2344
+ * (verified against pi source agent-session.js:1811).
2224
2345
  *
2225
- * The sessionId ref is mutated by the caller right after
2226
- * `createAgentSession` returns, before any model turn the tool
2227
- * isn't invoked until prompt-time, so the late binding is safe.
2346
+ * SessionId binding is late: pi mints the id inside `createAgentSession`
2347
+ * (after `customTools` is built), so we share a single mutable ref
2348
+ * across all overrides. The caller mutates `sessionIdRef.current`
2349
+ * right after createAgentSession returns, before any model turn — the
2350
+ * tools aren't invoked until prompt-time, so the late binding is safe.
2228
2351
  *
2229
- * Returns `null` when the client doesn't advertise the capability;
2230
- * callers then skip the overlay and pi's built-in `read` handles
2231
- * everything locally.
2352
+ * Returns `null` when neither capability is advertised; callers skip
2353
+ * the overlay and pi's built-in tools handle everything locally.
2232
2354
  */
2233
- buildAcpReadOverlay(cwd) {
2234
- if (!this.clientCapabilities.fsReadTextFile) return null;
2355
+ buildAcpToolOverlay(cwd) {
2356
+ const wantRead = this.clientCapabilities.fsReadTextFile;
2357
+ const wantBash = this.clientCapabilities.terminal;
2358
+ if (!wantRead && !wantBash) return null;
2235
2359
  const sessionIdRef = { current: "" };
2360
+ const customTools = [];
2361
+ if (wantRead) {
2362
+ const readToolDef = createReadToolDefinition(cwd, { operations: createAcpReadOperations({
2363
+ conn: this.conn,
2364
+ getSessionId: () => sessionIdRef.current
2365
+ }) });
2366
+ customTools.push(readToolDef);
2367
+ }
2368
+ if (wantBash) {
2369
+ const bashToolDef = createBashToolDefinition(cwd, { operations: createAcpBashOperations({
2370
+ conn: this.conn,
2371
+ getSessionId: () => sessionIdRef.current
2372
+ }) });
2373
+ customTools.push(bashToolDef);
2374
+ }
2236
2375
  return {
2237
2376
  sessionIdRef,
2238
2377
  tools: [
@@ -2244,10 +2383,7 @@ var PiAcpAgent = class {
2244
2383
  "find",
2245
2384
  "ls"
2246
2385
  ],
2247
- customTools: [createReadToolDefinition(cwd, { operations: createAcpReadOperations({
2248
- conn: this.conn,
2249
- getSessionId: () => sessionIdRef.current
2250
- }) })]
2386
+ customTools
2251
2387
  };
2252
2388
  }
2253
2389
  async initialize(params) {
@@ -2298,15 +2434,15 @@ var PiAcpAgent = class {
2298
2434
  sources: resourceLoader.listSources(),
2299
2435
  manifestDiagnostics
2300
2436
  }).text : "";
2301
- const acpReadOverlay = this.buildAcpReadOverlay(effectiveCwd);
2437
+ const acpToolOverlay = this.buildAcpToolOverlay(effectiveCwd);
2302
2438
  let result;
2303
2439
  try {
2304
2440
  result = await createAgentSession({
2305
2441
  cwd: effectiveCwd,
2306
2442
  resourceLoader,
2307
- ...acpReadOverlay ? {
2308
- tools: acpReadOverlay.tools,
2309
- customTools: acpReadOverlay.customTools
2443
+ ...acpToolOverlay ? {
2444
+ tools: acpToolOverlay.tools,
2445
+ customTools: acpToolOverlay.customTools
2310
2446
  } : {}
2311
2447
  });
2312
2448
  } catch (e) {
@@ -2317,7 +2453,7 @@ var PiAcpAgent = class {
2317
2453
  throw RequestError.internalError({}, `Failed to create pi session: ${msg}`);
2318
2454
  }
2319
2455
  const piSession = result.session;
2320
- if (acpReadOverlay !== null) acpReadOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2456
+ if (acpToolOverlay !== null) acpToolOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2321
2457
  if (piSession.modelRegistry.getAvailable().length === 0) {
2322
2458
  piSession.dispose();
2323
2459
  modeResult.cleanup();
@@ -2580,7 +2716,7 @@ var PiAcpAgent = class {
2580
2716
  this.sessions.close(params.sessionId);
2581
2717
  const sessionFile = await this.resolveSessionFile(params.sessionId);
2582
2718
  if (sessionFile === null) throw RequestError.invalidParams(`Unknown sessionId: ${params.sessionId}`);
2583
- const acpReadOverlay = this.buildAcpReadOverlay(params.cwd);
2719
+ const acpToolOverlay = this.buildAcpToolOverlay(params.cwd);
2584
2720
  let result;
2585
2721
  try {
2586
2722
  const sm = SessionManager.open(sessionFile);
@@ -2589,9 +2725,9 @@ var PiAcpAgent = class {
2589
2725
  cwd: params.cwd,
2590
2726
  sessionManager: sm,
2591
2727
  resourceLoader,
2592
- ...acpReadOverlay ? {
2593
- tools: acpReadOverlay.tools,
2594
- customTools: acpReadOverlay.customTools
2728
+ ...acpToolOverlay ? {
2729
+ tools: acpToolOverlay.tools,
2730
+ customTools: acpToolOverlay.customTools
2595
2731
  } : {}
2596
2732
  });
2597
2733
  } catch (e) {
@@ -2601,7 +2737,7 @@ var PiAcpAgent = class {
2601
2737
  throw RequestError.internalError({}, `Failed to load pi session: ${msg}`);
2602
2738
  }
2603
2739
  const piSession = result.session;
2604
- if (acpReadOverlay !== null) acpReadOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2740
+ if (acpToolOverlay !== null) acpToolOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2605
2741
  const session = new PiAcpSession({
2606
2742
  sessionId: params.sessionId,
2607
2743
  cwd: params.cwd,
@@ -2686,7 +2822,7 @@ var PiAcpAgent = class {
2686
2822
  }
2687
2823
  const sessionFile = await this.resolveSessionFile(params.sessionId);
2688
2824
  if (sessionFile === null) throw RequestError.invalidParams(`Unknown sessionId: ${params.sessionId}`);
2689
- const acpReadOverlay = this.buildAcpReadOverlay(params.cwd);
2825
+ const acpToolOverlay = this.buildAcpToolOverlay(params.cwd);
2690
2826
  let result;
2691
2827
  try {
2692
2828
  const sm = SessionManager.open(sessionFile);
@@ -2695,9 +2831,9 @@ var PiAcpAgent = class {
2695
2831
  cwd: params.cwd,
2696
2832
  sessionManager: sm,
2697
2833
  resourceLoader,
2698
- ...acpReadOverlay ? {
2699
- tools: acpReadOverlay.tools,
2700
- customTools: acpReadOverlay.customTools
2834
+ ...acpToolOverlay ? {
2835
+ tools: acpToolOverlay.tools,
2836
+ customTools: acpToolOverlay.customTools
2701
2837
  } : {}
2702
2838
  });
2703
2839
  } catch (e) {
@@ -2707,7 +2843,7 @@ var PiAcpAgent = class {
2707
2843
  throw RequestError.internalError({}, `Failed to resume pi session: ${msg}`);
2708
2844
  }
2709
2845
  const piSession = result.session;
2710
- if (acpReadOverlay !== null) acpReadOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2846
+ if (acpToolOverlay !== null) acpToolOverlay.sessionIdRef.current = piSession.sessionManager.getSessionId();
2711
2847
  const session = new PiAcpSession({
2712
2848
  sessionId: params.sessionId,
2713
2849
  cwd: params.cwd,
@@ -2751,7 +2887,7 @@ var PiAcpAgent = class {
2751
2887
  if (!isAbsolute(params.cwd)) throw RequestError.invalidParams(`cwd must be an absolute path: ${params.cwd}`);
2752
2888
  const sourceFile = await this.resolveSessionFile(params.sessionId);
2753
2889
  if (sourceFile === null) throw RequestError.invalidParams(`Unknown sessionId: ${params.sessionId}`);
2754
- const acpReadOverlay = this.buildAcpReadOverlay(params.cwd);
2890
+ const acpToolOverlay = this.buildAcpToolOverlay(params.cwd);
2755
2891
  let result;
2756
2892
  try {
2757
2893
  const sm = SessionManager.forkFrom(sourceFile, params.cwd);
@@ -2760,9 +2896,9 @@ var PiAcpAgent = class {
2760
2896
  cwd: params.cwd,
2761
2897
  sessionManager: sm,
2762
2898
  resourceLoader,
2763
- ...acpReadOverlay ? {
2764
- tools: acpReadOverlay.tools,
2765
- customTools: acpReadOverlay.customTools
2899
+ ...acpToolOverlay ? {
2900
+ tools: acpToolOverlay.tools,
2901
+ customTools: acpToolOverlay.customTools
2766
2902
  } : {}
2767
2903
  });
2768
2904
  } catch (e) {
@@ -2773,7 +2909,7 @@ var PiAcpAgent = class {
2773
2909
  }
2774
2910
  const piSession = result.session;
2775
2911
  const newSessionId = piSession.sessionManager.getSessionId();
2776
- if (acpReadOverlay !== null) acpReadOverlay.sessionIdRef.current = newSessionId;
2912
+ if (acpToolOverlay !== null) acpToolOverlay.sessionIdRef.current = newSessionId;
2777
2913
  const newSessionFile = piSession.sessionManager.getSessionFile();
2778
2914
  if (newSessionFile !== void 0) this.sessionPaths.set(newSessionId, newSessionFile);
2779
2915
  const session = new PiAcpSession({
@@ -3392,4 +3528,4 @@ async function runDaemon() {
3392
3528
  //#endregion
3393
3529
  export { runDaemon };
3394
3530
 
3395
- //# sourceMappingURL=daemon-BErbUhcE.mjs.map
3531
+ //# sourceMappingURL=daemon-CjPR14E_.mjs.map