pi-open-agents 0.1.27 → 0.1.29

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
@@ -398,7 +398,7 @@ opening an issue or pull request.
398
398
 
399
399
  ```bash
400
400
  npm install
401
- npm test # 178 tests
401
+ npm test # 197 tests
402
402
  npm run typecheck # tsc --noEmit
403
403
  ```
404
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-agents",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -261,8 +261,8 @@ export { matchPattern, isWildcardPattern } from "./permission/matcher.ts";
261
261
  export { evaluate, toolToPermission, getDisabledTools, filterDisabledTools } from "./permission/evaluator.ts";
262
262
 
263
263
  // Subagent re-exports
264
- export { runSubagent, resolveSubagentSession, resolvePiEntryPoint } from "./subagent/executor.ts";
265
- export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode } from "./subagent/executor.ts";
264
+ export { runSubagent, resolveSubagentSession, resolvePiEntryPoint, isVirtualBunPath, resolveCompiledCommand } from "./subagent/executor.ts";
265
+ export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode, HostProcessProbe } from "./subagent/executor.ts";
266
266
  export { resolveSkills } from "./subagent/skills.ts";
267
267
  export type { ResolvedSkill } from "./subagent/skills.ts";
268
268
  export { buildSubagentPrompt } from "./subagent/prompt.ts";
@@ -227,9 +227,95 @@ export function subagentSessionDir(
227
227
  return path.join(agentDir, "sessions", safeProject, "subagents");
228
228
  }
229
229
 
230
+ /**
231
+ * Detect Bun's in-memory virtual paths (issue #28).
232
+ *
233
+ * Inside a `bun build --compile` single-file binary, `process.argv[1]` (and on
234
+ * some platforms `process.execPath`) resolves to a compile-time virtual entry
235
+ * such as `/$bunfs/root/pi`. That path does not exist on disk — any
236
+ * `realpathSync`/`lstat` on it throws ENOENT. A real on-disk executable path
237
+ * never contains these markers, so the check has no false positives.
238
+ */
239
+ export function isVirtualBunPath(candidate: string | undefined | null): boolean {
240
+ if (!candidate) return false;
241
+ return (
242
+ candidate.includes("$bunfs") || candidate.includes("~BUN") || candidate.includes("%7EBUN")
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Injectable host-process values for {@link resolvePiEntryPoint}.
248
+ *
249
+ * The production default reads `process.execPath` / `process.argv` /
250
+ * `/proc/self/exe` directly. Tests inject exact strings (including the
251
+ * virtual `/$bunfs/...` paths a compiled host reports) without spawning
252
+ * a real compiled binary.
253
+ */
254
+ export interface HostProcessProbe {
255
+ execPath?: string;
256
+ argv0?: string;
257
+ argv1?: string;
258
+ readProcSelfExe?: () => string;
259
+ }
260
+
261
+ /**
262
+ * Pick the real executable for a compiled single-file host (issue #28).
263
+ *
264
+ * Priority order (most reliable first):
265
+ * 1. `/proc/self/exe` (kernel truth on Linux),
266
+ * 2. `process.execPath` (real in every observed compiled binary),
267
+ * 3. `process.argv[0]` — ONLY when absolute. A compiled Bun binary reports
268
+ * the bare word `"bun"` here, which must never become the spawn command
269
+ * (it would resolve via PATH to the Bun runtime instead of pi).
270
+ *
271
+ * Returns `undefined` when every candidate is missing or virtual — the caller
272
+ * turns that into a descriptive error instead of spawning a `$bunfs` path.
273
+ */
274
+ export function resolveCompiledCommand(probe: {
275
+ execPath?: string;
276
+ argv0?: string;
277
+ procSelfExe?: string | undefined;
278
+ }): string | undefined {
279
+ for (const candidate of [probe.procSelfExe, probe.execPath]) {
280
+ if (candidate && !isVirtualBunPath(candidate)) return candidate;
281
+ }
282
+ const argv0 = probe.argv0;
283
+ if (argv0 && path.isAbsolute(argv0) && !isVirtualBunPath(argv0)) return argv0;
284
+ return undefined;
285
+ }
286
+
230
287
  export function resolvePiEntryPoint(
231
288
  resolveSelf: (specifier: string) => string = (specifier) => import.meta.resolve(specifier),
289
+ hostProbe?: HostProcessProbe,
232
290
  ): PiResolution {
291
+ const execPath = hostProbe?.execPath ?? process.execPath;
292
+ const argv0 = hostProbe?.argv0 ?? process.argv[0];
293
+ const argv1 = hostProbe?.argv1 ?? process.argv[1];
294
+
295
+ // Compiled single-file host (`bun build --compile`): the entry "file" is a
296
+ // virtual `$bunfs` path that does not exist on disk. The binary IS the CLI,
297
+ // so resolve the real executable and return an empty entry point — the args
298
+ // builder omits it and the child receives only real CLI flags.
299
+ if (isVirtualBunPath(execPath) || isVirtualBunPath(argv1)) {
300
+ let procSelfExe: string | undefined;
301
+ try {
302
+ procSelfExe = hostProbe?.readProcSelfExe
303
+ ? hostProbe.readProcSelfExe()
304
+ : realpathSync("/proc/self/exe");
305
+ } catch {
306
+ procSelfExe = undefined;
307
+ }
308
+ const command = resolveCompiledCommand({ execPath, argv0, procSelfExe });
309
+ if (!command) {
310
+ throw new Error(
311
+ "pi-open-agents: running inside a compiled single-file pi binary but the real " +
312
+ "executable path could not be determined (only virtual paths available). " +
313
+ "Subagent delegation is unavailable in this environment.",
314
+ );
315
+ }
316
+ return { command, entryPoint: "" };
317
+ }
318
+
233
319
  let packageEntryPoint: string;
234
320
  try {
235
321
  packageEntryPoint = fileURLToPath(resolveSelf("@earendil-works/pi-coding-agent"));
@@ -239,14 +325,21 @@ export function resolvePiEntryPoint(
239
325
  // resolution which cannot see the host package from the plugin's install
240
326
  // tree (peer dependencies are never installed next to it). Fall back to
241
327
  // the running pi entry itself — the child must run the same pi anyway.
328
+ if (!argv1) {
329
+ throw new Error(
330
+ "pi-open-agents: cannot resolve the pi CLI entry point (host package " +
331
+ "self-resolution failed and process.argv[1] is missing). " +
332
+ "Subagent delegation is unavailable in this environment.",
333
+ );
334
+ }
242
335
  return {
243
- command: process.execPath,
244
- entryPoint: realpathSync(process.argv[1]),
336
+ command: execPath,
337
+ entryPoint: realpathSync(argv1),
245
338
  };
246
339
  }
247
340
  const packageRoot = path.dirname(path.dirname(packageEntryPoint));
248
341
  return {
249
- command: process.execPath,
342
+ command: execPath,
250
343
  entryPoint: path.join(packageRoot, "dist", "cli.js"),
251
344
  };
252
345
  }
@@ -347,10 +440,28 @@ function usageFromMessage(
347
440
  };
348
441
  }
349
442
 
443
+ /**
444
+ * Extract the display model string from an assistant message (issue #27).
445
+ *
446
+ * `AssistantMessage` always carries both `provider` and `model`, so prefix the
447
+ * bare id (`provider/model`) to match the `model:` frontmatter format and the
448
+ * per-call override (`parseModelRef` splits on the first slash, so a
449
+ * namespaced id such as `anthropic/claude-opus-5.5` under `openrouter`
450
+ * round-trips exactly). No `provider` → bare id, as before.
451
+ *
452
+ * The `startsWith` guard prevents `provider/provider/...` when a host echoes
453
+ * an already-qualified id. Deliberately NOT `responseModel`: that is the
454
+ * provider-native answering snapshot (alias → dated version), a different
455
+ * granularity from "which provider ran the agent".
456
+ */
350
457
  function modelFromMessage(message: unknown): string | undefined {
351
458
  if (!message || typeof message !== "object") return undefined;
352
- const model = (message as { model?: unknown }).model;
353
- return typeof model === "string" ? model : undefined;
459
+ const data = message as { provider?: unknown; model?: unknown };
460
+ if (typeof data.model !== "string") return undefined;
461
+ if (typeof data.provider === "string" && !data.model.startsWith(`${data.provider}/`)) {
462
+ return `${data.provider}/${data.model}`;
463
+ }
464
+ return data.model;
354
465
  }
355
466
 
356
467
  function updateUsage(target: AgentUsage, update: Partial<AgentUsage> | undefined) {
@@ -542,8 +653,11 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
542
653
  const modelRegistry = buildModelRegistry(options.agentDir);
543
654
 
544
655
  // ── Build CLI args ──
656
+ // A compiled single-file host IS the CLI: its PiResolution carries an
657
+ // empty entryPoint (issue #28) and the child receives only real CLI
658
+ // flags. Non-compiled hosts keep runtime + script + flags, unchanged.
545
659
  const args = [
546
- pi.entryPoint,
660
+ ...(pi.entryPoint ? [pi.entryPoint] : []),
547
661
  "--mode", "json",
548
662
  "-p",
549
663
  "--no-skills",