@awebai/oats 0.22.12 → 0.22.16

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.
@@ -0,0 +1,53 @@
1
+ # OATS v0.22.16
2
+
3
+ The same reviewed content as the unpublished v0.22.15 tag, whose hosted
4
+ run failed in frozen test data (retirement goldens still carrying the
5
+ removed `harvested` field, and a Desktop harvest fixture predating the
6
+ operations gate) before anything was published; only tests changed.
7
+
8
+ Souls and capabilities become something you inspect and manage, through
9
+ one kernel contract, and knowledge providers declare what they can do.
10
+
11
+ ## Provider operations and inspection (kernel)
12
+
13
+ `oats inspect --json` answers everything a GUI needs in one envelope for a
14
+ scope, a selected soul or a running home: souls with runtime defaults,
15
+ editability and instructions; installed capabilities with health from the
16
+ package engine, separately from their effective activation, provenance and
17
+ settings; effective layer bindings; and the operations each provider
18
+ declares, with availability and the reason when unavailable. For a home the
19
+ answer is its captured bindings (with current manifests and trust), the live
20
+ config beside it, and the drift between them. Same-named souls in member
21
+ repositories are addressed by name plus agents root; a home selects its own
22
+ soul under its own root.
23
+
24
+ `oats operation run <layer>:<name>` resolves the provider that fills a layer
25
+ for a home or a soul, checks declaration, trust and host requirements, runs
26
+ the provider's own command in the right place with the right identity, and
27
+ relays its receipt; a view answers labeled documents. Unconfirmed outcomes
28
+ carry what was observed and a scheduled operation keeps its slot until
29
+ reconciled. Manifests declare `operations`. Schedules gain kind
30
+ `operation`. `oats use --json` answers receipts and `--inherit` returns a
31
+ level to inheritance; `oats soul set` edits an editable soul's defaults and
32
+ instructions in place. All four route to a registered server through the
33
+ saved route; the probe advertises `operations` and `operationsApi: 1`. The
34
+ retire receipt no longer implies a harvest. See
35
+ docs/design/operations-contract.md.
36
+
37
+ ## oats.okf 1.6.0
38
+
39
+ The official knowledge provider declares `inspect` (a view of STATE.md,
40
+ log.md and pending notes) and `harvest` (the existing action); the catalog
41
+ pins v1.6.0. Existing scopes keep their locked 1.5.2 until their owners
42
+ update them; the GUI then shows no operations for those providers, which is
43
+ the truthful state.
44
+
45
+ ## Desktop
46
+
47
+ Souls & capabilities replaces the launch-centric roster: select a soul to
48
+ inspect it, Launch and Schedule are explicit, defaults and instructions are
49
+ editable where the kernel says so, installed capabilities are shown apart
50
+ from activation with enable, disable and inherit at an exact scope, an
51
+ instance's menu opens Knowledge & capabilities, provider views render as
52
+ text, and schedules run declared provider operations. See
53
+ docs/design/2026-09-07-desktop-souls-capabilities.md.
package/docs/schedules.md CHANGED
@@ -56,6 +56,15 @@ and no queue.
56
56
  home is started again only at due minutes, never every minute, so a
57
57
  harness that keeps exiting is not restarted in a loop. A job holds at most
58
58
  one pending delivery: a due minute while one is pending adds nothing.
59
+ - **operation** `{id, enabled, cron, tz, kind: "operation", operation, home}`
60
+ — runs a provider operation such as `knowledge:harvest` in the instance at
61
+ `home` through `oats operation run <layer>:<name> --home <home>`. The
62
+ provider is whatever fills that layer for the home when the job runs (its
63
+ snapshot), not something stored in the job, so the job stays valid across
64
+ provider changes and a GUI can list and edit it without parsing argv.
65
+ Admission, tracking and reconciliation are those of a command job: a
66
+ launch receipt the provider answers (a harvester it spawned) is followed
67
+ until that home is gone; the source home is never treated as a launch.
59
68
  Unobservable or still starting: skipped with the reason, delivery kept
60
69
  pending. Whether a running harness is busy cannot be seen from the
61
70
  terminal: delivery is terminal input (bracketed paste plus Enter), never an
@@ -0,0 +1,164 @@
1
+ /** Instance attachments: bytes a viewer drops or pastes for an agent, kept as
2
+ * private files INSIDE the instance home so the agent can read them by path.
3
+ * Upload never touches the terminal; the caller pastes the returned path.
4
+ * Remote uploads stream through the same ssh transport as every routed
5
+ * command, into `oats session receive` on the execution host. */
6
+ import { createHash } from "node:crypto";
7
+ import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeSync, realpathSync } from "node:fs";
8
+ import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
9
+ import { checkRemote, resolveRoute, runRemote, serverError } from "./servers.mjs";
10
+
11
+ export const ATTACHMENTS_DIRNAME = ".oats-attachments";
12
+ /** Kernel bound per file; a GUI imposes its own stricter interaction bound. */
13
+ export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
14
+ /** The kernel version whose probe first advertises session-upload. */
15
+ export const SESSION_UPLOAD_REMOTE_VERSION = "0.22.13";
16
+
17
+ const err = (code, message, extra) => Object.assign(new Error(message), { code, ...(extra || {}) });
18
+
19
+ /** A file name as it will exist in the attachments directory: one path
20
+ * segment, printable, never a dot name, at most 200 bytes. */
21
+ export function attachmentName(name) {
22
+ if (typeof name !== "string" || !name.trim()) throw err("E_BAD_ARGS", "attachment name must be a non-empty file name");
23
+ if (name.includes("/") || name.includes("\\") || name.includes("\0")) throw err("E_BAD_ARGS", "attachment name must be a single path segment without slashes or NUL");
24
+ if (/[\x00-\x1f\x7f]/.test(name)) throw err("E_BAD_ARGS", "attachment name must not contain control characters");
25
+ if (name === "." || name === "..") throw err("E_BAD_ARGS", "attachment name may not be a dot name");
26
+ // Deliberate: a name starting with a dash would read as an option on the
27
+ // remote command line; the caller renames the file (screenshots never
28
+ // start with one).
29
+ if (name.startsWith("-")) throw err("E_BAD_ARGS", `attachment name ${JSON.stringify(name)} starts with a dash; rename the file before attaching it`);
30
+ if (Buffer.byteLength(name) > 200) throw err("E_BAD_ARGS", "attachment name is longer than 200 bytes");
31
+ return name;
32
+ }
33
+
34
+ export function sha256Hex(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
35
+
36
+ /** Read a REGULAR file descriptor to its end, refusing once more than
37
+ * `maxBytes` arrive. Regular files never answer EAGAIN; pipes must use
38
+ * readStreamBounded, which waits for data instead of spinning. */
39
+ export function readBounded(fd, maxBytes = MAX_ATTACHMENT_BYTES) {
40
+ const chunks = [];
41
+ let total = 0;
42
+ const buf = Buffer.allocUnsafe(1024 * 1024);
43
+ for (;;) {
44
+ const n = readSync(fd, buf, 0, buf.length, null);
45
+ if (n === 0) break;
46
+ total += n;
47
+ if (total > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
48
+ chunks.push(Buffer.from(buf.subarray(0, n)));
49
+ }
50
+ return Buffer.concat(chunks, total);
51
+ }
52
+
53
+ /** Collect a readable stream (stdin from ssh) into memory, bounded: the
54
+ * read is event-driven, so a slow or stalled sender costs no CPU, and the
55
+ * bound is checked as chunks arrive, before anything is written. The whole
56
+ * file is buffered on both sides (up to the bound); this is not end-to-end
57
+ * streaming. */
58
+ export async function readStreamBounded(stream, maxBytes = MAX_ATTACHMENT_BYTES) {
59
+ const chunks = [];
60
+ let total = 0;
61
+ for await (const chunk of stream) {
62
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
63
+ total += b.length;
64
+ if (total > maxBytes) { stream.destroy?.(); throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`); }
65
+ chunks.push(b);
66
+ }
67
+ return Buffer.concat(chunks, total);
68
+ }
69
+
70
+ function instanceHomeOf(home) {
71
+ if (typeof home !== "string" || !isAbsolute(home)) throw err("E_BAD_ARGS", "attachments need an absolute instance home");
72
+ if (!existsSync(join(home, "instance.json"))) throw err("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json); nothing was written`);
73
+ return realpathSync(home);
74
+ }
75
+
76
+ /** The attachments directory must be a real directory inside the home: a
77
+ * symlink planted there (or a file) must not turn a receive into a write
78
+ * somewhere else. Created 0700 when absent. */
79
+ function attachmentsDir(realHome) {
80
+ const dir = join(realHome, ATTACHMENTS_DIRNAME);
81
+ let st;
82
+ try { st = lstatSync(dir); }
83
+ catch (e) {
84
+ if (e.code !== "ENOENT") throw e;
85
+ // Two first uploads may both see ENOENT: the loser's mkdir answers
86
+ // EEXIST and it validates what the winner (or a planted entry) put there.
87
+ try { mkdirSync(dir, { mode: 0o700 }); } catch (m) { if (m.code !== "EEXIST") throw m; }
88
+ st = lstatSync(dir);
89
+ }
90
+ if (st.isSymbolicLink() || !st.isDirectory()) throw err("E_UPLOAD_FAILED", `${dir} is not a real directory inside the instance home; nothing was written`);
91
+ const real = realpathSync(dir);
92
+ if (real !== dir && !real.startsWith(realHome + sep)) throw err("E_UPLOAD_FAILED", `${dir} resolves outside the instance home; nothing was written`);
93
+ return dir;
94
+ }
95
+
96
+ /** Store `bytes` as a private file under <home>/.oats-attachments/<name>,
97
+ * taking name-2, name-3 ... when the name is taken. The destination is
98
+ * allocated exclusively (O_CREAT|O_EXCL, 0600): two simultaneous uploads
99
+ * of the same name get two files and neither clobbers the other, and a
100
+ * symlink planted under a candidate name is skipped, never followed. The
101
+ * path is reported only after the bytes are written and synced. */
102
+ export function receiveAttachment(home, name, bytes, { maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
103
+ const realHome = instanceHomeOf(home);
104
+ const safe = attachmentName(name);
105
+ if (!Buffer.isBuffer(bytes)) throw err("E_BAD_ARGS", "attachment bytes must be a Buffer");
106
+ if (bytes.length > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
107
+ const dir = attachmentsDir(realHome);
108
+ const ext = extname(safe), stem = safe.slice(0, safe.length - ext.length);
109
+ for (let i = 1; i <= 10000; i++) {
110
+ const path = i === 1 ? join(dir, safe) : join(dir, `${stem}-${i}${ext}`);
111
+ let fd;
112
+ try { fd = openSync(path, "wx", 0o600); }
113
+ catch (e) { if (e.code === "EEXIST") continue; throw e; }
114
+ try {
115
+ let off = 0;
116
+ while (off < bytes.length) off += writeSync(fd, bytes, off, bytes.length - off);
117
+ fsyncSync(fd);
118
+ } catch (e) { closeSync(fd); rmSync(path, { force: true }); throw e; }
119
+ closeSync(fd);
120
+ return { path, bytes: bytes.length, sha256: sha256Hex(bytes) };
121
+ }
122
+ throw err("E_UPLOAD_FAILED", `no free name for ${safe} under ${dir}`);
123
+ }
124
+
125
+ function readLocalFile(file, maxBytes) {
126
+ if (typeof file !== "string" || !file) throw err("E_BAD_ARGS", "--file needs a path");
127
+ const abs = resolve(file);
128
+ let st;
129
+ try { st = statSync(abs); } catch { throw err("E_BAD_ARGS", `no such file: ${abs}`); }
130
+ if (!st.isFile()) throw err("E_BAD_ARGS", `${abs} is not a regular file`);
131
+ if (st.size > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `${abs} is ${st.size} bytes; the kernel bound is ${maxBytes}`);
132
+ const fd = openSync(abs, "r");
133
+ try { if (fstatSync(fd).size !== st.size) throw err("E_UPLOAD_FAILED", `${abs} changed while being read`); return { abs, bytes: readBounded(fd, maxBytes) }; }
134
+ finally { closeSync(fd); }
135
+ }
136
+
137
+ /** Upload a local file into an instance's attachments: locally by home, or
138
+ * on the execution host of a registered server through its saved route
139
+ * (the same resolution as session attach). The remote answer's sha256 and
140
+ * size must equal the local file's before the path is reported. */
141
+ export function uploadAttachment({ file, home, server, instance }, io = {}) {
142
+ const maxBytes = io.maxBytes || MAX_ATTACHMENT_BYTES;
143
+ const { abs, bytes } = readLocalFile(file, maxBytes);
144
+ const name = basename(abs);
145
+ const sha256 = sha256Hex(bytes);
146
+ if (!server) {
147
+ if (!home) throw err("E_BAD_ARGS", "session upload needs --home </absolute/instance> or --server <id> with --instance <name> or --home");
148
+ return { ...receiveAttachment(home, name, bytes, { maxBytes }), name, home: realpathSync(home), source: abs };
149
+ }
150
+ const route = resolveRoute(server, { instance, home }, "session upload");
151
+ const remote = checkRemote(route.target, io);
152
+ // Both lists must carry the token; a probe without the arrays is an old kernel.
153
+ const advertises = (list) => Array.isArray(list) && list.includes("session-upload");
154
+ if (!advertises(remote.remote) || !advertises(remote.features)) {
155
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-upload (kernels from ${SESSION_UPLOAD_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
156
+ }
157
+ const { envelope, stderr } = runRemote(route.target, ["session", "receive", "--home", route.home, "--name", name, "--json"], { ...io, input: bytes, timeoutMs: io.timeoutMs || 600000 });
158
+ if (!envelope.ok) throw serverError(envelope.error?.code || "E_UPLOAD_FAILED", `${server}: ${envelope.error?.message || "receive failed"}`);
159
+ const r = envelope.result || {};
160
+ if (r.sha256 !== sha256 || r.bytes !== bytes.length || typeof r.path !== "string" || !r.path.startsWith("/")) {
161
+ throw serverError("E_UPLOAD_FAILED", `${server} stored ${r.bytes ?? "?"} bytes with sha256 ${r.sha256 ?? "?"} at ${r.path || "?"}, but the local file is ${bytes.length} bytes with sha256 ${sha256}; the remote file is left for inspection`);
162
+ }
163
+ return { path: r.path, bytes: r.bytes, sha256: r.sha256, name, home: route.home, server, instance: instance || route.snapshot?.instance, source: abs, ...(stderr?.trim() ? { stderr: stderr.trim() } : {}) };
164
+ }
package/lib/core.mjs CHANGED
@@ -987,9 +987,45 @@ function loadManifestAt(idir, origin) {
987
987
  if (hookDeclaration(value)?.required && hook !== "spawn") throw new Error(`capability ${id} hook "${hook}" cannot be required — only the spawn hook is enforced (retire and soul-scaffold run outside a spawn transaction)`);
988
988
  }
989
989
  if (m.agents !== undefined && (!Array.isArray(m.agents) || m.agents.some((a) => typeof a !== "string"))) throw new Error(`capability ${id} "agents" must be an array of package-relative soul directories`);
990
+ validateManifestOperations(m, id);
990
991
  return { ...m, _dir: idir, _origin: origin };
991
992
  }
992
993
 
994
+ /** `operations`: what a capability offers a GUI or scheduler by name, each
995
+ * delegating to one of its own `commands`. kind "action" runs something;
996
+ * kind "view" answers { documents: [...] } for presentation (a knowledge
997
+ * provider's own view of an instance's working knowledge). context "home"
998
+ * runs in an instance home; "scope" runs in the config scope. */
999
+ const OPERATION_NAME_RE = /^[a-z][a-z0-9-]*$/;
1000
+ function validateManifestOperations(m, id) {
1001
+ if (m.operations === undefined) return;
1002
+ if (!m.operations || typeof m.operations !== "object" || Array.isArray(m.operations)) throw new Error(`capability ${id} "operations" must be an object map`);
1003
+ for (const [name, op] of Object.entries(m.operations)) {
1004
+ if (!OPERATION_NAME_RE.test(name)) throw new Error(`capability ${id} operation "${name}": name must match ${OPERATION_NAME_RE.source}`);
1005
+ if (!op || typeof op !== "object" || Array.isArray(op)) throw new Error(`capability ${id} operation "${name}" must be an object`);
1006
+ if (typeof op.command !== "string" || !Object.prototype.hasOwnProperty.call(m.commands || {}, op.command)) throw new Error(`capability ${id} operation "${name}": "command" must name one of the manifest's commands`);
1007
+ if (op.kind !== undefined && !["action", "view"].includes(op.kind)) throw new Error(`capability ${id} operation "${name}": "kind" must be action or view`);
1008
+ if (op.context !== undefined && !["home", "scope"].includes(op.context)) throw new Error(`capability ${id} operation "${name}": "context" must be home or scope`);
1009
+ if (op.description !== undefined && typeof op.description !== "string") throw new Error(`capability ${id} operation "${name}": "description" must be a string`);
1010
+ if (op.args !== undefined) {
1011
+ if (!Array.isArray(op.args)) throw new Error(`capability ${id} operation "${name}": "args" must be an array`);
1012
+ for (const a of op.args) {
1013
+ if (!a || typeof a !== "object" || typeof a.name !== "string" || !OPERATION_NAME_RE.test(a.name)) throw new Error(`capability ${id} operation "${name}": each arg needs a name matching ${OPERATION_NAME_RE.source}`);
1014
+ if (a.flag !== undefined && (typeof a.flag !== "string" || !/^--[a-z][a-z0-9-]*$/.test(a.flag))) throw new Error(`capability ${id} operation "${name}" arg "${a.name}": "flag" must look like --name`);
1015
+ if (a.required !== undefined && typeof a.required !== "boolean") throw new Error(`capability ${id} operation "${name}" arg "${a.name}": "required" must be a boolean`);
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ /** The normalized operations a manifest declares (empty when none). */
1021
+ export function manifestOperations(manifest) {
1022
+ return Object.entries(manifest?.operations || {}).map(([name, op]) => ({
1023
+ name, kind: op.kind || "action", command: op.command, context: op.context || "home",
1024
+ description: op.description || null,
1025
+ args: (op.args || []).map((a) => ({ name: a.name, flag: a.flag || `--${a.name}`, required: !!a.required, description: a.description || null })),
1026
+ }));
1027
+ }
1028
+
993
1029
  /** Discover capability manifests. Later sources take precedence: outer scopes < inner scopes; installed < owned within one scope. Duplicates inside one source layer are errors. */
994
1030
  export function capabilityManifests(startDir) {
995
1031
  // Capability-id keyed — never answer for `constructor`/`toString`. The ids
@@ -7230,7 +7266,6 @@ export function retireInstance(root, name, o = {}) {
7230
7266
  contextDir: meta.repo, workspaceDir: workspaceOf(root), rootDir: root, resolved, priorMeta: meta.capabilityMeta || {},
7231
7267
  });
7232
7268
  }
7233
- const harvested = hookResults?.meta?.["oats.okf"]?.harvested || [];
7234
7269
 
7235
7270
  // ORDINARY retirement must not delete a home whose cleanup did not finish.
7236
7271
  // The failures were already collected above; until now they were read ONLY on
@@ -7439,7 +7474,7 @@ export function retireInstance(root, name, o = {}) {
7439
7474
  }
7440
7475
 
7441
7476
 
7442
- const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, harvested, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
7477
+ const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
7443
7478
  if (self) {
7444
7479
  // The caller is the instance: its process lives in the window we are about to
7445
7480
  // kill. Detach the kill so this function can return and the caller can report
package/lib/schedule.mjs CHANGED
@@ -40,11 +40,12 @@ export function scheduleError(code, message, extra) { return Object.assign(new E
40
40
  * is never observed or executed, and is reported invalid on that job only. */
41
41
  export function shapeError(def) {
42
42
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
43
- if (!["spawn", "command", "wake"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command or wake`;
43
+ if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
44
44
  if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
45
45
  if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
46
46
  if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
47
47
  if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
48
+ if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
48
49
  return null;
49
50
  }
50
51
 
@@ -229,7 +230,14 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
229
230
  if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
230
231
  validateMessage(def.message, "message");
231
232
  out.home = resolve(def.home); out.message = def.message;
232
- } else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command or wake", { field: "kind" });
233
+ } else if (def.kind === "operation") {
234
+ // A provider operation in a running home, resolved when the job runs
235
+ // (oats operation run <layer>:<name> --home): the provider is whatever
236
+ // fills that layer for the home then; nothing about it is stored here.
237
+ if (typeof def.operation !== "string" || !/^(knowledge|messaging|tasks):[a-z][a-z0-9-]*$/.test(def.operation)) throw scheduleError("E_SCHEDULE_INVALID", "operation: <layer>:<name>, e.g. knowledge:harvest", { field: "operation" });
238
+ if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
239
+ out.operation = def.operation; out.home = resolve(def.home);
240
+ } else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
233
241
  return out;
234
242
  }
235
243
 
@@ -320,6 +328,13 @@ function launchSpawn(ws, def, minute, io) {
320
328
  }
321
329
  return run;
322
330
  }
331
+ /** The command job an operation job becomes when it runs: the kernel's own
332
+ * operation runner in the home, which resolves the provider at that moment
333
+ * and reports a launch receipt (instance/home) only for what the provider
334
+ * launched, never for the source home. */
335
+ export function operationAsCommand(def) {
336
+ return { ...def, kind: "command", cwd: def.home, argv: ["oats", "operation", "run", def.operation, "--home", def.home] };
337
+ }
323
338
  /** Parse a command's stdout as one JSON document (remote output can be
324
339
  * multi-line); fall back to the last {...} block; null when nothing parses. */
325
340
  export function parseEnvelopeText(text) {
@@ -355,9 +370,16 @@ function launchCommand(ws, def, io) {
355
370
  if (instance && !home) { const found = findHomesInScope(ws, instance); if (found.length === 1) home = found[0]; }
356
371
  if (envelope.ok && instance && !home) return { kind: "command", launched: true, instance, unconfirmed: true, error: `the envelope names instance ${instance} but no home for it is in this scope's roster; run oats schedule reconcile once it appears` };
357
372
  // A valid ok:false answer that reports an incomplete rollback (a harvest
358
- // spawn whose compensation could not stop or remove everything) is not a
359
- // confirmed failure either.
360
- if (!envelope.ok && reportsRetainedEffects(envelope.error?.message)) return { kind: "command", launched: false, unconfirmed: true, ...(instance ? { instance } : {}), error: `command failed with retained effects: ${envelope.error?.message}`, errorCode: envelope.error?.code };
373
+ // spawn whose compensation could not stop or remove everything), or an
374
+ // operation runner's unconfirmed outcome (timeout, no valid receipt, a
375
+ // receipt contradicted by the exit status), is not a confirmed failure.
376
+ // Whatever name the provider managed to answer travels in error.details
377
+ // so reconcile can adopt it.
378
+ if (!envelope.ok && (UNCONFIRMED_ERROR_CODES.has(envelope.error?.code) || envelope.error?.details?.unconfirmed === true || reportsRetainedEffects(envelope.error?.message))) {
379
+ const partial = envelope.error?.details?.envelope?.result;
380
+ const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
381
+ return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
382
+ }
361
383
  return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
362
384
  }
363
385
  /** One wake. `startIfStopped` false between due minutes (a harness that
@@ -367,6 +389,8 @@ function launchCommand(ws, def, io) {
367
389
  * confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
368
390
  * retained home): the launch's effects are unconfirmed, never a confirmed
369
391
  * failure. Shared by caught spawn errors and ok:false command envelopes. */
392
+ /** Error codes whose meaning is "the effects are unconfirmed" by contract. */
393
+ const UNCONFIRMED_ERROR_CODES = new Set(["E_OPERATION_TIMEOUT", "E_OPERATION_RESULT"]);
370
394
  export function reportsRetainedEffects(message) { return /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || "")); }
371
395
  function performWake(def, io, { startIfStopped = true, canStart = true, reserve } = {}) {
372
396
  const seen = observeHome(def.home, io);
@@ -498,7 +522,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
498
522
  js.lastLaunchedAt = now.toISOString();
499
523
  writeState(ws, st);
500
524
  let run;
501
- try { run = def.kind === "command" ? launchCommand(ws, def, io) : launchSpawn(ws, def, minute, io); }
525
+ try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
502
526
  catch (e) {
503
527
  // spawnInstance compensates its own failures, but its rollback can be
504
528
  // INCOMPLETE (a pane it could not stop, a quarantined home it kept):
@@ -595,7 +619,7 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
595
619
  const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
596
620
  if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
597
621
  else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
598
- } else if (def.kind === "command") {
622
+ } else if (def.kind === "command" || def.kind === "operation") {
599
623
  const named = js.lastRun?.instance ? findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home })) : [];
600
624
  if (named.length === 1) adopted = named[0];
601
625
  else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
@@ -651,7 +675,7 @@ export function updateSchedule(ws, id, spec, io) {
651
675
  return withHostLock(() => withScopeLock(ws, () => {
652
676
  const defs = readDefinitions(ws);
653
677
  if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
654
- const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv]);
678
+ const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation]);
655
679
  const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
656
680
  if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its kind, agent, agentsRoot, repo, purpose, home, cwd and argv cannot change until it ends (cron, tz, task, message, runtime, model and enabled can)`);
657
681
  defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
package/lib/servers.mjs CHANGED
@@ -185,7 +185,9 @@ export function runRemote(target, oatsArgs, io = {}) {
185
185
  let stderr = "";
186
186
  let status = 0;
187
187
  try {
188
- stdout = exec(bin, argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
188
+ // `io.input` (a Buffer) streams to the remote command's stdin: the only
189
+ // way bytes reach a host, as a quoted argument never could.
190
+ stdout = exec(bin, argv, { encoding: "utf8", stdio: [io.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], ...(io.input === undefined ? {} : { input: io.input }), maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
189
191
  } catch (e) {
190
192
  stdout = String(e.stdout || "");
191
193
  stderr = String(e.stderr || e.message || "");
@@ -230,6 +232,7 @@ export function checkRemote(target, io = {}) {
230
232
  launchOptions: list("launchOptions", []),
231
233
  remote: list("remote", []),
232
234
  features: list("features", []),
235
+ operationsApi: probe.operationsApi === 1 ? 1 : null,
233
236
  advertised: Array.isArray(probe.runtimes),
234
237
  };
235
238
  }
@@ -477,7 +480,34 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
477
480
  const { envelope, stderr } = runRemote(target, json(withScope(["status", ...oatsArgs])), io);
478
481
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
479
482
  }
480
- throw serverError("E_USAGE", `--server routes spawn, retire, status, session and okf harvest only (not ${cmd})`);
483
+ if (OPERATIONS_COMMANDS.has(cmd)) {
484
+ // The operations contract (inspect, operation run, use, soul set): the
485
+ // remote must advertise it before anything is sent. An explicit --dir is
486
+ // the exact member context the caller chose and travels as is; without
487
+ // one, a --home selection is left to the host (the home is its own
488
+ // context) and anything else runs in the registered workspace.
489
+ // An exact remote home (or a saved instance name) resolves through its
490
+ // FROZEN route exactly as session attach does: the snapshot's target,
491
+ // and a home/name disagreement is refused; the registration's target is
492
+ // used only for scope requests, which get its workspace as --dir.
493
+ let args = [...oatsArgs];
494
+ const valueOf = (name) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined; };
495
+ let route;
496
+ if (valueOf("--home") !== undefined || valueOf("--instance") !== undefined) {
497
+ route = resolveRoute(serverId, { instance: valueOf("--instance"), home: valueOf("--home") }, cmd);
498
+ target = route.target;
499
+ const ii = args.indexOf("--instance");
500
+ if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
501
+ }
502
+ const remote = checkRemote(target, io);
503
+ if (!Array.isArray(remote.features) || !remote.features.includes("operations") || remote.operationsApi !== 1) {
504
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the operations contract (kernels from ${OPERATIONS_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
505
+ }
506
+ const scoped = args.includes("--dir") || args.includes("--home") ? args : [...args, "--dir", target.workspace];
507
+ const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
508
+ return { envelope: envelope.result && typeof envelope.result === "object" ? { ...envelope, result: { ...envelope.result, server: serverId, ...(route ? { route: { home: route.home, instance: route.snapshot?.instance || null, frozen: !!route.snapshot } } : {}) } } : envelope, stderr };
509
+ }
510
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect, operation, use and soul only (not ${cmd})`);
481
511
  }
482
512
 
483
513
  // ------------------------------------------------------------------- roster
@@ -619,6 +649,9 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
619
649
  /** The kernel version whose probe first advertises the `session-start`
620
650
  * feature; the probe's features list is the actual check. */
621
651
  export const SESSION_START_REMOTE_VERSION = "0.22.9";
652
+ /** The kernel version whose probe first advertises the operations contract. */
653
+ export const OPERATIONS_REMOTE_VERSION = "0.22.16";
654
+ const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
622
655
 
623
656
  /** `session start` on the execution host for a remote instance: the same
624
657
  * route resolution as inspect, refused before any mutation when the remote
@@ -26,6 +26,10 @@ export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
26
26
  // when this agent retires. Disable window navigation in the viewer only.
27
27
  for (const name of ["prefix", "prefix2"]) run(["set-option", "-t", viewer, name, "None"]);
28
28
  run(["set-option", "-t", viewer, "key-table", "oatsview-locked"]);
29
+ // The viewer shows one agent; its status line only repeats that name.
30
+ // Session-scoped on the temporary viewer: the agents' own session and
31
+ // the operator's tmux settings are untouched.
32
+ run(["set-option", "-t", viewer, "status", "off"]);
29
33
  run(["unbind-key", "-a", "-q", "-T", "oatsview-locked"]);
30
34
  run(["bind-key", "-T", "oatsview-locked", "WheelUpPane", "if-shell", "-F", "#{||:#{pane_in_mode},#{mouse_any_flag}}", "send-keys -M", "copy-mode -e; send-keys -M"]);
31
35
  run(["set-option", "-t", viewer, "mouse", "on"]);
@@ -2,7 +2,7 @@
2
2
  "packages": {
3
3
  "oats.okf": {
4
4
  "url": "https://github.com/awebai/oats-okf.git",
5
- "ref": "v1.5.2",
5
+ "ref": "v1.6.0",
6
6
  "path": "oats-package"
7
7
  },
8
8
  "oats.aweb": {
@@ -33,12 +33,33 @@
33
33
  },
34
34
  "capabilities": {
35
35
  "oats.review": "oats.dev",
36
- "oas.okf": { "package": "oats.okf", "capability": "oats.okf" },
37
- "oas.aweb": { "package": "oats.aweb", "capability": "oats.aweb" },
38
- "oas.jira": { "package": "oats.jira", "capability": "oats.jira" },
39
- "oas.linear": { "package": "oats.linear", "capability": "oats.linear" },
40
- "oas.authoring": { "package": "oats.authoring", "capability": "oats.authoring" },
41
- "oas.dev": { "package": "oats.dev", "capability": "oats.dev" },
42
- "oas.review": { "package": "oats.dev", "capability": "oats.review" }
36
+ "oas.okf": {
37
+ "package": "oats.okf",
38
+ "capability": "oats.okf"
39
+ },
40
+ "oas.aweb": {
41
+ "package": "oats.aweb",
42
+ "capability": "oats.aweb"
43
+ },
44
+ "oas.jira": {
45
+ "package": "oats.jira",
46
+ "capability": "oats.jira"
47
+ },
48
+ "oas.linear": {
49
+ "package": "oats.linear",
50
+ "capability": "oats.linear"
51
+ },
52
+ "oas.authoring": {
53
+ "package": "oats.authoring",
54
+ "capability": "oats.authoring"
55
+ },
56
+ "oas.dev": {
57
+ "package": "oats.dev",
58
+ "capability": "oats.dev"
59
+ },
60
+ "oas.review": {
61
+ "package": "oats.dev",
62
+ "capability": "oats.review"
63
+ }
43
64
  }
44
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.12",
3
+ "version": "0.22.16",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",