@awebai/oats 0.24.8 → 0.24.10

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.
@@ -0,0 +1,55 @@
1
+ # OATS v0.24.10 — confirmed, idempotent spawn (apply contract) and the Desktop Review → Confirm spawn flow
2
+
3
+ Kernel/Pi/Desktop **0.24.10**. Tag `v0.24.10` → the commit carrying these
4
+ notes; the version-bump commit lands after the tag. Consumers gate on
5
+ `oats version --json` `features[]` names and API integers — never on the version.
6
+
7
+ ## Kernel — the spawn apply contract (K6c–K6f)
8
+
9
+ A Desktop can now let a person **review** exactly what a spawn will do and then
10
+ **confirm** it, with the kernel guaranteeing that what runs is what was
11
+ reviewed — and that a lost response never spawns twice.
12
+
13
+ - **`spawn-apply-2`** (`spawnApplyApi: 1`): the previewed `decision` binds
14
+ **effective launch facts** (`repo, work, runtime, model, launchConfig, yolo,
15
+ backend, childSpawns, relation/anchor`) as well as placement; an inherited
16
+ default edited between preview and apply → `E_DECISION_STALE`. No backend
17
+ (Herdr) is started before the decision fence. The home is reserved with an
18
+ **exclusive** non-recursive `mkdir` right after the check: two concurrent
19
+ applies of one decision produce exactly one home; the loser refuses
20
+ `E_PLACEMENT_TAKEN` having touched nothing.
21
+ - **`spawn-idempotency-2`**: `spawn --expect-decision <rev> --idempotency-key
22
+ <key>` records both in the new home. A **same-key retry replays** the
23
+ recorded receipt (found by key, never by name — the planned name may have
24
+ been auto-suffixed past it), runs **before** any placement/branch/base check
25
+ (so a spawn that created its explicit branch still replays), and re-saves no
26
+ wake. Same key + different decision → `E_IDEMPOTENCY_CONFLICT`. A home whose
27
+ spawn did not finish (crash between metadata and launch) → `E_SPAWN_INCOMPLETE`,
28
+ never a replayed success. The wake outcome is recorded and returned on replay
29
+ (`wake {requested, saved, error}`; `saved:null` = not recorded).
30
+ - Decision-bound receipts echo the **full** bound decision; the kernel's own
31
+ post-spawn writes (completion marker, wake record) re-stamp the retirement
32
+ baseline so a fresh keyed home retires clean.
33
+ - Every one of these came from the Desktop engineer's pre-wiring reads of the
34
+ merged producer, several proved in an inert VM; each is a new advertised name
35
+ because a strengthened guarantee must be distinguishable from the installed
36
+ CLI that lacks it.
37
+
38
+ ## Desktop
39
+
40
+ - **Review spawn → Confirm spawn**: the spawn modal prepares a server-owned,
41
+ immutable intent (soul, root, choices, task, wake, the kernel's decision) and
42
+ applies only by reference; the idempotency key is minted at the first
43
+ confirmation and kept for that intent's retries. A lost response shows
44
+ **unknown** with an explicit *Check result* (a same-key kernel replay), never
45
+ a second spawn or a guessed terminal. Stale/conflict refusals require a fresh
46
+ review and a new confirmation. `E_SPAWN_INCOMPLETE` points at the session
47
+ surface; an unrecorded wake outcome says so ("check Schedules"). A raw local
48
+ spawn request on a fully capable CLI is refused (`E_PLAN_REQUIRED`); older or
49
+ remote CLIs keep ordinary spawn with the new choices unavailable. No
50
+ persistent task journal: after a Desktop restart a submitted intent is
51
+ unavailable/unknown and mints nothing.
52
+
53
+ ## Upgrade
54
+
55
+ `npm i -g @awebai/oats@0.24.10`, then `oats doctor`.
@@ -0,0 +1,67 @@
1
+ # OATS v0.24.9 — readiness pins, side-effect-free spawn preview (API 2), process-group safety, Desktop Readiness view
2
+
3
+ Kernel/Pi/Desktop **0.24.9**. Tag `v0.24.9` → the commit carrying these notes;
4
+ the version-bump commit lands after the tag. Consumers gate on `oats version
5
+ --json` `features[]` names and API integers — never on the version.
6
+
7
+ ## Kernel
8
+
9
+ - **Spawn preview API 2** (feature `spawn-preview-2`, `spawnPreviewApi: 2`).
10
+ API 1 previews **wrote before they returned** — a refused child spawn appended
11
+ an event to the parent, a Herdr backend could be started, an unknown soul
12
+ could be imported from an importable def — and nothing bound a later spawn to
13
+ the previewed decision. API 2: a preview touches nothing, success or refusal
14
+ (proven by a byte-identical deployment tree); `spawn --agents-root <abs>`
15
+ binds the exact root with no fallback and the preview echoes `subject` as
16
+ given; `decision {instance, home, branch, base, revision}` + `spawn
17
+ --expect-decision <rev>` refuses **`E_DECISION_STALE`** with the fresh
18
+ decision on any drift (no auto-suffix, no silent re-base, nothing created);
19
+ every native probe shares one bounded preflight budget and is process-group
20
+ killed on timeout (`preflight {status, budgetMs, elapsedMs}`). **Gate on API
21
+ 2 — API 1 is the pre-fix marker.**
22
+ - **Readiness pins** (`oats readiness`): `configured` is *effective* activation
23
+ (a capability declared for the soul but disabled is a fail that says so);
24
+ data-only capabilities (skills/inject, no commands/hooks/env) report trust
25
+ **not-applicable** — the inspect row carries `health.executableSurface`;
26
+ every item is typed (`capability {id, level, scope}`, `origin {kind,
27
+ target}`) and `summary.byCapability[]` regroups the same items with
28
+ `ownReady` vs `ready` (never ready under a `summary.subjectBlockers[]` item
29
+ such as unknown membership); `subject.selector` echoes the arguments as given,
30
+ byte-exact; an unreadable member document is `unknown`, not not-applicable;
31
+ `readiness --home <captured>` refuses `E_UNSUPPORTED_MODE`; `--agents-root`
32
+ documented. **Signature verification** (feature `readiness-verify`) is
33
+ bounded custody: one budget per read, process-group kill, cleanup on
34
+ SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL=/dev/null`, https/ssh only, a closed
35
+ `signature.failure.code` — never stderr.
36
+ - **Process-group safety (high).** Bounded-custody code killed a child's
37
+ process group on timeout with `process.kill(-child.pid)`; a **failed** spawn
38
+ (binary missing → `ENOENT`) reports `pid: 0`, and `kill(-0)` signals the
39
+ caller's own process group — `oats readiness --verify-signatures` without
40
+ `git` would have SIGKILLed the operator's shell/tmux/Desktop backend. One
41
+ guarded helper now refuses non-positive PIDs at every kill site.
42
+ - **OKF 2.1.3** mirrored and pinned in the official catalog: per-cause `check`
43
+ reasons, a named remedy when a soul has no `okf.json` (was a raw ENOENT from
44
+ the required spawn hook), retired drained sources switch their `okf-<id>` job
45
+ off.
46
+
47
+ ## Desktop
48
+
49
+ - **Readiness** (frame 09/04): Workspace header entry and first-run
50
+ invitation; the quartet from `oats readiness` with items, remedies (display
51
+ only), *View policy*, *Skip for now* (presentation only). Verify signatures
52
+ and Enrol are shown unavailable with the exact reason until their contracts
53
+ land. Effective-readiness section per scope on the Capabilities view.
54
+ - **Normalized route classification**: one classifier decides every specialized
55
+ IPC route from the normalized pathname — dot-segment, percent-encoded,
56
+ backslash, tab and CRLF aliases can no longer skip a route's frame/epoch
57
+ guard.
58
+ - Spawn modal: kernel **preview** (API 2) for the instance name, home, worktree,
59
+ branch and resolved base — *Suggest* asks the kernel for its default
60
+ candidate; preview-only choices block legacy submission rather than being
61
+ dropped. Apply companion, attach-knowledge, auto-PR and branch enumeration are
62
+ named follow-ups, not parity-done.
63
+
64
+ ## Upgrade
65
+
66
+ `npm i -g @awebai/oats@0.24.9`, then `oats doctor`. Desktops on an older CLI
67
+ show the new controls as unavailable until the CLI advertises them.
@@ -107,7 +107,10 @@ onboarding and legacy roster/knowledge cutover remain separate.
107
107
  production store or grants are supplied. An acceptance fixture is parent-owned
108
108
  and cannot be counted as production knowledge adoption.
109
109
  - Current authored expert editions require knowledge **oats.okf@2.1.2** and
110
- messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults. These published revisions
110
+ messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults.
111
+ The official catalog now offers **oats.okf 2.1.3** (per-cause `check` reasons,
112
+ a named remedy for a soul without `okf.json`, retired sources switch their
113
+ job off); editions move to it when their owner re-reviews them. These published revisions
111
114
  are **not proof that their combined bindings/runtime profile is ready**. The provider
112
115
  owner supplies that evidence and any subsequently reviewed compatible revision.
113
116
  Do not replace either requirement with none or erase a read edge to launch.
@@ -71,7 +71,7 @@ name: domain-expert
71
71
  requires:
72
72
  knowledge:
73
73
  capability: oats.okf
74
- source: git:github.com/awebai/oats-okf@v2.1.2#oats-package
74
+ source: git:github.com/awebai/oats-okf@v2.1.3#oats-package
75
75
  ```
76
76
 
77
77
  This illustrates software selection, not complete OKF provisioning: the chosen capability also needs its own valid knowledge declaration, bindings and accepted base.
package/lib/core.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  * work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
27
27
  * (attached as soul default is for service agents — spawn must supply workDir)
28
28
  */
29
- import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
29
+ import { execFileSync, execSync, spawn as spawnProcess, spawnSync } from "node:child_process";
30
30
  import {
31
31
  chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
32
32
  } from "node:fs";
@@ -41,6 +41,7 @@ import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapt
41
41
  import { attachSessionTarget } from "./session-viewer.mjs";
42
42
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
43
43
  import { appendEvent } from "./instance-events.mjs";
44
+ import { killGroup } from "./process-group.mjs";
44
45
  import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
45
46
 
46
47
  import { oatsError } from "./errors.mjs";
@@ -129,6 +130,30 @@ function legacyOperationalSkills(soulDir) {
129
130
  // ---------- shell helpers ----------
130
131
  function sh(cmdline) { return execSync(cmdline, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
131
132
  function shTry(cmdline) { try { return sh(cmdline); } catch { return undefined; } }
133
+ /** A native PROBE (a runtime binary asked about its catalogue or packages) under
134
+ * a preview: bounded by what is left of the shared preflight budget, run in its
135
+ * own process group and group-killed on timeout. Cheap lookups (`command -v`)
136
+ * are not probes and never draw from the budget. Outside a preview: shTry. */
137
+ function probeTry(cmdline) {
138
+ if (!previewPreflightBudget) return shTry(cmdline);
139
+ const left = previewPreflightBudget.deadline - Date.now();
140
+ if (left <= 0) { previewPreflightBudget.exhausted = true; return undefined; }
141
+ const r = spawnSync("/bin/sh", ["-c", cmdline], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false" } });
142
+ if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; return undefined; }
143
+ return r.status === 0 ? String(r.stdout).trim() : undefined;
144
+ }
145
+ let previewPreflightBudget = null;
146
+ /** execFileSync for a native probe: under a preview budget, the timeout is what
147
+ * is left of it and the child is group-killed; otherwise the caller's timeout. */
148
+ function probeExecFile(file, args, options = {}) {
149
+ if (!previewPreflightBudget) return execFileSync(file, args, options);
150
+ const left = previewPreflightBudget.deadline - Date.now();
151
+ if (left <= 0) { previewPreflightBudget.exhausted = true; throw Object.assign(new Error("preflight budget exhausted"), { code: "E_PREFLIGHT_BUDGET" }); }
152
+ const r = spawnSync(file, args, { ...options, timeout: Math.min(left, options.timeout ?? left), killSignal: "SIGKILL", detached: true });
153
+ if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; throw r.error || Object.assign(new Error("probe killed"), { code: "E_PREFLIGHT_BUDGET" }); }
154
+ if (r.status !== 0) throw Object.assign(new Error(`probe exited ${r.status}`), { status: r.status, stdout: r.stdout });
155
+ return r.stdout;
156
+ }
132
157
  function shIn(cwd, cmdline, timeout = 45000) {
133
158
  return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
134
159
  }
@@ -4327,7 +4352,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
4327
4352
  try {
4328
4353
  // The SELECTED executable answers (a wrapper or another binary), with
4329
4354
  // pi's own controlled list subcommand only: never a launch argument.
4330
- const out = execFileSync(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4355
+ const out = probeExecFile(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4331
4356
  const rows = [];
4332
4357
  // pi dims the path with chalk; strip any escapes before matching.
4333
4358
  const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
@@ -4418,7 +4443,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
4418
4443
  list: (env = process.env, opts = {}) => {
4419
4444
  let out;
4420
4445
  try {
4421
- out = execFileSync(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
4446
+ out = probeExecFile(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
4422
4447
  { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60000, env });
4423
4448
  } catch { return []; }
4424
4449
  let rows;
@@ -5422,7 +5447,7 @@ export function resolveModelPreference(model, runtime = "pi") {
5422
5447
  const [provider, ...rest] = bare.split("/");
5423
5448
  const id = rest.join("/");
5424
5449
  if (!id) return pref; // bare pattern (no provider) — let pi resolve it
5425
- const out = shTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
5450
+ const out = probeTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
5426
5451
  const found = out.split("\n").some((line) => {
5427
5452
  const cols = line.trim().split(/\s+/);
5428
5453
  return cols[0] === provider && cols[1] === id;
@@ -5865,7 +5890,14 @@ export function spawnInstance(root, agent, o = {}) {
5865
5890
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
5866
5891
  // Launch selection: a named configuration (explicit, or the soul's
5867
5892
  // launch-config default), or none; the runtime and model follow from it.
5868
- const launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } });
5893
+ // K6b: a preview's native probes (model catalogue, runtime packages) share
5894
+ // ONE budget from here to the return; each probe is group-killed on timeout.
5895
+ const preflightStarted = Date.now(), preflightBudgetMs = o.preview === true ? (Number(process.env.OATS_PREVIEW_PREFLIGHT_BUDGET_MS) || 20000) : undefined;
5896
+ let preflight = { status: "complete", budgetMs: preflightBudgetMs ?? null };
5897
+ if (o.preview === true) previewPreflightBudget = { deadline: preflightStarted + preflightBudgetMs };
5898
+ let launchSelection;
5899
+ try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } }); }
5900
+ catch (e) { previewPreflightBudget = null; throw e; }
5869
5901
  const launchConfig = launchSelection.config;
5870
5902
  const runtime = launchSelection.runtime;
5871
5903
  const model = launchSelection.model;
@@ -5893,6 +5925,25 @@ export function spawnInstance(root, agent, o = {}) {
5893
5925
  if (!instance.startsWith(agent.name)) instance = `${agent.name}-${slug(instance)}`;
5894
5926
  instance = slug(instance);
5895
5927
 
5928
+ // K6e: key recovery FIRST — before any placement, branch, base, preflight or
5929
+ // backend work — so a retry of a spawn that already created its explicit
5930
+ // branch (or whose base moved) still reaches its own receipt.
5931
+ if (o.idempotencyKey !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(String(o.idempotencyKey))) throw oatsError("E_BAD_ARGS", "--idempotency-key must be 1-128 chars of [A-Za-z0-9._:-]");
5932
+ if (o.expectDecision !== undefined && o.idempotencyKey !== undefined) {
5933
+ const instancesDir = join(agent._dir, "instances");
5934
+ const prior = (existsSync(instancesDir) ? readdirSync(instancesDir) : []).filter((n) => !n.startsWith("."))
5935
+ .map((n) => { try { return JSON.parse(readFileSync(join(instancesDir, n, "instance.json"), "utf8")); } catch { return null; } })
5936
+ .find((m) => m && m.spawnIdempotencyKey === o.idempotencyKey);
5937
+ if (prior) {
5938
+ if (prior.decision?.revision !== o.expectDecision) throw Object.assign(oatsError("E_IDEMPOTENCY_CONFLICT", `idempotency key ${o.idempotencyKey} was used for a different decision (${prior.decision?.revision ?? "unrecorded"}); a key binds one confirmed decision`), { instance: prior.instance, home: prior.home });
5939
+ // Completion custody: the home exists from the first metadata write, but
5940
+ // the launch/lineage/events that make it a finished spawn may not have
5941
+ // happened (crash in the interval). Say which, never replay a half-spawn.
5942
+ if (prior.spawnCompleted !== true) throw Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false });
5943
+ return { ...prior, replayed: true, launch: undefined, command: undefined, wake: prior.wake ?? { requested: null, saved: null, error: null } };
5944
+ }
5945
+ }
5946
+
5896
5947
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
5897
5948
  // anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
5898
5949
  // (CLI --parent) is sugar for relation=child. Parsed and resolved BEFORE any
@@ -6100,7 +6151,9 @@ export function spawnInstance(root, agent, o = {}) {
6100
6151
  const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
6101
6152
  const policy = childPolicyOf(parentMeta);
6102
6153
  if (policy.allowed === false) {
6103
- if (parentHome) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
6154
+ // A preview touches nothing — not even the parent's event log; the
6155
+ // typed refusal IS the preview's answer.
6156
+ if (parentHome && o.preview !== true) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
6104
6157
  throw Object.assign(oatsError("E_CHILD_SPAWNS_DISABLED", `${parentInstance} does not allow child spawns (policy origin: ${policy.origin?.kind ?? "recorded"}${policy.origin?.detail ? ` — ${policy.origin.detail}` : ""}); nothing was spawned. Spawn without a parent relation, or respawn the parent with --allow-child-spawns.`),
6105
6158
  { parent: parentInstance, policy });
6106
6159
  }
@@ -6220,8 +6273,11 @@ export function spawnInstance(root, agent, o = {}) {
6220
6273
  // spawn hooks run, which happens after the home exists.
6221
6274
  if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
6222
6275
  const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
6223
- if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
6224
- const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
6276
+ // Backend presence/startup (ensureHerdr) happens AFTER the decision fence
6277
+ // and the exclusive placement reservation below — a stale or losing apply
6278
+ // must not start a daemon. Preview never starts one either.
6279
+ let herdrBase;
6280
+ if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
6225
6281
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
6226
6282
 
6227
6283
  if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
@@ -6242,9 +6298,22 @@ export function spawnInstance(root, agent, o = {}) {
6242
6298
  if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
6243
6299
  plannedBase = { ref: baseRef, oid: baseOid };
6244
6300
  }
6301
+ // The decision a confirmation binds: placement AND what would actually
6302
+ // launch (inherited defaults re-resolved at apply must not drift silently).
6303
+ const buildDecision = () => {
6304
+ const d = { instance, home, branch: plannedBranch, base: plannedBase,
6305
+ effective: { repo: repoAbs, work, runtime, model: model || null, launchConfig: launchConfig?.name ?? null, yolo: yolo ?? null, backend, // backend regardless of --no-launch: the decision is what WOULD launch
6306
+ childSpawns: ownChildPolicy.allowed, relation: relation ? { kind: relation, anchor: { instance: relativeTo ?? null, agentsRoot: anchorHome ? dirname(dirname(dirname(anchorHome))) : null } } : null } };
6307
+ d.revision = createHash("sha256").update(canonicalJson(d)).digest("hex").slice(0, 24);
6308
+ return d;
6309
+ };
6245
6310
  if (o.preview === true) {
6311
+ const decision = buildDecision();
6246
6312
  return {
6247
- spawnPreviewApi: 1, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
6313
+ spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
6314
+ subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
6315
+ decision, preflight,
6316
+ backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
6248
6317
  runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
6249
6318
  branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
6250
6319
  relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
@@ -6252,7 +6321,27 @@ export function spawnInstance(root, agent, o = {}) {
6252
6321
  skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
6253
6322
  };
6254
6323
  }
6255
- mkdirSync(home, { recursive: true });
6324
+ if (o.expectDecision !== undefined) {
6325
+ // A confirmed preview binds THIS apply: same name, home, branch and base
6326
+ // oid, recomputed here under the same placement path. Any drift is a typed
6327
+ // refusal carrying the fresh decision — nothing is created, nothing is
6328
+ // auto-suffixed or silently re-based.
6329
+ const fresh = buildDecision();
6330
+ if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
6331
+ }
6332
+ // Exclusive placement reservation: the parent may be created, the home
6333
+ // itself never with `recursive` — EEXIST means another spawn (a concurrent
6334
+ // apply of the same decision, or anything else) got here first, and this one
6335
+ // has touched nothing.
6336
+ mkdirSync(dirname(home), { recursive: true });
6337
+ try { mkdirSync(home); }
6338
+ catch (e) {
6339
+ if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
6340
+ throw e;
6341
+ }
6342
+ // Backend startup only now — the decision is bound and the placement is ours.
6343
+ if (launch && !which(backend)) { try { rmdirSync(home); } catch { /* keep whatever is there */ } throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`); }
6344
+ herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
6256
6345
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
6257
6346
  // package preflight, both of which shell out — a window in which anything able
6258
6347
  // to write in the agent directory can swap `instances/` for a link elsewhere,
@@ -6672,6 +6761,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6672
6761
  relativeTo: relation ? relativeTo : undefined,
6673
6762
  spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
6674
6763
  policy: { childSpawns: ownChildPolicy },
6764
+ // K6c: a decision-bound spawn records what bound it, so a retry with the
6765
+ // same key replays this receipt instead of spawning again.
6766
+ // The FULL bound decision (placement + effective), exactly as the fence
6767
+ // compared it — a receipt echoes what it was bound to, not a subset.
6768
+ ...(o.expectDecision !== undefined ? { decision: buildDecision() } : {}),
6769
+ ...(o.idempotencyKey !== undefined ? { spawnIdempotencyKey: String(o.idempotencyKey), spawnCompleted: false } : {}),
6675
6770
  capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
6676
6771
  layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
6677
6772
  capabilities: resolvedCfg.capabilities.map((cap) => ({
@@ -6800,7 +6895,13 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6800
6895
 
6801
6896
  appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch } });
6802
6897
  if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
6803
- return { ...meta, launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
6898
+ if (o.idempotencyKey !== undefined) {
6899
+ // Only now is the spawn a finished receipt a same-key retry may replay.
6900
+ meta.spawnCompleted = true;
6901
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6902
+ refreshRetirementBaselineHome(home); // a kernel write, not the agent's
6903
+ }
6904
+ return { ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
6804
6905
  } catch (error) {
6805
6906
  const note = compensateSpawn();
6806
6907
  error.message += note;
@@ -7234,6 +7335,18 @@ function retirementDisposableRoots(work, workMode, capabilities) {
7234
7335
  return roots;
7235
7336
  }
7236
7337
 
7338
+ /** Re-stamp ONLY the home fingerprint of an existing baseline after a KERNEL
7339
+ * write to the home (completion marker, wake record). Retirement compares the
7340
+ * home against this baseline; kernel-owned metadata written after spawn must
7341
+ * not read as the agent's "changed instance-home bytes". Everything else in
7342
+ * the baseline (work fingerprint, mode, capabilities) is untouched. */
7343
+ export function refreshRetirementBaselineHome(home) {
7344
+ const path = retirementBaselinePath(home);
7345
+ let baseline; try { baseline = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
7346
+ baseline.homeFingerprint = fingerprintTree(home, { excludeRoot: new Set(["work"]) });
7347
+ writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
7348
+ return true;
7349
+ }
7237
7350
  function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
7238
7351
  if (mode === "directory") assertDirectoryRoots(home);
7239
7352
  const isWorktree = mode === "worktree";
@@ -0,0 +1,12 @@
1
+ /** Kill a detached child's whole process group (e.g. git + its remote helper /
2
+ * ssh, or a runtime probe + what it forked). ONLY for a child that was actually
3
+ * spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
4
+ * `process.kill(0)` address the CALLER's own process group — the operator's
5
+ * shell, tmux session or Desktop backend. Returns whether anything was signalled. */
6
+ export function killGroup(child, signal = "SIGKILL") {
7
+ const pid = child?.pid;
8
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
9
+ try { process.kill(-pid, signal); } catch { /* already gone */ }
10
+ try { process.kill(pid, signal); } catch { /* already gone */ }
11
+ return true;
12
+ }