@awebai/oats 0.40.2 → 0.41.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.
@@ -6,7 +6,7 @@
6
6
  * repository's default branch) with "no upstream" ≠ 0/0. Diffs are bounded and
7
7
  * addressed by an opaque file id minted with an observation revision; a diff
8
8
  * against a tree that has since moved is refused, never served. */
9
- import { execFileSync } from "node:child_process";
9
+ import { execFileSync, spawnSync } from "node:child_process";
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
@@ -44,6 +44,78 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
44
44
  }
45
45
  const trim = (s) => (s === null ? null : s.trim());
46
46
 
47
+ const BRANCH_REFS = Buffer.from("refs/heads/");
48
+ /** The two reads of HEAD, as bytes. They run like the module's other reads:
49
+ * read-only, helper-free, and with the module's own environment (gitEnv),
50
+ * which keeps only the caller's PATH and HOME: GIT_DIR, GIT_WORK_TREE,
51
+ * GIT_COMMON_DIR, GIT_INDEX_FILE and every other variable of the caller are
52
+ * not passed, and no global or system configuration is read, so nothing but
53
+ * the work tree named by `-C` decides what is read. `-C` comes first. */
54
+ const readHead = (work, args) => spawnSync("git", ["-C", work, ...READ_ONLY_GIT, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
55
+ /** What a failed read of HEAD says, without a line of the output that was not an error. */
56
+ const headReadFailure = (r, command) => String(r.error?.message ?? r.stderr ?? "").trim() || `git ${command} ${r.signal ? `was ended by ${r.signal}` : `exited with ${r.status}`}`;
57
+
58
+ /** What a work tree's HEAD names → { branch, detached, ref }. The one reader
59
+ * of that name: the retire plan and the retire both use it.
60
+ *
61
+ * `ref` is the ref HEAD points at, as the bytes `git symbolic-ref --quiet HEAD`
62
+ * prints, without the one line feed that ends them; null when HEAD is
63
+ * detached. It is evidence for a comparison and is never serialized. Through a
64
+ * chain of symbolic refs it is the last one: the branch the commits go to.
65
+ *
66
+ * `branch` is the name OATS carries for it: the part after `refs/heads/`, when
67
+ * that part is valid UTF-8 (it decodes and encodes back to the same bytes).
68
+ * Nothing of it is trimmed or replaced: a name may begin or end with a
69
+ * character that reads as white space. It is null when HEAD is detached, and
70
+ * also for a ref OATS carries no name for: one whose name is not valid UTF-8,
71
+ * or one outside `refs/heads/`. So `branch: null, detached: false` means "a
72
+ * name OATS does not carry", and two such HEADs are never taken for the same
73
+ * one by their missing names: compare `ref`.
74
+ *
75
+ * A read that fails throws E_WORK_INSPECTION_FAILED. Only Git's own quiet
76
+ * answer (exit 1, nothing printed) is "detached". */
77
+ export function headName(work) {
78
+ const r = readHead(work, ["symbolic-ref", "--quiet", "HEAD"]);
79
+ if (!r.error && r.status === 0 && r.stdout.length > 1) {
80
+ const ref = Buffer.from(r.stdout[r.stdout.length - 1] === 0x0a ? r.stdout.subarray(0, -1) : r.stdout);
81
+ let branch = null;
82
+ if (ref.length > BRANCH_REFS.length && ref.subarray(0, BRANCH_REFS.length).equals(BRANCH_REFS)) {
83
+ const name = ref.subarray(BRANCH_REFS.length);
84
+ try {
85
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(name);
86
+ if (Buffer.from(text, "utf8").equals(name)) branch = text;
87
+ } catch { /* not valid UTF-8: a name OATS does not carry */ }
88
+ }
89
+ return { branch, detached: false, ref };
90
+ }
91
+ if (!r.error && r.status === 1 && !r.stdout.length && !r.stderr.length) return { branch: null, detached: true, ref: null };
92
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read what the worktree at ${work} has checked out: ${headReadFailure(r, "symbolic-ref")}`);
93
+ }
94
+
95
+ /** A worktree's HEAD for a retire → { commit, branch, detached, ref }: what
96
+ * headName gives, and the commit HEAD is at.
97
+ *
98
+ * A HEAD that has no commit (a branch that is not born yet) is a failed read
99
+ * here: the retire proves its copy against this commit, and removes a
100
+ * worktree only after it has asked which refs reach it, so there is nothing
101
+ * it could do without one. That differs from the plan on purpose: the plan
102
+ * (observeInstanceGit) reads the commit for itself, and an unborn work tree is
103
+ * a value on its wire (`revision: "unborn"`).
104
+ *
105
+ * The name and the commit are two reads, and nothing binds them but what is
106
+ * done with them: a copy is proven against `commit`, so a name read at
107
+ * another moment than the commit gives a copy that fails its proof. Nothing
108
+ * destructive rests on the name. */
109
+ export function worktreeHead(work) {
110
+ const name = headName(work);
111
+ const r = readHead(work, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
112
+ const commit = !r.error && r.status === 0 ? r.stdout.toString("latin1").replace(/\n$/, "") : "";
113
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
114
+ throw oatsError("E_WORK_INSPECTION_FAILED", `the HEAD of the worktree at ${work} has no commit that could be read${r.error || r.stderr?.length ? `: ${headReadFailure(r, "rev-parse")}` : ""}`);
115
+ }
116
+ return { commit, ...name };
117
+ }
118
+
47
119
  /** The instance's work tree, from its home. The home's instance.json names
48
120
  * the work mode; the tree is `<home>/work` (a directory or a symlink to the
49
121
  * shared checkout). Missing/retired → attributed refusal, never a crash. */
@@ -169,12 +241,49 @@ function fileId(revision, indexOid, entry) {
169
241
  return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
170
242
  }
171
243
 
244
+ /** The HEAD of a worktree a retire is about to remove, and whether a ref of
245
+ * its repository reaches that commit → { head, unreached }. A ref outlives
246
+ * the worktree; HEAD, and refs only the worktree has, do not.
247
+ *
248
+ * One read in the repository that carries no ref name, so a ref whose name is
249
+ * not valid UTF-8, or very many refs, cannot make it refuse:
250
+ * `git for-each-ref --contains <commit> --count=1 --format=%(objectname)`.
251
+ * Exit 0 with a line printed: some ref reaches the commit. Exit 0 with
252
+ * nothing printed: none does, and the commit is something to preserve. Any
253
+ * other exit throws E_WORK_INSPECTION_FAILED. Standard error is not a failure
254
+ * here: a damaged ref prints a warning there with exit 0, and reaches
255
+ * nothing. A commit that only another worktree's HEAD reaches counts as not
256
+ * reached, which can only add a copy. */
257
+ export function worktreeCommitUnreached(repo, work) {
258
+ const head = worktreeHead(work);
259
+ const r = spawnSync("git", ["-C", repo, ...READ_ONLY_GIT, "for-each-ref", "--contains", head.commit, "--count=1", "--format=%(objectname)"], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
260
+ if (r.error || r.status !== 0) throw oatsError("E_WORK_INSPECTION_FAILED", `could not read which refs of ${repo} reach the commit the worktree at ${work} has checked out: ${headReadFailure(r, "for-each-ref")}`);
261
+ return { head, unreached: r.stdout.length === 0 };
262
+ }
263
+
264
+ /** The check made immediately before a retire removes a worktree: HEAD as it
265
+ * is now (`now`, worktreeHead) against HEAD as the final inspection read it
266
+ * (`inspected`). Equal when the commits are equal and both are detached or
267
+ * both point at a ref with the same bytes. The branch names are not compared:
268
+ * for a ref OATS carries no name for they are null on both sides, and null
269
+ * equal to null proves nothing. Anything else throws
270
+ * E_WORK_PRESERVATION_FAILED, and the worktree is not removed. */
271
+ export function assertSameWorktreeHead(inspected, now) {
272
+ const same = !!inspected && !!now && inspected.commit === now.commit
273
+ && (inspected.ref === null || now.ref === null ? inspected.ref === null && now.ref === null && inspected.detached === true && now.detached === true : Buffer.compare(inspected.ref, now.ref) === 0);
274
+ if (!same) throw oatsError("E_WORK_PRESERVATION_FAILED", "the worktree's HEAD changed after it was inspected, so the worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.");
275
+ }
276
+
172
277
  /** One consistent observation of the tree. Every field is what git said. */
173
278
  export function observeInstanceGit(home) {
174
279
  const { meta, work, mode } = instanceWorkTree(home);
175
280
  const headOid = trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true }));
176
281
  const raw = git(work, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--ignore-submodules=none"]);
177
282
  const { branch, entries } = parsePorcelainV2(raw);
283
+ // The name comes from headName, not from the status header, which prints
284
+ // "(detached)" and other words where a name would be and is read as text.
285
+ // The header still gives the upstream and its counts.
286
+ const head = headName(work);
178
287
  // The index state participates in the revision so that a stage/unstage
179
288
  // between observation and diff is a moved tree, not a stale-but-served diff.
180
289
  // Hashed from the index listing: no `write-tree`, so observing creates no object.
@@ -198,10 +307,10 @@ export function observeInstanceGit(home) {
198
307
  return {
199
308
  instanceGitApi: INSTANCE_GIT_API,
200
309
  instance: meta.instance ?? null, agent: meta.agent ?? null, home, workMode: mode,
201
- observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch.head, detached: branch.head === null && headOid !== null, unborn: headOid === null },
202
- recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch.head !== meta.branch },
310
+ observation: { revision, indexRevision: indexOid, at, worktree: work, branch: head.branch, detached: head.detached && headOid !== null, unborn: headOid === null },
311
+ recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && head.branch !== meta.branch },
203
312
  upstream, base: baseComparison,
204
- remote: remoteOf(work, branch.head),
313
+ remote: remoteOf(work, head.branch),
205
314
  summary, files,
206
315
  notes: [
207
316
  ...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
@@ -167,7 +167,8 @@ function writeFileSyncAtomic(path, value) {
167
167
  /** Facts for Remove (retire): what retirement would touch, with the design's
168
168
  * defaults (retain worktree and branch; never touch a PR). `oats retire`
169
169
  * itself applies them: plain retire re-homes the worktree; --discard-worktree
170
- * / --delete-branch are the dialog's opt-ins. */
170
+ * is the dialog's opt-in. No retire deletes a branch: `deleteBranch` is
171
+ * always false. */
171
172
  export function planRetire(ctx, root, name, { home } = {}) {
172
173
  const me = resolveInstance(ctx, root, name, { home });
173
174
  const kids = descendantsOf(me.root, name);
@@ -183,7 +184,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
183
184
  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 });
184
185
  return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
185
186
  notes: [
186
- ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}; branch actions use the worktree's branch`] : []),
187
+ ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
187
188
  ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
188
189
  ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
189
190
  ...((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`] : []),
package/lib/packages.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * OATS packages — versions, lock v3 (workspace model v2).
3
3
  *
4
4
  * Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
5
- * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-expert/decisions/workspace-model-v2.md.
5
+ * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md.
6
6
  *
7
7
  * A package is a place to fetch from WITH a version attached. Nothing is
8
8
  * installed: `resolvePackages` turns each `workspace.packages` entry into an
package/lib/resolve.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * lib/resolve.mjs — from a soul to an immutable resolution (module contract §3).
3
3
  *
4
4
  * Contract: docs/design/2026-09-23-workspace-module-contracts.md §3.
5
- * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-expert/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
5
+ * Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
6
6
  *
7
7
  * `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
8
8
  * exact set of modules an instance will be built from: which capability comes
package/lib/servers.mjs CHANGED
@@ -25,6 +25,7 @@ import { parseStrictJson } from "./canonical-json.mjs";
25
25
  import { noteRuntimeName } from "./deprecation.mjs";
26
26
  import { readWaitingClaim } from "./instance-events.mjs";
27
27
  import { withDirLock } from "./dir-lock.mjs";
28
+ import { shellWord } from "./core.mjs";
28
29
 
29
30
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
30
31
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -917,22 +918,29 @@ export function instanceOnHost(serverId, name, io = {}) {
917
918
  function requireSessionRemote(target, io, home) {
918
919
  const remote = checkRemote(target, io);
919
920
  if (!remote.remote.includes("session")) {
920
- throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with ssh -t ${target.sshHost} tmux attach -t ${recordedTmuxTarget(target, io, home)}`);
921
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with ${oldKernelAttachHint(target, io, home)}`);
921
922
  }
922
923
  return remote;
923
924
  }
924
925
 
925
- /** Where a kernel without `oats session` put the instance's window: the tmux
926
- * session and window its roster records for that home, else its default
927
- * session (pi-agents, the default of every kernel before 0.31). */
926
+ /** How to attach by hand to an instance on a kernel without `oats session`: the tmux session and
927
+ * window its roster records for that home, else its default session (pi-agents, the default of every
928
+ * kernel before 0.31). When the roster row records the server's socket the hint names it, and the
929
+ * remote command is ONE quoted word (ssh joins its words for the remote shell, which would split a
930
+ * path with a space); a row without a socket is a kernel that only ever used its default server. */
928
931
  const OLD_KERNEL_TMUX_SESSION = "pi-agents";
929
- function recordedTmuxTarget(target, io, home) {
932
+ function oldKernelAttachHint(target, io, home) {
933
+ let row;
930
934
  try {
931
935
  const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, input: undefined }).envelope;
932
- const row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
933
- if (row?.tmux?.session) return row.tmux.window ? `${row.tmux.session}:${row.tmux.window}` : row.tmux.session;
936
+ row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
934
937
  } catch { /* the hint falls back to the default session */ }
935
- return OLD_KERNEL_TMUX_SESSION;
938
+ const tmux = row?.tmux?.session ? row.tmux : null;
939
+ const recorded = tmux ? (tmux.window ? `${tmux.session}:${tmux.window}` : tmux.session) : OLD_KERNEL_TMUX_SESSION;
940
+ // The remote command is ONE word for the local shell, with or without a socket: ssh joins its
941
+ // arguments and the remote shell reads the result again.
942
+ const server = typeof tmux?.socket === "string" && tmux.socket ? `-S ${shellWord(tmux.socket)} ` : "";
943
+ return `ssh -t ${target.sshHost} ${shellWord(`tmux ${server}attach -t ${shellWord(recorded)}`)}`;
936
944
  }
937
945
 
938
946
  export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.40.2",
3
+ "version": "0.41.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",