@sigrun/sigrun 0.0.0-stage → 0.1.1

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 (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,1002 @@
1
+ /**
2
+ * Boot-time extension package install (the pi-style packages dir).
3
+ *
4
+ * Sigrun deployments declare extensions in the settings file's unified
5
+ * top-level `extensions` list (an `ExtensionSpec`: a string source or
6
+ * `{source, entry?, ref?, enabled?}`) and restart — no image rebuild,
7
+ * no manual npm. The `SIGRUN_EXTENSIONS` env var (a comma-separated
8
+ * specifier list) REPLACES the file's list when set — env wins, validated
9
+ * first; it is the one environment-shaped override of the declaration.
10
+ *
11
+ * The install list is derived from the entries' sources:
12
+ * `normalizeExtSpec` flattens each entry, and `sourceKind` routes it —
13
+ * npm-shaped sources (registry names/ranges/scoped specifiers, and
14
+ * `file:` installable specifiers) install into the packages dir; git
15
+ * sources (`git:` or an https `…​.git` URL, optional `@ref`) clone/fetch
16
+ * into the git dir (`<config-dir>/git/`) with entry discovery; bare
17
+ * `./`/`../`/absolute paths LOAD IN PLACE (nothing installed). A `file:`
18
+ * entry is an INSTALLABLE npm specifier keyed by the TARGET's own
19
+ * manifest name and compared by canonical absolute path; a `file:`-less
20
+ * relative/absolute path never installs. `enabled: false` entries keep
21
+ * their directory state but produce no loadable resolution. The packages
22
+ * live in a dedicated section of the config directory
23
+ * (`<config-dir>/packages/`, a sibling of `settings.json` and
24
+ * `auth.json`), which is the PVC-backed path in every deployment shape,
25
+ * so installed packages persist across restarts.
26
+ *
27
+ * The directory is a REAL npm root, exactly pi's shape (`~/.pi/agent/npm`):
28
+ * a synthetic private root `package.json` (`{"name": "sigrun-packages",
29
+ * "private": true, "dependencies": {…}}`) plus `node_modules/`. The root
30
+ * manifest's `dependencies` map IS the resolved state (name → range);
31
+ * the settings file's npm-shaped `extensions` entries are the
32
+ * user-authored INTENT. Boot diffs the two (`planSync`):
33
+ *
34
+ * - a wanted specifier whose package is missing from (or whose range or
35
+ * canonical path differs in) the dependencies map → `npm install
36
+ * <changed specifiers> --prefix <packagesDir>`;
37
+ * - a dependencies entry named by NO wanted specifier → `npm uninstall
38
+ * <name> --prefix <packagesDir>` (a removed declaration prunes);
39
+ * - no diff → no subprocess at all (the fast path; a steady-state boot
40
+ * costs one file read).
41
+ *
42
+ * Fail-loud is the posture: an install or uninstall that fails, times out
43
+ * (120s), or leaves the declared package unresolved fails the BOOT, naming
44
+ * the specifier — an extension declared but uninstallable is a config
45
+ * error, exactly like a bad `SIGRUN_*` value.
46
+ *
47
+ * SECURITY: `npm install` executes the installed packages' lifecycle
48
+ * scripts (`postinstall` and friends). The packages dir is
49
+ * operator-configured — the specifiers the operator declares carry the
50
+ * same trust level as the extension code they name (which runs IN the
51
+ * harness with full tool authority). This is pi's documented posture too:
52
+ * the dir is trusted by configuration, not sandboxed.
53
+ */
54
+ import { createHash } from "node:crypto";
55
+ import { execFile, execFileSync } from "node:child_process";
56
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
57
+ import { createRequire } from "node:module";
58
+ import { basename, dirname, join, resolve } from "node:path";
59
+ import { fileURLToPath, pathToFileURL } from "node:url";
60
+ /** The npm install/uninstall timeout (ms) — one subprocess invocation. */
61
+ export const NPM_TIMEOUT_MS = 120_000;
62
+ /**
63
+ * Runtime modules the HOST ships that extension packages may import bare.
64
+ * Extension packages declare these devDependencies-only (the
65
+ * provider-plugins dependency posture: pi-ai/pi-durable ship with sigrun,
66
+ * typebox ships as pi-ai's own dependency — structural typing at runtime,
67
+ * never identity), and npm never installs a dependency's devDependencies,
68
+ * so a package installed into the packages dir cannot resolve them on its
69
+ * own. {@link ensureHostModuleLinks} links sigrun's own copies into the
70
+ * packages dir's node_modules so the dynamic import of such a package
71
+ * resolves them — the SAME module instances the host runs.
72
+ */
73
+ export const HOST_RUNTIME_MODULES = [
74
+ "@earendil-works/pi-ai",
75
+ "@earendil-works/pi-durable",
76
+ "@earendil-works/chord",
77
+ "typebox",
78
+ ];
79
+ /**
80
+ * Read the `version` field of the package at `moduleDir` (following
81
+ * links — a linked entry reads its target's manifest). `undefined` when
82
+ * the dir carries no readable manifest: callers treat that as "nothing
83
+ * to compare", never as a mismatch.
84
+ */
85
+ function installedVersionAt(moduleDir) {
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(join(moduleDir, "package.json"), "utf8"));
88
+ return typeof parsed.version === "string" ? parsed.version : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /**
95
+ * The version of the HOST's own copy of a runtime module (the tree
96
+ * sigrun itself runs from), or `undefined` when sigrun's tree does not
97
+ * carry it. The loader's host-module guard compares every extension's
98
+ * resolution against THIS value (spec §6.4 R6).
99
+ */
100
+ export function hostModuleVersion(name) {
101
+ const dir = resolveHostModuleDir(name);
102
+ return dir === undefined ? undefined : installedVersionAt(dir);
103
+ }
104
+ /**
105
+ * Resolve a host runtime module's package dir from sigrun's own tree: the
106
+ * nearest `node_modules/<name>` walking up from THIS module's location
107
+ * (the repo root in every deploy shape — sigrun runs from source). The
108
+ * walk stops at the filesystem root; `undefined` when nothing resolves.
109
+ */
110
+ function resolveHostModuleDir(name) {
111
+ let dir = dirname(fileURLToPath(import.meta.url));
112
+ for (let hops = 0; hops < 10; hops++) {
113
+ const candidate = join(dir, "node_modules", ...name.split("/"));
114
+ if (existsSync(join(candidate, "package.json")))
115
+ return candidate;
116
+ const parent = dirname(dir);
117
+ if (parent === dir)
118
+ return undefined;
119
+ dir = parent;
120
+ }
121
+ return undefined;
122
+ }
123
+ /**
124
+ * Link every host runtime module into the packages dir's node_modules
125
+ * (absolute dir symlinks), so packages installed there whose manifests
126
+ * declare those modules devDependencies-only resolve their bare imports
127
+ * — without the link, `import()` of the package fails ERR_MODULE_NOT_FOUND
128
+ * (the packages dir is an unrelated node_modules tree; Node's upward walk
129
+ * from the package never finds sigrun's own).
130
+ *
131
+ * Present entries are LEFT ALONE (an npm-installed real copy — a package
132
+ * that declared the module a runtime dependency — wins over the link; the
133
+ * sync never fights npm's own tree). A stale BROKEN link is removed before
134
+ * relinking. A module unresolvable from sigrun's tree warns and skips: a
135
+ * package that does not import it never needs the link, and the loader's
136
+ * quarantine names the gap for one that does.
137
+ */
138
+ export function ensureHostModuleLinks(packagesDir) {
139
+ const nodeModules = join(packagesDir, "node_modules");
140
+ if (!existsSync(nodeModules))
141
+ return;
142
+ for (const name of HOST_RUNTIME_MODULES) {
143
+ const linkPath = join(nodeModules, ...name.split("/"));
144
+ // existsSync follows links: an INTACT link (or a real npm-installed
145
+ // copy) reads true and is left alone.
146
+ if (existsSync(linkPath)) {
147
+ // A present entry whose version differs from the host's is
148
+ // SURFACED, not replaced (spec §6.4 R6): the sync never fights
149
+ // npm's tree — an npm-installed copy still wins over the link —
150
+ // but a skewing copy must not sit silently. The loader's guard
151
+ // quarantines the extensions that RESOLVE a skewing module; this
152
+ // warn covers the packages tree itself (same version reads equal
153
+ // for an intact host link, so links never trip it).
154
+ const present = installedVersionAt(linkPath);
155
+ const host = hostModuleVersion(name);
156
+ if (present !== undefined &&
157
+ host !== undefined &&
158
+ present !== host) {
159
+ console.warn(`packages: ${name}@${present} installed in ${packagesDir} differs from host ${host}`);
160
+ }
161
+ continue;
162
+ }
163
+ let stat;
164
+ try {
165
+ stat = lstatSync(linkPath);
166
+ }
167
+ catch {
168
+ stat = undefined; // nothing at the path
169
+ }
170
+ if (stat !== undefined) {
171
+ // Present but NOT a link: something npm placed that does not
172
+ // resolve (a stray file) — leave it for npm, never fight the tree.
173
+ if (!stat.isSymbolicLink())
174
+ continue;
175
+ // A present-but-BROKEN symlink (existsSync followed it and failed):
176
+ // clear it so the fresh link below is creatable.
177
+ rmSync(linkPath, { force: true });
178
+ }
179
+ const target = resolveHostModuleDir(name);
180
+ if (target === undefined) {
181
+ console.warn(`packages: host module ${name} not found in the sigrun tree; not linking into ${packagesDir}`);
182
+ continue;
183
+ }
184
+ mkdirSync(dirname(linkPath), { recursive: true });
185
+ try {
186
+ symlinkSync(target, linkPath, "dir");
187
+ }
188
+ catch (cause) {
189
+ if (cause?.code === "EEXIST")
190
+ continue;
191
+ throw cause;
192
+ }
193
+ }
194
+ }
195
+ /** The packages dir's synthetic root manifest name (pi's is `pi-extensions`). */
196
+ export const PACKAGES_ROOT_NAME = "sigrun-packages";
197
+ /** The packages dir under the config dir: `<configDir>/packages`. */
198
+ export function packagesDirOf(configDir) {
199
+ return join(configDir, "packages");
200
+ }
201
+ /** The synthetic root manifest path: `<packagesDir>/package.json`. */
202
+ export function packagesManifestPath(packagesDir) {
203
+ return join(packagesDir, "package.json");
204
+ }
205
+ /**
206
+ * The failure type: the boot wiring (`src/index.ts`) lets this throw —
207
+ * boot fails loud naming the specifier (or the manifest path).
208
+ */
209
+ export class PackageSyncError extends Error {
210
+ }
211
+ /**
212
+ * One `npm <args>` invocation against the packages dir (execFile — no
213
+ * shell, so no quoting surface). Failures and timeouts reject with a
214
+ * `PackageSyncError` carrying npm's stderr tail; `verbose` captures the
215
+ * full output for the error message.
216
+ */
217
+ function runNpm(args, packagesDir, verbose) {
218
+ return new Promise((resolve, reject) => {
219
+ execFile("npm", [...args, "--prefix", packagesDir, "--no-audit", "--no-fund"], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
220
+ if (error === null) {
221
+ if (verbose === true && stderr !== "") {
222
+ console.warn(`packages: npm ${args[0] ?? ""}: ${stderr.trim()}`);
223
+ }
224
+ resolve();
225
+ return;
226
+ }
227
+ const timedOut = error.killed === true || error.signal === "SIGTERM";
228
+ const detail = (stderr || stdout).trim().split("\n").slice(-5).join("\n");
229
+ reject(new PackageSyncError(`packages: npm ${args.join(" ")} ${timedOut ? `timed out after ${NPM_TIMEOUT_MS}ms` : `failed (exit ${String(error.code)})`}` +
230
+ (detail === "" ? "" : `:\n${detail}`)));
231
+ });
232
+ });
233
+ }
234
+ /**
235
+ * Read the packages dir's root manifest dependencies (name → range). A
236
+ * missing manifest is the empty map (first boot with `packages` set);
237
+ * a manifest that exists but does not parse fails loud — the dir is
238
+ * sigrun-owned, so a corrupt manifest is an operator-visible error, not
239
+ * something to silently reinstall over.
240
+ */
241
+ function readInstalled(packagesDir) {
242
+ const path = packagesManifestPath(packagesDir);
243
+ if (!existsSync(path))
244
+ return {};
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(readFileSync(path, "utf8"));
248
+ }
249
+ catch (cause) {
250
+ throw new PackageSyncError(`packages: ${path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
251
+ }
252
+ const manifest = parsed;
253
+ if (typeof manifest !== "object" ||
254
+ manifest === null ||
255
+ (manifest.dependencies !== undefined &&
256
+ (typeof manifest.dependencies !== "object" ||
257
+ manifest.dependencies === null ||
258
+ Array.isArray(manifest.dependencies)))) {
259
+ throw new PackageSyncError(`packages: ${path} must be an object with an optional dependencies map`);
260
+ }
261
+ return { ...(manifest.dependencies ?? {}) };
262
+ }
263
+ /**
264
+ * Parse an npm package specifier into `{ name, range }`.
265
+ *
266
+ * A specifier is `<name>@<range>` with the `@<range>` part optional
267
+ * (npm installs `latest` when it is absent); scoped names
268
+ * (`@scope/name`) carry their leading `@`, so the range split is the
269
+ * LAST `@` that is not the first character. For PATH-shaped specifiers
270
+ * (`file:`, `./`, `/`) the "name" is the path itself — npm keys those
271
+ * dependencies entries by the package's OWN manifest name, so callers
272
+ * needing the real identity use {@link identifySpecifier}.
273
+ */
274
+ export function parsePackageSpecifier(specifier) {
275
+ const at = specifier.lastIndexOf("@");
276
+ if (at <= 0)
277
+ return { name: specifier, range: "" };
278
+ return { name: specifier.slice(0, at), range: specifier.slice(at + 1) };
279
+ }
280
+ /** Whether a specifier names a path (`file:`, `./x`, `../x`, `/x`, a tarball) rather than a registry package. */
281
+ function isPathSpecifier(name) {
282
+ return (name.startsWith("file:") ||
283
+ name.startsWith("./") ||
284
+ name.startsWith("../") ||
285
+ name.startsWith("/") ||
286
+ name.endsWith(".tgz"));
287
+ }
288
+ /** The filesystem path of a path-shaped specifier (`file:` URL and plain forms). */
289
+ function specToPath(spec) {
290
+ if (spec.startsWith("file://"))
291
+ return fileURLToPath(spec);
292
+ if (spec.startsWith("file:"))
293
+ return spec.slice("file:".length);
294
+ return spec;
295
+ }
296
+ /**
297
+ * Canonical absolute path of a path-shaped specifier. `base` is the
298
+ * directory a RELATIVE spec resolves against — the process cwd for a
299
+ * wanted specifier (npm resolves install args against its cwd, which is
300
+ * the sigrun process's), the packages dir for a recorded range (npm
301
+ * relativizes `file:` dependency entries against the manifest's dir).
302
+ */
303
+ function canonicalizePath(spec, base) {
304
+ return resolve(base, specToPath(spec));
305
+ }
306
+ /** Read a package's manifest: the directory form, else the tarball form. */
307
+ function readManifest(absPath) {
308
+ try {
309
+ return JSON.parse(readFileSync(join(absPath, "package.json"), "utf8"));
310
+ }
311
+ catch {
312
+ // Not a readable directory manifest — try the tarball form.
313
+ }
314
+ if (absPath.endsWith(".tgz")) {
315
+ try {
316
+ const out = execFileSync("tar", ["-xzOf", absPath, "package/package.json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
317
+ return JSON.parse(out);
318
+ }
319
+ catch {
320
+ // Unreadable tarball — the name is unknown; the install itself
321
+ // will fail loud if the specifier is bad.
322
+ }
323
+ }
324
+ return undefined;
325
+ }
326
+ /**
327
+ * The `name` of a path-shaped specifier's target package, when readable.
328
+ * A directory's `package.json` reads directly; a `.tgz` tarball's
329
+ * `package/package.json` reads by listing the archive (npm keys a tarball
330
+ * install by the packed manifest's name too). Exported for the loader's
331
+ * provisional-identity derivation (the nearest manifest above a load
332
+ * entry names the package).
333
+ */
334
+ export function readPackageName(absPath) {
335
+ const name = readManifest(absPath)?.name;
336
+ return typeof name === "string" && name !== "" ? name : undefined;
337
+ }
338
+ /**
339
+ * The `version` of a path-shaped specifier's target package, when
340
+ * readable — `readPackageName`'s sibling over the SAME manifests
341
+ * (directory `package.json`, else the tarball's `package/package.json`).
342
+ * Exported for the loader's outcome recording: the extension inventory
343
+ * (`GET /v1/extensions`, spec §7.7) surfaces the manifest version.
344
+ */
345
+ export function readPackageVersion(absPath) {
346
+ const version = readManifest(absPath)?.version;
347
+ return typeof version === "string" && version !== "" ? version : undefined;
348
+ }
349
+ /**
350
+ * The identity a specifier installs under: registry specs key by their
351
+ * parsed name; path specs key by the target package's OWN manifest name
352
+ * (npm's rule) and compare by canonical absolute path (npm relativizes
353
+ * the recorded `file:` entry against the packages dir, so a raw string
354
+ * compare would reinstall on every boot).
355
+ */
356
+ export function identifySpecifier(specifier) {
357
+ const { name, range } = parsePackageSpecifier(specifier);
358
+ if (!isPathSpecifier(name))
359
+ return { name, range, pathShaped: false };
360
+ const absPath = canonicalizePath(name, process.cwd());
361
+ return {
362
+ name: readPackageName(absPath) ?? name,
363
+ range: absPath,
364
+ pathShaped: true,
365
+ };
366
+ }
367
+ /**
368
+ * Normalize one settings `extensions` entry (string or object form) into
369
+ * the install/load stem. Exported for the unit tests.
370
+ */
371
+ export function normalizeExtSpec(entry) {
372
+ if (typeof entry === "string") {
373
+ return { source: entry, enabled: true };
374
+ }
375
+ return {
376
+ source: entry.source,
377
+ ...(entry.entry === undefined ? {} : { entry: entry.entry }),
378
+ ...(entry.ref === undefined ? {} : { ref: entry.ref }),
379
+ enabled: entry.enabled ?? true,
380
+ };
381
+ }
382
+ /**
383
+ * The kind of source a normalized spec points at, deciding which boot
384
+ * tree gets it (packages dir, git dir, or load-in-place).
385
+ */
386
+ export function sourceKind(source) {
387
+ if (source.startsWith("git:") || /^https?:\/\/.*\.git/.test(source)) {
388
+ return "git";
389
+ }
390
+ // `file:` is an INSTALLABLE npm specifier (npm records it in the
391
+ // packages manifest and loads the symlinked source — the dev-checkout
392
+ // shape). Bare ./ ../ / paths are LOAD-IN-PLACE (nothing installed).
393
+ if (((source.startsWith("./") || source.startsWith("../")) &&
394
+ !source.startsWith("file:")) ||
395
+ (source.startsWith("/") && !source.startsWith("file:/"))) {
396
+ return "path";
397
+ }
398
+ // A git https/shp URL without a .git suffix would collide with the
399
+ // npm "name" grammar: only definite .git URLs are git here; everything
400
+ // else is a registry/bare name.
401
+ return "npm";
402
+ }
403
+ /**
404
+ * Diff the wanted specifiers against the installed dependencies map.
405
+ * Wanted-but-missing or range/path-changed → install; installed-but-
406
+ * unwanted → uninstall. Exported for the unit tests (the diff rules are
407
+ * the sync's core logic).
408
+ */
409
+ export function planSync(wanted, installed, packagesDir) {
410
+ const install = [];
411
+ const wantedNames = new Set();
412
+ for (const specifier of wanted) {
413
+ const id = identifySpecifier(specifier);
414
+ wantedNames.add(id.name);
415
+ const installedRange = installed[id.name];
416
+ if (installedRange === undefined) {
417
+ install.push(specifier);
418
+ continue;
419
+ }
420
+ if (id.pathShaped) {
421
+ // A recorded registry range where a path is wanted (the package
422
+ // moved to a local checkout) is a change too.
423
+ const recordedPath = installedRange.startsWith("file:")
424
+ ? canonicalizePath(installedRange, packagesDir)
425
+ : undefined;
426
+ if (recordedPath !== id.range)
427
+ install.push(specifier);
428
+ continue;
429
+ }
430
+ // A rangeless registry specifier ("name") accepts any installed
431
+ // entry (npm recorded the resolved version or "latest" —
432
+ // reinstalling every boot would defeat the fast path); a ranged
433
+ // specifier must match the recorded range exactly, else the
434
+ // declaration changed.
435
+ if (id.range !== "" && installedRange !== id.range) {
436
+ install.push(specifier);
437
+ }
438
+ }
439
+ const uninstall = Object.keys(installed).filter((name) => !wantedNames.has(name));
440
+ return { install, uninstall };
441
+ }
442
+ /**
443
+ * Ensure the packages dir exists with its synthetic root manifest. The
444
+ * manifest is written ONLY when absent — once npm owns the dir it keeps
445
+ * the manifest's dependencies map current, and rewriting it here would
446
+ * fight npm's own writes.
447
+ */
448
+ function ensurePackagesDir(packagesDir) {
449
+ mkdirSync(packagesDir, { recursive: true });
450
+ const path = packagesManifestPath(packagesDir);
451
+ if (!existsSync(path)) {
452
+ writeFileSync(path, `${JSON.stringify({ name: PACKAGES_ROOT_NAME, private: true }, null, "\t")}\n`);
453
+ }
454
+ }
455
+ /**
456
+ * Reconcile the packages dir with the settings file's `extensions`
457
+ * intent (the npm-shaped entries; git/path entries never reach the npm
458
+ * root).
459
+ *
460
+ * @param extensions the settings file's unified `extensions` array
461
+ * (already validated: string or object entries).
462
+ * @param packagesDir the packages dir (`packagesDirOf(configDir)`).
463
+ * @throws PackageSyncError on any install/uninstall failure, timeout, or
464
+ * a declared specifier that does not resolve afterward.
465
+ */
466
+ export async function syncPackages(extensions, packagesDir, options = {}) {
467
+ // Normalize + split by source kind: npm-shaped entries install into
468
+ // the packages dir; git entries clone/fetch into the git dir; path
469
+ // entries load in place; disabled entries keep their directory state
470
+ // but produce no loadable resolution.
471
+ const normalized = extensions.map(normalizeExtSpec);
472
+ const resolutions = [];
473
+ const gitDir = gitDirOf(options.configDir ?? resolve(dirname(packagesDir)));
474
+ const npmSpecs = [];
475
+ for (const spec of normalized) {
476
+ if (!spec.enabled)
477
+ continue;
478
+ const kind = sourceKind(spec.source);
479
+ if (kind === "npm") {
480
+ npmSpecs.push(spec.source);
481
+ continue;
482
+ }
483
+ if (kind === "git") {
484
+ const { source, ref } = splitGitRef(spec.source, spec.ref);
485
+ const repoDir = await syncGit(source, ref, gitDir, options);
486
+ let entry;
487
+ try {
488
+ entry = findExtEntry(repoDir, spec.entry);
489
+ }
490
+ catch (cause) {
491
+ throw cause instanceof GitSyncError
492
+ ? new GitSyncError(`git extension ${sanitizeUrl(spec.source)}: ${cause.message}`)
493
+ : cause;
494
+ }
495
+ // The §9.4 three cases: a discoverable entry resolves entry-kind
496
+ // (carrying any manifest skills); NO entry + declared skills is the
497
+ // valid skills-only package; NO entry and NO skills keeps today's
498
+ // loud throw.
499
+ const skills = manifestSkills(repoDir);
500
+ if (entry !== undefined) {
501
+ resolutions.push({
502
+ kind: "entry",
503
+ entry: pathToFileURL(entry).href,
504
+ enabled: true,
505
+ ...(skills.length === 0 ? {} : { skills }),
506
+ });
507
+ continue;
508
+ }
509
+ if (skills.length > 0) {
510
+ resolutions.push({
511
+ kind: "skills-only",
512
+ name: readPackageName(repoDir) ?? basename(repoDir),
513
+ skills,
514
+ enabled: true,
515
+ });
516
+ continue;
517
+ }
518
+ throw new GitSyncError(`git extension ${sanitizeUrl(spec.source)}: no loadable entry (no sigrun manifest, no dist/index.js) — ship a built entry; a .ts source cannot load under plain node`);
519
+ }
520
+ // path kind: load in place (nothing installed). A path package's
521
+ // manifest skills still ride along (the path target is its root).
522
+ const pathRoot = sourceKind(spec.source) === "path" && statIsDirectory(spec.source)
523
+ ? spec.source
524
+ : undefined;
525
+ const pathSkills = pathRoot === undefined ? [] : manifestSkills(pathRoot);
526
+ resolutions.push({
527
+ kind: "entry",
528
+ entry: spec.source,
529
+ enabled: true,
530
+ ...(pathSkills.length === 0 ? {} : { skills: pathSkills }),
531
+ });
532
+ }
533
+ await syncPackagesFlat(npmSpecs, packagesDir, options);
534
+ // npm-path resolutions (bare names, file: forms): resolved through the
535
+ // packages dir then the app tree — the loader's resolution seam, kept.
536
+ for (const spec of normalized) {
537
+ if (!spec.enabled)
538
+ continue;
539
+ if (sourceKind(spec.source) !== "npm")
540
+ continue;
541
+ const explicitEntry = spec.entry;
542
+ const root = manifestRootOf(spec.source, packagesDir);
543
+ const skills = root === undefined ? [] : manifestSkills(root);
544
+ const pushSkillsOnly = () => {
545
+ resolutions.push({
546
+ kind: "skills-only",
547
+ name: root === undefined ? spec.source : (readPackageName(root) ?? basename(root)),
548
+ skills,
549
+ enabled: true,
550
+ });
551
+ };
552
+ const resolved = resolveExtensionEntry(spec.source, packagesDir);
553
+ if (resolved === undefined) {
554
+ // Nothing resolvable: skills-only when the package declares skills,
555
+ // else today's shape (the specifier verbatim — the import fails
556
+ // loud at load, the quarantine).
557
+ if (skills.length > 0) {
558
+ pushSkillsOnly();
559
+ continue;
560
+ }
561
+ resolutions.push({ kind: "entry", entry: spec.source, enabled: true });
562
+ continue;
563
+ }
564
+ // A resolved DIRECTORY is packageEntry's "nothing discoverable"
565
+ // fallback (no self-resolvable entry, no manifest entry, no
566
+ // convention file): with declared skills that is the skills-only
567
+ // package; without, keep today's loud dir-import failure.
568
+ const resolvedPath = resolved.startsWith("file://")
569
+ ? fileURLToPath(resolved)
570
+ : resolved;
571
+ if (statIsDirectory(resolvedPath)) {
572
+ if (skills.length > 0) {
573
+ pushSkillsOnly();
574
+ continue;
575
+ }
576
+ resolutions.push({ kind: "entry", entry: resolved, enabled: true });
577
+ continue;
578
+ }
579
+ resolutions.push({
580
+ kind: "entry",
581
+ entry: explicitEntry === undefined
582
+ ? resolved
583
+ : pathToFileURL(join(dirname(resolved), explicitEntry)).href,
584
+ enabled: true,
585
+ ...(skills.length === 0 ? {} : { skills }),
586
+ });
587
+ }
588
+ return resolutions;
589
+ }
590
+ /** Split a git source into its fetch URL + declared ref. */
591
+ function splitGitRef(source, objectRef) {
592
+ if (objectRef !== undefined)
593
+ return { source, ref: objectRef };
594
+ // The @ref suffix: only treat the segment AFTER the last slash (a
595
+ // scoped/URL @ in the host part is not a ref).
596
+ const slash = source.lastIndexOf("/");
597
+ const at = source.lastIndexOf("@");
598
+ if (at > slash + 1)
599
+ return { source: source.slice(0, at), ref: source.slice(at + 1) };
600
+ return { source };
601
+ }
602
+ /** The pre-existing flat npm synchronizer (now an internal step). */
603
+ async function syncPackagesFlat(packages, packagesDir, options = {}) {
604
+ const log = options.log !== false;
605
+ ensurePackagesDir(packagesDir);
606
+ const installed = readInstalled(packagesDir);
607
+ const plan = planSync(packages, installed, packagesDir);
608
+ if (plan.install.length === 0 && plan.uninstall.length === 0) {
609
+ // Fast path: the dir already matches the declaration — no
610
+ // subprocess, no npm startup cost (the steady-state boot).
611
+ if (log)
612
+ console.log(`packages: ${packages.length} declared, 0 changes`);
613
+ // Host-module links are idempotent-cheap (an existsSync per module)
614
+ // and still ensured here: a packages dir populated by an older boot
615
+ // (or hand-copied) gains them on the next steady-state boot.
616
+ ensureHostModuleLinks(packagesDir);
617
+ return;
618
+ }
619
+ if (plan.uninstall.length > 0) {
620
+ if (log)
621
+ console.log(`packages: removing ${plan.uninstall.join(", ")}`);
622
+ await runNpm(["uninstall", ...plan.uninstall], packagesDir, options.verbose);
623
+ }
624
+ if (plan.install.length > 0) {
625
+ if (log)
626
+ console.log(`packages: installing ${plan.install.join(", ")}`);
627
+ // npm resolves a CWD-relative file: install arg against ITS OWN cwd
628
+ // (the spawned npm inherits sigrun's, but an operator's "./x" is
629
+ // authored against the deploy root) — absolutize path specifiers so
630
+ // the meaning never depends on the subprocess's working directory.
631
+ const installArgs = plan.install.map((specifier) => {
632
+ const id = identifySpecifier(specifier);
633
+ return id.pathShaped ? `file:${id.range}` : specifier;
634
+ });
635
+ await runNpm(["install", "--save-exact=false", ...installArgs], packagesDir, options.verbose);
636
+ // Post-install assertion: every DECLARED specifier must now resolve
637
+ // against the packages dir. npm exit 0 with nothing installed is the
638
+ // classic bad-specifier failure mode (typo'd name, unreachable
639
+ // registry serving an empty success) — catch it here, at boot, not
640
+ // at extension-load time.
641
+ const nodeModules = join(packagesDir, "node_modules");
642
+ for (const specifier of plan.install) {
643
+ const { name } = identifySpecifier(specifier);
644
+ if (!existsSync(join(nodeModules, ...name.split("/"), "package.json"))) {
645
+ throw new PackageSyncError(`packages: ${JSON.stringify(specifier)} installed without error but ` +
646
+ `${join(nodeModules, ...name.split("/"))} does not exist — the specifier ` +
647
+ `does not resolve to an installed package`);
648
+ }
649
+ }
650
+ }
651
+ // Uninstalled-but-still-present is npm's problem to have gotten right —
652
+ // npm uninstall is authoritative; no post-assertion (a leftover
653
+ // transitive dep directory is normal and not an error).
654
+ // Host-module links: after any npm pass (install or uninstall), so the
655
+ // dir the loader resolves from carries the host's runtime modules.
656
+ ensureHostModuleLinks(packagesDir);
657
+ if (log)
658
+ console.log("packages: sync complete");
659
+ }
660
+ /**
661
+ * Resolve an extension LOAD specifier against the packages dir first
662
+ * (deployment-installed wins), falling back to bare import resolution
663
+ * (paths, `file://` URLs, dev checkouts, image-bundled packages).
664
+ *
665
+ * Returns the absolute entry path to dynamic-import. The packages dir is
666
+ * an unrelated tree from sigrun's own `node_modules`, so Node's upward
667
+ * walk never finds packages-dir modules from sigrun's imports — the
668
+ * packages dir must be consulted explicitly, via its own root manifest as
669
+ * the resolution anchor. A bare name that resolves in BOTH places loads
670
+ * from the packages dir (the deployment's declared version wins over
671
+ * whatever the image happened to bundle).
672
+ *
673
+ * Returns `undefined` when nothing resolves — the loader quarantines with
674
+ * its standard not-found message.
675
+ */
676
+ export function resolveExtensionEntry(specifier, packagesDir) {
677
+ // Bare ./ ../ / relative paths are load-in-place (never packages-dir
678
+ // entries; the pre-packages behavior). NOTE: `file:` is NOT here —
679
+ // a `file:` LOAD specifier may name a package the packages dir
680
+ // installed under its own manifest name (via a file: INSTALL
681
+ // specifier — the npm symlinked checkout); resolve through the
682
+ // manifest first, then fall through to bare resolution.
683
+ if ((specifier.startsWith("./") || specifier.startsWith("../")) &&
684
+ !specifier.startsWith("file:")) {
685
+ return specifier;
686
+ }
687
+ if (specifier.startsWith("/") || specifier.startsWith("file://")) {
688
+ return specifier;
689
+ }
690
+ // A file:-installed package LOADS under its own manifest name, which
691
+ // resolves from the packages dir to the real (symlinked) source —
692
+ // exactly pi's "symlink a dev checkout" shape. For a file:-SHAPE load
693
+ // specifier, the manifest name is looked up BY PATH identity (npm
694
+ // records the dep under the target's own manifest name, not the
695
+ // specifier): find the dependency entry whose canonical path matches
696
+ // this specifier's.
697
+ const manifest = packagesManifestPath(packagesDir);
698
+ if (existsSync(manifest)) {
699
+ if (specifier.startsWith("file:") || specifier.startsWith("/")) {
700
+ const id = identifySpecifier(specifier);
701
+ const name = readPackageName(id.pathShaped ? id.range : specifier) ?? id.name;
702
+ const pkgDir = join(packagesDir, "node_modules", ...name.split("/"));
703
+ if (existsSync(pkgDir))
704
+ return packageEntry(pkgDir, name);
705
+ }
706
+ else {
707
+ try {
708
+ return importableEntry(createRequire(manifest).resolve(specifier));
709
+ }
710
+ catch {
711
+ // Not installed in the packages dir — fall through to bare
712
+ // resolution.
713
+ }
714
+ }
715
+ }
716
+ try {
717
+ return importableEntry(createRequire(import.meta.url).resolve(specifier));
718
+ }
719
+ catch {
720
+ return undefined;
721
+ }
722
+ }
723
+ /**
724
+ * Import-shape the resolved entry: `import()` must receive a `file://`
725
+ * URL (Windows absolutes are not import specifiers; POSIX absolutes are
726
+ * tolerated, but the URL is the portable form).
727
+ */
728
+ function importableEntry(absolutePath) {
729
+ return pathToFileURL(absolutePath).href;
730
+ }
731
+ /**
732
+ * Resolve a packages-dir package's ENTRY FILE — the thing `import()` can
733
+ * actually load. A bare package DIRECTORY url is not importable in ESM
734
+ * (`ERR_UNSUPPORTED_DIR_IMPORT`), so the loader needs the file.
735
+ * Self-resolution through the package's own manifest (anchored at its
736
+ * root — `exports` map or `main`) is the npm-blessed computation;
737
+ * conventional fallbacks (`index.js`, `dist/index.js`, `index.ts`) cover
738
+ * manifest-less shapes, and a package with none of those resolves to its
739
+ * dir so the loader's import fails LOUD (quarantined with the path)
740
+ * instead of silently resolving something adjacent.
741
+ */
742
+ function packageEntry(pkgDir, name) {
743
+ try {
744
+ return importableEntry(createRequire(join(pkgDir, "package.json")).resolve(name));
745
+ }
746
+ catch {
747
+ // No self-resolvable entry (no exports, no main) — probe the
748
+ // manifest and conventions in priority order.
749
+ }
750
+ // W5 Task 4: the manifest entry (sigrun.extension, else the first
751
+ // non-`.ts` pi entry — {@link manifestEntry} warns each `.ts` skip).
752
+ const manifest = manifestEntry(pkgDir);
753
+ if (manifest !== undefined)
754
+ return importableEntry(manifest);
755
+ for (const entry of ["index.js", "dist/index.js", "index.ts"]) {
756
+ if (existsSync(join(pkgDir, entry))) {
757
+ return importableEntry(join(pkgDir, entry));
758
+ }
759
+ }
760
+ // The package DIR is today's loud fallback: an unimportable entry the
761
+ // loader quarantines naming the path (the sync's caller decides
762
+ // skills-only when the package declared skill dirs).
763
+ return importableEntry(pkgDir);
764
+ }
765
+ // ---------------------------------------------------------------------------
766
+ // Git sources — the non-npm half of the unified extensions list
767
+ // ---------------------------------------------------------------------------
768
+ /** A git-source sync failure (clone, fetch, ref, or entry discovery). */
769
+ export class GitSyncError extends Error {
770
+ }
771
+ /**
772
+ * Strip userinfo credentials from a URL surfaced in an error or log:
773
+ * `https://user:token@host/path` renders as `https://<sanitized>@host/path`.
774
+ * Never echoes secret material.
775
+ */
776
+ export function sanitizeUrl(url) {
777
+ // Only strip userinfo that CONTAINS a colon (user:pass = credential).
778
+ // A plain non-colon user (`ssh://git@host`, the git-remote convention)
779
+ // is not secret material and stays visible.
780
+ return url.replace(/^(https?|ssh):\/\/[^/@]*:[^/@]+@/, (_match, scheme) => `${scheme}://<sanitized>@`);
781
+ }
782
+ /**
783
+ * The git checkout dir under the config dir: `<configDir>/git` (pi's
784
+ * package-manager layout — host/path segments, one dir per repo).
785
+ */
786
+ export function gitDirOf(configDir) {
787
+ return join(configDir, "git");
788
+ }
789
+ /**
790
+ * The clone target for one git source: `<gitDir>/<host>/<path>…`.
791
+ * Rejects any resulting path that escapes `gitDir` (the traversal guard).
792
+ */
793
+ export function gitTargetDir(gitDir, source) {
794
+ // Two dir shapes: absolute sources (file:/ https:// with real slashes)
795
+ // map to <gitDir>/<domain-ish prefix>/<hash of path> — flat, safe,
796
+ // and never encodes a filesystem path inside gitDir (a local
797
+ // file:///tmp/repo must not nest tmp dirs that could escape).
798
+ const stripped = source
799
+ .replace(/^(git:|https?:|ssh:)/, "")
800
+ .replace(/\.git$/, "")
801
+ .split("@")[0];
802
+ const segments = stripped.split("/").filter((s) => s !== "");
803
+ // Local absolute paths / URLs with a real scheme: flatten to a single
804
+ // segment keyed by a stable digest (traversal-safe by construction).
805
+ if (source.startsWith("file:") || source.startsWith("https:/") || source.startsWith("ssh:")) {
806
+ segments.splice(1);
807
+ }
808
+ // Hash every segment beyond the first two (host/leaf only in pi's
809
+ // layout) when the source is a full URL/abs path.
810
+ const leaf = segments.at(-1) ?? "";
811
+ const digest = createHash("sha256").update(stripped).digest("hex").slice(0, 12);
812
+ const safeSegments = [leaf || digest].map((s) => s.replace(/[^A-Za-z0-9._-]/g, "-"));
813
+ const target = join(gitDir, ...safeSegments);
814
+ if (!target.startsWith(gitDir)) {
815
+ throw new GitSyncError(`git source ${sanitizeUrl(source)} resolves outside the git dir`);
816
+ }
817
+ return target;
818
+ }
819
+ async function runGit(args, cwd) {
820
+ return new Promise((resolveResult, rejectResult) => {
821
+ execFile("git", args, { cwd, timeout: NPM_TIMEOUT_MS, encoding: "utf8" }, (error, stdout) => error === null
822
+ ? resolveResult(stdout)
823
+ : rejectResult(new GitSyncError(`git ${args[0]} failed: ${sanitizeUrl(String(stderrTail(error)))}`)));
824
+ });
825
+ }
826
+ /** The last stderr line of a child failure (concise context, no spam). */
827
+ function stderrTail(error) {
828
+ const raw = typeof error.stderr === "string" ? error.stderr : error.message;
829
+ const lines = raw.trim().split("\n").filter((l) => l !== "");
830
+ return lines.at(-1) ?? error.message;
831
+ }
832
+ /**
833
+ * Clone-or-reconcile one git source into `gitDir`. Absent → clone.
834
+ * Present + `ref` → fetch + checkout FETCH_HEAD (pi's ensureGitRef
835
+ * pattern). Present + no ref → plain fetch only (the checkout stays
836
+ * where the user left it — no silent rebases).
837
+ */
838
+ export async function syncGit(source, ref, gitDir, options = {}) {
839
+ const log = options.log !== false;
840
+ mkdirSync(gitDir, { recursive: true });
841
+ const target = gitTargetDir(gitDir, source);
842
+ const { source: fetchUrl } = splitGitRef(source, ref);
843
+ // A `git:` source is a LOCAL-first shorthand: `git:file://…` clones a
844
+ // local path (hermetic tests); any other host part stays as written
845
+ // (git itself resolves https/ssh schemes). The scheme is what pi uses;
846
+ // the shim only strips the `git:` marker itself.
847
+ const url = fetchUrl.startsWith("git:") ? fetchUrl.slice(4) : fetchUrl;
848
+ if (!existsSync(target)) {
849
+ if (log)
850
+ console.log(`git: cloning ${sanitizeUrl(url)} → ${target}`);
851
+ await runGit(["clone", url, target], gitDir);
852
+ }
853
+ else if (ref !== undefined) {
854
+ if (log)
855
+ console.log(`git: fetching ${sanitizeUrl(url)} @ ${ref}`);
856
+ await runGit(["fetch", "origin", ref], target);
857
+ await runGit(["checkout", "FETCH_HEAD"], target);
858
+ }
859
+ else {
860
+ if (log)
861
+ console.log(`git: fetching ${sanitizeUrl(url)} (no ref change)`);
862
+ await runGit(["fetch", "origin"], target);
863
+ }
864
+ return target;
865
+ }
866
+ /**
867
+ * The pi-compat `.ts` skip warning (spec §9.4): a `pi.extensions` entry
868
+ * ending in `.ts` cannot load under plain node (the build spec removed
869
+ * the `.ts` entry fallback) — it is skipped, keeping the package's
870
+ * skills loadable. ONE line per skipped entry, per discovery.
871
+ */
872
+ function warnTsPiExtension(entry) {
873
+ console.warn(`pi-compat: skipping TypeScript pi extension ${entry} (not loadable under node); loading the package's skills only`);
874
+ }
875
+ /** Read one package root's manifest (absent/unreadable → undefined). */
876
+ function readDiscoveryManifest(pkgRoot) {
877
+ const manifestPath = join(pkgRoot, "package.json");
878
+ if (!existsSync(manifestPath))
879
+ return undefined;
880
+ try {
881
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
882
+ }
883
+ catch {
884
+ return undefined;
885
+ }
886
+ }
887
+ /** A string-array manifest field (skills lists), else undefined. */
888
+ function manifestStringArray(value) {
889
+ if (!Array.isArray(value))
890
+ return undefined;
891
+ return value.every((entry) => typeof entry === "string")
892
+ ? value
893
+ : undefined;
894
+ }
895
+ /**
896
+ * A package root's manifest skill dirs (spec §9.4): `sigrun.skills`
897
+ * (string[]) else pi-compat `pi.skills` (string[] — same SKILL.md format,
898
+ * so no warning), each resolved against the package root, keeping only
899
+ * EXISTING directories. No manifest, no field, or nothing existing → [].
900
+ */
901
+ export function manifestSkills(pkgRoot) {
902
+ const manifest = readDiscoveryManifest(pkgRoot);
903
+ if (manifest === undefined)
904
+ return [];
905
+ const declared = manifestStringArray(manifest.sigrun?.skills) ??
906
+ manifestStringArray(manifest.pi?.skills);
907
+ if (declared === undefined)
908
+ return [];
909
+ const dirs = [];
910
+ for (const dir of declared) {
911
+ const resolved = resolve(pkgRoot, dir);
912
+ if (statIsDirectory(resolved))
913
+ dirs.push(resolved);
914
+ }
915
+ return dirs;
916
+ }
917
+ /** Whether `path` is an existing directory (never throws). */
918
+ function statIsDirectory(path) {
919
+ try {
920
+ return statSync(path).isDirectory();
921
+ }
922
+ catch {
923
+ return false;
924
+ }
925
+ }
926
+ /**
927
+ * A package root's manifest ENTRY (the discovery both git checkouts and
928
+ * packages-dir packages run, W5 Task 4): the `sigrun.extension` manifest
929
+ * value VERBATIM (an entry that does not resolve keeps today's loud
930
+ * load-time failure — never a silent skills-only downgrade), else the
931
+ * FIRST `pi.extensions` entry that does not end in `.ts` (each `.ts` skip
932
+ * warns once; a `.ts`-only list discovers no entry — the package loads
933
+ * its skills only). `undefined` when the manifest declares no usable
934
+ * entry.
935
+ */
936
+ export function manifestEntry(pkgRoot) {
937
+ const manifest = readDiscoveryManifest(pkgRoot);
938
+ if (manifest === undefined)
939
+ return undefined;
940
+ const sigrunEntry = manifest.sigrun?.extension;
941
+ if (typeof sigrunEntry === "string" && sigrunEntry !== "") {
942
+ return join(pkgRoot, sigrunEntry);
943
+ }
944
+ const piEntries = manifestStringArray(manifest.pi?.extensions);
945
+ if (piEntries !== undefined) {
946
+ for (const entry of piEntries) {
947
+ if (/\.ts$/.test(entry)) {
948
+ warnTsPiExtension(join(pkgRoot, entry));
949
+ continue;
950
+ }
951
+ return join(pkgRoot, entry);
952
+ }
953
+ console.warn(`pi-compat: no loadable pi extension in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
954
+ }
955
+ return undefined;
956
+ }
957
+ /**
958
+ * The package ROOT a resolution's manifest skills read from, for
959
+ * npm/file-shaped sources: the packages-dir install (`node_modules/<name>`
960
+ * under the packages dir — npm keys path-shaped installs by the target
961
+ * package's own manifest name), else the path target itself when it is a
962
+ * package dir. `undefined` when neither carries a manifest (a bare
963
+ * app-tree resolution — no root to read skills from; such a package
964
+ * contributes skills through its factory result instead).
965
+ */
966
+ function manifestRootOf(specifier, packagesDir) {
967
+ const id = identifySpecifier(specifier);
968
+ const name = id.pathShaped ? (readPackageName(id.range) ?? id.name) : id.name;
969
+ const installed = join(packagesDir, "node_modules", ...name.split("/"));
970
+ if (existsSync(join(installed, "package.json")))
971
+ return installed;
972
+ if (id.pathShaped && existsSync(join(id.range, "package.json")))
973
+ return id.range;
974
+ return undefined;
975
+ }
976
+ /**
977
+ * Entry discovery for a synced git checkout, in priority order: the
978
+ * object form's explicit `entry` (must exist — a declared entry that
979
+ * does not resolve is today's loud failure), the manifest entry
980
+ * (`sigrun.extension` verbatim, else the first non-`.ts` pi entry — see
981
+ * {@link manifestEntry}), or `dist/index.js`. Returns `undefined` when
982
+ * nothing is discoverable: the CALLER then decides skills-only (declared
983
+ * skills) vs today's loud throw. No `.ts` fallback (build-and-webui Task
984
+ * 3, BREAKING): the built server runs plain node, which cannot load a
985
+ * `.ts` entry — npm/git/path sources must ship a built entry.
986
+ */
987
+ export function findExtEntry(repoDir, explicitEntry) {
988
+ if (explicitEntry !== undefined) {
989
+ const entry = join(repoDir, explicitEntry);
990
+ if (!existsSync(entry)) {
991
+ throw new GitSyncError(`git source entry ${explicitEntry} does not exist in the checkout`);
992
+ }
993
+ return entry;
994
+ }
995
+ const manifest = manifestEntry(repoDir);
996
+ if (manifest !== undefined)
997
+ return manifest;
998
+ const dist = join(repoDir, "dist", "index.js");
999
+ if (existsSync(dist))
1000
+ return dist;
1001
+ return undefined;
1002
+ }