@edgehero/pi-dispatch 0.1.2 → 0.2.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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Read the operator's OWN pi setup: which packages they installed with `pi install`, and which of their
3
+ * extensions they turned off with `pi config` (issue #102).
4
+ *
5
+ * Why this exists. `import-pi` used to stage packages from `pi-packages.json` and nothing else, so a package
6
+ * the operator had already installed never reached a job and re-running the import did not change that. The
7
+ * only road in was to declare it a second time, by hand, with an exact version, while the docs promised the
8
+ * import staged "your host pi setup". This module closes that gap by reading what pi itself recorded.
9
+ *
10
+ * Why a separate module from import-pi.mjs. `doctor` is a first-class consumer -- it reports host packages
11
+ * that are not staged and version drift between the two -- and it needs those facts with no overlay write,
12
+ * no `out` stream and none of the stager's print side effects. So this module returns structured facts and
13
+ * every caller renders them its own way.
14
+ *
15
+ * What it must never become. Nothing on the worker's BOOT path may import this file. It reads host paths and
16
+ * may spawn a package manager, and neither belongs anywhere near `start.mjs`.
17
+ *
18
+ * Everything here MIRRORS a private detail of the pinned pi (0.80.7) rather than calling it: pi exports no
19
+ * public answer to "where is this package installed" or "is this resource enabled", and importing the whole
20
+ * coding-agent SDK to read two well-known paths is not worth the weight. That mirroring is a real risk --
21
+ * pi could change the grammar and we would silently start staging something the operator turned off -- so it
22
+ * is pinned twice: `worker/test/host-pi.pinned.test.mjs` asserts the pinned artifact still reads the way this
23
+ * file assumes, and `.github/scripts/host-pi-canary.mjs` runs the same needles against pi@latest for advance
24
+ * warning. Both import PINNED_PI_NEEDLES from here so the pin and the canary cannot drift apart. The residual
25
+ * is `OQ-018`, recorded rather than glossed: the pins catch a moved internal, not a mirror that was checking
26
+ * the wrong lines all along.
27
+ *
28
+ * Where it cannot decide, it says so. A glob in an enablement pattern is NOT evaluated (we do not carry a
29
+ * matcher), the extension is copied, and the caller prints that it could not be honoured. Fail open, and say
30
+ * which.
31
+ */
32
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { basename, join, relative, resolve } from "node:path";
35
+ import { EXACT_VERSION_RE, RESOURCE_DIRS, stagedDirName } from "./packages.mjs";
36
+
37
+ /** pi's user-scope npm install root: `<agentDir>/npm/node_modules/<name>` (getManagedNpmInstallPath). */
38
+ export const AGENT_NPM_SUBDIR = "npm";
39
+ /** pi's user-scope git install root: `<agentDir>/git/<host>/<path>` (getGitInstallRoot). */
40
+ export const AGENT_GIT_SUBDIR = "git";
41
+ /** pi's own settings file. READ here, never copied into the overlay -- reading is not copying. */
42
+ export const AGENT_SETTINGS_FILE = "settings.json";
43
+
44
+ /**
45
+ * The exact source strings this module assumes are still present in the pinned pi. Consumed by the pinned
46
+ * test (build gate) and by the release canary (advance warning) so a pi bump that moves any of them fails a
47
+ * test here rather than silently changing what lands in every job container.
48
+ */
49
+ export const PINNED_PI_NEEDLES = {
50
+ "dist/core/package-manager.js": [
51
+ // The two install roots we resolve by hand.
52
+ 'return join(this.agentDir, "npm", "node_modules", source.name);',
53
+ 'return join(this.agentDir, "git");',
54
+ // The user-scope legacy fallback, and its precedence: managed path first, global root only if absent.
55
+ "return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name);",
56
+ 'this.runNpmCommandSync(["root", "-g"]).trim()',
57
+ 'this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"])',
58
+ // A package with no `pi` key is still a pi package when it ships a convention dir. Anchored on the
59
+ // FALLTHROUGH rather than on `readPiManifest`'s null return, because the fallthrough IS the behaviour
60
+ // the mirror depends on while the manifest read is only how you arrive at it. pi 0.84.1 extracted
61
+ // that read into its own module without changing what it means, and a needle on its old body cried
62
+ // wolf on a pure refactor -- which is how a canary teaches people to ignore it.
63
+ "let hasAnyDir = false;",
64
+ // The enablement grammar: which prefixes are overrides, and the order they resolve in.
65
+ 'return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));',
66
+ "function isEnabledByOverrides(filePath, patterns, baseDir) {",
67
+ // The npm spec split that yields a name from `@scope/name@version`.
68
+ "const match = spec.match(/^(@?[^@]+(?:\\/[^@]+)?)(?:@(.+))?$/);",
69
+ ],
70
+ "dist/core/settings-manager.d.ts": [
71
+ "export type PackageSource = string | {",
72
+ " autoload?: boolean;",
73
+ " packages?: PackageSource[];",
74
+ " extensions?: string[];",
75
+ ],
76
+ };
77
+
78
+ /**
79
+ * Resolve the host's pi agent dir the way pi's getAgentDir() does: env override, else `~/.pi/agent`.
80
+ * Lives here now (it moved out of import-pi.mjs) because discovery, the extension read and doctor all
81
+ * need the same answer.
82
+ */
83
+ export function agentDirFrom(env = process.env) {
84
+ return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
85
+ }
86
+
87
+ /**
88
+ * Parse `<agentDir>/settings.json` TEXT. Pure, fs-free, and it NEVER throws: one bad file on the operator's
89
+ * host must not be able to block their overlay refresh, most of which (models, skills, persona) does not
90
+ * depend on this file at all.
91
+ *
92
+ * Only three keys are read, and every other key in pi's Settings is ignored by construction rather than by a
93
+ * schema -- a schema here would be a second, drifting copy of pi's own type.
94
+ * packages the operator's declared install list, the source of truth for discovery
95
+ * extensions the top-level override patterns that decide which auto-discovered extensions load
96
+ * npmCommand only to learn whether the host runs npm, pnpm or bun, for the legacy global lookup
97
+ *
98
+ * `state` is "ok", "malformed" (not JSON, or not an object), or "packages-not-an-array".
99
+ */
100
+ export function parseHostSettings(text) {
101
+ const empty = { state: "malformed", packages: [], patterns: [], npmCommand: null };
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(text);
105
+ } catch {
106
+ return empty;
107
+ }
108
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return empty;
109
+
110
+ const patterns = Array.isArray(parsed.extensions) ? parsed.extensions.filter((p) => typeof p === "string") : [];
111
+ const npmCommand = Array.isArray(parsed.npmCommand) ? parsed.npmCommand.filter((p) => typeof p === "string") : null;
112
+ if (parsed.packages !== undefined && !Array.isArray(parsed.packages)) {
113
+ return { state: "packages-not-an-array", packages: [], patterns, npmCommand };
114
+ }
115
+ return { state: "ok", packages: Array.isArray(parsed.packages) ? parsed.packages : [], patterns, npmCommand };
116
+ }
117
+
118
+ /** pi's own npm spec split: `@scope/name@1.2.3` -> name `@scope/name`, version `1.2.3` (parseNpmSpec). */
119
+ const NPM_SPEC_RE = /^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/;
120
+
121
+ /**
122
+ * Classify one `settings.packages` source string, mirroring pi's parseSource narrowly.
123
+ * Returns `{ kind, name, spec, requested, raw }` where kind is "npm", "git" or "local".
124
+ *
125
+ * `requested` is whatever version the operator typed and is reported, never trusted: it may be a range, and
126
+ * a range is exactly what CONST-PI-VERSION-PINNED forbids. The version that gets pinned is always read back
127
+ * off the installed directory.
128
+ */
129
+ export function parsePackageSource(source) {
130
+ const raw = typeof source === "string" ? source.trim() : "";
131
+ if (raw === "") return { kind: "local", name: null, spec: null, requested: null, raw };
132
+
133
+ if (raw.startsWith("npm:")) {
134
+ const spec = raw.slice("npm:".length).trim();
135
+ const match = spec.match(NPM_SPEC_RE);
136
+ if (!match) return { kind: "npm", name: spec, spec, requested: null, raw };
137
+ return { kind: "npm", name: match[1], spec, requested: match[2] ?? null, raw };
138
+ }
139
+ // A relative or absolute path is pi's "local" source. Checked BEFORE the git shapes because `./a/b`
140
+ // would otherwise read as a host-and-path pair.
141
+ if (/^[.~/]/.test(raw) || /^[a-zA-Z]:[\\/]/.test(raw)) {
142
+ return { kind: "local", name: raw, spec: null, requested: null, raw };
143
+ }
144
+ // Everything else is a git source as far as staging is concerned: `git:host/path`, an ssh or https URL,
145
+ // or the bare `host/owner/repo` form pi accepts. We stage none of them, so the only job of this branch
146
+ // is to name the thing accurately in the printed reason.
147
+ const name = raw.startsWith("git:") ? raw.slice("git:".length) : raw;
148
+ return { kind: "git", name, spec: null, requested: null, raw };
149
+ }
150
+
151
+ /** The prefixes pi treats as overrides; a plain pattern is not one (getOverridePatterns). */
152
+ function isOverridePattern(pattern) {
153
+ return typeof pattern === "string" && (pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
154
+ }
155
+
156
+ /** Any minimatch magic we decline to interpret. Deliberately over-broad: a false "unknown" only costs a note. */
157
+ const GLOB_RE = /[*?[\]{}()]/;
158
+
159
+ const toPosix = (p) => String(p).split("\\").join("/");
160
+ /** pi's normalizeExactPattern: a leading "./" is stripped before comparison. */
161
+ const normalizeExact = (pattern) => toPosix(pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern);
162
+
163
+ /**
164
+ * Is this resource enabled, given pi's top-level override patterns? Returns true, false, or **null** for
165
+ * "we refuse to guess" (mirrors isEnabledByOverrides).
166
+ *
167
+ * `candidate` is `{ rel, name, abs }`: the path relative to the agent dir, the basename, and the absolute
168
+ * path. pi compares `+`/`-` patterns EXACTLY and only against `rel` and `abs`; it compares `!` patterns with
169
+ * minimatch and also against the basename. That asymmetry is pi's, not a simplification.
170
+ *
171
+ * Precedence is pi's, and it is why this is resolved in three passes rather than one loop: `-` beats `+`
172
+ * beats `!`. So a `-` hit is final, a `+` hit is final, and only then does an unevaluable `!` glob matter --
173
+ * which is what keeps `-a/b.js` a determinate answer even when some other pattern is a glob we skipped.
174
+ *
175
+ * The null case is the honest one. We carry no matcher (the worker has four runtime dependencies and none is
176
+ * a glob library, and adding one to read another tool's config is not the trade), so a `!` glob means we do
177
+ * not know. The caller copies the extension and prints that it could not honour the pattern.
178
+ */
179
+ export function isEnabledByPatterns(candidate, patterns = []) {
180
+ const overrides = patterns.filter(isOverridePattern);
181
+ if (overrides.length === 0) return true;
182
+
183
+ const exactHit = (pattern) => {
184
+ const normalized = normalizeExact(pattern);
185
+ return normalized === candidate.rel || normalized === candidate.abs;
186
+ };
187
+ for (const pattern of overrides) {
188
+ if (pattern.startsWith("-") && exactHit(pattern.slice(1))) return false;
189
+ }
190
+ for (const pattern of overrides) {
191
+ if (pattern.startsWith("+") && exactHit(pattern.slice(1))) return true;
192
+ }
193
+
194
+ let unknown = false;
195
+ for (const pattern of overrides) {
196
+ if (!pattern.startsWith("!")) continue;
197
+ const body = pattern.slice(1);
198
+ if (GLOB_RE.test(body)) {
199
+ unknown = true;
200
+ continue;
201
+ }
202
+ const normalized = toPosix(body);
203
+ if (normalized === candidate.rel || normalized === candidate.name || normalized === candidate.abs) return false;
204
+ }
205
+ return unknown ? null : true;
206
+ }
207
+
208
+ /** pi's resolveExtensionEntries: the package's own `pi.extensions[]`, else index.ts, else index.js, else none. */
209
+ function resolveExtensionEntries(fs, dir) {
210
+ const packageJsonPath = join(dir, "package.json");
211
+ if (fs.existsSync(packageJsonPath)) {
212
+ try {
213
+ const declared = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))?.pi?.extensions;
214
+ if (Array.isArray(declared)) {
215
+ const entries = declared.filter((p) => typeof p === "string").map((p) => resolve(dir, p)).filter((p) => fs.existsSync(p));
216
+ if (entries.length > 0) return entries;
217
+ }
218
+ } catch {
219
+ // Unreadable or malformed: fall through to the index convention, exactly as pi does.
220
+ }
221
+ }
222
+ for (const index of ["index.ts", "index.js"]) {
223
+ const candidate = join(dir, index);
224
+ if (fs.existsSync(candidate)) return [candidate];
225
+ }
226
+ return [];
227
+ }
228
+
229
+ /**
230
+ * The entry files pi would load for one child of `<agentDir>/extensions/`. A `.ts`/`.js` FILE is its own
231
+ * entry; a DIRECTORY resolves through the package manifest or the index convention. Anything else
232
+ * contributes nothing, which is why an empty result means "leave it alone" rather than "disabled".
233
+ */
234
+ export function extensionEntryPaths(fs, extDir, name) {
235
+ const child = join(extDir, name);
236
+ let st;
237
+ try {
238
+ st = fs.statSync(child);
239
+ } catch {
240
+ return [];
241
+ }
242
+ if (st.isDirectory()) return resolveExtensionEntries(fs, child);
243
+ if (/\.(ts|js)$/.test(name)) return [child];
244
+ return [];
245
+ }
246
+
247
+ /**
248
+ * Which of the host's extensions are turned OFF in pi. Returns `{ disabled, unevaluated }` -- a Set of child
249
+ * names not to copy, and the names whose state we declined to guess.
250
+ *
251
+ * This runs on EVERY import, not just under --with-packages: the extensions copy has shipped since long
252
+ * before discovery existed, and an extension the operator explicitly disabled has been loading in every job
253
+ * container the whole time. That is the live half of issue #102.
254
+ */
255
+ export function hostExtensionState({ fs, agentDir, patterns = [] }) {
256
+ const disabled = new Set();
257
+ const unevaluated = [];
258
+ const extDir = join(agentDir, "extensions");
259
+ if (!fs.existsSync(extDir)) return { disabled, unevaluated };
260
+
261
+ const overrides = patterns.filter(isOverridePattern);
262
+ if (overrides.length === 0) return { disabled, unevaluated };
263
+
264
+ // pi checks the extensions dir ITSELF for entries first, and when it finds them the whole directory is a
265
+ // single extension rather than a parent of many. In that layout no per-child verdict exists to compute,
266
+ // so we say so instead of inventing one.
267
+ if (resolveExtensionEntries(fs, extDir).length > 0) {
268
+ unevaluated.push("extensions/ (the directory itself resolves as one extension, so per-name state does not apply)");
269
+ return { disabled, unevaluated };
270
+ }
271
+
272
+ let names;
273
+ try {
274
+ names = fs.readdirSync(extDir);
275
+ } catch {
276
+ return { disabled, unevaluated };
277
+ }
278
+
279
+ for (const name of names) {
280
+ if (name.startsWith(".") || name === "node_modules") continue;
281
+ const entries = extensionEntryPaths(fs, extDir, name);
282
+ if (entries.length === 0) continue; // pi loads nothing from it, so the copy is inert either way
283
+
284
+ let allDisabled = true;
285
+ let anyUnknown = false;
286
+ for (const entry of entries) {
287
+ const verdict = isEnabledByPatterns({ rel: toPosix(relative(agentDir, entry)), name: basename(entry), abs: toPosix(entry) }, overrides);
288
+ if (verdict === null) anyUnknown = true;
289
+ if (verdict !== false) allDisabled = false;
290
+ }
291
+ // An extension is off only when EVERY entry it loads is off. Unknown wins over disabled: we would
292
+ // rather copy something the operator turned off, and print that we could not tell, than quietly
293
+ // withhold a tool their flows were written against.
294
+ if (anyUnknown) unevaluated.push(name);
295
+ else if (allDisabled) disabled.add(name);
296
+ }
297
+ return { disabled, unevaluated };
298
+ }
299
+
300
+ /** pi's getPackageManagerName: the token after the last `--`, else the command, basename, minus .cmd/.exe. */
301
+ function packageManagerName(npmCommand) {
302
+ if (!Array.isArray(npmCommand) || npmCommand.length === 0) return "npm";
303
+ const separatorIndex = npmCommand.lastIndexOf("--");
304
+ const command = separatorIndex >= 0 ? npmCommand[separatorIndex + 1] : npmCommand[0];
305
+ return command ? basename(command).replace(/\.(cmd|exe)$/i, "") : "";
306
+ }
307
+
308
+ /**
309
+ * The host's global package root, for pi's LEGACY fallback only. One lazy probe per run, memoised, and every
310
+ * failure degrades to `null` with a reason rather than throwing: this shells out to whatever package manager
311
+ * the operator configured, on their machine, and a feature that reads their setup must not be able to break
312
+ * their import because `pnpm` was not on PATH.
313
+ */
314
+ function makeGlobalRootProbe({ exec, npmCommand, platform }) {
315
+ let cached;
316
+ const manager = packageManagerName(npmCommand);
317
+ const [command, ...prefixArgs] = Array.isArray(npmCommand) && npmCommand.length > 0 ? npmCommand : [platform === "win32" ? "npm.cmd" : "npm"];
318
+
319
+ const run = async (args) => {
320
+ // shell:true on win32 for the same CVE-2024-27980 reason import-pi documents: a .cmd cannot be
321
+ // spawned without one. Safe here for the same reason too -- argv is literal flags and nothing else.
322
+ const options = platform === "win32" ? { shell: true } : {};
323
+ const { stdout } = await exec(command, [...prefixArgs, ...args], options);
324
+ return String(stdout ?? "").trim();
325
+ };
326
+
327
+ return {
328
+ manager,
329
+ async resolve(name) {
330
+ if (cached === undefined) {
331
+ cached = { root: null, pnpm: null, reason: null };
332
+ try {
333
+ if (manager === "pnpm") {
334
+ cached.pnpm = JSON.parse(await run(["list", "-g", "--depth", "0", "--json"]));
335
+ } else if (manager === "bun") {
336
+ cached.root = join(dirnameOf(await run(["pm", "bin", "-g"])), "install", "global", "node_modules");
337
+ } else {
338
+ cached.root = await run(["root", "-g"]);
339
+ }
340
+ } catch (error) {
341
+ cached.reason = `could not ask ${manager} for its global root (${String(error?.message ?? error).split("\n")[0]})`;
342
+ }
343
+ }
344
+ if (cached.pnpm) {
345
+ for (const entry of Array.isArray(cached.pnpm) ? cached.pnpm : []) {
346
+ const path = entry?.dependencies?.[name]?.path;
347
+ if (typeof path === "string" && path !== "") return { path, reason: null };
348
+ }
349
+ return { path: null, reason: null };
350
+ }
351
+ if (cached.root) return { path: join(cached.root, name), reason: null };
352
+ return { path: null, reason: cached.reason };
353
+ },
354
+ };
355
+ }
356
+
357
+ /** `path.dirname` over a possibly-Windows path, without importing win32-specific helpers. */
358
+ function dirnameOf(p) {
359
+ const normalized = toPosix(p).replace(/\/+$/, "");
360
+ const cut = normalized.lastIndexOf("/");
361
+ return cut <= 0 ? normalized : normalized.slice(0, cut);
362
+ }
363
+
364
+ /**
365
+ * Discover the packages the operator installed in pi, from `settings.packages`.
366
+ *
367
+ * Why settings and not a walk of `<agentDir>/npm/node_modules`: pi installs with plain npm and default
368
+ * hoisting (getNpmInstallArgs), so in that tree an installed package and a transitive dependency are
369
+ * indistinguishable. A walk would stage third-party code into every job container because it happened to be
370
+ * hoisted next to something the operator did ask for. Settings carries intent, git sources and enablement;
371
+ * the only thing it lacks is a trustworthy version, and that is read back off the install dir.
372
+ *
373
+ * When settings is absent or malformed we discover NOTHING. We deliberately do not fall back to the walk:
374
+ * inferring intent from a hoisted tree is the failure this function exists to avoid, and it would be worst
375
+ * exactly when the operator's config is already broken.
376
+ */
377
+ export async function discoverHostPackages({ agentDir, settings, fs, exec, platform = process.platform }) {
378
+ const sources = settings?.state === "ok" ? settings.packages : [];
379
+ if (sources.length === 0) return [];
380
+
381
+ const probe = makeGlobalRootProbe({ exec, npmCommand: settings.npmCommand, platform });
382
+ const managedRoot = join(agentDir, AGENT_NPM_SUBDIR, "node_modules");
383
+ const found = [];
384
+
385
+ for (const source of sources) {
386
+ // A PackageSource is either the bare spec string or an object carrying the spec plus filters.
387
+ const spec = typeof source === "string" ? source : source?.source;
388
+ if (typeof spec !== "string") continue;
389
+ const parsed = parsePackageSource(spec);
390
+ const autoload = typeof source === "object" && source !== null ? source.autoload : undefined;
391
+ const filtered = typeof source === "object" && source !== null && RESOURCE_DIRS.some((kind) => Array.isArray(source[kind]));
392
+ const forced = typeof source === "object" && source !== null && RESOURCE_DIRS.some((kind) => (source[kind] ?? []).some?.((p) => typeof p === "string" && p.startsWith("+")));
393
+ const base = { source: spec, kind: parsed.kind, name: parsed.name, version: null, installPath: null, resolvedVia: null, isPiPackage: null, autoload, filtered, forced, skip: null };
394
+
395
+ if (parsed.kind === "git") {
396
+ found.push({ ...base, skip: "git source, and pi-packages.json pins an npm name plus an exact version only" });
397
+ continue;
398
+ }
399
+ if (parsed.kind === "local") {
400
+ found.push({ ...base, skip: "local path source, staged from your own filesystem rather than a registry" });
401
+ continue;
402
+ }
403
+
404
+ // pi's getNpmInstallPath for scope "user": the managed path, and ONLY if that is absent, the legacy
405
+ // global root. Replicating the precedence matters as much as replicating the paths -- honouring the
406
+ // global copy when a managed one exists would stage a different build than the operator runs.
407
+ let installPath = join(managedRoot, parsed.name);
408
+ let resolvedVia = "managed";
409
+ if (!fs.existsSync(installPath)) {
410
+ const legacy = await probe.resolve(parsed.name);
411
+ if (legacy.path && fs.existsSync(legacy.path)) {
412
+ installPath = legacy.path;
413
+ resolvedVia = probe.manager === "pnpm" ? "pnpm-global" : probe.manager === "bun" ? "bun-global" : "legacy-global";
414
+ } else {
415
+ found.push({ ...base, skip: legacy.reason ?? `not installed at ${managedRoot}, and not in ${probe.manager}'s global root either` });
416
+ continue;
417
+ }
418
+ }
419
+
420
+ let pkg;
421
+ try {
422
+ pkg = JSON.parse(fs.readFileSync(join(installPath, "package.json"), "utf8"));
423
+ } catch {
424
+ found.push({ ...base, installPath, resolvedVia, skip: `no readable package.json at ${installPath}` });
425
+ continue;
426
+ }
427
+
428
+ // The same field pi reads (getInstalledNpmVersion), re-checked against the pin rule before it can
429
+ // become one. A prerelease passes; anything that is not a concrete version does not.
430
+ const version = typeof pkg.version === "string" ? pkg.version : null;
431
+ if (!version || !EXACT_VERSION_RE.test(version)) {
432
+ found.push({ ...base, installPath, resolvedVia, skip: `installed version ${JSON.stringify(pkg.version ?? null)} is not an exact version (CONST-PI-VERSION-PINNED)` });
433
+ continue;
434
+ }
435
+
436
+ // pi's own predicate: a `pi` manifest OR a convention dir. A package with neither contributes nothing
437
+ // and would stage as a silent no-op.
438
+ const isPiPackage = (pkg.pi !== null && typeof pkg.pi === "object") || RESOURCE_DIRS.some((kind) => fs.existsSync(join(installPath, kind)));
439
+ const entry = { ...base, version, installPath, resolvedVia, isPiPackage, dir: stagedDirName(parsed.name) };
440
+ if (!isPiPackage) {
441
+ found.push({ ...entry, skip: `contributes no pi resources (no "pi" manifest and none of ${RESOURCE_DIRS.join("/")})` });
442
+ continue;
443
+ }
444
+ // autoload:false with no `+` pattern anywhere is the closest thing pi has to "disabled": its delta
445
+ // filter starts from nothing and re-adds only what a `+` names. With a `+` present, or with per-kind
446
+ // filters, the package IS partly live, and staging is all-or-nothing on a directory, so it stages and
447
+ // the caller warns.
448
+ if (autoload === false && !forced) {
449
+ found.push({ ...entry, skip: "autoload is off in your pi settings" });
450
+ continue;
451
+ }
452
+ found.push(entry);
453
+ }
454
+ return found;
455
+ }
456
+
457
+ /**
458
+ * The one call each command site makes. Reads pi's settings once, derives the extension enablement state
459
+ * always, and discovers packages only when the caller asked for them.
460
+ *
461
+ * The two halves stay separate on purpose: the package half must not run without `--with-packages`, because
462
+ * that is what keeps a flagless import free of any packages output at all.
463
+ */
464
+ export async function readHostPi({ agentDir, fs = { existsSync, readFileSync, readdirSync, statSync }, exec, platform = process.platform, withPackages = false }) {
465
+ const settingsPath = join(agentDir, AGENT_SETTINGS_FILE);
466
+ let settings = { state: "absent", packages: [], patterns: [], npmCommand: null };
467
+ if (fs.existsSync(settingsPath)) {
468
+ try {
469
+ settings = parseHostSettings(fs.readFileSync(settingsPath, "utf8"));
470
+ } catch {
471
+ settings = { state: "malformed", packages: [], patterns: [], npmCommand: null };
472
+ }
473
+ }
474
+
475
+ const extensions = hostExtensionState({ fs, agentDir, patterns: settings.patterns });
476
+ const packages = withPackages ? await discoverHostPackages({ agentDir, settings, fs, exec, platform }) : [];
477
+ return { agentDir, settingsPath, settingsState: settings.state, packages, extensions };
478
+ }