@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.
@@ -10,7 +10,8 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { basename, join } from "node:path";
13
- import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, stopInstanceSession } from "./core.mjs";
13
+ import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
14
+ import { groupedByOwner } from "./retire-output.mjs";
14
15
  import { observeInstanceGit } from "./instance-git.mjs";
15
16
  import { appendEvent } from "./instance-events.mjs";
16
17
  import { oatsError } from "./errors.mjs";
@@ -164,6 +165,8 @@ function writeFileSyncAtomic(path, value) {
164
165
  writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
165
166
  }
166
167
 
168
+ /** How many declared roots a retire plan's recovery note lists before `, and N more`. */
169
+ const PLAN_NOT_COPIED_CAP = 16;
167
170
  /** Facts for Remove (retire): what retirement would touch, with the design's
168
171
  * defaults (retain worktree and branch; never touch a PR). `oats retire`
169
172
  * itself applies them: plain retire re-homes the worktree; --discard-worktree
@@ -181,6 +184,12 @@ export function planRetire(ctx, root, name, { home } = {}) {
181
184
  pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
182
185
  const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
183
186
  const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
187
+ // What retire would preserve and where, read from the spawn baseline and the
188
+ // work mode: nothing is hashed, and the plan revision does not depend on it.
189
+ // The declared roots are listed as written, capped so one note stays short.
190
+ const recovery = retirementRecoveryFacts(me.home);
191
+ const declared = recovery.disposableHome.slice(0, PLAN_NOT_COPIED_CAP);
192
+ const notCopied = declared.length ? `; not copied: ${groupedByOwner(declared, "root")}${recovery.disposableHome.length > declared.length ? `, and ${recovery.disposableHome.length - declared.length} more` : ""}` : "";
184
193
  appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
185
194
  return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
186
195
  notes: [
@@ -190,5 +199,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
190
199
  ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
191
200
  ...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
192
201
  "pull request state is unknown to the kernel; the ADE reports it when a forge connection exists",
202
+ `recovery: the home is copied to ${recovery.recoveryRoot} before the home is removed, when it changed since spawn${notCopied}`,
203
+ ...(meta.work === "worktree" ? ["recovery: uncommitted worktree state is copied there too"] : meta.work === "directory" ? ["recovery: work/ is copied there when it is not empty"] : []),
193
204
  ] };
194
205
  }
@@ -78,6 +78,15 @@ export function harnessUnavailable({ harness, from, at, why }) {
78
78
  return fail("E_HARNESS_UNAVAILABLE", `${harness} is not installed on this machine (${why}); ${source} chose it — ${fix}`, { harness, from, at: at ?? null, fix });
79
79
  }
80
80
 
81
+ /** What a person can do about a harness its pane would not find. */
82
+ export const PANE_PATH_FIX = "declare the executable's absolute path in the launch configuration (executable), set PATH in the launch configuration (env), or start the OATS tmux server from your own shell (tmux -L oats new-session -d -s <session> -n hq), so that it has your PATH";
83
+ /** E_HARNESS_UNAVAILABLE for a harness looked up where its pane looks it up (the session's or the
84
+ * server's PATH, or a launch configuration's): `why` names that source and what went wrong, never
85
+ * the PATH's contents. Never a fallback to this process's PATH or to another harness. */
86
+ export function harnessNotOnPanePath({ harness, from, at, why }) {
87
+ return fail("E_HARNESS_UNAVAILABLE", `${harness} is not available to its pane: ${why}, and a pane looks its harness up there; ${PANE_PATH_FIX}`, { harness, from, at: at ?? null, fix: PANE_PATH_FIX });
88
+ }
89
+
81
90
  /** The closed `Launch` report: `{declared, effective: {harness, model, launchConfig}, from, at, problem}`. */
82
91
  export function launchReport({ declared = null, harness, model = null, launchConfig = null, from, at = null, problem = null }) {
83
92
  return { declared: declared ? { harness: declared.harness, model: declared.model ?? null } : null, effective: { harness, model: model || null, launchConfig: launchConfig || null }, from, at: at ?? null, problem };
@@ -0,0 +1,212 @@
1
+ // The user's login environment, read when the kernel creates the OATS tmux server (awebai/oats#616).
2
+ //
3
+ // A tmux server keeps the environment of the process that starts it, and every pane on it inherits
4
+ // that. When the kernel starts the server it gives it what the user's own login shell sets up (the
5
+ // PATH a version manager activates, an agent socket an rc file exports), read as data, never the
6
+ // creator's ambient environment: the creator may be an agent instance, whose identity, credentials
7
+ // and harness variables must not become the server's. docs/execution-targets.md owns the rule.
8
+ //
9
+ // Whose: only a creator whose HOME is the user's home directory in the password database is taken
10
+ // to run in the user's session, and only for it is the login shell run. A process that redirected
11
+ // HOME (a sandbox, a test fixture) is not: its reading fails, and its caller takes the fallback. The
12
+ // test seam (a fake shell) is honoured only there, never in the user's own session.
13
+ //
14
+ // How: the login shell from the password database (os.userInfo().shell, never the creator's SHELL),
15
+ // one of bash, zsh or fish, runs `-l -i -c '<node> -e <emitter> <nonce>'`, started from a fixed seed
16
+ // in the OS user's home directory. The emitter writes JSON.stringify(process.env) to its stdout as
17
+ // one frame: a start and an end delimiter that each carry a random nonce, made for this reading only,
18
+ // and begin and end with a line break, which valid JSON never holds unescaped. Exactly one complete,
19
+ // ordered frame for that nonce is the answer; whatever the shell's start-up prints around it (rc
20
+ // chatter, prompts, text shaped like assignments or like JSON, a frame with another nonce) is
21
+ // discarded, and its stderr is not read. The nonce separates the answer from accidental chatter; it
22
+ // does not authenticate anything against the user's own start-up files, which can also redirect the
23
+ // shell's stdout away (then there is no answer). Not a descriptor of its own: bash 5.3, started -l -i, marks
24
+ // the descriptors it inherits from 3 to 19 close-on-exec, so a pipe there never reaches the emitter,
25
+ // and no shell is relied on to leave any descriptor but stdout to the command it runs. No value goes
26
+ // into argv (the nonce is not one), a file, a log or a diagnostic. The answer is accepted only whole: exit status 0, at most
27
+ // 1 MiB, one JSON object whose values are all strings, plain identifiers as names, HOME and PATH
28
+ // present. The functions bash exports (`BASH_FUNC_<name>%%`, a stock profile's `export -f`) are code,
29
+ // not environment: they are dropped from the answer, never carried. Nothing in it is evaluated.
30
+ //
31
+ // Bounded: the shell runs in its own process group. The deadline (5 s) ends the read even when a
32
+ // descendant still holds the shell's stdout after the shell exited; then that group, and only that
33
+ // group, gets SIGKILL (never the shell's own pid, which is reaped by then). A descendant that made
34
+ // its own session or group (a daemon) is outside it, and group cleanup does not promise to end it.
35
+ //
36
+ // Imports nothing but node's own modules: the kernel stays dependency-free.
37
+ import { spawnSync } from "node:child_process";
38
+ import { randomBytes } from "node:crypto";
39
+ import { accessSync, constants as fsConstants, realpathSync } from "node:fs";
40
+ import { userInfo } from "node:os";
41
+ import { basename, resolve } from "node:path";
42
+ import { signalGroup } from "./process-group.mjs";
43
+
44
+ /** The session variables the seed carries: what lets the user's setup reach their agent socket and
45
+ * display. Per name, the first source where the name is present (an empty value counts) is used. */
46
+ export const LOGIN_SESSION_ENV = ["SSH_AUTH_SOCK", "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"];
47
+ /** The shells whose `-l -i -c` and single quotes this relies on. Any other is an acquisition failure. */
48
+ export const LOGIN_SHELLS = ["bash", "zsh", "fish"];
49
+ export const LOGIN_CAPTURE_TIMEOUT_MS = 5000;
50
+ export const LOGIN_CAPTURE_MAX_BYTES = 1024 * 1024;
51
+ const SEED_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
52
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
53
+ const PLATFORM_READ_TIMEOUT_MS = 2000;
54
+ // The frame's delimiters, each `\n<marker><nonce>\n`; the emitter receives the nonce as its one
55
+ // argument (process.argv[1]).
56
+ const BEGIN = "OATS-LOGIN-ENVIRONMENT-BEGIN-", END = "OATS-LOGIN-ENVIRONMENT-END-";
57
+ // Loops until every byte is written: stdout is a blocking pipe in the emitter.
58
+ const EMITTER = `const f=require("fs"),n=process.argv[1],b=Buffer.from("\\n${BEGIN}"+n+"\\n"+JSON.stringify(process.env)+"\\n${END}"+n+"\\n");for(let o=0;o<b.length;)o+=f.writeSync(1,b,o)`;
59
+ const BASH_FUNCTION = /^BASH_FUNC_.+%%$/;
60
+ /** One single-quoted word for `shell`: POSIX's `'…'\\''…'` for bash and zsh; fish's own form, where a
61
+ * backslash and a quote inside single quotes are escaped with a backslash. */
62
+ const quoteFor = (shell) => basename(shell) === "fish"
63
+ ? (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
64
+ : (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
65
+ const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
66
+
67
+ /**
68
+ * Read the login environment. `creatorEnv`: the creating process's environment (its locale names,
69
+ * and its session variables when it is not an instance). `instance`: whether the creator is an
70
+ * instance; then `recorded` is the global environment of the server its home records, as the strict
71
+ * reader read it (null when it could not be read). `shell` replaces the password database's shell, a
72
+ * test seam honoured only for a creator outside the user's session (its HOME is not the password
73
+ * database's), where it also makes the platform's tools the creator's (its PATH's). → `{ env, notes }` (env as the shell's environment ended up, unfiltered) or
74
+ * `{ failure, notes }`: why, in words that hold no value. `notes`: what a person should know even on
75
+ * success (a platform session that could not be read), also without values; a caller reports them
76
+ * only with a successful capture, so a failure is one line.
77
+ */
78
+ export function captureLoginEnvironment({ creatorEnv = process.env, instance = false, recorded = null, shell: seam, timeoutMs = LOGIN_CAPTURE_TIMEOUT_MS } = {}) {
79
+ const notes = [];
80
+ let user;
81
+ try { user = userInfo(); } catch { return { failure: "your user has no password database entry", notes }; }
82
+ // Only a creator in the user's session (its HOME is the user's home directory) has a login shell run
83
+ // for it; a test seam is never honoured there.
84
+ const ownSession = typeof creatorEnv.HOME === "string" && creatorEnv.HOME !== "" && samePath(creatorEnv.HOME, user.homedir);
85
+ if (!ownSession && !seam) return { failure: "this process's HOME is not your home directory, so it does not run in your login session", notes };
86
+ const shell = ownSession ? user.shell : seam;
87
+ if (!shell) return { failure: "your user has no login shell in the password database", notes };
88
+ if (!LOGIN_SHELLS.includes(basename(shell))) return { failure: `your login shell (${basename(shell)}) is not bash, zsh or fish`, notes };
89
+ // Nothing is read for a shell that cannot run (the user session included).
90
+ try { accessSync(shell, fsConstants.X_OK); } catch (e) { return { failure: `your login shell could not be started (${e.code || "error"})`, notes }; }
91
+ const seed = { HOME: user.homedir, USER: user.username, LOGNAME: user.username, SHELL: user.shell ?? shell, PATH: SEED_PATH, TERM: "dumb" };
92
+ for (const name of ["LANG", "LC_ALL", "LC_CTYPE"]) if (creatorEnv[name] !== undefined) seed[name] = creatorEnv[name];
93
+ Object.assign(seed, sessionVariables({ creatorEnv, instance, recorded, notes, user, toolPath: ownSession ? SEED_PATH : creatorEnv.PATH }));
94
+ const sq = quoteFor(shell);
95
+ const nonce = randomBytes(16).toString("hex");
96
+ const r = spawnSync(shell, ["-l", "-i", "-c", `${sq(process.execPath)} -e ${sq(EMITTER)} ${nonce}`], {
97
+ cwd: user.homedir, env: seed, stdio: ["ignore", "pipe", "ignore"],
98
+ timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: LOGIN_CAPTURE_MAX_BYTES, detached: true, windowsHide: true,
99
+ });
100
+ // The deadline or the size limit ended it: whatever of its group is left (a descendant holding the
101
+ // shell's stdout, a shell still in its rc) is killed. Only this group, only then: the shell itself
102
+ // was reaped before spawnSync returned, so its bare pid is never signalled.
103
+ if (r.error?.code === "ETIMEDOUT" || r.error?.code === "ENOBUFS" || r.signal) signalGroup(r, "SIGKILL");
104
+ if (r.error?.code === "ETIMEDOUT") return { failure: `your login shell did not answer within ${Math.round(timeoutMs / 1000)} s`, notes };
105
+ if (r.error?.code === "ENOBUFS") return { failure: "its answer was larger than 1 MiB", notes };
106
+ if (r.error) return { failure: `your login shell could not be started (${r.error.code || "error"})`, notes };
107
+ if (r.signal) return { failure: `your login shell was ended by ${r.signal}`, notes };
108
+ if (r.status !== 0) return { failure: `your login shell exited with status ${r.status}`, notes };
109
+ const printed = r.stdout;
110
+ if (printed?.length > LOGIN_CAPTURE_MAX_BYTES) return { failure: "its answer was larger than 1 MiB", notes };
111
+ // Exactly one start and one end delimiter of this nonce, in that order; anything else is no answer
112
+ // or an ambiguous one, never a part of it taken as the whole.
113
+ const begin = Buffer.from(`\n${BEGIN}${nonce}\n`), end = Buffer.from(`\n${END}${nonce}\n`);
114
+ const from = printed ? printed.indexOf(begin) : -1, to = printed ? printed.indexOf(end) : -1;
115
+ if (from < 0 && to < 0) return { failure: "your login shell gave no answer", notes };
116
+ if (from < 0 || to < from + begin.length || printed.indexOf(begin, from + 1) >= 0 || printed.indexOf(end, to + 1) >= 0) return { failure: "its answer was not exactly one complete frame", notes };
117
+ const data = printed.subarray(from + begin.length, to);
118
+ let env;
119
+ try { env = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data)); } catch { return { failure: "its answer was not one complete JSON object", notes }; }
120
+ if (!env || typeof env !== "object" || Array.isArray(env)) return { failure: "its answer was not one complete JSON object", notes };
121
+ // Functions bash exports are code every bash in a pane would import: dropped, never carried.
122
+ for (const name of Object.keys(env)) if (BASH_FUNCTION.test(name)) delete env[name];
123
+ const names = Object.keys(env);
124
+ if (names.some((name) => typeof env[name] !== "string")) return { failure: "its answer held a value that is not text", notes };
125
+ if (names.some((name) => !NAME.test(name))) return { failure: "its answer held a name that is not a plain identifier", notes };
126
+ for (const name of ["HOME", "PATH"]) if (!Object.hasOwn(env, name)) return { failure: `its answer had no ${name}`, notes };
127
+ return { env: Object.assign(Object.create(null), env), notes };
128
+ }
129
+
130
+ /** The seed's session variables, per name from the first source that has it: (a) the creator, only
131
+ * when it is not an instance; (b) for an instance, the recorded server's global environment; (c)
132
+ * the platform's user session, read as data. */
133
+ function sessionVariables({ creatorEnv, instance, recorded, notes, user, toolPath }) {
134
+ const out = {}, missing = [];
135
+ for (const name of LOGIN_SESSION_ENV) {
136
+ if (!instance && creatorEnv[name] !== undefined) out[name] = creatorEnv[name];
137
+ else if (instance && recorded && Object.hasOwn(recorded, name)) out[name] = recorded[name];
138
+ else missing.push(name);
139
+ }
140
+ if (!missing.length) return out;
141
+ const platform = platformSession(missing, user, toolPath);
142
+ if (platform.failure) notes.push(`could not read the user session's environment (${platform.failure}); ${missing.join(", ")} came only from your login shell`);
143
+ for (const name of missing) if (Object.hasOwn(platform.env, name)) out[name] = platform.env[name];
144
+ return out;
145
+ }
146
+
147
+ /** The user session's variables of `names`: `systemctl --user show-environment` on Linux,
148
+ * `launchctl getenv NAME` on macOS, each bounded, never through a shell. The tool is looked up on
149
+ * `toolPath` (the seed's PATH; under the test seam, the creator's) and runs with an environment of
150
+ * the OS user's own (HOME, USER, LOGNAME and, on Linux, the user's runtime directory, where
151
+ * systemctl finds the user bus), never the creator's. A failed or unavailable read, a timeout or a
152
+ * missing tool leave every name absent. → `{ env, failure? }` */
153
+ function platformSession(names, user, toolPath = SEED_PATH) {
154
+ const toolEnv = { HOME: user.homedir, USER: user.username, LOGNAME: user.username, PATH: toolPath ?? SEED_PATH, ...(process.platform === "linux" && user.uid >= 0 ? { XDG_RUNTIME_DIR: `/run/user/${user.uid}` } : {}) };
155
+ const run = (file, args) => spawnSync(file, args, { env: toolEnv, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: PLATFORM_READ_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: LOGIN_CAPTURE_MAX_BYTES, windowsHide: true });
156
+ const why = (r, tool) => r.error ? (r.error.code === "ENOENT" ? `${tool} was not found` : r.error.code === "ETIMEDOUT" ? `${tool} timed out` : `${tool} failed (${r.error.code})`) : r.status !== 0 ? `${tool} exited with status ${r.status ?? r.signal}` : null;
157
+ if (process.platform === "linux") {
158
+ const r = run("systemctl", ["--user", "show-environment"]);
159
+ const failure = why(r, "systemctl --user show-environment");
160
+ return failure ? { env: {}, failure } : { env: parseSystemdEnvironment(r.stdout, names) };
161
+ }
162
+ if (process.platform === "darwin") {
163
+ const env = {};
164
+ for (const name of names) {
165
+ const r = run("launchctl", ["getenv", name]);
166
+ const failure = why(r, "launchctl getenv");
167
+ if (failure) return { env, failure };
168
+ const value = r.stdout.replace(/\n$/, "");
169
+ if (value !== "") env[name] = value; // empty output: absent
170
+ }
171
+ return { env };
172
+ }
173
+ return { env: {} };
174
+ }
175
+
176
+ /**
177
+ * `systemctl --user show-environment` read as data: lines `NAME=value`. A value systemd printed in
178
+ * its `$'…'` C-escaped form (\a \b \e \f \n \r \t \v \\ \' \" \ooo \xHH) is decoded, as bytes, then as
179
+ * UTF-8; one that cannot be decoded leaves its name absent. Only the names asked for are kept.
180
+ */
181
+ export function parseSystemdEnvironment(text, names = LOGIN_SESSION_ENV) {
182
+ const env = {};
183
+ for (const line of String(text).split("\n")) {
184
+ const eq = line.indexOf("=");
185
+ if (eq <= 0) continue;
186
+ const name = line.slice(0, eq), raw = line.slice(eq + 1);
187
+ if (!names.includes(name)) continue;
188
+ if (!raw.startsWith("$'")) { env[name] = raw; continue; }
189
+ const value = decodeCEscaped(raw);
190
+ if (value !== null) env[name] = value;
191
+ }
192
+ return env;
193
+ }
194
+ const SIMPLE_ESCAPES = { a: 7, b: 8, e: 27, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, "'": 39, "\"": 34 };
195
+ function decodeCEscaped(raw) {
196
+ if (!raw.endsWith("'") || raw.length < 3) return null;
197
+ const body = raw.slice(2, -1);
198
+ const bytes = [];
199
+ for (let i = 0; i < body.length; i++) {
200
+ const c = String.fromCodePoint(body.codePointAt(i));
201
+ if (c === "'") return null; // an unescaped quote cannot be inside
202
+ if (c !== "\\") { bytes.push(...Buffer.from(c, "utf8")); i += c.length - 1; continue; }
203
+ const next = body[i + 1];
204
+ if (next !== undefined && Object.hasOwn(SIMPLE_ESCAPES, next)) { bytes.push(SIMPLE_ESCAPES[next]); i += 1; continue; }
205
+ const oct = /^[0-7]{3}/.exec(body.slice(i + 1, i + 4));
206
+ if (oct && parseInt(oct[0], 8) < 256) { bytes.push(parseInt(oct[0], 8)); i += 3; continue; }
207
+ const hex = /^x([0-9A-Fa-f]{2})/.exec(body.slice(i + 1, i + 4));
208
+ if (hex) { bytes.push(parseInt(hex[1], 16)); i += 3; continue; }
209
+ return null;
210
+ }
211
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)); } catch { return null; }
212
+ }
@@ -0,0 +1,50 @@
1
+ /** What `oats retire` says about the work it preserved. One function renders
2
+ * the lines for the local and the remote path of bin/oats.mjs, from the
3
+ * receipt's `workRecovery` (lib/core.mjs, retireInstance). A receipt from an
4
+ * older kernel, or a stored one, may carry `workRecoveries[]` instead: one
5
+ * block is printed per entry. Dependency-free. */
6
+
7
+ export 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`;
8
+
9
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
10
+
11
+ /** `a, b (owner); c (other)`: the `key` of each row, grouped by its owner; owners sorted, rows in their order. */
12
+ export function groupedByOwner(rows, key) {
13
+ const groups = new Map();
14
+ for (const row of rows) groups.set(row.owner, [...(groups.get(row.owner) || []), row[key]]);
15
+ return [...groups].sort(([a], [b]) => byCodeUnit(String(a), String(b))).map(([owner, names]) => `${names.join(", ")}${owner ? ` (${owner})` : ""}`).join("; ");
16
+ }
17
+
18
+ /** `{ paths: [{ path, bytes }], bytes }` as one line: the first 8 with their size, then the total. */
19
+ function sizedPathsLine(label, part) {
20
+ if (!part?.paths?.length) return [];
21
+ const shown = part.paths.slice(0, 8).map((p) => `${p.path} (${formatBytes(p.bytes)})`);
22
+ const more = part.paths.length > 8 ? `, and ${part.paths.length - 8} more` : "";
23
+ return [` ${label}: ${shown.join(", ")}${more} — ${formatBytes(part.bytes)} in total`];
24
+ }
25
+
26
+ /** The recoveries a retire receipt names: every entry of a historical `workRecoveries[]`, else its one `workRecovery`. */
27
+ export function receiptRecoveries(receipt) {
28
+ if (Array.isArray(receipt?.workRecoveries) && receipt.workRecoveries.length) return receipt.workRecoveries;
29
+ return receipt?.workRecovery ? [receipt.workRecovery] : [];
30
+ }
31
+
32
+ /** The lines `oats retire` prints for what a receipt preserved; `host` (the
33
+ * remote path) names where. Per recovery: the classes, the path with its
34
+ * size, then, when they apply, what was copied from the home, the copied
35
+ * outputs, the home entries left out by a capability's declaration (names and
36
+ * owners only) and which parts were copied again under after-hooks/. */
37
+ export function workRecoveryLines(receipt, { host } = {}) {
38
+ const lines = [];
39
+ for (const recovery of receiptRecoveries(receipt)) {
40
+ lines.push(`Work that was not committed has been preserved${host ? ` on ${host}` : ""}: ${(recovery.classes || []).join(", ")}`);
41
+ lines.push(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
42
+ lines.push(...sizedPathsLine("copied from the home", recovery.home));
43
+ lines.push(...sizedPathsLine("copied outputs", recovery.outputs));
44
+ const notCopied = Array.isArray(recovery.notCopied) ? recovery.notCopied.filter((row) => typeof row?.path === "string") : [];
45
+ if (notCopied.length) lines.push(` not copied: ${groupedByOwner(notCopied, "path")}`);
46
+ const again = [recovery.afterHooks?.home === true && "home", recovery.afterHooks?.work === true && "work"].filter(Boolean);
47
+ if (again.length) lines.push(` after the retire hooks: ${again.join(" and ")} copied again under after-hooks/`);
48
+ }
49
+ return lines;
50
+ }
package/lib/tree-copy.mjs CHANGED
@@ -21,14 +21,16 @@ import { oatsError } from "./errors.mjs";
21
21
  * - deterministic traversal (sorted entries), so two copies of one tree hash
22
22
  * identically;
23
23
  * - symlinks are recreated VERBATIM — never followed, never rewritten — because
24
- * the bytes about to be hashed must be the bytes the author wrote;
24
+ * the bytes about to be hashed must be the bytes the author wrote. The target
25
+ * is read and written as bytes: read as text, a target that is not valid
26
+ * UTF-8 would be recreated as another one;
25
27
  * - FIFOs, sockets and device nodes are rejected fail-closed: they are not
26
28
  * distributable content, and copying them has no defined meaning here;
27
29
  * - directory modes are applied AFTER their children, so a read-only source
28
30
  * directory cannot block writing its own contents. */
29
31
  export function copyTreeSafe(src, dest) {
30
32
  const st = lstatSync(src);
31
- if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
33
+ if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src, "buffer"), dest); return; }
32
34
  if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
33
35
  if (!st.isDirectory()) {
34
36
  throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
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",