@narumitw/pi-subagents 0.43.1 → 0.46.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.43.1",
3
+ "version": "0.46.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,168 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getPackageDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ const CORE_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
6
+ const MAX_DISPLAY_PATH_LENGTH = 500;
7
+
8
+ export interface PiInvocation {
9
+ command: string;
10
+ args: string[];
11
+ }
12
+
13
+ export interface PiInvocationRuntime {
14
+ execPath: string;
15
+ packageDir: string;
16
+ runtimeKind: "node" | "bun" | "unsupported";
17
+ }
18
+
19
+ export class PiInvocationError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = "PiInvocationError";
23
+ }
24
+ }
25
+
26
+ function displayPath(value: string): string {
27
+ const suffix = value.length > MAX_DISPLAY_PATH_LENGTH ? "…" : "";
28
+ return JSON.stringify(`${value.slice(0, MAX_DISPLAY_PATH_LENGTH)}${suffix}`);
29
+ }
30
+
31
+ function resolutionError(packageDir: string, reason: string): PiInvocationError {
32
+ return new PiInvocationError(
33
+ `Unable to resolve the Pi CLI from the loaded ${CORE_PACKAGE_NAME} package at ${displayPath(packageDir)}: ${reason}. Reinstall the matching Pi core package before using the subprocess transport.`,
34
+ );
35
+ }
36
+
37
+ function currentRuntime(): PiInvocationRuntime {
38
+ let packageDir: string;
39
+ try {
40
+ packageDir = getPackageDir();
41
+ } catch {
42
+ throw resolutionError("<unavailable>", "Pi core did not provide its package directory");
43
+ }
44
+ const runtimeKind = process.versions.bun
45
+ ? "bun"
46
+ : process.release.name === "node" && !process.versions.electron
47
+ ? "node"
48
+ : "unsupported";
49
+ return {
50
+ execPath: process.execPath,
51
+ packageDir,
52
+ runtimeKind,
53
+ };
54
+ }
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ function isWithinDirectory(parent: string, candidate: string): boolean {
61
+ const relative = path.relative(parent, candidate);
62
+ return (
63
+ relative === "" ||
64
+ (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
65
+ );
66
+ }
67
+
68
+ function readCoreManifest(packageDir: string): Record<string, unknown> {
69
+ const manifestPath = path.join(packageDir, "package.json");
70
+ let source: string;
71
+ try {
72
+ source = fs.readFileSync(manifestPath, "utf8");
73
+ } catch {
74
+ throw resolutionError(packageDir, "the package manifest is unavailable");
75
+ }
76
+ let manifest: unknown;
77
+ try {
78
+ manifest = JSON.parse(source);
79
+ } catch {
80
+ throw resolutionError(packageDir, "the package manifest is invalid JSON");
81
+ }
82
+ if (!isRecord(manifest)) {
83
+ throw resolutionError(packageDir, "the package manifest is invalid");
84
+ }
85
+ if (manifest.name !== CORE_PACKAGE_NAME) {
86
+ throw resolutionError(packageDir, "the package manifest has an unexpected package name");
87
+ }
88
+ return manifest;
89
+ }
90
+
91
+ function resolveDeclaredBin(packageDir: string, manifest: Record<string, unknown>): string {
92
+ const bin = manifest.bin;
93
+ const piBin = isRecord(bin) ? bin.pi : undefined;
94
+ if (typeof piBin !== "string" || !piBin.trim()) {
95
+ throw resolutionError(packageDir, "package.json bin.pi must be a non-empty string");
96
+ }
97
+ const candidate = path.resolve(packageDir, piBin);
98
+ if (path.isAbsolute(piBin) || !isWithinDirectory(packageDir, candidate)) {
99
+ throw resolutionError(packageDir, "the declared bin.pi target escapes the package directory");
100
+ }
101
+ return candidate;
102
+ }
103
+
104
+ function resolveExistingFile(packageDir: string, candidate: string, reason: string): string {
105
+ let resolved: string;
106
+ try {
107
+ resolved = fs.realpathSync(candidate);
108
+ if (!fs.statSync(resolved).isFile()) throw new Error("not a file");
109
+ } catch {
110
+ throw resolutionError(packageDir, reason);
111
+ }
112
+ if (!isWithinDirectory(packageDir, resolved)) {
113
+ throw resolutionError(packageDir, "the declared bin.pi target escapes the package directory");
114
+ }
115
+ return resolved;
116
+ }
117
+
118
+ function resolveStandaloneExecutable(
119
+ packageDir: string,
120
+ runtime: PiInvocationRuntime,
121
+ ): string | undefined {
122
+ if (runtime.runtimeKind !== "bun") return undefined;
123
+ const { execPath } = runtime;
124
+ if (!/^pi(?:\.exe)?$/i.test(path.basename(execPath))) return undefined;
125
+ let resolved: string;
126
+ let mode: number;
127
+ try {
128
+ resolved = fs.realpathSync(execPath);
129
+ const stat = fs.statSync(resolved);
130
+ if (!stat.isFile()) throw new Error("not a file");
131
+ mode = stat.mode;
132
+ } catch {
133
+ throw resolutionError(packageDir, "the standalone Pi executable is unavailable");
134
+ }
135
+ if (path.dirname(resolved) !== packageDir) return undefined;
136
+ if (process.platform !== "win32" && (mode & 0o111) === 0) {
137
+ throw resolutionError(packageDir, "the standalone Pi executable is not executable");
138
+ }
139
+ return resolved;
140
+ }
141
+
142
+ export function resolvePiInvocation(
143
+ args: string[],
144
+ runtime: PiInvocationRuntime = currentRuntime(),
145
+ ): PiInvocation {
146
+ let packageDir: string;
147
+ try {
148
+ packageDir = fs.realpathSync(runtime.packageDir);
149
+ if (!fs.statSync(packageDir).isDirectory()) throw new Error("not a directory");
150
+ } catch {
151
+ throw resolutionError(runtime.packageDir, "the package directory is unavailable");
152
+ }
153
+
154
+ const manifest = readCoreManifest(packageDir);
155
+ const declaredBin = resolveDeclaredBin(packageDir, manifest);
156
+ const standalone = resolveStandaloneExecutable(packageDir, runtime);
157
+ if (standalone) return { command: standalone, args: [...args] };
158
+
159
+ if (runtime.runtimeKind !== "node" && runtime.runtimeKind !== "bun") {
160
+ throw resolutionError(packageDir, "the host does not provide a supported Node or Bun runtime");
161
+ }
162
+ const cliPath = resolveExistingFile(
163
+ packageDir,
164
+ declaredBin,
165
+ "the declared bin.pi target is unavailable",
166
+ );
167
+ return { command: runtime.execPath, args: [cliPath, ...args] };
168
+ }
package/src/runner.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  MAX_SUBAGENT_TIMEOUT_MS,
17
17
  truncateUtf8,
18
18
  } from "./limits.js";
19
+ import { resolvePiInvocation } from "./pi-invocation.js";
19
20
  import { JsonLineDecoder } from "./protocol.js";
20
21
 
21
22
  export const KILL_GRACE_MS = 5000;
@@ -353,22 +354,6 @@ export function buildPiArgs(options: PiArgsOptions): string[] {
353
354
  return args;
354
355
  }
355
356
 
356
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
357
- const currentScript = process.argv[1];
358
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
359
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
360
- return { command: process.execPath, args: [currentScript, ...args] };
361
- }
362
-
363
- const execName = path.basename(process.execPath).toLowerCase();
364
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
365
- if (!isGenericRuntime) {
366
- return { command: process.execPath, args };
367
- }
368
-
369
- return { command: "pi", args };
370
- }
371
-
372
357
  function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
373
358
  if (process.platform !== "win32" && proc.pid) {
374
359
  try {
@@ -576,16 +561,26 @@ export async function runSingleAgent(
576
561
  systemPromptPath: tmpPromptPath ?? undefined,
577
562
  task,
578
563
  });
579
- let wasAborted = false;
580
- let timedOut = false;
581
-
582
- const exitCode = await new Promise<number>((resolve) => {
583
- const invocation = invocationOverride
564
+ let invocation: { command: string; args: string[] };
565
+ try {
566
+ invocation = invocationOverride
584
567
  ? {
585
568
  command: invocationOverride.command,
586
569
  args: [...(invocationOverride.argsPrefix ?? []), ...args],
587
570
  }
588
- : getPiInvocation(args);
571
+ : resolvePiInvocation(args);
572
+ } catch (error) {
573
+ currentResult.launchFailed = true;
574
+ currentResult.exitCode = 1;
575
+ currentResult.stderr = setErrorMessage(
576
+ error instanceof Error ? error.message : String(error),
577
+ );
578
+ return currentResult;
579
+ }
580
+ let wasAborted = false;
581
+ let timedOut = false;
582
+
583
+ const exitCode = await new Promise<number>((resolve) => {
589
584
  let settled = false;
590
585
  let cleanupTermination: (() => void) | undefined;
591
586
  let timeout: NodeJS.Timeout | undefined;