@awebai/oats 0.34.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 CHANGED
@@ -391,7 +391,7 @@ async function workspaceTarget(bail, { command, liveTeams = true }) {
391
391
  try { meta = JSON.parse(readFileSync(join(homeFlag, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeFlag} is not an OATS instance home (${e.code === "ENOENT" ? "no instance.json" : e.message})`); }
392
392
  if (isCapturedHome(meta)) { const e = capturedHomeRefusal(homeFlag, "nothing was read"); return bail(e.code, e.message, e.details); }
393
393
  if (!meta || typeof meta.modules !== "object" || meta.modules === null) return bail("E_UNSUPPORTED_MODE", `${homeFlag} is not a workspace-model home (it records no modules): it was spawned by an earlier kernel — re-spawn it from the deployment`);
394
- if (soulFlag && soulFlag !== meta.agent) return bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${homeFlag} (${meta.agent})`);
394
+ if (soulFlag && !(await import("../lib/instance-resolution.mjs")).homeSoulMatches(soulFlag, meta)) return bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${homeFlag} (${meta.agent})`);
395
395
  const deployment = dirname(dirname(dirname(dirname(realOrResolved(homeFlag)))));
396
396
  // A v2 home lives at <deployment>/agents/<soul>/instances/<name>: its deployment is
397
397
  // derived, so it must hold oats-local.yaml EXACTLY there (never found by walking up).
@@ -537,8 +537,9 @@ async function workspaceOperation(t, { bail, address, layer, opName }) {
537
537
  const settings = lp.settings;
538
538
  const cwd = op.context === "home" ? t.home : t.deployment;
539
539
  const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
540
- if (op.context === "home") Object.assign(env, { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home, OATS_HOME: t.home, PI_AGENT_INSTANCE: t.meta.instance, PI_AGENT_HOME: t.home });
541
- else for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
540
+ // The home's identity, never one inherited from the caller (the reserved PI_AGENT_* names included).
541
+ for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
542
+ if (op.context === "home") Object.assign(env, { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home, OATS_HOME: t.home });
542
543
  await readSession?.closeBatches(); // no idle `git cat-file --batch` child held for the provider's whole run
543
544
  const r = spawnSync("node", [abs, ...rest, ...argFlags, "--json"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: OPERATION_TIMEOUT_MS, killSignal: "SIGTERM" });
544
545
  finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
@@ -2324,8 +2325,8 @@ function retireCmd() {
2324
2325
  }
2325
2326
  // The calling instance knows its own home: self-retire never needs to
2326
2327
  // disambiguate a same-named twin by hand.
2327
- if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && (process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name)) homeFlag = process.env.OATS_INSTANCE_HOME;
2328
- const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
2328
+ if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && process.env.OATS_INSTANCE === name) homeFlag = process.env.OATS_INSTANCE_HOME;
2329
+ const isSelf = process.env.OATS_INSTANCE === name;
2329
2330
  if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
2330
2331
  if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
2331
2332
  const root = ensureRoot(dirFlag());
@@ -2851,9 +2852,12 @@ async function capabilityCommand() {
2851
2852
  let activeIds;
2852
2853
  let context = process.cwd();
2853
2854
  let teamCtx, homeMeta, homeTeamCtx;
2854
- // OATS_INSTANCE_HOME is the canonical identity; the older names still count. With none set
2855
- // (a harness that strips the session env), the home enclosing the cwd.
2856
- const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME || enclosingInstanceHome(logicalCwd());
2855
+ // OATS_INSTANCE_HOME is the identity; OATS_HOME still counts. With neither set (a harness that
2856
+ // strips the session env), the home enclosing the cwd. What chose the home is named in every
2857
+ // refusal about it.
2858
+ const homeVariable = ["OATS_INSTANCE_HOME", "OATS_HOME"].find((name) => process.env[name]);
2859
+ const instanceHome = homeVariable ? process.env[homeVariable] : enclosingInstanceHome(logicalCwd());
2860
+ const chosenBy = homeVariable ?? "the working directory";
2857
2861
  const metaFile = instanceHome && join(instanceHome, "instance.json");
2858
2862
  // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
2859
2863
  // braces: the ids come from instance.json, which spawn wrote from resolved
@@ -2872,11 +2876,19 @@ async function capabilityCommand() {
2872
2876
  if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
2873
2877
  context = meta.repo || context;
2874
2878
  soulDir = instanceSoulDir(instanceHome, meta);
2879
+ const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
2880
+ // Inside a home the namespace is the home's, so a --soul for another soul is refused, never
2881
+ // ignored (as inspect's --soul against --home is). The home's own soul, by any of its names, is fine.
2882
+ const soulFlag = flag("soul");
2883
+ if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name", { flag: "--soul" });
2884
+ if (typeof soulFlag === "string" && !(await import("../lib/instance-resolution.mjs")).homeSoulMatches(soulFlag, meta)) {
2885
+ bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of the instance home ${instanceHome} (${meta.agent}), which ${chosenBy} chose; to run "${cmd}" as a spawn of ${soulFlag} would, run it outside the instance home with OATS_INSTANCE_HOME and OATS_HOME unset`,
2886
+ { home: instanceHome, soul: meta.agent, chosenBy, flag: "--soul" });
2887
+ }
2875
2888
  // The team/workspace facts the home recorded at spawn, as its hooks got them, with the
2876
2889
  // recorded eligible teams (OATS_TEAMS_SOURCE=recorded). Only the home's MESSAGING module
2877
2890
  // gets them live (below): its team verbs (join/leave/teams) must see what the workspace
2878
2891
  // allows now, and no other command pays a remote read for them.
2879
- const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
2880
2892
  const messaging = (meta.capabilities || []).find((c) => c.layer === "messaging")?.id;
2881
2893
  homeMeta = { meta, messaging };
2882
2894
  homeTeamCtx = (t) => teamEnv({ workspace: { key: ws.key, name: ws.name, deployment: ws.deployment }, teams: t.teams, defaultTeam: t.defaultTeam, teamsSource: t.source });
@@ -2894,7 +2906,7 @@ async function capabilityCommand() {
2894
2906
  // Workspace model: an instance's own materialized modules are the command
2895
2907
  // namespaces available to it (instance.json.modules → <home>/.oats/modules).
2896
2908
  const mans = Object.values(capabilityManifests(instanceHome)).filter((m) => m.command === cmd && m.commands);
2897
- if (!mans.length) return NOT_DISPATCHED;
2909
+ if (!mans.length) bail("E_UNKNOWN_COMMAND", `oats ${cmd}: no capability of the instance home ${instanceHome} (soul ${homeMeta.meta.agent}), which ${chosenBy} chose, provides "${cmd}"`, { home: instanceHome, chosenBy, namespace: cmd });
2898
2910
  if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
2899
2911
  const m = mans[0];
2900
2912
  if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
@@ -27,9 +27,9 @@ named launch configuration (`--launch-config <name>`); see
27
27
  [configuration.md](configuration.md#launch-configurations).
28
28
  `--model @native-default` uses the harness's own default model.
29
29
 
30
- The launch command sets `OATS_INSTANCE`, `OATS_INSTANCE_HOME`,
31
- `PI_AGENT_INSTANCE` and `PI_AGENT_HOME`, plus the environment that the launch
32
- configuration and capabilities contribute. The home's layout and what those
30
+ The launch command sets `OATS_INSTANCE` and `OATS_INSTANCE_HOME`, plus the
31
+ environment that the launch configuration and capabilities contribute. No
32
+ `PI_AGENT_*` name is set, for any harness. The home's layout and what those
33
33
  variables point at are described in
34
34
  [souls-and-instances.md](souls-and-instances.md#instance-anatomy).
35
35
 
@@ -72,6 +72,27 @@ Every CLI command owns one read session (`createReadSession` in
72
72
  CLI closes it when the command ends). A library caller without a session
73
73
  gets the plain per-call behaviour. Within a session:
74
74
 
75
+ - a HEAD observation speaks protocol v0 when the operator has not pinned
76
+ `protocol.version` (`git config --get`, read once per command): the whole
77
+ ref advertisement in one round trip, resolved exactly as v2's filtered
78
+ answer. `V0_ADVERTISEMENT_BUDGET` (4 MiB, git's `maxBuffer`) bounds what
79
+ is kept, not the transfer: git reads the whole advertisement before it
80
+ prints a ref, so a remote over budget costs its advertisement once, then
81
+ git is killed, the remote observed again under v2 and recorded. A v0
82
+ timeout stays today's error (no retry) and is recorded too, unless the
83
+ session's `deadline` cut that read's timeout (the deadline, not the remote,
84
+ may have ended it). The
85
+ v0 read and its `protocol.version` check go through `sessionExec` like every
86
+ other git call. The record is
87
+ `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`, `{ protocol: "v2",
88
+ reason: "overflow" | "timeout", recordedAt }`, one file per reason (an
89
+ in-flight timeout never replaces an overflow), written atomically with no lock;
90
+ `overflow` is permanent, `timeout` expires after 7 days, and an unreadable,
91
+ corrupt or expired record is no record (v0 is tried). Another v0 failure
92
+ that is not final (auth, not-found, cache, an abort) is retried once under
93
+ v2. Each is a session notice. The v2 argv (`lsRemoteArgs`) stays the
94
+ observation's identity: records and memo keys do not depend on the
95
+ protocol;
75
96
  - a head is observed once per (cache repo, ref), and a commit peeled once; at
76
97
  most eight observations run at once (`OBSERVE_LIMIT`), each holding its slot
77
98
  for all its git work (the `ls-remote` and the fetch of the commit it names,
@@ -107,7 +128,8 @@ gets the plain per-call behaviour. Within a session:
107
128
  exit hook (`closeNow`), and the system reaps them once the process is gone;
108
129
  - a session may have a `deadline` (`READ_REMOTE_BUDGET_MS`, 12 s after it
109
130
  starts): the CLI gives one to `status` and `workspace status` only
110
- (`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS` overrides it for tests). Every
131
+ (`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS` is a test and ops override,
132
+ not a contract). Every
111
133
  remote step then gets what is left of it instead of its own default: each
112
134
  git call's timeout (`sessionExec`: ls-remote, fetch, ls-tree, the cache's
113
135
  plumbing; none starts once nothing is left), the git version probe
package/docs/knowledge.md CHANGED
@@ -220,8 +220,10 @@ identical copies in both role capabilities.
220
220
  - **From an instance home**, `oats okf …` runs the home's copy of the module
221
221
  with the settings recorded at spawn.
222
222
  - **From the deployment directory** (holding `oats-local.yaml`), in a shell
223
- without another instance's `OATS_*` identity, every capability command
224
- needs `--soul <name>` (`E_BAD_ARGS` without it). The kernel resolves the
223
+ where neither `OATS_INSTANCE_HOME` nor `OATS_HOME` is set (either one pins
224
+ the command to that instance home), every capability command needs
225
+ `--soul <name>` (`E_BAD_ARGS` without it). Inside an instance home, a
226
+ `--soul` naming another soul is refused (`E_HOME_MISMATCH`). The kernel resolves the
225
227
  soul as a spawn would, fetches its module at the locked commit into
226
228
  `<deployment>/.oats/modules/` and runs it with the soul's merged settings.
227
229
  An unlocked package is `E_PACKAGE_MISSING` until `oats sync`.
@@ -12,7 +12,7 @@ or workspace membership alone does not make a package official.
12
12
  | `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
13
  | `oats.okf` | `v4.0.5` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
14
  | `oats.aweb` | `v1.17.5` | `oats.aweb` (messaging) | |
15
- | `oats.engineering` | `v1.4.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
15
+ | `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
16
16
  | `oats.authoring` | `v1.0.3` | `oats.authoring` | |
17
17
  | `oats.jira` | `v1.0.1` | `oats.jira` (tasks) | |
18
18
  | `oats.linear` | `v1.0.1` | `oats.linear` (tasks) | |
@@ -0,0 +1,57 @@
1
+ # OATS 0.34.1
2
+
3
+ ## Changed
4
+
5
+ - **Reading a remote's current head takes one round trip instead of two.**
6
+ `oats spawn` (preview and apply) and the read verbs ask each workspace
7
+ member for its head with Git's protocol v0, which returns the whole ref list
8
+ in one request, where protocol v2 takes two. On a 6-member deployment the
9
+ live phase of a spawn preview went from 0.77 s to 0.49 s (median of 5). Tags
10
+ and branches are read as before. If your Git configuration sets
11
+ `protocol.version`, OATS uses it as set. A remote whose ref list is over
12
+ 4 MiB is read with protocol v2 from then on (remembered in the remote
13
+ cache). A remote that times out under v0 fails as before and is read with
14
+ v2 for the next 7 days; a read ended by the 12 s budget of `oats status` or
15
+ `oats workspace status` is not counted as the remote's timeout. One that
16
+ fails under v0 for another reason than an
17
+ authentication refusal, a missing repository or the local cache is read
18
+ again with v2. Each case is one `oats: warning`. What a read observes is
19
+ unchanged, and an apply still observes every member live. Known limit: the
20
+ 4 MiB bounds what OATS keeps, not what Git downloads, so a remote with a
21
+ larger ref list transfers it once before OATS switches to protocol v2.
22
+ - **oats.engineering 1.5.0** (catalog and workspace pin, and the bundled
23
+ mirrors): developers keep each dynamic workflow under 10 agents and ask
24
+ their human for permission first for more. Within the cap they run
25
+ workflows without asking where the harness allows it; where it requires the
26
+ human's opt-in (Claude Code's workflow tool does), they use a standing
27
+ opt-in the human configured, or ask once per task.
28
+ - **The `oats-setup-admin` soul has knowledge and is harvested.** It drops
29
+ `oats.developer` and `knowledge: none` and takes the workspace's oats.okf
30
+ knowledge slot, owning the new `oats/oats-setup-admin` node (the judgement
31
+ of administering a workspace's config, never deployment state) and reading
32
+ the operator, expert and kernel nodes. This reverses the 0.29.1 opt-out:
33
+ deployment specifics are kept out of the base at promotion, by the node's
34
+ charter and the reviewed harvest PR, instead of by not harvesting the soul.
35
+ It needs `oats/oats-setup-admin` in the bound `oats` base
36
+ (awebai/oats-knowledge#51); new instances only.
37
+
38
+ - **`PI_AGENT_INSTANCE` and `PI_AGENT_HOME` are gone.** No launch sets them,
39
+ for any harness, pi included. (A home that records only its launch command,
40
+ from before launch recipes, keeps running that command as recorded.) The identity is `OATS_INSTANCE` and
41
+ `OATS_INSTANCE_HOME`, and the pi bridge (`@awebai/oats-pi`) reads
42
+ `OATS_INSTANCE_HOME`.
43
+ - Upgrade the CLI and the pi bridge together (`oats update` does both). A
44
+ 0.34.0 pi bridge under a 0.34.1 kernel does not find its instance home.
45
+ - Capability commands no longer read `PI_AGENT_HOME`. A leftover one in an
46
+ operator's shell no longer pins a command to that instance.
47
+ - The names stay reserved: a launch configuration still cannot set them.
48
+
49
+ ## Fixed
50
+
51
+ - **A capability command inside an instance home says which home, and why.**
52
+ - A `--soul` naming another soul than the home's was silently ignored; it
53
+ is now refused with `E_HOME_MISMATCH`.
54
+ - A namespace the home does not have answered `unknown command`; it is now
55
+ `E_UNKNOWN_COMMAND` naming the home.
56
+ - Both name what chose the home: `OATS_INSTANCE_HOME`, `OATS_HOME` or the
57
+ working directory.
@@ -245,8 +245,7 @@ OATS codex session does not appear in `codex agents`. So that the environment
245
245
  does not depend on this, a codex launch also sets it for tool
246
246
  commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
247
247
 
248
- - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
249
- `PI_AGENT_HOME`;
248
+ - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`;
250
249
  - every capability's launch environment (for example the messaging
251
250
  provider's identity home and delivery mode);
252
251
  - the launch configuration's literal values. A reference's value never goes
@@ -258,9 +257,9 @@ runs tool commands through the user's login shell, and a profile that prepends
258
257
  directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
259
258
  directory is found first.
260
259
 
261
- A capability command (`oats <namespace> …`) run with none of
262
- `OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
263
- from the working directory. It uses the nearest enclosing directory laid out as
260
+ A capability command (`oats <namespace> …`) runs in the instance home that
261
+ `OATS_INSTANCE_HOME` names, else `OATS_HOME`. With neither set, it finds its
262
+ instance from the working directory. It uses the nearest enclosing directory laid out as
264
263
  `<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
265
264
  name, and validates it like a home named by the environment. The walk uses the
266
265
  directory as the shell names it (`$PWD`). That matters for an attached
@@ -268,6 +267,13 @@ instance, whose `work/` links into its owner's tree: below it, the physical
268
267
  path is the owner's. A process that has no `$PWD` there would act as the
269
268
  owner, so an attached instance runs capability commands from its home.
270
269
 
270
+ Inside an instance home the namespace is that home's. A `--soul` naming
271
+ another soul is refused (`E_HOME_MISMATCH`), and a namespace the home does
272
+ not have is `E_UNKNOWN_COMMAND`. Both name the home and what chose it (the
273
+ variable, or the working directory). To run a command as a spawn of another
274
+ soul would, run it from the deployment with `OATS_INSTANCE_HOME` and
275
+ `OATS_HOME` unset.
276
+
271
277
  ## Lifecycle
272
278
 
273
279
  ### Spawn
@@ -561,9 +567,10 @@ Every instance is told its own home as **`OATS_INSTANCE_HOME`** (absolute), and
561
567
  instructions refer to it as `<instance-home>`. The two environments differ, so
562
568
  they are stated separately:
563
569
 
564
- - **Runtime session**: `OATS_INSTANCE_HOME` and `PI_AGENT_HOME` (plus
565
- `OATS_INSTANCE`/`PI_AGENT_INSTANCE`). The `PI_`-prefixed names are
566
- compatibility aliases for the separately published pi extension.
570
+ - **Runtime session**: `OATS_INSTANCE_HOME` and `OATS_INSTANCE`, for every
571
+ harness. The pi extension reads `OATS_INSTANCE_HOME` too; the `PI_AGENT_*`
572
+ names are not set (they stay reserved, so a launch configuration cannot set
573
+ them).
567
574
  - **Lifecycle hooks**: `OATS_INSTANCE_HOME` and `OATS_HOME`, alongside the rest of
568
575
  the hook contract. `OATS_HOME` predates `OATS_INSTANCE_HOME` and is kept because
569
576
  shipped capability hooks read it; it is **not** exported to harness sessions.
@@ -174,7 +174,18 @@ from *outside* that boundary, and **declaring one in the workspace's
174
174
  access context.** The kernel reads both halves over the remotes
175
175
  (`git ls-remote`, shallow fetches, the operator's own credential helpers,
176
176
  never a prompt). A half that cannot be read makes the member *unconfirmed*,
177
- never a half-success. `oats workspace status` and `oats sync` show each member
177
+ never a half-success. A remote's current head is read with Git's protocol v0
178
+ (one round trip) unless your Git configuration sets `protocol.version`, which
179
+ is then used as set; a tag or branch is read as before. A remote whose v0 ref
180
+ advertisement is over 4 MiB is read with protocol v2 from then on: the 4 MiB
181
+ bounds what OATS keeps, not the download, so the first read transfers that
182
+ advertisement once before falling back. A remote that times out under v0
183
+ fails as before and is read with protocol v2 for the next 7 days; a read
184
+ ended by the 12 s budget of `oats status` or `oats workspace status` is not
185
+ the remote's own timeout, and is neither remembered nor warned about. One that
186
+ fails under v0 for another reason than an authentication refusal, a missing
187
+ repository or the local cache is read again with v2. Each case prints one
188
+ `oats: warning`; the first two are remembered in the remote cache. `oats workspace status` and `oats sync` show each member
178
189
  as `confirmed` or the reason it is not:
179
190
 
180
191
  | status | meaning |
@@ -13,7 +13,7 @@
13
13
  export const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
14
14
  export const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
15
15
  export const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
16
- export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
16
+ export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
17
17
  export const PROCESS_BOOTSTRAP_ENV = new Set([
18
18
  "PATH", "HOME", "SHELL", "TMPDIR", "TMP", "TEMP", "PWD", "OLDPWD", "SHLVL", "_",
19
19
  "ENV", "BASH_ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "IFS", "PROMPT_COMMAND", "PS4", "ZDOTDIR",
package/lib/core.mjs CHANGED
@@ -2354,7 +2354,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2354
2354
  // The launch's environment, in prefix order: the instance, the capabilities' env, the
2355
2355
  // configuration's env (a reference by reference, never by value).
2356
2356
  const hookEnv = recipe.hooks?.env || {};
2357
- const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home], ["PI_AGENT_INSTANCE", instance], ["PI_AGENT_HOME", home]].map(([name, value]) => ({ name, value }));
2357
+ const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]].map(([name, value]) => ({ name, value }));
2358
2358
  for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
2359
2359
  for (const name of Object.keys(recipe.env || {}).sort()) {
2360
2360
  const v = recipe.env[name];
@@ -2561,7 +2561,7 @@ export function describeLaunchCommand(command) {
2561
2561
  * stay references); a command the parser refuses is withheld whole. */
2562
2562
  /** The identity environment every launch carries: public facts (instance
2563
2563
  * name, home path), shown in public renderings; everything else is withheld. */
2564
- export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
2564
+ export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
2565
2565
  export function redactLaunchCommand(command) {
2566
2566
  try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
2567
2567
  catch { return "<unparseable launch command withheld>"; }
@@ -2720,7 +2720,7 @@ function* spawnBody(root, agent, o = {}) {
2720
2720
  // decision: an attached agent shares its owner's work tree and is ALWAYS the
2721
2721
  // owner's child — relation flags that say anything else are contradictory and
2722
2722
  // rejected. Ambient env
2723
- // (OATS_INSTANCE/PI_AGENT_INSTANCE) is deliberately NOT consulted: any shell
2723
+ // (OATS_INSTANCE) is deliberately NOT consulted: any shell
2724
2724
  // opened inside an agent's tmux window inherits those vars, and env inheritance
2725
2725
  // is not evidence of intent — human spawns from such shells were misattributed
2726
2726
  // as instance-origin. Manual spawns land top-level unless a relation is
@@ -85,21 +85,43 @@ export function qualifiedSoulName(entry) {
85
85
  return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
86
86
  }
87
87
 
88
+ /** A soul name as an operator gives it: `[repoPart, soulPart]`, repoPart null for a bare name. */
89
+ function splitSoulName(name) {
90
+ return name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
91
+ }
92
+ /** Whether `name` names the member or package soul `{ name, repoKey }` / `{ name, package }`: a
93
+ * bare name, `<repo>/<soul>` with the member's key, a key suffix or its member name, or
94
+ * `<package>/<soul>` for a package soul. findSoulEntry's rule for each candidate. */
95
+ export function soulNameMatches(name, soul) {
96
+ const [repoPart, soulPart] = splitSoulName(name);
97
+ if (soulPart !== soul.name) return false;
98
+ if (!repoPart) return true;
99
+ if (typeof soul.package === "string") return repoPart === soul.package;
100
+ const key = soul.repoKey ?? "";
101
+ return key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
102
+ }
103
+ /** Whether `name` names the soul an instance home was spawned from (its instance.json `meta`): any name
104
+ * a spawn accepts for it, or the home's agent directory name. */
105
+ export function homeSoulMatches(name, meta) {
106
+ if (name === meta?.agent) return true;
107
+ const soul = meta?.workspace?.soul ?? {};
108
+ return soulNameMatches(name, soul.package && typeof soul.package === "object" ? { name: soul.name, package: soul.package.id } : { name: meta?.agent, repoKey: soul.repoKey });
109
+ }
110
+
88
111
  /** Find the soul named `name` in a discovery: confirmed members, external souls and package
89
112
  * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
90
113
  * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
91
114
  * or an external source, `<package>/<soul>` a package soul. */
92
115
  export function findSoulEntry(discovery, name) {
93
- const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
116
+ const [repoPart, soulPart] = splitSoulName(name);
94
117
  const hits = [];
95
118
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
96
119
  // workspace could not be read); resolveSoul admits exactly that case.
97
120
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
98
- const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
99
121
  for (const m of discovery.members || []) {
100
122
  if (!m.confirmed && m.key !== standaloneOwn) continue;
101
123
  for (const s of m.souls || []) {
102
- if (s.name !== soulPart || !memberMatches(m.key)) continue;
124
+ if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
103
125
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
104
126
  }
105
127
  }
@@ -107,7 +129,7 @@ export function findSoulEntry(discovery, name) {
107
129
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
108
130
  }
109
131
  for (const s of discovery.packageSouls || []) {
110
- if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
132
+ if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
111
133
  }
112
134
  if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
113
135
  if (hits.length > 1) {
package/lib/remote.mjs CHANGED
@@ -4,7 +4,11 @@
4
4
  * APPROACH (one approach, used for every remote kind — local bare repos and
5
5
  * https/ssh remotes alike):
6
6
  * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
- * never a fetch when the caller already gave a full OID.
7
+ * never a fetch when the caller already gave a full OID. A HEAD observation
8
+ * speaks protocol v0 unless the operator pinned `protocol.version` (one round
9
+ * trip instead of v2's two; what is kept of the advertisement is bounded, and a
10
+ * remote over budget, or one that timed out under v0, is observed under v2:
11
+ * observeLive).
8
12
  * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
13
  * commit locally. `ensureCommit` does a shallow, partial
10
14
  * `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
@@ -168,6 +172,10 @@ export const GIT_FETCH_TIMEOUT_MS = 600_000;
168
172
  /** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
169
173
  * this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
170
174
  export const READ_REMOTE_BUDGET_MS = 12_000;
175
+ /** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
176
+ * reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
177
+ * and the remote observed under v2, its server-side filter, from then on (observeLive). */
178
+ export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
171
179
  /** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
172
180
  export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
173
181
  /** `--max-age` bounds (seconds). */
@@ -752,7 +760,7 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
752
760
 
753
761
  class ReadSession {
754
762
  constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
755
- cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
763
+ cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
756
764
  if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
757
765
  if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
758
766
  this.maxAge = maxAge;
@@ -764,6 +772,8 @@ class ReadSession {
764
772
  this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
765
773
  this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
766
774
  this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
775
+ this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
776
+ this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
767
777
  this.parsedLimits = parsedLimits; // tests inject small prune bounds
768
778
  this.observations = new Map(); // memo key → Promise<head observation>
769
779
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
@@ -853,7 +863,8 @@ class ReadSession {
853
863
 
854
864
  /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
855
865
  * milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
856
- * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
866
+ * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
867
+ * `v0AdvertisementBudget`. */
857
868
  export function createReadSession(options = {}) { return new ReadSession(options); }
858
869
  /** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
859
870
  * by a caller that is still reading, so its shape only has to be a typed remote failure. */
@@ -1390,7 +1401,7 @@ function parseLsRemote(stdout) {
1390
1401
  }
1391
1402
 
1392
1403
  function resolveAt(parsed, at) {
1393
- if (at === undefined || at === null || at === "" || at === "HEAD") {
1404
+ if (isHeadAt(at)) {
1394
1405
  const oid = parsed.oids.get("HEAD");
1395
1406
  return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
1396
1407
  }
@@ -1434,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
1434
1445
 
1435
1446
  /** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
1436
1447
  function lsRemoteArgs(ref, at) {
1437
- const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
1448
+ const wantHead = isHeadAt(at);
1438
1449
  if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
1439
1450
  const args = ["ls-remote", "--symref", ref.url];
1440
1451
  if (wantHead) args.push("HEAD");
@@ -1518,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
1518
1529
  } finally { session.observeDone(); }
1519
1530
  }
1520
1531
 
1532
+ /** Whether `at` asks for the remote's default branch (a HEAD observation). */
1533
+ const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
1534
+
1535
+ /** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
1536
+ * `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
1537
+ * once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
1538
+ * included) → true: today's argv, never an error. */
1539
+ const protocolPins = new WeakMap();
1540
+ function protocolPinned(exec, session) {
1541
+ const memo = session?.protocolPins ?? protocolPins;
1542
+ let pinned = memo.get(exec);
1543
+ if (!pinned) {
1544
+ pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
1545
+ .then(() => true, (error) => error?.code !== 1);
1546
+ memo.set(exec, pinned);
1547
+ }
1548
+ return pinned;
1549
+ }
1550
+
1551
+ /** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
1552
+ * beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
1553
+ * root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
1554
+ * good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
1555
+ * file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
1556
+ * Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
1557
+ * that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
1558
+ const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
1559
+ const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
1560
+ function lsRemoteV2Recorded(root, ref, now) {
1561
+ const record = (reason) => {
1562
+ const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
1563
+ return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
1564
+ };
1565
+ if (record("overflow")) return true;
1566
+ const rec = record("timeout");
1567
+ const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
1568
+ return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
1569
+ }
1570
+ function recordLsRemoteV2(root, ref, reason, now) {
1571
+ writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
1572
+ }
1573
+
1574
+ /** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
1575
+ * such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
1576
+ const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
1577
+
1578
+ /**
1579
+ * `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
1580
+ * operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
1581
+ * advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
1582
+ * filtered answer gives, HEAD's symref included (v0's symref capability).
1583
+ * - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
1584
+ * advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
1585
+ * recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
1586
+ * - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
1587
+ * - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
1588
+ * and said once if the retry succeeds.
1589
+ * `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
1590
+ * and branches always use it.
1591
+ */
1521
1592
  async function observeLive(ref, args, at, options) {
1522
1593
  const exec = options.exec ?? runGit;
1523
- const signal = sessionOf(options)?.signal;
1524
- let out;
1525
- try { out = await sessionExec(exec, sessionOf(options), args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
1526
- catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1594
+ const session = sessionOf(options);
1595
+ const signal = session?.signal;
1596
+ // Every git call takes what is left of the session's deadline, if it has one (sessionExec).
1597
+ const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
1598
+ const root = cacheRootOf(options);
1599
+ const now = () => (session ? session.now() : Date.now());
1600
+ const url = redactUrl(ref.url);
1601
+ const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
1602
+ let out = null, retried = null;
1603
+ const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
1604
+ // A command given up while its protocol was asked starts no ls-remote.
1605
+ if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
1606
+ if (v0) {
1607
+ const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
1608
+ // A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
1609
+ // which says nothing about the remote: its timeout is not recorded.
1610
+ const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
1611
+ try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
1612
+ catch (error) {
1613
+ if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
1614
+ recordLsRemoteV2(root, ref, "overflow", now());
1615
+ say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
1616
+ } else {
1617
+ const reason = classifyRemoteFailure(error);
1618
+ const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
1619
+ if (!aborted && reason === "timeout" && !cut) {
1620
+ recordLsRemoteV2(root, ref, "timeout", now());
1621
+ say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
1622
+ }
1623
+ if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
1624
+ retried = reason;
1625
+ }
1626
+ }
1627
+ }
1628
+ if (!out) {
1629
+ try { out = await run(args); }
1630
+ catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1631
+ }
1527
1632
  const parsed = parseLsRemote(out.stdout);
1528
1633
  const hit = resolveAt(parsed, at);
1529
1634
  if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1530
1635
  if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1531
1636
  const { commit } = await ensureCommit(ref, hit.commit, options);
1637
+ if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
1532
1638
  return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
1533
1639
  }
1640
+ const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
1534
1641
 
1535
1642
  // ---------------------------------------------------------------------------
1536
1643
  // tree reading
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "oats.engineering": {
30
30
  "url": "https://github.com/awebai/oats-engineering.git",
31
- "ref": "v1.4.0",
31
+ "ref": "v1.5.0",
32
32
  "path": "oats-package"
33
33
  },
34
34
  "oats.framework": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
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",