@awebai/oats 0.30.2 → 0.30.3
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 +36 -1
- package/docs/configuration.md +18 -1
- package/docs/desktop-cli-api.md +8 -3
- package/docs/execution-targets.md +9 -3
- package/docs/release-notes/v0.30.3.md +75 -0
- package/docs/souls-and-instances.md +6 -1
- package/lib/core.mjs +67 -7
- package/lib/herdr.mjs +45 -8
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
|
|
28
28
|
capabilityManifests, capabilityTrust, capabilityExecutablePath,
|
|
29
29
|
officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
|
|
30
|
-
findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
|
|
30
|
+
findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
|
|
31
31
|
} from "../lib/core.mjs";
|
|
32
32
|
import {
|
|
33
33
|
writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
|
|
@@ -1792,6 +1792,9 @@ async function status() {
|
|
|
1792
1792
|
console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
|
|
1793
1793
|
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
1794
1794
|
if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
|
|
1795
|
+
// The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
|
|
1796
|
+
const launchedBy = i.home ? recordedKernelBin(i.home) : null;
|
|
1797
|
+
if (typeof launchedBy === "string" && (verbose || launchedBy !== CLI_BIN)) console.log(` kernel: ${launchedBy}${launchedBy !== CLI_BIN ? " (not this oats)" : ""}`);
|
|
1795
1798
|
const s = ws?.soul.get(key);
|
|
1796
1799
|
if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
|
|
1797
1800
|
const rows = ws?.drift.get(key) || [];
|
|
@@ -1803,6 +1806,31 @@ async function status() {
|
|
|
1803
1806
|
}
|
|
1804
1807
|
}
|
|
1805
1808
|
|
|
1809
|
+
/** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
|
|
1810
|
+
* `--instance` is refused by a local spawn (with its replacement) but still travels to an older
|
|
1811
|
+
* host through `--server`, whose route reads it. */
|
|
1812
|
+
const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "herdr-socket", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
|
|
1813
|
+
const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
|
|
1814
|
+
/** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
|
|
1815
|
+
* soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
|
|
1816
|
+
* flag() reads it; a missing value is the flag's own check. */
|
|
1817
|
+
function spawnArgvProblem(argv) {
|
|
1818
|
+
const soul = argv[0];
|
|
1819
|
+
for (let i = 1; i < argv.length; i++) {
|
|
1820
|
+
const a = argv[i];
|
|
1821
|
+
if (a === "--provider") { i += 2; continue; }
|
|
1822
|
+
if (a.startsWith("--")) {
|
|
1823
|
+
const name = a.slice(2);
|
|
1824
|
+
if (SPAWN_SWITCHES.has(name)) continue;
|
|
1825
|
+
if (!SPAWN_VALUE_FLAGS.has(name)) return `oats spawn: unknown flag ${a}`;
|
|
1826
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) i++;
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
if (/^[A-Za-z0-9_.-]+=/.test(a)) return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: a capability setting is given as --provider <capability> key=value (here: --provider <capability> ${a})`;
|
|
1830
|
+
return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: spawn takes one soul`;
|
|
1831
|
+
}
|
|
1832
|
+
return undefined;
|
|
1833
|
+
}
|
|
1806
1834
|
async function spawnCmd() {
|
|
1807
1835
|
// JSON mode: contract envelope, stable error codes, stderr-only progress.
|
|
1808
1836
|
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
@@ -1831,6 +1859,7 @@ async function spawnCmd() {
|
|
|
1831
1859
|
// The slug rule is checked here too, before any side effect (soul fetch).
|
|
1832
1860
|
if (nameFlag !== undefined) { try { explicitInstanceName(String(nameFlag)); } catch (e) { bail(e.code, e.message); throw e; } }
|
|
1833
1861
|
if (args.includes("--ephemeral")) bail("E_BAD_ARGS", "--ephemeral was removed by the runtime-boundary ruling — declare the agent in a capability manifest (agents:) for automatic ephemeral semantics");
|
|
1862
|
+
{ const problem = spawnArgvProblem(args.slice(1)); if (problem) bail("E_BAD_ARGS", problem); }
|
|
1834
1863
|
let root;
|
|
1835
1864
|
// A spawn needs a workspace deployment (lead decision c3-1): no oats-local.yaml
|
|
1836
1865
|
// in reach is E_LOCAL_MISSING, before anything else is read. The agents root is
|
|
@@ -3084,6 +3113,12 @@ async function serverRouteCmd() {
|
|
|
3084
3113
|
const r = spawnSyncProc(route.argv[0], route.argv.slice(1), { stdio: "inherit" });
|
|
3085
3114
|
process.exit(r.status ?? 1);
|
|
3086
3115
|
}
|
|
3116
|
+
// A spawn's argv is checked here, before the server is contacted.
|
|
3117
|
+
if (cmd === "spawn") {
|
|
3118
|
+
const local = args.slice(1).filter((a, i, all) => a !== "--server" && all[i - 1] !== "--server");
|
|
3119
|
+
const problem = spawnArgvProblem(local);
|
|
3120
|
+
if (problem) bail("E_BAD_ARGS", problem);
|
|
3121
|
+
}
|
|
3087
3122
|
// Everything after the command word travels, minus the routing flags; a
|
|
3088
3123
|
// local --task-file is read here and travels as --task text, since the
|
|
3089
3124
|
// remote cannot read this machine's files.
|
package/docs/configuration.md
CHANGED
|
@@ -151,10 +151,27 @@ source variable must be set on the host (`E_LAUNCH_ENV_MISSING`, before
|
|
|
151
151
|
anything is created or stopped), and only the harness's pane receives it.
|
|
152
152
|
`list` and `preview` redact every environment value, literals included.
|
|
153
153
|
|
|
154
|
+
**The instance's `oats`.** Every launch (`oats spawn`, `session start`,
|
|
155
|
+
`session restart`, locally or through `--server`) writes `<home>/.oats/bin/oats`,
|
|
156
|
+
a link to the launching kernel's `bin/oats.mjs`, and runs the harness with
|
|
157
|
+
`<home>/.oats/bin` first on `PATH` and the rest of `PATH` unchanged. Plain
|
|
158
|
+
`oats` inside an instance is therefore the kernel that launched it, even on a
|
|
159
|
+
machine whose `PATH` finds another kernel first. A launch configuration's own
|
|
160
|
+
`PATH` (literal or `fromEnv`) comes after it. A restart by a different kernel
|
|
161
|
+
re-points the link to that kernel; `spawn --no-launch` writes it too. The
|
|
162
|
+
recipe in `instance.json` records the target as `launch.kernelBin` (no JSON
|
|
163
|
+
answer carries it); `oats status` prints it
|
|
164
|
+
(`kernel:`) under `--verbose`, or when it is not the `oats` running the status.
|
|
165
|
+
A launch that cannot write the link fails with `E_LAUNCH_SHIM` naming the path
|
|
166
|
+
and the cause: a spawn is rolled back, a start starts nothing. The recorded
|
|
167
|
+
`command` does not carry the `PATH`; the kernel adds it when it runs the
|
|
168
|
+
command. Hooks still receive `OATS_CLI_BIN`, unchanged.
|
|
169
|
+
|
|
154
170
|
**The launch recipe.** A spawn records what a start is made of in
|
|
155
171
|
`instance.json` under `launch`: the harness, the configuration and where it
|
|
156
172
|
came from, the executable, args, env, model, yolo, and each capability's
|
|
157
|
-
launch contribution with its settings and trust
|
|
173
|
+
launch contribution with its settings and trust, and the kernel that launched
|
|
174
|
+
it (`kernelBin`, re-written by every start). One renderer turns it into
|
|
158
175
|
the `command`. Configuration `args` go after the harness's own options and
|
|
159
176
|
before capability arguments; every argument is single-quoted.
|
|
160
177
|
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -1157,6 +1157,9 @@ it to a temporary copy (`soulFetched: true`).
|
|
|
1157
1157
|
repeatable, `a.b=c` nests): a malformed pair is `E_BAD_ARGS`; a capability
|
|
1158
1158
|
the soul does not resolve is `E_CAPABILITY_MISSING {capability, soul,
|
|
1159
1159
|
modules}`.
|
|
1160
|
+
- Any other positional after the soul, or a flag spawn does not read, is
|
|
1161
|
+
`E_BAD_ARGS` naming the argument, before anything is resolved; a bare
|
|
1162
|
+
`key=value` is refused with the `--provider <capability> key=value` form.
|
|
1160
1163
|
|
|
1161
1164
|
### The decision
|
|
1162
1165
|
|
|
@@ -1239,7 +1242,7 @@ Feature `spawn-name`. `--name <slug>` is the exact name, with no prefix.
|
|
|
1239
1242
|
|
|
1240
1243
|
| Code | Details | When |
|
|
1241
1244
|
|---|---|---|
|
|
1242
|
-
| `E_USAGE`, `E_BAD_ARGS` | | no soul; bad, contradictory or
|
|
1245
|
+
| `E_USAGE`, `E_BAD_ARGS` | | no soul; bad, contradictory, removed or unknown flags; an argument after the soul |
|
|
1243
1246
|
| `E_LOCAL_MISSING`, `E_NO_DEPLOYMENT` | | no `oats-local.yaml`; no `agents/` root |
|
|
1244
1247
|
| `E_SOUL_UNKNOWN` | `{name, members, packages}` | no such soul, or not at `--agents-root` |
|
|
1245
1248
|
| `E_SOUL_AMBIGUOUS` | `{name, repos, qualified}` | several souls answer the bare name; use one of `qualified` |
|
|
@@ -1262,6 +1265,7 @@ Feature `spawn-name`. `--name <slug>` is the exact name, with no prefix.
|
|
|
1262
1265
|
| `E_PLACEMENT_TAKEN`, `E_IDEMPOTENCY_CONFLICT` | `{instance, home}` | |
|
|
1263
1266
|
| `E_SPAWN_INCOMPLETE` | `{instance, home, launched}` | |
|
|
1264
1267
|
| `E_LAUNCH_*`, `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS` | | the launch selection is refused |
|
|
1268
|
+
| `E_LAUNCH_SHIM` | | the home's `oats` (`<home>/.oats/bin/oats`) cannot be written; the spawn is rolled back |
|
|
1265
1269
|
| `E_SCHEDULE_INVALID` | | a bad wake (`--wake-json`, `--wake-file`, `--wake-*`) |
|
|
1266
1270
|
| `E_SPAWN_FAILED` | | anything else |
|
|
1267
1271
|
|
|
@@ -1670,8 +1674,9 @@ selection flags. See [the start workflow](desktop-instance-start.md).
|
|
|
1670
1674
|
- A lost response does not mean the launch failed: check status before a
|
|
1671
1675
|
retry. A remote home's saved route names its execution host.
|
|
1672
1676
|
- Errors: `E_BAD_ARGS`, `E_SESSION_UNKNOWN`, `E_UNSUPPORTED_MODE`,
|
|
1673
|
-
`E_SESSION_START_BUSY`, `E_INSTANCE_RETIRING`, `E_LAUNCH_
|
|
1674
|
-
`
|
|
1677
|
+
`E_SESSION_START_BUSY`, `E_INSTANCE_RETIRING`, `E_LAUNCH_*` (among them
|
|
1678
|
+
`E_LAUNCH_SHIM`: the home's `oats` link cannot be written, nothing was
|
|
1679
|
+
started), `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS`, `E_SESSION_FAILED`.
|
|
1675
1680
|
|
|
1676
1681
|
### Upload
|
|
1677
1682
|
|
|
@@ -59,9 +59,15 @@ id distinguishes a replacement occupant of the same pane.
|
|
|
59
59
|
(default `~/.config/...`) and starts `herdr --session oats server` if no
|
|
60
60
|
server is running there.
|
|
61
61
|
- The adapter speaks the Herdr socket API at an explicit protocol version,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
20 or 22. A new session records the protocol its server reports (20 for
|
|
63
|
+
Herdr 0.8, 22 for 0.9) and refuses any other; before 0.30.3 it always
|
|
64
|
+
selected 20, so Herdr 0.9 could not be spawned on. A recorded target is
|
|
65
|
+
never renegotiated: each call, the kernel's and the Desktop's, checks that
|
|
66
|
+
the server's snapshot reports the recorded protocol.
|
|
67
|
+
- Herdr cannot give a pane a command at creation, so OATS types the launch
|
|
68
|
+
command into the pane's shell. It waits until the shell has drawn its prompt
|
|
69
|
+
(`herdr pane read`, at most 10 s): text typed earlier is cut at the
|
|
70
|
+
terminal's line-buffer limit.
|
|
65
71
|
|
|
66
72
|
## Lifecycle
|
|
67
73
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# OATS 0.30.3
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **Desktop: the whole app works from the keyboard, with a Linux-sane keymap.**
|
|
6
|
+
Changed Linux and Windows defaults: the command palette is now Ctrl+Shift+P
|
|
7
|
+
on Linux and Windows; close tab Ctrl+Shift+W; split Ctrl+Shift+E /
|
|
8
|
+
Ctrl+Shift+O (close the split Ctrl+Shift+Alt+W); Spawn instance
|
|
9
|
+
Ctrl+Shift+N; the theme cycle has no default chord (on macOS too; the palette
|
|
10
|
+
keeps it). No default chord takes a key a program in the terminal reads:
|
|
11
|
+
plain Ctrl+W, Ctrl+K, Ctrl+\, Ctrl+P and Ctrl+B, F6, Alt+digit and
|
|
12
|
+
Ctrl+PgUp/PgDn (and ⌃digits on macOS) always reach the program, and nothing
|
|
13
|
+
binds Super. New: go to tab (⌥⌘1–⌥⌘9 / Alt+1–Alt+9), Ctrl+PgDn / Ctrl+PgUp,
|
|
14
|
+
⌘3 / Ctrl+3 for Automations, and F6 / Shift+F6 to move between the sidebar,
|
|
15
|
+
the instances, the main area and the instance panel; from a terminal,
|
|
16
|
+
⇧⌘F6 / Ctrl+Shift+F6 leaves it for the next region. Your own rebinds are
|
|
17
|
+
kept; one that now clashes with another shortcut is flagged in the shortcuts
|
|
18
|
+
editor, never changed for you. Quick Open (⌘P / Ctrl+P)
|
|
19
|
+
now opens the spawn dialog for the soul you pick, and Esc takes you back to
|
|
20
|
+
where you were; ⌘↵ / Ctrl+Enter spawns from any field, and Tab walks the
|
|
21
|
+
form in the order you see it. Keyboard gaps found in an audit are closed:
|
|
22
|
+
roster rows of unknown state, Independent nodes in the Active overview, the
|
|
23
|
+
Automations row menu, the schedule form and focus lost after switching tabs
|
|
24
|
+
or views. The keymap and the audit are in
|
|
25
|
+
`packages/desktop/docs/desktop-keyboard.md`.
|
|
26
|
+
- **Desktop: the spawn dialog says why each capability is there.** In "What
|
|
27
|
+
will be created", each Capabilities row shows a muted reason tag beside its
|
|
28
|
+
source: **Soul**, **Workspace default**, or, for a workspace default that
|
|
29
|
+
fills a core slot, **Workspace default · messaging** (knowledge, messaging or
|
|
30
|
+
tasks). It is the preview's `composedFrom`, read only from a CLI that reports
|
|
31
|
+
`preview-composed-from`; with an older CLI the rows show no tag, never a
|
|
32
|
+
guess. The Core capabilities table is unchanged.
|
|
33
|
+
- **Desktop: clearer overview lines, a centred rail, pinned Workspace controls.**
|
|
34
|
+
The Active overview draws parent links as smooth curves from a parent's
|
|
35
|
+
bottom to its child's top, with dashed arcs between siblings, in a colour
|
|
36
|
+
that is visible in every theme. The collapsed instance panel's icons sit
|
|
37
|
+
on its centre line. In Workspace, the Capabilities section pills and search
|
|
38
|
+
and the Teams header stay in view while the content scrolls, and scrolling
|
|
39
|
+
to the end of a list no longer moves the whole window.
|
|
40
|
+
|
|
41
|
+
## Fixes
|
|
42
|
+
|
|
43
|
+
- **Plain `oats` inside an instance is the kernel that launched it.** On a
|
|
44
|
+
machine with two kernels side by side (a global 0.24 for classic
|
|
45
|
+
deployments and a 0.30 prefix install), an agent typing `oats` got whatever
|
|
46
|
+
`PATH` found first: `oats aweb teams` answered `E_UNKNOWN_COMMAND` in a 0.30
|
|
47
|
+
home. Every launch (spawn, `session start`, `session restart`) now writes
|
|
48
|
+
`<home>/.oats/bin/oats`, a link to the launching kernel, and runs the harness
|
|
49
|
+
with `<home>/.oats/bin` first on `PATH`. A restart by another kernel
|
|
50
|
+
re-points it. The recipe records the target as `launch.kernelBin`, and
|
|
51
|
+
`oats status` shows it (`kernel:`) when it is not the running `oats`. A
|
|
52
|
+
launch that cannot write the link fails with `E_LAUNCH_SHIM`. Existing homes
|
|
53
|
+
get the link at their next start or restart.
|
|
54
|
+
- **`oats spawn` refuses an argument it does not read.** A positional after
|
|
55
|
+
the soul, or a flag spawn does not know, is `E_BAD_ARGS` naming it, before
|
|
56
|
+
anything is resolved or created. `oats spawn <soul> join=oats` used to be
|
|
57
|
+
accepted and the bare `join=oats` ignored, so the instance joined nothing;
|
|
58
|
+
the refusal now names the form that was meant, `--provider <capability>
|
|
59
|
+
key=value`. A routed spawn (`--server`) is refused on this machine, before
|
|
60
|
+
the server is contacted.
|
|
61
|
+
- **Herdr 0.9 spawns work.** A new Herdr session was always opened at protocol
|
|
62
|
+
20, so `oats spawn --backend herdr` against Herdr 0.9, which speaks protocol
|
|
63
|
+
22, failed with "Herdr snapshot does not match selected protocol 20". A new
|
|
64
|
+
session now records the protocol its server reports when it is one OATS
|
|
65
|
+
supports (20 or 22), and refuses any other by name. A recorded session is
|
|
66
|
+
still checked against its recorded protocol on every call and is never
|
|
67
|
+
renegotiated. The Desktop attaches to protocol-22 sessions too; it accepted
|
|
68
|
+
only protocol 20 before. An instance recorded at protocol 20 is unreachable
|
|
69
|
+
after an in-place Herdr 0.8 → 0.9 upgrade until it is respawned.
|
|
70
|
+
- **A Herdr launch no longer arrives cut.** OATS typed the launch command into
|
|
71
|
+
the new pane before its shell had started. The terminal's line buffer
|
|
72
|
+
(1024 bytes on macOS) dropped the rest of the longer command, so the harness
|
|
73
|
+
never started: the shell waited on an unclosed quote. On Herdr 0.9, 0 of 5
|
|
74
|
+
fresh panes ran a 2000-character command intact. A launch now waits, up to
|
|
75
|
+
10 s, until the pane has drawn its prompt, then types; 5 of 5 ran intact.
|
|
@@ -29,6 +29,7 @@ that model.
|
|
|
29
29
|
| Instance operating doc | `<home>/AGENTS.md` (generated) |
|
|
30
30
|
| Instance skills | `<home>/.agents/skills/` |
|
|
31
31
|
| Instance modules | `<home>/.oats/modules/<capability>/` (the copies this instance runs) |
|
|
32
|
+
| Instance `oats` | `<home>/.oats/bin/oats` (a link to the kernel that last launched it) |
|
|
32
33
|
| Instance record | `<home>/instance.json` (`modules`, `providers`, `workspace`, `teams`) |
|
|
33
34
|
|
|
34
35
|
## Soul anatomy
|
|
@@ -126,6 +127,7 @@ full copy** of every capability the soul resolved to:
|
|
|
126
127
|
<skill>/SKILL.md # one level deep, where every harness discovers skills
|
|
127
128
|
.claude/skills → ../.agents/skills
|
|
128
129
|
.oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
|
|
130
|
+
.oats/bin/oats → <kernel>/bin/oats.mjs # the kernel that last launched this home: first on the harness's PATH
|
|
129
131
|
work/ # worktree, checkout symlink, attached tree, or private directory
|
|
130
132
|
TASK.md # briefing and task
|
|
131
133
|
instance.json # provenance (below); `soulDir` = the soul directory hooks get as OATS_SOUL
|
|
@@ -244,7 +246,10 @@ it would bind, `providers` (the `--provider` map exactly as given) and
|
|
|
244
246
|
the apply refuses with `E_DECISION_STALE` if a member
|
|
245
247
|
moved in between. `--provider <cap> key=value` (repeatable; dotted keys nest)
|
|
246
248
|
must name a capability the soul resolves (`E_CAPABILITY_MISSING` otherwise) and
|
|
247
|
-
needs a workspace deployment.
|
|
249
|
+
needs a workspace deployment. Spawn takes one soul and the flags it reads:
|
|
250
|
+
another positional, or a flag it does not know, is `E_BAD_ARGS` naming the
|
|
251
|
+
argument, before anything is created (a bare `key=value` is a provider
|
|
252
|
+
setting given without `--provider <capability>`). The full DTOs are in
|
|
248
253
|
[desktop-cli-api.md](desktop-cli-api.md#workspace-model-workspaceapi-2).
|
|
249
254
|
|
|
250
255
|
Examples of spawn hooks:
|
package/lib/core.mjs
CHANGED
|
@@ -1537,7 +1537,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
|
|
|
1537
1537
|
// Hooks also run through direct core callers (not only bin/oats).
|
|
1538
1538
|
// Author this from the running kernel, never PATH, ambient env or
|
|
1539
1539
|
// a caller's extraEnv: those may point at a different executable.
|
|
1540
|
-
OATS_CLI_BIN:
|
|
1540
|
+
OATS_CLI_BIN: kernelBin(),
|
|
1541
1541
|
OATS_SETTINGS: JSON.stringify(cap.settings || {}),
|
|
1542
1542
|
// Where each leaf of OATS_SETTINGS came from (JSON pointer → { kind, at }; kind is
|
|
1543
1543
|
// manifest-default | workspace | soul | host | spawn | anchor), so a provider can tell a
|
|
@@ -2191,6 +2191,53 @@ export function launchEnvRefs(recipe, env = process.env) {
|
|
|
2191
2191
|
export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
|
|
2192
2192
|
export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
|
|
2193
2193
|
|
|
2194
|
+
/** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
|
|
2195
|
+
export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
|
|
2196
|
+
/** Where a home's `oats` lives: first on its harness's PATH, so plain `oats` inside an instance is
|
|
2197
|
+
* the kernel that launched it, whatever other `oats` the host's PATH finds first. */
|
|
2198
|
+
export const kernelShimDir = (home) => join(home, ".oats", "bin");
|
|
2199
|
+
/** Write <home>/.oats/bin/oats, a symlink to this kernel's CLI, replacing any shim a previous
|
|
2200
|
+
* launch (perhaps by another kernel) left: built aside, then renamed over. `.oats` and `.oats/bin`
|
|
2201
|
+
* must be directories of the home itself: a link there would carry the write outside the home.
|
|
2202
|
+
* The target, or E_LAUNCH_SHIM naming the path and the cause (a launch never runs with the
|
|
2203
|
+
* wrong `oats`), before anything outside the home is touched. */
|
|
2204
|
+
export function writeKernelShim(home) {
|
|
2205
|
+
const dir = kernelShimDir(home), shim = join(dir, "oats");
|
|
2206
|
+
let target, aside;
|
|
2207
|
+
try {
|
|
2208
|
+
target = kernelBin();
|
|
2209
|
+
for (const d of [join(home, ".oats"), dir]) {
|
|
2210
|
+
let st;
|
|
2211
|
+
try { st = lstatSync(d); }
|
|
2212
|
+
catch (e) { if (e.code !== "ENOENT") throw e; mkdirSync(d); st = lstatSync(d); }
|
|
2213
|
+
if (!st.isDirectory()) throw new Error(`${d} is ${st.isSymbolicLink() ? "a symbolic link" : "not a directory"}`);
|
|
2214
|
+
}
|
|
2215
|
+
if (realpathSync(dir) !== join(realpathSync(home), ".oats", "bin")) throw new Error(`${dir} resolves outside the home`);
|
|
2216
|
+
aside = join(dir, `.oats-${randomUUID()}`);
|
|
2217
|
+
symlinkSync(target, aside);
|
|
2218
|
+
renameSync(aside, shim);
|
|
2219
|
+
return target;
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
if (aside) rmSync(aside, { force: true });
|
|
2222
|
+
throw oatsError("E_LAUNCH_SHIM", `cannot write the kernel shim ${shim}${target ? ` (to ${target})` : ""}: ${e.message}; nothing was started`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
/** A parsed launch command's environment prefix with the home's shim directory first on PATH: a
|
|
2226
|
+
* configuration's PATH (literal or reference) follows it, else the PATH the command runs under. */
|
|
2227
|
+
function shimPathPrefix(tokens, binary, home) {
|
|
2228
|
+
const dir = shq(kernelShimDir(home));
|
|
2229
|
+
const prefix = tokens.slice(0, binary);
|
|
2230
|
+
const texts = prefix.map((t) => t.name !== "PATH" ? t.text : t.kind === "env" ? `PATH=${dir}:${shq(t.value)}` : `PATH=${dir}:"$${t.source}"`);
|
|
2231
|
+
if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
|
|
2232
|
+
return texts;
|
|
2233
|
+
}
|
|
2234
|
+
/** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
|
|
2235
|
+
* first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
|
|
2236
|
+
export function launchShellCommand(command, home) {
|
|
2237
|
+
const { tokens, binary } = parseLaunchCommand(command);
|
|
2238
|
+
return [...shimPathPrefix(tokens, binary, home), ...tokens.slice(binary).map((t) => t.text)].join(" ");
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2194
2241
|
/** The harness command line of a recipe. With no configuration the bytes
|
|
2195
2242
|
* equal what spawn rendered before recipes: env prefix, the executable, the
|
|
2196
2243
|
* harness's own arguments, capability launch args, the task prompt. A
|
|
@@ -2234,14 +2281,15 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
2234
2281
|
|
|
2235
2282
|
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
2236
2283
|
* storage inside the backend shell under the actual command environment.
|
|
2237
|
-
* The original executable/argv is exec'd unchanged after recording succeeds
|
|
2284
|
+
* The original executable/argv is exec'd unchanged after recording succeeds,
|
|
2285
|
+
* with the home's kernel shim first on PATH (every launch runs through here). */
|
|
2238
2286
|
function nativeRecordCommand(command, home, harness) {
|
|
2239
2287
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
2240
2288
|
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
2241
2289
|
const id = prepareNativeStart(home, harness);
|
|
2242
2290
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
2243
2291
|
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
|
|
2244
|
-
return `${tokens
|
|
2292
|
+
return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
2245
2293
|
}
|
|
2246
2294
|
|
|
2247
2295
|
|
|
@@ -2374,7 +2422,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2374
2422
|
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
|
|
2375
2423
|
executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
|
|
2376
2424
|
args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
2377
|
-
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
|
|
2425
|
+
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
|
|
2378
2426
|
...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
|
|
2379
2427
|
};
|
|
2380
2428
|
const inst = instance || meta?.instance || basename(home);
|
|
@@ -2412,11 +2460,18 @@ export function redactLaunchCommand(command) {
|
|
|
2412
2460
|
try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
|
|
2413
2461
|
catch { return "<unparseable launch command withheld>"; }
|
|
2414
2462
|
}
|
|
2415
|
-
/** The recipe
|
|
2463
|
+
/** The recipe as answers carry it: every environment value withheld, and without `kernelBin`
|
|
2464
|
+
* (the home's record keeps it; human `oats status` reads it from there). */
|
|
2416
2465
|
export function redactLaunchRecipe(recipe) {
|
|
2417
2466
|
const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
|
|
2418
2467
|
const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
|
|
2419
|
-
|
|
2468
|
+
const { kernelBin: _recorded, ...answered } = recipe;
|
|
2469
|
+
return { ...answered, env, ...(hooks ? { hooks } : {}) };
|
|
2470
|
+
}
|
|
2471
|
+
/** The kernel a home's last launch pointed its `oats` at (instance.json launch.kernelBin), or null. */
|
|
2472
|
+
export function recordedKernelBin(home) {
|
|
2473
|
+
try { const bin = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.launch?.kernelBin; return typeof bin === "string" ? bin : null; }
|
|
2474
|
+
catch { return null; }
|
|
2420
2475
|
}
|
|
2421
2476
|
/** Spawn. With `o.prepared` (workspace model: a resolution prepared by
|
|
2422
2477
|
* instance-resolution.mjs) this is ASYNC — capabilities are fetched and copied
|
|
@@ -3477,6 +3532,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3477
3532
|
// instance.json beside its rendering, which is executed in the instance's
|
|
3478
3533
|
// tmux window. Capabilities contributed harness-specific arguments and
|
|
3479
3534
|
// environment through their spawn hook; both are recorded with provenance.
|
|
3535
|
+
// The home's `oats` is this kernel, launched now or later (--no-launch).
|
|
3536
|
+
const shimTarget = writeKernelShim(home);
|
|
3480
3537
|
const recipe = {
|
|
3481
3538
|
version: LAUNCH_RECIPE_VERSION, harness,
|
|
3482
3539
|
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
|
|
@@ -3484,7 +3541,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3484
3541
|
args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
|
|
3485
3542
|
model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
3486
3543
|
hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
|
|
3487
|
-
prompt: LAUNCH_PROMPT,
|
|
3544
|
+
prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
|
|
3488
3545
|
};
|
|
3489
3546
|
if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
|
|
3490
3547
|
const cmdline = renderLaunchRecipe(recipe, { home, instance });
|
|
@@ -4893,6 +4950,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
4893
4950
|
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
4894
4951
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
4895
4952
|
}
|
|
4953
|
+
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
4954
|
+
checkRoots();
|
|
4955
|
+
writeKernelShim(realHome);
|
|
4896
4956
|
const startedAt = new Date().toISOString();
|
|
4897
4957
|
const id = randomUUID();
|
|
4898
4958
|
checkRoots();
|
package/lib/herdr.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** Herdr 0.8.x/0.9.x host-local backend (explicit protocols 20/22).
|
|
2
|
-
* SSH belongs to the CLI router
|
|
2
|
+
* SSH belongs to the CLI router. A new session records the server's protocol once; a recorded
|
|
3
|
+
* target is never renegotiated. */
|
|
3
4
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
5
|
import { existsSync, mkdirSync } from "node:fs";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { dirname, join, resolve } from "node:path";
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
+
// The legacy protocol literal; a new session records the protocol its server reports (ensureHerdr).
|
|
9
10
|
export const HERDR_PROTOCOL = 20;
|
|
10
11
|
export const HERDR_SUPPORTED_PROTOCOLS = Object.freeze([20, 22]);
|
|
11
12
|
export const isSupportedHerdrProtocol = (value) => HERDR_SUPPORTED_PROTOCOLS.includes(value);
|
|
@@ -21,10 +22,15 @@ export function herdrSocket(session = "oats") {
|
|
|
21
22
|
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "herdr", "sessions", session, "herdr.sock");
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/** One Herdr CLI call on the target's socket, its stdout as text. */
|
|
26
|
+
function herdrOutput(target, args, { timeout = 10000, exec = execFileSync } = {}) {
|
|
25
27
|
const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
|
|
26
28
|
delete env.HERDR_SESSION;
|
|
27
|
-
|
|
29
|
+
return exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function herdrCommand(target, args, io) {
|
|
33
|
+
const output = herdrOutput(target, args, io);
|
|
28
34
|
// Some successful terminal mutations have no output; reads below require
|
|
29
35
|
// their documented result shape independently.
|
|
30
36
|
if (!output.trim()) return {};
|
|
@@ -42,9 +48,20 @@ export function herdrSnapshot(target, io) {
|
|
|
42
48
|
return snapshot;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/** The target for a NEW session on the server at `socket`: it records the supported protocol that server
|
|
52
|
+
* reports (Herdr 0.8 speaks 20, 0.9 speaks 22). Every later call checks the snapshot against that recorded
|
|
53
|
+
* protocol, so a recorded target is never renegotiated. */
|
|
54
|
+
function newSessionTarget(target, io) {
|
|
55
|
+
const protocol = herdrCommand(target, ["api", "snapshot"], io).snapshot?.protocol;
|
|
56
|
+
if (!isSupportedHerdrProtocol(protocol)) throw Object.assign(new Error(`Herdr server speaks protocol ${protocol ?? "unknown"}; OATS supports ${HERDR_SUPPORTED_PROTOCOLS.join(" and ")}`), { unsupportedProtocol: true });
|
|
57
|
+
const selected = { ...target, protocol };
|
|
58
|
+
herdrSnapshot(selected, io);
|
|
59
|
+
return selected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensureHerdr({ binary = "herdr", socket, session = "oats", io } = {}) {
|
|
63
|
+
const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)) };
|
|
64
|
+
try { return newSessionTarget(target, io); }
|
|
48
65
|
catch (e) {
|
|
49
66
|
// An explicit socket belongs to an operator-managed server. Never start a
|
|
50
67
|
// different server when it cannot be inspected or is incompatible.
|
|
@@ -59,7 +76,8 @@ export function ensureHerdr({ binary = "herdr", socket, session = "oats" } = {})
|
|
|
59
76
|
let error;
|
|
60
77
|
for (let i = 0; i < 30; i++) {
|
|
61
78
|
pause(100);
|
|
62
|
-
|
|
79
|
+
// A server that answers with an unsupported protocol is up: waiting longer cannot change its answer.
|
|
80
|
+
try { return newSessionTarget(target, io); } catch (e) { if (e.unsupportedProtocol) throw e; error = e; }
|
|
63
81
|
}
|
|
64
82
|
throw new Error(`Herdr did not start: ${error?.message || "no socket"}`);
|
|
65
83
|
}
|
|
@@ -81,8 +99,27 @@ export function inspectHerdr(target, io) {
|
|
|
81
99
|
return { present: !!pane, pane, agent, status: agent?.agent_status || "unknown" };
|
|
82
100
|
}
|
|
83
101
|
|
|
102
|
+
/** Wait until a new pane's shell has drawn something (its prompt) before typing into it. Text typed
|
|
103
|
+
* earlier sits in the terminal's canonical line buffer, which drops a line past 1024 bytes on macOS:
|
|
104
|
+
* a launch command is longer than that, and arrived cut. Bounded; a Herdr that cannot read a pane
|
|
105
|
+
* leaves the old behaviour (type at once). */
|
|
106
|
+
function awaitShellReady(target, io) {
|
|
107
|
+
const deadline = Date.now() + (io?.shellReadyMs ?? 10000);
|
|
108
|
+
for (;;) {
|
|
109
|
+
let text;
|
|
110
|
+
// `pane read` answers the screen as plain text, not a JSON envelope.
|
|
111
|
+
const remaining = Math.max(100, deadline - Date.now());
|
|
112
|
+
try { text = herdrOutput(target, ["pane", "read", target.paneId, "--source", "visible"], { ...io, timeout: Math.min(2000, remaining) }); }
|
|
113
|
+
catch { return; }
|
|
114
|
+
if (typeof text === "string" && text.trim()) return;
|
|
115
|
+
if (Date.now() >= deadline) return;
|
|
116
|
+
pause(100);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
84
120
|
export function launchHerdr(target, command, io) {
|
|
85
121
|
if (!inspectHerdr(target, io).present) throw new Error("Herdr launch pane disappeared");
|
|
122
|
+
awaitShellReady(target, io);
|
|
86
123
|
// This is the allocated shell's one launch command. Exec avoids a fallback
|
|
87
124
|
// shell that could accidentally consume a subsequent agent notification.
|
|
88
125
|
herdrCommand(target, ["pane", "run", target.paneId, `exec /bin/sh -c ${quote(command)}`], io);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.3",
|
|
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",
|