@awebai/oats 0.22.17 → 0.23.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.
Files changed (45) hide show
  1. package/README.md +7 -2
  2. package/bin/oats.mjs +365 -31
  3. package/docs/configuration.md +65 -0
  4. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
  5. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  6. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  7. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  8. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  9. package/docs/design/launch-configurations.md +164 -0
  10. package/docs/design/package-runtime-api.md +177 -3
  11. package/docs/desktop-cli-api.md +68 -2
  12. package/docs/desktop-instance-start.md +39 -3
  13. package/docs/execution-targets.md +16 -0
  14. package/docs/knowledge-capability-authoring.md +98 -0
  15. package/docs/knowledge-reference/acceptance.md +108 -0
  16. package/docs/knowledge-reference/adoption.md +61 -0
  17. package/docs/knowledge-reference/harvester.md +107 -0
  18. package/docs/knowledge-reference/model.md +84 -0
  19. package/docs/knowledge-reference/package-craft.md +126 -0
  20. package/docs/knowledge-reference/provider-mapping.md +77 -0
  21. package/docs/knowledge-reference/reader-capture.md +87 -0
  22. package/docs/knowledge-theory.md +20 -6
  23. package/docs/layers.md +8 -7
  24. package/docs/oats-config.schema.json +33 -2
  25. package/docs/release-notes/v0.22.18.md +101 -0
  26. package/docs/release-notes/v0.22.19.md +115 -0
  27. package/docs/release-notes/v0.23.0.md +93 -0
  28. package/docs/souls-and-instances.md +18 -1
  29. package/injects/work-directory.md +18 -0
  30. package/lib/core.mjs +1109 -187
  31. package/lib/schedule.mjs +12 -2
  32. package/lib/servers.mjs +89 -4
  33. package/package.json +2 -2
  34. package/packages/record/README.md +19 -0
  35. package/packages/record/bin/capture.mjs +144 -53
  36. package/packages/record/bin/recall.mjs +17 -11
  37. package/packages/record/bin/record-native-start.mjs +11 -0
  38. package/packages/record/lib/capture-cc.mjs +82 -27
  39. package/packages/record/lib/capture-lock.mjs +81 -5
  40. package/packages/record/lib/formats.mjs +108 -21
  41. package/packages/record/lib/native-history.mjs +87 -0
  42. package/packages/record/lib/session-roots.mjs +90 -0
  43. package/packages/record/lib/session-snapshot.mjs +61 -0
  44. package/packages/record/lib/sessions-for-home.mjs +88 -56
  45. package/skills/oats/SKILL.md +3 -1
package/lib/schedule.mjs CHANGED
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
24
24
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { Cron } from "croner";
27
- import { ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
27
+ import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
28
28
 
29
29
  export const SCHEDULE_FILE = "oats-schedules.json";
30
30
  export const SCHEDULE_API = 1;
@@ -355,7 +355,17 @@ function launchCommand(ws, def, io) {
355
355
  let envelope, timedOut = false, raw = "";
356
356
  if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
357
357
  else {
358
- const env = { ...process.env }; delete env.OATS_INSTANCE; delete env.OATS_INSTANCE_HOME; delete env.PI_AGENT_INSTANCE; delete env.PI_AGENT_HOME;
358
+ const env = { ...process.env };
359
+ // A schedule may be ticked/run-now from inside an unrelated instance.
360
+ // Dispatch belongs to the job's cwd/explicit selectors, never that
361
+ // caller's frozen capability snapshot (including legacy OATS_HOME).
362
+ // Keep host configuration such as OATS_HOME_DIR and credentials intact.
363
+ for (const key of [...RESERVED_LAUNCH_ENV,
364
+ "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
365
+ "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
366
+ "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
367
+ "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
368
+ ]) delete env[key];
359
369
  const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
360
370
  timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
361
371
  raw = String(r.stdout || "");
package/lib/servers.mjs CHANGED
@@ -252,7 +252,8 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
252
252
  // capability-defined agent, or one with no live instance) has no default
253
253
  // this side can establish: the remote kernel validates its own default at
254
254
  // spawn, and nothing is asserted here about it.
255
- const runtime = flagOf("--runtime") || soul?.runtime;
255
+ const namedConfig = oatsArgs.includes("--launch-config");
256
+ const runtime = flagOf("--runtime") || (namedConfig ? undefined : soul?.runtime);
256
257
  // What the message may claim depends on what was established: an
257
258
  // advertising remote said what it supports; a silent one (before 0.22.2)
258
259
  // said nothing, and only pi and claude are assumed of it.
@@ -260,6 +261,7 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
260
261
  ? `it advertises runtimes ${remote.runtimes.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
261
262
  : `it does not advertise what it supports (kernels before 0.22.2 do not), so only pi and claude on tmux with no launch options are assumed of it; upgrade it there to use more`;
262
263
  const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
264
+ if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
263
265
  if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
264
266
  // A wake schedule at spawn is saved by the host: it must advertise schedules.
265
267
  if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
@@ -656,17 +658,100 @@ const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
656
658
  /** `session start` on the execution host for a remote instance: the same
657
659
  * route resolution as inspect, refused before any mutation when the remote
658
660
  * kernel does not advertise session-start, the envelope relayed as is. */
659
- export function startRemote(serverId, { instance, home, model } = {}, io = {}) {
660
- const route = resolveRoute(serverId, { instance, home }, "session start");
661
+ export function startRemote(serverId, choices = {}, io = {}) {
662
+ return launchRemoteSession(serverId, "start", choices, io);
663
+ }
664
+
665
+ export function restartRemote(serverId, choices = {}, io = {}) {
666
+ return launchRemoteSession(serverId, "restart", choices, io);
667
+ }
668
+
669
+ function remoteLaunchArgs({ launchConfig, runtime, model, yolo } = {}) {
670
+ const args = [];
671
+ if (launchConfig !== undefined) {
672
+ if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
673
+ args.push("--launch-config", launchConfig);
674
+ }
675
+ if (runtime !== undefined) {
676
+ if (!["pi", "claude", "codex"].includes(runtime)) throw serverError("E_BAD_ARGS", "runtime must be pi, claude or codex");
677
+ args.push("--runtime", runtime);
678
+ }
679
+ if (model !== undefined && model !== null && model !== "") {
680
+ if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
681
+ args.push("--model", model);
682
+ }
683
+ if (yolo !== undefined) {
684
+ if (typeof yolo !== "boolean") throw serverError("E_BAD_ARGS", "yolo must be true or false");
685
+ args.push(yolo ? "--yolo" : "--no-yolo");
686
+ }
687
+ return args;
688
+ }
689
+
690
+ function requireRemoteFeature(remote, target, feature) {
691
+ if (!remote.features.includes(feature)) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise ${feature}; upgrade it there; nothing was sent`);
692
+ }
693
+
694
+ function launchRemoteSession(serverId, action, choices, io) {
695
+ const { instance, home, launchConfig, runtime, yolo } = choices;
696
+ const choiceArgs = remoteLaunchArgs(choices);
697
+ const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
661
698
  const remote = requireSessionRemote(route.target, io);
662
699
  if (!remote.features.includes("session-start")) {
663
700
  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`);
664
701
  }
665
- const args = ["session", "start", "--home", route.home, ...(model ? ["--model", String(model)] : []), "--json"];
702
+ if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
703
+ if (launchConfig !== undefined || runtime !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
704
+ const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
666
705
  const { envelope, stderr } = runRemote(route.target, args, io);
667
706
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
668
707
  }
669
708
 
709
+ /** Scope operations follow the registration; existing-home operations follow
710
+ * its saved route. Definitions travel on stdin, never as remote file paths or
711
+ * shell arguments. The host resolves environment references and validates the
712
+ * definition; this client never substitutes its own environment. */
713
+ export function launchConfigRemote(serverId, options = {}, io = {}) {
714
+ const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
715
+ if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
716
+ const write = action === "set" || action === "remove";
717
+ const homeSelected = home !== undefined || instance !== undefined;
718
+ if (homeSelected && (context !== undefined || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "select one home or configuration scope");
719
+ if (write && (homeSelected || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "edit a launch configuration with --dir, not a home or soul");
720
+ if (action === "preview" && !homeSelected && !soul) throw serverError("E_BAD_ARGS", "select a home or soul to preview");
721
+ const args = ["launch-config", action];
722
+ if (write) {
723
+ if (typeof name !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
724
+ args.push(name);
725
+ }
726
+ let input;
727
+ if (action === "set") {
728
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
729
+ if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
730
+ if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
731
+ input = Buffer.from(JSON.stringify(definition));
732
+ args.push("--file", "-");
733
+ if (keepEnv) args.push("--keep-env");
734
+ }
735
+ const add = (flag, value, absolute = false) => {
736
+ if (typeof value !== "string" || !value || value.startsWith("-") || value.includes("\0") || (absolute && !value.startsWith("/"))) throw serverError("E_BAD_ARGS", `invalid ${flag}`);
737
+ args.push(flag, value);
738
+ };
739
+ // Validate explicit selectors before even probing the host.
740
+ if (context !== undefined) add("--dir", context, true);
741
+ if (soul !== undefined) add("--soul", soul);
742
+ if (agentsRoot !== undefined) add("--agents-root", agentsRoot, true);
743
+ if (action === "preview") args.push(...remoteLaunchArgs(options));
744
+ const route = homeSelected ? resolveRoute(serverId, { instance, home }, "launch-config") : undefined;
745
+ const target = route?.target || targetOf(io.server || getServer(serverId));
746
+ if (route) add("--home", route.home, true);
747
+ else if (context === undefined) args.push("--dir", target.workspace);
748
+ // stdin belongs only to the set operation, never its preceding version probe.
749
+ const remote = checkRemote(target, { ...io, input: undefined });
750
+ requireRemoteFeature(remote, target, "launch-config");
751
+ const { envelope, stderr } = runRemote(target, [...args, "--json"], { ...io, input });
752
+ return { envelope, stderr, target, ...(route ? { route } : {}) };
753
+ }
754
+
670
755
  /** `oats schedule ...` on the execution host: schedules are host-owned, so
671
756
  * every subcommand runs in the server's registered workspace; refused
672
757
  * before any remote mutation when the remote kernel does not advertise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.17",
3
+ "version": "0.23.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",
@@ -15,7 +15,7 @@
15
15
  "type": "module",
16
16
  "scripts": {
17
17
  "test": "node scripts/run-tests.mjs",
18
- "check": "node --check lib/core.mjs && node --check lib/packages.mjs && node --check bin/oats.mjs",
18
+ "check": "node scripts/check-package-dry-runs.mjs --syntax-only",
19
19
  "check:pi": "node --experimental-strip-types --check packages/pi/extension/index.ts",
20
20
  "validate": "node scripts/validate-project.mjs",
21
21
  "validate:okf": "node scripts/validate-okf.mjs",
@@ -209,3 +209,22 @@ into an orphaned inode until it restarts.
209
209
 
210
210
  - Deletion via tombstone is eventual: an offline replica retains bytes until
211
211
  it reconnects. The SOT says this plainly; so do we.
212
+
213
+ ## Per-home source authority
214
+
215
+ `capture --home <dir>` defaults to the kernel's independent managed-launch
216
+ record-location history, retained beside the home under `.oats-native-record/`.
217
+ It does not re-resolve old `fromEnv` references using the capturing process.
218
+ Missing history (legacy/standalone), pending launches, and missing historical
219
+ roots fail closed instead of certifying empty observer storage. Runtime switches
220
+ and resumed starts retain earlier roots. A managed scaffold with no starts has
221
+ an empty managed-launch inventory; executing a saved recipe by hand is not a
222
+ managed start.
223
+
224
+ For a deliberately observer-time inventory, explicitly pass `--current-roots`.
225
+ The JSON labels this `sourceRoots: "current-env"`, rather than `"launch-history"`;
226
+ completion then applies only to the chosen current inventory, not historical
227
+ source custody. The library alternative is `sessionsForHome(home, { roots })`:
228
+ unspecified formats are excluded, and missing supplied roots fail. Synthetic
229
+ standalone tests must choose one of these explicitly, not masquerade as a
230
+ managed native launch. Background capture without `--home` is unchanged.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { watch } from "node:fs";
16
16
  import { homedir, hostname } from "node:os";
17
- import { dirname, join } from "node:path";
17
+ import { join } from "node:path";
18
18
  import process from "node:process";
19
19
 
20
20
  import { RecordStore } from "../lib/store.mjs";
@@ -53,6 +53,7 @@ const BOOL_FLAGS = new Set([
53
53
  "sessions-only",
54
54
  "aw-only",
55
55
  "no-index",
56
+ "current-roots",
56
57
  ]);
57
58
 
58
59
  const USAGE = `capture — land sessions and aw client logs in the turn record.
@@ -65,10 +66,21 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
65
66
  capture --status show store/stream summary, capture nothing
66
67
  capture --home <dir> capture the sessions that ran inside <dir> (an
67
68
  OATS instance home) and print them as JSON:
68
- thread, stream, turn count, first/last turn id.
69
- Tombstoned turns are never a boundary. Codex
70
- keeps a day's rollouts in one directory, so the
71
- pass captures that day; the list is filtered.
69
+ thread, stream, turn count, first/last turn id;
70
+ status, complete, skipped, held, incomplete,
71
+ failed, ignored (explicit privacy exclusions).
72
+ Only complete:true confirms a performed pass
73
+ with no holds, incomplete tails, unattributed
74
+ sources or errors. Lock skips/holds exit 0 but
75
+ report complete:false; failures exit 1.
76
+ Only attributed files are captured, even in
77
+ shared directories. Unattributed non-ignored
78
+ sources conservatively block completion.
79
+ Tombstoned turns are never a boundary.
80
+ capture --current-roots --home <dir>
81
+ explicit observer-time inventory for standalone
82
+ or legacy sources lacking launch history. Not a
83
+ certificate of all historical source locations.
72
84
  capture --install-hint print the Claude Code hook snippet
73
85
  capture --help this text
74
86
  capture --quiet suppress per-pass progress
@@ -160,27 +172,65 @@ function log(...parts) {
160
172
 
161
173
  /** Take the root's single-run lock, or say who holds it. A hook-triggered
162
174
  * pass that finds it held exits 0: the holder's pass, or the next one,
163
- * reconciles the same sessions. */
175
+ * reconciles the same sessions.
176
+ *
177
+ * `fn` must return or throw, never call process.exit: the lock is released
178
+ * in the finally, and a release that did not happen is said so on stderr
179
+ * with the operator recovery and a nonzero exit status. Likewise an owner
180
+ * record that could not be written: the lock never silently outlives the
181
+ * pass that created it. */
164
182
  function withCaptureLock(fn) {
165
- const lock = acquireCaptureLock(root);
183
+ let lock;
184
+ try {
185
+ lock = acquireCaptureLock(root);
186
+ } catch (err) {
187
+ if (err.lockCleanup) {
188
+ const c = err.lockCleanup;
189
+ const outcome = c.removed ? "the initializing lock was removed"
190
+ : c.reason === "gone" ? "the initializing lock was already gone (removed by another party)"
191
+ : c.reason === "replaced" ? (c.owner
192
+ ? `the lock now belongs to pid ${c.owner.pid} (started ${c.owner.startedAt || "?"}) and was left alone`
193
+ : "the lock directory changed or its identity could not be verified, no owner record was readable, and it was left alone")
194
+ : `the initializing lock could NOT be removed${c.error ? ` (${c.error})` : ""}; ${c.recovery}`;
195
+ console.error(`capture: could not write the owner record of ${c.path}: ${err.message}; ${outcome}`);
196
+ }
197
+ throw err;
198
+ }
166
199
  if (lock.held) {
167
200
  // Never quiet: a stale lock after a killed pass needs the operator, and
168
201
  // the line says exactly what to check and what to remove.
169
202
  const line = `capture: another pass holds ${root}: ${lock.held.recovery}; skipping this pass`;
170
- if (lock.held.liveness === "alive") log(line); else console.error(line);
171
- return { appended: 0, skipped: true };
203
+ if (args.home) { if (!quiet || lock.held.liveness !== "alive") console.error(line); }
204
+ else if (lock.held.liveness === "alive") log(line); else console.error(line);
205
+ return { appended: 0, skipped: true, lock: lock.held };
206
+ }
207
+ try {
208
+ return fn();
209
+ } finally {
210
+ const r = lock.release();
211
+ if (!r.released) {
212
+ // Say what was observed: gone, unreadable (unknown), another owner, or
213
+ // our own lock that would not go away; never a guess about liveness.
214
+ const detail = r.reason === "gone" ? "it was already removed by another party (an operator recovery?); nothing to release"
215
+ : r.reason === "not-owner" ? `it now belongs to pid ${r.owner.pid} (started ${r.owner.startedAt || "?"}); left alone`
216
+ : r.reason === "unknown-owner" ? `its owner record is missing or unreadable, so it may be an operator removal in progress or a newer pass initializing; left alone; ${r.recovery}`
217
+ : `${r.error ? `${r.error}; ` : ""}${r.recovery}`;
218
+ console.error(`capture: did not release ${lock.path} (${r.reason}): ${detail}`);
219
+ process.exitCode = 1;
220
+ }
172
221
  }
173
- try { return fn(); } finally { lock.release(); }
174
222
  }
175
223
 
176
224
  function pass() {
225
+ // The privacy loader fails closed by exiting; it runs before the lock is
226
+ // taken so that exit never leaves the lock behind.
227
+ const ignore = loadIgnoreOrExit(root);
177
228
  return withCaptureLock(() => {
178
229
  const out = { appended: 0 };
179
- const ignore = loadIgnoreOrExit(root);
180
230
  if (!args["aw-only"]) {
181
231
  for (const r of captureAllSessions(store, { owner, ignore })) {
182
232
  out.appended += r.appended;
183
- const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : ""]
233
+ const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : "", r.incomplete ? `${r.incomplete} incomplete` : ""]
184
234
  .filter(Boolean)
185
235
  .join(", ");
186
236
  log(
@@ -263,55 +313,94 @@ if (args.status) {
263
313
  // exists for programs.
264
314
  if (args.home) {
265
315
  warnOnStrangerOwner();
266
- const ignore = loadIgnoreOrExit(root);
267
316
  const unattributed = [];
268
- const found = sessionsForHome(args.home, { onUnattributed: (source, path) => unattributed.push({ source, path }) });
317
+ let found = [];
269
318
  const sessions = [];
270
- const dirs = new Map(); // one capture pass per (format, directory)
271
- for (const s of found) dirs.set(`${s.source}\0${dirname(s.path)}`, { format: s.source, dir: dirname(s.path) });
272
- let appended = 0;
273
- const homePass = withCaptureLock(() => {
274
- let n = 0;
275
- for (const { format, dir } of dirs.values()) {
276
- n += captureSessions(store, { owner, roots: [dir], format, ignore }).appended;
319
+ const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
320
+ const issues = [];
321
+ let error;
322
+ try {
323
+ // Unlike background passes, --home always answers JSON, including errors.
324
+ // Do not exit from inside the lock callback: its finally owns release.
325
+ const ignore = loadIgnore(root);
326
+ found = sessionsForHome(args.home, {
327
+ ignore,
328
+ ...(args["current-roots"] ? { fallback: "current-env" } : {}),
329
+ onIgnored: () => outcome.ignored++,
330
+ onUnattributed: (source, path) => unattributed.push({ source, path }),
331
+ });
332
+ const formats = new Map(); // exact files, not their shared directories
333
+ for (const s of found) {
334
+ if (!formats.has(s.source)) formats.set(s.source, []);
335
+ formats.get(s.source).push(s);
277
336
  }
278
- if (!args["no-index"]) { // same as pass(): an earlier append-only pass may have left unindexed turns
279
- const index = new RecordIndex(store);
280
- try {
281
- index.update();
282
- } finally {
283
- index.close();
337
+ Object.assign(outcome, withCaptureLock(() => {
338
+ for (const [format, files] of formats) {
339
+ const r = captureSessions(store, { owner, files, format, ignore, final: true });
340
+ outcome.appended += r.appended;
341
+ outcome.held += r.held;
342
+ outcome.incomplete += r.incomplete;
343
+ outcome.ignored += r.ignored;
344
+ issues.push(...r.issues);
284
345
  }
346
+ if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
347
+ const index = new RecordIndex(store);
348
+ try {
349
+ index.update();
350
+ } finally {
351
+ index.close();
352
+ }
353
+ }
354
+ return outcome;
355
+ }));
356
+ // A tombstoned turn is hidden everywhere; a boundary naming one would be
357
+ // refused by recall, so boundaries come from the visible turns only.
358
+ const claims = store.tombstoneClaims();
359
+ for (const s of found) {
360
+ const stream = `${owner}~${s.source}.${s.sessionId}`;
361
+ const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
362
+ if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
363
+ sessions.push({
364
+ thread: s.thread,
365
+ source: s.source,
366
+ sessionId: s.sessionId,
367
+ path: s.path,
368
+ cwd: s.cwd,
369
+ stream,
370
+ turns: turns.length,
371
+ firstTurnId: turns[0].id,
372
+ lastTurnId: turns[turns.length - 1].id,
373
+ lastTs: turns[turns.length - 1].ts,
374
+ });
285
375
  }
286
- return { appended: n };
287
- });
288
- appended = homePass.appended;
289
- // A tombstoned turn is hidden everywhere; a boundary naming one would be
290
- // refused by recall, so boundaries come from the visible turns only.
291
- const claims = store.tombstoneClaims();
292
- for (const s of found) {
293
- const stream = `${owner}~${s.source}.${s.sessionId}`;
294
- const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
295
- if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
296
- sessions.push({
297
- thread: s.thread,
298
- source: s.source,
299
- sessionId: s.sessionId,
300
- path: s.path,
301
- cwd: s.cwd,
302
- stream,
303
- turns: turns.length,
304
- firstTurnId: turns[0].id,
305
- lastTurnId: turns[turns.length - 1].id,
306
- lastTs: turns[turns.length - 1].ts,
307
- });
376
+ } catch (err) {
377
+ error = err.message || String(err);
378
+ console.error(`capture pass failed: ${error}`);
379
+ outcome.failed++;
380
+ // captureSessions can throw after appending part of a directory. Do not
381
+ // claim zero (or a complete count) for a partially performed failed pass.
382
+ outcome.appended = null;
383
+ process.exitCode = 1;
308
384
  }
309
- console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
310
- process.exit(0);
311
- }
385
+ if (process.exitCode && !outcome.failed) {
386
+ outcome.failed++;
387
+ error = "capture lock release failed; see stderr for recovery";
388
+ }
389
+ const status = outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed.length ? "incomplete" : "complete";
390
+ console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
391
+ ...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
392
+ }, null, 2));
393
+ // Let stdout drain naturally, including large session-boundary receipts.
394
+ } else {
312
395
 
313
396
  warnOnStrangerOwner();
314
- pass();
397
+ try {
398
+ pass();
399
+ } catch (err) {
400
+ // The lock's finally has run by now; one line, then the status.
401
+ console.error(`capture pass failed: ${err.message}`);
402
+ process.exit(1);
403
+ }
315
404
 
316
405
  if (args.watch) {
317
406
  const roots = [
@@ -340,3 +429,5 @@ if (args.watch) {
340
429
  }
341
430
  setInterval(schedule, 15 * 60 * 1000); // reconcile even if events were missed
342
431
  }
432
+
433
+ }
@@ -43,12 +43,13 @@ const store = new RecordStore(root, {});
43
43
  let index;
44
44
  const getIndex = () => (index ??= new RecordIndex(store));
45
45
 
46
+ async function main() {
46
47
  try {
47
48
  if (args.reindex) {
48
49
  const index = getIndex();
49
50
  index.rebuild();
50
51
  console.log(JSON.stringify(index.counts()));
51
- process.exit(0);
52
+ return;
52
53
  }
53
54
 
54
55
  if (args.show) {
@@ -58,21 +59,21 @@ try {
58
59
  const turn = resolveTurn(store, index, args.show, byId);
59
60
  if (!turn) {
60
61
  console.error(`no turn ${args.show}`);
61
- process.exit(1);
62
+ process.exitCode = 1; return;
62
63
  }
63
64
  // Tombstoned turns are hidden from tool output, id lookup included.
64
65
  if (store.hiddenIds(byId).has(args.show)) {
65
66
  console.error(`turn ${args.show} is tombstoned`);
66
- process.exit(1);
67
+ process.exitCode = 1; return;
67
68
  }
68
69
  console.log(JSON.stringify(turn, null, 2));
69
- process.exit(0);
70
+ return;
70
71
  }
71
72
 
72
73
  const query = args._.join(" ").trim();
73
74
  if (!query && !args.thread) {
74
75
  console.error("usage: recall [--kind k] [--thread t] [--from f] [--role r] [--limit n] <query>");
75
- process.exit(2);
76
+ process.exitCode = 2; return;
76
77
  }
77
78
 
78
79
  // Thread turns straight from the journal, in capture SEQUENCE: the order a
@@ -100,12 +101,12 @@ try {
100
101
  let end = turns.length;
101
102
  if (args.after) {
102
103
  const i = ids.indexOf(args.after);
103
- if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exit(1); }
104
+ if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exitCode = 1; return; }
104
105
  start = i + 1;
105
106
  }
106
107
  if (args.until) {
107
108
  const i = ids.indexOf(args.until);
108
- if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exit(1); }
109
+ if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exitCode = 1; return; }
109
110
  end = i + 1;
110
111
  }
111
112
  const cap = args.limit ? Number(args.limit) : Infinity;
@@ -123,7 +124,7 @@ try {
123
124
  return full;
124
125
  });
125
126
  console.log(JSON.stringify({ thread: args.thread, total: turns.length, from: start, to: stop, remaining: end - stop, turns: out }, null, 2));
126
- process.exit(0);
127
+ return;
127
128
  }
128
129
 
129
130
  const index = getIndex();
@@ -150,15 +151,15 @@ try {
150
151
 
151
152
  if (rows.length === 0 && args.json) {
152
153
  console.log("[]");
153
- process.exit(0);
154
+ return;
154
155
  }
155
156
  if (rows.length === 0) {
156
157
  console.error("no matches");
157
- process.exit(1);
158
+ process.exitCode = 1; return;
158
159
  }
159
160
  if (args.json) {
160
161
  console.log(JSON.stringify(rows, null, 2));
161
- process.exit(0);
162
+ return;
162
163
  }
163
164
  for (const r of rows) {
164
165
  const where = r.loc ? ` @${r.loc}` : "";
@@ -170,3 +171,8 @@ try {
170
171
  } finally {
171
172
  index?.close();
172
173
  }
174
+
175
+ }
176
+ // Natural process termination drains piped stdout; process.exit after a JSON
177
+ // write truncates large recall windows even though it reports exit status 0.
178
+ await main();
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // The environment/argv stays in this process, never in the custody receipt.
3
+ import { recordNativeStart } from "../lib/native-history.mjs";
4
+ try {
5
+ const [home, id, runtime, args] = process.argv.slice(2);
6
+ recordNativeStart(home, id, runtime, JSON.parse(args));
7
+ } catch {
8
+ // Native paths can contain user-chosen data; never echo argv or env on errors.
9
+ console.error("native record location receipt failed; launch refused and pending custody retained");
10
+ process.exitCode = 1;
11
+ }