pi-background-tasks 2.5.0 → 2.6.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.
Files changed (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -1,22 +1,37 @@
1
- import { readFileSync, realpathSync, statSync } from 'node:fs';
1
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import type { Stats } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
- import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
4
+ import {
5
+ basename,
6
+ delimiter,
7
+ dirname,
8
+ extname,
9
+ isAbsolute,
10
+ join,
11
+ relative,
12
+ resolve,
13
+ sep,
14
+ win32 as win32Path,
15
+ } from 'node:path';
5
16
  import { fileURLToPath } from 'node:url';
6
17
 
7
18
  export interface PiLaunchSpec {
8
19
  readonly executable: string;
9
20
  readonly argvPrefix: readonly string[];
10
- readonly kind: 'path' | 'package-node-cli';
21
+ readonly kind: 'path' | 'package-node-cli' | 'compiled-host';
11
22
  }
12
23
 
13
24
  export interface PiLaunchDependencies {
14
25
  readonly platform?: NodeJS.Platform;
15
26
  readonly execPath?: string;
27
+ readonly hostScript?: string;
28
+ readonly path?: string;
16
29
  readonly resolvePackageJson?: (specifier: string) => string;
30
+ readonly resolveModule?: (specifier: string) => string;
17
31
  readonly readFile?: (path: string) => string | Buffer;
18
32
  readonly realpath?: (path: string) => string;
19
33
  readonly stat?: (path: string) => Pick<Stats, 'isFile'>;
34
+ readonly access?: (path: string, mode?: number) => void;
20
35
  }
21
36
 
22
37
  export class PiLaunchResolutionError extends Error {
@@ -48,46 +63,63 @@ export class PiCommandLineLimitError extends Error {
48
63
  const PI_PACKAGE_NAME = '@earendil-works/pi-coding-agent';
49
64
  const PI_PACKAGE_MANIFEST = `${PI_PACKAGE_NAME}/package.json`;
50
65
  const WINDOWS_COMMAND_LINE_LIMIT = 32767;
66
+ const JAVASCRIPT_EXTENSIONS = new Set(['.js', '.cjs', '.mjs']);
67
+ const WINDOWS_NATIVE_EXTENSIONS = new Set(['.exe', '.com']);
51
68
 
52
69
  interface JsonRecord {
53
70
  readonly [key: string]: unknown;
54
71
  }
55
72
 
56
- function defaultResolvePackageJson(specifier: string): string {
57
- const requireForPi = createRequire(import.meta.url);
58
- try {
59
- return requireForPi.resolve(specifier);
60
- } catch (manifestError) {
61
- if (specifier !== PI_PACKAGE_MANIFEST) throw manifestError;
62
- let packageEntry: string;
63
- try {
64
- packageEntry = fileURLToPath(import.meta.resolve(PI_PACKAGE_NAME));
65
- } catch (entryError) {
66
- throw new Error(
67
- `${errorMessage(manifestError)}; package entry resolve failed: ${errorMessage(entryError)}`,
68
- );
69
- }
70
- const diagnostics: string[] = [];
71
- let dir = dirname(packageEntry);
72
- for (;;) {
73
- const candidate = join(dir, 'package.json');
74
- try {
75
- if (statSync(candidate).isFile()) return candidate;
76
- diagnostics.push(`${candidate} is not a regular file`);
77
- } catch (statError) {
78
- diagnostics.push(`${candidate}: ${errorMessage(statError)}`);
79
- }
80
- const parent = dirname(dir);
81
- if (parent === dir) {
82
- throw new Error(
83
- `${errorMessage(manifestError)}; package entry search failed: ${diagnostics.join('; ')}`,
84
- );
85
- }
86
- dir = parent;
87
- }
73
+ interface LaunchIo {
74
+ readonly readFile: (path: string) => string | Buffer;
75
+ readonly realpath: (path: string) => string;
76
+ readonly stat: (path: string) => Pick<Stats, 'isFile'>;
77
+ readonly access: (path: string, mode?: number) => void;
78
+ }
79
+
80
+ interface NamedManifest {
81
+ readonly manifestPath: string;
82
+ readonly sourceReal: string;
83
+ readonly name: string;
84
+ }
85
+
86
+ class HostIsNotPiError extends Error {
87
+ constructor(message: string) {
88
+ super(message);
89
+ this.name = 'HostIsNotPiError';
90
+ }
91
+ }
92
+
93
+ class LaunchPathOperationError extends Error {
94
+ readonly operation: string;
95
+ readonly path: string;
96
+ readonly pathCause: unknown;
97
+
98
+ constructor(operation: string, path: string, cause: unknown) {
99
+ super(`${operation} failed for ${path}: ${errorMessage(cause)}`, { cause });
100
+ this.name = 'LaunchPathOperationError';
101
+ this.operation = operation;
102
+ this.path = path;
103
+ this.pathCause = cause;
88
104
  }
89
105
  }
90
106
 
107
+ const nodeLaunchIo: LaunchIo = {
108
+ readFile: (path) => readFileSync(path),
109
+ realpath: (path) => realpathSync(path),
110
+ stat: (path) => statSync(path),
111
+ access: (path, mode) => accessSync(path, mode),
112
+ };
113
+
114
+ function launchIo(deps: PiLaunchDependencies): LaunchIo {
115
+ return {
116
+ readFile: deps.readFile ?? nodeLaunchIo.readFile,
117
+ realpath: deps.realpath ?? nodeLaunchIo.realpath,
118
+ stat: deps.stat ?? nodeLaunchIo.stat,
119
+ access: deps.access ?? nodeLaunchIo.access,
120
+ };
121
+ }
122
+
91
123
  function failResolution(message: string): never {
92
124
  throw new PiLaunchResolutionError(message);
93
125
  }
@@ -96,14 +128,20 @@ function errorMessage(error: unknown): string {
96
128
  return error instanceof Error ? error.message : String(error);
97
129
  }
98
130
 
99
- function readPath<T>(label: string, path: string, action: () => T): T {
131
+ function pathOperation<T>(label: string, path: string, action: () => T): T {
100
132
  try {
101
133
  return action();
102
134
  } catch (error) {
103
- failResolution(`${label} failed for ${path}: ${errorMessage(error)}`);
135
+ throw new LaunchPathOperationError(label, path, error);
104
136
  }
105
137
  }
106
138
 
139
+ function isMissingPathError(error: unknown): boolean {
140
+ if (typeof error !== 'object' || error === null) return false;
141
+ const code = Reflect.get(error, 'code');
142
+ return code === 'ENOENT' || code === 'ENOTDIR';
143
+ }
144
+
107
145
  function isJsonRecord(value: unknown): value is JsonRecord {
108
146
  return typeof value === 'object' && value !== null && !Array.isArray(value);
109
147
  }
@@ -113,12 +151,30 @@ function parseManifest(raw: string | Buffer, manifestPath: string): JsonRecord {
113
151
  try {
114
152
  parsed = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : raw);
115
153
  } catch (error) {
116
- failResolution(`manifest JSON is invalid at ${manifestPath}: ${errorMessage(error)}`);
154
+ throw new Error(`manifest JSON is invalid at ${manifestPath}: ${errorMessage(error)}`);
117
155
  }
118
- if (!isJsonRecord(parsed)) failResolution(`manifest is not an object at ${manifestPath}`);
156
+ if (!isJsonRecord(parsed)) throw new Error(`manifest is not an object at ${manifestPath}`);
119
157
  return parsed;
120
158
  }
121
159
 
160
+ function packageName(manifest: JsonRecord, manifestPath: string): string | undefined {
161
+ const name = manifest['name'];
162
+ if (name === undefined) return undefined;
163
+ if (typeof name !== 'string' || name.trim().length === 0) {
164
+ throw new Error(`manifest name is malformed at ${manifestPath}`);
165
+ }
166
+ return name;
167
+ }
168
+
169
+ function requirePiPackageName(manifest: JsonRecord, manifestPath: string): void {
170
+ const name = packageName(manifest, manifestPath);
171
+ if (name !== PI_PACKAGE_NAME) {
172
+ throw new Error(
173
+ `manifest package name must be ${PI_PACKAGE_NAME} at ${manifestPath}; received ${name === undefined ? '<missing>' : name}`,
174
+ );
175
+ }
176
+ }
177
+
122
178
  function readPiBin(manifest: JsonRecord, manifestPath: string): string {
123
179
  const bin = manifest['bin'];
124
180
  if (typeof bin === 'string' && bin.trim().length > 0) return bin;
@@ -126,54 +182,323 @@ function readPiBin(manifest: JsonRecord, manifestPath: string): string {
126
182
  const pi = bin['pi'];
127
183
  if (typeof pi === 'string' && pi.trim().length > 0) return pi;
128
184
  }
129
- failResolution(`manifest bin.pi is missing or malformed at ${manifestPath}`);
185
+ throw new Error(`manifest bin.pi is missing or malformed at ${manifestPath}`);
130
186
  }
131
187
 
132
188
  function pathInside(parent: string, child: string): boolean {
133
189
  const rel = relative(parent, child);
134
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
190
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
135
191
  }
136
192
 
137
- export function resolvePiLaunch(deps: PiLaunchDependencies = {}): PiLaunchSpec {
138
- const platform = deps.platform ?? process.platform;
139
- if (platform !== 'win32') return { executable: 'pi', argvPrefix: [], kind: 'path' };
193
+ function findNearestNamedManifest(sourcePath: string, io: LaunchIo): NamedManifest {
194
+ const sourceReal = pathOperation('source realpath', sourcePath, () => io.realpath(sourcePath));
195
+ let dir = dirname(sourceReal);
196
+ for (;;) {
197
+ const candidate = join(dir, 'package.json');
198
+ let candidateStat: Pick<Stats, 'isFile'>;
199
+ try {
200
+ candidateStat = io.stat(candidate);
201
+ } catch (error) {
202
+ if (!isMissingPathError(error)) {
203
+ throw new Error(`manifest stat failed for ${candidate}: ${errorMessage(error)}`);
204
+ }
205
+ const parent = dirname(dir);
206
+ if (parent === dir) {
207
+ throw new HostIsNotPiError(`no named package manifest above ${sourceReal}`);
208
+ }
209
+ dir = parent;
210
+ continue;
211
+ }
212
+ if (!candidateStat.isFile()) {
213
+ throw new Error(`package boundary manifest is not a regular file at ${candidate}`);
214
+ }
215
+ const manifest = parseManifest(
216
+ pathOperation('manifest read', candidate, () => io.readFile(candidate)),
217
+ candidate,
218
+ );
219
+ const name = packageName(manifest, candidate);
220
+ if (name !== undefined) return { manifestPath: candidate, sourceReal, name };
140
221
 
141
- const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson;
142
- const readFile = deps.readFile ?? readFileSync;
143
- const realpath = deps.realpath ?? realpathSync;
144
- const stat = deps.stat ?? statSync;
145
- const execPath = deps.execPath ?? process.execPath;
222
+ const parent = dirname(dir);
223
+ if (parent === dir) {
224
+ throw new HostIsNotPiError(`no named package manifest above ${sourceReal}`);
225
+ }
226
+ dir = parent;
227
+ }
228
+ }
146
229
 
147
- let manifestPath: string;
230
+ function findPiManifestFromHostScript(hostScript: string | undefined, io: LaunchIo): NamedManifest {
231
+ if (!hostScript) throw new HostIsNotPiError('host script path is unavailable');
232
+ let found: NamedManifest;
233
+ try {
234
+ found = findNearestNamedManifest(hostScript, io);
235
+ } catch (error) {
236
+ if (error instanceof HostIsNotPiError) throw error;
237
+ if (
238
+ error instanceof LaunchPathOperationError &&
239
+ error.operation === 'source realpath' &&
240
+ isMissingPathError(error.pathCause)
241
+ ) {
242
+ throw new HostIsNotPiError(error.message);
243
+ }
244
+ throw error;
245
+ }
246
+ if (found.name !== PI_PACKAGE_NAME) {
247
+ throw new HostIsNotPiError(
248
+ `nearest named manifest above host script belongs to ${found.name}: ${found.manifestPath}`,
249
+ );
250
+ }
251
+ return found;
252
+ }
253
+
254
+ /**
255
+ * Resolve only a genuine running Pi package. This helper performs package-boundary
256
+ * discovery; resolvePiLaunch() additionally validates bin.pi and host/bin identity.
257
+ */
258
+ export function resolvePiManifestFromHostScript(
259
+ hostScript: string | undefined,
260
+ deps: Pick<PiLaunchDependencies, 'readFile' | 'realpath' | 'stat' | 'access'> = {},
261
+ ): string {
262
+ return findPiManifestFromHostScript(hostScript, launchIo(deps)).manifestPath;
263
+ }
264
+
265
+ function normalizeResolvedModulePath(pathOrUrl: string): string {
266
+ return pathOrUrl.startsWith('file:') ? fileURLToPath(pathOrUrl) : pathOrUrl;
267
+ }
268
+
269
+ function defaultResolveModule(specifier: string): string {
270
+ if (specifier === PI_PACKAGE_MANIFEST) {
271
+ return createRequire(import.meta.url).resolve(specifier);
272
+ }
273
+ return normalizeResolvedModulePath(import.meta.resolve(specifier));
274
+ }
275
+
276
+ function resolvePiManifestFromModules(deps: PiLaunchDependencies, io: LaunchIo): string {
277
+ if (deps.resolvePackageJson) {
278
+ return normalizeResolvedModulePath(deps.resolvePackageJson(PI_PACKAGE_MANIFEST));
279
+ }
280
+
281
+ const resolveModule = deps.resolveModule ?? defaultResolveModule;
282
+ let manifestError: unknown;
148
283
  try {
149
- manifestPath = resolvePackageJson(PI_PACKAGE_MANIFEST);
284
+ return normalizeResolvedModulePath(resolveModule(PI_PACKAGE_MANIFEST));
150
285
  } catch (error) {
151
- failResolution(`package manifest resolve failed for ${PI_PACKAGE_MANIFEST}: ${errorMessage(error)}`);
286
+ manifestError = error;
152
287
  }
153
288
 
154
- const packageRoot = dirname(manifestPath);
155
- const packageRootReal = readPath('package root realpath', packageRoot, () => realpath(packageRoot));
289
+ let packageEntry: string;
290
+ try {
291
+ packageEntry = normalizeResolvedModulePath(resolveModule(PI_PACKAGE_NAME));
292
+ } catch (entryError) {
293
+ throw new Error(
294
+ `package manifest resolve failed for ${PI_PACKAGE_MANIFEST}: ${errorMessage(manifestError)}; package entry resolve failed: ${errorMessage(entryError)}`,
295
+ );
296
+ }
297
+
298
+ let found: NamedManifest;
299
+ try {
300
+ found = findNearestNamedManifest(packageEntry, io);
301
+ } catch (searchError) {
302
+ throw new Error(
303
+ `package manifest resolve failed for ${PI_PACKAGE_MANIFEST}: ${errorMessage(manifestError)}; package entry manifest search failed: ${errorMessage(searchError)}`,
304
+ );
305
+ }
306
+ if (found.name !== PI_PACKAGE_NAME) {
307
+ throw new Error(
308
+ `package entry nearest named manifest belongs to ${found.name}: ${found.manifestPath}`,
309
+ );
310
+ }
311
+ return found.manifestPath;
312
+ }
313
+
314
+ function executableBasename(execPath: string, platform: NodeJS.Platform): string {
315
+ return platform === 'win32' ? win32Path.basename(execPath) : basename(execPath);
316
+ }
317
+
318
+ function isGenericJavaScriptRuntime(execPath: string, platform: NodeJS.Platform): boolean {
319
+ return /^(?:node|nodejs|bun)(?:\.exe)?$/i.test(executableBasename(execPath, platform));
320
+ }
321
+
322
+ function resolveCompiledHostLaunch(
323
+ deps: PiLaunchDependencies,
324
+ io: LaunchIo,
325
+ platform: NodeJS.Platform,
326
+ ): PiLaunchSpec | undefined {
327
+ const execPath = deps.execPath ?? process.execPath;
328
+ const executableName = executableBasename(execPath, platform);
329
+ const isNamedPiExecutable =
330
+ platform === 'win32' ? /^pi\.(?:exe|com)$/i.test(executableName) : executableName === 'pi';
331
+ // A Bun virtual script path describes packaging mechanics shared by every
332
+ // compiled SDK application; it is not Pi CLI authority. Retain the direct
333
+ // route only for the established Pi executable name.
334
+ if (isGenericJavaScriptRuntime(execPath, platform) || !isNamedPiExecutable) return undefined;
335
+
336
+ const executableReal = pathOperation('compiled host realpath', execPath, () => io.realpath(execPath));
337
+ const executableStat = pathOperation('compiled host stat', executableReal, () =>
338
+ io.stat(executableReal),
339
+ );
340
+ if (!executableStat.isFile()) throw new Error('compiled Pi host is not a regular file');
341
+ if (platform === 'win32') {
342
+ if (!WINDOWS_NATIVE_EXTENSIONS.has(extname(executableReal).toLowerCase())) {
343
+ throw new Error('compiled Windows Pi host must be an .exe or .com file');
344
+ }
345
+ } else {
346
+ pathOperation('compiled host execute access', executableReal, () =>
347
+ io.access(executableReal, constants.X_OK),
348
+ );
349
+ }
350
+ return { executable: executableReal, argvPrefix: [], kind: 'compiled-host' };
351
+ }
352
+
353
+ function resolvePackageLaunch(
354
+ manifestPath: string,
355
+ deps: PiLaunchDependencies,
356
+ io: LaunchIo,
357
+ platform: NodeJS.Platform,
358
+ expectedHostReal?: string,
359
+ ): PiLaunchSpec {
360
+ const manifestReal = pathOperation('manifest realpath', manifestPath, () => io.realpath(manifestPath));
361
+ if (basename(manifestReal).toLowerCase() !== 'package.json') {
362
+ throw new Error(`resolved Pi manifest is not package.json: ${manifestReal}`);
363
+ }
364
+ const manifestStat = pathOperation('manifest stat', manifestReal, () => io.stat(manifestReal));
365
+ if (!manifestStat.isFile()) throw new Error(`Pi package manifest is not a regular file: ${manifestReal}`);
366
+
156
367
  const manifest = parseManifest(
157
- readPath('manifest read', manifestPath, () => readFile(manifestPath)),
158
- manifestPath,
368
+ pathOperation('manifest read', manifestReal, () => io.readFile(manifestReal)),
369
+ manifestReal,
370
+ );
371
+ requirePiPackageName(manifest, manifestReal);
372
+ const packageRootReal = pathOperation('package root realpath', dirname(manifestReal), () =>
373
+ io.realpath(dirname(manifestReal)),
374
+ );
375
+ if (!pathInside(packageRootReal, manifestReal)) {
376
+ throw new Error('Pi package manifest resolves outside the package root');
377
+ }
378
+
379
+ const bin = readPiBin(manifest, manifestReal);
380
+ if (
381
+ isAbsolute(bin) ||
382
+ (platform === 'win32' && (win32Path.isAbsolute(bin) || /^[a-z]:/i.test(bin)))
383
+ )
384
+ throw new Error('Pi package bin target must not be absolute; it must be relative to the package root');
385
+ const targetCandidate = resolve(packageRootReal, bin);
386
+ if (!pathInside(packageRootReal, targetCandidate)) {
387
+ throw new Error('Pi package bin target path escapes the package root');
388
+ }
389
+ const targetReal = pathOperation('bin target realpath', targetCandidate, () =>
390
+ io.realpath(targetCandidate),
159
391
  );
160
- const bin = readPiBin(manifest, manifestPath);
161
- const targetCandidate = join(packageRoot, bin);
162
- const targetReal = readPath('bin target realpath', targetCandidate, () => realpath(targetCandidate));
163
392
  if (!pathInside(packageRootReal, targetReal)) {
164
- failResolution('Pi package bin target resolves outside the package root');
393
+ throw new Error('Pi package bin target resolves outside the package root');
394
+ }
395
+ const targetStat = pathOperation('bin target stat', targetReal, () => io.stat(targetReal));
396
+ if (!targetStat.isFile()) throw new Error('Pi package bin target is not a regular file');
397
+ if (expectedHostReal !== undefined && targetReal !== expectedHostReal) {
398
+ throw new Error(
399
+ `running host script does not match manifest bin.pi: host ${expectedHostReal}; bin ${targetReal}`,
400
+ );
165
401
  }
166
- const targetStat = readPath('bin target stat', targetReal, () => stat(targetReal));
167
- if (!targetStat.isFile()) failResolution('Pi package bin target is not a regular file');
168
402
 
169
403
  const extension = extname(targetReal).toLowerCase();
170
- if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
404
+ if (JAVASCRIPT_EXTENSIONS.has(extension)) {
405
+ const execPath = deps.execPath ?? process.execPath;
406
+ if (!isGenericJavaScriptRuntime(execPath, platform)) {
407
+ throw new Error(
408
+ `Pi package JavaScript bin cannot launch through a non-generic JavaScript runtime: ${execPath}`,
409
+ );
410
+ }
171
411
  return { executable: execPath, argvPrefix: [targetReal], kind: 'package-node-cli' };
172
412
  }
173
- if (extension === '.exe' || extension === '.com') {
413
+ if (platform === 'win32' && WINDOWS_NATIVE_EXTENSIONS.has(extension)) {
414
+ // Keep the historical package launch kind for compatibility; argvPrefix
415
+ // distinguishes this direct native form from the JavaScript package form.
174
416
  return { executable: targetReal, argvPrefix: [], kind: 'package-node-cli' };
175
417
  }
176
- failResolution(`Pi package bin target extension is unsupported: ${extension || '<none>'}`);
418
+ throw new Error(`Pi package bin target extension is unsupported: ${extension || '<none>'}`);
419
+ }
420
+
421
+ function resolveExecutableOnPosixPath(
422
+ deps: PiLaunchDependencies,
423
+ io: LaunchIo,
424
+ ): { readonly launch?: PiLaunchSpec; readonly diagnostics: readonly string[] } {
425
+ const rawPath = deps.path ?? process.env['PATH'] ?? '';
426
+ const diagnostics: string[] = [];
427
+ if (rawPath.length === 0) diagnostics.push('PATH is empty');
428
+ for (const entry of rawPath.split(delimiter)) {
429
+ const directory = entry.length === 0 ? process.cwd() : entry;
430
+ const candidate = resolve(directory, 'pi');
431
+ let candidateReal: string;
432
+ try {
433
+ candidateReal = io.realpath(candidate);
434
+ } catch (error) {
435
+ diagnostics.push(`${candidate}: ${errorMessage(error)}`);
436
+ continue;
437
+ }
438
+ try {
439
+ if (!io.stat(candidateReal).isFile()) {
440
+ diagnostics.push(`${candidateReal} is not a regular file`);
441
+ continue;
442
+ }
443
+ io.access(candidateReal, constants.X_OK);
444
+ } catch (error) {
445
+ diagnostics.push(`${candidateReal} is not executable: ${errorMessage(error)}`);
446
+ continue;
447
+ }
448
+ return {
449
+ // Bind the launch plan to the exact canonical executable admitted above.
450
+ // A later cwd or environment/PATH change must not trigger a second lookup.
451
+ launch: { executable: candidateReal, argvPrefix: [], kind: 'path' },
452
+ diagnostics,
453
+ };
454
+ }
455
+ return { diagnostics };
456
+ }
457
+
458
+ export function resolvePiLaunch(deps: PiLaunchDependencies = {}): PiLaunchSpec {
459
+ const platform = deps.platform ?? process.platform;
460
+ const io = launchIo(deps);
461
+
462
+ try {
463
+ const compiled = resolveCompiledHostLaunch(deps, io, platform);
464
+ if (compiled) return compiled;
465
+ } catch (error) {
466
+ failResolution(errorMessage(error));
467
+ }
468
+
469
+ const hostScript = deps.hostScript ?? process.argv[1];
470
+ let pathDiagnostic: string | undefined;
471
+ if (platform !== 'win32') {
472
+ const pathResult = resolveExecutableOnPosixPath(deps, io);
473
+ if (pathResult.launch) return pathResult.launch;
474
+ pathDiagnostic = `no executable pi on PATH (${pathResult.diagnostics.join('; ') || 'no candidates'})`;
475
+ }
476
+
477
+ let hostDiagnostic = 'host script was not inspected';
478
+ try {
479
+ const host = findPiManifestFromHostScript(hostScript, io);
480
+ // A manifest that identifies the running host as Pi is authoritative. Never
481
+ // hide its invalid bin/identity behind another module installation.
482
+ return resolvePackageLaunch(host.manifestPath, deps, io, platform, host.sourceReal);
483
+ } catch (error) {
484
+ // Only genuine absence or a positively foreign package identity permits the
485
+ // exact installed-module route. I/O and integrity failures remain fatal.
486
+ if (!(error instanceof HostIsNotPiError)) failResolution(errorMessage(error));
487
+ hostDiagnostic = errorMessage(error);
488
+ }
489
+
490
+ let manifestPath: string;
491
+ try {
492
+ manifestPath = resolvePiManifestFromModules(deps, io);
493
+ } catch (error) {
494
+ const prefix = pathDiagnostic === undefined ? '' : `${pathDiagnostic}; `;
495
+ failResolution(`${prefix}${errorMessage(error)}; running host lookup: ${hostDiagnostic}`);
496
+ }
497
+ try {
498
+ return resolvePackageLaunch(manifestPath, deps, io, platform);
499
+ } catch (error) {
500
+ failResolution(errorMessage(error));
501
+ }
177
502
  }
178
503
 
179
504
  export function piLaunchArgv(launch: PiLaunchSpec, piArgs: readonly string[]): string[] {