@awebai/oats 0.22.12 → 0.22.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -43,6 +43,7 @@ import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, star
43
43
  import { spawnSync as spawnSyncProc } from "node:child_process";
44
44
  import { scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
45
45
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
46
+ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
46
47
 
47
48
  const args = process.argv.slice(2);
48
49
  const cmd = args[0];
@@ -2960,7 +2961,19 @@ async function sessionCmd() {
2960
2961
  if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
2961
2962
  if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
2962
2963
  result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
2963
- } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start --home /absolute/home [--text-file path] [--model id] [--json]"), { code: "E_BAD_ARGS" });
2964
+ } else if (args[1] === "receive") {
2965
+ // Bytes arrive on stdin (the routed upload pipes them through ssh),
2966
+ // collected event-driven and bounded before anything is written.
2967
+ const name = flag("name");
2968
+ if (!name || name === true) throw Object.assign(new Error("session receive needs --name <file name>"), { code: "E_BAD_ARGS" });
2969
+ if (!home || home === true) throw Object.assign(new Error("session receive needs --home </absolute/instance>"), { code: "E_BAD_ARGS" });
2970
+ if (process.stdin.isTTY) throw Object.assign(new Error("session receive reads the attachment bytes from stdin"), { code: "E_BAD_ARGS" });
2971
+ result = receiveAttachment(home, name, await readStreamBounded(process.stdin, MAX_ATTACHMENT_BYTES));
2972
+ } else if (args[1] === "upload") {
2973
+ const file = flag("file");
2974
+ if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
2975
+ result = uploadAttachment({ file, home: home === true ? undefined : home });
2976
+ } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start|receive|upload --home /absolute/home [--text-file path] [--model id] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
2964
2977
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2965
2978
  } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
2966
2979
  }
@@ -3255,7 +3268,7 @@ function versionCmd() {
3255
3268
  // on it (an older CLI without the surface must fail closed with a
3256
3269
  // reason, not an argument error). `features`: kernel abilities a peer
3257
3270
  // must see before relying on them (retire-home: retire --home).
3258
- 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", "session-start", "roster", "harvest", "schedule"], features: ["retire-home", "session-start", "schedule"], scheduleApi: SCHEDULE_API }));
3271
+ 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", "session-start", "roster", "harvest", "schedule", "session-upload"], features: ["retire-home", "session-start", "schedule", "session-upload"], scheduleApi: SCHEDULE_API }));
3259
3272
  return;
3260
3273
  }
3261
3274
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3462,7 +3475,19 @@ function serverRouteCmd() {
3462
3475
  console.log(`Started ${r.instance || r.home} on ${id} (${r.backend}${r.model ? `, model ${r.model}` : ""}, ${r.reused === "pane" ? "in its existing pane" : r.reused === "adopted" ? "adopted the pending session" : "new window"})`);
3463
3476
  return;
3464
3477
  }
3465
- if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3478
+ if (args[1] === "upload") {
3479
+ // Bytes stream to `session receive` on the execution host over the
3480
+ // same route as attach; the answer's checksum is verified here.
3481
+ const file = flag("file");
3482
+ if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
3483
+ let r;
3484
+ try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message); }
3485
+ if (r.stderr) process.stderr.write(r.stderr + "\n");
3486
+ if (JSON_MODE) { jsonOk(r); return; }
3487
+ console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
3488
+ return;
3489
+ }
3490
+ if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start`, `session upload` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3466
3491
  let route;
3467
3492
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3468
3493
  catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
@@ -3664,6 +3689,10 @@ Usage:
3664
3689
  oats session start --server <id> start a stopped remote instance in its existing home
3665
3690
  --instance <name> | --home <abs> over its saved route; the server must advertise
3666
3691
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3692
+ oats session upload --server <id> copy a local file into a remote instance's private
3693
+ --instance <name> | --home <abs> attachments over its saved route (bytes stream on
3694
+ --file <path> [--json] ssh stdin; sha256 verified); the server must
3695
+ advertise session-upload (oats 0.22.13 or later)
3667
3696
  oats create <name> [--local] create an agent soul; --local = full
3668
3697
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3669
3698
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
@@ -3676,6 +3705,11 @@ Usage:
3676
3705
  routes to that host's workspace
3677
3706
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3678
3707
  bound to the new instance's home (docs/schedules.md)
3708
+ oats session upload --file <path> store a copy of a local file as a private attachment
3709
+ --home <absolute-home> [--json] in the instance home (.oats-attachments/); answers
3710
+ {path, bytes, sha256}; never types into the session
3711
+ oats session receive --home <abs> --name store attachment bytes read from stdin (the routed
3712
+ <file> [--json] upload's remote half)
3679
3713
  oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3680
3714
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3681
3715
  spawn hooks); --model replaces the recorded model
@@ -0,0 +1,131 @@
1
+ # Architecture reassessment: usable agents with replaceable services
2
+
3
+ Assessment of main `76c0ea4`, September 7, 2026. Requested by Juan after
4
+ operating the Desktop; incorporates Pepe's terminal, shortcut, split and soul
5
+ management feedback. This records the direction and implementation gaps, not a
6
+ claim that every item below has shipped.
7
+
8
+ The requirements authority for this assessment is Juan's September 3
9
+ “Architecture simplification and generlization” in his OATS project KB:
10
+ souls carry instructions and capabilities; a harvester converts ephemeral
11
+ state into knowledge accessible through the selected knowledge capability;
12
+ packages distribute souls and capabilities; OATS constructs and operates
13
+ instances across runtimes and platforms. The private Bookshelf README points
14
+ to September 1's adoption/sovereignty strategy. Its relevant constraint is that
15
+ changing a runtime or service provider must not erase the team's relationships
16
+ or working knowledge. The older August proposals about genomes, clothes and
17
+ turn-record experiments are background, not reasons to expand this release.
18
+
19
+ ## Decision
20
+
21
+ Keep the existing package and capability-layer mechanism. Correct the places
22
+ where the CLI and GUI bypass it. The architecture is broadly right; the
23
+ operator experience and some service boundaries are incomplete. More
24
+ abstractions will not fix a terminal that cannot accept a screenshot.
25
+
26
+ | Component | Owns | Must not assume |
27
+ |---|---|---|
28
+ | OATS kernel | Soul/instance construction, configuration, lifecycle, host placement, scheduling, capability resolution | A particular knowledge provider, messaging service, or GUI |
29
+ | Session backend | Durable terminal, attach/detach, resize, literal input, observation | OATS soul/package semantics or task success |
30
+ | Knowledge capability | Knowledge access and representation; its harvester and promotion policy | Every installation uses OKF or stores memory in the same files |
31
+ | Messaging capability / aweb | Durable messages, identities, event subscriptions and delivery policy | A particular model harness or an open Desktop window |
32
+ | Task capability | Durable work and task semantics | A particular terminal backend |
33
+ | Desktop | Inspect effective configuration; explicit operator actions; terminal input, files, layout | Provider names, SSH commands, or a second lifecycle implementation |
34
+
35
+ Scheduling belongs to OATS, and a scheduled harvest is one use of scheduling.
36
+ Harvesting behavior belongs to the knowledge capability. Starting an agent,
37
+ writing a prompt, and producing reviewed knowledge are different outcomes.
38
+ The UI must retain those distinctions.
39
+
40
+ ## What already fits, and what does not
41
+
42
+ `lib/core.mjs` already selects exclusive knowledge/messaging/tasks layers by
43
+ manifest, resolves scoped capability settings, dispatches lifecycle hooks, and
44
+ materializes runtime instructions. Jira and Linear demonstrate that a layer
45
+ can have alternative implementations. OKF's harvester is already an exported
46
+ capability agent. There is no reason to replace this machinery.
47
+
48
+ The remaining coupling is concrete:
49
+
50
+ - The retire receipt reads `hookResults.meta["oats.okf"].harvested`, although
51
+ current OKF does not supply a retire hook. Remove the dead provider-specific
52
+ inference; do not invent successful harvesting at retirement.
53
+ - Remote capability routing recognizes `okf harvest` specifically in
54
+ `bin/oats.mjs` and `lib/servers.mjs`.
55
+ - Desktop harvest actions and scheduled-harvest definitions construct
56
+ `oats okf harvest`; the editor recognizes that exact argv.
57
+ - The “brain” view presents `STATE.md`, `log.md` and `notes/` as universal
58
+ knowledge structure. These are conventions of the current setup.
59
+
60
+ The next service contract should expose the **effective knowledge provider and
61
+ its supported operations for a specific soul/home**. Discovering a capability
62
+ command namespace alone is insufficient: it does not promise a `harvest` verb
63
+ or a shared file layout. A provider may offer harvest, different operations,
64
+ or read-only access. Desktop must show only declared operations and route
65
+ execution through the selected CLI capability. The same resolution must apply
66
+ locally, remotely, and when a scheduled operation runs.
67
+
68
+ Keep that change small: explicit operation metadata, scoped discovery, and one
69
+ invocation route using the existing capability engine. Do not add a plugin
70
+ framework, generic workflow language, or a second knowledge store. Demonstrate
71
+ replaceability with a tiny alternative-provider fixture that uses a different
72
+ command and storage layout; a full second product is unnecessary.
73
+
74
+ ## tmux and Herdr
75
+
76
+ The current kernel defaults to a shared tmux session (`pi-agents`) with a
77
+ window per instance. An agent does not require another tmux server. The GUI
78
+ creates a temporary session linking only the selected window. That isolates
79
+ viewers: switching a terminal elsewhere cannot redirect a GUI tab to another
80
+ agent, and an agent exiting cannot silently expose a sibling under its label.
81
+ The `oatsdesk-*` name in Juan's screenshot is this viewer. Its status bar can
82
+ be hidden with a viewer-local setting. Do not alter global tmux settings or
83
+ merge/move live agent sessions to solve a display defect.
84
+
85
+ Herdr 0.8.2 is a separate terminal runtime, not a tmux-compatible superset.
86
+ Its documented direct terminal attachment, socket API, events and SSH support
87
+ are useful. Its semantic agent state comes from detection and/or integrations;
88
+ it is richer evidence, not proof that a particular message was consumed.
89
+ The [socket API](https://herdr.dev/docs/socket-api/) explicitly distinguishes
90
+ agent-state waits from arbitrary command completion. The
91
+ [remote documentation](https://herdr.dev/docs/persistence-remote/) also
92
+ separates a local thin client from running a client entirely on the server;
93
+ only the former can directly bridge the local desktop clipboard.
94
+
95
+ Keep one OATS session contract and both existing adapters for now. Do not add
96
+ another supervisor above them. Qualify Herdr's actual literal multi-line
97
+ input, exact occupant checks, detach, resize, process exit and remote behavior
98
+ before selecting it as the default for new instances. Inspect `pane run`
99
+ implementation before replacing it merely because its name sounds like shell
100
+ execution. Removing tmux is a later deployment choice, not a prerequisite for
101
+ consistent UI or remote agents. Existing tmux agents stay where they are.
102
+
103
+ ## Desktop correction sequence
104
+
105
+ 1. **Terminal essentials.** Hide viewer chrome, accept dropped files and pasted
106
+ images without pressing Enter, upload remote files to the execution host,
107
+ preserve the destination tab across async work, and show transfer failures.
108
+ Keep a single GUI and bounded viewers. This is the immediate implementation.
109
+ 2. **Keyboard and splits.** Restore Ctrl+Tab navigation inside Mac terminals;
110
+ let an already-open terminal fill an empty split without a second attach;
111
+ expose draggable and keyboard-operable separators. Follow up specific
112
+ keyboard reports with real input tests, especially terminal editing keys
113
+ and non-US layouts. App shortcuts must not silently steal terminal edits.
114
+ 3. **Souls & capabilities.** Replace the launch-centric roster with a management
115
+ surface showing souls, installed capabilities, effective layer providers,
116
+ source/version, scoped activation and runtime defaults. Inspect and launch
117
+ are distinct actions. Edits use existing CLI operations with the exact scope
118
+ visible; installing a capability must not silently activate it. This is not
119
+ a marketplace or Team Builder project.
120
+ 4. **Provider-neutral operations.** Introduce the scoped operation contract
121
+ above, then use it for manual harvest, schedules and knowledge inspection.
122
+ No second hardcoded default is accepted as a generalization.
123
+ 5. **Operating rollout.** Enable schedules per team with the owner's learning
124
+ policy. Capture-lock recovery is a separate reliability issue affecting
125
+ unattended harvesting; it does not block arbitrary scheduled wakes.
126
+
127
+ Acceptance must include real Electron file objects, actual terminal bytes,
128
+ remote transfer hashes, split focus/resize without extra PTYs, and a provider
129
+ replacement fixture. Unit tests of mocked argv alone do not establish these
130
+ boundaries. Reuse the existing agents and GUI sparingly; no model is needed to
131
+ exercise attachment transport or terminal layout.
package/docs/desktop.md CHANGED
@@ -211,3 +211,19 @@ certificate auto-discovery disabled) and
211
211
  marked build-verify mode (inventory + strict codesign verification +
212
212
  node-pty ABI, no GUI launch); a local
213
213
  interactive run may also exercise the launch phase.
214
+
215
+ ### Attach files and screenshots
216
+
217
+ Drop a file onto an agent terminal to insert its path into that agent's draft.
218
+ Pasting an image from the clipboard uses the same attachment path. Neither
219
+ operation presses Enter. Text paste continues to use the terminal's normal
220
+ paste behavior. A drop targets the pane under the pointer, including a visible
221
+ pane in a split.
222
+
223
+ Local files are referenced in place. Clipboard images are saved privately in
224
+ Desktop's application-data `attachments` directory and retained so an agent can
225
+ read them later. For remote terminals, Desktop calls the installed CLI's
226
+ `session upload` operation; it inserts the returned path only after the file
227
+ has reached the execution host. Both CLI installations must advertise
228
+ `session-upload`. A failed transfer leaves the draft unchanged and shows an
229
+ error in the terminal. Each drop/paste accepts up to 16 files totaling 25 MB.
@@ -181,6 +181,27 @@ pane in that exact window. Herdr additionally verifies the original terminal ID.
181
181
  The broker owns busy/approval policy and must not interpret `submitted` as
182
182
  processing acknowledgement.
183
183
 
184
+ ### Attachments
185
+
186
+ A viewer that drops a file or pastes an image gives the agent a path, never
187
+ terminal input. `oats session upload --file <local> --home <abs>` stores a
188
+ copy as a private file under `<home>/.oats-attachments/` (directory 0700,
189
+ file 0600, `name-2` on collision) and answers `{path, bytes, sha256}`; the
190
+ caller pastes `path` into the still-live session itself. With `--server <id>`
191
+ and `--instance <name>` (or `--home`), the same saved route as attach is
192
+ resolved, the remote must list `session-upload` in both its `remote` and
193
+ `features` probe arrays, and the bytes travel on ssh stdin into `oats session
194
+ receive --home <abs> --name <file>` on the execution host. Each side holds
195
+ the whole file in memory up to the 64 MiB kernel bound (this is a bounded
196
+ transfer, not end-to-end streaming); the receiver reads stdin event-driven,
197
+ refuses above the bound before writing, and allocates the destination
198
+ exclusively (`name-2`, `name-3` when taken), so simultaneous uploads of one
199
+ name never overwrite each other and a planted symlink is never followed. The
200
+ attachments directory must be a real directory inside the home. The local
201
+ side refuses the result unless the remote's size and sha256 equal the local
202
+ file's. A remote file is never a local path over SSH. Attachments live and
203
+ die with the home.
204
+
184
205
  Capability spawn hooks register a pending home before runtime allocation;
185
206
  inspection becomes available once its receipt is persisted. Retire hooks
186
207
  unregister after quiescence. The broker must tolerate this lifecycle order and
@@ -0,0 +1,44 @@
1
+ # OATS v0.22.13
2
+
3
+ Terminal essentials for the Desktop and one kernel operation behind them:
4
+ attachments reach the agent as a path, never as typed input.
5
+
6
+ ## Attachments (kernel)
7
+
8
+ `oats session upload --file <local> --home <abs>` copies a file into the
9
+ instance home's private `.oats-attachments/` directory (0700, file 0600,
10
+ `name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
11
+ `--server <id> --instance <name>` (or `--home`), the same saved route as
12
+ `session attach` is resolved, the remote must advertise the new
13
+ `session-upload` feature, and the bytes travel on ssh stdin into
14
+ `oats session receive --home <abs> --name <file>` on the execution host,
15
+ which reads stdin event-driven, enforces the 64 MiB kernel bound before
16
+ writing anything, allocates the destination exclusively so simultaneous
17
+ uploads never overwrite each other, refuses a symlinked or redirected
18
+ attachments directory, and reports the path only after the bytes are
19
+ synced. The local side refuses the answer unless size and sha256 equal the
20
+ local file's. Each side buffers the whole file up to the bound; a remote
21
+ file is never a local path over SSH. Upload never sends terminal input.
22
+ `oats version --json` lists `session-upload` in `remote` and `features`.
23
+ The CLI's temporary tmux viewer session hides its status line; the agents'
24
+ session and the operator's tmux settings are untouched.
25
+
26
+ ## Desktop
27
+
28
+ Drop files or paste clipboard images onto an agent terminal: the paths are
29
+ inserted into the draft with bracketed paste and no Enter, local files in
30
+ place, clipboard images saved privately under the app's data directory, and
31
+ remote terminals uploaded through the installed CLI first (both CLIs must
32
+ advertise `session-upload`; a failed transfer leaves the draft unchanged and
33
+ never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
34
+ tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
35
+ on macOS while other Ctrl chords stay with the program. An already-open
36
+ terminal fills an empty split without a second attach; separators resize by
37
+ pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
38
+
39
+ ## Design
40
+
41
+ docs/design/2026-09-07-architecture-reassessment.md records the direction:
42
+ keep the package and capability-layer machinery, fix the places where the
43
+ CLI and GUI bypass it, one session contract with both tmux and Herdr
44
+ adapters until Herdr is qualified, and the Desktop correction sequence.
@@ -0,0 +1,48 @@
1
+ # OATS v0.22.14
2
+
3
+ The same reviewed content as the unpublished v0.22.13 tag, whose hosted
4
+ run failed in one Desktop test pin before anything was published; the
5
+ test now checks the behaviour instead of the source text.
6
+
7
+ Terminal essentials for the Desktop and one kernel operation behind them:
8
+ attachments reach the agent as a path, never as typed input.
9
+
10
+ ## Attachments (kernel)
11
+
12
+ `oats session upload --file <local> --home <abs>` copies a file into the
13
+ instance home's private `.oats-attachments/` directory (0700, file 0600,
14
+ `name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
15
+ `--server <id> --instance <name>` (or `--home`), the same saved route as
16
+ `session attach` is resolved, the remote must advertise the new
17
+ `session-upload` feature, and the bytes travel on ssh stdin into
18
+ `oats session receive --home <abs> --name <file>` on the execution host,
19
+ which reads stdin event-driven, enforces the 64 MiB kernel bound before
20
+ writing anything, allocates the destination exclusively so simultaneous
21
+ uploads never overwrite each other, refuses a symlinked or redirected
22
+ attachments directory, and reports the path only after the bytes are
23
+ synced. The local side refuses the answer unless size and sha256 equal the
24
+ local file's. Each side buffers the whole file up to the bound; a remote
25
+ file is never a local path over SSH. Upload never sends terminal input.
26
+ `oats version --json` lists `session-upload` in `remote` and `features`.
27
+ The CLI's temporary tmux viewer session hides its status line; the agents'
28
+ session and the operator's tmux settings are untouched.
29
+
30
+ ## Desktop
31
+
32
+ Drop files or paste clipboard images onto an agent terminal: the paths are
33
+ inserted into the draft with bracketed paste and no Enter, local files in
34
+ place, clipboard images saved privately under the app's data directory, and
35
+ remote terminals uploaded through the installed CLI first (both CLIs must
36
+ advertise `session-upload`; a failed transfer leaves the draft unchanged and
37
+ never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
38
+ tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
39
+ on macOS while other Ctrl chords stay with the program. An already-open
40
+ terminal fills an empty split without a second attach; separators resize by
41
+ pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
42
+
43
+ ## Design
44
+
45
+ docs/design/2026-09-07-architecture-reassessment.md records the direction:
46
+ keep the package and capability-layer machinery, fix the places where the
47
+ CLI and GUI bypass it, one session contract with both tmux and Herdr
48
+ adapters until Herdr is qualified, and the Desktop correction sequence.
@@ -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/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 || "");
@@ -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"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.12",
3
+ "version": "0.22.14",
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",