@awebai/oats 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  * oats package add|remove ... edit `packages:` in the workspace file
11
11
  * oats workspace status membership table, packages
12
12
  * oats capabilities | oats souls every visible item of the workspace
13
+ * oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>] [--json]
14
+ * what one capability ships (inject text, skill files)
13
15
  *
14
16
  * Workspace model v2 (docs/design/2026-09-23-workspace-module-contracts.md §6):
15
17
  * nothing is installed. `oats-local.yaml` names the workspace, `oats sync`
@@ -1027,9 +1029,13 @@ async function readinessCmd() {
1027
1029
  let readSession = null;
1028
1030
  /** The validated `--max-age` seconds (checked once at dispatch: maxAgeRefusal), null when not given. */
1029
1031
  let maxAgeGiven = null;
1032
+ /** The remote budget of this command's reads (ms), or null: only the deployment reads `status` and `workspace
1033
+ * status` have one (remote.mjs READ_REMOTE_BUDGET_MS; OATS_READ_REMOTE_BUDGET_MS overrides it), so they answer
1034
+ * inside a caller's own limit. Spawn, sync and every other verb read with no deadline. */
1035
+ let readBudgetMs = null;
1030
1036
  function commandSession() {
1031
1037
  if (!readSession) {
1032
- readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0 });
1038
+ readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0, ...(readBudgetMs !== null ? { deadline: Date.now() + readBudgetMs } : {}) });
1033
1039
  process.on("exit", () => { sayReadNotices(); readSession.closeNow(); });
1034
1040
  for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) process.once(signal, () => {
1035
1041
  readSession.closeNow();
@@ -1046,7 +1052,7 @@ function sayReadNotices() {
1046
1052
  }
1047
1053
  /** Which kernel command forms take --max-age: THE allow-list (docs/desktop-cli-api.md "Observation reuse").
1048
1054
  * → null when this form reads with observation reuse, else the E_BAD_ARGS message. `head` is argv before `--`. */
1049
- const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
1055
+ const MAX_AGE_READS = "status, workspace status, souls, capabilities, capabilities show, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
1050
1056
  function maxAgeRefusal(command, head) {
1051
1057
  const word = (i) => (head[i] !== undefined && !head[i].startsWith("--") ? head[i] : undefined);
1052
1058
  const refuse = (form) => `--max-age is not accepted by \`oats ${form}\`: only the read verbs reuse observations (${MAX_AGE_READS})`;
@@ -1645,14 +1651,25 @@ async function soulCmd() {
1645
1651
  if (doc.teams.length) printTable(["team", "id", "from", "why"], doc.teams.map((t) => [t.default ? `${t.label} (default)` : t.label, t.team ?? "(no id yet)", t.from, t.via.join(",")]));
1646
1652
  }
1647
1653
 
1648
- /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
1649
- async function itemsCmd(kind) {
1650
- const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1654
+ /** The catalog's rows of `kind` ("capabilities" | "souls") as `oats capabilities` / `oats souls` list them:
1655
+ * one discovery (honouring --max-age), the lock, workspaceItems. → { ctx, discovery, lock, items }. */
1656
+ async function catalogRows(kind, bail) {
1651
1657
  const ctx = workspaceContext(bail);
1652
1658
  const discovery = await discoverForCli(ctx, bail);
1653
1659
  let lock;
1654
1660
  try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1655
- const items = workspaceItems(discovery, lock, ctx.local, ctx.deploymentDir)[kind];
1661
+ return { ctx, discovery, lock, items: workspaceItems(discovery, lock, ctx.local, ctx.deploymentDir)[kind] };
1662
+ }
1663
+
1664
+ /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
1665
+ async function itemsCmd(kind) {
1666
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1667
+ if (kind === "capabilities") {
1668
+ const { words } = capabilitiesArgv();
1669
+ if (words[0] === "show") return capabilityShowCmd(bail);
1670
+ if (words.length) return bail("E_BAD_ARGS", `unknown capabilities subcommand ${JSON.stringify(words[0])}: \`oats capabilities\` lists the catalog, \`oats capabilities show <name>\` reads one capability`, { subcommand: words[0] });
1671
+ }
1672
+ const { ctx, discovery, lock, items } = await catalogRows(kind, bail);
1656
1673
  // One command's reads at a commit are shared: many souls resolve over the same manifests and listings.
1657
1674
  const remote = memoizedRemote(remoteModule);
1658
1675
  if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
@@ -1667,6 +1684,74 @@ async function itemsCmd(kind) {
1667
1684
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
1668
1685
  }
1669
1686
 
1687
+ /** `oats capabilities` argv after the command: its words (the subcommand first) with every flag and a value
1688
+ * flag's value set aside, and the first flag neither form takes (`unknown`). */
1689
+ function capabilitiesArgv() {
1690
+ const VALUE_FLAGS = new Set(["--member", "--package", "--file", "--dir", "--max-age", "--server"]);
1691
+ const words = [];
1692
+ let unknown;
1693
+ for (let i = 1; i < args.length; i++) {
1694
+ if (VALUE_FLAGS.has(args[i])) { if (args[i + 1] !== undefined && !args[i + 1].startsWith("--")) i++; continue; }
1695
+ if (args[i].startsWith("--")) { if (args[i] !== "--json") unknown ??= args[i]; continue; }
1696
+ words.push(args[i]);
1697
+ }
1698
+ return { words, unknown };
1699
+ }
1700
+
1701
+ /** `oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>] [--dir] [--json]`
1702
+ * (feature capability-show, capabilityShowApi 1; lib/capability-show.mjs): what one catalog row ships, read
1703
+ * at that row's commit — the rows are `oats capabilities`'s own (catalogRows). */
1704
+ async function capabilityShowCmd(bail) {
1705
+ const S = await import("../lib/capability-show.mjs");
1706
+ if (flag("server") !== undefined) return bail("E_BAD_ARGS", "oats capabilities show reads this machine's workspace only: --server is not accepted");
1707
+ const { words, unknown } = capabilitiesArgv();
1708
+ if (unknown) return bail("E_BAD_ARGS", `oats capabilities show: unknown flag ${unknown}`, { flag: unknown });
1709
+ const positionals = words.slice(1);
1710
+ if (positionals.length !== 1) return bail("E_BAD_ARGS", positionals.length ? `oats capabilities show takes one capability name, got ${positionals.map((p) => JSON.stringify(p)).join(" ")}` : "oats capabilities show needs a capability name (`oats capabilities` lists them)");
1711
+ const name = positionals[0];
1712
+ const memberArg = valueFlag("member"), packageArg = valueFlag("package"), file = valueFlag("file");
1713
+ if (memberArg !== undefined && packageArg !== undefined) return bail("E_BAD_ARGS", "choose --member <repoKey> or --package <id>, not both");
1714
+ if (file !== undefined && S.unsafeFilePath(file)) return bail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(file)} is not a relative path inside the capability`, { path: file });
1715
+ const { ctx, discovery, lock, items } = await catalogRows("capabilities", bail);
1716
+ // --member takes the repo key a row shows, or any ref spelling of it (compared on the canonical key).
1717
+ let member = memberArg ?? null;
1718
+ if (member !== null && !items.some((r) => r.repoKey === member)) { try { member = remoteModule.parseRepoRef(member).key; } catch { /* matches no row */ } }
1719
+ const remote = memoizedRemote(remoteModule);
1720
+ const catalog = (() => { try { return officialPackageCatalog(); } catch { return null; } })();
1721
+ let doc;
1722
+ try {
1723
+ const row = S.selectCapabilityRow(items, name, { member, package: packageArg ?? null });
1724
+ const source = await S.capabilitySource(row, { discovery, lock, catalog, remote, remoteOptions: ctx.remoteOptions });
1725
+ doc = file === undefined ? await S.capabilityShow(source, { remote, remoteOptions: ctx.remoteOptions }) : await S.capabilityFile(source, file, { remote, remoteOptions: ctx.remoteOptions });
1726
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1727
+ if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
1728
+ if (file !== undefined) {
1729
+ const f = doc.file;
1730
+ if (f.binary) console.log(`(${f.path}: binary, ${formatBytes(f.bytes)} — not shown)`);
1731
+ else {
1732
+ process.stdout.write(f.text.endsWith("\n") || f.text === "" ? f.text : `${f.text}\n`);
1733
+ if (f.truncated) console.log(`(${f.path}: truncated — ${formatBytes(f.bytes)}, the first ${formatBytes(S.TEXT_LIMIT)} shown)`);
1734
+ }
1735
+ return;
1736
+ }
1737
+ console.log(`${doc.name} — ${doc.kind === "member" ? `member ${doc.repoKey}` : `package ${doc.package} v${doc.version} (${doc.repoKey})`} @ ${short(doc.commit)}, ${doc.path}\n`);
1738
+ const size = (bytes) => (bytes === null ? "unreadable" : formatBytes(bytes));
1739
+ console.log(` inject: ${doc.inject ? `${doc.inject.path ?? "(unsafe path)"} (${size(doc.inject.bytes)}${doc.inject.binary ? ", binary" : ""}${doc.inject.truncated ? ", truncated" : ""})` : "(none)"}`);
1740
+ if (doc.skills === null) console.log(" skills: (cannot be listed — see problems)");
1741
+ else if (!doc.skills.length) console.log(" skills: (none)");
1742
+ else {
1743
+ console.log(" skills:");
1744
+ for (const skill of doc.skills) {
1745
+ console.log(` ${skill.name} ${skill.path}`);
1746
+ if (skill.files === null) console.log(" (files cannot be listed — see problems)");
1747
+ for (const f of skill.files ?? []) console.log(` ${f.path} ${size(f.bytes)}`);
1748
+ if (skill.filesTruncated) console.log(` … more files (the first ${S.FILES_PER_SKILL} are listed)`);
1749
+ }
1750
+ }
1751
+ for (const p of doc.problems) console.log(` problem: ${p.code}${p.path ? ` ${p.path}` : ""} — ${p.message}`);
1752
+ console.log(`\n a file's text: oats capabilities show ${doc.name}${doc.kind === "package" ? ` --package ${doc.package}` : ""} --file <path>`);
1753
+ }
1754
+
1670
1755
  /** Capability rows' manifest facts (feature desktop-facts): layer, description, and what each provides
1671
1756
  * (skills, commands, hooks by name) — member rows from discovery's manifest, package rows from the manifest
1672
1757
  * at the locked commit (the sync cache; no network beyond sync's). An unreadable package leaves its rows' facts null. */
@@ -2946,7 +3031,7 @@ function versionCmd() {
2946
3031
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2947
3032
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2948
3033
  // never listed.
2949
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
3034
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
2950
3035
  return;
2951
3036
  }
2952
3037
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3349,6 +3434,10 @@ try {
3349
3434
  maxAgeGiven = Number(raw);
3350
3435
  activateLocalInputs(); // observation.localRevision: every local config read from here on is recorded
3351
3436
  }
3437
+ if (kernelArgv && !head.includes("--server") && (cmd === "status" || (cmd === "workspace" && head[1] === "status"))) {
3438
+ const override = Number(process.env.OATS_READ_REMOTE_BUDGET_MS);
3439
+ readBudgetMs = Number.isSafeInteger(override) && override > 0 ? override : remoteModule.READ_REMOTE_BUDGET_MS;
3440
+ }
3352
3441
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
3353
3442
  if (inherited.length && cmd !== "version") refuse(`this environment carries a captured context (${inherited.join(", ")}): the captured/portable path was removed in 0.26, and nothing is run against the current context in its place — retire the captured home and re-spawn it from the deployment`, { inherited });
3354
3443
  if (cmd === "inspect" && head.includes("--request")) {
@@ -3606,6 +3695,10 @@ Usage:
3606
3695
  oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
3607
3696
  [--max-age <s>] private one is listed as repo-owned: usable only by
3608
3697
  its own repo's souls) + the locked packages
3698
+ oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>]
3699
+ [--dir <d>] [--json] [--max-age <s>] what one capability of that list ships, at its commit:
3700
+ its inject (text) and each skill's files (sizes);
3701
+ --file <path>: one listed file's text
3609
3702
  oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
3610
3703
  [--max-age <s>] (souls have no private mode), with origin
3611
3704
  (member <key> @ <commit> | package <id> v<ver>) and its
@@ -246,6 +246,23 @@ discovery; the details are in [souls-and-instances.md](souls-and-instances.md).
246
246
  Change a capability's inject or skills in its repository, then spawn a new
247
247
  instance: the generated files are not a source.
248
248
 
249
+ See what a capability ships before any instance has it:
250
+
251
+ ```bash
252
+ oats capabilities show oats.okf # inject path and size, each skill's files, problems
253
+ oats capabilities show oats.okf --file injects/okf.md # one listed file's text
254
+ oats capabilities show nw-house-style --member github.com/nw/agents --json
255
+ ```
256
+
257
+ `oats capabilities show <name>` reads one row of `oats capabilities` at that
258
+ row's commit (a package at its locked commit, after spawn's lock check): the
259
+ inject text exactly as committed, and each skill, enumerated as a spawn
260
+ enumerates it, with its description and files. `--file <path>` prints one
261
+ file the show lists (the inject or a skill file), and no other file of the
262
+ capability. Use `--member <repoKey>` or `--package <id>` when two rows share
263
+ the name. The JSON contract is in
264
+ [desktop-cli-api.md](desktop-cli-api.md#oats-capabilities-show).
265
+
249
266
  Inspect a composition before it exists:
250
267
 
251
268
  ```bash
@@ -38,9 +38,10 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
38
38
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
39
39
  "workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
40
40
  "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
41
- "preview-composed-from","observe-max-age","spawn-preview-max-age"],
41
+ "preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show"],
42
42
  "automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
43
- "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2}
43
+ "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2,
44
+ "capabilityShowApi":1}
44
45
  ```
45
46
 
46
47
  - The Desktop accepts `desktopApi === 1` and a released `version` inside
@@ -97,6 +98,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
97
98
  | `preview-composed-from` | `composedFrom` on preview `modules[]` ([Composition](#the-preview)) | |
98
99
  | `observe-max-age` | `--max-age <s>` on the read verbs and their `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311)) | |
99
100
  | `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
101
+ | `capability-show` | `oats capabilities show <name>` and its `--file` form, OATS 0.34.0 ([`oats capabilities show`](#oats-capabilities-show)) | `capabilityShowApi: 1` |
100
102
 
101
103
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
102
104
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -196,6 +198,7 @@ it and answer live, without the block.
196
198
  oats status | workspace status | souls | capabilities | inspect --soul|--home
197
199
  | teams | soul teams <soul> … --max-age <seconds> --json
198
200
  oats spawn <soul> … --preview --max-age <seconds> --json (feature spawn-preview-max-age)
201
+ oats capabilities show <name> … --max-age <seconds> --json (feature capability-show)
199
202
  ```
200
203
 
201
204
  - **Values:** whole seconds, `0` to `86400`. `0` is live: it reuses nothing.
@@ -253,8 +256,8 @@ oats spawn <soul> … --preview --max-age <seconds> --json (feature spawn-pre
253
256
  invocation refuse the flag before reading or writing anything, with
254
257
  `E_BAD_ARGS` "--max-age is not accepted by \`oats <form>\`: only the read
255
258
  verbs reuse observations (status, workspace status, souls, capabilities,
256
- inspect --soul|--home, spawn --preview, and the read forms of teams and soul
257
- teams)" and,
259
+ capabilities show, inspect --soul|--home, spawn --preview, and the read
260
+ forms of teams and soul teams)" and,
258
261
  with `--server`, "--max-age cannot be combined with --server: observation
259
262
  reuse is local to this machine".
260
263
  A capability command's argv (`oats <namespace> …`) is its provider's: the
@@ -734,6 +737,14 @@ Read-only (it writes no lock):
734
737
  ```
735
738
 
736
739
  - `members[]` and `packages[]` are the sync rows (packages from the lock).
740
+ - **Remote budget (0.33.1).** `oats workspace status` and `oats status` finish
741
+ their remote work within 12 s of their first remote read, whatever the
742
+ machine's load or another process holding a remote's cache. A member not
743
+ read by then is a `cannot-read` row whose `detail` ends `(timeout)`, and its
744
+ git is ended; the command still answers. The workspace definition itself
745
+ (the host) not read by then fails the command as any unreadable host does
746
+ (`E_REMOTE_UNREADABLE`, `reason: "timeout"`). Spawn, sync and every other
747
+ verb have no such budget.
737
748
  - `declaredPackages`: the ids in `packages:` (standalone: the kernel's
738
749
  default). `unsynced`: declared, not locked. `stale`: locked, no longer
739
750
  declared. `external[]`: `{source, soul}`.
@@ -810,6 +821,175 @@ problem`, and (feature `launch-preference`) `key` and `launch`.
810
821
  This document keeps `soulsApi: 1`; the probe's `soulsApi: 2` is the inspect
811
822
  soul row's.
812
823
 
824
+ ### `oats capabilities show`
825
+
826
+ Feature `capability-show`, `capabilityShowApi: 1`, OATS 0.34.0.
827
+
828
+ ```text
829
+ oats capabilities show <name> [--member <repoKey> | --package <id>] [--dir <d>] [--max-age <s>] --json
830
+ oats capabilities show <name> [--member <repoKey> | --package <id>] --file <path> [--dir <d>] [--max-age <s>] --json
831
+ ```
832
+
833
+ What one capability ships: its inject text and each skill's files, and one
834
+ file's text on request. The Desktop's capability page shows them without
835
+ reading clones or caches itself.
836
+
837
+ > **Every `text` and `description` is untrusted repository content.** Render
838
+ > it as plain text (`textContent`), or through a sanitising Markdown renderer
839
+ > that allows no raw HTML, no scripts and no remote images.
840
+
841
+ **Selection.** The rows are exactly the rows of `oats capabilities --json`
842
+ (one discovery, honouring `--max-age`), so the answer's `commit` equals that
843
+ row's `commit`.
844
+
845
+ - `<name>` alone selects the one row with that name.
846
+ - `--member <repoKey>` selects a member row of that repository: the key as a
847
+ row shows it, or any ref spelling of the same repository.
848
+ - `--package <id>` selects that package's row.
849
+ - No row: `E_CAPABILITY_UNKNOWN`, `details: {name}` plus `member` or
850
+ `package` when one was given. An unsynced package is not in the catalog, so
851
+ its capabilities are `E_CAPABILITY_UNKNOWN` until `oats sync`.
852
+ - More than one row: `E_CAPABILITY_AMBIGUOUS`, `details: {name, candidates}`,
853
+ each candidate `{kind, repoKey, origin}` (member) or `{kind, package,
854
+ origin}` (package). Choose one with `--member` or `--package`.
855
+ - `E_BAD_ARGS` for `--member` with `--package`, a missing or second name, an
856
+ unknown flag, and `--server` (the verb reads this machine's workspace only).
857
+ An unknown word after `oats capabilities` (`oats capabilities foo`) is
858
+ `E_BAD_ARGS` too.
859
+
860
+ **Trust path.** Every read goes through the remote cache at a full commit id;
861
+ nothing reads a working clone.
862
+
863
+ - A member capability is read from its member repository at the row's commit.
864
+ - A package capability is read at the locked commit, the same trust path
865
+ spawn uses. First comes spawn's lock check: when the package manifest at
866
+ that commit does not list the capability, or its capability list differs
867
+ from the lock's, the show refuses `E_PACKAGE_INTEGRITY` with spawn's
868
+ details. The full-tree content digest is not recomputed per show: `oats
869
+ sync` proved the lock's integrity over the tree of exactly that commit, and
870
+ the commit id content-addresses the tree.
871
+
872
+ **The show:**
873
+
874
+ ```json
875
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.5",
876
+ "commit":"26d8216f…","path":"oats-package/capabilities/oats-okf",
877
+ "inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
878
+ "skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
879
+ "files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
880
+ "filesTruncated":false},
881
+ {"name":"okf-instance-knowledge","path":"skills/okf-instance-knowledge","description":"Keeping this instance's own knowledge …",
882
+ "files":[{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787}],"filesTruncated":false}],
883
+ "problems":[]}
884
+ ```
885
+
886
+ - `kind` is `member` or `package`. `repoKey` is set for both kinds; for a
887
+ package it is the repository the package is read from. `package` and
888
+ `version` are the package id and locked version, `null` for a member.
889
+ - `commit` is 40 hex and equals the catalog row's `commit`. `path` is the
890
+ capability directory, repository-relative.
891
+ - `inject` is `{path, bytes, text, binary, truncated}` or `null`. `skills`
892
+ is a list of `{name, path, description, files, filesTruncated}`, each file
893
+ `{path, bytes}`, or `null`. `problems` is a list of `{code, message,
894
+ path}`.
895
+ - With `--max-age` (`0` included) both the show and the `--file` answer gain
896
+ the [`observation`](#observation-reuse-feature-observe-max-age-oats-0311)
897
+ block `{observedAt, reused, localRevision}`.
898
+
899
+ **The `--file` answer:**
900
+
901
+ ```json
902
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"26d8216f…",
903
+ "file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
904
+ ```
905
+
906
+ **Rules.**
907
+
908
+ - **Paths.** Every `path` in `inject`, `skills`, `file` and `problems` is
909
+ POSIX and relative to the capability directory, never the repository.
910
+ Every non-null `path` is a safe relative path: no `.`, `..` or `.git`
911
+ component (any case), no empty component, no `\`. A manifest's inject and
912
+ skill paths are reported as the module install reads them: a `\` is a
913
+ separator, and a leading `./` and trailing slashes are dropped
914
+ (`injects\guide.md` is `injects/guide.md`).
915
+ - **The inject** is the committed file exactly: untrimmed and untemplated.
916
+ (Spawn composes it raw and trimmed, with no settings substitution.)
917
+ - `inject: null`: the manifest declares no inject.
918
+ - Declared but unreadable (missing, a symlink, a directory, over the read
919
+ budget): `inject: {path, bytes: null, text: null, binary: false,
920
+ truncated: false}` and a `problems[]` entry with the remote's code
921
+ (`E_REMOTE_PATH_MISSING`, `E_REMOTE_TREE_UNSAFE`, `E_REMOTE_FILE_OVERSIZE`,
922
+ …). The show still answers ok.
923
+ - Declared as a path that is not a safe relative path (spawn refuses it):
924
+ `inject: {path: null, bytes: null, text: null, binary: false, truncated:
925
+ false}` and a problem with spawn's code (`E_CAPABILITY_MISSING` for a
926
+ member, `E_PACKAGE_MANIFEST` for a package) and `path: null`. The raw
927
+ manifest value appears only inside `message`, JSON-quoted. So
928
+ `inject.path` is `null` only with a problem.
929
+ - **Skills** are in the catalog row's order (by name, in codepoint order),
930
+ enumerated exactly as a spawn enumerates them. `skills` is `null` exactly
931
+ when the catalog row's `skills` is `null`, with a problem carrying spawn's
932
+ code (`E_CAPABILITY_MISSING` or `E_PACKAGE_MANIFEST`) or an `E_REMOTE_*`
933
+ code, and `path: null`. A skill whose path is not safe (a `.git`
934
+ directory) makes the skills unlistable the same way, in both answers.
935
+ - **Files.** `files` is every regular file under the skill directory,
936
+ recursively (no symlinks, no submodules), sorted by path in codepoint order.
937
+ At most 200 per skill; beyond that `filesTruncated` is `true`. `bytes` is
938
+ the blob size. When a skill's files cannot be listed (an unsafe entry name
939
+ in the tree, an unreadable remote), `files` is `null`, `filesTruncated` is
940
+ `false`, and a problem's `path` is the skill's `path`: "could not list"
941
+ never collapses into "listed nothing".
942
+ - **`description`** is the `description` key of SKILL.md's leading `---` YAML
943
+ front matter, when it is a string. It is parsed from the whole SKILL.md,
944
+ not from its 262144-byte text cut. It is `null` when the front matter is
945
+ absent or does not parse, the key is absent or not a string, or SKILL.md is
946
+ unreadable or binary (no problem is reported for it). It is cut to at most
947
+ 1024 UTF-8 bytes on a code point boundary.
948
+ - **Text.** A file is `binary: true, text: null` when it contains a NUL byte
949
+ or is not valid UTF-8. Only the first 262144 + 3 bytes are examined, so the
950
+ cut is decided on the same bytes; when the file is longer, a valid sequence
951
+ they end inside of is not held against it. Otherwise `text` is the content cut to at
952
+ most 262144 UTF-8 bytes on a code point boundary, with `truncated: true`
953
+ when cut. A byte order mark is kept in the text. `bytes` is always the real
954
+ size.
955
+ - **Invariants** (pinned for the Desktop):
956
+ - `binary: true` ⇒ `text: null` and `truncated: false` (`truncated` is a
957
+ text-only flag).
958
+ - `truncated: true` ⇒ `text` is a string and `binary: false`.
959
+ - `bytes` is `null` only for an unreadable declared inject (with its
960
+ problem). In a `--file` answer it is always an integer.
961
+ - **Large files.** A file over the read budget (4 MiB) is listed with its
962
+ size. `--file` refuses it with `E_REMOTE_FILE_OVERSIZE`, passed through
963
+ unchanged: its `details.path` is repository-relative, not
964
+ capability-relative.
965
+
966
+ **`--file <path>`** reads one file the show lists, and nothing else.
967
+
968
+ - A syntactically unsafe path is `E_CAPABILITY_FILE_UNSAFE`, `details:
969
+ {path}`, before anything is read: absolute, empty, a `.`, `..` or `.git`
970
+ component (any case), an empty component, a trailing slash, a `\` or a NUL.
971
+ - Otherwise the path must be the inject's `path` or a path in some skill's
972
+ `files` as the show lists it (the 200 cap included). Anything else is
973
+ `E_CAPABILITY_FILE_UNKNOWN`, `details: {path, name}`. No other file of the
974
+ capability (`oats.json`, scripts, `bin/`) is readable through this verb.
975
+ - A file the show does not list (beyond the 200 cap, a symlink, under a skill
976
+ whose files cannot be listed) is `E_CAPABILITY_FILE_UNKNOWN` by design:
977
+ show "not available", not an error.
978
+ - A listed file the remote cannot read answers the remote's own code
979
+ (`E_REMOTE_FILE_OVERSIZE`, `E_REMOTE_PATH_MISSING`, `E_REMOTE_TREE_UNSAFE`,
980
+ …).
981
+
982
+ **Failures.** Every failure is exactly one error envelope on stdout with a
983
+ nonzero exit, as for every command: `E_BAD_ARGS`, `E_CAPABILITY_UNKNOWN`,
984
+ `E_CAPABILITY_AMBIGUOUS`, `E_PACKAGE_INTEGRITY`, `E_CAPABILITY_FILE_UNSAFE`,
985
+ `E_CAPABILITY_FILE_UNKNOWN`, an `E_REMOTE_*` code, and the workspace's own
986
+ (`E_LOCAL_MISSING`, `E_LOCK_SCHEMA`, …).
987
+
988
+ **Without `--json`** the show prints a short listing for an operator: the
989
+ inject's path and size, each skill with its files and sizes, and the
990
+ problems. `--file` prints the text; a binary file prints a one-line note
991
+ instead, and a truncated file prints its text followed by a one-line note.
992
+
813
993
  <a id="desktop-facts-feature-desktop-facts-oats-0290"></a>
814
994
  ### Desktop facts
815
995
 
@@ -48,6 +48,7 @@ published to npm. Its developer docs are in
48
48
  | `workspace.mjs` | workspace, membership and soul files; discovery |
49
49
  | `resolve.mjs` | a soul's resolution: capabilities, slots, provenance |
50
50
  | `packages.mjs` | `packages:`, the catalog, `oats sync`, `oats-lock.json` |
51
+ | `capability-show.mjs` | `oats capabilities show`: one catalog row's inject and skill files, read at its commit |
51
52
  | `materialize.mjs` | copying modules into a home and composing it |
52
53
  | `core.mjs` | spawn, retire, sessions, hooks, launch recipes, instance metadata |
53
54
  | `instruction-composition.mjs` | the generated `AGENTS.md` |
@@ -104,6 +105,19 @@ gets the plain per-call behaviour. Within a session:
104
105
  command that ends normally awaits the close, so its readers are reaped
105
106
  before it exits; a `process.exit` (every refusal) ends them in the
106
107
  exit hook (`closeNow`), and the system reaps them once the process is gone;
108
+ - a session may have a `deadline` (`READ_REMOTE_BUDGET_MS`, 12 s after it
109
+ starts): the CLI gives one to `status` and `workspace status` only
110
+ (`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS` overrides it for tests). Every
111
+ remote step then gets what is left of it instead of its own default: each
112
+ git call's timeout (`sessionExec`: ls-remote, fetch, ls-tree, the cache's
113
+ plumbing; none starts once nothing is left), the git version probe
114
+ (`readVersion`), the batch readers' answers, the cache write lock's wait,
115
+ the half-initialised cache's wait and the lock-race backoff. What the
116
+ deadline ends is a `timeout` (a peel or version it ended is never read as a
117
+ missing commit or an older git), so an unread member degrades as any
118
+ unreadable one. A cut wait never changes what it judges: past the deadline
119
+ no lock is taken or reclaimed (live, stale or unreadable), and a cache
120
+ directory waited for less than in full is not taken for a crash's leftover;
107
121
  - every git child is ended with SIGTERM first and SIGKILL only after a
108
122
  grace (`terminateGroup`): git removes its own lock files on SIGTERM, and
109
123
  a git killed outright leaves one that blocks every later write. The
@@ -0,0 +1,63 @@
1
+ # OATS 0.34.0
2
+
3
+ ## Added
4
+
5
+ - **`oats capabilities show <name>`** (feature `capability-show`,
6
+ `capabilityShowApi: 1`). It answers what one capability of the catalog
7
+ ships: its inject text exactly as committed, and each skill, enumerated as
8
+ a spawn enumerates it, with its SKILL.md description and its files with
9
+ their sizes. `--file <path>` returns the text of one file the show lists
10
+ (the inject or a skill file) and refuses any other file of the capability.
11
+ The rows are `oats capabilities`'s own (`--member <repoKey>` or `--package
12
+ <id>` picks one when names collide, and `--max-age` is accepted), and the
13
+ reads take the trust path spawn takes: a member at the row's commit, a
14
+ package at its locked commit after spawn's lock check (`E_PACKAGE_INTEGRITY`),
15
+ always through the remote cache, never a working clone. Every text and
16
+ description is untrusted repository content: render it as plain text or
17
+ through a sanitising Markdown renderer. The Desktop's capability page reads
18
+ it ([desktop-cli-api.md](../desktop-cli-api.md#oats-capabilities-show)).
19
+ `oats capabilities --json` is unchanged; an unknown word after
20
+ `oats capabilities` is now `E_BAD_ARGS`. A skill path with a `.git`
21
+ component, which a spawn's fetch refuses, now gives `skills: null` on that
22
+ catalog row too.
23
+
24
+ - **The Desktop's capability page shows what a capability ships.** A new
25
+ *Contents* section, between *Provides* and *Used by*, lists the injected
26
+ instructions and every skill's files on the left and shows the selected
27
+ file on the right: Markdown rendered (a SKILL.md's front matter as a small
28
+ table), other text as highlighted code. A relative link to another listed
29
+ file opens it in place. The content is untrusted repository text, so no raw
30
+ HTML, script or image is rendered. The section reads `oats capabilities
31
+ show` (feature `capability-show`) and needs this release's CLI; with an
32
+ older one it says so and reads nothing. Remote workspaces show it later
33
+ ([desktop-file-viewer.md](../../packages/desktop/docs/desktop-file-viewer.md#rendering-rendererviewsmarkdownmjs)).
34
+ *Provides* above it is now one compact card, with every skill, command and
35
+ hook as its own chip.
36
+
37
+ ## Changed
38
+
39
+ - **The Desktop's Instance tab says how an instance works in one sentence.**
40
+ "Where it works" becomes **Work**: the work mode's tile and a sentence,
41
+ for example "Works in its own worktree of oats, on branch main." or "Has its
42
+ own folder, not tied to one repository". Folder and Home move into a closed
43
+ **Paths** disclosure, each with Copy. The ahead/behind counts stay on the
44
+ Developer tab. **Messaging & Teams** shows the instance's **Messaging ID**
45
+ with Copy, then its **Teams**, introduced as the messaging teams its soul is
46
+ allowed to join.
47
+
48
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.35.0`**, so it runs against
49
+ this release's kernel. Install the CLI and the Desktop 0.34.0 together: the
50
+ Desktop 0.33.x refuses a 0.34 CLI.
51
+
52
+ ## Fixed
53
+
54
+ - **`oats status` and `oats workspace status` answer within 12 s of remote
55
+ work, even under load.** Each git call they made had its own 30 s limit, and
56
+ a read could wait up to 11 minutes behind another process fetching the same
57
+ remote, so the Desktop's 30 s limit could end them (`E_CLI_TIMEOUT`). Now
58
+ every remote step of these two reads gets what is left of a 12 s budget: a
59
+ member not read by then is a `cannot-read` row (`… (timeout)`) and its git is
60
+ ended, and the command still answers. A workspace definition not read by then
61
+ fails as an unreadable one does today (`reason: "timeout"`). Another
62
+ process's write to the remote cache is waited for only as long, never taken
63
+ over. Spawn, sync and the other verbs are unchanged.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * lib/capability-show.mjs — `oats capabilities show` (feature capability-show, capabilityShowApi 1): what one
3
+ * catalog row of `oats capabilities` ships — its inject text and each skill's files — and one file's text on
4
+ * request, read at the row's commit through the read path a spawn uses. Contract: docs/desktop-cli-api.md
5
+ * "`oats capabilities show`".
6
+ *
7
+ * - A member row is read from its member repository at the row's commit, a package row at the LOCKED commit,
8
+ * after the lock check a spawn makes (lockedCapability: the package's capability list there is the lock's).
9
+ * The package's full-tree digest is not recomputed: `oats sync` proved the lock's integrity over the tree of
10
+ * exactly that commit, and the commit id content-addresses the tree.
11
+ * - Skills are the spawn's enumeration (capabilitySkills over enumerateSkills, lib/resolve.mjs); a skill's
12
+ * files are every regular file under its directory (listRemoteFiles), at most FILES_PER_SKILL.
13
+ * - Every read goes through lib/remote.mjs at a full commit id: nothing reads a working clone and nothing is
14
+ * written outside the remote cache.
15
+ * - Paths in the answers are POSIX and relative to the capability directory. `--file` reads only a path the
16
+ * show lists (the inject, a listed skill file): no other file of the capability is readable through it.
17
+ *
18
+ * Every text is untrusted repository content; a consumer renders it as plain text or through a sanitising
19
+ * renderer (the docs say so).
20
+ */
21
+ import { posix } from "node:path";
22
+ import YAML from "yaml";
23
+ import { oatsError } from "./errors.mjs";
24
+ import { bindRemote } from "./packages.mjs";
25
+ import { capabilitySkills, lockedCapability, lockedPackageCapabilities, manifestFilePath, memberRef, unsafeRelPath } from "./resolve.mjs";
26
+ import { memberRowByKey } from "./workspace.mjs";
27
+
28
+ export const CAPABILITY_SHOW_API = 1;
29
+ /** A text is cut to at most this many UTF-8 bytes; binary detection examines this many + 3. */
30
+ export const TEXT_LIMIT = 262144;
31
+ /** A skill lists at most this many files (`filesTruncated` beyond). */
32
+ export const FILES_PER_SKILL = 200;
33
+ /** A skill's `description` is cut to at most this many UTF-8 bytes. */
34
+ export const DESCRIPTION_LIMIT = 1024;
35
+
36
+ function fail(code, message, details) {
37
+ const e = oatsError(code, message, details);
38
+ if (details !== undefined) e.details = details;
39
+ return e;
40
+ }
41
+ const isOatsError = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
42
+ const decoder = () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
43
+ const isContinuation = (byte) => (byte & 0xc0) === 0x80;
44
+
45
+ /** The cut of `bytes` at most `limit` long, on a code point boundary (bytes are valid UTF-8 there). */
46
+ function cutAt(bytes, limit) {
47
+ if (bytes.length <= limit) return bytes.length;
48
+ let cut = limit;
49
+ while (cut > 0 && isContinuation(bytes[cut])) cut--;
50
+ return cut;
51
+ }
52
+
53
+ /**
54
+ * A file's bytes as the answers carry them: → { text, binary, truncated }. Binary when the examined bytes (the
55
+ * first TEXT_LIMIT + 3) hold a NUL or are not valid UTF-8 (TextDecoder fatal, BOM kept); otherwise `text` is
56
+ * the content cut to at most TEXT_LIMIT bytes on a code point boundary, `truncated` when cut. The examined
57
+ * bytes include the whole code point that straddles the limit, so the cut is decided on the same bytes.
58
+ */
59
+ export function decodeText(bytes, limit = TEXT_LIMIT) {
60
+ const head = bytes.subarray(0, limit + 3);
61
+ const binary = { text: null, binary: true, truncated: false };
62
+ if (head.includes(0)) return binary;
63
+ // A file longer than the examined bytes may continue a valid sequence they end inside of: `stream` accepts
64
+ // such an incomplete tail, and still refuses every invalid byte among them.
65
+ try { decoder().decode(head, { stream: bytes.length > head.length }); }
66
+ catch { return binary; }
67
+ const cut = cutAt(bytes, limit);
68
+ return { text: decoder().decode(bytes.subarray(0, cut)), binary: false, truncated: cut < bytes.length };
69
+ }
70
+
71
+ /** `text` cut to at most `limit` UTF-8 bytes on a code point boundary. */
72
+ export function cutUtf8(text, limit) {
73
+ const bytes = Buffer.from(text, "utf8");
74
+ return bytes.length <= limit ? text : bytes.subarray(0, cutAt(bytes, limit)).toString("utf8");
75
+ }
76
+
77
+ const FRONT_MATTER_RE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
78
+ /** The `description` of a SKILL.md's leading `---` YAML front matter (a string only), cut to DESCRIPTION_LIMIT
79
+ * bytes; null when there is no front matter, it does not parse, or the key is absent or not a string. */
80
+ export function skillDescription(text) {
81
+ if (typeof text !== "string") return null;
82
+ const m = FRONT_MATTER_RE.exec(text.replace(/^\uFEFF/, ""));
83
+ if (!m) return null;
84
+ let data;
85
+ try { data = YAML.parse(m[1] ?? "", { logLevel: "error" }); } catch { return null; }
86
+ if (data === null || typeof data !== "object" || Array.isArray(data) || typeof data.description !== "string") return null;
87
+ return cutUtf8(data.description, DESCRIPTION_LIMIT);
88
+ }
89
+
90
+ /** A `--file` path no listing could hold (lib/resolve.mjs unsafeRelPath): absolute, empty, a `.`/`..`/`.git`
91
+ * component (any case), an empty component, a trailing slash, a backslash or a NUL. */
92
+ export const unsafeFilePath = unsafeRelPath;
93
+
94
+ /** The text a SKILL.md's description is parsed from: the whole readable file (its front matter may run past the
95
+ * display cut), null when the file is binary by decodeText's rule. */
96
+ function skillText(bytes) {
97
+ if (decodeText(bytes).binary) return null;
98
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
99
+ }
100
+
101
+ /** The catalog row a show reads: rows named `name`, narrowed by `--member <repoKey>` (a member row of that
102
+ * repository) or `--package <id>` (that package's row). 0 → E_CAPABILITY_UNKNOWN; >1 → E_CAPABILITY_AMBIGUOUS. */
103
+ export function selectCapabilityRow(rows, name, { member = null, package: pkg = null } = {}) {
104
+ let matches = rows.filter((r) => r.name === name);
105
+ if (member !== null) matches = matches.filter((r) => r.kind === "member" && r.repoKey === member);
106
+ if (pkg !== null) matches = matches.filter((r) => r.kind === "package" && r.package === pkg);
107
+ const selector = member !== null ? { member } : pkg !== null ? { package: pkg } : {};
108
+ if (matches.length === 0) {
109
+ const where = member !== null ? ` from member ${member}` : pkg !== null ? ` from package ${pkg}` : "";
110
+ throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(name)}${where} in this workspace's catalog (\`oats capabilities\` lists them; a package's capabilities appear after \`oats sync\`)`, { name, ...selector });
111
+ }
112
+ if (matches.length > 1) {
113
+ const candidates = matches.map((r) => (r.kind === "package" ? { kind: r.kind, package: r.package, origin: r.origin } : { kind: r.kind, repoKey: r.repoKey, origin: r.origin }));
114
+ throw fail("E_CAPABILITY_AMBIGUOUS", `${matches.length} capabilities are named ${JSON.stringify(name)} (${candidates.map((c) => c.origin).join("; ")}): choose one with --member <repoKey> or --package <id>`, { name, candidates });
115
+ }
116
+ return matches[0];
117
+ }
118
+
119
+ /**
120
+ * Where a catalog row is read from: → { name, kind, repoKey, package, version, commit, ref, dir, manifest,
121
+ * missingCode }. A member row from discovery (its capability path and manifest, the member's ref); a package
122
+ * row from its manifests at the locked commit, after the spawn's lock check (E_PACKAGE_INTEGRITY).
123
+ */
124
+ export async function capabilitySource(row, { discovery, lock, catalog = null, remote, remoteOptions }) {
125
+ if (row.kind === "package") {
126
+ const entry = lock?.packages?.[row.package];
127
+ if (!entry) throw fail("E_CAPABILITY_UNKNOWN", `package ${row.package} is not in the lock — run \`oats sync\``, { name: row.name, package: row.package });
128
+ const { ref, capabilities } = await lockedPackageCapabilities(row.package, entry, { catalog, remote, remoteOptions });
129
+ const cap = lockedCapability(row.name, row.package, entry, capabilities);
130
+ return { name: row.name, kind: "package", repoKey: remote.parseRepoRef(ref).key, package: row.package, version: entry.version, commit: entry.commit, ref, dir: cap.dir, manifest: cap.manifest, missingCode: "E_PACKAGE_MANIFEST" };
131
+ }
132
+ const cap = memberRowByKey(discovery.members, row.repoKey)?.capabilities.find((c) => c.name === row.name);
133
+ if (!cap) throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(row.name)} in member ${row.repoKey}`, { name: row.name, member: row.repoKey });
134
+ return { name: row.name, kind: "member", repoKey: row.repoKey, package: null, version: null, commit: row.commit, ref: memberRef(discovery, remote, row.repoKey), dir: cap.path, manifest: cap.manifest, missingCode: "E_CAPABILITY_MISSING" };
135
+ }
136
+
137
+ const problemOf = (e, path) => ({ code: e.code, message: e.message, path });
138
+
139
+ /** What a source lists, without reading any file's content: → { inject: { path (null when unsafe), safe } | null,
140
+ * skills: [{ name, path, files: [{ path, bytes }] | null, filesTruncated }] | null, problems }. */
141
+ async function listing(source, { remote, remoteOptions }) {
142
+ const r = bindRemote(remote, remoteOptions);
143
+ const { ref, commit, dir, manifest, missingCode } = source;
144
+ const problems = [];
145
+ let inject = null;
146
+ if (typeof manifest.inject === "string" && manifest.inject) {
147
+ const path = manifestFilePath(manifest.inject);
148
+ // An unsafe declared path is never put in a `path` field (every path in the answers is safe): null, and the
149
+ // raw value only inside the problem's message.
150
+ inject = { path, safe: path !== null };
151
+ if (!path) problems.push({ code: missingCode, message: `${source.name} declares inject ${JSON.stringify(manifest.inject)}, which is not a relative path inside the capability`, path: null });
152
+ }
153
+ const { skills: found, problem } = await capabilitySkills({ ref, commit, dir, manifest, remote, remoteOptions, missingCode });
154
+ if (!found) problems.push(problemOf(problem, null));
155
+ const skills = found && [];
156
+ for (const skill of found ?? []) {
157
+ // A skill whose files cannot be listed has `files: null` (and a problem): never "listed nothing".
158
+ // Only the listed files' sizes are learned: nothing past FILES_PER_SKILL is fetched (#409).
159
+ let listed = null;
160
+ try { listed = await r.listRemoteFiles(ref, commit, posix.join(dir, skill.path), { limit: FILES_PER_SKILL }); }
161
+ catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, skill.path)); }
162
+ skills.push({ name: skill.name, path: skill.path, files: listed && listed.files.map((f) => ({ path: `${skill.path}/${f.path}`, bytes: f.size })), filesTruncated: listed !== null && listed.total > FILES_PER_SKILL });
163
+ }
164
+ return { inject, skills, problems };
165
+ }
166
+
167
+ const header = (source) => ({ capabilityShowApi: CAPABILITY_SHOW_API, name: source.name, kind: source.kind });
168
+
169
+ /** The show answer (capabilityShowApi 1): the inject with its text, each skill with its description and files. */
170
+ export async function capabilityShow(source, { remote, remoteOptions }) {
171
+ const r = bindRemote(remote, remoteOptions);
172
+ const { ref, commit, dir } = source;
173
+ const listed = await listing(source, { remote, remoteOptions });
174
+ const problems = [...listed.problems];
175
+ let inject = null;
176
+ if (listed.inject) {
177
+ inject = { path: listed.inject.path, bytes: null, text: null, binary: false, truncated: false };
178
+ if (listed.inject.safe) {
179
+ try { const { bytes, size } = await r.readRemoteFile(ref, commit, posix.join(dir, inject.path)); inject = { path: inject.path, bytes: size, ...decodeText(bytes) }; }
180
+ catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, inject.path)); }
181
+ }
182
+ }
183
+ let skills = null;
184
+ if (listed.skills) {
185
+ skills = [];
186
+ for (const skill of listed.skills) {
187
+ let description = null;
188
+ try {
189
+ const { bytes } = await r.readRemoteFile(ref, commit, posix.join(dir, skill.path, "SKILL.md"));
190
+ description = skillDescription(skillText(bytes));
191
+ } catch (e) { if (!isOatsError(e)) throw e; }
192
+ skills.push({ name: skill.name, path: skill.path, description, files: skill.files, filesTruncated: skill.filesTruncated });
193
+ }
194
+ }
195
+ return { ...header(source), repoKey: source.repoKey, package: source.package, version: source.version, commit, path: dir, inject, skills, problems };
196
+ }
197
+
198
+ /** The `--file` answer: one file the show lists (the inject, or a listed skill file), at the row's commit.
199
+ * E_CAPABILITY_FILE_UNSAFE for a path no listing could hold; E_CAPABILITY_FILE_UNKNOWN for one it does not
200
+ * list; the remote's own refusal (E_REMOTE_FILE_OVERSIZE, …) for one it lists but cannot read. */
201
+ export async function capabilityFile(source, path, { remote, remoteOptions }) {
202
+ if (unsafeFilePath(path)) throw fail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(path)} is not a relative path inside the capability`, { path });
203
+ const listed = await listing(source, { remote, remoteOptions });
204
+ const known = (listed.inject?.safe && listed.inject.path === path) || (listed.skills ?? []).some((s) => (s.files ?? []).some((f) => f.path === path));
205
+ if (!known) throw fail("E_CAPABILITY_FILE_UNKNOWN", `${path} is not a file \`oats capabilities show ${source.name}\` lists (the inject, or a skill's files)`, { path, name: source.name });
206
+ const { bytes, size } = await bindRemote(remote, remoteOptions).readRemoteFile(source.ref, source.commit, posix.join(source.dir, path));
207
+ return { ...header(source), commit: source.commit, file: { path, bytes: size, ...decodeText(bytes) } };
208
+ }
package/lib/packages.mjs CHANGED
@@ -452,7 +452,7 @@ export function memoizedRemote(remote) {
452
452
  export function bindRemote(remote, remoteOptions) {
453
453
  if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
454
454
  const bound = { ...remote };
455
- const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
455
+ const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, listRemoteFiles: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
456
456
  for (const name of Object.keys(arities)) {
457
457
  if (typeof remote[name] !== "function") continue;
458
458
  bound[name] = (...args) => {
package/lib/remote.mjs CHANGED
@@ -117,7 +117,15 @@
117
117
  * unref'd while idle and ended by session.close();
118
118
  * - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
119
119
  * (the observation store below) no older than that;
120
- * - the head observations used, for the `observation` output block.
120
+ * - the head observations used, for the `observation` output block;
121
+ * - `deadline` (DEADLINE; the deployment reads `oats status` and `oats workspace status` only, READ_REMOTE_BUDGET_MS
122
+ * after their session starts): every remote step gets what is left of it, not its own default — each git call's
123
+ * timeout (ls-remote, fetch, ls-tree, the batch readers' answers, every cache plumbing call, the git version
124
+ * probe), the cache write lock's wait, the half-initialised cache's wait and the lock-race backoff. A step the deadline ends is a
125
+ * `timeout` (git's group killed as any timeout kills it; a step not started yet starts no git), so a member
126
+ * not read by then degrades as any unreadable member does (a peel or version the deadline ended is a timeout,
127
+ * never a missing commit or an older git). A wait the deadline cuts never changes what it judges: past it no
128
+ * lock is taken or reclaimed, and a cache directory is not taken for a crash's leftover.
121
129
  *
122
130
  * PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
123
131
  * (repo key, commit) by a given kernel never changes, so it is kept on disk under
@@ -157,6 +165,9 @@ export const GIT_TIMEOUT_MS = 30_000;
157
165
  /** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
158
166
  * large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
159
167
  export const GIT_FETCH_TIMEOUT_MS = 600_000;
168
+ /** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
169
+ * this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
170
+ export const READ_REMOTE_BUDGET_MS = 12_000;
160
171
  /** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
161
172
  export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
162
173
  /** `--max-age` bounds (seconds). */
@@ -376,6 +387,21 @@ function abortError(signal) {
376
387
  return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
377
388
  }
378
389
 
390
+ /** A git call the session's deadline left no time for: shaped as runGit's own timeout (classified "timeout"). */
391
+ function deadlineError(args) {
392
+ return Object.assign(new Error(`Command failed: git ${args.join(" ")} (the read budget ended before it ran)`),
393
+ { code: null, killed: false, signal: null, timedOut: true, overflowed: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) });
394
+ }
395
+
396
+ /** `exec(args, opts)` under the session's deadline: its timeout (opts.timeout, else GIT_TIMEOUT_MS) cut to what is
397
+ * left, and no git started once nothing is. Without a deadline, exactly `exec(args, opts)`. */
398
+ function sessionExec(exec, session, args, opts = {}) {
399
+ if (session?.deadline == null) return exec(args, opts);
400
+ const timeout = session.remaining(opts.timeout ?? GIT_TIMEOUT_MS);
401
+ if (timeout <= 0) return Promise.reject(deadlineError(args));
402
+ return exec(args, { ...opts, timeout });
403
+ }
404
+
379
405
  function stderrText(error) {
380
406
  const s = error?.stderr;
381
407
  return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
@@ -475,15 +501,17 @@ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|an
475
501
  * git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
476
502
  const LOCK_WAIT_MS = 3_000;
477
503
  /** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
478
- * LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried. */
479
- async function retryLockRace(fn) {
504
+ * LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
505
+ * A session's deadline cuts the backoff: the next call then ends as its timeout (sessionExec). */
506
+ async function retryLockRace(fn, session = null) {
480
507
  const deadline = Date.now() + LOCK_WAIT_MS;
481
508
  for (let attempt = 0; ; attempt++) {
482
509
  try { return await fn(); }
483
510
  catch (error) {
484
511
  if (!isLockRace(error) || error.timedOut) throw error;
485
512
  if (Date.now() >= deadline) throw error;
486
- await sleep(Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random()));
513
+ const backoff = Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random());
514
+ await sleep(session ? session.remaining(backoff) : backoff);
487
515
  }
488
516
  }
489
517
  }
@@ -541,8 +569,18 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
541
569
  try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
542
570
  const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
543
571
  let held = null, waited = false;
572
+ // The session's deadline ends the wait before anything is taken or reclaimed: the read is a timeout, and the
573
+ // lock (live, stale or unreadable) stays exactly as it is.
574
+ const budgetEnded = () => {
575
+ let now = held;
576
+ try { now = readCacheWriteLock(lock); } catch { /* as last seen */ }
577
+ const holder = now?.owner ? `oats process ${now.owner.pid}` : "another process";
578
+ return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (timeout, ${stage}): the read budget ended ${now ? `while ${holder} held its write lock ${lock}` : `before it took its write lock ${lock}`}`,
579
+ { url: ref.url, key: ref.key, reason: "timeout", stage, cacheDir: dir, lock, ...(now?.owner ? { holderPid: now.owner.pid } : {}) });
580
+ };
544
581
  for (let attempt = 0; ; attempt++) {
545
582
  if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
583
+ if (session?.expired()) throw budgetEnded();
546
584
  const tmp = `${lock}.${owner.pid}.${owner.token}`;
547
585
  try {
548
586
  writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
@@ -550,7 +588,7 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
550
588
  } catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
551
589
  finally { try { unlinkSync(tmp); } catch { /* never written */ } }
552
590
  try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
553
- if (held && isStaleLock(held)) {
591
+ if (held && isStaleLock(held) && !session?.expired()) {
554
592
  const abandoned = reclaimCacheWriteLock(lock, held, owner);
555
593
  if (abandoned) {
556
594
  throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
@@ -561,8 +599,10 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
561
599
  throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
562
600
  : `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
563
601
  }
602
+ if (session?.expired()) throw budgetEnded();
564
603
  waited = true;
565
- await sleep(Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random()));
604
+ const backoff = Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random());
605
+ await sleep(session ? Math.max(1, session.remaining(backoff)) : backoff);
566
606
  }
567
607
  try { return await fn({ waited }); }
568
608
  finally {
@@ -627,7 +667,7 @@ function reclaimGitLock(repo, lockFile) {
627
667
  * the call made once more. Anything else is thrown as git raised it. */
628
668
  async function cacheGit(repo, args, opts = {}) {
629
669
  const run = () => repo.local(args, opts);
630
- try { return await retryLockRace(run); }
670
+ try { return await retryLockRace(run, repo.session); }
631
671
  catch (error) {
632
672
  const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
633
673
  if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
@@ -646,15 +686,16 @@ function redactUrl(url) {
646
686
  }
647
687
 
648
688
  function repoHandle(dir, exec, session) {
649
- const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
689
+ const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
650
690
  return { dir, exec, local, session };
651
691
  }
652
692
 
653
693
  async function cacheRepo(ref, options) {
654
694
  const exec = options.exec ?? runGit;
655
695
  const dir = cacheDirOf(cacheRootOf(options), ref);
656
- if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec);
657
- return repoHandle(dir, exec, sessionOf(options));
696
+ const session = sessionOf(options);
697
+ if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec, session);
698
+ return repoHandle(dir, exec, session);
658
699
  }
659
700
 
660
701
  /** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
@@ -669,12 +710,12 @@ const HALF_INIT_WAIT_MS = 2_000;
669
710
  * moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
670
711
  * { reason: "cache", stage: "init", cacheDir }.
671
712
  */
672
- async function initCacheRepo(ref, dir, exec) {
713
+ async function initCacheRepo(ref, dir, exec, session = null) {
673
714
  const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
674
715
  const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
675
716
  try {
676
717
  try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
677
- try { await exec(["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
718
+ try { await sessionExec(exec, session, ["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
678
719
  try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
679
720
  const deadline = Date.now() + HALF_INIT_WAIT_MS;
680
721
  for (;;) {
@@ -683,7 +724,13 @@ async function initCacheRepo(ref, dir, exec) {
683
724
  if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
684
725
  }
685
726
  if (existsSync(join(dir, "HEAD"))) return; // another process created it first
686
- if (Date.now() < deadline) { await sleep(50); continue; }
727
+ if (Date.now() < deadline) {
728
+ // The session's deadline never shortens the judgment: a directory waited for less than the whole wait is
729
+ // never taken for a leftover. The read ends as a timeout instead.
730
+ if (session?.expired()) throw failInit(deadlineError(["init", "-q", "--bare", dir]));
731
+ await sleep(Math.max(1, Math.min(50, session ? session.remaining(50) : 50)));
732
+ continue;
733
+ }
687
734
  // No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
688
735
  // with ENOENT, which is fine) and take its place.
689
736
  const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
@@ -705,8 +752,9 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
705
752
 
706
753
  class ReadSession {
707
754
  constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
708
- cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
755
+ cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
709
756
  if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
757
+ if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
710
758
  this.maxAge = maxAge;
711
759
  this.now = now;
712
760
  this.startedAt = new Date(now()).toISOString();
@@ -715,6 +763,7 @@ class ReadSession {
715
763
  this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
716
764
  this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
717
765
  this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
766
+ this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
718
767
  this.parsedLimits = parsedLimits; // tests inject small prune bounds
719
768
  this.observations = new Map(); // memo key → Promise<head observation>
720
769
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
@@ -793,12 +842,18 @@ class ReadSession {
793
842
  this.retiring.clear();
794
843
  reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
795
844
  }
845
+ /** What a wait or a git call of `ms` may take: `ms`, cut to what is left before the deadline (never below 0).
846
+ * Without a deadline, `ms` itself. */
847
+ remaining(ms) { return this.deadline === null ? ms : Math.max(0, Math.min(ms, this.deadline - Date.now())); }
848
+ /** Whether the deadline has passed: every remote step not over by then ends as a `timeout`. */
849
+ expired() { return this.remaining(Infinity) === 0; }
796
850
  /** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
797
851
  toJSON() { return "[oats read session]"; }
798
852
  }
799
853
 
800
- /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). Test seams:
801
- * `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
854
+ /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
855
+ * milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
856
+ * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
802
857
  export function createReadSession(options = {}) { return new ReadSession(options); }
803
858
  /** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
804
859
  * by a caller that is still reading, so its shape only has to be a typed remote failure. */
@@ -1106,7 +1161,7 @@ async function ensureCommit(ref, oid, options) {
1106
1161
  let peeled = false; // the object store was asked already, and said no
1107
1162
  const usable = async (repo) => {
1108
1163
  if (!existsSync(join(dir, "HEAD"))) return null;
1109
- if (!keepsPartialCache(await gitVersion(repo.exec)) && await fetchMode(repo) === "partial") return null;
1164
+ if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
1110
1165
  peeled = true;
1111
1166
  return peelCommit(repo, oid);
1112
1167
  };
@@ -1116,7 +1171,7 @@ async function ensureCommit(ref, oid, options) {
1116
1171
  // waited for it finds what the holder fetched.
1117
1172
  return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
1118
1173
  let repo = await cacheRepo(ref, options);
1119
- const version = await gitVersion(repo.exec);
1174
+ const version = await readVersion(repo, ref);
1120
1175
  // A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
1121
1176
  if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
1122
1177
  const cached = peeled && !waited ? null : await peelCommit(repo, oid);
@@ -1129,6 +1184,8 @@ async function ensureCommit(ref, oid, options) {
1129
1184
  if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
1130
1185
  const commit = await peelCommit(repo, oid);
1131
1186
  if (!commit) {
1187
+ // A peel the session's deadline ended is the read's timeout, never a missing commit.
1188
+ if (session?.expired()) throw unreadable(ref, deadlineError(["rev-parse", `${oid}^{commit}`]), { commit: oid, cacheDir: repo.dir, stage: "fetch" });
1132
1189
  const type = await objectType(repo, oid);
1133
1190
  throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
1134
1191
  }
@@ -1186,19 +1243,41 @@ async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does
1186
1243
  const olderGitNotice = (version, ref) =>
1187
1244
  `git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
1188
1245
 
1189
- /** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null. */
1246
+ /** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
1247
+ * `opts` (a timeout, a signal) go to the call that asks. A call that timed out says nothing about this git:
1248
+ * it answers null to its waiters and is asked again next time. */
1190
1249
  const gitVersions = new WeakMap();
1191
- export function gitVersion(exec = runGit) {
1250
+ export function gitVersion(exec = runGit, opts = undefined) {
1192
1251
  let version = gitVersions.get(exec);
1193
1252
  if (!version) {
1194
- version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
1253
+ version = Promise.resolve().then(() => exec(["--version"], ...(opts ? [opts] : []))).then((out) => {
1195
1254
  const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
1196
1255
  return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
1197
- }, () => null);
1256
+ }, (error) => {
1257
+ if (error?.timedOut && gitVersions.get(exec) === version) gitVersions.delete(exec);
1258
+ return null;
1259
+ });
1198
1260
  gitVersions.set(exec, version);
1199
1261
  }
1200
1262
  return version;
1201
1263
  }
1264
+ /** gitVersion for a read of `ref` in `repo`'s session. Under a deadline the probe gets what is left of it, and
1265
+ * is waited for no longer (another caller may own the probe): a version the deadline left unknown is the
1266
+ * read's timeout, never taken for an older git (which would rebuild a partial cache). */
1267
+ async function readVersion(repo, ref) {
1268
+ const session = repo.session;
1269
+ if (session?.deadline == null) return gitVersion(repo.exec);
1270
+ const ended = () => unreadable(ref, deadlineError(["--version"]), { cacheDir: repo.dir, stage: "fetch" });
1271
+ const left = session.remaining(GIT_TIMEOUT_MS);
1272
+ if (left <= 0) throw ended();
1273
+ let timer;
1274
+ const late = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise(ended), left); });
1275
+ try {
1276
+ const version = await Promise.race([gitVersion(repo.exec, { timeout: left, signal: session.signal }), late]);
1277
+ if (version === ended || (version === null && session.expired())) throw ended();
1278
+ return version;
1279
+ } finally { clearTimeout(timer); }
1280
+ }
1202
1281
  /** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
1203
1282
  export function keepsPartialCache(version) {
1204
1283
  const [major, minor] = PARTIAL_FETCH_GIT;
@@ -1443,7 +1522,7 @@ async function observeLive(ref, args, at, options) {
1443
1522
  const exec = options.exec ?? runGit;
1444
1523
  const signal = sessionOf(options)?.signal;
1445
1524
  let out;
1446
- try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
1525
+ try { out = await sessionExec(exec, sessionOf(options), args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
1447
1526
  catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1448
1527
  const parsed = parseLsRemote(out.stdout);
1449
1528
  const hit = resolveAt(parsed, at);
@@ -1578,11 +1657,11 @@ function killChild(child) {
1578
1657
  * like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
1579
1658
  * never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
1580
1659
  * body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
1581
- * the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS rejects EVERY pending
1582
- * request with a git-shaped error (classifyRemoteFailure reads it as today: timeout → "timeout") and
1583
- * ends the child; the next read starts a new one.
1660
+ * the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS (cut to what is left of
1661
+ * `session`'s deadline) rejects EVERY pending request with a git-shaped error (classifyRemoteFailure reads it
1662
+ * as today: timeout → "timeout") and ends the child; the next read starts a new one.
1584
1663
  */
1585
- function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
1664
+ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS, session = null) {
1586
1665
  // Detached, as its own process group: killChild's group kill also ends anything git started.
1587
1666
  const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
1588
1667
  liveChildren.add(child);
@@ -1596,7 +1675,8 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
1596
1675
  const arm = () => {
1597
1676
  clearTimeout(timer); timer = null;
1598
1677
  if (!queue.length) { idle(); return; }
1599
- timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${timeoutMs} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), timeoutMs);
1678
+ const ms = session ? session.remaining(timeoutMs) : timeoutMs;
1679
+ timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${ms} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), ms);
1600
1680
  };
1601
1681
  const die = (error) => {
1602
1682
  if (dead) return;
@@ -1686,7 +1766,8 @@ function catFileBatch(repo) {
1686
1766
  if (!session || session.closed || repo.exec !== runGit) return null;
1687
1767
  let reader = session.batches.get(repo.dir);
1688
1768
  if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
1689
- reader = openBatch(repo.dir, session.batchTimeoutMs);
1769
+ if (session.expired()) return null; // a per-blob read, which ends as the deadline's timeout (sessionExec)
1770
+ reader = openBatch(repo.dir, session.batchTimeoutMs, session);
1690
1771
  session.batches.set(repo.dir, reader);
1691
1772
  if (session.batches.size > BATCH_LIMIT) {
1692
1773
  for (const [dir, other] of session.batches) {
@@ -1795,6 +1876,42 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
1795
1876
  return result;
1796
1877
  }
1797
1878
 
1879
+ /**
1880
+ * → { files: [{ path, size }], total } for the regular files (blobs) under <dir> at <commit>, recursively,
1881
+ * relative to <dir>, sorted by path in codepoint order (feature capability-show): `total` of them in all, and
1882
+ * `files` the first `limit` (every one without a limit). Symlinks and gitlinks are omitted; a name that could
1883
+ * escape a checkout ANYWHERE below <dir> is refused (E_REMOTE_TREE_UNSAFE, as listRemoteTree), past the limit
1884
+ * too. One listing (the session's tree index when it has one); the sizes a partial cache lacks are learned with
1885
+ * ONE ensureBlobs call over the listed files only, so nothing past the limit is fetched. A missing dir, or one
1886
+ * that is not a directory → { files: [], total: 0 }. Memoized at the commit (memoAtCommit), per limit.
1887
+ */
1888
+ export async function listRemoteFiles(refText, commitArg, dir, { limit, ...options } = {}) {
1889
+ const ref = parseRepoRef(refText, options);
1890
+ requireCommit(commitArg);
1891
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) throw fail("E_REPO_REF", "limit must be a non-negative integer", { limit });
1892
+ const rel = normalizeTreePath(dir, { allowRoot: true });
1893
+ const none = { files: [], total: 0 };
1894
+ return memoAtCommit(ref, commitArg, `files\0${rel}\0${limit ?? "all"}`, async () => {
1895
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
1896
+ if (rel) {
1897
+ const entry = await entryAt(repo, commit, rel, ref);
1898
+ if (!entry || entry.type !== "tree") return none;
1899
+ }
1900
+ const index = await treeIndex(repo, commit, ref);
1901
+ const entries = index ? indexDescendants(index, rel) : await lsTree(repo, rel ? `${commit}:${rel}` : commit, { flags: ["-r"], ref, commit });
1902
+ if (!entries) return none;
1903
+ const blobs = [];
1904
+ for (const e of entries) {
1905
+ assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
1906
+ if (e.type === "blob" && e.mode !== "120000") blobs.push({ ...e });
1907
+ }
1908
+ blobs.sort(byPath);
1909
+ const listed = limit === undefined ? blobs : blobs.slice(0, limit);
1910
+ await ensureBlobs(repo, ref, commit, listed);
1911
+ return { files: listed.map((e) => ({ path: e.path, size: e.size })), total: blobs.length };
1912
+ }, options);
1913
+ }
1914
+
1798
1915
  // ---------------------------------------------------------------------------
1799
1916
  // digest
1800
1917
  // ---------------------------------------------------------------------------
package/lib/resolve.mjs CHANGED
@@ -474,12 +474,29 @@ export function packageRef(id, entry, catalog, remote) {
474
474
  const SAFE_REL = (rel) => typeof rel === "string" && rel && !posix.isAbsolute(rel) && !/^[\\/]/.test(rel) && !/\0/.test(rel) && !rel.split(/[\\/]/).some((p) => p === "..");
475
475
 
476
476
  /** Normalize a manifest-declared relative path ("./skills/x/" → "skills/x"); null when unsafe. */
477
- function declaredPath(rel) {
477
+ export function declaredPath(rel) {
478
478
  if (!SAFE_REL(rel)) return null;
479
479
  const n = posix.normalize(rel).replace(/\/+$/, "");
480
480
  return n === "." || n === "" || n.startsWith("../") ? null : n;
481
481
  }
482
482
 
483
+ /** A relative path no listing of a checkout can hold: absolute, empty, a `.`/`..`/`.git` (any case) or empty
484
+ * component, a trailing slash, a backslash or a NUL. */
485
+ export function unsafeRelPath(path) {
486
+ if (typeof path !== "string" || path === "" || path.startsWith("/") || path.endsWith("/") || /[\\\0]/.test(path)) return true;
487
+ return path.split("/").some((c) => c === "" || c === "." || c === ".." || c.toLowerCase() === ".git");
488
+ }
489
+
490
+ /** A manifest-declared path as the module install reads it (lib/materialize.mjs modulePath: a backslash is a
491
+ * separator, `./` and trailing slashes dropped), and only when the result is a safe POSIX path (unsafeRelPath);
492
+ * else null. What `oats capabilities show` reports for an inject or a skill. */
493
+ export function manifestFilePath(rel) {
494
+ const declared = declaredPath(rel);
495
+ if (!declared) return null;
496
+ const n = posix.normalize(declared.replace(/\\/g, "/")).replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
497
+ return unsafeRelPath(n) ? null : n;
498
+ }
499
+
483
500
  /**
484
501
  * Skills declared by a manifest: each `skills[]` entry is a directory under the capability that either
485
502
  * IS a skill (holds SKILL.md) or holds skill directories (<entry>/<skill>/SKILL.md) — the same reading
@@ -525,14 +542,31 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
525
542
  * would), commands and hooks. `skills` is null when its declared skills cannot be listed (a spawn of it
526
543
  * would refuse; the listing does not). */
527
544
  export async function capabilityProvides({ ref, commit, dir, manifest, remote: injected, remoteOptions }) {
528
- const remote = remoteOf({ remote: injected, remoteOptions });
529
545
  const keys = (o) => (isObject(o) ? Object.keys(o).sort(byCodepoint) : []);
530
- let skills;
546
+ const { skills } = await capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions });
547
+ return { skills: skills && skills.map((s) => s.name), commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
548
+ }
549
+
550
+ /** A capability's skills as a spawn enumerates them (enumerateSkills), sorted by name in codepoint order:
551
+ * → { skills: [{ name, path }] (path relative to the capability directory), problem: null }, or
552
+ * { skills: null, problem } when the declared skills cannot be enumerated — `problem` the oats error a spawn
553
+ * would meet (`missingCode`: E_CAPABILITY_MISSING for a member, E_PACKAGE_MANIFEST for a package), or a skill
554
+ * path that is not safe (manifestFilePath). Paths are as the install reads them. The one reading of
555
+ * `manifest.skills` behind `oats capabilities` (capabilityProvides) and `oats capabilities show`. */
556
+ export async function capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions, missingCode = "E_CAPABILITY_MISSING" }) {
557
+ const remote = remoteOf({ remote: injected, remoteOptions });
558
+ const missing = (raw, why, text) => fail(missingCode, `${manifest.capability} ${text}`, { capability: manifest.capability, skill: raw, why });
531
559
  try {
532
- const missing = (raw, why, text) => fail("E_CAPABILITY_MISSING", text, { skill: raw, why });
533
- skills = (await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })).map((x) => x.name).sort(byCodepoint);
534
- } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) skills = null; else throw e; }
535
- return { skills, commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
560
+ const skills = [];
561
+ for (const { name, path } of await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })) {
562
+ // A skill path is reported as the install reads it, and only when it is a safe POSIX path (a `.git`
563
+ // directory the remote serves is one a spawn's fetch refuses).
564
+ const safe = manifestFilePath(path);
565
+ if (!safe) throw missing(path, "unsafe", `declares skill ${show(name)} at ${show(path)}, which is not a safe path inside the capability`);
566
+ skills.push({ name, path: safe });
567
+ }
568
+ return { skills: skills.sort((a, b) => byCodepoint(a.name, b.name)), problem: null };
569
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return { skills: null, problem: e }; throw e; }
536
570
  }
537
571
 
538
572
  /** The capability manifests of a locked package, read at its locked commit (feature desktop-facts). */
@@ -543,6 +577,20 @@ export async function lockedPackageCapabilities(id, entry, { catalog = null, rem
543
577
  return { ref, capabilities };
544
578
  }
545
579
 
580
+ /** The lock must describe the tree it names: the capability list a package's manifests declare at the locked
581
+ * commit (`capabilities`, readPackageManifests) must equal the lock entry's, and must hold `name`, else
582
+ * E_PACKAGE_INTEGRITY. → that capability { name, dir, manifest }. Spawn's check (resolveSoul), shared with
583
+ * `oats capabilities show`. */
584
+ export function lockedCapability(name, id, entry, capabilities, details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path }) {
585
+ const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
586
+ if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
587
+ throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
588
+ }
589
+ const cap = capabilities.find((c) => c.name === name);
590
+ if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
591
+ return cap;
592
+ }
593
+
546
594
  /* ───────────────────────────── resolveSoul ────────────────────────────── */
547
595
 
548
596
  /**
@@ -601,13 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
601
649
  const ref = packageRef(id, entry, catalog, remote);
602
650
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
603
651
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
604
- // The lock must describe the tree it names: its capability list is what the package declares there.
605
- const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
606
- if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
607
- throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
608
- }
609
- const cap = capabilities.find((c) => c.name === name);
610
- if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
652
+ const cap = lockedCapability(name, id, entry, capabilities, details);
611
653
  const layer = layerOf(cap.manifest);
612
654
  if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
613
655
  module = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",