@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.
- package/README.md +7 -2
- package/bin/oats.mjs +365 -31
- package/docs/configuration.md +65 -0
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
- package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
- package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
- package/docs/design/launch-configurations.md +164 -0
- package/docs/design/package-runtime-api.md +177 -3
- package/docs/desktop-cli-api.md +68 -2
- package/docs/desktop-instance-start.md +39 -3
- package/docs/execution-targets.md +16 -0
- package/docs/knowledge-capability-authoring.md +98 -0
- package/docs/knowledge-reference/acceptance.md +108 -0
- package/docs/knowledge-reference/adoption.md +61 -0
- package/docs/knowledge-reference/harvester.md +107 -0
- package/docs/knowledge-reference/model.md +84 -0
- package/docs/knowledge-reference/package-craft.md +126 -0
- package/docs/knowledge-reference/provider-mapping.md +77 -0
- package/docs/knowledge-reference/reader-capture.md +87 -0
- package/docs/knowledge-theory.md +20 -6
- package/docs/layers.md +8 -7
- package/docs/oats-config.schema.json +33 -2
- package/docs/release-notes/v0.22.18.md +101 -0
- package/docs/release-notes/v0.22.19.md +115 -0
- package/docs/release-notes/v0.23.0.md +93 -0
- package/docs/souls-and-instances.md +18 -1
- package/injects/work-directory.md +18 -0
- package/lib/core.mjs +1109 -187
- package/lib/schedule.mjs +12 -2
- package/lib/servers.mjs +89 -4
- package/package.json +2 -2
- package/packages/record/README.md +19 -0
- package/packages/record/bin/capture.mjs +144 -53
- package/packages/record/bin/recall.mjs +17 -11
- package/packages/record/bin/record-native-start.mjs +11 -0
- package/packages/record/lib/capture-cc.mjs +82 -27
- package/packages/record/lib/capture-lock.mjs +81 -5
- package/packages/record/lib/formats.mjs +108 -21
- package/packages/record/lib/native-history.mjs +87 -0
- package/packages/record/lib/session-roots.mjs +90 -0
- package/packages/record/lib/session-snapshot.mjs +61 -0
- package/packages/record/lib/sessions-for-home.mjs +88 -56
- 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 };
|
|
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
|
|
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,
|
|
660
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
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
|
-
|
|
317
|
+
let found = [];
|
|
269
318
|
const sessions = [];
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
67
|
+
process.exitCode = 1; return;
|
|
67
68
|
}
|
|
68
69
|
console.log(JSON.stringify(turn, null, 2));
|
|
69
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
return;
|
|
154
155
|
}
|
|
155
156
|
if (rows.length === 0) {
|
|
156
157
|
console.error("no matches");
|
|
157
|
-
process.
|
|
158
|
+
process.exitCode = 1; return;
|
|
158
159
|
}
|
|
159
160
|
if (args.json) {
|
|
160
161
|
console.log(JSON.stringify(rows, null, 2));
|
|
161
|
-
|
|
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
|
+
}
|