@awebai/oats 0.39.4 → 0.40.2

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
@@ -32,6 +32,7 @@ import {
32
32
  chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
33
33
  } from "node:fs";
34
34
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
35
+ import { homedir } from "node:os";
35
36
  import { accessSync, constants as fsConstants } from "node:fs";
36
37
  import { recordLocalInput } from "./local-inputs.mjs";
37
38
  import { createHash, randomUUID } from "node:crypto";
@@ -40,7 +41,7 @@ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/
40
41
  import { noteRuntimeName } from "./deprecation.mjs";
41
42
  import { attachSessionTarget } from "./session-viewer.mjs";
42
43
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
43
- import { appendEvent } from "./instance-events.mjs";
44
+ import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
44
45
  import { killGroup } from "./process-group.mjs";
45
46
 
46
47
  import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
@@ -204,7 +205,7 @@ function shIn(cwd, cmdline, timeout = 45000) {
204
205
  return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
205
206
  }
206
207
  function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
207
- function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
208
+ export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
208
209
  export function slug(s) {
209
210
  const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
210
211
  return r || "agent";
@@ -2008,7 +2009,7 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
2008
2009
  /** Verify the harness packages that ACTIVE capabilities require for `harness`.
2009
2010
  *
2010
2011
  * Each comes from a declared harness-package requirement, so the capability has
2011
- * stated the dependency and the user has consented to install it (`oats install`).
2012
+ * stated the dependency. Installing it remains the operator's responsibility.
2012
2013
  * We verify PRESENCE and record provenance; we deliberately do NOT resolve the
2013
2014
  * extension's entry file. pi owns that resolution — its manifest supports globs
2014
2015
  * and exclusions, packages without a `pi` manifest use conventional directories,
@@ -2050,8 +2051,16 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
2050
2051
  const status = harnessPackageStatus(harness, spec, probeEnv, probeOpts);
2051
2052
  const mgr = HARNESS_PACKAGE_MANAGERS[harness];
2052
2053
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
2053
- const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
2054
- const remedy = `run \`oats install --accept-requirement ${harness}:${harnessPackageIdentity(harness, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
2054
+ // Keep the remedy in the same resource directory and selected wrapper as
2055
+ // the probe; quote argv, never turn a path or package selector into shell code.
2056
+ // Pi expands ~ against the launch HOME; relative selectors are anchored
2057
+ // to the probe cwd before the remedy changes to contextDir.
2058
+ const piResourceDir = harness === "pi" ? piAgentDir(probeEnv).replace(/^~(?=$|\/)/, () => probeEnv.HOME || homedir()) : undefined;
2059
+ const resourceEnv = harness === "pi" ? `PI_CODING_AGENT_DIR=${shq(resolve(piResourceDir))} `
2060
+ : harness === "claude" && probeEnv.CLAUDE_CONFIG_DIR ? `CLAUDE_CONFIG_DIR=${shq(resolve(probeEnv.CLAUDE_CONFIG_DIR))} ` : "";
2061
+ const direct = stepList.filter((a) => a.length).map((a) => resourceEnv + [bin || a[0], ...a.slice(1)].map(shq).join(" ")).join(" && ");
2062
+ const remedy = direct ? `ask the operator to run \`cd ${shq(contextDir)} && ${direct}\` with the selected harness's environment; OATS does not install packages during spawn or restart`
2063
+ : `ask the operator to install it with the selected harness's package manager`;
2055
2064
  // `ifInstalled: true`: the row constrains a package that may be absent
2056
2065
  // (an ambient extension must honour a contract IF it is there); absence
2057
2066
  // satisfies it. Without the flag, absence fails as before.
@@ -2659,6 +2668,12 @@ export async function spawnInstanceAsync(root, agent, o = {}) {
2659
2668
  }
2660
2669
  return step.value;
2661
2670
  }
2671
+ /** The producer sets this only when dispatched effects or their compensation
2672
+ * cannot be confirmed. A diagnostic's wording or home path is not evidence. */
2673
+ function unconfirmedSpawn(error) {
2674
+ error.details = { ...error.details, unconfirmed: true };
2675
+ return error;
2676
+ }
2662
2677
  function* spawnBody(root, agent, o = {}) {
2663
2678
  if (!o.prepared) throw localMissingForSpawn(agent);
2664
2679
  const deliver = (r) => r;
@@ -2747,7 +2762,7 @@ function* spawnBody(root, agent, o = {}) {
2747
2762
  // Completion custody: the home exists from the first metadata write, but
2748
2763
  // the launch/lineage/events that make it a finished spawn may not have
2749
2764
  // happened (crash in the interval). Say which, never replay a half-spawn.
2750
- 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 });
2765
+ if (prior.spawnCompleted !== true) throw unconfirmedSpawn(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 }));
2751
2766
  return { ...prior, replayed: true, launch: undefined, command: undefined, wake: prior.wake ?? { requested: null, saved: null, error: null } };
2752
2767
  }
2753
2768
  }
@@ -3209,7 +3224,7 @@ function* spawnBody(root, agent, o = {}) {
3209
3224
  // name taken (M1). Nothing of a classic home exists at this point either.
3210
3225
  const rollbackEmptyOrPreparedHome = (e) => {
3211
3226
  try { rmSync(home, { recursive: true, force: true }); }
3212
- catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; }
3227
+ catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; unconfirmedSpawn(e); }
3213
3228
  return e;
3214
3229
  };
3215
3230
  // Workspace model: copy every resolved capability WHOLE into the new home
@@ -3377,9 +3392,10 @@ function* spawnBody(root, agent, o = {}) {
3377
3392
  if (incomplete.length) {
3378
3393
  // Nothing outside the home exists yet (no worktree, no hooks, no window), so
3379
3394
  // removing the scaffold is the whole rollback.
3380
- let removal = "";
3381
- try { rmSync(home, { recursive: true, force: true }); } catch (e) { removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
3382
- throw oatsError("E_COMPOSITION_INCOMPLETE", `the instance composition did not materialize completely:\n${incomplete.map((m) => ` ${m}`).join("\n")}${removal}`);
3395
+ let removal = "", removalFailed = false;
3396
+ try { rmSync(home, { recursive: true, force: true }); } catch (e) { removalFailed = true; removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
3397
+ const error = oatsError("E_COMPOSITION_INCOMPLETE", `the instance composition did not materialize completely:\n${incomplete.map((m) => ` ${m}`).join("\n")}${removal}`);
3398
+ throw removalFailed ? unconfirmedSpawn(error) : error;
3383
3399
  }
3384
3400
 
3385
3401
  // Work tree.
@@ -3424,7 +3440,8 @@ function* spawnBody(root, agent, o = {}) {
3424
3440
  }
3425
3441
  try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
3426
3442
  const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
3427
- throw new Error(`git worktree add/canonicalization failed: ${original}${note}`);
3443
+ const error = new Error(`git worktree add/canonicalization failed: ${original}${note}`);
3444
+ throw incomplete.length ? unconfirmedSpawn(error) : error;
3428
3445
  }
3429
3446
  } else if (work === "directory") {
3430
3447
  // An owned execution directory, not a link to the source or a fake Git repo.
@@ -3520,11 +3537,11 @@ function* spawnBody(root, agent, o = {}) {
3520
3537
  outstandingGit.add("worktree");
3521
3538
  if (branch) outstandingGit.add("branch");
3522
3539
  }
3523
- return quarantineInstanceHome({
3540
+ return { unconfirmed: true, note: quarantineInstanceHome({
3524
3541
  home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
3525
3542
  repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
3526
3543
  launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
3527
- });
3544
+ }) };
3528
3545
  }
3529
3546
  }
3530
3547
  // Once hooks ran, directory execution may already hold authored results.
@@ -3546,7 +3563,7 @@ function* spawnBody(root, agent, o = {}) {
3546
3563
  launched: false, directoryPreservation: true, directoryHome: homeReal,
3547
3564
  recordRetirementBaseline: true,
3548
3565
  });
3549
- return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}`;
3566
+ return { unconfirmed: true, note: `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}` };
3550
3567
  };
3551
3568
  const preserveDirectory = () => {
3552
3569
  if (work !== "directory") return;
@@ -3630,7 +3647,7 @@ function* spawnBody(root, agent, o = {}) {
3630
3647
  if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
3631
3648
  note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
3632
3649
  }
3633
- return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}`;
3650
+ return { unconfirmed: incomplete.length > 0, note: `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}` };
3634
3651
  };
3635
3652
 
3636
3653
  try {
@@ -3849,8 +3866,15 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3849
3866
  }
3850
3867
  return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
3851
3868
  } catch (error) {
3852
- const note = compensateSpawn();
3853
- error.message += note;
3869
+ try {
3870
+ const compensation = compensateSpawn();
3871
+ error.message += compensation.note;
3872
+ if (compensation.unconfirmed) unconfirmedSpawn(error);
3873
+ } catch (cleanupError) {
3874
+ // An interrupted compensation pass cannot establish that effects ended.
3875
+ error.message += ` — rollback INCOMPLETE: cleanup could not be completed: ${cleanupError.message}`;
3876
+ unconfirmedSpawn(error);
3877
+ }
3854
3878
  throw error;
3855
3879
  }
3856
3880
  }
@@ -3948,7 +3972,18 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3948
3972
  ...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
3949
3973
  ...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
3950
3974
  };
3951
- return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3975
+ // Feature waiting-on-you: a producer's live claim that the instance is
3976
+ // blocked on a human, read only for a running row (one bounded read of
3977
+ // its home log); null otherwise, and null means unknown. `running` is
3978
+ // the window's presence, which a crashed harness's fallback shell or a
3979
+ // retained dead pane keeps: a row with a claim is shown only when its
3980
+ // session is observed running a harness, as session inspect reports it.
3981
+ let waitingOnYou = liveness.running === true ? liveWaiting(home) : null;
3982
+ if (waitingOnYou) {
3983
+ try { const s = instanceSessionTarget(home); if (!s.target || !harnessRunning(inspectSessionTarget(s.target))) waitingOnYou = null; }
3984
+ catch { waitingOnYou = null; }
3985
+ }
3986
+ return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, waitingOnYou, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3952
3987
 
3953
3988
  });
3954
3989
  };
@@ -4324,6 +4359,12 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
4324
4359
  * otherwise every stop or event write would read as "changed home bytes". */
4325
4360
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
4326
4361
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
4362
+ /** Harness project settings in the home are configuration, not work: the
4363
+ * home's `.claude/` is harness layout the kernel already shapes (the skills
4364
+ * alias), and capabilities keep their own entries current in its
4365
+ * settings.json at every launch. A retirement fingerprint ignores exactly
4366
+ * that path, home-relative. */
4367
+ const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
4327
4368
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
4328
4369
  const hash = createHash("sha256");
4329
4370
  const rootStat = lstatSync(root);
@@ -4338,6 +4379,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
4338
4379
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
4339
4380
  if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
4340
4381
  const childRel = rel ? join(rel, e.name) : e.name;
4382
+ if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
4341
4383
  const path = join(dir, e.name);
4342
4384
  const st = lstatSync(path);
4343
4385
  hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
@@ -4565,12 +4607,18 @@ function instanceSessionTarget(home) {
4565
4607
  }
4566
4608
 
4567
4609
  export function inspectInstanceSession(home) {
4568
- if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
4610
+ if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped", waitingOnYou: null };
4569
4611
  const s = instanceSessionTarget(home);
4570
- if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
4571
- try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
4612
+ if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched", waitingOnYou: null };
4613
+ let observed;
4614
+ try { observed = inspectSessionTarget(s.target); }
4572
4615
  catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
4616
+ // Feature waiting-on-you: beside `state`, whose enum is unchanged.
4617
+ return { home: s.home, ...observed, waitingOnYou: harnessRunning(observed) ? liveWaiting(s.home) : null };
4573
4618
  }
4619
+ /** Whether an observed session runs a harness: present, and neither a
4620
+ * fallback shell nor a dead pane. Only such a session can be waiting on a human. */
4621
+ function harnessRunning(observed) { return observed?.present === true && observed.state !== "shell" && observed.state !== "stopped"; }
4574
4622
 
4575
4623
  /** K3: quiesce one instance's session and RETAIN everything else — home,
4576
4624
  * worktree, transcript, launch configuration — so `restart` can bring it back.
@@ -5125,6 +5173,9 @@ export function startInstanceSession(home, o = {}) {
5125
5173
  // Reconcile even an exited target: the independent baseline may
5126
5174
  // already name it while metadata still names the old allocation.
5127
5175
  const meta = readMeta();
5176
+ // The adopted start's session boundary, at its launch time, completed
5177
+ // in whichever log the interrupted start did not record it.
5178
+ recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
5128
5179
  const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
5129
5180
  if (st.present && st.state !== "shell") {
5130
5181
  if (o.restart) { rmSync(pendingPath, { force: true }); }
@@ -5305,6 +5356,10 @@ export function startInstanceSession(home, o = {}) {
5305
5356
  catch (e) { throw launchFailure(e); }
5306
5357
  }
5307
5358
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5359
+ // The session exists: its boundary (lib/instance-events.mjs) is recorded
5360
+ // now, before the metadata, so a start whose metadata write fails still
5361
+ // voids the claims of the session it replaced; its adoption only completes a log it missed.
5362
+ recordStartBoundary(realHome, { startId: id, startedAt, harness: launchPlan?.harness || harness, backend: "tmux", launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: o.restart ? "restart" : "start" });
5308
5363
  // Keep launch evidence until the command exits or the target disappears.
5309
5364
  // A transient child (for example the native-start recorder) is not proof that startup
5310
5365
  // has finished. A later start reconciles the receipt without a watcher.
package/lib/dir-lock.mjs CHANGED
@@ -10,9 +10,9 @@ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) {
10
10
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
11
 
12
12
  /** A mkdir lock that is never reclaimed by another process: an existing
13
- * lock whose owner is unreadable or dead is refused with the directory to
14
- * remove, because the gap between mkdir and owner.json belongs to a live
15
- * acquirer and a dead-owner reclaim races every other acquirer. The
13
+ * lock whose owner is unreadable or dead is refused after bounded waiting,
14
+ * with the directory to remove, because the gap between mkdir and owner.json
15
+ * belongs to a live acquirer and a dead-owner reclaim races every other acquirer. The
16
16
  * holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
17
17
  * held after `retryMs` is refused with `busy(why)`, the caller's own error. */
18
18
  export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
@@ -23,7 +23,10 @@ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
23
23
  catch (e) {
24
24
  if (e.code !== "EEXIST") throw e;
25
25
  let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
26
- if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
26
+ // Missing/unreadable owners also occur between mkdir and publication,
27
+ // and while the holder removes its directory. Wait without touching it.
28
+ const remaining = deadline - Date.now();
29
+ if (remaining > 0) { pause(Math.min(50, remaining)); continue; }
27
30
  throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
28
31
  }
29
32
  }