@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 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.
@@ -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. One renderer turns it into
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
 
@@ -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 removed flags |
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
- `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS`, `E_SESSION_FAILED`.
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
- with no negotiation. New sessions use protocol 20. A recorded session target
63
- may carry protocol 20 or 22, and each call checks that the server's snapshot
64
- reports the recorded protocol.
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. The full DTOs are in
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: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
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.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
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 with every environment value withheld. */
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
- return { ...recipe, env, ...(hooks ? { hooks } : {}) };
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; protocol selection is never negotiation. */
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
- // Keep the existing legacy/default protocol literal; captured callers select one.
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
- export function herdrCommand(target, args, { timeout = 10000, exec = execFileSync } = {}) {
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
- const output = exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
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
- export function ensureHerdr({ binary = "herdr", socket, session = "oats" } = {}) {
46
- const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)), protocol: HERDR_PROTOCOL };
47
- try { herdrSnapshot(target); return target; }
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
- try { herdrSnapshot(target); return target; } catch (e) { error = e; }
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.2",
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",