@awebai/oats 0.36.1 → 0.38.0
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 +71 -65
- package/docs/capabilities.md +64 -9
- package/docs/capability-manifest.schema.json +4 -0
- package/docs/configuration.md +16 -21
- package/docs/design/2026-09-27-team-model-v2.md +1 -1
- package/docs/design/2026-10-02-team-model-3.md +146 -0
- package/docs/design/README.md +5 -1
- package/docs/desktop-cli-api.md +159 -121
- package/docs/desktop.md +33 -0
- package/docs/first-team.md +14 -7
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -14
- package/docs/oats-workspace.schema.json +4 -4
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +10 -9
- package/docs/release-notes/v0.37.0.md +111 -0
- package/docs/release-notes/v0.38.0.md +128 -0
- package/docs/souls-and-instances.md +4 -3
- package/docs/workspaces.md +122 -77
- package/lib/core.mjs +123 -32
- package/lib/instance-inspect.mjs +18 -15
- package/lib/instance-resolution.mjs +9 -6
- package/lib/resolve.mjs +5 -4
- package/lib/schedule.mjs +1 -1
- package/lib/teams-verbs.mjs +44 -70
- package/lib/teams.mjs +151 -112
- package/lib/workspace.mjs +15 -5
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +20 -9
package/docs/workspaces.md
CHANGED
|
@@ -35,7 +35,7 @@ workspace commit. Schemas: [`oats-workspace.schema.json`](oats-workspace.schema.
|
|
|
35
35
|
[`oats-membership.schema.json`](oats-membership.schema.json),
|
|
36
36
|
[`soul.schema.json`](soul.schema.json), [`oats-local.schema.json`](oats-local.schema.json);
|
|
37
37
|
the lock's format is in [packages](packages.md#lock-v3). The JSON schemas encode
|
|
38
|
-
shapes; domain rules (
|
|
38
|
+
shapes; domain rules (labels that are shared teams, duplicate members, canonical `from:` keys,
|
|
39
39
|
the two `packages:` value forms) live in the kernel's `validateWorkspace` /
|
|
40
40
|
`validateSoul`, which are the authority.
|
|
41
41
|
|
|
@@ -54,13 +54,16 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
54
54
|
- git:github.com/acme/tools # a member that ALSO publishes a package (see below)
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
|
-
oats.framework: v1.
|
|
57
|
+
oats.framework: v1.5.0 # bare version → resolves through the official catalog
|
|
58
58
|
oats.okf: v4.1.1
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
|
61
61
|
teams: # SHARED teams: the same provider team for everyone
|
|
62
62
|
engineering: { description: Platform and release automation, team: "engineering:acme.aweb.ai" }
|
|
63
63
|
reviewers: { description: Code review } # declared, not created yet (no `team` id): readiness team-unmapped
|
|
64
|
+
defaultTeam: engineering # the workspace's default team; see Teams below
|
|
65
|
+
souls: # which teams each soul may join besides its default
|
|
66
|
+
platform/*: { teams: [reviewers] }
|
|
64
67
|
|
|
65
68
|
defaults:
|
|
66
69
|
capabilities:
|
|
@@ -146,7 +149,8 @@ its content digest.
|
|
|
146
149
|
### `oats-local.yaml`: the only per-machine file
|
|
147
150
|
|
|
148
151
|
Which workspace this machine realizes, where member clones live, host-owned
|
|
149
|
-
provider settings, this deployment's local teams and
|
|
152
|
+
provider settings, this deployment's local teams and default (where the
|
|
153
|
+
workspace allows them), host
|
|
150
154
|
facts for automations, and launch configurations. The full reference is
|
|
151
155
|
[configuration.md](configuration.md).
|
|
152
156
|
|
|
@@ -308,89 +312,130 @@ Harnesses start normally, with their own skill discovery intact
|
|
|
308
312
|
|
|
309
313
|
## Teams
|
|
310
314
|
|
|
311
|
-
A team is a messaging-provider team (for oats.aweb, an
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
315
|
+
A team is a messaging-provider team (for oats.aweb, an aweb team id
|
|
316
|
+
`<team>:<namespace>`) under a **label**. An instance that sits in two teams is
|
|
317
|
+
a bridge between them: one process reads both inboxes and could relay anything
|
|
318
|
+
from one to the other. So which teams an organisation's instances may be in is
|
|
319
|
+
the organisation's decision, committed in its workspace file and closed by
|
|
320
|
+
default; a deployment's `oats-local.yaml` adds a team only where the workspace
|
|
321
|
+
allows it. Souls stay team-free in their own repositories: an organisation
|
|
322
|
+
says how souls behave in its teams in its `oats-workspace.yaml`, and someone
|
|
323
|
+
running the same souls standalone sees none of it.
|
|
324
|
+
|
|
325
|
+
**What this protects against, and what it does not.** The workspace's team
|
|
326
|
+
rules prevent **accidental** joins in a cooperative installation, and they make
|
|
327
|
+
the organisation's intended team set visible and reviewable in its git. That is
|
|
328
|
+
all they claim. They do not stop an operator from bridging teams on purpose:
|
|
329
|
+
whoever holds credentials for two teams can read under one identity and relay
|
|
330
|
+
under the other, or copy content outside the messaging layer, and distinct keys
|
|
331
|
+
do not prove distinct processes. The messaging provider's admission (for aweb,
|
|
332
|
+
the team controller key signs memberships; hosted invites are mediated by the
|
|
333
|
+
service) controls who holds credentials for a team, not what a process does
|
|
334
|
+
with what it reads. A deployment's authority comes from the credentials it
|
|
335
|
+
holds, not from its path or its name, and a team certificate carries no soul,
|
|
336
|
+
workspace or home-team claim (such metadata would be self-asserted, not a
|
|
337
|
+
control).
|
|
338
|
+
|
|
339
|
+
### Where teams are declared
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
# oats-workspace.yaml (committed, edited by PR)
|
|
343
|
+
teams: # SHARED teams: the same provider team for everyone
|
|
344
|
+
engineering: { team: "engineering:acme.aweb.ai" }
|
|
345
|
+
security: { team: "security:acme.aweb.ai" }
|
|
346
|
+
docs: { description: Docs rota } # declared, not created yet: team-unmapped
|
|
347
|
+
defaultTeam: engineering # the workspace's fallback default team (a shared label)
|
|
348
|
+
localTeams: false # may deployments declare their own teams? (absent: false)
|
|
349
|
+
souls: # per soul pattern: its default team and the other teams it may join
|
|
350
|
+
"*": { teams: [] } # unlisted souls: default only (also what no entry means)
|
|
351
|
+
security-souls/*: { default: security, teams: [engineering] }
|
|
352
|
+
security-souls/incident-responder: { default: security, teams: [engineering, docs] }
|
|
353
|
+
oats.engineering/*: { teams: any } # any: every shared team of this file
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- **Shared teams** (`teams.<label> = { description?, team? }`): a shared team
|
|
357
|
+
without `team` is declared but not created yet (readiness `team-unmapped`):
|
|
358
|
+
its owner creates it with the messaging provider, then commits the id.
|
|
359
|
+
- **`souls:` keys are patterns**: `<member>/<soul>` or `<package>/<soul>`,
|
|
360
|
+
`<member>/*` or `<package>/*`, and `"*"`, with the member and package names
|
|
361
|
+
`souls.disabled` uses (a member's name is its repository's). A bare soul name
|
|
362
|
+
is refused. A key that is neither a pattern nor a soul the workspace offers
|
|
363
|
+
is the warning `team-soul-unknown` (a typo guard).
|
|
364
|
+
- **The most specific key wins outright** for a soul's teams, and lists never
|
|
365
|
+
merge: the soul's own key, then `<member|package>/*`, then `"*"`. The
|
|
366
|
+
default comes from the most specific key that sets one. So
|
|
367
|
+
`a/*: {default: security}` and `a/x: {teams: [docs]}` give `a/x` the default
|
|
368
|
+
`security` and the teams `[docs]` only.
|
|
369
|
+
- **A soul no key matches** (and no `"*"`) has its default only. That applies
|
|
370
|
+
to member and package souls alike: a workspace opens a package explicitly.
|
|
371
|
+
- **Every label** (`defaultTeam`, each `default`, each of `teams`) must be a
|
|
372
|
+
shared team of the same file; anything else is `E_WORKSPACE_SCHEMA` when the
|
|
373
|
+
file is read, never at a spawn on someone else's machine.
|
|
374
|
+
- **Local teams** (`oats-local.yaml` `teams.<label> = { team, description? }`)
|
|
375
|
+
and a local `defaultTeam` are allowed only when the workspace says
|
|
376
|
+
`localTeams: true`. Otherwise every spawn, preview and inspect is refused with
|
|
377
|
+
`E_WORKSPACE_SCHEMA` (reason `local-teams-closed`), naming both fixes: add
|
|
378
|
+
`localTeams: true` to the workspace file, or commit the teams and the
|
|
379
|
+
default there and remove them locally. A label in both files is
|
|
380
|
+
`team-label-collision` (a warning): the **shared** definition wins, and the
|
|
381
|
+
fix is renaming the local label.
|
|
382
|
+
- **The standalone view** (an explicit `standalone:`, or a fallback when the
|
|
383
|
+
workspace cannot be read) has no workspace rules: local teams and the local
|
|
384
|
+
`defaultTeam` apply there.
|
|
385
|
+
- `soul.yaml` and `oats-membership.yaml` say nothing about teams.
|
|
386
|
+
`oats-local.yaml` `souls.teams` and `souls.default` were removed in 0.38.0:
|
|
387
|
+
they are refused, and the refusal prints the `souls:` to commit instead.
|
|
388
|
+
|
|
389
|
+
### A soul's default team and its teams
|
|
390
|
+
|
|
391
|
+
The default, in order:
|
|
392
|
+
|
|
393
|
+
1. the soul's `default` from `souls:` (the most specific matching key that
|
|
394
|
+
sets one);
|
|
395
|
+
2. else the deployment's `oats-local.yaml` `defaultTeam`, only when
|
|
396
|
+
`localTeams: true`;
|
|
397
|
+
3. else the workspace's `defaultTeam`;
|
|
398
|
+
4. else none (`E_TEAM_UNCONFIGURED` when a messaging layer is active).
|
|
399
|
+
|
|
400
|
+
`defaultTeam.from` says which: `soul`, `deployment` or `workspace`.
|
|
401
|
+
|
|
402
|
+
The teams a soul may join: its default, plus the `teams` of its most specific
|
|
403
|
+
matching `souls:` key, plus, with `localTeams: true`, every local team the
|
|
404
|
+
deployment declares. Each row says why (`via`: `default`, `workspace`,
|
|
405
|
+
`local`). At spawn an instance joins its **default** only; the others are
|
|
337
406
|
eligible: offered, and joined on request through the provider (`join=` at
|
|
338
|
-
spawn, or its own verbs later)
|
|
339
|
-
|
|
340
|
-
and the soul only, the same for everyone. **A
|
|
341
|
-
changes trust or partitions the knowledge
|
|
407
|
+
spawn, or its own verbs later), which refuses a team that is not eligible.
|
|
408
|
+
A local default no file declares is `E_TEAM_UNKNOWN`. Capabilities compose
|
|
409
|
+
from the workspace defaults and the soul only, the same for everyone. **A
|
|
410
|
+
label never gates, restricts, changes trust or partitions the knowledge
|
|
411
|
+
store.**
|
|
412
|
+
|
|
413
|
+
### The verbs
|
|
342
414
|
|
|
343
|
-
|
|
415
|
+
They never call a provider. `oats teams add` and `oats teams default` edit
|
|
416
|
+
`oats-local.yaml` in place, where the workspace allows local teams; `oats teams
|
|
417
|
+
remove` runs anywhere (removing local teams is how a deployment moves them into
|
|
418
|
+
the workspace). A soul's teams are edited by a PR to `oats-workspace.yaml`:
|
|
419
|
+
`oats soul teams` only reads them.
|
|
344
420
|
|
|
345
421
|
```
|
|
346
|
-
oats teams [--json] #
|
|
347
|
-
oats teams add <label> --team <id> [--description d] # declare a local team (the first one becomes the default)
|
|
348
|
-
oats teams remove <label> # refused while
|
|
349
|
-
oats teams default <label>
|
|
350
|
-
oats soul teams <soul>|'*' [--
|
|
422
|
+
oats teams [--json] # the teams, ids, the default, localTeams, the workspace's souls:, problems
|
|
423
|
+
oats teams add <label> --team <id> [--description d] # declare a local team (the first one becomes the local default)
|
|
424
|
+
oats teams remove <label> # refused while it is the local default (E_TEAM_IN_USE) or shared (E_TEAM_SHARED)
|
|
425
|
+
oats teams default <label> # the local default
|
|
426
|
+
oats soul teams <soul>|'*' [--json] # a soul's default and teams here, and which souls: key gave them
|
|
351
427
|
```
|
|
352
428
|
|
|
353
|
-
The messaging provider's own setup creates provider teams
|
|
354
|
-
|
|
429
|
+
The messaging provider's own setup creates provider teams (see the provider's
|
|
430
|
+
documentation). The spawn preview, `inspect` and `oats souls` report a soul's
|
|
355
431
|
`teams` and `defaultTeam`; readiness reports the team problems in
|
|
356
432
|
`checks.configured` (`E_TEAM_UNCONFIGURED` when a messaging layer is active and
|
|
357
433
|
there is no default; `team-unmapped`, blocking when it is the default;
|
|
358
|
-
`default-team-changed` for a running
|
|
359
|
-
its environment — see
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
OATS 0.37.0 commits a soul's teams in the workspace (team model 3,
|
|
365
|
-
awebai/oats#484): the teams an organisation's instances may join become its
|
|
366
|
-
own decision, visible and reviewable in its git, so a deployment's
|
|
367
|
-
`oats-local.yaml` no longer adds one by accident. 0.36.x prepares for it, so
|
|
368
|
-
every workspace and deployment can migrate first:
|
|
369
|
-
|
|
370
|
-
- **`oats-workspace.yaml` accepts the new keys** and validates them, but
|
|
371
|
-
**does not apply them**: a soul's teams and default are still resolved as
|
|
372
|
-
above, from `oats-local.yaml`.
|
|
373
|
-
|
|
374
|
-
```yaml
|
|
375
|
-
defaultTeam: engineering # the workspace's fallback default team
|
|
376
|
-
localTeams: true # deployments may declare their own teams (absent: false)
|
|
377
|
-
souls: # per pattern: "*", <member|package>/*, <member|package>/<soul>
|
|
378
|
-
"*": { teams: [] } # default only ({} says the same)
|
|
379
|
-
security-souls/*: { default: security, teams: [engineering] }
|
|
380
|
-
oats.engineering/*: { teams: any } # every shared team
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
`<member|package>` is the name `souls.disabled` uses. Every label (`defaultTeam`,
|
|
384
|
-
a `souls:` `default`, each of its `teams`) must be a shared team in `teams:` of
|
|
385
|
-
the same file; anything else is `E_WORKSPACE_SCHEMA` when the file is read. A
|
|
386
|
-
key naming a member or package the workspace does not have is not an error.
|
|
387
|
-
- **The readiness warning `team-model-3-migration`** (never blocking) names
|
|
388
|
-
what 0.37.0 will refuse: `souls.teams` / `souls.default` in `oats-local.yaml`
|
|
389
|
-
(they move to `souls:`), and local `teams` / `defaultTeam` while the workspace
|
|
390
|
-
does not say `localTeams: true` (fix: add `localTeams: true`, or commit the
|
|
391
|
-
teams and `defaultTeam` in the workspace file). `oats teams`, readiness (and
|
|
392
|
-
so the Desktop) and `oats doctor` show it. The migration steps are in the
|
|
393
|
-
[0.36.1 release notes](release-notes/v0.36.1.md).
|
|
434
|
+
local-teams-closed; `team-soul-unknown`; `default-team-changed` for a running
|
|
435
|
+
instance). The provider receives them in its environment — see
|
|
436
|
+
[capabilities.md](capabilities.md#teams-in-the-provider-environment). Exact
|
|
437
|
+
shapes: [desktop-cli-api.md](desktop-cli-api.md#team-model-3-feature-team-model-3-oats-0370-replaces-feature-team-model-2).
|
|
438
|
+
Why it is shaped this way: [team model 3](design/2026-10-02-team-model-3.md).
|
|
394
439
|
|
|
395
440
|
## Provider payloads have three homes
|
|
396
441
|
|
package/lib/core.mjs
CHANGED
|
@@ -1574,8 +1574,12 @@ export function teamEnv(resolved) {
|
|
|
1574
1574
|
OATS_WORKSPACE_NAME: typeof ws.name === "string" ? ws.name : "", OATS_WORKSPACE_KEY: typeof ws.key === "string" ? ws.key : "",
|
|
1575
1575
|
};
|
|
1576
1576
|
}
|
|
1577
|
+
function withoutAmbientLaunchPreview(env) {
|
|
1578
|
+
const { OATS_LAUNCH_PREVIEW: _ambient, ...rest } = env;
|
|
1579
|
+
return rest;
|
|
1580
|
+
}
|
|
1577
1581
|
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, soulId, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
|
|
1578
|
-
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
|
|
1582
|
+
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], volatileEnv: {} };
|
|
1579
1583
|
// OATS_SOUL is set for EVERY hook: a caller that does not name the soul (launch,
|
|
1580
1584
|
// retire) gets the directory the home's spawn recorded.
|
|
1581
1585
|
if (!soulDir && home) soulDir = instanceSoulDir(home);
|
|
@@ -1596,7 +1600,9 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
|
|
|
1596
1600
|
const stdout = execSync(cmd, {
|
|
1597
1601
|
cwd: home, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000,
|
|
1598
1602
|
env: {
|
|
1599
|
-
|
|
1603
|
+
// OATS_LAUNCH_PREVIEW is the kernel's to give (prepareLaunchHooks), never
|
|
1604
|
+
// inherited: a real start's hook must not read an ambient one as a preview.
|
|
1605
|
+
...withoutAmbientLaunchPreview(process.env),
|
|
1600
1606
|
// OATS_INSTANCE_HOME is the runtime-neutral contract name for the
|
|
1601
1607
|
// instance home (absolute). OATS_HOME predates it and stays as a
|
|
1602
1608
|
// compatibility alias: shipped capability hooks read it
|
|
@@ -1624,6 +1630,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
|
|
|
1624
1630
|
let o = {};
|
|
1625
1631
|
try { o = JSON.parse(lastLine); } catch { /* non-JSON hook output is fine */ }
|
|
1626
1632
|
if (o.meta) results.meta[cap.id] = o.meta;
|
|
1633
|
+
// A launch hook's answer as given; prepareLaunchHooks validates it.
|
|
1634
|
+
if (event === "launch" && o.volatileEnv !== undefined) results.volatileEnv[cap.id] = o.volatileEnv;
|
|
1627
1635
|
if (o.brief) results.briefs.push(`- ${o.brief}`);
|
|
1628
1636
|
if (o.warning) results.warnings.push(o.warning);
|
|
1629
1637
|
if (o.launch && typeof o.launch === "object") for (const [rt, args] of Object.entries(o.launch)) results.launch[rt] = `${results.launch[rt] ? `${results.launch[rt]} ` : ""}${args}`;
|
|
@@ -2468,7 +2476,10 @@ function requirementsWithArgsMessage(harness, providers, config) {
|
|
|
2468
2476
|
/** ONE planner for what a start would run, used by preview and by starts of
|
|
2469
2477
|
* existing homes alike: the recorded recipe (or, under a selection, the
|
|
2470
2478
|
* current scoped configuration) resolved, preflighted, rendered. `preview`
|
|
2471
|
-
* collects every failed check into `preflight` instead of throwing.
|
|
2479
|
+
* collects every failed check into `preflight` instead of throwing. Launch
|
|
2480
|
+
* hooks of capabilities declaring `launchPreview` run here only as a preview
|
|
2481
|
+
* (OATS_LAUNCH_PREVIEW=1); the others run here for real when a start plans,
|
|
2482
|
+
* and not at all for a preview. A home
|
|
2472
2483
|
* that records no launch recipe (an earlier kernel spawned it) is not planned:
|
|
2473
2484
|
* E_LAUNCH_LEGACY, re-spawn it from the deployment. */
|
|
2474
2485
|
export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null }) {
|
|
@@ -2514,14 +2525,17 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2514
2525
|
// Recorded contributions, refreshed by capabilities that declare a
|
|
2515
2526
|
// launch hook; a harness change needs the new harness's arguments from
|
|
2516
2527
|
// every capability that gave harness-specific ones; recorded arguments
|
|
2517
|
-
// of a capability the scope no longer trusts are not reused.
|
|
2528
|
+
// of a capability the scope no longer trusts are not reused. Hooks that
|
|
2529
|
+
// declare launchPreview run here under OATS_LAUNCH_PREVIEW=1, for a start
|
|
2530
|
+
// too (it runs them for real once its preflight passed); the others run
|
|
2531
|
+
// here for real on a start, and not at all for a preview.
|
|
2518
2532
|
try {
|
|
2519
|
-
hooks = prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, assertRoots });
|
|
2533
|
+
hooks = prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, assertRoots, pass: preview ? "preview" : "plan" });
|
|
2520
2534
|
const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
|
|
2521
2535
|
const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
|
|
2522
2536
|
const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
|
|
2523
2537
|
if (untrusted.length) fail("capabilities", "E_LAUNCH_PREPARATION", `${untrusted.join(", ")} contributed to this launch at spawn but is no longer trusted in the scope; respawn the instance; nothing was stopped`);
|
|
2524
|
-
else problems.push({ check: "capabilities", ok: true, detail: `${harness !== frozen.harness ? "prepared for the new harness" : "recorded contributions reused"}${hooks.refreshed?.length ? `; refreshed by launch hooks: ${hooks.refreshed.join(", ")}` : ""}${inactive.length ? `; no longer active in the scope, recorded contribution kept: ${inactive.join(", ")}` : ""}` });
|
|
2538
|
+
else problems.push({ check: "capabilities", ok: true, detail: `${harness !== frozen.harness ? "prepared for the new harness" : "recorded contributions reused"}${hooks.refreshed?.length ? `; refreshed by launch hooks: ${hooks.refreshed.join(", ")}` : ""}${hooks.notRun?.length ? `; recorded contribution shown, launch hook not run for a preview (not preview-aware): ${hooks.notRun.join(", ")}` : ""}${inactive.length ? `; no longer active in the scope, recorded contribution kept: ${inactive.join(", ")}` : ""}` });
|
|
2525
2539
|
} catch (e) {
|
|
2526
2540
|
if (e.code !== "E_LAUNCH_PREPARATION") throw e;
|
|
2527
2541
|
fail("capabilities", e.code, e.message);
|
|
@@ -2574,7 +2588,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2574
2588
|
const { trustHome } = launchFolderTrust({ harness, home, meta, yolo: recipe.yolo, env });
|
|
2575
2589
|
const command = renderLaunchRecipe(recipe, { home, instance: inst, trustHome });
|
|
2576
2590
|
const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.harness) ? "frozen" : "config") : "config";
|
|
2577
|
-
return { recipe, command, trustHome, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
|
|
2591
|
+
return { recipe, command, trustHome, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], previewedHooks: hooks.previewed || [], volatileEnv: hooks.volatileEnv || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
|
|
2578
2592
|
}
|
|
2579
2593
|
/** The environment a planned launch runs under: the host's base, the
|
|
2580
2594
|
* capabilities' validated env, the configuration's literals and its
|
|
@@ -4863,10 +4877,24 @@ export function capturedProviders(meta, frozen) {
|
|
|
4863
4877
|
}
|
|
4864
4878
|
/** Capability contributions for a start of an existing home: the recorded
|
|
4865
4879
|
* ones, refreshed by any capability that declares a `launch` hook (asked
|
|
4866
|
-
* for the target harness;
|
|
4867
|
-
*
|
|
4868
|
-
*
|
|
4869
|
-
|
|
4880
|
+
* for the target harness; spawn hooks are never re-run). A harness change
|
|
4881
|
+
* needs the new harness's launch arguments from every capability that
|
|
4882
|
+
* contributed harness-specific ones.
|
|
4883
|
+
* The hook contract: a launch hook may do idempotent provider registration
|
|
4884
|
+
* on a real start. A capability whose manifest (the home's module copy)
|
|
4885
|
+
* declares `launchPreview: true` promises that its hook changes nothing
|
|
4886
|
+
* under OATS_LAUNCH_PREVIEW=1 and returns the same contribution (launch
|
|
4887
|
+
* arguments and env) either way, except for the env names its preview
|
|
4888
|
+
* answer lists in `volatileEnv`, whose values only the real run knows.
|
|
4889
|
+
* `pass` says which hooks run, and how:
|
|
4890
|
+
* - "preview" (a launch preview): declaring hooks under the flag; the others
|
|
4891
|
+
* do not run, and their recorded contributions stand (`notRun`);
|
|
4892
|
+
* - "plan" (a start's preflight): declaring hooks under the flag, the others
|
|
4893
|
+
* once, for real;
|
|
4894
|
+
* - "real" (a start after its preflight passed): declaring hooks for real,
|
|
4895
|
+
* over `frozen.hooks` = the planned contributions; startInstanceSession
|
|
4896
|
+
* refuses a contribution that differs from the preview's. */
|
|
4897
|
+
export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots, pass = "plan" }) {
|
|
4870
4898
|
const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
|
|
4871
4899
|
const env = { ...(frozen.hooks?.env || {}) };
|
|
4872
4900
|
const refreshed = [];
|
|
@@ -4889,20 +4917,44 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
|
|
|
4889
4917
|
const hooks = manifestHookCommands(manifest);
|
|
4890
4918
|
if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, settingsOrigins: p.settingsOrigins, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
|
|
4891
4919
|
}
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4920
|
+
const aware = withLaunchHook.filter((c) => c.manifest.launchPreview === true);
|
|
4921
|
+
const unaware = withLaunchHook.filter((c) => c.manifest.launchPreview !== true);
|
|
4922
|
+
const runs = (pass === "real" ? [[aware, false]] : pass === "preview" ? [[aware, true]] : [[unaware, false], [aware, true]]).filter(([caps]) => caps.length);
|
|
4923
|
+
const notRun = pass === "preview" ? unaware.map((c) => c.id) : [];
|
|
4924
|
+
const previewed = runs.filter(([, asPreview]) => asPreview).flatMap(([caps]) => caps.map((c) => c.id));
|
|
4925
|
+
const volatileEnv = [];
|
|
4926
|
+
if (runs.length) {
|
|
4927
|
+
const results = runs.map(([caps, asPreview]) => ({ asPreview, res: runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: caps }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...(asPreview ? { OATS_LAUNCH_PREVIEW: "1" } : {}), ...extraEnv } }) }));
|
|
4928
|
+
const failed = results.flatMap(({ res }) => res.failures || []).map((f) => `${f.capability}: ${f.message}`);
|
|
4895
4929
|
if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${harness} launch:\n ${failed.join("\n ")}`);
|
|
4930
|
+
const fresh = results.flatMap(({ res }) => res.contributions || []);
|
|
4896
4931
|
// Ownership holds across retained AND refreshed contributions, as the
|
|
4897
4932
|
// spawn runner holds it across providers: a refreshed provider may
|
|
4898
|
-
// replace its own previous keys, never a key another provider retains
|
|
4899
|
-
|
|
4933
|
+
// replace its own previous keys, never a key another provider retains,
|
|
4934
|
+
// nor one another provider's hook set in this pass.
|
|
4935
|
+
const refreshedIds = new Set(fresh.map((c) => c.capability));
|
|
4900
4936
|
const retainedOwner = new Map();
|
|
4901
4937
|
for (const c of contributions) if (c.capability && !refreshedIds.has(c.capability)) for (const name of c.env || []) retainedOwner.set(name, c.capability);
|
|
4902
|
-
|
|
4938
|
+
const freshOwner = new Map();
|
|
4939
|
+
for (const c of fresh) for (const name of c.env || []) {
|
|
4903
4940
|
if (retainedOwner.has(name)) throw oatsError("E_LAUNCH_PREPARATION", `${c.capability}'s launch hook set ${name}, which ${retainedOwner.get(name)} contributed at spawn and retains; one provider owns an environment name; nothing was stopped`);
|
|
4941
|
+
if (freshOwner.has(name) && freshOwner.get(name) !== c.capability) throw oatsError("E_LAUNCH_PREPARATION", `${c.capability}'s launch hook set ${name}, which ${freshOwner.get(name)}'s launch hook also set; one provider owns an environment name; nothing was stopped`);
|
|
4942
|
+
freshOwner.set(name, c.capability);
|
|
4943
|
+
}
|
|
4944
|
+
// A preview answer may name env whose values only the real run knows
|
|
4945
|
+
// (a credential minted at start). Each is a name the same hook returned,
|
|
4946
|
+
// and never a harness's configuration selector: the package probe reads
|
|
4947
|
+
// that under the preview's value before the real run.
|
|
4948
|
+
for (const { asPreview, res } of results) if (asPreview) for (const [id, names] of Object.entries(res.volatileEnv || {})) {
|
|
4949
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook answered volatileEnv that is not an array of environment names; nothing was stopped`);
|
|
4950
|
+
const returned = (res.contributions || []).find((c) => c.capability === id)?.env || [];
|
|
4951
|
+
for (const name of names) {
|
|
4952
|
+
if (!returned.includes(name)) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook declared ${name} volatile but did not return it in env; nothing was stopped`);
|
|
4953
|
+
if (HARNESS_CONFIG_SELECTORS.has(name)) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook declared ${name} volatile, but ${name} selects a harness's configuration, which the start's package probe reads before the real run; a volatile value must not affect harness package resolution; nothing was stopped`);
|
|
4954
|
+
volatileEnv.push(name);
|
|
4955
|
+
}
|
|
4904
4956
|
}
|
|
4905
|
-
for (const c of
|
|
4957
|
+
for (const c of fresh) {
|
|
4906
4958
|
const idx = contributions.findIndex((x) => x.capability === c.capability);
|
|
4907
4959
|
// The provider's previous contribution goes whole, its env names
|
|
4908
4960
|
// included, before its new (validated) one is merged: an empty answer
|
|
@@ -4912,23 +4964,32 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
|
|
|
4912
4964
|
if (idx >= 0) contributions[idx] = row; else contributions.push(row);
|
|
4913
4965
|
refreshed.push(c.capability);
|
|
4914
4966
|
}
|
|
4915
|
-
Object.assign(env, res.env || {});
|
|
4967
|
+
for (const { res } of results) Object.assign(env, res.env || {});
|
|
4968
|
+
// What the caller records comes from real runs only; a launch preview
|
|
4969
|
+
// answers its own runs' warnings.
|
|
4970
|
+
const recorded = results.filter(({ asPreview }) => asPreview === (pass === "preview")).map(({ res }) => res);
|
|
4916
4971
|
// A launch hook's `meta` is part of its documented return (the same shape
|
|
4917
4972
|
// spawn persists as capabilityMeta). It was collected and then dropped
|
|
4918
4973
|
// here, so a provider that re-issues a credential at every start — a
|
|
4919
4974
|
// renewed session grant, for instance — left the ORIGINAL id on record and
|
|
4920
4975
|
// retire undid the wrong one. Carry it to the caller; the start records it.
|
|
4921
|
-
|
|
4976
|
+
const metaOf = Object.assign({}, ...recorded.map((res) => res.meta || {}));
|
|
4977
|
+
hookMeta = Object.keys(metaOf).length ? metaOf : undefined;
|
|
4922
4978
|
// The hooks' advisory warnings go to the start's answer and events, as
|
|
4923
4979
|
// spawn's do; they were dropped here before 0.30.
|
|
4924
|
-
warnings =
|
|
4980
|
+
warnings = recorded.flatMap((res) => res.warnings || []);
|
|
4925
4981
|
}
|
|
4926
|
-
|
|
4982
|
+
// A hook not run for a preview keeps its recorded contribution: whether it
|
|
4983
|
+
// has arguments for the target harness is its own run's answer, at start.
|
|
4984
|
+
const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && !notRun.includes(c.capability) && c.launch && c.launch[frozen.harness] !== undefined && c.launch[harness] === undefined).map((c) => c.capability);
|
|
4927
4985
|
if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.harness} launch arguments at spawn and none for ${harness}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
|
|
4928
4986
|
const launch = {};
|
|
4929
4987
|
for (const c of contributions) { for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
|
|
4930
|
-
return { launch, env, contributions, refreshed, warnings, ...(hookMeta ? { meta: hookMeta } : {}) };
|
|
4988
|
+
return { launch, env, contributions, refreshed, notRun, previewed, volatileEnv, warnings, ...(hookMeta ? { meta: hookMeta } : {}) };
|
|
4931
4989
|
}
|
|
4990
|
+
/** Environment that selects which configuration (account, packages) a
|
|
4991
|
+
* harness reads, under which the package probe inspects its packages. */
|
|
4992
|
+
const HARNESS_CONFIG_SELECTORS = new Set(["CLAUDE_CONFIG_DIR", "CODEX_HOME", "PI_CODING_AGENT_DIR"]);
|
|
4932
4993
|
|
|
4933
4994
|
|
|
4934
4995
|
export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
|
|
@@ -5093,7 +5154,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5093
5154
|
const reselect = o.reselectLaunch === true ? homeLaunchLayers(realHome, meta) : null;
|
|
5094
5155
|
const selected = reselect !== null || o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
|
|
5095
5156
|
const hasRecipe = meta.launch && typeof meta.launch === "object";
|
|
5096
|
-
let launchPlan = null, explicitModelFrom = null, warnings = [];
|
|
5157
|
+
let launchPlan = null, launchHooksPass = null, hookMeta, explicitModelFrom = null, warnings = [];
|
|
5097
5158
|
if (selected || hasRecipe) {
|
|
5098
5159
|
// Every start of a home with a recipe (ordinary, model-only, or under a
|
|
5099
5160
|
// selection) goes through the one planner: recipe shape, the recorded
|
|
@@ -5106,11 +5167,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5106
5167
|
const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, defaultTeam: o.defaultTeam, teamsSource: o.teamsSource });
|
|
5107
5168
|
let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
|
|
5108
5169
|
const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
|
|
5109
|
-
launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
|
|
5170
|
+
launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
|
|
5110
5171
|
...(plan.launchChoice ? { launchFrom: plan.launchChoice.from, launchAt: plan.launchChoice.at, launchDeclared: plan.launchChoice.declared } : {}) };
|
|
5172
|
+
// The plan ran preview-aware launch hooks as a preview: their real run, over the planned
|
|
5173
|
+
// contributions, waits for the rest of preflight.
|
|
5174
|
+
if (plan.previewedHooks.length) launchHooksPass = { frozen: { ...plan.frozen, hooks: plan.recipe.hooks }, harness: plan.harness, resolvedCfg, contextDir: context, previewed: plan.recipe.hooks, volatileEnv: plan.volatileEnv, trustHome: plan.trustHome };
|
|
5111
5175
|
command = launchPlan.command; model = launchPlan.model;
|
|
5112
|
-
// The launch hooks
|
|
5113
|
-
// the start does next, and the answer carries them.
|
|
5176
|
+
// The other launch hooks ran for real in the plan: their warnings are
|
|
5177
|
+
// events now, whatever the start does next, and the answer carries them.
|
|
5178
|
+
hookMeta = plan.hookMeta;
|
|
5114
5179
|
warnings = plan.warnings;
|
|
5115
5180
|
for (const message of warnings) appendEvent(realHome, { kind: "launch-warning", data: { message } });
|
|
5116
5181
|
} else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
|
|
@@ -5127,10 +5192,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5127
5192
|
if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
|
|
5128
5193
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
5129
5194
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
5130
|
-
checkRoots(); //
|
|
5131
|
-
const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
|
|
5132
|
-
...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
|
|
5133
|
-
...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}) } : (explicitModelFrom ? { modelFrom: explicitModelFrom } : {});
|
|
5195
|
+
checkRoots(); // preparation has run; no backend has been observed
|
|
5134
5196
|
let target = receipt.target;
|
|
5135
5197
|
let state = { present: false, state: "not-launched" };
|
|
5136
5198
|
let serverGone = false;
|
|
@@ -5141,9 +5203,38 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5141
5203
|
else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
|
|
5142
5204
|
}
|
|
5143
5205
|
}
|
|
5206
|
+
if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
|
|
5207
|
+
// Every preflight has passed: the preview-aware launch hooks run for real
|
|
5208
|
+
// (they may register the home with their provider), before a restart's
|
|
5209
|
+
// stop, and must contribute exactly what they contributed as a preview,
|
|
5210
|
+
// which is what the command was rendered and preflighted from, except the
|
|
5211
|
+
// values of the env their preview declared volatile: those come from this
|
|
5212
|
+
// run, and the command is rendered again with them.
|
|
5213
|
+
if (launchHooksPass) {
|
|
5214
|
+
const { previewed, volatileEnv, trustHome, ...pass } = launchHooksPass;
|
|
5215
|
+
const real = prepareLaunchHooks({ ...pass, home: realHome, meta, assertRoots: checkRoots, pass: "real" });
|
|
5216
|
+
const canon = (v) => canonicalJson(JSON.parse(JSON.stringify(v ?? null)));
|
|
5217
|
+
const stable = (h) => Object.fromEntries(Object.entries(h.env).filter(([n]) => !volatileEnv.includes(n)));
|
|
5218
|
+
if (canon({ launch: real.launch, env: stable(real), contributions: real.contributions }) !== canon({ launch: previewed.launch, env: stable(previewed), contributions: previewed.contributions })) {
|
|
5219
|
+
// A provider's row names its env; the values are in the merged env.
|
|
5220
|
+
const rowOf = (h, id) => { const row = h.contributions.find((c) => c.capability === id); return canon({ row, values: (row?.env || []).map((n) => volatileEnv.includes(n) ? null : h.env[n]) }); };
|
|
5221
|
+
const differing = [...new Set([...real.contributions, ...previewed.contributions].map((c) => c.capability))].filter((id) => rowOf(real, id) !== rowOf(previewed, id));
|
|
5222
|
+
throw oatsError("E_LAUNCH_PREPARATION", `the launch hook of ${differing.join(", ") || "a capability"} returned a contribution that differs from its preview contribution (a launch hook returns the same contribution under OATS_LAUNCH_PREVIEW, apart from the values of its volatileEnv); nothing was stopped or started`);
|
|
5223
|
+
}
|
|
5224
|
+
launchPlan.recipe = { ...launchPlan.recipe, hooks: { launch: real.launch, env: real.env, contributions: real.contributions } };
|
|
5225
|
+
command = launchPlan.command = renderLaunchRecipe(launchPlan.recipe, { home: realHome, instance: meta.instance, trustHome });
|
|
5226
|
+
if (real.meta) hookMeta = { ...(hookMeta || {}), ...real.meta };
|
|
5227
|
+
// The real run's warnings are events now, whatever the start does next,
|
|
5228
|
+
// and the answer carries them.
|
|
5229
|
+
warnings = [...warnings, ...real.warnings];
|
|
5230
|
+
for (const message of real.warnings) appendEvent(realHome, { kind: "launch-warning", data: { message } });
|
|
5231
|
+
checkRoots();
|
|
5232
|
+
}
|
|
5233
|
+
const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
|
|
5234
|
+
...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
|
|
5235
|
+
...(hookMeta ? { hookMeta } : {}) } : (explicitModelFrom ? { modelFrom: explicitModelFrom } : {});
|
|
5144
5236
|
let stopReceipt = null;
|
|
5145
5237
|
if (state.present && state.state !== "shell") {
|
|
5146
|
-
if (!o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
|
|
5147
5238
|
// Restart: every preflight above passed, so ask the running harness to
|
|
5148
5239
|
// end and wait, bounded. A harness still there afterwards is reported
|
|
5149
5240
|
// as running; nothing is escalated and nothing is launched.
|