@awebai/oats 0.41.0 → 0.42.0

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
@@ -53,6 +53,7 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
53
53
 
54
54
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
55
55
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
56
+ import { formatBytes, workRecoveryLines } from "../lib/retire-output.mjs";
56
57
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
57
58
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
58
59
  import { readEvents, setWaiting, incarnationOf } from "../lib/instance-events.mjs";
@@ -155,15 +156,6 @@ const jsonFail = (code, message, details, exit = 1) => { console.log(JSON.string
155
156
  const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result, ...envelopeWarnings() })); };
156
157
  // Text mode (or a JSON answer printed before the read): the warning goes to stderr, never stdout.
157
158
  process.on("exit", () => { const w = runtimeNameWarning(); if (w && !warningDelivered) process.stderr.write(`oats: warning: ${w.message}\n`); });
158
- const formatBytes = (n) => n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MiB` : `${(n / 1024 ** 3).toFixed(1)} GiB`;
159
- /** A retire recovery's copied outputs (untracked/ignored or directory work), named with their size. */
160
- function preservedOutputLines(recovery) {
161
- const outputs = recovery?.outputs;
162
- if (!outputs?.paths?.length) return [];
163
- const shown = outputs.paths.slice(0, 8).map((p) => `${p.path} (${formatBytes(p.bytes)})`);
164
- const more = outputs.paths.length > 8 ? `, and ${outputs.paths.length - 8} more` : "";
165
- return [` copied outputs: ${shown.join(", ")}${more} — ${formatBytes(outputs.bytes)} in total`];
166
- }
167
159
 
168
160
  function shortPath(p) {
169
161
  if (!p) return p;
@@ -2570,11 +2562,7 @@ function retireCmd() {
2570
2562
  console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}`);
2571
2563
  // Preserving work and not saying so leaves the operator believing it is gone,
2572
2564
  // which is most of the harm of deleting it. Name the classes and the path.
2573
- for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
2574
- console.log(`Work that was not committed has been preserved: ${recovery.classes.join(", ")}`);
2575
- console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
2576
- for (const line of preservedOutputLines(recovery)) console.log(line);
2577
- }
2565
+ for (const line of workRecoveryLines(r)) console.log(line);
2578
2566
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2579
2567
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2580
2568
  }
@@ -3725,11 +3713,7 @@ async function serverRouteCmd() {
3725
3713
  if (branch) console.error(branch);
3726
3714
  }
3727
3715
  console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3728
- for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
3729
- console.log(`Work that was not committed has been preserved on ${target.sshHost}: ${(recovery.classes || []).join(", ")}`);
3730
- console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
3731
- for (const line of preservedOutputLines(recovery)) console.log(line);
3732
- }
3716
+ for (const line of workRecoveryLines(r, { host: target.sshHost })) console.log(line);
3733
3717
  if (r.rollbackIncomplete) {
3734
3718
  for (const f of r.rollbackIncomplete) console.error(` ${f}`);
3735
3719
  const branch = failedSpawnBranchLine(r, r.rollbackIncomplete, { host: target.sshHost });
@@ -59,7 +59,8 @@ A self-contained package has an `oats.json`:
59
59
  "hooks": {
60
60
  "spawn": "bin/team-chat-hook.mjs spawn",
61
61
  "retire": "bin/team-chat-hook.mjs retire"
62
- }
62
+ },
63
+ "retirement": { "disposable": { "home": [".team-chat", ".team-chat-id-*"] } }
63
64
  }
64
65
  ```
65
66
 
@@ -153,7 +154,77 @@ A self-contained package has an `oats.json`:
153
154
  and nothing is silently dropped. Without `--force` that state fails closed with
154
155
  `E_UNIDENTIFIED_INSTANCE_HOME` rather than deleting whatever credentials the
155
156
  directory still holds; `--force` removes it and leaves any external state for
156
- the operator to clean up by hand.
157
+ the operator to clean up by hand. Home entries declared in
158
+ `retirement.disposable.home` (below) go with the home: recovery holds no
159
+ copy of them.
160
+ - `retirement.disposable` declares what retirement treats as the provider's
161
+ own state rather than the instance's work. It is a map with two optional
162
+ keys, `home` and `work`, each an array of strings.
163
+ - `home`: provider-owned state that is not the instance's work, left in
164
+ place until the home is removed, and not copied to recovery. The one
165
+ exception is the preservation of a failed spawn in directory mode, which
166
+ copies the whole home, declared entries included. Each entry
167
+ names top-level entries of the instance home:
168
+ - an exact hidden name, `^\.[A-Za-z0-9_][A-Za-z0-9._-]*$` (`.team-chat`);
169
+ - or a prefix, `^\.[A-Za-z0-9_][A-Za-z0-9._-]*-\*$` (`.team-chat-id-*`),
170
+ meaning every top-level entry whose name starts with the text before
171
+ `*`.
172
+
173
+ Refused:
174
+ - anything that is not one hidden top-level name (`notes`, `.aw/keys`,
175
+ `.a*`);
176
+ - an exact name that is `.oats`, `.agents` or `.claude`, or that starts
177
+ with `.oats-events`, `.oats-stop`, `.oats-restart`, `.oats-rollback`,
178
+ `.oats-agents-md`, `.oats-start` or `.oats-attachments`: the top-level
179
+ entries the kernel itself writes in an instance home;
180
+ - a prefix that starts with `.oats-`.
181
+
182
+ An exact name such as `.oats-aweb` is allowed: it is a provider's
183
+ directory. Entries are matched by name, without following symlinks: a
184
+ declared entry that is a symlink is left out and never followed.
185
+
186
+ A matching entry is left out of the home fingerprint that retire compares
187
+ with the spawn baseline, of the home copy and of that copy's
188
+ verification, before and after the retire hooks. A change to declared
189
+ entries alone is therefore not "changed instance-home bytes" and causes
190
+ no copy. When a recovery is written, its receipt names what was left out
191
+ in `workRecovery.notCopied` (names and owners only).
192
+
193
+ The declaration is recorded at spawn, with its owner, in the home's
194
+ retirement baseline, and retire reads it from there only: never from the
195
+ module copy in the home, from the capability as it is today, or from
196
+ `instance.json`. A running instance is unaffected until it is respawned,
197
+ and a home spawned before its capability declared the entries gains no
198
+ exclusion from a package update: its home is copied whole. A missing or
199
+ invalid baseline means no exclusions either.
200
+
201
+ Exclusion means "not copied" and nothing more. Nothing is removed early:
202
+ the entries stay in the home until the home is removed, and retire hooks
203
+ still see them. An incomplete cleanup, or a copy or verification that
204
+ fails, keeps the home with them. A capability that declares nothing has
205
+ its home state copied with the rest of the home.
206
+ - `work`: relative roots under `work/` that the capability generates. In
207
+ worktree mode the roots are kept out of the "untracked or ignored
208
+ worktree bytes" class, so untracked or ignored bytes under them alone do
209
+ not cause a recovery. They are still copied when a copy is made. Like
210
+ `home`, the roots are recorded at spawn. The other work modes do not use
211
+ them.
212
+
213
+ A malformed `retirement` is refused wherever the manifest is read: member
214
+ discovery (`E_WORKSPACE_SCHEMA`), package manifests (`E_PACKAGE_MANIFEST`)
215
+ and the kernel loader refuse the same manifest, with a JSON pointer:
216
+ `/retirement` (no `disposable` map, or a key other than `disposable`,
217
+ `home` and `work`), `/retirement/disposable/<scope>` (not an array of
218
+ strings) or `/retirement/disposable/home/<i>` (an entry outside the
219
+ grammar):
220
+
221
+ ```text
222
+ capability <id> manifest retirement.disposable.home entry "<value>" must name one hidden top-level home entry (".name", or ".prefix-*")
223
+ capability <id> manifest retirement.disposable.home entry "<value>" covers a kernel-owned home entry
224
+ ```
225
+
226
+ This kernel reads the field. What retire does with the entries is in
227
+ [souls-and-instances.md](souls-and-instances.md#retire).
157
228
  - `requires` declares what must exist before the capability works. Two kinds:
158
229
  - a **host command** (`command`), satisfied by a binary on `PATH`;
159
230
  - a **harness package** (`harness` + `package`, optionally `marketplace`),
@@ -293,6 +293,44 @@
293
293
  "type": "string",
294
294
  "pattern": "^[A-Z][A-Z0-9]*_$"
295
295
  }
296
+ },
297
+ "retirement": {
298
+ "type": "object",
299
+ "description": "What retirement treats as the provider's own state rather than the instance's work.",
300
+ "required": ["disposable"],
301
+ "properties": {
302
+ "disposable": {
303
+ "type": "object",
304
+ "properties": {
305
+ "home": {
306
+ "type": "array",
307
+ "description": "Provider-owned state that is not the instance's work, left in place until the home is removed, and not copied to recovery (the preservation of a failed spawn in directory mode is the exception: it copies the whole home, declared entries included). Each entry names top-level entries of the instance home: one hidden name (.aw) or a prefix (.aweb-identity-*, every top-level name that starts with the text before the *). Recorded at spawn: a running instance is unaffected until it is respawned.",
308
+ "items": {
309
+ "type": "string",
310
+ "pattern": "^\\.[A-Za-z0-9_][A-Za-z0-9._-]*(?:-\\*)?$",
311
+ "not": {
312
+ "description": "Home entries the kernel owns cannot be declared.",
313
+ "anyOf": [
314
+ {"pattern": "^\\.(?:oats|agents|claude)$"},
315
+ {"pattern": "^\\.oats-(?:events|stop|restart|rollback|agents-md|start|attachments)"},
316
+ {"pattern": "^\\.oats-.*\\*$"}
317
+ ]
318
+ }
319
+ }
320
+ },
321
+ "work": {
322
+ "type": "array",
323
+ "description": "Relative roots under work/ that a worktree instance's capability generates. Their untracked or ignored bytes do not count as the instance's untracked or ignored worktree bytes; they are still copied when a recovery copy is made.",
324
+ "items": {
325
+ "type": "string",
326
+ "minLength": 1
327
+ }
328
+ }
329
+ },
330
+ "additionalProperties": false
331
+ }
332
+ },
333
+ "additionalProperties": false
296
334
  }
297
335
  },
298
336
  "additionalProperties": false
@@ -2457,6 +2457,25 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2457
2457
  `unestablished`, with a `note` saying why, and retire refuses with
2458
2458
  `E_RUNTIME_ENDPOINT_UNKNOWN`, `--force` included. `notes` repeats either
2459
2459
  case. Read an unknown `state` as not idle.
2460
+ - `notes` says what a retire would copy to recovery (0.42), in up to two
2461
+ strings read from the home's retirement baseline and the work mode, without
2462
+ hashing anything:
2463
+
2464
+ ```text
2465
+ recovery: the home is copied to /w/agents/dev/instances/.oats-retirement/recovery before the home is removed, when it changed since spawn; not copied: .aw, .aweb-identity, .aweb-identity-*, .oats-aweb (oats.aweb)
2466
+ recovery: uncommitted worktree state is copied there too
2467
+ ```
2468
+
2469
+ The first is always present. Its `; not copied: …` tail is there only when
2470
+ the baseline records declared home entries
2471
+ (`retirement.disposable.home`): `not copied: <roots> (<capability>)`, the
2472
+ roots as written in the manifest, in the order the baseline stores them
2473
+ (sorted by capability, then root), one group per capability, groups
2474
+ separated by `; `. At most 16 roots are listed, then `, and N more`.
2475
+ The second is the line above in worktree mode, `recovery: work/ is copied
2476
+ there when it is not empty` in directory mode, and absent in checkout,
2477
+ attached and workspace modes. They are strings in `notes`: no other key
2478
+ changes, and `planRevision` is unaffected.
2460
2479
 
2461
2480
  Plain `retire` keeps a worktree-mode instance's work: the worktree is moved
2462
2481
  (`git worktree move`) to `<deployment>/.agents/worktrees/<repo>/<branch>` (a
@@ -2472,8 +2491,12 @@ A first retire prints the **raw receipt**, not an envelope:
2472
2491
  {"retired":"dev-1","agent":"dev",
2473
2492
  "retention":{"worktree":"retained","movedTo":"/w/.agents/worktrees/one/feat-x","branch":"feat/x","detachedAt":null,"recordedBranch":"agents/dev-1"},
2474
2493
  "worktreeRemoved":false,"branchDeleted":false,"removedDir":true,
2475
- "workRecovery":{"path":"/w/.agents/recovered/dev-1-20260928T111000Z","classes":["untracked"],"bytes":2048,
2476
- "outputs":{"paths":[{"path":"notes.md","bytes":2048}],"bytes":2048}},
2494
+ "workRecovery":{"path":"/w/agents/dev/instances/.oats-retirement/recovery/dev-1-AbC123",
2495
+ "classes":["changed instance-home bytes","untracked or ignored worktree bytes"],"bytes":48444211,
2496
+ "home":{"paths":[{"path":".oats/","bytes":874696},{"path":".agents/","bytes":141312},{"path":"notes/","bytes":2048},{"path":"STATE.md","bytes":512}],"bytes":1018568},
2497
+ "outputs":{"paths":[{"path":"scratch/","bytes":1258291},{"path":"note.txt","bytes":12}],"bytes":1258303},
2498
+ "notCopied":[{"scope":"home","path":".aw","owner":"oats.aweb"}],
2499
+ "afterHooks":{"home":true,"work":false}},
2477
2500
  "childrenStopped":[{"instance":"dev-1-child","home":"/w/agents/dev/instances/dev-1-child","ok":true,"stopped":false,"alreadyIdle":true}],
2478
2501
  "planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","idempotencyKey":"r1","replayed":false}
2479
2502
  ```
@@ -2512,9 +2535,67 @@ A first retire prints the **raw receipt**, not an envelope:
2512
2535
  retire (hooks run, a recovery copied), and neither says that one happened:
2513
2536
  an error here is not proof that nothing happened, nor that a recovery
2514
2537
  exists.
2515
- - `workRecovery` (or `workRecoveries[]`): `{path, classes, bytes, outputs?,
2516
- repoCopy?}`; `outputs: {paths: [{path, bytes}], bytes}` names what was
2517
- copied beyond tracked state, largest first.
2538
+ - `workRecovery`: `{path, classes, bytes, home, outputs?, repoCopy?,
2539
+ notCopied?, afterHooks?}`, present when a recovery was written. One retire
2540
+ writes at most one recovery directory, and `path` is that directory.
2541
+ - `classes` is the union of the classes seen before and after the retire
2542
+ hooks, in first-seen order. `bytes` covers the whole directory,
2543
+ `after-hooks/` included.
2544
+ - `home`, `outputs` and `repoCopy` describe the pre-hook snapshot (the
2545
+ top-level `home/`, `repo/` or `work/`) and are not rewritten by the
2546
+ post-hook pass. So `repoCopy.copied: false` (a home-only snapshot) can
2547
+ appear with `afterHooks.work: true`: the repository copy is then under
2548
+ `after-hooks/repo/` only. A recovery first written after the hooks (the
2549
+ instance was clean before them) has no `after-hooks/`, and those keys
2550
+ describe that one snapshot.
2551
+ - `home`: `{paths: [{path, bytes}], bytes}`. The top-level entries of the
2552
+ `home/` snapshot, largest first, a directory ending in `/`. Always
2553
+ present when a recovery is written.
2554
+ - `outputs`: `{paths: [{path, bytes}], bytes}` names what was copied
2555
+ beyond tracked state, largest first.
2556
+ - `notCopied`: `[{scope, path, owner}]`, sorted by `path`. The home
2557
+ entries that existed at any point of the retire (before or after the
2558
+ hooks) and were left out by a capability's `retirement.disposable.home`
2559
+ declaration ([capabilities.md](capabilities.md#manifest)), by their real
2560
+ names: a prefix that matches several entries lists each one, and a
2561
+ declared entry that does not exist is not listed. `owner` is the
2562
+ declaring capability; when two declare the same entry it is the first in
2563
+ capability-name order. Each entry has exactly these three keys: names and
2564
+ owners only, no sizes, hashes, modes or contents of what was left out.
2565
+ `scope` is always `"home"` in this release. Present only when there is
2566
+ at least one.
2567
+ - `afterHooks`: `{home: boolean, work: boolean}`, saying which parts were
2568
+ copied again under `after-hooks/`: `after-hooks/home/` when a retire hook
2569
+ changed the home (its bytes and permission bits, the kernel's own
2570
+ records included), and `after-hooks/repo/` (worktree mode) or
2571
+ `after-hooks/work/` (directory mode) unless the work is proven unchanged
2572
+ after the hooks: a directory by its bytes and bits, a worktree by its Git
2573
+ state and the bytes and bits of its files
2574
+ ([souls and instances](souls-and-instances.md#retire)). A worktree that
2575
+ holds a repository is never proven unchanged: for it `work: true` says
2576
+ that a work copy was made after the hooks, not that a hook changed the
2577
+ work. `work` is also `true` when the pre-hook snapshot held the home
2578
+ only and the work was copied for the first time after the hooks, moved
2579
+ or not, because something beyond the home was there to preserve. The
2580
+ home is not copied again because the work is.
2581
+ Each part is whole and verified, not a delta, but `after-hooks/` is not
2582
+ a complete picture of the instance after the hooks: with `home: false`
2583
+ the recovery's home is the pre-hook one, and it holds the kernel's own
2584
+ records (the event log, the stop and restart receipts, listed in
2585
+ [souls and instances](souls-and-instances.md#retire)) as of then.
2586
+ Present only when that directory was written.
2587
+ - `workRecoveries` is no longer emitted. An older kernel on a server may
2588
+ still send `workRecoveries[]` beside `workRecovery` (one `{path, classes,
2589
+ bytes, outputs?, repoCopy?}` per recovery directory it wrote), so a
2590
+ reader must keep accepting it. A kernel before 0.42 sends no `home`,
2591
+ `notCopied` or `afterHooks`.
2592
+ - `recovery.json` inside the directory stays `version: 1`. It carries
2593
+ `phase` (`"before-hooks"`, then `"complete"` once the post-hook check has
2594
+ concluded), `home` and, when they apply, `notCopied` and `afterHooks`.
2595
+ `phase` is not in the receipt: a receipt is produced only once that check
2596
+ has concluded. A retried retire writes its own recovery and its receipt
2597
+ names only that one
2598
+ ([souls-and-instances.md](souls-and-instances.md#retire)).
2518
2599
  - When they apply: `rollbackIncomplete` and `retainedHome` (cleanup
2519
2600
  incomplete, home kept, exit 1), `forcedIncomplete`, `relinked`,
2520
2601
  `capabilityMeta`, `warnings`, `wakeSchedulesRemoved`.
@@ -2535,7 +2616,9 @@ A first retire prints the **raw receipt**, not an envelope:
2535
2616
 
2536
2617
  Refusals (envelopes): `E_PLAN_STALE`, `E_CHILDREN_RUNNING`,
2537
2618
  `E_WORK_PRESERVATION_FAILED` (the home is kept; retry or
2538
- `--discard-worktree`), `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2619
+ `--discard-worktree`), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2620
+ message names the entry or the state that could not be read),
2621
+ `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2539
2622
  `E_NO_ROOT`, `E_LIFECYCLE_FAILED`. A recovery whose Git status disagrees with
2540
2623
  the source's carries `details: {home, statusDisagreement: {repo, rows: [{path,
2541
2624
  source, recovery}], total}}` (the first 10 paths). Usage errors are text on
package/docs/desktop.md CHANGED
@@ -77,16 +77,26 @@ Opened from Finder or the Dock, an app inherits launchd's PATH
77
77
  the CLI's `#!/usr/bin/env node`. At startup the Desktop therefore runs your
78
78
  login shell once (`$SHELL -ilc`, 3 s timeout) and puts its PATH in front of
79
79
  the inherited one, so the CLI probe, every `oats` call, a CLI picked with
80
- **Choose oats…**, and the tmux server and terminals the Desktop starts all run
81
- with your shell's PATH. Only PATH is taken from the shell, never the rest of
82
- its environment. The shell is started with your environment, without what the
80
+ **Choose oats…**, and the terminals the Desktop starts all run with your
81
+ shell's PATH. Only PATH is taken from the shell, never the rest of its
82
+ environment. The OATS tmux server is different: when a spawn or a start the
83
+ Desktop runs has to start it, the CLI gives it your whole login environment,
84
+ read from your login shell
85
+ ([execution-targets.md](execution-targets.md#the-servers-start-environment)),
86
+ and falls back to the environment the Desktop gave the CLI (with this PATH)
87
+ only when that cannot be read, saying so on stderr. The shell is started with your environment, without what the
83
88
  Desktop or its packaging added to its own (on the AppImage, the entries under
84
89
  its mount), like every other program the Desktop starts. If the shell fails, times out or prints no PATH, the
85
90
  inherited PATH stays: the backend's `/api/cli` reports `pathSource`
86
91
  (`login-shell` or `inherited`), `pathError` (why, or `null`) and
87
92
  `probePath` (the PATH the probe used), and the reason is logged at startup.
88
- A tmux server that was already running keeps its own environment; restart it
89
- if its sessions should get the new PATH, which ends its sessions: `tmux -L oats kill-server` for the OATS tmux server, where instances run, and `tmux kill-server` for your default server, where an instance started by an earlier kernel may still be.
93
+ An agent's pane takes its tmux session's or server's PATH, whoever opens its
94
+ window, and its harness is looked up there
95
+ ([execution-targets.md](execution-targets.md#the-servers-start-environment)):
96
+ a window the Desktop opens on a server that already runs gets that server's
97
+ PATH, not the Desktop's. A tmux server that was already running keeps its own
98
+ environment; restart it if its sessions should get the new PATH, which ends
99
+ its sessions: `tmux -L oats kill-server` for the OATS tmux server, where instances run, and `tmux kill-server` for your default server, where an instance started by an earlier kernel may still be.
90
100
 
91
101
  ## Opening a workspace
92
102