@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 +37 -3
- package/docs/design/2026-09-07-architecture-reassessment.md +131 -0
- package/docs/desktop.md +16 -0
- package/docs/execution-targets.md +21 -0
- package/docs/release-notes/v0.22.13.md +44 -0
- package/docs/release-notes/v0.22.14.md +48 -0
- package/lib/attachments.mjs +164 -0
- package/lib/servers.mjs +3 -1
- package/lib/session-viewer.mjs +4 -0
- package/package.json +1 -1
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
|
|
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]
|
|
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
|
-
|
|
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 || "");
|
package/lib/session-viewer.mjs
CHANGED
|
@@ -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.
|
|
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",
|