@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.
- package/bin/oats.mjs +122 -17
- package/docs/capabilities.md +17 -0
- package/docs/desktop-cli-api.md +184 -4
- package/docs/execution-targets.md +3 -3
- package/docs/implementation.md +36 -0
- package/docs/knowledge.md +4 -2
- package/docs/official-catalog.md +1 -1
- package/docs/release-notes/v0.34.0.md +63 -0
- package/docs/release-notes/v0.34.1.md +57 -0
- package/docs/souls-and-instances.md +15 -8
- package/docs/workspaces.md +12 -1
- package/lib/capability-contract.mjs +1 -1
- package/lib/capability-show.mjs +208 -0
- package/lib/core.mjs +3 -3
- package/lib/instance-resolution.mjs +26 -4
- package/lib/packages.mjs +1 -1
- package/lib/remote.mjs +259 -35
- package/lib/resolve.mjs +56 -14
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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> …`)
|
|
262
|
-
`OATS_INSTANCE_HOME
|
|
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 `
|
|
565
|
-
|
|
566
|
-
|
|
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.
|
package/docs/workspaces.md
CHANGED
|
@@ -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.
|
|
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"
|
|
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]
|
|
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"
|
|
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
|
|
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] =
|
|
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
|
|
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 (
|
|
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) => {
|