@awebai/oats 0.41.1 → 0.42.1

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 { extraWorktreeLines, 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;
@@ -2506,7 +2498,7 @@ function retireCmd() {
2506
2498
  const planRev = flag("plan-revision"), idemKey = flag("idempotency-key");
2507
2499
  if (planRev === true || idemKey === true) die("--plan-revision and --idempotency-key need values");
2508
2500
  if ((planRev !== undefined) !== (idemKey !== undefined)) die("--plan-revision and --idempotency-key go together");
2509
- let replayPath = null, childrenStopped = null;
2501
+ let replayPath = null, childrenStopped = null, plannedExtraWorktrees;
2510
2502
  if (planRev !== undefined) {
2511
2503
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idemKey)) die("--idempotency-key: 1-128 chars of [A-Za-z0-9._:-]");
2512
2504
  // Replay first: after a successful retire the home is gone, so the receipt
@@ -2516,6 +2508,9 @@ function retireCmd() {
2516
2508
  let fresh;
2517
2509
  try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.details) : die(e.message); }
2518
2510
  replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
2511
+ // The extra trees the confirmed plan names, as it names them: the retire refuses as stale rather than
2512
+ // move or remove one that no longer reads that way when it gets to them.
2513
+ plannedExtraWorktrees = fresh.facts.extraWorktrees;
2519
2514
  if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
2520
2515
  // The plan promised: recorded children are STOPPED first (bounded, never
2521
2516
  // escalated) and retained. A child still running after the grace refuses
@@ -2529,7 +2524,7 @@ function retireCmd() {
2529
2524
  if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
2530
2525
  }
2531
2526
  let r;
2532
- try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") }); }
2527
+ try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(plannedExtraWorktrees ? { plannedExtraWorktrees } : {}) }); }
2533
2528
  catch (e) { if (!e?.code) throw e; return args.includes("--json") ? jsonFail(e.code, e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
2534
2529
  if (childrenStopped) r.childrenStopped = childrenStopped;
2535
2530
  if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
@@ -2570,11 +2565,8 @@ function retireCmd() {
2570
2565
  console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}`);
2571
2566
  // Preserving work and not saying so leaves the operator believing it is gone,
2572
2567
  // 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
- }
2568
+ for (const line of workRecoveryLines(r)) console.log(line);
2569
+ for (const line of extraWorktreeLines(r)) console.log(line);
2578
2570
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2579
2571
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2580
2572
  }
@@ -3725,11 +3717,8 @@ async function serverRouteCmd() {
3725
3717
  if (branch) console.error(branch);
3726
3718
  }
3727
3719
  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
- }
3720
+ for (const line of workRecoveryLines(r, { host: target.sshHost })) console.log(line);
3721
+ for (const line of extraWorktreeLines(r, { host: target.sshHost })) console.log(line);
3733
3722
  if (r.rollbackIncomplete) {
3734
3723
  for (const f of r.rollbackIncomplete) console.error(` ${f}`);
3735
3724
  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
@@ -1923,7 +1923,11 @@ route target:
1923
1923
  "running":true,"identity":{"alias":"dev-a","address":"acme/dev-a"},"identityAddress":"acme/dev-a",
1924
1924
  "teams":[{"label":"default","team":"acme:team"}],"startedAt":"2026-09-29T10:00:00.000Z","createdAt":"2026-09-29T09:58:12.004Z",
1925
1925
  "model":"opus","runtimeState":null,"parentInstance":"lead","siblingInstance":null,"relation":"child","relativeTo":"lead",
1926
- "spawnOrigin":"instance","retirePending":false,"rollbackIncomplete":false,
1926
+ "spawnOrigin":"instance","work":"worktree","repo":"/srv/team/ws","branch":"agents/dev-a","modelFrom":"soul",
1927
+ "soul":{"repoKey":"github.com/acme/team","commit":"66566512…","current":"9c1e04ab…","status":"moved"},
1928
+ "modules":[{"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1929
+ "commit":"ab897841…","current":{"commit":"ab897841…","version":"2.1.3"},"status":"current"}],
1930
+ "retirePending":false,"rollbackIncomplete":false,
1927
1931
  "savedRoute":false,"addressable":true,"missingRemotely":false}],
1928
1932
  "retireFailures":[]}
1929
1933
  ```
@@ -1940,9 +1944,16 @@ route target:
1940
1944
  - **Instance rows** relay the host's own `status --json` row: `identity`,
1941
1945
  `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
1942
1946
  `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
1943
- `relativeTo` and `spawnOrigin` are always present, `null` when the host
1944
- does not supply them (a host before 0.31, a fact it never recorded, or a
1945
- saved route the host no longer lists). Nothing is derived on this side.
1947
+ `relativeTo` and `spawnOrigin`, and (relayed from 0.42.1) `work`, `repo`,
1948
+ `branch`, `modelFrom`, `soul` and `modules`, are always present, `null`
1949
+ when the host does not supply them (an older host, a fact it never
1950
+ recorded, or a saved route the host no longer lists). Each is the
1951
+ [local row's](#the-roster-oats-status---json) fact of the same name,
1952
+ relayed as the host answered it. Nothing is derived on this side: `repo`
1953
+ is a path on the host, never read here, and `soul` and `modules` are the
1954
+ host's own drift observation (against its own members and lock), not
1955
+ recomputed: `modules` is the drift rows, or the recorded map when the host
1956
+ could not read its workspace, as on a local row.
1946
1957
  - **`waitingOnYou`** (0.40.2, [Waiting on you](#waiting-on-you)) is on a row
1947
1958
  only when the host's kernel reports it: a row from a host before 0.40.0,
1948
1959
  and a saved route the host did not list, have no such key. Absent means
@@ -2219,6 +2230,15 @@ oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--
2219
2230
  `--delete-branch` (since 0.41.0: `this self-retire was requested with
2220
2231
  --delete-branch by an older OATS; retirement no longer deletes branches, so
2221
2232
  the branch and the worktree were left`).
2233
+ - `worktree-retained` (`data: {movedTo, branch, recordedBranch}`) and
2234
+ `worktree-removed` (`data: {branch}`) are written to the workspace log only,
2235
+ by the retire's worktree step. Since 0.42.1 the retire also writes one per
2236
+ extra tree it handled
2237
+ ([extra trees at retire](souls-and-instances.md#extra-trees-at-retire)),
2238
+ with `extra: true` and the tree's absolute `path` added:
2239
+ `worktree-retained` `{movedTo, branch, recordedBranch: null, extra: true,
2240
+ path}` and `worktree-removed` `{branch, extra: true, path}`. A row without
2241
+ `extra` is about `work/`.
2222
2242
  - **Incarnation.** Each row carries the writing home's `createdAt` (or
2223
2243
  `null` for old rows); the top-level `incarnation` is the current home's (or
2224
2244
  `null`). Earlier incarnations are returned as this address's history.
@@ -2438,7 +2458,9 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2438
2458
  "upstream":{"ref":null,"ahead":null,"behind":null},"base":{"ref":null,"ahead":null,"behind":null},"remote":null},
2439
2459
  "workMode":"worktree","repo":"/w/one","recordedBranch":"agents/dev-1",
2440
2460
  "children":[{"instance":"dev-1-child","agent":"dev","home":"/w/agents/dev/instances/dev-1-child","session":{"state":"shell","present":true,"backend":"tmux","established":true}}],
2441
- "ambiguous":[],"pullRequest":"unknown"},
2461
+ "ambiguous":[],"pullRequest":"unknown",
2462
+ "extraWorktrees":[{"path":"/w/agents/dev/instances/dev-1/.work-docs","repo":"/w/docs","branch":"agents/dev-1-docs","detachedAt":null,
2463
+ "disposition":"retain","movedTo":"/w/.agents/worktrees/docs/agents-dev-1-docs","reason":"…"}]},
2442
2464
  "defaults":{"retainWorktree":true,"deleteBranch":false,"stopChildren":true,"retainChildren":true},
2443
2465
  "planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","notes":["the worktree is on feat/x, not the recorded agents/dev-1; …"]}
2444
2466
  ```
@@ -2457,10 +2479,54 @@ oats retire <instance> --plan [--home <abs>] [--dir <d>] --json
2457
2479
  `unestablished`, with a `note` saying why, and retire refuses with
2458
2480
  `E_RUNTIME_ENDPOINT_UNKNOWN`, `--force` included. `notes` repeats either
2459
2481
  case. Read an unknown `state` as not idle.
2482
+ - `notes` says what a retire would copy to recovery (0.42), in up to two
2483
+ strings read from the home's retirement baseline and the work mode, without
2484
+ hashing anything:
2485
+
2486
+ ```text
2487
+ 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)
2488
+ recovery: uncommitted worktree state is copied there too
2489
+ ```
2490
+
2491
+ The first is always present. Its `; not copied: …` tail is there only when
2492
+ the baseline records declared home entries
2493
+ (`retirement.disposable.home`): `not copied: <roots> (<capability>)`, the
2494
+ roots as written in the manifest, in the order the baseline stores them
2495
+ (sorted by capability, then root), one group per capability, groups
2496
+ separated by `; `. At most 16 roots are listed, then `, and N more`.
2497
+ The second is the line above in worktree mode, `recovery: work/ is copied
2498
+ there when it is not empty` in directory mode, and absent in checkout,
2499
+ attached and workspace modes. They are strings in `notes`: no other key
2500
+ changes, and `planRevision` is unaffected.
2501
+ - `facts.extraWorktrees` (0.42.1) lists the instance's
2502
+ [extra trees](souls-and-instances.md#extra-trees-at-retire): linked
2503
+ worktrees at `<home>/.work-*` that Git confirms. It is an array, empty when
2504
+ there are none, in every work mode. Each row is `{path, repo, branch,
2505
+ detachedAt, disposition, movedTo, reason}`:
2506
+ - `path`: the tree's absolute path in the home.
2507
+ - `repo`: its repository, the first entry of that repository's `git
2508
+ worktree list` (the main worktree, or the bare repository).
2509
+ - `branch`: the branch its HEAD is on, or `null`. `detachedAt`: the commit
2510
+ when HEAD is detached, else `null`.
2511
+ - `disposition`: `"remove"` (the tree is clean: it would be removed, its
2512
+ branch kept), `"retain"` (it would be moved to `movedTo`, as `work/` is
2513
+ retained) or `"refuse"` (the retire would refuse with
2514
+ `E_WORK_PRESERVATION_FAILED` and keep the home).
2515
+ - `movedTo`: the target for `"retain"`, else `null`.
2516
+ - `reason`: `null` for `"remove"`, why the tree is not clean for
2517
+ `"retain"`, why it is refused for `"refuse"`.
2518
+
2519
+ `notes` also carries one string per tree that says the same. The trees are
2520
+ part of `planRevision`: a tree created, removed, dirtied or cleaned between
2521
+ the plan and the apply, or a change of its disposition or target (another
2522
+ directory taking the `<leaf>-N` it would move to, for example), refuses a
2523
+ guarded apply with `E_PLAN_STALE` before anything runs. A reader that does
2524
+ not know the key can ignore it; the `notes` strings say the same.
2460
2525
 
2461
2526
  Plain `retire` keeps a worktree-mode instance's work: the worktree is moved
2462
2527
  (`git worktree move`) to `<deployment>/.agents/worktrees/<repo>/<branch>` (a
2463
- `-2` suffix if taken; `detached-<oid12>` when detached), state intact.
2528
+ `-2` suffix if taken; `detached-<oid12>` when detached), state intact. An
2529
+ extra tree that is not clean is moved the same way; a clean one is removed.
2464
2530
 
2465
2531
  ```text
2466
2532
  oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--home <abs>] --json
@@ -2472,8 +2538,12 @@ A first retire prints the **raw receipt**, not an envelope:
2472
2538
  {"retired":"dev-1","agent":"dev",
2473
2539
  "retention":{"worktree":"retained","movedTo":"/w/.agents/worktrees/one/feat-x","branch":"feat/x","detachedAt":null,"recordedBranch":"agents/dev-1"},
2474
2540
  "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}},
2541
+ "workRecovery":{"path":"/w/agents/dev/instances/.oats-retirement/recovery/dev-1-AbC123",
2542
+ "classes":["changed instance-home bytes","untracked or ignored worktree bytes"],"bytes":48444211,
2543
+ "home":{"paths":[{"path":".oats/","bytes":874696},{"path":".agents/","bytes":141312},{"path":"notes/","bytes":2048},{"path":"STATE.md","bytes":512}],"bytes":1018568},
2544
+ "outputs":{"paths":[{"path":"scratch/","bytes":1258291},{"path":"note.txt","bytes":12}],"bytes":1258303},
2545
+ "notCopied":[{"scope":"home","path":".aw","owner":"oats.aweb"}],
2546
+ "afterHooks":{"home":true,"work":false}},
2477
2547
  "childrenStopped":[{"instance":"dev-1-child","home":"/w/agents/dev/instances/dev-1-child","ok":true,"stopped":false,"alreadyIdle":true}],
2478
2548
  "planRevision":"4e5f6a7b8c9d0e1f2a3b4c5d","idempotencyKey":"r1","replayed":false}
2479
2549
  ```
@@ -2512,9 +2582,84 @@ A first retire prints the **raw receipt**, not an envelope:
2512
2582
  retire (hooks run, a recovery copied), and neither says that one happened:
2513
2583
  an error here is not proof that nothing happened, nor that a recovery
2514
2584
  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.
2585
+ - `extraWorktrees` (0.42.1): the extra trees the retire handled, present only
2586
+ when it handled at least one. Each row is the plan's row (`{path, repo,
2587
+ branch, detachedAt, disposition, movedTo, reason}`) plus `outcome`:
2588
+ `"removed"` or `"retained"`, with `movedTo` where a retained tree went. The
2589
+ step runs only when the home is removed (not with `--keep-dir`, not when the
2590
+ home is kept for a retry), after the hooks and before the worktree step of
2591
+ `work/`. `--discard-worktree` does not apply to it. A locked tree
2592
+ (`"refuse"` in the plan) stops the retire with `E_WORK_PRESERVATION_FAILED`
2593
+ naming the tree before anything runs (no session stop, no retire hook);
2594
+ a lock that appears during the hooks, or a move or removal Git refuses,
2595
+ stops it at the step, after the hooks. `--force` does not bypass either;
2596
+ the home and `work/` are kept, and trees already handled stay handled. A tree
2597
+ that no longer matches what the applied plan said refuses with
2598
+ `E_PLAN_STALE`, the home kept, rather than be moved or removed unplanned.
2599
+ The key is additive.
2600
+ - `workRecovery`: `{path, classes, bytes, home, outputs?, repoCopy?,
2601
+ notCopied?, afterHooks?}`, present when a recovery was written. One retire
2602
+ writes at most one recovery directory, and `path` is that directory.
2603
+ - `classes` is the union of the classes seen before and after the retire
2604
+ hooks, in first-seen order. `bytes` covers the whole directory,
2605
+ `after-hooks/` included.
2606
+ - `home`, `outputs` and `repoCopy` describe the pre-hook snapshot (the
2607
+ top-level `home/`, `repo/` or `work/`) and are not rewritten by the
2608
+ post-hook pass. So `repoCopy.copied: false` (a home-only snapshot) can
2609
+ appear with `afterHooks.work: true`: the repository copy is then under
2610
+ `after-hooks/repo/` only. A recovery first written after the hooks (the
2611
+ instance was clean before them) has no `after-hooks/`, and those keys
2612
+ describe that one snapshot.
2613
+ - `home`: `{paths: [{path, bytes}], bytes}`. The top-level entries of the
2614
+ `home/` snapshot, largest first, a directory ending in `/`. Always
2615
+ present when a recovery is written.
2616
+ - `outputs`: `{paths: [{path, bytes}], bytes}` names what was copied
2617
+ beyond tracked state, largest first.
2618
+ - `notCopied`: `[{scope, path, owner}]`, sorted by `path`. The home
2619
+ entries that existed at any point of the retire (before or after the
2620
+ hooks) and were left out by a capability's `retirement.disposable.home`
2621
+ declaration ([capabilities.md](capabilities.md#manifest)), by their real
2622
+ names: a prefix that matches several entries lists each one, and a
2623
+ declared entry that does not exist is not listed. `owner` is the
2624
+ declaring capability; when two declare the same entry it is the first in
2625
+ capability-name order. Since 0.42.1 the list also holds each verified
2626
+ extra tree (`.work-<purpose>`), with `owner: "kernel:extra-worktree"`: the
2627
+ retire handles it at its own step, never in the copy. Each entry has
2628
+ exactly these three keys: names and owners only, no sizes, hashes, modes
2629
+ or contents of what was left out. `scope` is always `"home"` in this
2630
+ release. Present only when there is at least one.
2631
+ - `afterHooks`: `{home: boolean, work: boolean}`, saying which parts were
2632
+ copied again under `after-hooks/`: `after-hooks/home/` when a retire hook
2633
+ changed the home (its bytes and permission bits, the kernel's own
2634
+ records included), and `after-hooks/repo/` (worktree mode) or
2635
+ `after-hooks/work/` (directory mode) unless the work is proven unchanged
2636
+ after the hooks: a directory by its bytes and bits, a worktree by its Git
2637
+ state and the bytes and bits of its files
2638
+ ([souls and instances](souls-and-instances.md#retire)). A worktree that
2639
+ holds a repository is never proven unchanged: for it `work: true` says
2640
+ that a work copy was made after the hooks, not that a hook changed the
2641
+ work. `work` is also `true` when the pre-hook snapshot held the home
2642
+ only and the work was copied for the first time after the hooks, moved
2643
+ or not, because something beyond the home was there to preserve. The
2644
+ home is not copied again because the work is.
2645
+ Each part is whole and verified, not a delta, but `after-hooks/` is not
2646
+ a complete picture of the instance after the hooks: with `home: false`
2647
+ the recovery's home is the pre-hook one, and it holds the kernel's own
2648
+ records (the event log, the stop and restart receipts, listed in
2649
+ [souls and instances](souls-and-instances.md#retire)) as of then.
2650
+ Present only when that directory was written.
2651
+ - `workRecoveries` is no longer emitted. An older kernel on a server may
2652
+ still send `workRecoveries[]` beside `workRecovery` (one `{path, classes,
2653
+ bytes, outputs?, repoCopy?}` per recovery directory it wrote), so a
2654
+ reader must keep accepting it. A kernel before 0.42 sends no `home`,
2655
+ `notCopied` or `afterHooks`.
2656
+ - `recovery.json` inside the directory stays `version: 1`. It carries
2657
+ `phase` (`"before-hooks"`, then `"complete"` once the post-hook check has
2658
+ concluded), `home` and, when they apply, `notCopied` and `afterHooks`.
2659
+ `phase` is not in the receipt: a receipt is produced only once that check
2660
+ has concluded. A retried retire writes its own recovery and its receipt
2661
+ names only that one
2662
+ ([souls-and-instances.md](souls-and-instances.md#retire)).
2518
2663
  - When they apply: `rollbackIncomplete` and `retainedHome` (cleanup
2519
2664
  incomplete, home kept, exit 1), `forcedIncomplete`, `relinked`,
2520
2665
  `capabilityMeta`, `warnings`, `wakeSchedulesRemoved`.
@@ -2534,8 +2679,11 @@ A first retire prints the **raw receipt**, not an envelope:
2534
2679
  `idempotencyKey` and `replayed: false`.
2535
2680
 
2536
2681
  Refusals (envelopes): `E_PLAN_STALE`, `E_CHILDREN_RUNNING`,
2537
- `E_WORK_PRESERVATION_FAILED` (the home is kept; retry or
2538
- `--discard-worktree`), `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2682
+ `E_WORK_PRESERVATION_FAILED` (the home is kept; retry, or
2683
+ `--discard-worktree` when it is `work/` that could not be re-homed: it does
2684
+ not apply to an extra tree), `E_WORK_INSPECTION_FAILED` (the home is kept; the
2685
+ message names the entry or the state that could not be read),
2686
+ `E_SESSION_UNKNOWN`, `E_AMBIGUOUS_INSTANCE`,
2539
2687
  `E_NO_ROOT`, `E_LIFECYCLE_FAILED`. A recovery whose Git status disagrees with
2540
2688
  the source's carries `details: {home, statusDisagreement: {repo, rows: [{path,
2541
2689
  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
 
@@ -239,14 +249,18 @@ for a password or key, and never runs anything over ssh itself.
239
249
 
240
250
  Every instance a registered server reports shows in its workspace's roster, whoever spawned it. You
241
251
  can open its terminal, start, restart, stop and remove it, and read its readiness, activity, Git
242
- and diffs, as for a local one. Only its pull request is not read here, since the forge reads this
252
+ and diffs, as for a local one. The context panel's Soul tab and its Messaging & Teams list read
253
+ through the server too (`oats inspect --server <id> --home <path>`), and its Work card, "model
254
+ from" line and "older build" chip show the facts the server relays. Only its pull request is not read here, since the forge reads this
243
255
  computer's clones. Every command goes to the server by the instance's home (`--server <id> --home
244
256
  <path>`), never by a bare name. Stop and Remove show the plan the server makes, and confirm
245
257
  against it.
246
258
 
247
259
  A read waits for the server: the view says "Reading from <server>…", and gives up after about
248
260
  45 seconds with "Couldn't reach <server>." When the server refuses, you see its code and message;
249
- nothing is read from this computer in its place. A row that can't be opened says why on the row:
261
+ nothing is read from this computer in its place. When the server's OATS, or this computer's, lacks
262
+ what a part of the panel needs, that part names the server and says which OATS to update, rather
263
+ than showing nothing. A row that can't be opened says why on the row:
250
264
  Herdr no longer supported, gone from <server>, not reachable on <server>, or <server> not reached.
251
265
  For an instance a server no longer lists, the reason names the command that removes it from this
252
266
  computer (`oats server forget <server> --instance <name>`).