@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.
@@ -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 (declared teams, duplicate members, canonical `from:` keys,
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.4.2 # bare version → resolves through the official catalog
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 team membership, host
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
- aweb team id `<team>:<namespace>`) under a **label**. Two files declare them:
313
-
314
- - **Shared teams**: the committed `oats-workspace.yaml` `teams.<label> =
315
- { description?, team? }`: the same provider team for everyone, edited by a PR.
316
- A shared team without `team` is declared but not created yet (readiness
317
- `team-unmapped`): its owner creates it with the messaging provider, then
318
- commits the id.
319
- - **Local teams**: the deployment's `oats-local.yaml` `teams.<label> = { team,
320
- description? }`: a team only this deployment uses (a personal team). A label in
321
- both files is `team-label-collision` (a warning); the **shared** definition
322
- wins, and the fix is renaming the local label.
323
-
324
- `oats-local.yaml` also says which teams each soul belongs to **here**:
325
-
326
- - `defaultTeam: <label>`: the team every instance of this deployment lives in
327
- (its default-team identity);
328
- - `souls.teams`: `"*"` for every soul, and a soul's own entry (its bare name, or
329
- `<package>/<soul>` for a package soul) adds to it;
330
- - `souls.default`: a per-soul override of `defaultTeam`; it must be one of that
331
- soul's teams (`E_TEAM_NOT_ELIGIBLE`).
332
-
333
- A soul's default is `souls.default[soul] ?? defaultTeam`; its teams are that
334
- default ∪ `souls.teams["*"]` ∪ `souls.teams[soul]`. A label no file declares is
335
- `E_TEAM_UNKNOWN` (a spawn, preview or `inspect --soul` of that soul is
336
- refused). At spawn an instance joins its **default** only; the others are
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). Nothing committed besides the shared `teams:`
339
- says anything about teams, and capabilities compose from the workspace defaults
340
- and the soul only, the same for everyone. **A label never gates, restricts,
341
- changes trust or partitions the knowledge store.**
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
- The verbs edit `oats-local.yaml` in place; they never call a provider:
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] # this deployment's teams, ids, the default, problems
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 referenced (E_TEAM_IN_USE) or shared (E_TEAM_SHARED)
349
- oats teams default <label>
350
- oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <label> | --clear-default] [--json]
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 and records them with
354
- `oats teams add` (see the provider's documentation). The spawn preview, `inspect` and `oats souls` report a soul's
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 instance). The provider receives them in
359
- its environment — see [capabilities.md](capabilities.md#teams-in-the-provider-environment).
360
- Exact shapes: [desktop-cli-api.md](desktop-cli-api.md#team-model-v2-feature-team-model-2-oats-0300-replaces-feature-teams).
361
-
362
- ### Preparing for team model 3 (0.36.x)
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
- ...process.env,
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. A home
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; side-effect-free by contract; spawn hooks are
4867
- * never re-run). A harness change needs the new harness's launch arguments
4868
- * from every capability that contributed harness-specific ones. */
4869
- export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
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
- if (withLaunchHook.length) {
4893
- const 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: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...extraEnv } });
4894
- const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
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
- const refreshedIds = new Set((res.contributions || []).map((c) => c.capability));
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
- for (const c of res.contributions || []) for (const name of c.env || []) {
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 res.contributions || []) {
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
- hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
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 = [...(res.warnings || [])];
4980
+ warnings = recorded.flatMap((res) => res.warnings || []);
4925
4981
  }
4926
- const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && c.launch && c.launch[frozen.harness] !== undefined && c.launch[harness] === undefined).map((c) => c.capability);
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 }), ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}),
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 have run: their warnings are events now, whatever
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(); // launch hooks/preparation have run; no backend has been observed
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.