@awebai/oats 0.22.7 → 0.22.9

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
@@ -31,7 +31,7 @@ import {
31
31
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
32
32
  findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
33
33
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
34
- spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
34
+ spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
35
35
  } from "../lib/core.mjs";
36
36
  import {
37
37
  aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
@@ -39,7 +39,7 @@ import {
39
39
  assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
40
40
  runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
41
41
  } from "../lib/packages.mjs";
42
- import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
42
+ import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
43
43
  import { spawnSync as spawnSyncProc } from "node:child_process";
44
44
 
45
45
  const args = process.argv.slice(2);
@@ -2857,12 +2857,16 @@ async function sessionCmd() {
2857
2857
  return;
2858
2858
  }
2859
2859
  if (args[1] === "inspect") result = inspectInstanceSession(home);
2860
- else if (args[1] === "input") {
2860
+ else if (args[1] === "start") {
2861
+ const model = flag("model");
2862
+ if (model === true) throw Object.assign(new Error("--model needs a model id; omit it to keep the recorded model"), { code: "E_BAD_ARGS" });
2863
+ result = startInstanceSession(home, { model: model || undefined });
2864
+ } else if (args[1] === "input") {
2861
2865
  const file = flag("text-file");
2862
2866
  if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
2863
2867
  if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
2864
2868
  result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
2865
- } else throw Object.assign(new Error("usage: oats session inspect|input|attach --home /absolute/home [--text-file path] [--json]"), { code: "E_BAD_ARGS" });
2869
+ } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start --home /absolute/home [--text-file path] [--model id] [--json]"), { code: "E_BAD_ARGS" });
2866
2870
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2867
2871
  } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
2868
2872
  }
@@ -3157,7 +3161,7 @@ function versionCmd() {
3157
3161
  // on it (an older CLI without the surface must fail closed with a
3158
3162
  // reason, not an argument error). `features`: kernel abilities a peer
3159
3163
  // must see before relying on them (retire-home: retire --home).
3160
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "roster", "harvest"], features: ["retire-home"] }));
3164
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "roster", "harvest"], features: ["retire-home", "session-start"] }));
3161
3165
  return;
3162
3166
  }
3163
3167
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3326,7 +3330,19 @@ function serverRouteCmd() {
3326
3330
  console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
3327
3331
  return;
3328
3332
  }
3329
- if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3333
+ if (args[1] === "start") {
3334
+ const model = flag("model");
3335
+ if (model === true) bail("E_BAD_ARGS", "--model needs a model id; omit it to keep the recorded model");
3336
+ let out;
3337
+ try { out = startRemote(id, { ...addr, model: model || undefined }); } catch (e) { bail(e.code || "E_SSH", e.message); }
3338
+ if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3339
+ if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3340
+ if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3341
+ const r = out.envelope.result;
3342
+ console.log(`Started ${r.instance || r.home} on ${id} (${r.backend}${r.model ? `, model ${r.model}` : ""}, ${r.reused === "pane" ? "in its existing pane" : r.reused === "adopted" ? "adopted the pending session" : "new window"})`);
3343
+ return;
3344
+ }
3345
+ if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3330
3346
  let route;
3331
3347
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3332
3348
  catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
@@ -3506,11 +3522,18 @@ Usage:
3506
3522
  oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
3507
3523
  --instance <name> | --home <abs> remote instance over its saved route (--print shows
3508
3524
  attach); the server needs oats 0.22.2 or later
3525
+ oats session start --server <id> start a stopped remote instance in its existing home
3526
+ --instance <name> | --home <abs> over its saved route; the server must advertise
3527
+ [--model <m>] [--json] session-start (oats 0.22.9 or later)
3509
3528
  oats create <name> [--local] create an agent soul; --local = full
3510
3529
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3511
3530
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
3512
3531
  [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
3513
3532
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3533
+ oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3534
+ [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3535
+ spawn hooks); --model replaces the recorded model
3536
+ for this and later starts; a live harness is refused
3514
3537
  oats spawn <agent> [--task <text>] spawn an instance (tmux/Herdr; --no-launch
3515
3538
  [--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
3516
3539
  [--parent <instance>] --def-file creates a local agent;
@@ -27,9 +27,12 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdi
27
27
  import { join, isAbsolute, dirname } from "node:path";
28
28
  import { tmpdir } from "node:os";
29
29
  import { execFile, spawnSync } from "node:child_process";
30
+ import { reclaimHarvestBranch } from "../lib/harvest-branch.mjs";
30
31
 
31
32
  const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); process.exit(0); };
32
33
  const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
34
+ // A reported failure must not exit 0: callers and hooks read the status.
35
+ const warnFail = (m) => { process.stdout.write(JSON.stringify({ warning: `oats-okf: ${String(m).slice(0, 300)}` }) + "\n"); process.exit(1); };
33
36
 
34
37
  // Desktop CLI API v1: `oats okf harvest --json` emits EXACTLY ONE envelope
35
38
  // object on stdout — {schemaVersion:1,ok,result|error} — and a nonzero exit
@@ -38,6 +41,12 @@ const JSON_MODE = process.argv.includes("--json");
38
41
  const jsonOk = (result) => { process.stdout.write(JSON.stringify({ schemaVersion: 1, ok: true, result }) + "\n"); process.exit(0); };
39
42
  const jsonFail = (code, message) => { process.stdout.write(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message).slice(0, 300) } }) + "\n"); process.exit(1); };
40
43
 
44
+ // --help/-h never runs a command here either (the kernel answers it from the
45
+ // manifest since 0.22.6; this keeps an older kernel from spawning a harvester).
46
+ if (process.argv.slice(2).some((a) => a === "--help" || a === "-h")) {
47
+ process.stdout.write("oats okf harvest [--json] [--from-record] [--force] promote this instance's pending notes (and record windows) into its soul by spawning a memory-harvest worker; --help never spawns\noats okf status [--json]\n");
48
+ process.exit(0);
49
+ }
41
50
  const event = process.env.OATS_EVENT || process.argv[2];
42
51
  const instance = process.env.OATS_INSTANCE;
43
52
  const home = process.env.OATS_HOME || process.cwd();
@@ -421,6 +430,10 @@ _(the single next action — keep this current; a fresh session on any model res
421
430
  const soulRepo = gitRootOf(realSoul);
422
431
  if (!soulRepo) skip("workspace-mode soul is not inside a git repo — nowhere to deliver a PR");
423
432
  const relSoul = realSoul.slice(soulRepo.length + 1);
433
+ // A leftover memory-harvest/<slug> branch from a merged promotion is
434
+ // deleted first; an unmerged one refuses the harvest with the remedy.
435
+ const reclaimed = reclaimHarvestBranch(soulRepo, `memory-harvest/${slug}`);
436
+ if (reclaimed.action === "deleted") process.stderr.write(`oats-okf: deleted stale harvest branch memory-harvest/${slug} (merged into ${reclaimed.base})\n`);
424
437
  const task = `Harvest the pending notes of live WORKSPACE-MODE instance "${inst}" (agent "${agName}") into its soul — delivered as a PR.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Your ./work is a dedicated worktree of the soul's home repo (${soulRepo}), branch memory-harvest/${slug}.\n- Soul knowledge bundle to update: ./work/${join(relSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ./work/${join(relSoul, "skills")}\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir, and commit once (prefixed "memory-harvest:") if anything changed.${recordBrief(recordPlan, packageRuntimeCli())}\n- If you changed anything: push the branch and open a PR (\`git push -u origin memory-harvest/${slug}\` then \`gh pr create --fill\`). Do NOT merge it; the humans/owners of ${soulRepo} review soul changes. If gh is unavailable, push the branch and report the compare URL. A harvest that promoted nothing has nothing to commit, push or open; that is a completed harvest, not a failed one.\n- Finally run \`oats retire ${harvName} --self\` (keep the branch: --self only).`;
425
438
  r = await spawnHarvester(harvestSpawnArgs({
426
439
  slug, parent: inst, repo: soulRepo, work: "worktree",
@@ -458,7 +471,7 @@ _(the single next action — keep this current; a fresh session on any model res
458
471
  out({ meta: { harvestSpawn: r.instance, window: r.tmux?.window }, ...warnings });
459
472
  } catch (e) {
460
473
  if (JSON_MODE) jsonFail(e.code || "E_HARVEST_FAILED", `harvest spawn failed (notes are safe on disk): ${e.message || e}`);
461
- warn(`harvest spawn failed (notes are safe on disk): ${e.message || e}`);
474
+ warnFail(`harvest spawn failed (notes are safe on disk): ${e.message || e}`);
462
475
  }
463
476
  } else if (event === "retire") {
464
477
  // Retirement is intentionally a no-op for knowledge (for now): promotion happens
@@ -0,0 +1,43 @@
1
+ // A workspace-mode harvest delivers its promotion as a PR from a branch named
2
+ // memory-harvest/<slug> in the soul's repository. After that PR merges, the
3
+ // local branch may still exist and the next harvest's spawn would refuse it.
4
+ // A branch fully merged into the base is stale and is deleted before the
5
+ // spawn; an unmerged one is the previous harvester's unfinished work and the
6
+ // harvest refuses with the exact remedy instead of touching it.
7
+ import { execFileSync } from "node:child_process";
8
+
9
+ /** Single-quote shell escaping for the operator remedy: the repo path may hold spaces or shell metacharacters. */
10
+ export function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
11
+
12
+ function git(repo, args) {
13
+ return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
14
+ }
15
+
16
+ /** The repository's base branch: origin/HEAD's target when known, else main, else master. */
17
+ export function baseBranchOf(repo) {
18
+ try { const ref = git(repo, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]); if (ref) return ref.replace(/^refs\/remotes\//, ""); } catch { /* no origin/HEAD */ }
19
+ for (const b of ["origin/main", "main", "origin/master", "master"]) {
20
+ try { git(repo, ["rev-parse", "--verify", "--quiet", b]); return b; } catch { /* next */ }
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ /** Returns { action: "absent" | "deleted", base } or throws E_HARVEST_BRANCH_EXISTS. */
26
+ export function reclaimHarvestBranch(repo, branch) {
27
+ try { git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); }
28
+ catch { return { action: "absent" }; }
29
+ const base = baseBranchOf(repo);
30
+ let merged = false;
31
+ if (base) { try { git(repo, ["merge-base", "--is-ancestor", branch, base]); merged = true; } catch { merged = false; } }
32
+ if (!merged) {
33
+ const err = new Error(`branch ${branch} already exists in ${repo} and is not merged into ${base || "any base branch"}: a previous harvest's promotion is unfinished — review and merge or delete it (git -C ${shellQuote(repo)} branch -D ${shellQuote(branch)}) before harvesting again`);
34
+ err.code = "E_HARVEST_BRANCH_EXISTS";
35
+ throw err;
36
+ }
37
+ // -D, not -d: the merge check above is against the BASE (origin/main when
38
+ // present). `branch -d` re-checks against the branch's upstream or the
39
+ // current HEAD instead, so with the soul's local main behind origin/main a
40
+ // branch fully merged upstream would still be refused as "not fully merged".
41
+ git(repo, ["branch", "-D", branch]);
42
+ return { action: "deleted", base };
43
+ }
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "1.5.1",
5
- "compatibility": { "oats": ">=0.22.3" },
4
+ "version": "1.5.2",
5
+ "compatibility": {
6
+ "oats": ">=0.22.3"
7
+ },
6
8
  "layer": "knowledge",
7
9
  "description": "Knowledge layer via OKF: soul bundles, instance memory (STATE.md/log.md/notes/), continuous post-commit harvest into the soul (commit, PR, or direct-edit for local souls), craft + memory skills, validator.",
8
10
  "requires": [],
9
11
  "settings": {
10
12
  "harvest-runtime": {
11
13
  "default": "pi",
12
- "values": ["pi", "claude", "codex"],
14
+ "values": [
15
+ "pi",
16
+ "claude",
17
+ "codex"
18
+ ],
13
19
  "description": "Harness for the memory harvester, independent of the source instance's runtime."
14
20
  },
15
21
  "harvest-model": {
@@ -0,0 +1,34 @@
1
+ # Starting an existing instance
2
+
3
+ An instance keeps its home, identity, work and notes when its harness stops.
4
+ The Desktop roster is the place to return to it:
5
+
6
+ - A running row opens its terminal.
7
+ - A stopped row offers **Start…**. Clicking the row opens the same dialog.
8
+ - The hierarchy's action popover offers **Start…** for a stopped instance.
9
+ - An unknown status is shown as unknown, not as permission to launch another process.
10
+
11
+ The Start dialog names the existing instance, runtime and host. Enter a model
12
+ or leave the field blank to retain its recorded choice. Available local model
13
+ suggestions are advisory; a model ID can also be typed. Start uses the saved
14
+ briefing and state in a new harness conversation; it does not resume an old
15
+ harness conversation ID. After the launch appears in the roster, Desktop
16
+ opens the instance's terminal.
17
+
18
+ If the instance is already running when the dialog checks, its action becomes
19
+ **Open terminal**. A failed or timed-out start requires **Refresh status** before
20
+ another attempt, because the launch may have succeeded before the reply was
21
+ lost. Changing workspaces dismisses the dialog and prevents a delayed launch
22
+ reply from opening a terminal in the wrong workspace.
23
+
24
+ Desktop sends `POST /api/start/<instance>?ws=…&home=…` (and `server=…` for a
25
+ remote instance). The backend resolves that exact roster identity and calls
26
+ `oats session start --home <absolute-home> [--server <id>] [--model <model>] --json`.
27
+ The installed CLI must advertise `session-start`; remote starting also needs
28
+ the remote operation. The execution host checks the actual saved session
29
+ before launch. Desktop does not scaffold a home or execute a launcher itself.
30
+
31
+ Status collection reads each instance's recorded tmux socket and session,
32
+ with one query per socket per collection. A launcher shell with a harness
33
+ child remains running; a fallback shell or dead pane is stopped. Errors that
34
+ prevent a reliable observation remain unknown. Herdr uses its saved target.
@@ -117,8 +117,41 @@ oats session attach --home /absolute/instance
117
117
  oats session inspect --home /absolute/instance --json
118
118
  oats session input --home /absolute/instance --text-file /path/to/message --json
119
119
  printf '%s' 'Check your pending work.' | oats session input --home /absolute/instance --json
120
+ oats session start --home /absolute/instance [--model <id>] --json
120
121
  ```
121
122
 
123
+ `start` runs a STOPPED instance again in its existing home: same identity,
124
+ worktree, notes and launch environment, no spawn hooks, no new home. It reuses
125
+ the persisted launch command, on the recorded tmux session and socket or the
126
+ recorded Herdr server, and records the new session target in the instance
127
+ metadata and the independent lifecycle receipt (whose home and work
128
+ fingerprints are untouched, so a later retire still preserves everything
129
+ changed since the original spawn). `--model` replaces the recorded model for
130
+ this and later starts by re-rendering the persisted command; a command shape
131
+ OATS did not generate is refused rather than rewritten. A quarantined home or one whose self-retirement is in progress is refused (`E_INSTANCE_RETIRING`). A live harness is
132
+ refused (`E_SESSION_RUNNING`); a fallback shell with no harness descendant
133
+ and a dead pane restart in that exact pane, a missing window opens again, and
134
+ a lost tmux server after a reboot is recreated on the recorded socket. A state
135
+ that cannot be established refuses (`E_SESSION_UNKNOWN`). Every observation
136
+ happens under a per-home guard, so two starts of one home serialize
137
+ (`E_SESSION_START_BUSY`). Each launch retains `.oats-start-pending.json`
138
+ with its target and unique id. The wrapper writes that id to
139
+ `.oats-start-exited` only when the saved command returns. A shell without
140
+ the matching exit marker is still starting and cannot be respawned by a
141
+ second caller. This also covers shell-only harness initialization; it needs
142
+ no background monitor. The next start reconciles the complete receipt before
143
+ the ordinary metadata check: a
144
+ target that is present is recorded and adopted; an exited target is reconciled
145
+ before restarting, and an unobservable target or malformed receipt refuses
146
+ and keeps the receipt. Recovery is idempotent for an already-recorded launch
147
+ and never silently applies a new model to an
148
+ already-running harness. A never-launched legacy Herdr home without a saved
149
+ server endpoint requires that endpoint to be configured before it can start;
150
+ it does not fall back to tmux.
151
+ The start opens a new harness conversation on the instance's `TASK.md`; the
152
+ instance resumes its work from its own `STATE.md`, as the knowledge protocol
153
+ prescribes.
154
+
122
155
  `attach` is interactive and does not accept `--json`. It validates the saved
123
156
  endpoint on the execution host, then opens a Herdr terminal viewer or an
124
157
  isolated tmux session linked to that agent's window alone. Closing its terminal
@@ -167,6 +167,14 @@ start a second harvester while the first one's home exists. The check uses the
167
167
  existing prepared watermark file and does not treat a successful spawn as
168
168
  completed learning.
169
169
 
170
+ ## oats.okf 1.5.2
171
+
172
+ `okf harvest` exits non-zero when it reports a failure (the plain and the
173
+ `--json` forms alike). A leftover `memory-harvest/<slug>` branch from a merged
174
+ promotion is deleted before the next workspace-mode harvest; an unmerged one
175
+ refuses the harvest and names the remedy. `oats okf harvest --help` prints
176
+ usage and never spawns.
177
+
170
178
  ## oats.aweb late joins (1.10.3)
171
179
 
172
180
  `aw team join` at spawn gets 120 s (a slow link is slow, not broken). If the
@@ -33,24 +33,30 @@ Every remembering role must have a tested learning path. Preserve each role's
33
33
  explicit policy: Cjr reviewers exclude accumulated memory; Themis uses
34
34
  reviewed learning. Config discovery alone establishes none of this.
35
35
 
36
- The installed CLI baseline is published OATS 0.22.5 on this Mac and `aweb-agents`,
36
+ The installed CLI baseline is published OATS 0.22.7 on this Mac and `aweb-agents`,
37
37
  including native Pi/Claude/Codex, tmux/Herdr, shared `yolo`, remote Desktop
38
38
  roster/actions, retained-authority binding and corrected deferred retirement.
39
- The installed Mac Desktop 0.22.5 passed published ZIP checksum, strict deep
40
- codesign, packaged renderer and PTY launch checks; the previous 0.22.4 app
41
- is preserved for rollback. Official oats.okf 1.5.1 is published after independent
39
+ The installed Mac Desktop 0.22.7 passed published ZIP checksum and strict deep
40
+ codesign. Its controlled single-instance check passed and the app was closed
41
+ afterwards, with all owned processes verified gone. The previous 0.22.6 app
42
+ is preserved for rollback; earlier renderer/PTY checks remain version-specific.
43
+ Official oats.okf 1.5.1 is published after independent
42
44
  review, adding harvest-runtime selection and detection of unadvanced record
43
45
  plans. Each deployment selects an authenticated harness; without an explicit
44
46
  harvest-model, that harness uses its own configured default. Some prepared
45
47
  teams still use the compatible 1.5.0 package; preserve the exact versions of
46
- each earlier qualification.
48
+ each earlier qualification. Follow-up 1.5.2 is now published with truthful
49
+ failure exits, harmless help and repeat-harvest branch reuse. BeadHub's actual
50
+ deployment has been updated and re-trusted at 1.5.2; its previous lock was 1.5.0.
47
51
 
48
52
  BeadHub, Minerva and Merlin now have managed standing executions with retained
49
53
  identities. BeadHub and Minerva passed separate mail/chat checks; Merlin verified
50
54
  his identity and preserved claims, then received and replied to Minerva's real
51
- mail through the host wake path. This is not completion of all teams: frontend
52
- and Themis encountered failed setup, Docflow still has a running backfill, and
53
- the coordinator handovers remain outstanding. See the current status below;
55
+ mail through the host wake path. Frontend also has a managed execution with
56
+ separate mail/chat proof and reviewed knowledge promotion. This is not completion
57
+ of all teams: TSM is held, Docflow still has a running backfill, and normal
58
+ coordinator learning remains queued. The final lead handover is accepted.
59
+ See the current status below;
54
60
  older evidence records keep the version and outcome of each earlier check.
55
61
 
56
62
  ## Current status and operating limits (2026-09-06)
@@ -70,22 +76,36 @@ no OATS Desktop process remained when lead took the incident snapshot.
70
76
 
71
77
  Lead stopped the capture watcher and residual capture passes, disabled their
72
78
  exact Claude hooks, and stopped the experimental mind follow service. Settings,
73
- service definitions, raw records and learning state are preserved. GUI launch
74
- is paused. Resume with one bounded operation at a time, checking memory between
75
- launches; declining swap alone does not prove sustained stability. Capture stays
76
- disabled until its concurrency fix is independently reviewed and measured. The
77
- first proposed lock was rejected because age-based stealing and initialization
78
- races could still permit overlapping passes.
79
+ service definitions, raw records and learning state are preserved. Continue
80
+ with one bounded operation at a time, checking memory between
81
+ launches; declining swap alone does not prove sustained stability. Published
82
+ 0.22.6 prevents overlapping capture passes with a conservative lock that never
83
+ steals an existing owner; interrupted owners require explicit recovery. Its
84
+ measured full pass still exceeded a 2 GiB RSS budget during indexing and was
85
+ stopped by the monitor. The follow-up streams journal entries instead of loading
86
+ whole arrays. The independently reviewed candidate completed the real index of
87
+ 1.74 million turns in 41.6 seconds, at 809.5 MiB peak RSS with normal memory
88
+ pressure, under a 256 MiB Node old-space budget. That fix is now published and
89
+ installed in 0.22.7. The replacement launchd job runs one background pass every
90
+ 15 minutes, without per-tool hooks or a permanent watcher. Its first real pass
91
+ completed in 101 seconds at 746 MiB peak RSS, with index completion and zero
92
+ aw-log projection failures. The reviewed operator wrapper stops its own child
93
+ on a 2 GiB RSS limit, two elevated memory-pressure samples, or a five-minute
94
+ deadline. It records each run atomically in
95
+ `~/.local/state/oats/capture/status.json`, retaining the previous success time
96
+ through failures. Idle between passes is normal. Interrupted capture locks
97
+ still need explicit owner-checked recovery; the wrapper reports the remedy.
98
+ Experimental mind follow remains paused.
79
99
 
80
100
  | Scope | Verified state | Next boundary |
81
101
  | --- | --- | --- |
82
102
  | Host services | Published aw 1.36.1 installed; normal launchd wake service on Mac and enabled user service on `aweb-agents`; private broker stopped | Investigate repeated reconnect hints and reported read timing without assuming the broker acknowledged mail |
83
- | BeadHub | `beadhub-seat`, retained DID/address and claims; native Codex; independent mail/chat; first reviewed knowledge PR merged at `70c839e` | Repeat harvest exposed a retained merged-branch collision; operator updated the linked soul and removed only the verified merged branch; next cycle waits for a bounded launch slot |
84
- | Cjr | `accountant-minerva` and `coordinator-merlin` live on retained identities; old holders stopped first; claims preserved; real delivery and reviewed learning recorded | Complete the existing log worker's fresh review, one reviewer at a time; no financial authority changes |
85
- | Aweb | Coordinator remains live; old frontend stopped; replacement failed during a timed-out join that completed server-side | Supported cleanup of the retained failed home/orphan binding, then one successor with independent delivery checks |
103
+ | BeadHub | `beadhub-seat`, retained DID/address and claims; native Codex; independent mail/chat; trusted OKF 1.5.2 and aweb 1.10.3; repeat harvest promoted all four notes and retired cleanly | PR #2 at `a111a222` independently reviewed and merged; canonical deployment checkout fast-forwarded with untracked data preserved, so the live soul sees the six-concept bundle |
104
+ | Cjr | `accountant-minerva` and `coordinator-merlin` live on retained identities; old holders stopped first; claims preserved; real delivery and reviewed learning recorded | Log `5bfeefd1` and corrected librarian knowledge `6c91f986` landed with user edits preserved; librarian retired with alias reusable; Merlin's own harvest and health-reader follow-up remain queued |
105
+ | Aweb | Retained `coordinator-aweb` and fresh `frontend-oats` both passed independent mail/chat; frontend knowledge `d649d729` reviewed and landed | Old holders stopped with their channel children before replacement; metadata repair and encryption-key findings tracked separately, with repeated wake-hint diagnosis in progress |
86
106
  | TSM | Prepared souls and owner checkpoints; Themis setup failed before the current hold | **No migration work until Zeus reports deployment finished**; re-inventory with its owner afterwards |
87
107
  | Docflow | Legacy seat and actual mail backfill remain running | Finish backfill and register checks; owner restores mail-ingest afterwards; accountant-sync remains unloaded under its separate export fence |
88
- | Oats/lead | Existing coordinators remain active | Last handovers, with actual stop receipts and all unresolved work carried forward |
108
+ | Oats/lead | Retained `oats-coordinator-coordinator` passed independent mail wake; fresh team-local `lead-operating-lead` passed separate idle-to-wake mail fetch/reply after verified predecessor stop; root packages trusted at OKF 1.5.2 and aweb 1.10.3 | BeadHub accepted the lead and transferred the still-open rollout epic; Oats's provider capacity, historical lead claim reconciliation, coordinator learning and preserved recovery lessons remain follow-ups; cross-team updates use retained global relays |
89
109
  | Remote qualification | Published host service delivered native Claude mail/chat through Herdr; corrected knowledge independently reviewed; source retired with `aliasReusable: true` | Earlier separate fresh-reader cycle passed; latest corrected wake-specific retrieval is still pending |
90
110
 
91
111
  Published aw 1.36.1 is tagged at `bfdb20886080e4ffe1f02b266f6116d12bd100fd`.
@@ -97,10 +117,17 @@ Production same-alias join/delete/rejoin passed, and official oats.aweb 1.10.2
97
117
  reports the released alias result truthfully. Retained standing-seat retirement
98
118
  must still preserve authority.
99
119
 
100
- Desktop 0.22.5 has six validated team roots saved as workspace suggestions,
120
+ Desktop has six validated team roots saved as workspace suggestions,
101
121
  not six running GUI instances. It starts with one workspace and can add others.
102
122
  It is currently closed; visual QA and sustained multi-workspace memory behavior
103
- are not claimed. Native remote Pi authentication and remote Codex remain
123
+ are not claimed. Version 0.22.6 added a single-instance guard and 0.22.7 runs the
124
+ packaged backend as Node. An installed 0.22.7 check served the Oats workspace API;
125
+ a second launch exited successfully while the primary remained, with both
126
+ owned process groups peaking at 589 MiB and normal memory pressure. All test
127
+ processes were stopped and verified absent. The full release gate and all three
128
+ Desktop builds passed on hosted runners; publication succeeded, and the bot's
129
+ version-bump PR permission failure was resolved through reviewed manual PR #8.
130
+ Native remote Pi authentication and remote Codex remain
104
131
  unqualified; the accepted remote harness is Claude.
105
132
 
106
133
  ## Scope inventory
@@ -119,7 +146,7 @@ of continuing seats.
119
146
  | `~/prj/docflow` | Live Claude seat identified itself as local `juan.aweb.ai/alice` on `docflow:juan.aweb.ai` | Owner Juan; finish running mail backfill and register checks before transfer; retain identity, memory and Minerva route; accountant-sync remains deliberately unloaded |
120
147
  | `ai.aweb` on `aweb-agents` | Aweb confirms Athena intentionally inactive; remote legacy home retained | Aweb and oats own archival inspection; do not resurrect as a continuing seat |
121
148
  | `~/awebai/demo-aweb/bob` | Live Pi demo | Aweb owns safe stop and archival disposition; it is not an operating-team migration |
122
- | `~/.turn-record` | Capture and experimental mind services paused after memory incident | Preserve records; review and measure resource fixes before resuming |
149
+ | `~/.turn-record` | Guarded periodic capture enabled and measured; per-tool hooks and experimental mind follow remain disabled | Preserve records; monitor the run artifact; no blind watcher restart |
123
150
 
124
151
  The starting inventory above was checked on 2026-09-05 using harness process
125
152
  working directories and exact custom tmux sockets, without interrupting them.
@@ -0,0 +1,40 @@
1
+ # OATS v0.22.8
2
+
3
+ Two fixes an operator meets directly: picking a remote workspace in Desktop
4
+ now works, and the bundled catalog pin for the knowledge package catches up
5
+ with what is published.
6
+
7
+ ## Desktop: selecting a discovered remote workspace
8
+
9
+ A registered SSH server's workspaces are discovered after the app starts, so
10
+ the main process's record of what the backend advertises could be seeded
11
+ before that discovery finished. Clicking the remote workspace in the menu then
12
+ looked like nothing happened: the request carried a workspace id the record
13
+ did not recognize, and an unrecognized selection is deliberately pinned back
14
+ to the verified local scope. The workaround was to open Add workspace and
15
+ cancel, whose own refresh repaired the record as a side effect.
16
+
17
+ The main process now learns that record from the successful `/api/panel`
18
+ responses it is already making, which are the same server-owned choices the
19
+ menu is built from, so a workspace that appears late is selectable as soon as
20
+ the menu can show it. No request is added, including for a selection that
21
+ stays unrecognized. Only a successful panel response teaches it: error
22
+ replies and other endpoints cannot. Nothing learned from an outgoing backend
23
+ can survive its replacement: a request that begins while the backend is being
24
+ replaced never teaches, and one that began earlier is refused if a replacement
25
+ started before its reply arrived, so the workspaces a stopped server
26
+ advertised cannot come back. Off-origin resolution is still rejected before
27
+ anything is fetched, and a selection the backend does not advertise is still
28
+ pinned to the local scope.
29
+
30
+ ## Bundled: oats.okf 1.5.2 pin
31
+
32
+ The catalog shipped with the kernel now pins oats.okf v1.5.2, so
33
+ `oats update oats.okf` resolves it without an explicit selector. Until now a
34
+ bare update resolved the pin baked into the installed kernel, which was
35
+ v1.5.1, and reaching 1.5.2 needed `oats update oats.okf --to v1.5.2`.
36
+ Deployments that moved early with the explicit selector are already on the
37
+ same payload and need nothing. 1.5.2 makes a failed harvest exit non-zero,
38
+ stops `okf harvest --help` from running a harvest, and reclaims a leftover
39
+ `memory-harvest/<slug>` branch whose promotion is already merged into the
40
+ base, including when the base is a remote main ahead of the local one.
@@ -0,0 +1,100 @@
1
+ # OATS v0.22.9
2
+
3
+ A stopped instance can be started again in its existing home, from the CLI
4
+ and from Desktop, with the model chosen at that moment; Desktop's running
5
+ status and terminal attachment follow each instance's saved tmux socket.
6
+
7
+ ## Start a stopped instance in its existing home
8
+
9
+ `oats session start --home <absolute-home> [--model <id>] [--json]` runs the
10
+ instance's persisted launch command again in the same home: same identity,
11
+ worktree, notes and launch environment, no spawn hooks, no new home. The
12
+ command runs in the recorded tmux session on the recorded socket, or on the
13
+ recorded Herdr server. A fallback shell with no harness descendant and a dead
14
+ pane restart in that exact pane; a missing window opens again; a tmux server
15
+ lost to a reboot is recreated on the recorded socket path. A live harness is
16
+ refused (`E_SESSION_RUNNING`), a state that cannot be established is refused
17
+ (`E_SESSION_UNKNOWN`, including permission failures, which are not treated as
18
+ absence), and a quarantined home or one whose self-retirement is in progress
19
+ is refused (`E_INSTANCE_RETIRING`). Nothing is started by any refusal.
20
+
21
+ `--model` replaces the recorded model for this and later starts. The kernel
22
+ re-renders the persisted command through a parser of the exact shapes spawn
23
+ generates; an existing `--model` value is replaced in place, otherwise the
24
+ pair is inserted after the harness binary, and every other token, including
25
+ capability launch environment and arguments, is kept byte for byte. A command
26
+ OATS did not generate, a duplicate or valueless `--model`, or a preference
27
+ whose provider prefix is incompatible with the recorded runtime is refused
28
+ rather than rewritten or silently defaulted. A runtime model id is passed
29
+ through to the harness as given; the kernel does not validate it against a
30
+ catalog. Omitting the model keeps the recorded one.
31
+
32
+ The start opens a new harness conversation from the instance's saved briefing
33
+ (`TASK.md`) and the instance resumes from its own `STATE.md`, as its
34
+ knowledge protocol prescribes. It is not a native conversation resume.
35
+
36
+ ## Recovery and the duplicate guard
37
+
38
+ Every observation a start makes happens under a per-home lock, so two starts
39
+ of one home (a double click, two clients) serialize instead of both seeing
40
+ "stopped" and allocating twice (`E_SESSION_START_BUSY`). The independent
41
+ lifecycle receipt and the instance metadata are updated in that order, the
42
+ receipt keeping its home and work fingerprints so a later retire still
43
+ preserves everything changed since the original spawn.
44
+
45
+ A per-launch receipt carrying a launch id and the actual target is written
46
+ before the harness is launched (a Herdr start allocates its empty pane first)
47
+ and is retained for the launch's lifetime, including after the metadata is
48
+ recorded. The command wrapper writes a matching exit marker only when the
49
+ saved command returns, so a start that observes only shells without that
50
+ marker sees a launch still starting up, or a transient child such as the
51
+ briefing being read, and refuses rather than mistaking it for a fallback
52
+ shell; no background watcher or polling is involved. A start that launched
53
+ but could not record its metadata leaves the receipt in place, and the next
54
+ start reconciles it before the ordinary metadata check: a present target is
55
+ recorded and adopted, an exited one is reconciled and restarted, and an
56
+ unobservable target refuses and keeps the receipt. Recovery is idempotent
57
+ through the launch id recorded in the metadata, so an attempt already
58
+ recorded is not counted again. Every field of the receipt, the command, the
59
+ model, the id, the timestamp and the target, is validated before any
60
+ authority or metadata is touched; a malformed receipt refuses and is kept
61
+ unchanged. A running adopted target never has a newly requested model applied
62
+ silently; that start is refused and says so.
63
+
64
+ ## Desktop: start with model choice, and saved-socket status
65
+
66
+ A stopped instance's sidebar row and hierarchy popover offer Start, which
67
+ opens a same-home dialog showing the existing runtime, host and home, with an
68
+ optional model (blank keeps the recorded one). The dialog re-checks status
69
+ before submitting, converts a running instance to Open terminal, and blocks
70
+ explicitly on an unknown state, an old CLI or a remote without a route. It
71
+ calls only the kernel command above and waits for the roster to show the
72
+ instance before opening its saved terminal target. When the launch returns
73
+ but the harness is not observed running, the dialog says the instance may
74
+ have exited and offers a status refresh before any retry, instead of
75
+ assuming the roster is merely late.
76
+
77
+ Status now reads each instance's saved tmux socket, session and window, and
78
+ sees a harness running under its launcher shell; a dead pane or fallback
79
+ shell is stopped and an observation error is unknown, never a guess. The
80
+ terminal transport carries that saved socket end to end (preflight, viewer,
81
+ PTY attachment, resource identity and cleanup), so two instances with the
82
+ same window name on different sockets are distinct and input never lands in
83
+ the wrong one. Viewer sessions left behind by a crash are reclaimed on the
84
+ default server at startup and on each saved socket when a terminal is next
85
+ opened there.
86
+
87
+ ## Remote hosts
88
+
89
+ `oats session start --server <id> (--instance <name> | --home <abs>)
90
+ [--model <id>]` routes over the saved route and runs the same command on the
91
+ execution host. The kernel's version probe advertises `session-start` in its
92
+ features and remote lists; the local side reads the server's version probe
93
+ and refuses before the remote start, so nothing is mutated on a host whose
94
+ kernel does not advertise it. A remote host needs this release installed
95
+ there first.
96
+
97
+ ## Limitation
98
+
99
+ A never-launched legacy Herdr home has no saved server endpoint; starting it
100
+ refuses with that reason and does not fall back to tmux.
package/docs/servers.md CHANGED
@@ -128,7 +128,10 @@ under another soul is observed only.
128
128
 
129
129
  - Routed: `spawn`, `retire`, `status`, `okf harvest`, and, against a 0.22.2
130
130
  or later server, `session inspect` (the execution host's envelope, relayed;
131
- a Desktop preflight before attaching) and `session attach`. Session input
131
+ a Desktop preflight before attaching) and `session attach`; against a
132
+ server whose version probe advertises the `session-start` feature (0.22.9
133
+ or later), `session start` (the execution host starts the stopped instance
134
+ in its saved home; `--model` travels). Session input
132
135
  runs on the execution host, where the wake broker calls it. `server roster`
133
136
  is local (registrations and saved routes, one status pull per group). The
134
137
  version probe's `remote` list names this kernel's remote-side surface
package/lib/core.mjs CHANGED
@@ -32,11 +32,11 @@ import {
32
32
  } from "node:fs";
33
33
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
34
34
  import { homedir, tmpdir } from "node:os";
35
- import { createHash } from "node:crypto";
35
+ import { createHash, randomUUID } from "node:crypto";
36
36
  import { fileURLToPath } from "node:url";
37
37
  import { attachSessionTarget } from "./session-viewer.mjs";
38
38
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
39
- import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget } from "./herdr.mjs";
39
+ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot } from "./herdr.mjs";
40
40
 
41
41
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
42
42
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
@@ -6448,6 +6448,321 @@ export function inputInstanceSession(home, text) {
6448
6448
  catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
6449
6449
  }
6450
6450
 
6451
+ // ---------------------------------------------------------------- session start
6452
+
6453
+ /** The exact prompt token spawn renders for claude and codex launches. */
6454
+ const LAUNCH_PROMPT_TOKEN = '"$(cat TASK.md)"';
6455
+
6456
+ /** Tokenize a persisted OATS launch command. The grammar is exactly what
6457
+ * spawn renders: space-separated tokens that are env assignments NAME='v',
6458
+ * single-quoted words ('...' with '\'' escapes, i.e. shq output), bare words
6459
+ * with no shell metacharacters (flags and capability launch args), the bare
6460
+ * `--` separator, or the exact prompt token "$(cat TASK.md)". Anything else
6461
+ * is refused with its position: the command was not one OATS
6462
+ * generated, and rewriting it would be guessing. Re-rendering joins the
6463
+ * tokens' original text, so untouched tokens are byte-identical. */
6464
+ export function parseLaunchCommand(command) {
6465
+ if (typeof command !== "string" || !command.trim() || command.includes("\0")) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", "instance has no valid persisted launch command to start from");
6466
+ const bad = (at, why) => { throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `persisted launch command is not a shape this kernel re-renders (${why} at offset ${at}); inspect the saved command in instance.json before starting this home manually`); };
6467
+ const tokens = [];
6468
+ const n = command.length;
6469
+ let i = 0;
6470
+ while (i < n) {
6471
+ if (command[i] === " ") { i++; continue; }
6472
+ const start = i;
6473
+ if (command.startsWith(LAUNCH_PROMPT_TOKEN, i)) {
6474
+ i += LAUNCH_PROMPT_TOKEN.length;
6475
+ if (i < n && command[i] !== " ") bad(start, "text glued to the prompt token");
6476
+ tokens.push({ kind: "prompt", text: LAUNCH_PROMPT_TOKEN });
6477
+ continue;
6478
+ }
6479
+ let envName;
6480
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)='/.exec(command.slice(i));
6481
+ if (m) { envName = m[1]; i += envName.length + 1; }
6482
+ if (command[i] === "'") {
6483
+ i++;
6484
+ let value = "";
6485
+ for (;;) {
6486
+ if (i >= n) bad(start, "unterminated single quote");
6487
+ if (command[i] === "'") {
6488
+ if (command.startsWith("'\\''", i)) { value += "'"; i += 4; continue; }
6489
+ i++; break;
6490
+ }
6491
+ value += command[i++];
6492
+ }
6493
+ if (i < n && command[i] !== " ") bad(start, "text glued to a quoted token");
6494
+ const text = command.slice(start, i);
6495
+ tokens.push(envName ? { kind: "env", name: envName, value, text } : { kind: "word", value, quoted: true, text });
6496
+ continue;
6497
+ }
6498
+ if (envName) bad(start, "unquoted env value");
6499
+ while (i < n && command[i] !== " ") {
6500
+ if (/[\s'"$`\\;|&<>(){}*?~#]/.test(command[i])) bad(start, "shell metacharacter outside quotes");
6501
+ i++;
6502
+ }
6503
+ const word = command.slice(start, i);
6504
+ tokens.push(word === "--" ? { kind: "sep", text: word } : { kind: "word", value: word, quoted: false, text: word });
6505
+ }
6506
+ let binary = -1;
6507
+ for (let k = 0; k < tokens.length; k++) {
6508
+ if (tokens[k].kind === "env") { if (binary >= 0) bad(0, "env assignment after the binary"); continue; }
6509
+ if (binary < 0) { if (tokens[k].kind !== "word" || !tokens[k].quoted) bad(0, "no quoted binary after the env prefix"); binary = k; }
6510
+ }
6511
+ if (binary < 0) bad(0, "no binary");
6512
+ let modelIndex = -1;
6513
+ for (let k = binary + 1; k < tokens.length; k++) {
6514
+ if (tokens[k].kind === "sep") break;
6515
+ if (tokens[k].kind === "word" && !tokens[k].quoted && tokens[k].value === "--model") {
6516
+ const v = tokens[k + 1];
6517
+ if (!v || v.kind !== "word" || v.value.startsWith("-")) bad(0, "--model without a value");
6518
+ if (modelIndex >= 0) bad(0, "duplicate --model options");
6519
+ modelIndex = k + 1;
6520
+ k++;
6521
+ }
6522
+ }
6523
+ return { tokens, binary, modelIndex };
6524
+ }
6525
+
6526
+ export function renderLaunchCommand(tokens) { return tokens.map((t) => t.text).join(" "); }
6527
+
6528
+ /** The persisted command with `model` as its --model value: an existing
6529
+ * --model value is replaced; otherwise the pair is inserted right after the
6530
+ * binary, before any option that could be waiting for a value. Capability
6531
+ * env, flags, launch args and the prompt expression are untouched. */
6532
+ export function withLaunchModel(command, model) {
6533
+ const { tokens, binary, modelIndex } = parseLaunchCommand(command);
6534
+ const valueToken = { kind: "word", value: model, quoted: true, text: shq(model) };
6535
+ if (modelIndex >= 0) tokens[modelIndex] = valueToken;
6536
+ else tokens.splice(binary + 1, 0, { kind: "word", value: "--model", quoted: false, text: "--model" }, valueToken);
6537
+ return renderLaunchCommand(tokens);
6538
+ }
6539
+
6540
+ function tmuxOn(socket, args, io) {
6541
+ return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
6542
+ }
6543
+
6544
+ function writeJsonAtomic(path, value, mode) {
6545
+ const tmp = `${path}.tmp-${process.pid}`;
6546
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", mode !== undefined ? { mode } : undefined);
6547
+ renameSync(tmp, path);
6548
+ }
6549
+
6550
+ /** Start a stopped instance again in its existing home: no new home, no
6551
+ * spawn hooks, no identity work. The persisted launch command runs in the
6552
+ * recorded tmux session (on the recorded socket) or Herdr server, the
6553
+ * instance metadata and the independent retirement receipt get the new
6554
+ * session target (the receipt's home and work fingerprints are untouched,
6555
+ * so retire still preserves everything changed since the original spawn),
6556
+ * and the wake broker's session linkage follows through those receipts.
6557
+ *
6558
+ * Every observation happens under a per-home lock (E_SESSION_START_BUSY for
6559
+ * a concurrent start), so two callers cannot both see "stopped" and
6560
+ * allocate twice. Refusals, before any mutation: unknown or unmanaged home,
6561
+ * a quarantined home or one whose self-retirement marker is present, a
6562
+ * receipt that disagrees with the metadata (the same rule retire and
6563
+ * session use), a session whose state cannot be
6564
+ * established, a live harness (E_SESSION_RUNNING), a model the recorded
6565
+ * runtime cannot use, and a command shape this kernel does not re-render.
6566
+ * A fallback shell with no harness descendant and a dead pane restart in
6567
+ * that exact pane; a missing window and a lost tmux server after a reboot
6568
+ * allocate again (the server on the same socket path).
6569
+ *
6570
+ * Every start retains .oats-start-pending.json naming its target and id.
6571
+ * Its wrapper writes the matching .oats-start-exited marker on command
6572
+ * exit, so shells during startup cannot be mistaken for fallback prompts.
6573
+ * There is no background monitor. The next start
6574
+ * reconciles that receipt BEFORE the ordinary metadata/receipt equality
6575
+ * gate: a target that is present (or a retained dead pane) is recorded and
6576
+ * adopted, an exited target has its metadata reconciled before restarting,
6577
+ * and a target that cannot be observed refuses and keeps the receipt. */
6578
+ export function startInstanceSession(home, o = {}) {
6579
+ if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
6580
+ const realHome = realPathOrNearest(home);
6581
+ const metaPath = join(realHome, "instance.json");
6582
+ if (!existsSync(metaPath)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json); nothing was started`);
6583
+ const lock = join(realHome, ".oats-start.lock");
6584
+ const pendingPath = join(realHome, ".oats-start-pending.json");
6585
+ const exitedPath = join(realHome, ".oats-start-exited");
6586
+ const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
6587
+ const lostTmuxServer = (e) => /no server running on |(?:error connecting to|failed to connect to) .*(?:No such file or directory|Connection refused)/i.test(String(e.stderr ?? e.message ?? ""));
6588
+ const launchFailure = (backend, error) => {
6589
+ // execFileSync errors embed argv (including capability environment) in
6590
+ // message; backend stderr can echo it too. Neither belongs in the API.
6591
+ const reason = error.code === "ENOENT" ? "backend executable unavailable"
6592
+ : error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
6593
+ return oatsError("E_SESSION_START_FAILED", `${backend} start of ${basename(realHome)} could not be confirmed (${reason}); inspect the recorded session before retrying. Launch evidence is retained in ${pendingPath}`);
6594
+ };
6595
+ // The independent receipt first (retire and session consult it), then the
6596
+ // mutable metadata; both tmp+rename. A failure between them is what the
6597
+ // pending receipt exists for.
6598
+ const record = (meta, { id, backend, target, model, command, startedAt, reused }, clearPending = true) => {
6599
+ const baselinePath = retirementBaselinePath(realHome);
6600
+ let baseline;
6601
+ try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
6602
+ if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== realHome || !runtimeAuthorityOf(baseline)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is invalid for ${realHome}`);
6603
+ baseline.runtime = backend === "herdr"
6604
+ ? { launched: true, sessionTarget: target }
6605
+ : { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
6606
+ writeJsonAtomic(baselinePath, baseline, 0o600);
6607
+ if (o.io?.failBeforeMetadataWrite) throw new Error("injected metadata write failure");
6608
+ const recorded = meta.startId === id;
6609
+ const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
6610
+ if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
6611
+ const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1) };
6612
+ if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
6613
+ else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
6614
+ writeJsonAtomic(metaPath, next);
6615
+ if (clearPending) rmSync(pendingPath, { force: true });
6616
+ return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: meta.runtime, backend, model: model ?? null, target, startedAt, restartCount: next.restartCount, reused };
6617
+ };
6618
+ try { mkdirSync(lock); }
6619
+ catch (e) {
6620
+ if (e.code === "EEXIST") throw oatsError("E_SESSION_START_BUSY", `another start of ${basename(realHome)} is in progress (${lock}); if no start is running, remove that directory and retry`);
6621
+ throw e;
6622
+ }
6623
+ try {
6624
+ if (existsSync(join(realHome, ".oats-rollback-incomplete.json"))) throw oatsError("E_INSTANCE_RETIRING", `${realHome} is a quarantined home whose cleanup is incomplete; finish its retirement (oats retire) before starting anything there`);
6625
+ // A self-retirement leaves this marker while its detached teardown runs:
6626
+ // the home is about to disappear, so nothing is relaunched into it.
6627
+ if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired (${retirePendingMarkerPath(realHome)} is present); nothing was started`);
6628
+ // 1. Reconcile a pending receipt before the equality gate: it may be the
6629
+ // only record of a session an earlier start allocated.
6630
+ if (existsSync(pendingPath)) {
6631
+ let pending;
6632
+ try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
6633
+ const pt = pending?.target;
6634
+ const validTarget = pt?.backend === "herdr" ? validHerdrTarget(pt)
6635
+ : pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
6636
+ let validCommand = false;
6637
+ try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
6638
+ const validReceipt = validTarget && validCommand
6639
+ && typeof pending.id === "string" && /^[a-zA-Z0-9-]{1,80}$/.test(pending.id)
6640
+ && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
6641
+ && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
6642
+ if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
6643
+ const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
6644
+ let st;
6645
+ try { st = inspectSessionTarget(pending.target, o.io); }
6646
+ catch (e) {
6647
+ if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
6648
+ else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (${pbackend === "herdr" ? `Herdr pane ${pending.target.paneId}` : `tmux ${pending.target.session}:${pending.target.window} on ${pending.target.socket}`}) that cannot be observed now: ${String(e.stderr ?? e.message ?? "").trim() || e.message}; the receipt ${pendingPath} is kept and nothing was started`);
6649
+ }
6650
+ // A launch may still consist entirely of shells (startup files, a
6651
+ // shell-script harness). Only its own completion marker proves this
6652
+ // is a fallback shell. Never respawn an accepted launch in that gap.
6653
+ if (st.present && st.state === "shell") {
6654
+ const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
6655
+ if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
6656
+ }
6657
+ // Reconcile even an exited target: the independent baseline may
6658
+ // already name it while metadata still names the old allocation.
6659
+ const meta = readMeta();
6660
+ const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
6661
+ if (st.present && st.state !== "shell") {
6662
+ if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), meta.runtime) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
6663
+ if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
6664
+ return done;
6665
+ }
6666
+ }
6667
+ // 2. The ordinary gate and observation, all under the lock.
6668
+ const receipt = instanceSessionTarget(realHome);
6669
+ const meta = readMeta();
6670
+ const runtime = meta.runtime;
6671
+ if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
6672
+ const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
6673
+ let command = meta.command;
6674
+ let model = meta.model || undefined;
6675
+ if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
6676
+ const resolved = resolveModelPreference(String(o.model), runtime);
6677
+ if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
6678
+ command = withLaunchModel(command, resolved);
6679
+ model = resolved;
6680
+ } else parseLaunchCommand(command);
6681
+ let target = receipt.target;
6682
+ let state = { present: false, state: "not-launched" };
6683
+ let serverGone = false;
6684
+ if (target) {
6685
+ try { state = inspectSessionTarget(target, o.io); }
6686
+ catch (e) {
6687
+ if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
6688
+ else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
6689
+ }
6690
+ }
6691
+ if (state.present && state.state !== "shell") throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
6692
+ const startedAt = new Date().toISOString();
6693
+ const id = randomUUID();
6694
+ const completedCommand = `${command}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
6695
+ let reused = "new";
6696
+ if (backend === "herdr") {
6697
+ if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
6698
+ if (state.present) { reused = "pane"; }
6699
+ else {
6700
+ const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
6701
+ try { herdrSnapshot(base, o.io); }
6702
+ catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
6703
+ target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
6704
+ }
6705
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6706
+ try { launchHerdr(target, `cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
6707
+ catch (e) { throw launchFailure("Herdr", e); }
6708
+ } else {
6709
+ const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
6710
+ const window = target?.window || meta.tmux?.window || meta.instance;
6711
+ let socket = target?.socket || meta.tmux?.socket;
6712
+ const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
6713
+ // A fallback shell (no harness descendant) or a retained dead pane is
6714
+ // the agent's own pane: the command runs there, no other window touched.
6715
+ const inPlace = state.paneId && (state.present || state.state === "stopped");
6716
+ if (inPlace) {
6717
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6718
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, windowCmd], o.io); }
6719
+ catch (e) { throw launchFailure("tmux", e); }
6720
+ reused = "pane";
6721
+ } else {
6722
+ const instancesRoot = dirname(realHome);
6723
+ const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
6724
+ if (!socket) {
6725
+ // Never launched (--no-launch): the default server, as spawn uses.
6726
+ if (!tmuxAlive(session)) {
6727
+ sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
6728
+ shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
6729
+ shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
6730
+ }
6731
+ socket = tmuxSocket(session);
6732
+ } else if (serverGone) {
6733
+ // The recorded server is gone (a reboot): the same socket path again.
6734
+ mkdirSync(dirname(socket), { recursive: true });
6735
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
6736
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
6737
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
6738
+ }
6739
+ let names = [];
6740
+ try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
6741
+ catch (e) {
6742
+ if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
6743
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
6744
+ }
6745
+ if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
6746
+ target = { backend: "tmux", session, window, socket: resolve(socket) };
6747
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6748
+ try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, windowCmd], o.io); }
6749
+ catch (e) { throw launchFailure("tmux", e); }
6750
+ }
6751
+ target = { backend: "tmux", session, window, socket: resolve(socket) };
6752
+ }
6753
+ // Keep launch evidence until the command exits or the target disappears.
6754
+ // A transient child (for example cat TASK.md) is not proof that startup
6755
+ // has finished. A later start reconciles the receipt without a watcher.
6756
+ try { return record(meta, { id, backend, target, model, command, startedAt, reused }, false); }
6757
+ catch (e) {
6758
+ if (e.code && String(e.code).startsWith("E_")) throw e;
6759
+ throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `tmux ${target.session}:${target.window} on ${target.socket}`}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
6760
+ }
6761
+ } finally {
6762
+ rmSync(lock, { recursive: true, force: true });
6763
+ }
6764
+ }
6765
+
6451
6766
  function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
6452
6767
  const classes = [];
6453
6768
  let baseline;
package/lib/servers.mjs CHANGED
@@ -614,6 +614,24 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
614
614
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance, home: route.home } } : envelope, stderr, route };
615
615
  }
616
616
 
617
+ /** The kernel version whose probe first advertises the `session-start`
618
+ * feature; the probe's features list is the actual check. */
619
+ export const SESSION_START_REMOTE_VERSION = "0.22.9";
620
+
621
+ /** `session start` on the execution host for a remote instance: the same
622
+ * route resolution as inspect, refused before any mutation when the remote
623
+ * kernel does not advertise session-start, the envelope relayed as is. */
624
+ export function startRemote(serverId, { instance, home, model } = {}, io = {}) {
625
+ const route = resolveRoute(serverId, { instance, home }, "session start");
626
+ const remote = requireSessionRemote(route.target, io);
627
+ if (!remote.features.includes("session-start")) {
628
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
629
+ }
630
+ const args = ["session", "start", "--home", route.home, ...(model ? ["--model", String(model)] : []), "--json"];
631
+ const { envelope, stderr } = runRemote(route.target, args, io);
632
+ return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
633
+ }
634
+
617
635
  export function attachArgv(serverId, { instance, home } = {}, io = {}) {
618
636
  const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach");
619
637
  if (!io.skipVersionCheck) requireSessionRemote(target, io);
@@ -2,7 +2,7 @@
2
2
  "packages": {
3
3
  "oats.okf": {
4
4
  "url": "https://github.com/awebai/oats-okf.git",
5
- "ref": "v1.5.1",
5
+ "ref": "v1.5.2",
6
6
  "path": "oats-package"
7
7
  },
8
8
  "oats.aweb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.7",
3
+ "version": "0.22.9",
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",