@awebai/oats 0.24.6 → 0.24.8
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 +300 -16
- package/docs/design/2026-09-20-redesign-program-board.md +3 -3
- package/docs/design/2026-09-22-desktop-parity-seams.md +10 -6
- package/docs/desktop-cli-api.md +406 -0
- package/docs/release-notes/v0.24.7.md +81 -0
- package/docs/release-notes/v0.24.8.md +107 -0
- package/lib/core.mjs +224 -17
- package/lib/instance-events.mjs +76 -0
- package/lib/instance-git.mjs +225 -0
- package/lib/instance-lifecycle.mjs +181 -0
- package/lib/portable-soul.mjs +5 -1
- package/lib/readiness.mjs +141 -0
- package/lib/schedule.mjs +22 -3
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { PI_SDK_HOST, isPiSdkHost, validatePiHostRecipe, piHostArgv, resolvePiSd
|
|
|
40
40
|
import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapturedPiRoot, prepareCapturedPiStart } from "./captured-pi-custody.mjs";
|
|
41
41
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
|
+
import { appendEvent } from "./instance-events.mjs";
|
|
43
44
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
44
45
|
|
|
45
46
|
import { oatsError } from "./errors.mjs";
|
|
@@ -4248,6 +4249,22 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
|
|
|
4248
4249
|
for (const b of composition.blocks || []) expected.push({ type: "instruction-block", source: b.source, declared: b.file, path: b.file });
|
|
4249
4250
|
}
|
|
4250
4251
|
|
|
4252
|
+
// A soul that DECLARES an operational capability (requires.capabilities,
|
|
4253
|
+
// e.g. oats.core written by `oats create`) has, by that declaration, given up
|
|
4254
|
+
// the kernel's legacy skill injection. If the declared capability is not
|
|
4255
|
+
// active for this soul in this context, the instance would start with no
|
|
4256
|
+
// operational curriculum and no warning — a hollow agent. That is an unmet
|
|
4257
|
+
// declared requirement: refuse, attributed, with the exact remedy. Removing
|
|
4258
|
+
// the declaration from soul.yaml is the supported way to opt out.
|
|
4259
|
+
const declaredOps = declaredOperationalCapabilities(soulDir);
|
|
4260
|
+
const inactiveOps = declaredOps.filter((id) => !(resolved.capabilities || []).some((c) => c.id === id));
|
|
4261
|
+
if (inactiveOps.length) {
|
|
4262
|
+
const soulName = agent?.name ?? basename(dirname(soulDir));
|
|
4263
|
+
const remedy = inactiveOps.map((id) => `oats use ${id} --soul ${soulName}${contextDir ? ` --dir ${contextDir}` : ""}`).join(" && ");
|
|
4264
|
+
throw Object.assign(oatsError("E_REQUIREMENT_INACTIVE", `soul ${soulName} declares ${inactiveOps.join(", ")} in requires.capabilities, but ${inactiveOps.length === 1 ? "it is" : "they are"} not active for this soul in ${contextDir}; the declaration suppresses the kernel's legacy skills, so the instance would start with no operational curriculum.\n Acquire if needed (\`oats install oats.framework\`), then activate: ${remedy}\n Or remove the declaration from ${join(soulDir, "soul.yaml")} to run without it.`),
|
|
4265
|
+
{ soul: soulName, capabilities: inactiveOps, context: contextDir, remedy });
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4251
4268
|
// A capability that declares a REQUIRED hook it cannot execute must not spawn.
|
|
4252
4269
|
// Advisory executable hooks stay disabled-with-warning; a required one is a
|
|
4253
4270
|
// promise, and starting without it is the failure required:true exists to stop.
|
|
@@ -5196,7 +5213,7 @@ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo,
|
|
|
5196
5213
|
home: soulDir, instance: name, agentName: name, soulDir,
|
|
5197
5214
|
contextDir: ctx, workspaceDir: workspaceOf(root), rootDir: root, resolved,
|
|
5198
5215
|
});
|
|
5199
|
-
return { agentDir, soulDir, ...(notes.length ? { notes } : {}) };
|
|
5216
|
+
return { agentDir, soulDir, declaredCapabilities: Object.keys(requires?.capabilities ?? {}), ...(notes.length ? { notes } : {}) };
|
|
5200
5217
|
}
|
|
5201
5218
|
function defaultSoulAgentsMd(name, description) {
|
|
5202
5219
|
return `# ${name}
|
|
@@ -5218,8 +5235,8 @@ export function createAgent(root, o) {
|
|
|
5218
5235
|
// kind: "local" → a FULL soul (memory, skills, instances) under the scope's
|
|
5219
5236
|
// local-agents/ — uncommitted by contract; otherwise a committed persistent soul.
|
|
5220
5237
|
const kind = o.local || o.kind === "local" ? "local" : "persistent";
|
|
5221
|
-
const { agentDir, notes } = writeSoul(root, { ...o, name, kind });
|
|
5222
|
-
return { agent: name, kind, soul: soulOf(agentDir), ...(notes ? { notes } : {}) };
|
|
5238
|
+
const { agentDir, notes, declaredCapabilities } = writeSoul(root, { ...o, name, kind });
|
|
5239
|
+
return { agent: name, kind, soul: soulOf(agentDir), declaredCapabilities, ...(notes ? { notes } : {}) };
|
|
5223
5240
|
}
|
|
5224
5241
|
|
|
5225
5242
|
/** Upsert a local agent soul (from raw instructions or a Claude-style def file).
|
|
@@ -5549,6 +5566,8 @@ export function missingLaunchEnvRefs(configEnv, env = process.env) {
|
|
|
5549
5566
|
* configuration behind (its executable and args are not carried), and a
|
|
5550
5567
|
* model never crosses runtimes: explicit or configured model, else the
|
|
5551
5568
|
* frozen model on the same runtime, else the runtime's native default. */
|
|
5569
|
+
/** Sentinel a caller passes as `model` to mean "the runtime's own default, not the configured/soul preference". */
|
|
5570
|
+
export const NATIVE_DEFAULT_MODEL = "@native-default";
|
|
5552
5571
|
export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
|
|
5553
5572
|
const bad = (code, msg) => { throw oatsError(code, msg); };
|
|
5554
5573
|
let wanted = selection.launchConfig;
|
|
@@ -5572,8 +5591,13 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
5572
5591
|
const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
|
|
5573
5592
|
if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
|
|
5574
5593
|
let model, modelSource;
|
|
5575
|
-
|
|
5576
|
-
|
|
5594
|
+
// K6: an EXPLICIT "use the runtime's native default" is distinct from an
|
|
5595
|
+
// omitted model (which inherits the configuration's or soul's preference).
|
|
5596
|
+
const nativeDefault = selection.model === NATIVE_DEFAULT_MODEL || (selection.model && typeof selection.model === "object" && selection.model.kind === "native-default");
|
|
5597
|
+
const explicit = !nativeDefault && selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
|
|
5598
|
+
if (nativeDefault) {
|
|
5599
|
+
model = ""; modelSource = "native default (explicit)";
|
|
5600
|
+
} else if (explicit) {
|
|
5577
5601
|
model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
|
|
5578
5602
|
if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
|
|
5579
5603
|
} else if (config?.model) {
|
|
@@ -6063,6 +6087,32 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6063
6087
|
if (anchorMeta?.siblingInstance) siblingInstance = anchorMeta.siblingInstance;
|
|
6064
6088
|
}
|
|
6065
6089
|
if (!relation && attachedOwner && attachedOwner !== instance) parentInstance = attachedOwner;
|
|
6090
|
+
// K5 §4 — child-spawn permission is ENFORCED here, by the spawn route. The
|
|
6091
|
+
// parent's recorded policy (instance.json policy.childSpawns) says whether
|
|
6092
|
+
// it may have children; off refuses, attributed to that policy and its
|
|
6093
|
+
// origin. Absent policy = allowed (today's behaviour), reported as such.
|
|
6094
|
+
// A lifecycle-authority claim, not an OS sandbox.
|
|
6095
|
+
const childPolicyOf = (metaOf) => metaOf?.policy?.childSpawns && typeof metaOf.policy.childSpawns === "object"
|
|
6096
|
+
? metaOf.policy.childSpawns : { allowed: true, origin: { kind: "default", detail: "no recorded policy: children allowed" } };
|
|
6097
|
+
if (parentInstance && parentInstance !== instance) {
|
|
6098
|
+
const parentHome = parentInstance === relativeTo && anchorHome ? anchorHome
|
|
6099
|
+
: (findInstanceHome(root, parentInstance) || findTeamInstance(root, parentInstance))?.home;
|
|
6100
|
+
const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
|
|
6101
|
+
const policy = childPolicyOf(parentMeta);
|
|
6102
|
+
if (policy.allowed === false) {
|
|
6103
|
+
if (parentHome) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
|
|
6104
|
+
throw Object.assign(oatsError("E_CHILD_SPAWNS_DISABLED", `${parentInstance} does not allow child spawns (policy origin: ${policy.origin?.kind ?? "recorded"}${policy.origin?.detail ? ` — ${policy.origin.detail}` : ""}); nothing was spawned. Spawn without a parent relation, or respawn the parent with --allow-child-spawns.`),
|
|
6105
|
+
{ parent: parentInstance, policy });
|
|
6106
|
+
}
|
|
6107
|
+
}
|
|
6108
|
+
// This instance's own policy: soul declaration (children.spawn), overridden
|
|
6109
|
+
// per spawn; recorded so the route above and the readiness view can read it.
|
|
6110
|
+
const declaredChildren = agent.children && typeof agent.children === "object" ? agent.children : (typeof agent.children === "string" ? (() => { try { return JSON.parse(agent.children); } catch { return undefined; } })() : undefined);
|
|
6111
|
+
const ownChildPolicy = o.allowChildSpawns !== undefined
|
|
6112
|
+
? { allowed: o.allowChildSpawns === true, origin: { kind: "spawn-option", detail: o.allowChildSpawns ? "--allow-child-spawns" : "--no-child-spawns" } }
|
|
6113
|
+
: declaredChildren && typeof declaredChildren.spawn === "boolean"
|
|
6114
|
+
? { allowed: declaredChildren.spawn, origin: { kind: "soul", detail: `children.spawn: ${declaredChildren.spawn} in soul.yaml` } }
|
|
6115
|
+
: { allowed: true, origin: { kind: "default", detail: "no declaration: children allowed" } };
|
|
6066
6116
|
|
|
6067
6117
|
// Inherited edges must round-trip too. Sibling and parent relations copy
|
|
6068
6118
|
// names from the ANCHOR's instance.json (anchorMeta.parentInstance /
|
|
@@ -6175,6 +6225,33 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6175
6225
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
6176
6226
|
|
|
6177
6227
|
if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
|
|
6228
|
+
// K6: everything a spawn decides is decided by here — and nothing has been
|
|
6229
|
+
// touched. Branch/base for a worktree are named now (not after mkdir) so the
|
|
6230
|
+
// preview and the apply agree on them; `o.baseRef` selects the start point.
|
|
6231
|
+
let plannedBranch = null, plannedBase = null;
|
|
6232
|
+
if (work === "worktree") {
|
|
6233
|
+
plannedBranch = o.branch || `agents/${instance}`;
|
|
6234
|
+
// Validity is Git's own rule (check-ref-format), not a stricter charset:
|
|
6235
|
+
// every later git call takes the name as an argv element, never through a
|
|
6236
|
+
// shell, so a valid-but-hostile name is safe and stays exercisable.
|
|
6237
|
+
try { execFileSync("git", ["check-ref-format", "--branch", plannedBranch], { stdio: ["ignore", "pipe", "pipe"] }); }
|
|
6238
|
+
catch { throw oatsError("E_BAD_ARGS", `branch ${JSON.stringify(plannedBranch)} is not a valid branch name`); }
|
|
6239
|
+
if (shInTry(repoAbs, `git rev-parse --verify --quiet ${shq("refs/heads/" + plannedBranch)}`) !== undefined) throw oatsError("E_BRANCH_EXISTS", `branch ${plannedBranch} already exists in ${repoAbs}; choose another name or reuse it deliberately`);
|
|
6240
|
+
const baseRef = o.baseRef || "HEAD";
|
|
6241
|
+
const baseOid = shInTry(repoAbs, `git rev-parse --verify --quiet ${shq(baseRef + "^{commit}")}`);
|
|
6242
|
+
if (baseOid === undefined) throw oatsError("E_BASE_UNKNOWN", `base ${JSON.stringify(baseRef)} does not resolve to a commit in ${repoAbs}`);
|
|
6243
|
+
plannedBase = { ref: baseRef, oid: baseOid };
|
|
6244
|
+
}
|
|
6245
|
+
if (o.preview === true) {
|
|
6246
|
+
return {
|
|
6247
|
+
spawnPreviewApi: 1, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6248
|
+
runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
6249
|
+
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
6250
|
+
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
6251
|
+
policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: resolvedCfg.capabilities.map((c) => c.id),
|
|
6252
|
+
skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
|
|
6253
|
+
};
|
|
6254
|
+
}
|
|
6178
6255
|
mkdirSync(home, { recursive: true });
|
|
6179
6256
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6180
6257
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -6275,11 +6352,11 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6275
6352
|
let branch;
|
|
6276
6353
|
let worktreeCanonical; // captured immediately after add, before setup/hooks can mutate/remove it
|
|
6277
6354
|
if (work === "worktree") {
|
|
6278
|
-
branch =
|
|
6355
|
+
branch = plannedBranch;
|
|
6279
6356
|
const wt = join(home, "work");
|
|
6280
6357
|
let added = false;
|
|
6281
6358
|
try {
|
|
6282
|
-
execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch],
|
|
6359
|
+
execFileSync("git", ["-C", repoAbs, "worktree", "add", wt, "-b", branch, plannedBase.oid],
|
|
6283
6360
|
{ stdio: ["ignore", "pipe", "pipe"] });
|
|
6284
6361
|
added = true;
|
|
6285
6362
|
// Git registers a canonical path. Retain it now: compensation hooks can
|
|
@@ -6594,6 +6671,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6594
6671
|
relation: relation || undefined,
|
|
6595
6672
|
relativeTo: relation ? relativeTo : undefined,
|
|
6596
6673
|
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
6674
|
+
policy: { childSpawns: ownChildPolicy },
|
|
6597
6675
|
capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
|
|
6598
6676
|
layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
|
|
6599
6677
|
capabilities: resolvedCfg.capabilities.map((cap) => ({
|
|
@@ -6720,6 +6798,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6720
6798
|
}
|
|
6721
6799
|
}
|
|
6722
6800
|
|
|
6801
|
+
appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch } });
|
|
6802
|
+
if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
|
|
6723
6803
|
return { ...meta, launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
6724
6804
|
} catch (error) {
|
|
6725
6805
|
const note = compensateSpawn();
|
|
@@ -7071,6 +7151,12 @@ function retirementBaselinePath(home) {
|
|
|
7071
7151
|
* loudly, and streaming is deliberately not attempted in this change. */
|
|
7072
7152
|
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
7073
7153
|
|
|
7154
|
+
/** Kernel-owned receipts the kernel itself appends to a home after the spawn
|
|
7155
|
+
* baseline (events log, stop/restart receipts). They are evidence about the
|
|
7156
|
+
* instance, not the instance's work, so a retirement fingerprint ignores them —
|
|
7157
|
+
* otherwise every stop or event write would read as "changed home bytes". */
|
|
7158
|
+
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
7159
|
+
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
7074
7160
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
7075
7161
|
const hash = createHash("sha256");
|
|
7076
7162
|
const rootStat = lstatSync(root);
|
|
@@ -7083,7 +7169,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
7083
7169
|
}
|
|
7084
7170
|
const walk = (dir, rel = "") => {
|
|
7085
7171
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7086
|
-
if ((!rel && excludeRoot.has(e.name)) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7172
|
+
if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7087
7173
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
7088
7174
|
const path = join(dir, e.name);
|
|
7089
7175
|
const st = lstatSync(path);
|
|
@@ -7259,6 +7345,73 @@ export function inspectInstanceSession(home) {
|
|
|
7259
7345
|
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
7260
7346
|
}
|
|
7261
7347
|
|
|
7348
|
+
/** K3: quiesce one instance's session and RETAIN everything else — home,
|
|
7349
|
+
* worktree, transcript, launch configuration — so `restart` can bring it back.
|
|
7350
|
+
* Exactly restart's stop half under the same independent endpoint authority
|
|
7351
|
+
* (`instanceSessionTarget`), bounded and never escalated: a harness still
|
|
7352
|
+
* there after the grace is reported still running, nothing is killed harder.
|
|
7353
|
+
* A no-launch or already-idle instance is a no-op that says so. */
|
|
7354
|
+
function tmuxServerLost(e) { return /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 ?? "")); }
|
|
7355
|
+
export function stopInstanceSession(home, o = {}) {
|
|
7356
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session stop needs an absolute instance home");
|
|
7357
|
+
if (o.graceMs !== undefined && (!Number.isSafeInteger(o.graceMs) || o.graceMs < 1 || o.graceMs > 300000)) throw oatsError("E_BAD_ARGS", "stop grace must be 1-300000 ms");
|
|
7358
|
+
const realHome = realPathOrNearest(home);
|
|
7359
|
+
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing was stopped`);
|
|
7360
|
+
const s = instanceSessionTarget(realHome);
|
|
7361
|
+
if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
|
|
7362
|
+
let before;
|
|
7363
|
+
try { before = inspectSessionTarget(s.target, s.io); }
|
|
7364
|
+
catch (e) { if (s.target.backend === "tmux" && tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
|
|
7365
|
+
if (!before.present || before.state === "shell" || before.state === "stopped") return { home: s.home, backend: s.target.backend, stopped: false, alreadyIdle: true, state: before.state, receipt: null };
|
|
7366
|
+
const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, io: s.io, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
7367
|
+
writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
|
|
7368
|
+
appendEvent(realHome, { kind: receipt.exited ? "stopped" : "stop-refused", data: { signal: receipt.signal, waitedMs: receipt.waitedMs, stillRunning: receipt.stillRunning ?? [], state: receipt.state } });
|
|
7369
|
+
if (!receipt.exited) throw Object.assign(oatsError("E_SESSION_STOP_FAILED", `${basename(realHome)} was asked to stop (${receipt.signal} to ${receipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")}) and is still running after ${receipt.waitedMs} ms; nothing was escalated`), { receipt });
|
|
7370
|
+
return { home: s.home, backend: s.target.backend, stopped: true, alreadyIdle: false, state: receipt.state, receipt };
|
|
7371
|
+
}
|
|
7372
|
+
|
|
7373
|
+
/** Recompose a running home's instructions from its CURRENT canonical soul and
|
|
7374
|
+
* context — an operator-authorized refresh, so a role change can reach a live
|
|
7375
|
+
* instance without a respawn. Composition only: the same composer spawn
|
|
7376
|
+
* used, the same soul the home links to, the home's recorded context/work
|
|
7377
|
+
* mode; the previous AGENTS.md is retained as evidence; the change is
|
|
7378
|
+
* recorded in instance.json (instructions sources) and as a typed event.
|
|
7379
|
+
* Nothing is launched, restarted or signalled — the running process picks the
|
|
7380
|
+
* new text up on its next read (harness-dependent; the receipt says so). */
|
|
7381
|
+
export function recomposeInstanceInstructions(home, { dryRun = false } = {}) {
|
|
7382
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "recompose needs an absolute instance home");
|
|
7383
|
+
const realHome = realPathOrNearest(home);
|
|
7384
|
+
const metaFile = join(realHome, "instance.json");
|
|
7385
|
+
if (!existsSync(metaFile)) throw oatsError("E_SESSION_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json)`);
|
|
7386
|
+
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing recomposed`);
|
|
7387
|
+
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
7388
|
+
if (meta.executionBinding || meta.captured) throw oatsError("E_UNSUPPORTED_MODE", "captured incarnations are recomposed by preparing a new resolution, not in place");
|
|
7389
|
+
const soulLink = join(realHome, "soul");
|
|
7390
|
+
let soulDir; try { soulDir = realpathSync(soulLink); } catch { throw oatsError("E_SOUL_UNKNOWN", `${realHome} has no readable soul link`); }
|
|
7391
|
+
const agentDir = dirname(dirname(realHome));
|
|
7392
|
+
const agent = findAgent(dirname(agentDir), basename(agentDir)) || { name: meta.agent, kind: meta.kind, _dir: agentDir };
|
|
7393
|
+
if (agent.kind === "capability") throw oatsError("E_UNSUPPORTED_MODE", "capability-defined souls are recomposed by updating their package");
|
|
7394
|
+
const contextDir = meta.repo && existsSync(meta.repo) ? meta.repo : dirname(dirname(agentDir));
|
|
7395
|
+
const composition = composeInstanceAgentsMd(soulDir, contextDir, meta.agent, meta.work || "checkout", meta.kind || "persistent");
|
|
7396
|
+
const target = join(realHome, "AGENTS.md");
|
|
7397
|
+
const before = existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
7398
|
+
const changed = before !== composition.text;
|
|
7399
|
+
const result = { home: realHome, instance: meta.instance, agent: meta.agent, soulDir, contextDir, changed, dryRun,
|
|
7400
|
+
blocks: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
7401
|
+
previous: null, note: "the running harness re-reads its instructions on its own schedule (typically the next turn or restart); this refresh does not signal or restart anything" };
|
|
7402
|
+
if (dryRun || !changed) return result;
|
|
7403
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
7404
|
+
const previous = join(realHome, `.oats-agents-md.${stamp}.previous`);
|
|
7405
|
+
if (before !== null) writeFileSync(previous, before);
|
|
7406
|
+
writeFileSync(target, composition.text);
|
|
7407
|
+
meta.instructions = composition.blocks.map((b) => ({ source: b.source, file: b.file }));
|
|
7408
|
+
meta.recomposedAt = new Date().toISOString();
|
|
7409
|
+
writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
|
|
7410
|
+
appendEvent(realHome, { kind: "recomposed", data: { previous, blocks: result.blocks.length, soulDir } });
|
|
7411
|
+
result.previous = previous;
|
|
7412
|
+
return result;
|
|
7413
|
+
}
|
|
7414
|
+
|
|
7262
7415
|
export async function attachInstanceSession(home) {
|
|
7263
7416
|
const s = instanceSessionTarget(home);
|
|
7264
7417
|
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
@@ -7445,8 +7598,12 @@ export function harnessProcesses(target, io) {
|
|
|
7445
7598
|
* running. Elapsed time is never taken as exit. */
|
|
7446
7599
|
export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io = {}, kill = process.kill, sleep } = {}) {
|
|
7447
7600
|
const wait = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
|
|
7448
|
-
|
|
7449
|
-
|
|
7601
|
+
// "Already idle" must be a STABLE reading: the shell-vs-harness decision walks
|
|
7602
|
+
// `ps`, which under load can miss a just-forked descendant for one sample.
|
|
7603
|
+
// One momentary "shell" must never be taken as exit with nothing signalled.
|
|
7604
|
+
const idle = (s) => !s.present || s.state === "shell" || s.state === "stopped";
|
|
7605
|
+
let before = inspectSessionTarget(target, io);
|
|
7606
|
+
if (idle(before)) { wait(250); const again = inspectSessionTarget(target, io); if (idle(again)) return { requested: [], signal, sentAt: null, observedAt: new Date().toISOString(), exited: true, waitedMs: 0, state: again.state, stillRunning: [] }; before = again; }
|
|
7450
7607
|
const procs = harnessProcesses(target, io);
|
|
7451
7608
|
if (!procs.length) throw oatsError("E_SESSION_UNKNOWN", `the session reads as ${before.state} but no harness process was found under its pane; nothing was signalled`);
|
|
7452
7609
|
const requested = [];
|
|
@@ -7457,12 +7614,14 @@ export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io =
|
|
|
7457
7614
|
}
|
|
7458
7615
|
const started = Date.now();
|
|
7459
7616
|
const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
7460
|
-
let st = before;
|
|
7617
|
+
let st = before, idleStreak = 0;
|
|
7461
7618
|
while (Date.now() - started <= graceMs) {
|
|
7462
7619
|
wait(250);
|
|
7463
7620
|
try { st = inspectSessionTarget(target, io); } catch { st = { present: true, state: "unknown" }; }
|
|
7464
7621
|
const gone = requested.every((r) => !alive(r.pid));
|
|
7465
|
-
|
|
7622
|
+
idleStreak = gone && idle(st) ? idleStreak + 1 : 0;
|
|
7623
|
+
// Exit needs two consecutive agreeing samples, not one.
|
|
7624
|
+
if (idleStreak >= 2) return { requested, signal, sentAt, observedAt: new Date().toISOString(), exited: true, waitedMs: Date.now() - started, state: st.state };
|
|
7466
7625
|
}
|
|
7467
7626
|
return { requested, signal, sentAt, observedAt: null, exited: false, waitedMs: Date.now() - started, state: st.state, stillRunning: requested.filter((r) => alive(r.pid)).map((r) => r.pid) };
|
|
7468
7627
|
}
|
|
@@ -7897,7 +8056,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7897
8056
|
const pendingPath = join(realHome, ".oats-start-pending.json");
|
|
7898
8057
|
const exitedPath = join(realHome, ".oats-start-exited");
|
|
7899
8058
|
const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
|
|
7900
|
-
const lostTmuxServer =
|
|
8059
|
+
const lostTmuxServer = tmuxServerLost;
|
|
7901
8060
|
const launchFailure = (backend, error) => {
|
|
7902
8061
|
// execFileSync errors embed argv (including capability environment) in
|
|
7903
8062
|
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
@@ -8058,6 +8217,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8058
8217
|
// as running; nothing is escalated and nothing is launched.
|
|
8059
8218
|
stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
|
|
8060
8219
|
writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { runtime: launchPlan?.runtime || runtime, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
|
|
8220
|
+
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
8061
8221
|
if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
|
|
8062
8222
|
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
8063
8223
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
@@ -8767,11 +8927,58 @@ export function retireInstance(root, name, o = {}) {
|
|
|
8767
8927
|
} catch { /* no team scope resolvable — local root only */ }
|
|
8768
8928
|
for (const r2 of roots) scanRoot(r2);
|
|
8769
8929
|
|
|
8930
|
+
// K3b — retention is the default (lifecycle decision §1): the worktree,
|
|
8931
|
+
// branch and any PR outlive the home. The worktree cannot stay under
|
|
8932
|
+
// <home>/work once the home is removed, so it is RE-HOMED with
|
|
8933
|
+
// `git worktree move` to the deployment-level worktrees root and the move is
|
|
8934
|
+
// recorded in the receipt. `discardWorktree` restores removal; branch
|
|
8935
|
+
// deletion uses the VERIFIED current ref of the worktree, never the
|
|
8936
|
+
// spawn-time recorded name. A failed move keeps the home (fail closed).
|
|
8937
|
+
let retention = null;
|
|
8770
8938
|
if (isWorktree && meta.repo) {
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8939
|
+
const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
|
|
8940
|
+
const verifiedBranch = ref.branch;
|
|
8941
|
+
// A branch cannot be deleted while a worktree has it checked out, so
|
|
8942
|
+
// --delete-branch implies discarding the worktree (which is what every
|
|
8943
|
+
// caller of it meant: clean up everything). Plain retire retains.
|
|
8944
|
+
if (o.discardWorktree || o.deleteBranch || quarantine) {
|
|
8945
|
+
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
8946
|
+
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
8947
|
+
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
8948
|
+
} else if (existsSync(workPath)) {
|
|
8949
|
+
const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
|
|
8950
|
+
const leaf = (verifiedBranch ?? `detached-${(ref.oid || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
|
|
8951
|
+
const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
|
|
8952
|
+
mkdirSync(retainedRoot, { recursive: true });
|
|
8953
|
+
let dest = join(retainedRoot, leaf);
|
|
8954
|
+
for (let n = 2; existsSync(dest); n++) dest = join(retainedRoot, `${leaf}-${n}`);
|
|
8955
|
+
try {
|
|
8956
|
+
execFileSync("git", ["-C", meta.repo, "worktree", "move", workPath, dest], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
8957
|
+
} catch (e) {
|
|
8958
|
+
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the worktree at ${workPath} could not be re-homed to ${dest} (${String(e.stderr ?? e.message ?? "").trim()}); the home is kept so nothing is lost — resolve and retry, or pass --discard-worktree to remove the worktree instead`);
|
|
8959
|
+
}
|
|
8960
|
+
retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.oid : null, recordedBranch: meta.branch ?? null };
|
|
8961
|
+
} else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
8962
|
+
if (o.deleteBranch && verifiedBranch) {
|
|
8963
|
+
// A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
|
|
8964
|
+
// the tree during retirement, so the branch is re-verified here, at the
|
|
8965
|
+
// moment of deletion; a mismatch deletes nothing and says so.
|
|
8966
|
+
if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
|
|
8967
|
+
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
|
|
8968
|
+
} else {
|
|
8969
|
+
shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
|
|
8970
|
+
retention.branchDeleted = verifiedBranch;
|
|
8971
|
+
}
|
|
8972
|
+
} else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
|
|
8973
|
+
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
|
|
8974
|
+
}
|
|
8975
|
+
}
|
|
8976
|
+
if (retention) {
|
|
8977
|
+
if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
|
|
8978
|
+
else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
|
|
8979
|
+
if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
|
|
8774
8980
|
}
|
|
8981
|
+
appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null } }, { workspaceOnly: true });
|
|
8775
8982
|
// Retrying a quarantine only clears it if compensation ACTUALLY completed.
|
|
8776
8983
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
8777
8984
|
// retry becomes the deletion the quarantine was preventing.
|
|
@@ -8877,7 +9084,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
8877
9084
|
}
|
|
8878
9085
|
|
|
8879
9086
|
|
|
8880
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(
|
|
9087
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
|
|
8881
9088
|
if (self) {
|
|
8882
9089
|
// The caller is the instance: its process lives in the window we are about to
|
|
8883
9090
|
// kill. Detach the kill so this function can return and the caller can report
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** K7 — typed lifecycle events per instance, append-only, producer-attributed.
|
|
2
|
+
*
|
|
3
|
+
* Every event is a fact a kernel action just made true (spawned, launched,
|
|
4
|
+
* stopped, restarted, retired…), written by the code that did it, with the
|
|
5
|
+
* receipt it produced. Nothing here is inferred from transcripts, task files or
|
|
6
|
+
* prose; "waiting on you" is reported only when a producer says so — today no
|
|
7
|
+
* producer does, and the view says `null`, not a guess. Retirement's event
|
|
8
|
+
* outlives the home in the workspace-level log. */
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const EVENTS_API = 1;
|
|
13
|
+
const HOME_LOG = ".oats-events.jsonl";
|
|
14
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "recomposed"];
|
|
17
|
+
|
|
18
|
+
function workspaceLogPath(home) {
|
|
19
|
+
// <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
|
|
20
|
+
// state beside installed capabilities and schedules; survives the home's removal.
|
|
21
|
+
const agentDir = dirname(dirname(home)); // <root>/<agent>/instances/<instance>
|
|
22
|
+
const workspace = dirname(dirname(agentDir)); // <workspace>/agents/<agent>
|
|
23
|
+
return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Append one typed event. Never throws into the caller's action: an event is
|
|
27
|
+
* evidence, not authority — a failed write is reported in the return value. */
|
|
28
|
+
export function appendEvent(home, event, { workspaceOnly = false } = {}) {
|
|
29
|
+
if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
|
|
30
|
+
const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
31
|
+
const line = JSON.stringify(row) + "\n";
|
|
32
|
+
const results = [];
|
|
33
|
+
// Retirement fingerprints the home against its baseline and preserves any
|
|
34
|
+
// changed bytes as "unknown work": events written DURING retirement go only
|
|
35
|
+
// to the workspace log, never into the home being inspected.
|
|
36
|
+
for (const path of [...(workspaceOnly ? [] : [join(home, HOME_LOG)]), workspaceLogPath(home)]) {
|
|
37
|
+
try {
|
|
38
|
+
if (path.startsWith(home) && !existsSync(home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
appendFileSync(path, line);
|
|
41
|
+
results.push({ path, ok: true });
|
|
42
|
+
} catch (e) { results.push({ path, ok: false, reason: e.message }); }
|
|
43
|
+
}
|
|
44
|
+
return { ok: results.some((r) => r.ok), row, results };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readLog(path) {
|
|
48
|
+
if (!existsSync(path)) return { rows: [], truncated: false, bytes: 0 };
|
|
49
|
+
const size = statSync(path).size;
|
|
50
|
+
const text = readFileSync(path, "utf8");
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const line of text.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r && r.eventsApi === EVENTS_API && typeof r.kind === "string") rows.push(r); } catch { /* a torn line is skipped, counted */ rows.push({ eventsApi: EVENTS_API, kind: "unreadable", at: null, producer: "kernel", data: { reason: "torn or invalid line" } }); } }
|
|
53
|
+
return { rows, truncated: size > MAX_BYTES, bytes: size };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Events for one instance, newest last, from both logs (deduplicated by
|
|
57
|
+
* at+kind), with a bounded window. `waitingOnYou` is a producer claim or null. */
|
|
58
|
+
export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
59
|
+
const a = readLog(join(home, HOME_LOG)), b = readLog(workspaceLogPath(home));
|
|
60
|
+
const seen = new Set(); const rows = [];
|
|
61
|
+
for (const r of [...a.rows, ...b.rows]) { const k = `${r.at}|${r.kind}|${JSON.stringify(r.data ?? null)}`; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
|
|
62
|
+
rows.sort((x, y) => String(x.at ?? "").localeCompare(String(y.at ?? "")));
|
|
63
|
+
const filtered = since ? rows.filter((r) => r.at && r.at > since) : rows;
|
|
64
|
+
const window = filtered.slice(-limit);
|
|
65
|
+
const last = window.at(-1) ?? null;
|
|
66
|
+
const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
|
|
67
|
+
return {
|
|
68
|
+
eventsApi: EVENTS_API, instance: basename(home), home, count: filtered.length, returned: window.length, truncated: filtered.length > window.length || a.truncated || b.truncated,
|
|
69
|
+
events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer } : null,
|
|
70
|
+
waitingOnYou: waiting ? { since: waiting.at, producer: waiting.producer, reason: waiting.data.reason ?? null } : null,
|
|
71
|
+
notes: [
|
|
72
|
+
"events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
|
|
73
|
+
"waitingOnYou is null unless a producer reported it — null means unknown, not 'not waiting'",
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
}
|