@awebai/oats 0.39.4 → 0.40.1

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() {
@@ -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.
@@ -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)`);
@@ -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>] [--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
@@ -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,217 @@ 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
+ Claude Code shows an AskUserQuestion through its permission dialog, so that
548
+ dialog's own `permission_prompt` follows the question's set: the script keeps
549
+ the current reason in its marker, and a permission prompt never relabels an
550
+ open question. A granted tool that fails fires `PostToolUseFailure`, not
551
+ `PostToolUse`, so that clears too.
552
+
553
+ **Subagents' tool calls do not clear.** Background and parallel subagents
554
+ in the same session fire the same tool hooks, so the main thread's prompt
555
+ could be cleared while the human is still on it. On Claude Code 2.1.288 a
556
+ subagent's tool event carries a top-level `agent_id` (main-thread events
557
+ have none), so a tool clear reads the hook's JSON input and skips the clear
558
+ when it finds one. The read happens only when there is a claim to clear,
559
+ takes at most 64 KiB and 1 s, and looks only at the text before
560
+ the first `"hook_event_name"`: a string value escapes its quotes, so that
561
+ text holds top-level keys only. Anything else (no key, an input cut short,
562
+ another key order, nothing read) means the main thread, and the clear goes
563
+ ahead. A permission `Notification` carries no `agent_id` and no tool id,
564
+ even when a subagent asked, so a claim never knows who set it.
565
+
566
+ **A refused permission prompt is not observable.** On Claude Code 2.1.288,
567
+ answering "No" at a permission prompt interrupts the turn and fires no hook
568
+ (no `PostToolUse`, `PostToolUseFailure`, `PostToolBatch` or `Stop`; probed).
569
+ Claude then waits for the human ("What should Claude do instead?"), and the
570
+ claim stays, still labelled `permission`, until the human's next prompt
571
+ clears it.
572
+
573
+ - **Its own entries only.** oats.core marks its entries by the absolute
574
+ path of its `claude-waiting.sh`. Each run removes only the entries that
575
+ name that path (and any matcher group or event array the removal
576
+ empties), then appends its current ones. Every other key and entry stays
577
+ as it was, in order. The file is written atomically, mode 0600, and only
578
+ when its content changes. A temp file an interrupted write left behind
579
+ (`.claude/.settings.json.oats-core-<pid>-<ms>.tmp`) is removed by the next
580
+ spawn or start once its writer is gone, so it needs no retirement
581
+ exclusion. If the file is a symlink, not a regular file,
582
+ not valid JSON, or not a JSON object with a well-formed `hooks` map,
583
+ oats.core leaves it alone and warns. The same applies when `.claude` is a
584
+ symlink or not a directory.
585
+ - **When it writes.** It writes at spawn, because `oats spawn` runs no
586
+ launch hook, and at every `oats session start|restart`, so the node and
587
+ CLI paths it bakes in follow the current kernel. A launch preview writes
588
+ nothing. Every pass answers `{}`: no launch arguments and no env. Codex
589
+ and pi homes get nothing.
590
+ - **It never hurts the session.** Claude Code reads a hook's stdout and exit
591
+ code as decisions. So every command runs the script through `/bin/sh`
592
+ with stdout and stderr on `/dev/null` and ends in `; exit 0`, under a 5 s
593
+ Claude hook timeout. Stdin, Claude's JSON input, reaches the script, which
594
+ moves it to a private descriptor and detaches its own stdin, stdout and
595
+ stderr first. It reads the input only for a tool clear, bounded as above,
596
+ always exits 0, and kills the CLI after 2 s: with no call starting 2 s
597
+ after the hook began, the worst case is about 4 s, well under Claude's 5 s
598
+ hook timeout.
599
+ - **Debounce.** The script keeps private state outside the home, in a file
600
+ per home: `<dir>/<first 16 hex of sha256(home)>.claude`, where `<dir>`
601
+ is per user: `$XDG_RUNTIME_DIR/oats-waiting` when that is set and
602
+ absolute, else `$TMPDIR/oats-waiting-<uid>` when `TMPDIR` is absolute,
603
+ else `/tmp/oats-waiting-<uid>`. The spawn and launch
604
+ hook computes that path and vets the directory once: it creates it 0700
605
+ and uses it only when it is a real directory the user owns, mode exactly
606
+ 0700, with no ACL (also one macOS shows only as `@`). An existing directory
607
+ is never changed. The hook passes the marker path to every command (or
608
+ `''` when the directory is refused), so the script, which runs on every
609
+ tool call, needs no `ls` or hash: it only re-checks that the directory is
610
+ still a real directory the user owns (only the user could have changed its
611
+ mode since). The marker holds the latest intent (`permission`, `question`
612
+ or `clear`), `<marker>.applied` what the CLI last recorded, and
613
+ `<marker>.lock` is the reconciler's lock. A tool clear when both say clear
614
+ (and no forced call is due) does nothing and starts no node process, so
615
+ the hooks that fire on every tool call cost a `/bin/sh` and a few file
616
+ reads. The turn-boundary clears (`UserPromptSubmit`, `Stop`,
617
+ `SessionEnd`) are not debounced: each gets a CLI call (see Order and
618
+ retry), a node process per prompt and per stop. A symlink is never followed. An
619
+ unusable marker (a refused, missing or replaced directory, a state path
620
+ that is not a regular file, a lock path that is not a directory) means no
621
+ debounce: set and clear then always call the (idempotent) CLI. The launch
622
+ hook resets the state at every spawn and start, as the kernel's session
623
+ boundary voids the claims. The script runs the CLI from the home, so its
624
+ cwd never matters.
625
+ - **Order and retry.** Each event writes its intent to the marker at once,
626
+ so the marker is always the latest intent. Then, if the lock is free, it
627
+ reconciles: one process at a time brings the recorded claim to the latest
628
+ intent, re-reading it after each CLI call (at most 3), so the calls land
629
+ in the order the events came, whatever their speed. Before each call it
630
+ records the claim as `unknown`, and records the intent only once the call
631
+ succeeds: a call that fails or is killed may still have written the claim
632
+ (the kernel writes the home log before the workspace log), so the next
633
+ event always calls the CLI after one. An event that finds the lock held
634
+ never waits: it exits, and the holder applies its intent on its next
635
+ read, or on the read it makes after letting the lock go. The lock is a
636
+ directory holding its holder's token (pid and start time); one whose
637
+ holder is gone (pid dead, or taken over 5 s ago, past Claude's hook
638
+ timeout, which also covers a reused pid and a machine that slept) is
639
+ broken by the next event, as is one a minute old with no pid yet.
640
+ Reapers take turns under a second lock, `<marker>.lock.reap`, and judge
641
+ the lock again there, so a stale judgement never removes the lock another
642
+ reaper has just taken; a reap lock a minute old (a reaper killed in its
643
+ instant) is removed. A holder records a call and lets the lock go only
644
+ while the lock still holds its token. A failed call, or a reconciliation
645
+ the time budget stops (no call starts 2 s after the hook began), leaves
646
+ the recorded claim and the intent apart, and the next event finishes it.
647
+ A turn-boundary clear writes `<marker>.force` beside its intent; the call
648
+ that next applies the intent consumes it. A turn-boundary clear gets a CLI
649
+ call: from that hook, or, if another hook holds the lock, from that holder
650
+ if it still has time; otherwise from the next event. The state files are
651
+ only ever deleted by the launch hook.
652
+ - **It never touches the agent's claim.** The script only ever passes
653
+ `--producer oats.core`.
654
+ - **Not "unknown work" at retirement.** Harness project settings in the home
655
+ are configuration, not work: the retirement fingerprint of a home ignores
656
+ exactly `.claude/settings.json`. oats.core writes it at spawn before the
657
+ retirement baseline is taken, and again at every start.
658
+
659
+ **Why the project settings file, not `--settings`.** On Claude Code
660
+ 2.1.288, Claude honours only the last `--settings` flag on a command line:
661
+ that file replaces earlier ones wholesale, even one with no hooks. The
662
+ project `.claude/settings.json` composes with the user's settings (under any
663
+ `CLAUDE_CONFIG_DIR`) and with a `--settings`. So any capability that needs
664
+ Claude settings uses the same managed `<home>/.claude/settings.json` with
665
+ its own marker, never `--settings`. oats.core leaves
666
+ `.claude/settings.local.json` to Claude Code, which writes its "don't ask
667
+ again" permission rules there.
668
+
669
+ **Limits.**
670
+
671
+ - If the user's Claude configuration sets `disableAllHooks` or
672
+ `allowManagedHooksOnly`, the emitter's hooks never run, so there is no
673
+ claim: the waiting state reads null, not "not waiting".
674
+ - The kernel's write is not fenced against an obsolete writer
675
+ ([#568](https://github.com/awebai/oats/issues/568)), so two rare paths can
676
+ leave the claim wrong while the emitter's state says it is right; the next
677
+ tool clear then skips it. (1) A hook suspended past 5 s mid-call (the
678
+ machine slept, the process was stopped) has its lock broken, and its call
679
+ can land after its successor's. (2) A reaper killed at a precise instant
680
+ can leave a reap lock that two later hooks remove at once, letting two
681
+ reconcilers run. Either can show a claim when nothing waits, or hide a
682
+ question. A turn-boundary clear gets a CLI call: from that hook, or, if
683
+ another hook holds the lock, from that holder if it still has time;
684
+ otherwise from the next event. So a wrongly shown claim lasts until the
685
+ end of the turn, or, if the turn's last hook found a reconciliation out
686
+ of time, until the next event (the human's next prompt). A hidden
687
+ question lasts until the human answers it (their prompt or the answer's
688
+ tool event clears it).
689
+ - **A permission prompt can stay hidden until it is answered**, with no
690
+ suspension or crash: when the prompt opens while another hook's
691
+ reconciliation is under way and that hook then runs out of time (a slow
692
+ clear on a loaded machine: the turn's forced `Stop` clear, or a
693
+ `PostToolUse` clear while a background subagent's prompt opens), the
694
+ prompt's hook has already exited, leaving its intent to the holder, and
695
+ the holder stops without applying it. The next event applies it, and
696
+ while the prompt is open that is usually the answer itself.
697
+ - Parallel tool calls in the main thread **may** clear a claim early: if one
698
+ waits at a permission prompt while a parallel one finishes after the
699
+ prompt's notification, that one's `PostToolUse` is a main-thread clear.
700
+ The notification carries no tool id, so there is no cheap fix. Not
701
+ observed on Claude Code 2.1.288: a parallel `Read` and a backgrounded
702
+ `Agent` call each finished before the notification (which came several
703
+ seconds after the dialog appeared), and the claim stayed.
704
+ - If the marker and the claim disagree (someone deleted the marker by hand,
705
+ say), a stale claim can remain until the turn ends, or the next set or
706
+ clear or session boundary.
707
+ - When a subagent asks for permission and the human approves, the
708
+ subagent's own tool events are skipped too, so the claim stays until the
709
+ next main-thread event: the main thread's next tool call, the subagent's
710
+ completion (Claude submits it as a `<task-notification>` prompt), a
711
+ `Stop` or the human's prompt. A foreground subagent holds the main thread
712
+ until it finishes, so after its prompt is approved the claim can last the
713
+ whole subagent run. That shows "needs input" too long, never hides a real
714
+ block.
715
+ - A `Stop` or `UserPromptSubmit` always clears, even one the main thread
716
+ produces while a subagent's prompt is open (a background subagent's
717
+ completion is submitted as a prompt).
718
+ - A Claude instance spawned before the upgrade gets the emitter only when
719
+ it is respawned. Its launch hook comes from its recorded module copy.
720
+
507
721
  ## Operations a capability declares
508
722
 
509
723
  A manifest may declare `operations`: named actions or views that a GUI, a