@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 +101 -11
- package/docs/capabilities.md +215 -1
- package/docs/desktop-cli-api.md +159 -13
- package/docs/execution-targets.md +4 -0
- package/docs/implementation.md +19 -0
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +5 -5
- package/docs/release-notes/v0.40.0.md +181 -0
- package/docs/release-notes/v0.40.1.md +20 -0
- package/docs/schedules.md +85 -16
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +4 -1
- package/lib/core.mjs +49 -9
- package/lib/instance-events.mjs +178 -23
- package/lib/schedule-command-child.mjs +58 -0
- package/lib/schedule-command.mjs +26 -0
- package/lib/schedule.mjs +142 -37
- package/lib/servers.mjs +14 -2
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|
|
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
|
package/docs/capabilities.md
CHANGED
|
@@ -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
|
|
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
|