@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/bin/oats.mjs CHANGED
@@ -46,7 +46,7 @@ import { attachArgv, checkRemote, connectServer, forgetSnapshot, getServer, insp
46
46
  import { spawnSync as spawnSyncProc } from "node:child_process";
47
47
  import { tickTriggers } from "../lib/triggers.mjs";
48
48
  import * as A from "../lib/automations.mjs";
49
- import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, testSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, scopeAutomations, scheduleKind, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
49
+ import { parseEnvelopeText, unresolvedScheduleAttempts, scheduleScopeOf, listSchedules, describe as describeSchedule, testSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, scopeAutomations, scheduleKind, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
50
50
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
51
51
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
52
52
 
@@ -54,7 +54,7 @@ import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
54
54
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
55
55
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
56
56
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
57
- import { readEvents } from "../lib/instance-events.mjs";
57
+ import { readEvents, setWaiting, incarnationOf } from "../lib/instance-events.mjs";
58
58
 
59
59
  const rawArgs = process.argv.slice(2);
60
60
  /** The kernel's switches: a value never rides one (`--yolo=false` must not turn yolo on). */
@@ -63,19 +63,25 @@ const KERNEL_SWITCHES = new Set(["allow-child-spawns", "apply", "check", "clear"
63
63
  * routed-command loops) then applies the spaced form's validation to it. `problem` is an empty
64
64
  * `--flag=`, a switch given a value, or a value that is itself an option (`--model=--yolo`):
65
65
  * expanded, it would be a flag token every reader sees, which the spaced form can never carry. */
66
+ /** Free-text flags whose inline value may start with `--` (`--message=--deploy failed`). Such a
67
+ * value is kept out of argv, where it would read as a flag (`--message=--clear` must not
68
+ * clear), and flag() answers it from `inline`. */
69
+ const INLINE_TEXT_FLAGS = new Set(["message"]);
66
70
  function expandInlineValues(argv) {
67
71
  const out = [];
72
+ const inline = new Map();
68
73
  let problem;
69
74
  for (const a of argv) {
70
75
  const eq = a.indexOf("=");
71
76
  if (!a.startsWith("--") || eq <= 2) { out.push(a); continue; }
72
77
  const name = a.slice(2, eq), value = a.slice(eq + 1);
78
+ if (INLINE_TEXT_FLAGS.has(name) && value.startsWith("--")) { inline.set(name, value); out.push(`--${name}`); continue; }
73
79
  problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : value.startsWith("--") ? `--${name}= takes a value, not an option (got ${a})` : undefined;
74
80
  out.push(`--${name}`, value);
75
81
  }
76
- return { argv: out, problem };
82
+ return { argv: out, problem, inline };
77
83
  }
78
- const { argv: args, problem: argvProblem } = expandInlineValues(rawArgs);
84
+ const { argv: args, problem: argvProblem, inline: inlineValues } = expandInlineValues(rawArgs);
79
85
  let cmd = args[0];
80
86
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
81
87
  const KERNEL_COMMANDS = new Set(["automations", "trigger", "capture", "capabilities", "doctor", "inspect", "instance", "operation", "package", "readiness", "souls", "soul", "teams", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "update", "version", "workspace"]);
@@ -85,6 +91,7 @@ const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]
85
91
  const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config", "readiness", "instance"]);
86
92
  const flag = (name) => {
87
93
  const i = args.indexOf(`--${name}`);
94
+ if (i >= 0 && inlineValues.has(name)) return inlineValues.get(name);
88
95
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
89
96
  };
90
97
  function yoloFlag() {
@@ -483,7 +490,7 @@ function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home,
483
490
  // (a partial receipt such as result.instance of something it launched
484
491
  // before failing, and any details it gave) travels in error.details so a
485
492
  // scheduler can keep an unconfirmed outcome and reconcile that target.
486
- if (!envelope.ok) bail(envelope.error?.code || "E_OPERATION_FAILED", `${address}: ${envelope.error?.message || "failed"}`, { exit: r.status, envelope, ...(reportsRetainedEffectsText(envelope.error?.message) ? { unconfirmed: true } : {}) });
493
+ if (!envelope.ok) bail(envelope.error?.code || "E_OPERATION_FAILED", `${address}: ${envelope.error?.message || "failed"}`, { exit: r.status, envelope, ...(envelope.error?.details?.unconfirmed === true || reportsRetainedEffectsText(envelope.error?.message) ? { unconfirmed: true } : {}) });
487
494
  if (r.status !== 0) bail("E_OPERATION_RESULT", `${address} (${provider.capability} ${op.command}) answered ok but exited ${r.status}; the receipt is not trusted and its effects are unconfirmed${stderr ? `: ${stderr.slice(0, 400)}` : ""}`, observed(envelope));
488
495
  const result = envelope.result && typeof envelope.result === "object" ? envelope.result : {};
489
496
  if (op.kind === "view") {
@@ -601,12 +608,13 @@ async function doctorWorkspaceJson(ctx, soulName, ws) {
601
608
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
602
609
  const agentsRoot = join(dirname(ws.local.path), "agents");
603
610
  const localTeams = doctorLocalTeams(ws.local.value);
604
- const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...localTeams.problems].filter(Boolean);
611
+ const schedules = unresolvedScheduleAttempts(dirname(ws.local.path));
612
+ const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...localTeams.problems, ...schedules.problems].filter(Boolean);
605
613
  return {
606
614
  schemaVersion: 1, workspaceApi: 2, context: ctx,
607
615
  workspace: { file: ws.local.path, ref: ws.local.workspace },
608
616
  workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
609
- information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...localTeams.information],
617
+ information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...localTeams.information, ...schedules.information],
610
618
  composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
611
619
  ...(problems.length ? { problems } : {}),
612
620
  };
@@ -644,7 +652,9 @@ async function doctor(dir) {
644
652
  const localTeams = doctorLocalTeams(ws.local.value);
645
653
  for (const p of [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot)].filter(Boolean)) console.log(`\n! ${p.code}: ${p.message}`);
646
654
  for (const p of localTeams.problems) console.log(`\n! ${p.code} (${p.condition}): ${p.message}`);
647
- for (const line of localTeams.information) console.log(`\nINFO: ${line}`);
655
+ const schedules = unresolvedScheduleAttempts(dirname(ws.local.path));
656
+ for (const p of schedules.problems) console.log(`\n! ${p.code}: ${p.message}`);
657
+ for (const line of [...localTeams.information, ...schedules.information]) console.log(`\nINFO: ${line}`);
648
658
  if (soulName) {
649
659
  const information = operationalKnowledgeNote(composition, soulName);
650
660
  if (information) console.log(`\nINFO: ${information}`);
@@ -913,6 +923,60 @@ async function launchConfigCmd() {
913
923
  }
914
924
 
915
925
 
926
+ /** `oats instance waiting <set|clear>` and `oats instance attention` (feature
927
+ * waiting-on-you): a producer's claim that an instance is blocked on a human,
928
+ * recorded as a `waiting` event only when the producer's live claim changes
929
+ * (lib/instance-events.mjs setWaiting). `waiting` addresses a home like
930
+ * `instance events` (--home, else $OATS_INSTANCE_HOME, else the home enclosing
931
+ * the cwd; it must be a home of its name under the scope); `attention` is the
932
+ * agent's own sugar for `waiting --producer agent --reason attention` and acts
933
+ * only on $OATS_INSTANCE_HOME. Local only (no --server route). A claim is
934
+ * display-only evidence: nothing in the kernel acts on it. */
935
+ function instanceWaitingCmd(sub, bail) {
936
+ dropAmbientRoot();
937
+ const usage = sub === "attention"
938
+ ? "usage: oats instance attention [--message <text>] [--clear] [--json]"
939
+ : "usage: oats instance waiting <set|clear> --producer <id> [--reason permission|question|attention] [--message <text>] [--home <abs>] [--dir <d>] [--json]";
940
+ const value = (name) => { const v = flag(name); if (v === true) throw Object.assign(new Error(`--${name} needs a value${name === "message" ? " (a message that starts with -- goes as --message=<text>)" : ""}`), { code: "E_BAD_ARGS" }); return v; };
941
+ let home, producer, waiting, reason, message;
942
+ try {
943
+ message = value("message");
944
+ if (sub === "attention") {
945
+ if (args[2] && !args[2].startsWith("--")) return bail("E_BAD_ARGS", usage);
946
+ for (const f of ["home", "dir", "producer", "reason"]) if (args.includes(`--${f}`)) return bail("E_BAD_ARGS", `oats instance attention has no --${f}: it records the agent's own claim on the instance it runs in ($OATS_INSTANCE_HOME); ${usage}`);
947
+ home = process.env.OATS_INSTANCE_HOME;
948
+ if (!home || !isAbsolute(home) || incarnationOf(home) === null) return bail("E_USAGE", `oats instance attention runs inside an instance session: $OATS_INSTANCE_HOME is ${home ? `${home}, which is not an instance home (no readable instance.json)` : "unset"}`);
949
+ producer = "agent"; waiting = !args.includes("--clear");
950
+ if (waiting) reason = "attention";
951
+ } else {
952
+ const verb = args[2];
953
+ if (!["set", "clear"].includes(verb)) return bail("E_BAD_ARGS", usage);
954
+ waiting = verb === "set";
955
+ producer = value("producer"); reason = value("reason");
956
+ if (producer === undefined) return bail("E_BAD_ARGS", `--producer is required; ${usage}`);
957
+ // Producer `agent` is the agent's own claim: only `attention` writes it.
958
+ if (producer === "agent") return bail("E_BAD_ARGS", "--producer agent is reserved for the agent's own claim: use `oats instance attention [--message <text>]` or `oats instance attention --clear` from its session");
959
+ const homeOpt = flag("home");
960
+ if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
961
+ home = homeOpt ?? (process.env.OATS_INSTANCE_HOME || enclosingInstanceHome(logicalCwd()));
962
+ if (!home) return bail("E_BAD_ARGS", `no instance home: pass --home <abs>, or run it inside an instance; ${usage}`);
963
+ if (!isAbsolute(home)) return bail("E_BAD_ARGS", `$OATS_INSTANCE_HOME must be an absolute instance home (${home})`);
964
+ }
965
+ if (sub === "attention" && !waiting && message !== undefined) return bail("E_BAD_ARGS", "--message is for setting attention, not --clear");
966
+ if (incarnationOf(home) === null) return bail("E_SESSION_UNKNOWN", `${home} is not an instance home (no readable instance.json)`);
967
+ // The home must be a home of its own name under the scope: --dir when
968
+ // given, else the agents root the home sits in.
969
+ const root = flag("dir") !== undefined ? ensureRoot(dirFlag()) : agentsRootOfHome(home);
970
+ const { resolveInstance } = await_import_lifecycle();
971
+ try { resolveInstance(dirFlag(), root, basename(home), { home }); }
972
+ catch (e) { if (sub === "attention") return bail("E_USAGE", `$OATS_INSTANCE_HOME (${home}) is not an instance home under ${root}: ${e.message}`); throw e; }
973
+ const r = setWaiting(home, { producer, waiting, ...(reason !== undefined ? { reason } : {}), ...(message !== undefined ? { message } : {}) });
974
+ if (JSON_MODE) { jsonOk(r); return; }
975
+ const w = r.waitingOnYou;
976
+ console.log(`${r.instance}: ${r.changed ? "recorded" : "unchanged"} — ${r.producer} ${w ? `needs input (${w.reason})${w.message ? `: ${w.message}` : ""}` : "not waiting"}`);
977
+ } catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
978
+ }
979
+
916
980
  /** `oats instance <git|diff> <instance>` — K1: read-only Git observation of one
917
981
  * instance's work tree. The instance is addressed qualified: an explicit
918
982
  * --home, or a name under the --dir scope (team roots included) that resolves
@@ -920,7 +984,8 @@ async function launchConfigCmd() {
920
984
  function instanceCmd() {
921
985
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
922
986
  const sub = args[1], name = args[2];
923
- const usage = "usage: oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
987
+ if (sub === "waiting" || sub === "attention") return instanceWaitingCmd(sub, bail);
988
+ const usage = "usage: oats instance waiting <set|clear> --producer <id> [--reason permission|question|attention] [--message <text>] [--home <abs>] [--dir <d>] [--json] | oats instance attention [--message <text>] [--clear] [--json] | oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
924
989
  if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
925
990
  dropAmbientRoot();
926
991
  if (sub === "events") {
@@ -2040,6 +2105,9 @@ async function status() {
2040
2105
  if (a.description) console.log(` ${a.description}`);
2041
2106
  for (const i of a.instances) {
2042
2107
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : livenessWord(i)} (branch ${i.branch || "?"}, ${i.work || "?"})${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
2108
+ // Feature waiting-on-you: non-null only on a running row; the reader has
2109
+ // already validated the message (one line, no control characters).
2110
+ if (i.running === true && i.waitingOnYou) console.log(` ! needs input (${i.waitingOnYou.reason ?? "unknown"})${i.waitingOnYou.message ? `: ${i.waitingOnYou.message}` : ""}`);
2043
2111
  const key = i.home ?? `${a.name}/${i.instance}`;
2044
2112
  if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
2045
2113
  // The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
@@ -2345,8 +2413,8 @@ async function spawnCmd() {
2345
2413
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
2346
2414
  if (e?.code === "E_INSTANCE_NAME_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home ?? null, ...(e.session ? { session: e.session } : {}) }); throw e; }
2347
2415
  if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message, e.details); throw e; }
2348
- if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
2349
- bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
2416
+ if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { ...e.details, instance: e.instance, home: e.home, launched: e.launched }); throw e; }
2417
+ bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e, e.details?.unconfirmed === true ? e.details : undefined); throw e;
2350
2418
  }
2351
2419
  // The instance exists from here on: a failed wake save is reported beside
2352
2420
  // the full receipt, never hidden, and never causes a second spawn.
@@ -2561,12 +2629,27 @@ async function scheduleCmd() {
2561
2629
  }
2562
2630
  case "host": {
2563
2631
  const op = args[2];
2564
- if (op === "install") { registerWorkspace(ws()); installHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
2632
+ if (op === "install") {
2633
+ const cap = (name, reset) => {
2634
+ const occurrences = args.filter((arg) => arg === `--${name}`);
2635
+ if (occurrences.length > 1) throw scheduleError("E_BAD_ARGS", `use --${name} <N|${reset}> once`);
2636
+ const value = flag(name);
2637
+ if (value === undefined || value === reset) return value;
2638
+ if (typeof value !== "string" || !/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
2639
+ throw scheduleError("E_BAD_ARGS", `--${name} requires a positive safe integer or ${reset}`);
2640
+ }
2641
+ return Number(value);
2642
+ };
2643
+ const caps = { maxConcurrent: cap("max-concurrent", "default"), triggersMaxConcurrent: cap("triggers-max-concurrent", "none") };
2644
+ registerWorkspace(ws(), caps);
2645
+ installHostUnit();
2646
+ return out({ scheduler: schedulerStatus(ws(), io) });
2647
+ }
2565
2648
  if (op === "uninstall") { unregisterWorkspace(ws()); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
2566
2649
  if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
2567
2650
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2568
2651
  }
2569
- default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|test <id>|add <id> --file <spec.json> [--workspace <member> --runs-on <host> --owner <host>/<login>]|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2652
+ default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|test <id>|add <id> --file <spec.json> [--workspace <member> --runs-on <host> --owner <host>/<login>]|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install [--max-concurrent <N|default>] [--triggers-max-concurrent <N|none>]|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2570
2653
  }
2571
2654
  } catch (e) {
2572
2655
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
@@ -3179,7 +3262,7 @@ function versionCmd() {
3179
3262
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3180
3263
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3181
3264
  // never listed.
3182
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity", "server-connect", "capability-route", "servers-per-workspace", "operator-default-soul"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3265
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "schedule-host-caps", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity", "server-connect", "capability-route", "servers-per-workspace", "operator-default-soul", "waiting-on-you"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3183
3266
  return;
3184
3267
  }
3185
3268
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3735,7 +3818,7 @@ else if (cmd === "session") await sessionCmd();
3735
3818
  else if (cmd === "schedule") await scheduleCmd();
3736
3819
  else if (cmd === "trigger") await triggerCmd();
3737
3820
  else if (cmd === "automations") await automationsCmd();
3738
- else if (cmd === "spawn") { try { await spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3821
+ else if (cmd === "spawn") { try { await spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e, e.details?.unconfirmed === true ? e.details : undefined); throw e; } }
3739
3822
  else if (cmd === "retire") retireCmd();
3740
3823
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
3741
3824
  else if (cmd === "experimental") await experimentalCmd();
@@ -3847,6 +3930,7 @@ Usage:
3847
3930
  tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
3848
3931
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
3849
3932
  routes to that host's workspace
3933
+ install [--max-concurrent N|default] [--triggers-max-concurrent N|none]
3850
3934
  oats trigger add (--file <json> | --from <package>:<template> [--set k=v]) | list | show | enable
3851
3935
  | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3852
3936
  polled with the host's gh by the schedule tick;
@@ -3978,6 +4062,12 @@ Usage:
3978
4062
  typed lifecycle events (spawned, launched, stopped,
3979
4063
  restarted, retired, worktree-retained…) written by
3980
4064
  the action that made them true; nothing inferred
4065
+ oats instance waiting <set|clear> --producer <id> [--reason permission|question|attention] [--message <text>] [--home <abs>] [--dir <d>] [--json]
4066
+ a producer's claim that the instance needs input
4067
+ from a human; appended only on change; display only
4068
+ oats instance attention [--message <text>] [--clear] [--json]
4069
+ run by the agent from its home: "I need a human's
4070
+ answer" (waiting --producer agent --reason attention)
3981
4071
  oats instance stop <instance> --plan [--no-recursive] [--json]
3982
4072
  what Stop would touch: session state, recorded
3983
4073
  children, dirty work; a planRevision to apply
@@ -4045,7 +4135,7 @@ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspa
4045
4135
  // Same two renderings as every other typed failure: one envelope on stdout in
4046
4136
  // --json mode, one `oats: <message>` line on stderr otherwise. The message
4047
4137
  // already names the offending file — the readers re-raise it with one.
4048
- if (JSON_MODE) jsonFail(e.code, e.message);
4138
+ if (JSON_MODE) jsonFail(e.code, e.message, e.details?.unconfirmed === true ? e.details : undefined);
4049
4139
  die(e.message);
4050
4140
  } finally {
4051
4141
  // The command's read session: every `git cat-file --batch` child ends before the process does.
@@ -158,7 +158,10 @@ A self-contained package has an `oats.json`:
158
158
  A harness package is **verified at spawn, never installed there**: installing
159
159
  would mutate the operator's harness configuration without asking, in the
160
160
  middle of a spawn. A missing, uninstalled or disabled package fails the spawn
161
- with the consent command that fixes it.
161
+ with direct package-manager guidance for the operator. The command uses the
162
+ selected harness executable and resource directory, with Claude marketplace
163
+ registration before plugin installation. Run it in the indicated context
164
+ with the same launch environment; spawn and restart never install packages.
162
165
  - OATS never installs a host requirement silently. A missing host command is
163
166
  the operator's to install; `oats doctor` reports it. Consent to install is
164
167
  separate from declaring the package.
@@ -504,6 +507,235 @@ carrying its expert soul. The framework's own souls say
504
507
  `oats.okf: { from: package }`: membership never turns a package into a
505
508
  latest-state capability.
506
509
 
510
+ ### oats.core: needs input
511
+
512
+ oats.core (2.4.0 and later, kernel 0.40.0 and later) tells the deployment
513
+ when an instance is blocked on a human, in two independent ways. Each shows
514
+ on `oats status` and in `oats instance events`. The verbs' contract
515
+ (`oats instance waiting`, `oats instance attention`, the `waiting` event) is
516
+ in [desktop-cli-api.md](desktop-cli-api.md#waiting-on-you). Claims are
517
+ display-only: nothing in the kernel acts on them.
518
+
519
+ **The agent's own claim.** The oats.core inject and `/oats-operate` teach
520
+ every instance this protocol. When it has asked a human something and cannot
521
+ continue without the answer, it runs
522
+ `oats instance attention --message "<one line>"` from its home and ends its
523
+ turn. Once it has the answer, it runs `oats instance attention --clear`. The
524
+ claim belongs to producer `agent`. Only `--clear` or the next session start,
525
+ restart or stop clears it. The message is one line of at most 200
526
+ characters; control characters, the Unicode line and paragraph separators,
527
+ bidi controls (U+202A–202E, U+2066–2069), U+200B, U+2060, U+FEFF and tag
528
+ characters (U+E0000–E007F) are refused, and everything else (emoji ZWJ
529
+ sequences, ZWNJ, LRM, RLM, ALM) is allowed.
530
+
531
+ **The Claude Code emitter.** For a Claude instance, oats.core's spawn and
532
+ launch hooks (`bin/oats-core.mjs`, preview-aware) write Claude Code hooks
533
+ into the home's project settings, `<home>/.claude/settings.json`. Each one
534
+ runs `bin/claude-waiting.sh` from the home's module copy, which calls
535
+ `oats instance waiting set|clear --producer oats.core`:
536
+
537
+ | Claude Code event | Matcher | Action |
538
+ | --- | --- | --- |
539
+ | `Notification` | `permission_prompt` | set `permission` |
540
+ | `Notification` | `elicitation_dialog` | set `question` |
541
+ | `PreToolUse` | `AskUserQuestion` | set `question` |
542
+ | `PreToolUse` | `^(?!AskUserQuestion$).*` (every other tool) | clear, unless a subagent made the call |
543
+ | `PostToolUse` | `*` | clear, unless a subagent made the call |
544
+ | `PostToolUseFailure` | `*` | clear, unless a subagent made the call |
545
+ | `UserPromptSubmit`, `Stop`, `SessionEnd` | none | clear, not debounced (a turn boundary) |
546
+
547
+ **One home per settings file.** The launch hook bakes the home it writes the
548
+ settings for into every command, as its real path (oats.core 2.4.1). The
549
+ script acts only when the session's `$OATS_INSTANCE_HOME` names that same
550
+ home, through any spelling (a symlinked deployment resolves to the same
551
+ path). A Claude process that loads one home's settings while carrying
552
+ another instance's environment (a nested `claude -p`, a `claude -p` started
553
+ with its working directory in another home, a pane that inherited the
554
+ variables) does nothing at all: no CLI call and no marker write, for either
555
+ home. Before 2.4.1 it set and cleared the claim of the home its environment
556
+ named, and its `Stop` and `SessionEnd` clears could erase that instance's
557
+ real claim.
558
+
559
+ Claude Code shows an AskUserQuestion through its permission dialog, so that
560
+ dialog's own `permission_prompt` follows the question's set: the script keeps
561
+ the current reason in its marker, and a permission prompt never relabels an
562
+ open question. A granted tool that fails fires `PostToolUseFailure`, not
563
+ `PostToolUse`, so that clears too.
564
+
565
+ **Subagents' tool calls do not clear.** Background and parallel subagents
566
+ in the same session fire the same tool hooks, so the main thread's prompt
567
+ could be cleared while the human is still on it. On Claude Code 2.1.288 a
568
+ subagent's tool event carries a top-level `agent_id` (main-thread events
569
+ have none), so a tool clear reads the hook's JSON input and skips the clear
570
+ when it finds one. The read happens only when there is a claim to clear,
571
+ takes at most 64 KiB and 1 s, and looks only at the text before
572
+ the first `"hook_event_name"`: a string value escapes its quotes, so that
573
+ text holds top-level keys only. Anything else (no key, an input cut short,
574
+ another key order, nothing read) means the main thread, and the clear goes
575
+ ahead. A permission `Notification` carries no `agent_id` and no tool id,
576
+ even when a subagent asked, so a claim never knows who set it.
577
+
578
+ **A refused permission prompt is not observable.** On Claude Code 2.1.288,
579
+ answering "No" at a permission prompt interrupts the turn and fires no hook
580
+ (no `PostToolUse`, `PostToolUseFailure`, `PostToolBatch` or `Stop`; probed).
581
+ Claude then waits for the human ("What should Claude do instead?"), and the
582
+ claim stays, still labelled `permission`, until the human's next prompt
583
+ clears it.
584
+
585
+ - **Its own entries only.** oats.core marks its entries by the absolute
586
+ path of its `claude-waiting.sh`. Each run removes only the entries that
587
+ name that path (and any matcher group or event array the removal
588
+ empties), then appends its current ones. Every other key and entry stays
589
+ as it was, in order. The file is written atomically, mode 0600, and only
590
+ when its content changes. A temp file an interrupted write left behind
591
+ (`.claude/.settings.json.oats-core-<pid>-<ms>.tmp`) is removed by the next
592
+ spawn or start once its writer is gone, so it needs no retirement
593
+ exclusion. If the file is a symlink, not a regular file,
594
+ not valid JSON, or not a JSON object with a well-formed `hooks` map,
595
+ oats.core leaves it alone and warns. The same applies when `.claude` is a
596
+ symlink or not a directory.
597
+ - **When it writes.** It writes at spawn, because `oats spawn` runs no
598
+ launch hook, and at every `oats session start|restart`, so the node and
599
+ CLI paths it bakes in follow the current kernel. A launch preview writes
600
+ nothing. Every pass answers `{}`: no launch arguments and no env. Codex
601
+ and pi homes get nothing.
602
+ - **It never hurts the session.** Claude Code reads a hook's stdout and exit
603
+ code as decisions. So every command runs the script through `/bin/sh`
604
+ with stdout and stderr on `/dev/null` and ends in `; exit 0`, under a 5 s
605
+ Claude hook timeout. Stdin, Claude's JSON input, reaches the script, which
606
+ moves it to a private descriptor and detaches its own stdin, stdout and
607
+ stderr first. It reads the input only for a tool clear, bounded as above,
608
+ always exits 0, and kills the CLI after 2 s: with no call starting 2 s
609
+ after the hook began, the worst case is about 4 s, well under Claude's 5 s
610
+ hook timeout.
611
+ - **Debounce.** The script keeps private state outside the home, in a file
612
+ per home: `<dir>/<first 16 hex of sha256(home)>.claude` (the home's real
613
+ path, so each spelling of a home has the same file), where `<dir>`
614
+ is per user: `$XDG_RUNTIME_DIR/oats-waiting` when that is set and
615
+ absolute, else `$TMPDIR/oats-waiting-<uid>` when `TMPDIR` is absolute,
616
+ else `/tmp/oats-waiting-<uid>`. The spawn and launch
617
+ hook computes that path and vets the directory once: it creates it 0700
618
+ and uses it only when it is a real directory the user owns, mode exactly
619
+ 0700, with no ACL (also one macOS shows only as `@`). An existing directory
620
+ is never changed. The hook passes the marker path to every command (or
621
+ `''` when the directory is refused), so the script, which runs on every
622
+ tool call, needs no `ls` or hash: it only re-checks that the directory is
623
+ still a real directory the user owns (only the user could have changed its
624
+ mode since). The marker holds the latest intent (`permission`, `question`
625
+ or `clear`), `<marker>.applied` what the CLI last recorded, and
626
+ `<marker>.lock` is the reconciler's lock. A tool clear when both say clear
627
+ (and no forced call is due) does nothing and starts no node process, so
628
+ the hooks that fire on every tool call cost a `/bin/sh` and a few file
629
+ reads. The turn-boundary clears (`UserPromptSubmit`, `Stop`,
630
+ `SessionEnd`) are not debounced: each gets a CLI call (see Order and
631
+ retry), a node process per prompt and per stop. A symlink is never followed. An
632
+ unusable marker (a refused, missing or replaced directory, a state path
633
+ that is not a regular file, a lock path that is not a directory) means no
634
+ debounce: set and clear then always call the (idempotent) CLI. The launch
635
+ hook resets the state at every spawn and start, as the kernel's session
636
+ boundary voids the claims. The script runs the CLI from the home, so its
637
+ cwd never matters.
638
+ - **Order and retry.** Each event writes its intent to the marker at once,
639
+ so the marker is always the latest intent. Then, if the lock is free, it
640
+ reconciles: one process at a time brings the recorded claim to the latest
641
+ intent, re-reading it after each CLI call (at most 3), so the calls land
642
+ in the order the events came, whatever their speed. Before each call it
643
+ records the claim as `unknown`, and records the intent only once the call
644
+ succeeds: a call that fails or is killed may still have written the claim
645
+ (the kernel writes the home log before the workspace log), so the next
646
+ event always calls the CLI after one. An event that finds the lock held
647
+ never waits: it exits, and the holder applies its intent on its next
648
+ read, or on the read it makes after letting the lock go. The lock is a
649
+ directory holding its holder's token (pid and start time); one whose
650
+ holder is gone (pid dead, or taken over 5 s ago, past Claude's hook
651
+ timeout, which also covers a reused pid and a machine that slept) is
652
+ broken by the next event, as is one a minute old with no pid yet.
653
+ Reapers take turns under a second lock, `<marker>.lock.reap`, and judge
654
+ the lock again there, so a stale judgement never removes the lock another
655
+ reaper has just taken; a reap lock a minute old (a reaper killed in its
656
+ instant) is removed. A holder records a call and lets the lock go only
657
+ while the lock still holds its token. A failed call, or a reconciliation
658
+ the time budget stops (no call starts 2 s after the hook began), leaves
659
+ the recorded claim and the intent apart, and the next event finishes it.
660
+ A turn-boundary clear writes `<marker>.force` beside its intent; the call
661
+ that next applies the intent consumes it. A turn-boundary clear gets a CLI
662
+ call: from that hook, or, if another hook holds the lock, from that holder
663
+ if it still has time; otherwise from the next event. The state files are
664
+ only ever deleted by the launch hook.
665
+ - **It never touches the agent's claim.** The script only ever passes
666
+ `--producer oats.core`.
667
+ - **Not "unknown work" at retirement.** Harness project settings in the home
668
+ are configuration, not work: the retirement fingerprint of a home ignores
669
+ exactly `.claude/settings.json`. oats.core writes it at spawn before the
670
+ retirement baseline is taken, and again at every start.
671
+
672
+ **Why the project settings file, not `--settings`.** On Claude Code
673
+ 2.1.288, Claude honours only the last `--settings` flag on a command line:
674
+ that file replaces earlier ones wholesale, even one with no hooks. The
675
+ project `.claude/settings.json` composes with the user's settings (under any
676
+ `CLAUDE_CONFIG_DIR`) and with a `--settings`. So any capability that needs
677
+ Claude settings uses the same managed `<home>/.claude/settings.json` with
678
+ its own marker, never `--settings`. oats.core leaves
679
+ `.claude/settings.local.json` to Claude Code, which writes its "don't ask
680
+ again" permission rules there.
681
+
682
+ **Limits.**
683
+
684
+ - If the user's Claude configuration sets `disableAllHooks` or
685
+ `allowManagedHooksOnly`, the emitter's hooks never run, so there is no
686
+ claim: the waiting state reads null, not "not waiting".
687
+ - The kernel's write is not fenced against an obsolete writer
688
+ ([#568](https://github.com/awebai/oats/issues/568)), so two rare paths can
689
+ leave the claim wrong while the emitter's state says it is right; the next
690
+ tool clear then skips it. (1) A hook suspended past 5 s mid-call (the
691
+ machine slept, the process was stopped) has its lock broken, and its call
692
+ can land after its successor's. (2) A reaper killed at a precise instant
693
+ can leave a reap lock that two later hooks remove at once, letting two
694
+ reconcilers run. Either can show a claim when nothing waits, or hide a
695
+ question. A turn-boundary clear gets a CLI call: from that hook, or, if
696
+ another hook holds the lock, from that holder if it still has time;
697
+ otherwise from the next event. So a wrongly shown claim lasts until the
698
+ end of the turn, or, if the turn's last hook found a reconciliation out
699
+ of time, until the next event (the human's next prompt). A hidden
700
+ question lasts until the human answers it (their prompt or the answer's
701
+ tool event clears it).
702
+ - **A permission prompt can stay hidden until it is answered**, with no
703
+ suspension or crash: when the prompt opens while another hook's
704
+ reconciliation is under way and that hook then runs out of time (a slow
705
+ clear on a loaded machine: the turn's forced `Stop` clear, or a
706
+ `PostToolUse` clear while a background subagent's prompt opens), the
707
+ prompt's hook has already exited, leaving its intent to the holder, and
708
+ the holder stops without applying it. The next event applies it, and
709
+ while the prompt is open that is usually the answer itself.
710
+ - Parallel tool calls in the main thread **may** clear a claim early: if one
711
+ waits at a permission prompt while a parallel one finishes after the
712
+ prompt's notification, that one's `PostToolUse` is a main-thread clear.
713
+ The notification carries no tool id, so there is no cheap fix. Not
714
+ observed on Claude Code 2.1.288: a parallel `Read` and a backgrounded
715
+ `Agent` call each finished before the notification (which came several
716
+ seconds after the dialog appeared), and the claim stayed.
717
+ - If the marker and the claim disagree (someone deleted the marker by hand,
718
+ say), a stale claim can remain until the turn ends, or the next set or
719
+ clear or session boundary.
720
+ - When a subagent asks for permission and the human approves, the
721
+ subagent's own tool events are skipped too, so the claim stays until the
722
+ next main-thread event: the main thread's next tool call, the subagent's
723
+ completion (Claude submits it as a `<task-notification>` prompt), a
724
+ `Stop` or the human's prompt. A foreground subagent holds the main thread
725
+ until it finishes, so after its prompt is approved the claim can last the
726
+ whole subagent run. That shows "needs input" too long, never hides a real
727
+ block.
728
+ - A `Stop` or `UserPromptSubmit` always clears, even one the main thread
729
+ produces while a subagent's prompt is open (a background subagent's
730
+ completion is submitted as a prompt).
731
+ - The one-home rule separates homes, not two sessions of one home: a second
732
+ Claude process started inside the same home with that home's own
733
+ `$OATS_INSTANCE_HOME` (a `claude -p` the agent runs there) still matches,
734
+ so its `Stop` and `SessionEnd` clears can erase the home's own live
735
+ `oats.core` claim ([#557](https://github.com/awebai/oats/issues/557)).
736
+ - A Claude instance spawned before the upgrade gets the emitter only when
737
+ it is respawned. Its launch hook comes from its recorded module copy.
738
+
507
739
  ## Operations a capability declares
508
740
 
509
741
  A manifest may declare `operations`: named actions or views that a GUI, a
@@ -538,7 +770,22 @@ with no provider or a home operation without `--home`
538
770
  The provider must exit 0 with exactly one JSON envelope on stdout. Otherwise
539
771
  the outcome is **unconfirmed**: `E_OPERATION_TIMEOUT` (after 240 s) or
540
772
  `E_OPERATION_RESULT`, with `error.details { unconfirmed: true, exit, envelope?, stderr? }`.
541
- A provider's own `ok: false` is relayed with its code. A schedule of kind
773
+ A provider's own `ok: false` is relayed with its code and full envelope. When
774
+ the provider sets `error.details.unconfirmed: true` (the boolean), the operation
775
+ wrapper also sets its outer `error.details.unconfirmed: true`. Providers should
776
+ set that marker when dispatched effects or their compensation cannot be
777
+ confirmed, and preserve it through wrappers. Producers should leave ordinary
778
+ refusals and fully compensated failures unmarked: naming a home or retained
779
+ evidence is not itself uncertainty. The operation wrapper and scheduler still
780
+ apply their existing message-based compatibility checks during this additive
781
+ migration, including for copied providers in older homes; their text-based
782
+ false positives are not removed by this change.
783
+
784
+ The kernel marks incomplete keyed spawns (`E_SPAWN_INCOMPLETE`) and spawn
785
+ failures whose rollback cannot finish with the same field, through the CLI.
786
+ A completed rollback remains an unmarked failure. This adds structural evidence;
787
+ it does not remove text fallbacks or change scheduler slot and retry rules.
788
+ A schedule of kind
542
789
  `operation` runs the same command ([schedules.md](schedules.md)). The JSON
543
790
  shapes are in [desktop-cli-api.md](desktop-cli-api.md#inspect-readiness-and-operation-run-on-the-workspace-model-operationsapi-2-soulsapi-2-readinessapi-2-oats-0260).
544
791