@awebai/oats 0.22.2 → 0.22.3
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 +80 -12
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +211 -6
- package/capabilities/oats-aweb/injects/aweb.md +6 -0
- package/capabilities/oats-aweb/oats.json +40 -7
- package/docs/capability-manifest.schema.json +41 -0
- package/docs/execution-targets.md +29 -0
- package/docs/integrations.md +36 -0
- package/docs/operating-team-migration.md +89 -37
- package/docs/release-notes/v0.22.3.md +80 -0
- package/docs/servers.md +62 -11
- package/lib/core.mjs +157 -26
- package/lib/servers.mjs +195 -8
- package/package-catalog.json +1 -1
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
|
|
40
40
|
runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
|
|
41
41
|
} from "../lib/packages.mjs";
|
|
42
|
-
import { attachArgv, checkRemote, getServer, inspectRemote, listSnapshots, readServers, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
42
|
+
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
43
43
|
import { spawnSync as spawnSyncProc } from "node:child_process";
|
|
44
44
|
|
|
45
45
|
const args = process.argv.slice(2);
|
|
@@ -2777,7 +2777,12 @@ function spawnCmd() {
|
|
|
2777
2777
|
|
|
2778
2778
|
function retireCmd() {
|
|
2779
2779
|
const name = args[1];
|
|
2780
|
-
if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
|
|
2780
|
+
if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--home <path>] [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
|
|
2781
|
+
let homeFlag = flag("home");
|
|
2782
|
+
if (homeFlag === true) die("--home needs the instance home path");
|
|
2783
|
+
// The calling instance knows its own home: self-retire never needs to
|
|
2784
|
+
// disambiguate a same-named twin by hand.
|
|
2785
|
+
if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && (process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name)) homeFlag = process.env.OATS_INSTANCE_HOME;
|
|
2781
2786
|
const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
|
|
2782
2787
|
if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
|
|
2783
2788
|
if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
|
|
@@ -2785,9 +2790,10 @@ function retireCmd() {
|
|
|
2785
2790
|
// Cross-repo: the instance may home in a sibling repo of the team scope.
|
|
2786
2791
|
if (!listAgents(root).some((a) => existsSync(join(a._dir, "instances", name)))) {
|
|
2787
2792
|
const hit = findTeamInstance(dirFlag(), name);
|
|
2788
|
-
|
|
2793
|
+
// Stdout carries only the envelope in JSON mode (the Desktop parses it).
|
|
2794
|
+
if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; (args.includes("--json") ? console.error : console.log)(`(cross-repo: instance homes at ${shortPath(root)})`); }
|
|
2789
2795
|
}
|
|
2790
|
-
const r = retireInstance(root, name, { self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
|
|
2796
|
+
const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
|
|
2791
2797
|
// Deferred self-retire: nothing has been inspected, run, or removed yet. The
|
|
2792
2798
|
// caller's window dies first; a detached process then retires the instance
|
|
2793
2799
|
// as an external operator and writes its outcome beside the home.
|
|
@@ -3118,10 +3124,13 @@ function versionCmd() {
|
|
|
3118
3124
|
if (JSON_MODE) {
|
|
3119
3125
|
// EXACT Desktop API v1 probe payload — one JSON object, nothing else on
|
|
3120
3126
|
// stdout. Desktop accepts desktopApi === 1 and a compatible semver range.
|
|
3121
|
-
// `remote`:
|
|
3122
|
-
//
|
|
3123
|
-
//
|
|
3124
|
-
|
|
3127
|
+
// `remote`: this kernel's remote-side surface: the commands it routes to
|
|
3128
|
+
// a registered server with --server, plus `roster` (the local command
|
|
3129
|
+
// over registrations and saved routes); a Desktop gates its remote path
|
|
3130
|
+
// on it (an older CLI without the surface must fail closed with a
|
|
3131
|
+
// reason, not an argument error). `features`: kernel abilities a peer
|
|
3132
|
+
// must see before relying on them (retire-home: retire --home).
|
|
3133
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "roster", "harvest"], features: ["retire-home"] }));
|
|
3125
3134
|
return;
|
|
3126
3135
|
}
|
|
3127
3136
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -3167,8 +3176,41 @@ async function experimentalCmd() {
|
|
|
3167
3176
|
function serverCmd() {
|
|
3168
3177
|
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
3169
3178
|
const sub = args[1];
|
|
3170
|
-
const usage = "usage: oats server add <id> --ssh <host-alias> --workspace </abs/path> [--oats <path>] [--herdr <path>] [--path <dir:dir>] [--label <text>] [--replace] | list | remove <id> | check <id> [--json]";
|
|
3171
|
-
if (!["add", "list", "remove", "check"].includes(sub)) bail("E_USAGE", usage);
|
|
3179
|
+
const usage = "usage: oats server add <id> --ssh <host-alias> --workspace </abs/path> [--oats <path>] [--herdr <path>] [--path <dir:dir>] [--label <text>] [--replace] | list | remove <id> | check <id> | roster [--server <id>] | forget <id> --instance <name> [--json]";
|
|
3180
|
+
if (!["add", "list", "remove", "check", "roster", "forget"].includes(sub)) bail("E_USAGE", usage);
|
|
3181
|
+
if (sub === "forget") {
|
|
3182
|
+
// A saved route whose remote instance is gone can be dropped only by
|
|
3183
|
+
// the operator: nothing routed can do it, and the changed-registration
|
|
3184
|
+
// guard counts it until then.
|
|
3185
|
+
const id = args[2];
|
|
3186
|
+
const inst = flag("instance");
|
|
3187
|
+
if (!id || id.startsWith("--") || !inst || inst === true) bail("E_USAGE", "usage: oats server forget <id> --instance <name> [--json]");
|
|
3188
|
+
let snap;
|
|
3189
|
+
try { snap = forgetSnapshot(id, inst); } catch (e) { bail(e.code || "E_SNAPSHOT_UNKNOWN", e.message); }
|
|
3190
|
+
if (JSON_MODE) { jsonOk({ server: id, instance: inst, home: snap.home, target: snap.target, forgotten: true }); return; }
|
|
3191
|
+
console.log(`Forgot the saved route of ${inst} through ${id} (${snap.target?.sshHost}:${snap.home}).`);
|
|
3192
|
+
console.log(` if that home still exists on the host it is no longer managed from here: retire it there with oats retire ${inst} --home ${snap.home}`);
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (sub === "roster") {
|
|
3196
|
+
// The remote roster for the Desktop and operators: grouped by saved route
|
|
3197
|
+
// target, one bounded status pull per target, saved routes as the action
|
|
3198
|
+
// authority. --server narrows to one registry id.
|
|
3199
|
+
const only = flag("server") === true ? bail("E_BAD_ARGS", "--server needs a registered server id") : flag("server");
|
|
3200
|
+
const ms = (name) => { const v = flag(name); if (v === undefined) return undefined; const n = Number(v); if (!Number.isInteger(n) || n < 1000) bail("E_BAD_ARGS", `--${name} takes whole milliseconds, at least 1000`); return n; };
|
|
3201
|
+
let out;
|
|
3202
|
+
try { out = rosterGroups({ server: only, io: { budgetMs: ms("budget"), perTargetTimeoutMs: ms("per-target") } }); } catch (e) { bail(e.code || "E_SERVERS_UNREADABLE", e.message); }
|
|
3203
|
+
if (JSON_MODE) { jsonOk(out); return; }
|
|
3204
|
+
if (!out.groups.length) { console.log("no remote groups: no registrations and no saved routes"); return; }
|
|
3205
|
+
for (const g of out.groups) {
|
|
3206
|
+
console.log(` ${g.server}${g.label && g.label !== g.server ? ` ${g.label}` : ""} [${g.id}] ssh ${g.target.sshHost} workspace ${g.target.workspace}${g.registrationPresent ? "" : " (registration removed or changed; saved routes only)"}`);
|
|
3207
|
+
console.log(g.probe?.ok ? ` reachable, ${g.souls.length} soul(s)` : ` UNREACHABLE: ${g.probe?.error?.message || "?"}`);
|
|
3208
|
+
for (const i of g.instances) console.log(` • ${i.instance} ${i.missingRemotely ? "GONE on the host (saved route is stale: oats server forget)" : i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown"}${i.retirePending ? " RETIRING" : ""}${i.rollbackIncomplete ? " QUARANTINED" : ""}${i.savedRoute ? "" : " (observed only, no saved route)"}${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
|
|
3209
|
+
for (const f of g.retireFailures || []) console.log(` ! deferred retirement of ${f.instance} (${f.agent}) FAILED there${f.error?.message ? `: ${f.error.message}` : ""}${f.retry ? ` — retry on the host: ${f.retry}` : ""}`);
|
|
3210
|
+
}
|
|
3211
|
+
console.log(` bounds: ${out.bounds.perTargetTimeoutMs} ms per target within ${out.bounds.budgetMs} ms, ${out.bounds.elapsedMs} ms used${out.bounds.skipped ? `, ${out.bounds.skipped} target(s) not reached` : ""}`);
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3172
3214
|
let servers;
|
|
3173
3215
|
try { servers = readServers(); } catch (e) { bail(e.code || "E_SERVERS_UNREADABLE", e.message); }
|
|
3174
3216
|
if (sub === "list") {
|
|
@@ -3227,6 +3269,22 @@ function serverRouteCmd() {
|
|
|
3227
3269
|
// Interactive viewer: `oats session attach --server <id> --instance <name>`
|
|
3228
3270
|
// (or --home </abs/remote/home>) runs the execution host's own attach
|
|
3229
3271
|
// through an ssh PTY with this terminal's stdio; nothing is captured.
|
|
3272
|
+
if (cmd === "okf") {
|
|
3273
|
+
// `oats okf harvest --server <id> --instance <name>`: the package's
|
|
3274
|
+
// harvest command run in the instance's SAVED home on the host.
|
|
3275
|
+
if (args[1] !== "harvest") bail("E_USAGE", "--server routes `okf harvest` only among the okf commands");
|
|
3276
|
+
const inst = flag("instance");
|
|
3277
|
+
if (!inst || inst === true) bail("E_BAD_ARGS", "okf harvest --server needs --instance <name> (spawned from here)");
|
|
3278
|
+
let routed;
|
|
3279
|
+
try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
3280
|
+
if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
|
|
3281
|
+
if (JSON_MODE) { console.log(JSON.stringify(routed.envelope, null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
|
|
3282
|
+
if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
|
|
3283
|
+
const hr = routed.envelope.result;
|
|
3284
|
+
console.log(`Harvest on ${id} for ${inst}: ${hr.harvest}${hr.reason ? ` (${hr.reason})` : ""}${hr.instance && hr.harvest === "spawned" ? ` — harvester ${hr.instance}` : ""}`);
|
|
3285
|
+
if (hr.instance && hr.instance !== inst) console.log(` the harvester ${hr.instance} runs on ${id}; retire it there when it is done, or let it self-retire`);
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3230
3288
|
if (cmd === "session") {
|
|
3231
3289
|
const addr = { instance: flag("instance") === true ? undefined : flag("instance"), home: flag("home") === true ? undefined : flag("home") };
|
|
3232
3290
|
if (args[1] === "inspect") {
|
|
@@ -3278,7 +3336,7 @@ function serverRouteCmd() {
|
|
|
3278
3336
|
if (cmd === "spawn") {
|
|
3279
3337
|
console.log(`Spawned ${r.instance} on ${id} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux?.window}" on ${r.target.sshHost}` : " — not launched"}`);
|
|
3280
3338
|
console.log(` remote home: ${r.home}`);
|
|
3281
|
-
console.log(` route snapshot: ${shortPath(r.snapshot)}`);
|
|
3339
|
+
console.log(` route snapshot: ${r.snapshot ? shortPath(r.snapshot) : "none (see the warning)"}`);
|
|
3282
3340
|
for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
|
|
3283
3341
|
console.log(` attach: ssh -t ${r.target.sshHost} tmux attach -t ${r.tmux?.session || "oats"}`);
|
|
3284
3342
|
} else if (cmd === "retire") {
|
|
@@ -3321,7 +3379,7 @@ function serverRouteCmd() {
|
|
|
3321
3379
|
// blame` pointing at the commit that last changed each command.
|
|
3322
3380
|
const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
|
|
3323
3381
|
try {
|
|
3324
|
-
if (flag("server") !== undefined && ["spawn", "retire", "status", "session"].includes(cmd)) serverRouteCmd();
|
|
3382
|
+
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf"].includes(cmd)) serverRouteCmd();
|
|
3325
3383
|
else if (cmd === "server") serverCmd();
|
|
3326
3384
|
else if (cmd === "doctor") {
|
|
3327
3385
|
const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
@@ -3373,6 +3431,16 @@ Usage:
|
|
|
3373
3431
|
oats spawn|retire|status ... --server <id> run that command on the server's installed oats
|
|
3374
3432
|
(same flags, same envelope; the saved route per
|
|
3375
3433
|
remote instance lives under ~/.oats/remote/)
|
|
3434
|
+
oats server roster [--server <id>] remote roster grouped by server and saved route
|
|
3435
|
+
[--budget <ms>] [--per-target <ms>] target: one status pull per group within a total
|
|
3436
|
+
[--json] budget (45 s, 20 s per target); saved routes are
|
|
3437
|
+
the authority for actions
|
|
3438
|
+
oats server forget <id> --instance <name> drop a saved route whose remote instance is gone
|
|
3439
|
+
(the roster shows it as missingRemotely)
|
|
3440
|
+
oats retire <instance> --home <path> retire exactly that home when two agents own an
|
|
3441
|
+
instance of the same name (else refused)
|
|
3442
|
+
oats okf harvest --server <id> run the knowledge harvest in a remote instance's
|
|
3443
|
+
--instance <name> [--json] saved home on its host
|
|
3376
3444
|
oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
|
|
3377
3445
|
--instance <name> | --home <abs> remote instance over its saved route (--print shows
|
|
3378
3446
|
attach); the server needs oats 0.22.2 or later
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
* identity joined moments before the failure must still be deletable.
|
|
38
38
|
*/
|
|
39
39
|
import { execFileSync } from "node:child_process";
|
|
40
|
-
import { existsSync, statSync } from "node:fs";
|
|
40
|
+
import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { hostname } from "node:os";
|
|
41
42
|
import { join, dirname, resolve, delimiter } from "node:path";
|
|
42
43
|
|
|
43
44
|
/** Run a command as ARGV — never a shell string. Team ids, aliases, instance
|
|
@@ -66,6 +67,10 @@ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } =
|
|
|
66
67
|
const why = secretSafe ? "" : (scrub(e.stderr).trim() || (e.status === undefined ? String(e.code || "failed") : ""));
|
|
67
68
|
const err = new Error(`${where} failed${e.status === undefined ? "" : ` (exit ${e.status})`}${why ? `: ${why}` : ""}${secretSafe ? " (output withheld: this command handles credentials)" : ""}`);
|
|
68
69
|
err.status = e.status;
|
|
70
|
+
// A classification, never the text: the caller may name a KNOWN failure
|
|
71
|
+
// class (an alias that still holds a certificate) without any output of a
|
|
72
|
+
// credential-handling command reaching a log.
|
|
73
|
+
err.aliasConflict = /already|exists|conflict|422|active certificate/i.test(String(e.stderr ?? "") + String(e.stdout ?? ""));
|
|
69
74
|
throw err;
|
|
70
75
|
}
|
|
71
76
|
};
|
|
@@ -97,6 +102,17 @@ const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb:
|
|
|
97
102
|
const event = process.env.OATS_EVENT || process.argv[2];
|
|
98
103
|
const instance = process.env.OATS_INSTANCE;
|
|
99
104
|
const home = process.env.OATS_HOME || process.cwd();
|
|
105
|
+
// Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
|
|
106
|
+
// delivery: "channel" (default) keeps the native channel packages waking the
|
|
107
|
+
// instance; "session" hands delivery to the host wake broker (aweb-abil):
|
|
108
|
+
// AWEB_DELIVERY=session goes into the launch environment, the Claude channel
|
|
109
|
+
// flag is omitted, and nothing wakes the instance until the broker exists.
|
|
110
|
+
let settings = {};
|
|
111
|
+
try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); } catch { settings = {}; }
|
|
112
|
+
const deliveryMode = (() => {
|
|
113
|
+
const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
|
|
114
|
+
return v === "session" ? "session" : "channel";
|
|
115
|
+
})();
|
|
100
116
|
|
|
101
117
|
/**
|
|
102
118
|
* The aweb root (minting authority). BOUNDED candidates — the deployment's team
|
|
@@ -148,7 +164,165 @@ if (!onPath("aw")) {
|
|
|
148
164
|
warn(`aw CLI not on PATH — no identity minted; ${AW_INSTALL}`);
|
|
149
165
|
}
|
|
150
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Retained identity (explicit per-soul opt-in): a standing seat keeps its
|
|
169
|
+
// did:aw and address when re-seated as an OATS instance. Per aweb's contract
|
|
170
|
+
// (2026-09-05): copy exactly the identity-authority files from the source
|
|
171
|
+
// .aw into the home's .aw, reconnect the coordination binding with
|
|
172
|
+
// `aw workspace connect`, verify online, heartbeat and status show the new
|
|
173
|
+
// path, and hold a lock BESIDE the source so no second seat can take it.
|
|
174
|
+
// Never copy workspace.yaml, caches or locks; never delete the source; never
|
|
175
|
+
// team-join (that is the mint path, which would try to create the alias
|
|
176
|
+
// again). Retire releases the lock and leaves the identity alone.
|
|
177
|
+
const IDENTITY_AUTHORITY = ["signing.key", "identity.yaml", "teams.yaml", "team-certs", "encryption.yaml", "encryption-keys"];
|
|
178
|
+
// Session delivery registers the home with the host wake broker (aweb-abil:
|
|
179
|
+
// `aw wake register --home <abs> --identity-home <abs> --delivery session
|
|
180
|
+
// [--backend tmux|herdr]`, durable even when the daemon is down). An aw
|
|
181
|
+
// without `aw wake` cannot deliver in session mode: refuse, never silently
|
|
182
|
+
// turn a working channel into a poll-only instance.
|
|
183
|
+
// Throws, never exits: both callers run it inside a try whose catch performs
|
|
184
|
+
// the rollback (the seat path restores the binding; the mint path hands the
|
|
185
|
+
// minted identity to compensation).
|
|
186
|
+
function wakeRegister(instanceHome, identityHome) {
|
|
187
|
+
const backend = process.env.OATS_BACKEND;
|
|
188
|
+
try {
|
|
189
|
+
run(["aw", "wake", "register", "--home", instanceHome, "--identity-home", identityHome, "--delivery", "session", ...(backend ? ["--backend", backend] : [])], instanceHome, 60000);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
throw new Error(`delivery: session needs an aw with the wake broker CLI (aw wake register), which this aw does not provide (${e.message || e}); install the aweb release that ships aw wake, or use delivery: channel`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function wakeDeregister(instanceHome) {
|
|
195
|
+
try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
|
|
198
|
+
const yamlScalar = (text, key) => {
|
|
199
|
+
const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
|
|
200
|
+
return m ? m[1].trim() : undefined;
|
|
201
|
+
};
|
|
202
|
+
function retainedSeatSpawn(source, takeOver) {
|
|
203
|
+
if (typeof source !== "string" || !source.startsWith("/")) fatal("identity.source must be the absolute path of the legacy .aw directory to retain");
|
|
204
|
+
if (!existsSync(join(source, "signing.key"))) fatal(`identity.source ${source} holds no signing.key, so there is no identity to retain`);
|
|
205
|
+
const lockPath = seatLockPath(source);
|
|
206
|
+
let takenOver;
|
|
207
|
+
if (existsSync(lockPath)) {
|
|
208
|
+
let held; try { held = JSON.parse(readFileSync(lockPath, "utf8")); } catch { held = {}; }
|
|
209
|
+
const holderHome = held.home;
|
|
210
|
+
// Held means the holder's home still exists: retire removes both the lock
|
|
211
|
+
// and the home, so a home that is there is a seat that was never retired.
|
|
212
|
+
// No process liveness is inferred (the spawner's pid says nothing about the
|
|
213
|
+
// runtime). The only escape is the explicit, warned take-over for a seat
|
|
214
|
+
// whose runtime is known to be dead.
|
|
215
|
+
if (holderHome && existsSync(holderHome)) {
|
|
216
|
+
if (takeOver !== true) fatal(`identity at ${source} is already held by ${holderHome} (${lockPath}); a seat is never taken from a holder whose home exists — retire that instance first, or set identity.takeOver: true only if you know its runtime is dead`);
|
|
217
|
+
takenOver = holderHome;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const srcWorkspace = existsSync(join(source, "workspace.yaml")) ? readFileSync(join(source, "workspace.yaml"), "utf8") : "";
|
|
221
|
+
const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
|
|
222
|
+
if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
|
|
223
|
+
const role = yamlScalar(srcWorkspace, "role_name");
|
|
224
|
+
let team = process.env.OATS_TEAM_ID;
|
|
225
|
+
if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
|
|
226
|
+
if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (set team.id in oats-config.yaml, or an active team in ${join(source, "teams.yaml")})`);
|
|
227
|
+
const dest = join(home, ".aw");
|
|
228
|
+
const legacyHome = dirname(source);
|
|
229
|
+
// The lock is taken FIRST: a concurrent second spawn must see it before any
|
|
230
|
+
// byte of the identity is copied.
|
|
231
|
+
// Exclusive creation (wx): two concurrent spawns cannot both pass the
|
|
232
|
+
// existence check and overwrite each other; the loser fails here having
|
|
233
|
+
// copied nothing. A take-over replaces the stale lock first, deliberately.
|
|
234
|
+
if (takenOver) { try { rmSync(lockPath, { force: true }); } catch { /* replaced below */ } }
|
|
235
|
+
try {
|
|
236
|
+
writeFileSync(lockPath, JSON.stringify({ home, instance, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
fatal(`identity at ${source} was taken by another spawn a moment ago (${lockPath} exists); nothing copied`);
|
|
239
|
+
}
|
|
240
|
+
let connected = false;
|
|
241
|
+
const rollback = () => {
|
|
242
|
+
// After `aw workspace connect` the server binding points at the new home;
|
|
243
|
+
// deleting the copy alone would leave the identity bound to nothing. Put
|
|
244
|
+
// the binding back where it was, from the legacy home, then remove the
|
|
245
|
+
// copy; if the restore fails, KEEP the copy so the seat stays recoverable.
|
|
246
|
+
let restored = !connected;
|
|
247
|
+
if (connected) {
|
|
248
|
+
try { run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], legacyHome, 60000); restored = true; }
|
|
249
|
+
catch { restored = false; }
|
|
250
|
+
}
|
|
251
|
+
if (restored) { try { rmSync(dest, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } }
|
|
252
|
+
return restored;
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
256
|
+
chmodSync(dest, 0o700);
|
|
257
|
+
for (const name of IDENTITY_AUTHORITY) {
|
|
258
|
+
const from = join(source, name);
|
|
259
|
+
if (!existsSync(from)) continue; // encryption material may be absent on an identity that never had it
|
|
260
|
+
const to = join(dest, name);
|
|
261
|
+
if (statSync(from).isDirectory()) {
|
|
262
|
+
cpSync(from, to, { recursive: true }); chmodSync(to, 0o700);
|
|
263
|
+
for (const f of readdirSync(to)) { const p = join(to, f); if (statSync(p).isFile()) chmodSync(p, 0o600); } // private keys inside, whatever the source modes were
|
|
264
|
+
} else { copyFileSync(from, to); chmodSync(to, 0o600); }
|
|
265
|
+
}
|
|
266
|
+
for (const forbidden of ["workspace.yaml", "context", "interaction-log.jsonl", "channel-delivered-ids.json", "chat-delivered-ids.json"]) {
|
|
267
|
+
if (existsSync(join(dest, forbidden))) rmSync(join(dest, forbidden), { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], home, 60000);
|
|
270
|
+
connected = true;
|
|
271
|
+
run(["aw", "check", "--online"], home, 60000);
|
|
272
|
+
run(["aw", "heartbeat"], home, 60000);
|
|
273
|
+
const status = run(["aw", "workspace", "status", "--json"], home, 60000);
|
|
274
|
+
// Thrown, not fatal: the catch below rolls the copy and the lock back first.
|
|
275
|
+
// Verified by parsing: the workspace row's path must be this home. The
|
|
276
|
+
// hostname the row records is whatever the binding stored (on hosted
|
|
277
|
+
// teams it need not equal this OS hostname), so it is reported, not judged.
|
|
278
|
+
let st; try { st = JSON.parse(String(status)); } catch { throw new Error(`aw workspace status from ${home} answered no JSON, so the seat is not connected; nothing is briefed`); }
|
|
279
|
+
const ws = st.workspace && typeof st.workspace === "object" ? st.workspace : st;
|
|
280
|
+
const shownPath = String(ws.workspace_path || ws.path || "");
|
|
281
|
+
const same = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
282
|
+
if (!shownPath || !same(shownPath, home)) throw new Error(`aw workspace status from ${home} shows workspace_path ${JSON.stringify(shownPath)} not this home, so the seat is not connected; nothing is briefed`);
|
|
283
|
+
const hostNote = ws.hostname && ws.hostname !== hostname() && ws.hostname.split(".")[0] !== hostname().split(".")[0] ? ` (workspace row hostname ${ws.hostname}, this host ${hostname()})` : "";
|
|
284
|
+
const identityText = readFileSync(join(dest, "identity.yaml"), "utf8");
|
|
285
|
+
const expectedDid = yamlScalar(identityText, "did");
|
|
286
|
+
const expectedAddress = yamlScalar(identityText, "address");
|
|
287
|
+
// The same-identity check the contract is for: `aw whoami --json` from the
|
|
288
|
+
// new home reports the did and address the CLI now acts as (workspace
|
|
289
|
+
// status carries no did); both must equal the copied identity.yaml.
|
|
290
|
+
let who; try { who = JSON.parse(String(run(["aw", "whoami", "--json"], home, 60000))); } catch (e) { throw new Error(`aw whoami from ${home} answered no JSON (${e.message || e}); the seat is not verified`); }
|
|
291
|
+
const shownDid = who.did || who.identity?.did;
|
|
292
|
+
const shownAddress = who.address || who.identity?.address;
|
|
293
|
+
if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
|
|
294
|
+
if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
|
|
295
|
+
const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
|
|
296
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
|
|
297
|
+
const alias = aliasRaw;
|
|
298
|
+
if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
|
|
299
|
+
writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
300
|
+
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
301
|
+
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
302
|
+
: undefined;
|
|
303
|
+
const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
|
|
304
|
+
const deliveryBrief = deliveryMode === "session"
|
|
305
|
+
? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
|
|
306
|
+
: "";
|
|
307
|
+
if (deliveryMode === "session") wakeRegister(home, dest);
|
|
308
|
+
const warnings = [];
|
|
309
|
+
if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
|
|
310
|
+
if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
|
|
311
|
+
out({
|
|
312
|
+
meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
|
|
313
|
+
...(env ? { env } : {}),
|
|
314
|
+
brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
|
|
315
|
+
...(launch ? { launch } : {}),
|
|
316
|
+
...(warnings.length ? { warning: warnings.join(" | ") } : {}),
|
|
317
|
+
});
|
|
318
|
+
} catch (e) {
|
|
319
|
+
const restored = rollback();
|
|
320
|
+
fatal(`retained identity could not be seated from ${source}: ${e.message || e}${connected ? (restored ? " (the server binding was restored to the legacy home and the copy removed)" : ` (the server binding still points at ${home} and the copy was KEPT there so the seat is recoverable: run aw workspace connect from ${legacyHome} to restore it, or retry the spawn)`) : ""}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
151
324
|
if (event === "spawn") {
|
|
325
|
+
if (settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
|
|
152
326
|
let minted; // external identity, once `aw team join` succeeds
|
|
153
327
|
const root = awebRoot();
|
|
154
328
|
if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
|
|
@@ -173,7 +347,18 @@ if (event === "spawn") {
|
|
|
173
347
|
// so neither their output nor their diagnostics may reach a log.
|
|
174
348
|
const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
|
|
175
349
|
if (!inv?.token || typeof inv.token !== "string") fatal("aw team invite returned no usable token, so no identity could be minted");
|
|
176
|
-
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = parseSecretJson(run(["aw", "team", "join", inv.token, "--name", instance, "--json"], home, 45000, { secrets: [inv.token], secretSafe: true }), "aw team join");
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// A retired alias keeps its certificate until aweb-abim ships, so a
|
|
355
|
+
// re-spawn under the same name is refused by AWID. Say that, and the
|
|
356
|
+
// remedy, instead of relaying a bare join error.
|
|
357
|
+
if (e.aliasConflict) {
|
|
358
|
+
fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — spawn with a fresh --purpose instead`);
|
|
359
|
+
}
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
177
362
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) fatal("aw team join returned no usable result, so no identity could be minted", minted);
|
|
178
363
|
// The RESPONSE is not a safe place to take strings from. Suppressing the
|
|
179
364
|
// failure paths does nothing if a successful reply is copied into meta and
|
|
@@ -203,13 +388,21 @@ if (event === "spawn") {
|
|
|
203
388
|
// spawn, which is exactly the silent host mutation the consent gate exists
|
|
204
389
|
// to prevent. By the time this runs the kernel has already proven the plugin
|
|
205
390
|
// is present and enabled, so contributing the flag is safe.
|
|
206
|
-
|
|
391
|
+
// Session delivery: no channel flag, AWEB_DELIVERY=session in the launch
|
|
392
|
+
// environment (declared in the manifest), and the truth about waking.
|
|
393
|
+
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
207
394
|
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
208
395
|
: undefined;
|
|
396
|
+
const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
|
|
209
397
|
const channelWarning = undefined;
|
|
398
|
+
if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
|
|
399
|
+
const deliveryBrief = deliveryMode === "session"
|
|
400
|
+
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
401
|
+
: "";
|
|
210
402
|
out({
|
|
211
|
-
meta: { team: joined.team_id, alias },
|
|
212
|
-
|
|
403
|
+
meta: { team: joined.team_id, alias, delivery: deliveryMode },
|
|
404
|
+
...(env ? { env } : {}),
|
|
405
|
+
brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
|
|
213
406
|
...(launch ? { launch } : {}),
|
|
214
407
|
...(mismatch ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : channelWarning ? { warning: channelWarning } : {}),
|
|
215
408
|
});
|
|
@@ -221,6 +414,14 @@ if (event === "spawn") {
|
|
|
221
414
|
}
|
|
222
415
|
} else if (event === "retire") {
|
|
223
416
|
const meta = JSON.parse(process.env.OATS_META || "{}");
|
|
417
|
+
// A retained seat: release the lock and leave the identity alone. Never
|
|
418
|
+
// aw workspace delete (it would soft-delete the standing identity's row)
|
|
419
|
+
// and never team retire; the source .aw stays until a human removes it.
|
|
420
|
+
if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
|
|
421
|
+
if (meta.retained) {
|
|
422
|
+
if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
|
|
423
|
+
out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
|
|
424
|
+
}
|
|
224
425
|
// No alias means the spawn hook never reported an identity: nothing exists to
|
|
225
426
|
// undo, which is completion. An alias WITH no local `.aw` is the opposite —
|
|
226
427
|
// the remote record exists and its key is gone, so the self-delete cannot be
|
|
@@ -233,7 +434,11 @@ if (event === "spawn") {
|
|
|
233
434
|
// Self-delete from inside the home, authenticated by its own key — a remote
|
|
234
435
|
// delete would 409 until the server marks the workspace stale.
|
|
235
436
|
run(["aw", "workspace", "delete", meta.alias], home);
|
|
236
|
-
|
|
437
|
+
// Honest: the workspace row is deleted, but a hosted local member cannot
|
|
438
|
+
// revoke its own AWID certificate (aweb-abim), so the alias is NOT
|
|
439
|
+
// reusable. retired stays true because the cleanup is as complete as the
|
|
440
|
+
// platform allows; the field and the line carry the truth.
|
|
441
|
+
out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a fresh --purpose` });
|
|
237
442
|
} catch (e) {
|
|
238
443
|
// Exit nonzero: during a required-hook rollback this is the signal that
|
|
239
444
|
// compensation did NOT complete, so the spawn is not reported as cleanly
|
|
@@ -40,6 +40,12 @@ Aliases are instance names (e.g. `dev-coordinator-1`). Discovery:
|
|
|
40
40
|
`oats status --team` lists this machine's live instances; `oats aweb roster`
|
|
41
41
|
lists the aweb team across machines.
|
|
42
42
|
|
|
43
|
+
**Notification delivery.** Your instance briefing (TASK.md, the Comms line)
|
|
44
|
+
says how messages reach you. If it carries "Notification delivery: external",
|
|
45
|
+
the native channel is NOT running in this session and, until the host wake
|
|
46
|
+
broker registers you, nothing wakes you: check `aw mail inbox` and
|
|
47
|
+
`aw chat pending` at every task boundary. Otherwise the rule below applies.
|
|
48
|
+
|
|
43
49
|
**Never sleep, poll, or busy-wait for another agent's reply.** Send your
|
|
44
50
|
message, finish your turn, and go idle when a delivery channel is configured
|
|
45
51
|
(you saw `✓ aweb connected` at startup). Native Codex has no aweb channel:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.aweb",
|
|
3
3
|
"command": "aweb",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.10.0",
|
|
5
5
|
"compatibility": {
|
|
6
|
-
"oats": ">=0.
|
|
6
|
+
"oats": ">=0.22.3"
|
|
7
7
|
},
|
|
8
8
|
"layer": "messaging",
|
|
9
9
|
"description": "Messaging layer via aweb: per-instance team identities + native aw mail/chat skills + cross-machine team roster.",
|
|
@@ -16,15 +16,32 @@
|
|
|
16
16
|
{
|
|
17
17
|
"runtime": "pi",
|
|
18
18
|
"package": "npm:@awebai/pi",
|
|
19
|
-
"why": "the aweb channel extension for pi sessions
|
|
20
|
-
"install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)"
|
|
19
|
+
"why": "the aweb channel extension for pi sessions \u2014 real-time mail/chat awakenings; without it a pi instance can send with `aw` but is never woken by incoming messages",
|
|
20
|
+
"install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)",
|
|
21
|
+
"when": {
|
|
22
|
+
"delivery": "channel"
|
|
23
|
+
}
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
26
|
"runtime": "claude",
|
|
24
27
|
"package": "aweb-channel@awebai-marketplace",
|
|
25
28
|
"marketplace": "awebai/claude-plugins",
|
|
26
|
-
"why": "the aweb channel plugin for Claude Code sessions
|
|
27
|
-
"install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)"
|
|
29
|
+
"why": "the aweb channel plugin for Claude Code sessions \u2014 real-time mail/chat awakenings; without it a Claude instance can send with `aw` but is never woken by incoming messages",
|
|
30
|
+
"install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)",
|
|
31
|
+
"when": {
|
|
32
|
+
"delivery": "channel"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"runtime": "pi",
|
|
37
|
+
"package": "npm:@awebai/pi",
|
|
38
|
+
"minVersion": "0.3.10",
|
|
39
|
+
"when": {
|
|
40
|
+
"delivery": "session"
|
|
41
|
+
},
|
|
42
|
+
"why": "an ambient aweb pi extension, if one is installed, must honour AWEB_DELIVERY=session (0.3.10 and later); an older one would open a second event stream beside the wake broker; no extension at all is fine",
|
|
43
|
+
"install": "pi install npm:@awebai/pi@latest",
|
|
44
|
+
"ifInstalled": true
|
|
28
45
|
}
|
|
29
46
|
],
|
|
30
47
|
"skills": [
|
|
@@ -43,5 +60,21 @@
|
|
|
43
60
|
"required": true
|
|
44
61
|
},
|
|
45
62
|
"retire": "bin/oats-aweb.mjs retire"
|
|
46
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"environment": [
|
|
65
|
+
"AWEB_DELIVERY"
|
|
66
|
+
],
|
|
67
|
+
"settings": {
|
|
68
|
+
"delivery": {
|
|
69
|
+
"default": "channel",
|
|
70
|
+
"values": [
|
|
71
|
+
"channel",
|
|
72
|
+
"session"
|
|
73
|
+
],
|
|
74
|
+
"description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"environmentNamespaces": [
|
|
78
|
+
"AWEB_"
|
|
79
|
+
]
|
|
47
80
|
}
|
|
@@ -119,6 +119,20 @@
|
|
|
119
119
|
"marketplace": {
|
|
120
120
|
"type": "string",
|
|
121
121
|
"description": "Optional source to register before installing (Claude marketplaces). Shown at the consent prompt, since registering a third-party source is part of what is being agreed to."
|
|
122
|
+
},
|
|
123
|
+
"when": {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"description": "Applies only when every named capability setting has the given effective value (a requirement conditional on configuration, e.g. delivery: channel).",
|
|
126
|
+
"additionalProperties": {}
|
|
127
|
+
},
|
|
128
|
+
"minVersion": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"description": "Lowest acceptable installed version of the package, read from the package.json under the install directory the runtime's listing names; an older or absent manifest fails the requirement with the install remedy.",
|
|
131
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+"
|
|
132
|
+
},
|
|
133
|
+
"ifInstalled": {
|
|
134
|
+
"type": "boolean",
|
|
135
|
+
"description": "When true, an absent package satisfies the row; the row's minVersion and loadability checks apply only to a package that is installed (an ambient extension that must honour a contract if present)."
|
|
122
136
|
}
|
|
123
137
|
},
|
|
124
138
|
"additionalProperties": false
|
|
@@ -217,6 +231,33 @@
|
|
|
217
231
|
"type": "string"
|
|
218
232
|
},
|
|
219
233
|
"description": "Package-relative soul directories (soul.yaml + AGENTS.md) \u2014 capability-defined agents; they resolve like local souls where the capability is active, souls stay read-only in the package, instances home under the scope's local-agents/."
|
|
234
|
+
},
|
|
235
|
+
"settings": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"description": "Declared capability settings: name to { default, values?, description }. Documentation for `oats use --settings`; undeclared settings are still accepted.",
|
|
238
|
+
"additionalProperties": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {
|
|
241
|
+
"default": {},
|
|
242
|
+
"values": {
|
|
243
|
+
"type": "array",
|
|
244
|
+
"description": "Accepted scalar values; an effective value outside the list is refused at resolution.",
|
|
245
|
+
"items": {}
|
|
246
|
+
},
|
|
247
|
+
"description": {
|
|
248
|
+
"type": "string"
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
"additionalProperties": false
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
"environmentNamespaces": {
|
|
255
|
+
"type": "array",
|
|
256
|
+
"description": "Additional environment-name prefixes this capability may declare besides its vendor's own (e.g. AWEB_ for the official oats.aweb integration). Each is an uppercase prefix ending in an underscore; the reserved core (OATS_, PI_AGENT_) and process bootstrap namespaces cannot be claimed. Disclosed at trust time with the environment list.",
|
|
257
|
+
"items": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"pattern": "^[A-Z][A-Z0-9]*_$"
|
|
260
|
+
}
|
|
220
261
|
}
|
|
221
262
|
},
|
|
222
263
|
"additionalProperties": false
|
|
@@ -179,3 +179,32 @@ inspection before attaching over SSH. Pending inspections share the terminal
|
|
|
179
179
|
resource limit and duplicate requests share one inspection. Remote status and
|
|
180
180
|
instance keys must include the server so identical paths on different hosts
|
|
181
181
|
remain distinct.
|
|
182
|
+
|
|
183
|
+
## Desktop remote roster and lifecycle
|
|
184
|
+
|
|
185
|
+
Desktop uses the installed CLI's `server roster --json` feature when advertised.
|
|
186
|
+
It refreshes one aggregate roster at a time, independently of terminal traffic;
|
|
187
|
+
the CLI owns SSH deadlines, server registration and saved routes. Each target
|
|
188
|
+
appears in the workspace selector with its souls and instances. An unreachable
|
|
189
|
+
target stays visible with unknown runtime state and an error. A saved route
|
|
190
|
+
remains visible after its registration is removed or changed.
|
|
191
|
+
|
|
192
|
+
Choose the server workspace to spawn one of its souls. A successful launch
|
|
193
|
+
switches to that workspace and opens the instance terminal once it appears in
|
|
194
|
+
the roster. The instance action menu offers harvest and retirement, including
|
|
195
|
+
for stopped agents. Remote harvest runs in the saved home on the execution host;
|
|
196
|
+
retirement uses the same saved route and work-preservation rules as the CLI.
|
|
197
|
+
Closing a viewer leaves the agent running.
|
|
198
|
+
|
|
199
|
+
Desktop retirement requires the CLI's `retire-home` feature and always sends
|
|
200
|
+
the exact selected home. The remote kernel must support that feature too;
|
|
201
|
+
older kernels require an upgrade before the GUI can retire an instance.
|
|
202
|
+
This keeps same-named instances distinct. Retirement feedback reports every
|
|
203
|
+
preserved recovery path and its classes, including an incomplete cleanup.
|
|
204
|
+
|
|
205
|
+
This first projection enables terminal and lifecycle actions only for remote
|
|
206
|
+
instances with a route saved on this machine. Other observed instances are
|
|
207
|
+
listed, but require the execution host's CLI to manage them. Remote brain files
|
|
208
|
+
are accessed through the terminal. The roster and remote harvest require CLI
|
|
209
|
+
feature tokens `roster` and `harvest`; the published 0.22.2 kernel has remote
|
|
210
|
+
spawn, status, retirement and terminals, but not these two additions.
|