@awebai/oats 0.33.0 → 0.34.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.
@@ -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,57 @@
1
+ # OATS 0.34.1
2
+
3
+ ## Changed
4
+
5
+ - **Reading a remote's current head takes one round trip instead of two.**
6
+ `oats spawn` (preview and apply) and the read verbs ask each workspace
7
+ member for its head with Git's protocol v0, which returns the whole ref list
8
+ in one request, where protocol v2 takes two. On a 6-member deployment the
9
+ live phase of a spawn preview went from 0.77 s to 0.49 s (median of 5). Tags
10
+ and branches are read as before. If your Git configuration sets
11
+ `protocol.version`, OATS uses it as set. A remote whose ref list is over
12
+ 4 MiB is read with protocol v2 from then on (remembered in the remote
13
+ cache). A remote that times out under v0 fails as before and is read with
14
+ v2 for the next 7 days; a read ended by the 12 s budget of `oats status` or
15
+ `oats workspace status` is not counted as the remote's timeout. One that
16
+ fails under v0 for another reason than an
17
+ authentication refusal, a missing repository or the local cache is read
18
+ again with v2. Each case is one `oats: warning`. What a read observes is
19
+ unchanged, and an apply still observes every member live. Known limit: the
20
+ 4 MiB bounds what OATS keeps, not what Git downloads, so a remote with a
21
+ larger ref list transfers it once before OATS switches to protocol v2.
22
+ - **oats.engineering 1.5.0** (catalog and workspace pin, and the bundled
23
+ mirrors): developers keep each dynamic workflow under 10 agents and ask
24
+ their human for permission first for more. Within the cap they run
25
+ workflows without asking where the harness allows it; where it requires the
26
+ human's opt-in (Claude Code's workflow tool does), they use a standing
27
+ opt-in the human configured, or ask once per task.
28
+ - **The `oats-setup-admin` soul has knowledge and is harvested.** It drops
29
+ `oats.developer` and `knowledge: none` and takes the workspace's oats.okf
30
+ knowledge slot, owning the new `oats/oats-setup-admin` node (the judgement
31
+ of administering a workspace's config, never deployment state) and reading
32
+ the operator, expert and kernel nodes. This reverses the 0.29.1 opt-out:
33
+ deployment specifics are kept out of the base at promotion, by the node's
34
+ charter and the reviewed harvest PR, instead of by not harvesting the soul.
35
+ It needs `oats/oats-setup-admin` in the bound `oats` base
36
+ (awebai/oats-knowledge#51); new instances only.
37
+
38
+ - **`PI_AGENT_INSTANCE` and `PI_AGENT_HOME` are gone.** No launch sets them,
39
+ for any harness, pi included. (A home that records only its launch command,
40
+ from before launch recipes, keeps running that command as recorded.) The identity is `OATS_INSTANCE` and
41
+ `OATS_INSTANCE_HOME`, and the pi bridge (`@awebai/oats-pi`) reads
42
+ `OATS_INSTANCE_HOME`.
43
+ - Upgrade the CLI and the pi bridge together (`oats update` does both). A
44
+ 0.34.0 pi bridge under a 0.34.1 kernel does not find its instance home.
45
+ - Capability commands no longer read `PI_AGENT_HOME`. A leftover one in an
46
+ operator's shell no longer pins a command to that instance.
47
+ - The names stay reserved: a launch configuration still cannot set them.
48
+
49
+ ## Fixed
50
+
51
+ - **A capability command inside an instance home says which home, and why.**
52
+ - A `--soul` naming another soul than the home's was silently ignored; it
53
+ is now refused with `E_HOME_MISMATCH`.
54
+ - A namespace the home does not have answered `unknown command`; it is now
55
+ `E_UNKNOWN_COMMAND` naming the home.
56
+ - Both name what chose the home: `OATS_INSTANCE_HOME`, `OATS_HOME` or the
57
+ working directory.
@@ -245,8 +245,7 @@ OATS codex session does not appear in `codex agents`. So that the environment
245
245
  does not depend on this, a codex launch also sets it for tool
246
246
  commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
247
247
 
248
- - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
249
- `PI_AGENT_HOME`;
248
+ - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`;
250
249
  - every capability's launch environment (for example the messaging
251
250
  provider's identity home and delivery mode);
252
251
  - the launch configuration's literal values. A reference's value never goes
@@ -258,9 +257,9 @@ runs tool commands through the user's login shell, and a profile that prepends
258
257
  directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
259
258
  directory is found first.
260
259
 
261
- A capability command (`oats <namespace> …`) run with none of
262
- `OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
263
- from the working directory. It uses the nearest enclosing directory laid out as
260
+ A capability command (`oats <namespace> …`) runs in the instance home that
261
+ `OATS_INSTANCE_HOME` names, else `OATS_HOME`. With neither set, it finds its
262
+ instance from the working directory. It uses the nearest enclosing directory laid out as
264
263
  `<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
265
264
  name, and validates it like a home named by the environment. The walk uses the
266
265
  directory as the shell names it (`$PWD`). That matters for an attached
@@ -268,6 +267,13 @@ instance, whose `work/` links into its owner's tree: below it, the physical
268
267
  path is the owner's. A process that has no `$PWD` there would act as the
269
268
  owner, so an attached instance runs capability commands from its home.
270
269
 
270
+ Inside an instance home the namespace is that home's. A `--soul` naming
271
+ another soul is refused (`E_HOME_MISMATCH`), and a namespace the home does
272
+ not have is `E_UNKNOWN_COMMAND`. Both name the home and what chose it (the
273
+ variable, or the working directory). To run a command as a spawn of another
274
+ soul would, run it from the deployment with `OATS_INSTANCE_HOME` and
275
+ `OATS_HOME` unset.
276
+
271
277
  ## Lifecycle
272
278
 
273
279
  ### Spawn
@@ -561,9 +567,10 @@ Every instance is told its own home as **`OATS_INSTANCE_HOME`** (absolute), and
561
567
  instructions refer to it as `<instance-home>`. The two environments differ, so
562
568
  they are stated separately:
563
569
 
564
- - **Runtime session**: `OATS_INSTANCE_HOME` and `PI_AGENT_HOME` (plus
565
- `OATS_INSTANCE`/`PI_AGENT_INSTANCE`). The `PI_`-prefixed names are
566
- compatibility aliases for the separately published pi extension.
570
+ - **Runtime session**: `OATS_INSTANCE_HOME` and `OATS_INSTANCE`, for every
571
+ harness. The pi extension reads `OATS_INSTANCE_HOME` too; the `PI_AGENT_*`
572
+ names are not set (they stay reserved, so a launch configuration cannot set
573
+ them).
567
574
  - **Lifecycle hooks**: `OATS_INSTANCE_HOME` and `OATS_HOME`, alongside the rest of
568
575
  the hook contract. `OATS_HOME` predates `OATS_INSTANCE_HOME` and is kept because
569
576
  shipped capability hooks read it; it is **not** exported to harness sessions.
@@ -174,7 +174,18 @@ from *outside* that boundary, and **declaring one in the workspace's
174
174
  access context.** The kernel reads both halves over the remotes
175
175
  (`git ls-remote`, shallow fetches, the operator's own credential helpers,
176
176
  never a prompt). A half that cannot be read makes the member *unconfirmed*,
177
- never a half-success. `oats workspace status` and `oats sync` show each member
177
+ never a half-success. A remote's current head is read with Git's protocol v0
178
+ (one round trip) unless your Git configuration sets `protocol.version`, which
179
+ is then used as set; a tag or branch is read as before. A remote whose v0 ref
180
+ advertisement is over 4 MiB is read with protocol v2 from then on: the 4 MiB
181
+ bounds what OATS keeps, not the download, so the first read transfers that
182
+ advertisement once before falling back. A remote that times out under v0
183
+ fails as before and is read with protocol v2 for the next 7 days; a read
184
+ ended by the 12 s budget of `oats status` or `oats workspace status` is not
185
+ the remote's own timeout, and is neither remembered nor warned about. One that
186
+ fails under v0 for another reason than an authentication refusal, a missing
187
+ repository or the local cache is read again with v2. Each case prints one
188
+ `oats: warning`; the first two are remembered in the remote cache. `oats workspace status` and `oats sync` show each member
178
189
  as `confirmed` or the reason it is not:
179
190
 
180
191
  | status | meaning |
@@ -13,7 +13,7 @@
13
13
  export const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
14
14
  export const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
15
15
  export const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
16
- export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
16
+ export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
17
17
  export const PROCESS_BOOTSTRAP_ENV = new Set([
18
18
  "PATH", "HOME", "SHELL", "TMPDIR", "TMP", "TEMP", "PWD", "OLDPWD", "SHLVL", "_",
19
19
  "ENV", "BASH_ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "IFS", "PROMPT_COMMAND", "PS4", "ZDOTDIR",
@@ -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/core.mjs CHANGED
@@ -2354,7 +2354,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2354
2354
  // The launch's environment, in prefix order: the instance, the capabilities' env, the
2355
2355
  // configuration's env (a reference by reference, never by value).
2356
2356
  const hookEnv = recipe.hooks?.env || {};
2357
- const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home], ["PI_AGENT_INSTANCE", instance], ["PI_AGENT_HOME", home]].map(([name, value]) => ({ name, value }));
2357
+ const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]].map(([name, value]) => ({ name, value }));
2358
2358
  for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
2359
2359
  for (const name of Object.keys(recipe.env || {}).sort()) {
2360
2360
  const v = recipe.env[name];
@@ -2561,7 +2561,7 @@ export function describeLaunchCommand(command) {
2561
2561
  * stay references); a command the parser refuses is withheld whole. */
2562
2562
  /** The identity environment every launch carries: public facts (instance
2563
2563
  * name, home path), shown in public renderings; everything else is withheld. */
2564
- export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
2564
+ export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
2565
2565
  export function redactLaunchCommand(command) {
2566
2566
  try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
2567
2567
  catch { return "<unparseable launch command withheld>"; }
@@ -2720,7 +2720,7 @@ function* spawnBody(root, agent, o = {}) {
2720
2720
  // decision: an attached agent shares its owner's work tree and is ALWAYS the
2721
2721
  // owner's child — relation flags that say anything else are contradictory and
2722
2722
  // rejected. Ambient env
2723
- // (OATS_INSTANCE/PI_AGENT_INSTANCE) is deliberately NOT consulted: any shell
2723
+ // (OATS_INSTANCE) is deliberately NOT consulted: any shell
2724
2724
  // opened inside an agent's tmux window inherits those vars, and env inheritance
2725
2725
  // is not evidence of intent — human spawns from such shells were misattributed
2726
2726
  // as instance-origin. Manual spawns land top-level unless a relation is
@@ -85,21 +85,43 @@ export function qualifiedSoulName(entry) {
85
85
  return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
86
86
  }
87
87
 
88
+ /** A soul name as an operator gives it: `[repoPart, soulPart]`, repoPart null for a bare name. */
89
+ function splitSoulName(name) {
90
+ return name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
91
+ }
92
+ /** Whether `name` names the member or package soul `{ name, repoKey }` / `{ name, package }`: a
93
+ * bare name, `<repo>/<soul>` with the member's key, a key suffix or its member name, or
94
+ * `<package>/<soul>` for a package soul. findSoulEntry's rule for each candidate. */
95
+ export function soulNameMatches(name, soul) {
96
+ const [repoPart, soulPart] = splitSoulName(name);
97
+ if (soulPart !== soul.name) return false;
98
+ if (!repoPart) return true;
99
+ if (typeof soul.package === "string") return repoPart === soul.package;
100
+ const key = soul.repoKey ?? "";
101
+ return key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
102
+ }
103
+ /** Whether `name` names the soul an instance home was spawned from (its instance.json `meta`): any name
104
+ * a spawn accepts for it, or the home's agent directory name. */
105
+ export function homeSoulMatches(name, meta) {
106
+ if (name === meta?.agent) return true;
107
+ const soul = meta?.workspace?.soul ?? {};
108
+ return soulNameMatches(name, soul.package && typeof soul.package === "object" ? { name: soul.name, package: soul.package.id } : { name: meta?.agent, repoKey: soul.repoKey });
109
+ }
110
+
88
111
  /** Find the soul named `name` in a discovery: confirmed members, external souls and package
89
112
  * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
90
113
  * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
91
114
  * or an external source, `<package>/<soul>` a package soul. */
92
115
  export function findSoulEntry(discovery, name) {
93
- const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
116
+ const [repoPart, soulPart] = splitSoulName(name);
94
117
  const hits = [];
95
118
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
96
119
  // workspace could not be read); resolveSoul admits exactly that case.
97
120
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
98
- const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
99
121
  for (const m of discovery.members || []) {
100
122
  if (!m.confirmed && m.key !== standaloneOwn) continue;
101
123
  for (const s of m.souls || []) {
102
- if (s.name !== soulPart || !memberMatches(m.key)) continue;
124
+ if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
103
125
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
104
126
  }
105
127
  }
@@ -107,7 +129,7 @@ export function findSoulEntry(discovery, name) {
107
129
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
108
130
  }
109
131
  for (const s of discovery.packageSouls || []) {
110
- if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
132
+ if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
111
133
  }
112
134
  if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
113
135
  if (hits.length > 1) {
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) => {