@awebai/oats 0.24.7 → 0.24.8

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/lib/core.mjs CHANGED
@@ -40,6 +40,7 @@ import { PI_SDK_HOST, isPiSdkHost, validatePiHostRecipe, piHostArgv, resolvePiSd
40
40
  import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapturedPiRoot, prepareCapturedPiStart } from "./captured-pi-custody.mjs";
41
41
  import { attachSessionTarget } from "./session-viewer.mjs";
42
42
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
43
+ import { appendEvent } from "./instance-events.mjs";
43
44
  import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
44
45
 
45
46
  import { oatsError } from "./errors.mjs";
@@ -5565,6 +5566,8 @@ export function missingLaunchEnvRefs(configEnv, env = process.env) {
5565
5566
  * configuration behind (its executable and args are not carried), and a
5566
5567
  * model never crosses runtimes: explicit or configured model, else the
5567
5568
  * frozen model on the same runtime, else the runtime's native default. */
5569
+ /** Sentinel a caller passes as `model` to mean "the runtime's own default, not the configured/soul preference". */
5570
+ export const NATIVE_DEFAULT_MODEL = "@native-default";
5568
5571
  export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
5569
5572
  const bad = (code, msg) => { throw oatsError(code, msg); };
5570
5573
  let wanted = selection.launchConfig;
@@ -5588,8 +5591,13 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
5588
5591
  const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
5589
5592
  if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5590
5593
  let model, modelSource;
5591
- const explicit = selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
5592
- if (explicit) {
5594
+ // K6: an EXPLICIT "use the runtime's native default" is distinct from an
5595
+ // omitted model (which inherits the configuration's or soul's preference).
5596
+ const nativeDefault = selection.model === NATIVE_DEFAULT_MODEL || (selection.model && typeof selection.model === "object" && selection.model.kind === "native-default");
5597
+ const explicit = !nativeDefault && selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
5598
+ if (nativeDefault) {
5599
+ model = ""; modelSource = "native default (explicit)";
5600
+ } else if (explicit) {
5593
5601
  model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
5594
5602
  if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
5595
5603
  } else if (config?.model) {
@@ -6079,6 +6087,32 @@ export function spawnInstance(root, agent, o = {}) {
6079
6087
  if (anchorMeta?.siblingInstance) siblingInstance = anchorMeta.siblingInstance;
6080
6088
  }
6081
6089
  if (!relation && attachedOwner && attachedOwner !== instance) parentInstance = attachedOwner;
6090
+ // K5 §4 — child-spawn permission is ENFORCED here, by the spawn route. The
6091
+ // parent's recorded policy (instance.json policy.childSpawns) says whether
6092
+ // it may have children; off refuses, attributed to that policy and its
6093
+ // origin. Absent policy = allowed (today's behaviour), reported as such.
6094
+ // A lifecycle-authority claim, not an OS sandbox.
6095
+ const childPolicyOf = (metaOf) => metaOf?.policy?.childSpawns && typeof metaOf.policy.childSpawns === "object"
6096
+ ? metaOf.policy.childSpawns : { allowed: true, origin: { kind: "default", detail: "no recorded policy: children allowed" } };
6097
+ if (parentInstance && parentInstance !== instance) {
6098
+ const parentHome = parentInstance === relativeTo && anchorHome ? anchorHome
6099
+ : (findInstanceHome(root, parentInstance) || findTeamInstance(root, parentInstance))?.home;
6100
+ const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
6101
+ const policy = childPolicyOf(parentMeta);
6102
+ if (policy.allowed === false) {
6103
+ if (parentHome) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
6104
+ 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
+ { parent: parentInstance, policy });
6106
+ }
6107
+ }
6108
+ // This instance's own policy: soul declaration (children.spawn), overridden
6109
+ // per spawn; recorded so the route above and the readiness view can read it.
6110
+ const declaredChildren = agent.children && typeof agent.children === "object" ? agent.children : (typeof agent.children === "string" ? (() => { try { return JSON.parse(agent.children); } catch { return undefined; } })() : undefined);
6111
+ const ownChildPolicy = o.allowChildSpawns !== undefined
6112
+ ? { allowed: o.allowChildSpawns === true, origin: { kind: "spawn-option", detail: o.allowChildSpawns ? "--allow-child-spawns" : "--no-child-spawns" } }
6113
+ : declaredChildren && typeof declaredChildren.spawn === "boolean"
6114
+ ? { allowed: declaredChildren.spawn, origin: { kind: "soul", detail: `children.spawn: ${declaredChildren.spawn} in soul.yaml` } }
6115
+ : { allowed: true, origin: { kind: "default", detail: "no declaration: children allowed" } };
6082
6116
 
6083
6117
  // Inherited edges must round-trip too. Sibling and parent relations copy
6084
6118
  // names from the ANCHOR's instance.json (anchorMeta.parentInstance /
@@ -6191,6 +6225,33 @@ export function spawnInstance(root, agent, o = {}) {
6191
6225
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
6192
6226
 
6193
6227
  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`);
6228
+ // K6: everything a spawn decides is decided by here — and nothing has been
6229
+ // touched. Branch/base for a worktree are named now (not after mkdir) so the
6230
+ // preview and the apply agree on them; `o.baseRef` selects the start point.
6231
+ let plannedBranch = null, plannedBase = null;
6232
+ if (work === "worktree") {
6233
+ plannedBranch = o.branch || `agents/${instance}`;
6234
+ // Validity is Git's own rule (check-ref-format), not a stricter charset:
6235
+ // every later git call takes the name as an argv element, never through a
6236
+ // shell, so a valid-but-hostile name is safe and stays exercisable.
6237
+ try { execFileSync("git", ["check-ref-format", "--branch", plannedBranch], { stdio: ["ignore", "pipe", "pipe"] }); }
6238
+ catch { throw oatsError("E_BAD_ARGS", `branch ${JSON.stringify(plannedBranch)} is not a valid branch name`); }
6239
+ if (shInTry(repoAbs, `git rev-parse --verify --quiet ${shq("refs/heads/" + plannedBranch)}`) !== undefined) throw oatsError("E_BRANCH_EXISTS", `branch ${plannedBranch} already exists in ${repoAbs}; choose another name or reuse it deliberately`);
6240
+ const baseRef = o.baseRef || "HEAD";
6241
+ const baseOid = shInTry(repoAbs, `git rev-parse --verify --quiet ${shq(baseRef + "^{commit}")}`);
6242
+ if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
6243
+ plannedBase = { ref: baseRef, oid: baseOid };
6244
+ }
6245
+ if (o.preview === true) {
6246
+ return {
6247
+ spawnPreviewApi: 1, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
6248
+ runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
6249
+ branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
6250
+ relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
6251
+ policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: resolvedCfg.capabilities.map((c) => c.id),
6252
+ skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
6253
+ };
6254
+ }
6194
6255
  mkdirSync(home, { recursive: true });
6195
6256
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
6196
6257
  // package preflight, both of which shell out — a window in which anything able
@@ -6291,11 +6352,11 @@ export function spawnInstance(root, agent, o = {}) {
6291
6352
  let branch;
6292
6353
  let worktreeCanonical; // captured immediately after add, before setup/hooks can mutate/remove it
6293
6354
  if (work === "worktree") {
6294
- branch = o.branch || `agents/${instance}`;
6355
+ branch = plannedBranch;
6295
6356
  const wt = join(home, "work");
6296
6357
  let added = false;
6297
6358
  try {
6298
- execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch],
6359
+ execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch, plannedBase.oid],
6299
6360
  { stdio: ["ignore", "pipe", "pipe"] });
6300
6361
  added = true;
6301
6362
  // Git registers a canonical path. Retain it now: compensation hooks can
@@ -6610,6 +6671,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6610
6671
  relation: relation || undefined,
6611
6672
  relativeTo: relation ? relativeTo : undefined,
6612
6673
  spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
6674
+ policy: { childSpawns: ownChildPolicy },
6613
6675
  capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
6614
6676
  layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
6615
6677
  capabilities: resolvedCfg.capabilities.map((cap) => ({
@@ -6736,6 +6798,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6736
6798
  }
6737
6799
  }
6738
6800
 
6801
+ 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
+ if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
6739
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 };
6740
6804
  } catch (error) {
6741
6805
  const note = compensateSpawn();
@@ -7087,6 +7151,12 @@ function retirementBaselinePath(home) {
7087
7151
  * loudly, and streaming is deliberately not attempted in this change. */
7088
7152
  const GIT_MAX_BUFFER = 512 * 1024 * 1024;
7089
7153
 
7154
+ /** Kernel-owned receipts the kernel itself appends to a home after the spawn
7155
+ * baseline (events log, stop/restart receipts). They are evidence about the
7156
+ * instance, not the instance's work, so a retirement fingerprint ignores them —
7157
+ * otherwise every stop or event write would read as "changed home bytes". */
7158
+ const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
7159
+ const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
7090
7160
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
7091
7161
  const hash = createHash("sha256");
7092
7162
  const rootStat = lstatSync(root);
@@ -7099,7 +7169,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
7099
7169
  }
7100
7170
  const walk = (dir, rel = "") => {
7101
7171
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
7102
- if ((!rel && excludeRoot.has(e.name)) || (excludeGitMetadata && e.name === ".git")) continue;
7172
+ if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
7103
7173
  const childRel = rel ? join(rel, e.name) : e.name;
7104
7174
  const path = join(dir, e.name);
7105
7175
  const st = lstatSync(path);
@@ -7275,6 +7345,73 @@ export function inspectInstanceSession(home) {
7275
7345
  catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
7276
7346
  }
7277
7347
 
7348
+ /** K3: quiesce one instance's session and RETAIN everything else — home,
7349
+ * worktree, transcript, launch configuration — so `restart` can bring it back.
7350
+ * Exactly restart's stop half under the same independent endpoint authority
7351
+ * (`instanceSessionTarget`), bounded and never escalated: a harness still
7352
+ * there after the grace is reported still running, nothing is killed harder.
7353
+ * A no-launch or already-idle instance is a no-op that says so. */
7354
+ function tmuxServerLost(e) { return /no server running on |(?:error connecting to|failed to connect to) .*(?:No such file or directory|Connection refused)/i.test(String(e.stderr ?? e.message ?? "")); }
7355
+ export function stopInstanceSession(home, o = {}) {
7356
+ if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session stop needs an absolute instance home");
7357
+ if (o.graceMs !== undefined && (!Number.isSafeInteger(o.graceMs) || o.graceMs < 1 || o.graceMs > 300000)) throw oatsError("E_BAD_ARGS", "stop grace must be 1-300000 ms");
7358
+ const realHome = realPathOrNearest(home);
7359
+ if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing was stopped`);
7360
+ const s = instanceSessionTarget(realHome);
7361
+ if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
7362
+ let before;
7363
+ try { before = inspectSessionTarget(s.target, s.io); }
7364
+ catch (e) { if (s.target.backend === "tmux" && tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
7365
+ if (!before.present || before.state === "shell" || before.state === "stopped") return { home: s.home, backend: s.target.backend, stopped: false, alreadyIdle: true, state: before.state, receipt: null };
7366
+ const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, io: s.io, kill: o.io?.kill, sleep: o.io?.sleep });
7367
+ writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
7368
+ appendEvent(realHome, { kind: receipt.exited ? "stopped" : "stop-refused", data: { signal: receipt.signal, waitedMs: receipt.waitedMs, stillRunning: receipt.stillRunning ?? [], state: receipt.state } });
7369
+ if (!receipt.exited) throw Object.assign(oatsError("E_SESSION_STOP_FAILED", `${basename(realHome)} was asked to stop (${receipt.signal} to ${receipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")}) and is still running after ${receipt.waitedMs} ms; nothing was escalated`), { receipt });
7370
+ return { home: s.home, backend: s.target.backend, stopped: true, alreadyIdle: false, state: receipt.state, receipt };
7371
+ }
7372
+
7373
+ /** Recompose a running home's instructions from its CURRENT canonical soul and
7374
+ * context — an operator-authorized refresh, so a role change can reach a live
7375
+ * instance without a respawn. Composition only: the same composer spawn
7376
+ * used, the same soul the home links to, the home's recorded context/work
7377
+ * mode; the previous AGENTS.md is retained as evidence; the change is
7378
+ * recorded in instance.json (instructions sources) and as a typed event.
7379
+ * Nothing is launched, restarted or signalled — the running process picks the
7380
+ * new text up on its next read (harness-dependent; the receipt says so). */
7381
+ export function recomposeInstanceInstructions(home, { dryRun = false } = {}) {
7382
+ if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "recompose needs an absolute instance home");
7383
+ const realHome = realPathOrNearest(home);
7384
+ const metaFile = join(realHome, "instance.json");
7385
+ if (!existsSync(metaFile)) throw oatsError("E_SESSION_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json)`);
7386
+ if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing recomposed`);
7387
+ const meta = JSON.parse(readFileSync(metaFile, "utf8"));
7388
+ if (meta.executionBinding || meta.captured) throw oatsError("E_UNSUPPORTED_MODE", "captured incarnations are recomposed by preparing a new resolution, not in place");
7389
+ const soulLink = join(realHome, "soul");
7390
+ let soulDir; try { soulDir = realpathSync(soulLink); } catch { throw oatsError("E_SOUL_UNKNOWN", `${realHome} has no readable soul link`); }
7391
+ const agentDir = dirname(dirname(realHome));
7392
+ const agent = findAgent(dirname(agentDir), basename(agentDir)) || { name: meta.agent, kind: meta.kind, _dir: agentDir };
7393
+ if (agent.kind === "capability") throw oatsError("E_UNSUPPORTED_MODE", "capability-defined souls are recomposed by updating their package");
7394
+ const contextDir = meta.repo && existsSync(meta.repo) ? meta.repo : dirname(dirname(agentDir));
7395
+ const composition = composeInstanceAgentsMd(soulDir, contextDir, meta.agent, meta.work || "checkout", meta.kind || "persistent");
7396
+ const target = join(realHome, "AGENTS.md");
7397
+ const before = existsSync(target) ? readFileSync(target, "utf8") : null;
7398
+ const changed = before !== composition.text;
7399
+ const result = { home: realHome, instance: meta.instance, agent: meta.agent, soulDir, contextDir, changed, dryRun,
7400
+ blocks: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
7401
+ previous: null, note: "the running harness re-reads its instructions on its own schedule (typically the next turn or restart); this refresh does not signal or restart anything" };
7402
+ if (dryRun || !changed) return result;
7403
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
7404
+ const previous = join(realHome, `.oats-agents-md.${stamp}.previous`);
7405
+ if (before !== null) writeFileSync(previous, before);
7406
+ writeFileSync(target, composition.text);
7407
+ meta.instructions = composition.blocks.map((b) => ({ source: b.source, file: b.file }));
7408
+ meta.recomposedAt = new Date().toISOString();
7409
+ writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
7410
+ appendEvent(realHome, { kind: "recomposed", data: { previous, blocks: result.blocks.length, soulDir } });
7411
+ result.previous = previous;
7412
+ return result;
7413
+ }
7414
+
7278
7415
  export async function attachInstanceSession(home) {
7279
7416
  const s = instanceSessionTarget(home);
7280
7417
  if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
@@ -7461,8 +7598,12 @@ export function harnessProcesses(target, io) {
7461
7598
  * running. Elapsed time is never taken as exit. */
7462
7599
  export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io = {}, kill = process.kill, sleep } = {}) {
7463
7600
  const wait = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
7464
- const before = inspectSessionTarget(target, io);
7465
- if (!before.present || before.state === "shell" || before.state === "stopped") return { requested: [], signal, sentAt: null, observedAt: new Date().toISOString(), exited: true, waitedMs: 0, state: before.state, note: "nothing was running" };
7601
+ // "Already idle" must be a STABLE reading: the shell-vs-harness decision walks
7602
+ // `ps`, which under load can miss a just-forked descendant for one sample.
7603
+ // One momentary "shell" must never be taken as exit with nothing signalled.
7604
+ const idle = (s) => !s.present || s.state === "shell" || s.state === "stopped";
7605
+ let before = inspectSessionTarget(target, io);
7606
+ if (idle(before)) { wait(250); const again = inspectSessionTarget(target, io); if (idle(again)) return { requested: [], signal, sentAt: null, observedAt: new Date().toISOString(), exited: true, waitedMs: 0, state: again.state, stillRunning: [] }; before = again; }
7466
7607
  const procs = harnessProcesses(target, io);
7467
7608
  if (!procs.length) throw oatsError("E_SESSION_UNKNOWN", `the session reads as ${before.state} but no harness process was found under its pane; nothing was signalled`);
7468
7609
  const requested = [];
@@ -7473,12 +7614,14 @@ export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io =
7473
7614
  }
7474
7615
  const started = Date.now();
7475
7616
  const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
7476
- let st = before;
7617
+ let st = before, idleStreak = 0;
7477
7618
  while (Date.now() - started <= graceMs) {
7478
7619
  wait(250);
7479
7620
  try { st = inspectSessionTarget(target, io); } catch { st = { present: true, state: "unknown" }; }
7480
7621
  const gone = requested.every((r) => !alive(r.pid));
7481
- if (gone && (!st.present || st.state === "shell" || st.state === "stopped")) return { requested, signal, sentAt, observedAt: new Date().toISOString(), exited: true, waitedMs: Date.now() - started, state: st.state };
7622
+ idleStreak = gone && idle(st) ? idleStreak + 1 : 0;
7623
+ // Exit needs two consecutive agreeing samples, not one.
7624
+ if (idleStreak >= 2) return { requested, signal, sentAt, observedAt: new Date().toISOString(), exited: true, waitedMs: Date.now() - started, state: st.state };
7482
7625
  }
7483
7626
  return { requested, signal, sentAt, observedAt: null, exited: false, waitedMs: Date.now() - started, state: st.state, stillRunning: requested.filter((r) => alive(r.pid)).map((r) => r.pid) };
7484
7627
  }
@@ -7913,7 +8056,7 @@ export function startInstanceSession(home, o = {}) {
7913
8056
  const pendingPath = join(realHome, ".oats-start-pending.json");
7914
8057
  const exitedPath = join(realHome, ".oats-start-exited");
7915
8058
  const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
7916
- const lostTmuxServer = (e) => /no server running on |(?:error connecting to|failed to connect to) .*(?:No such file or directory|Connection refused)/i.test(String(e.stderr ?? e.message ?? ""));
8059
+ const lostTmuxServer = tmuxServerLost;
7917
8060
  const launchFailure = (backend, error) => {
7918
8061
  // execFileSync errors embed argv (including capability environment) in
7919
8062
  // message; backend stderr can echo it too. Neither belongs in the API.
@@ -8074,6 +8217,7 @@ export function startInstanceSession(home, o = {}) {
8074
8217
  // as running; nothing is escalated and nothing is launched.
8075
8218
  stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
8076
8219
  writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { runtime: launchPlan?.runtime || runtime, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
8220
+ appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
8077
8221
  if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
8078
8222
  try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
8079
8223
  if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
@@ -8783,11 +8927,58 @@ export function retireInstance(root, name, o = {}) {
8783
8927
  } catch { /* no team scope resolvable — local root only */ }
8784
8928
  for (const r2 of roots) scanRoot(r2);
8785
8929
 
8930
+ // K3b — retention is the default (lifecycle decision §1): the worktree,
8931
+ // branch and any PR outlive the home. The worktree cannot stay under
8932
+ // <home>/work once the home is removed, so it is RE-HOMED with
8933
+ // `git worktree move` to the deployment-level worktrees root and the move is
8934
+ // recorded in the receipt. `discardWorktree` restores removal; branch
8935
+ // deletion uses the VERIFIED current ref of the worktree, never the
8936
+ // spawn-time recorded name. A failed move keeps the home (fail closed).
8937
+ let retention = null;
8786
8938
  if (isWorktree && meta.repo) {
8787
- shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
8788
- shTry(`git -C ${shq(meta.repo)} worktree prune`);
8789
- if (o.deleteBranch && meta.branch) shTry(`git -C ${shq(meta.repo)} branch -D ${shq(meta.branch)}`);
8939
+ const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
8940
+ const verifiedBranch = ref.branch;
8941
+ // A branch cannot be deleted while a worktree has it checked out, so
8942
+ // --delete-branch implies discarding the worktree (which is what every
8943
+ // caller of it meant: clean up everything). Plain retire retains.
8944
+ if (o.discardWorktree || o.deleteBranch || quarantine) {
8945
+ shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
8946
+ shTry(`git -C ${shq(meta.repo)} worktree prune`);
8947
+ retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
8948
+ } else if (existsSync(workPath)) {
8949
+ const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
8950
+ const leaf = (verifiedBranch ?? `detached-${(ref.oid || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
8951
+ const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
8952
+ mkdirSync(retainedRoot, { recursive: true });
8953
+ let dest = join(retainedRoot, leaf);
8954
+ for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
8955
+ try {
8956
+ execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
8957
+ } catch (e) {
8958
+ throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the worktree at ${workPath} could not be re-homed to ${dest} (${String(e.stderr ?? e.message ?? "").trim()}); the home is kept so nothing is lost — resolve and retry, or pass --discard-worktree to remove the worktree instead`);
8959
+ }
8960
+ retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.oid : null, recordedBranch: meta.branch ?? null };
8961
+ } else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
8962
+ if (o.deleteBranch && verifiedBranch) {
8963
+ // A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
8964
+ // the tree during retirement, so the branch is re-verified here, at the
8965
+ // moment of deletion; a mismatch deletes nothing and says so.
8966
+ if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
8967
+ retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
8968
+ } else {
8969
+ shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
8970
+ retention.branchDeleted = verifiedBranch;
8971
+ }
8972
+ } else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
8973
+ retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
8974
+ }
8975
+ }
8976
+ if (retention) {
8977
+ if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
8978
+ else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
8979
+ if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
8790
8980
  }
8981
+ appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null } }, { workspaceOnly: true });
8791
8982
  // Retrying a quarantine only clears it if compensation ACTUALLY completed.
8792
8983
  // Otherwise the home — and the credentials in it — must survive again, or the
8793
8984
  // retry becomes the deletion the quarantine was preventing.
@@ -8893,7 +9084,7 @@ export function retireInstance(root, name, o = {}) {
8893
9084
  }
8894
9085
 
8895
9086
 
8896
- const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
9087
+ const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
8897
9088
  if (self) {
8898
9089
  // The caller is the instance: its process lives in the window we are about to
8899
9090
  // kill. Detach the kill so this function can return and the caller can report
@@ -0,0 +1,76 @@
1
+ /** K7 — typed lifecycle events per instance, append-only, producer-attributed.
2
+ *
3
+ * Every event is a fact a kernel action just made true (spawned, launched,
4
+ * stopped, restarted, retired…), written by the code that did it, with the
5
+ * receipt it produced. Nothing here is inferred from transcripts, task files or
6
+ * prose; "waiting on you" is reported only when a producer says so — today no
7
+ * producer does, and the view says `null`, not a guess. Retirement's event
8
+ * outlives the home in the workspace-level log. */
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+
12
+ export const EVENTS_API = 1;
13
+ const HOME_LOG = ".oats-events.jsonl";
14
+ const MAX_BYTES = 4 * 1024 * 1024;
15
+
16
+ export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "recomposed"];
17
+
18
+ function workspaceLogPath(home) {
19
+ // <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
20
+ // state beside installed capabilities and schedules; survives the home's removal.
21
+ const agentDir = dirname(dirname(home)); // <root>/<agent>/instances/<instance>
22
+ const workspace = dirname(dirname(agentDir)); // <workspace>/agents/<agent>
23
+ return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
24
+ }
25
+
26
+ /** Append one typed event. Never throws into the caller's action: an event is
27
+ * evidence, not authority — a failed write is reported in the return value. */
28
+ export function appendEvent(home, event, { workspaceOnly = false } = {}) {
29
+ if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
30
+ const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
31
+ const line = JSON.stringify(row) + "\n";
32
+ const results = [];
33
+ // Retirement fingerprints the home against its baseline and preserves any
34
+ // changed bytes as "unknown work": events written DURING retirement go only
35
+ // to the workspace log, never into the home being inspected.
36
+ for (const path of [...(workspaceOnly ? [] : [join(home, HOME_LOG)]), workspaceLogPath(home)]) {
37
+ try {
38
+ if (path.startsWith(home) && !existsSync(home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ appendFileSync(path, line);
41
+ results.push({ path, ok: true });
42
+ } catch (e) { results.push({ path, ok: false, reason: e.message }); }
43
+ }
44
+ return { ok: results.some((r) => r.ok), row, results };
45
+ }
46
+
47
+ function readLog(path) {
48
+ if (!existsSync(path)) return { rows: [], truncated: false, bytes: 0 };
49
+ const size = statSync(path).size;
50
+ const text = readFileSync(path, "utf8");
51
+ const rows = [];
52
+ for (const line of text.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r && r.eventsApi === EVENTS_API && typeof r.kind === "string") rows.push(r); } catch { /* a torn line is skipped, counted */ rows.push({ eventsApi: EVENTS_API, kind: "unreadable", at: null, producer: "kernel", data: { reason: "torn or invalid line" } }); } }
53
+ return { rows, truncated: size > MAX_BYTES, bytes: size };
54
+ }
55
+
56
+ /** Events for one instance, newest last, from both logs (deduplicated by
57
+ * at+kind), with a bounded window. `waitingOnYou` is a producer claim or null. */
58
+ export function readEvents(home, { limit = 200, since = null } = {}) {
59
+ const a = readLog(join(home, HOME_LOG)), b = readLog(workspaceLogPath(home));
60
+ const seen = new Set(); const rows = [];
61
+ for (const r of [...a.rows, ...b.rows]) { const k = `${r.at}|${r.kind}|${JSON.stringify(r.data ?? null)}`; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
62
+ rows.sort((x, y) => String(x.at ?? "").localeCompare(String(y.at ?? "")));
63
+ const filtered = since ? rows.filter((r) => r.at && r.at > since) : rows;
64
+ const window = filtered.slice(-limit);
65
+ const last = window.at(-1) ?? null;
66
+ const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
67
+ return {
68
+ eventsApi: EVENTS_API, instance: basename(home), home, count: filtered.length, returned: window.length, truncated: filtered.length > window.length || a.truncated || b.truncated,
69
+ events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer } : null,
70
+ waitingOnYou: waiting ? { since: waiting.at, producer: waiting.producer, reason: waiting.data.reason ?? null } : null,
71
+ notes: [
72
+ "events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
73
+ "waitingOnYou is null unless a producer reported it — null means unknown, not 'not waiting'",
74
+ ],
75
+ };
76
+ }
@@ -118,6 +118,26 @@ function blobOf(work, path) {
118
118
  // Content fingerprint of the working-tree file without writing an object.
119
119
  return trim(git(work, ["hash-object", "--no-filters", "--", path], { allowFail: true }));
120
120
  }
121
+ /** The branch's upstream remote (else `origin`, else null), with host/path parsed
122
+ * from ssh/https/git forms so a consumer can pick a forge backend WITHOUT running
123
+ * Git itself. Parsing only: no network, no forge knowledge. */
124
+ export function parseRemoteUrl(url) {
125
+ if (typeof url !== "string" || !url) return { host: null, path: null };
126
+ let m = /^(?:ssh:\/\/)?(?:[^@\/]+@)?([^:\/]+)(?::\d+)?[:\/](.+?)(?:\.git)?\/?$/.exec(url);
127
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
128
+ try { const u = new URL(url); m = [null, u.hostname, u.pathname.replace(/^\/+/, "").replace(/\.git$/, "").replace(/\/+$/, "")]; } catch { m = null; }
129
+ }
130
+ if (!m || !m[1] || !m[2]) return { host: null, path: null };
131
+ return { host: m[1].toLowerCase(), path: m[2] };
132
+ }
133
+ function remoteOf(work, branch) {
134
+ const configured = branch ? trim(git(work, ["config", "--get", `branch.${branch}.remote`], { allowFail: true })) : null;
135
+ const name = configured && configured !== "." ? configured : (trim(git(work, ["remote"], { allowFail: true })) ?? "").split("\n").includes("origin") ? "origin" : null;
136
+ if (!name) return null;
137
+ const url = trim(git(work, ["remote", "get-url", name], { allowFail: true }));
138
+ if (!url) return null;
139
+ return { name, url, ...parseRemoteUrl(url), source: configured && configured !== "." ? "branch-upstream" : "origin" };
140
+ }
121
141
  function fileId(revision, indexOid, entry) {
122
142
  return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
123
143
  }
@@ -153,6 +173,7 @@ export function observeInstanceGit(home) {
153
173
  observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch.head, detached: branch.head === null && headOid !== null, unborn: headOid === null },
154
174
  recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch.head !== meta.branch },
155
175
  upstream, base: baseComparison,
176
+ remote: remoteOf(work, branch.head),
156
177
  summary, files,
157
178
  notes: [
158
179
  ...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),