moshcode 0.58.0 → 0.60.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.
@@ -20,11 +20,39 @@ import fs from "node:fs";
20
20
  import path from "node:path";
21
21
 
22
22
  import { ENGINES } from "./engines.mjs";
23
- import { capture, herdDir, sessionExited } from "./herd.mjs";
23
+ import { capture, herdDir, remoteStatus, sessionExited } from "./herd.mjs";
24
+ import { TOOLS } from "./tools.mjs";
24
25
 
25
26
  /** The vocabulary the roster, notifications, and `wait` all share. */
26
27
  export const STATES = ["working", "blocked", "done", "idle", "unknown"];
27
28
 
29
+ /**
30
+ * What a blocked session is blocked *on* (PRD 0011 R4).
31
+ *
32
+ * The roster still prints `blocked`, because five kinds of amber is four more
33
+ * than anyone reads at a glance. The sub-kind rides in `--json` and in
34
+ * notifications, where it is worth something: an `--ask` reply to a numbered
35
+ * menu wants a digit, and one to a question wants a sentence, and answering a
36
+ * menu with a paragraph types the paragraph into the menu.
37
+ */
38
+ export const BLOCKED_KINDS = ["permission", "question", "menu"];
39
+
40
+ /**
41
+ * Parse the state token a hook or a human passes to `herd report`.
42
+ *
43
+ * `blocked:permission` is one string on a command line and two facts here.
44
+ * Returns null for anything not in the vocabulary — an unknown state has to
45
+ * fail loudly at the edge rather than be written into the status file where
46
+ * every later reader has to cope with it.
47
+ */
48
+ export function parseState(raw) {
49
+ const [state, kind] = String(raw ?? "").trim().split(":");
50
+ if (!STATES.includes(state)) return null;
51
+ if (kind === undefined || kind === "") return { state };
52
+ if (state !== "blocked" || !BLOCKED_KINDS.includes(kind)) return null;
53
+ return { state, kind };
54
+ }
55
+
28
56
  /**
29
57
  * `gone` is deliberately not in STATES: it is not a state an agent is in, it is
30
58
  * the absence of one. It exists so the roster can show what a reboot took and
@@ -101,6 +129,43 @@ export const COMMON_RULES = {
101
129
  ],
102
130
  };
103
131
 
132
+ /**
133
+ * Which *kind* of blocked a screen is showing.
134
+ *
135
+ * Only consulted once a screen has already classified as `blocked`, so these
136
+ * are labels rather than detectors and can afford to be loose. A screen that
137
+ * matches nothing here is blocked with no sub-kind, which is exactly what the
138
+ * roster printed before this existed.
139
+ */
140
+ export const BLOCKED_KIND_RULES = {
141
+ // The menu test goes first: Claude Code's permission dialog IS a numbered
142
+ // menu, and "which keystroke answers this" is the question the sub-kind is
143
+ // for. A y/n is a menu of two with no digits, so it stays a permission.
144
+ menu: [/^\s*[❯›▸>]\s*\d+\.\s+\S/m],
145
+ permission: [
146
+ /\[y\/n\]/i,
147
+ /\((?:y(?:es)?\/n(?:o)?)\)\s*[:?]?\s*$/im,
148
+ /\((?:Y\)es|N\)o)/,
149
+ /\bdo you want to\b/i,
150
+ /\bpermission (?:request|required)\b/i,
151
+ /\ballow (?:this )?(?:command|tool|execution)\b/i,
152
+ /\bapprove this (?:command|edit|change)\b/i,
153
+ /\bwaiting for (?:your )?(?:approval|confirmation)\b/i,
154
+ ],
155
+ question: [/\?\s*$/m, /\bpress (?:enter|return) to continue\b/i],
156
+ };
157
+
158
+ /** The sub-kind of an already-blocked screen, or null when it does not say. */
159
+ export function blockedKind(screen) {
160
+ const text = stripAnsi(screen);
161
+ const lines = text.split("\n");
162
+ const tail = lines.slice(Math.max(0, lines.length - 25)).join("\n");
163
+ for (const kind of ["menu", "permission", "question"]) {
164
+ if ((BLOCKED_KIND_RULES[kind] || []).some((re) => re.test(tail))) return kind;
165
+ }
166
+ return null;
167
+ }
168
+
104
169
  /**
105
170
  * User overrides, so a rule that rots can be fixed on the box it rots on.
106
171
  *
@@ -130,9 +195,76 @@ export function loadUserRules(file = path.join(herdDir(), "rules.json")) {
130
195
  return out;
131
196
  }
132
197
 
133
- /** The rule set for one engine: user overrides, then its own, then the shared. */
198
+ /** The states a user rules file is allowed to carry patterns for. */
199
+ const RULE_STATES = ["blocked", "working", "idle"];
200
+
201
+ /**
202
+ * Everything wrong with the user's rules file, said out loud (PRD 0011 R3).
203
+ *
204
+ * loadUserRules() is silent by design — a malformed rules file must not take
205
+ * down the roster, and it does not. The cost of that is a file which has been
206
+ * quietly ignored since the day someone typo'd a bracket in it, with the herd
207
+ * classifying from the built-in rules and nothing anywhere saying so. This is
208
+ * where that gets to be loud, and `herd doctor` is the one caller.
209
+ */
210
+ export function inspectUserRules(file = path.join(herdDir(), "rules.json")) {
211
+ const empty = { file, present: false, ok: true, patterns: 0, problems: [] };
212
+ let text;
213
+ try { text = fs.readFileSync(file, "utf8"); }
214
+ catch (error) {
215
+ return error.code === "ENOENT" ? empty
216
+ : { ...empty, present: true, ok: false, problems: [{ where: file, error: String(error.message || error) }] };
217
+ }
218
+
219
+ let raw;
220
+ try { raw = JSON.parse(text); }
221
+ catch (error) {
222
+ return { ...empty, present: true, ok: false, problems: [{ where: file, error: `not valid JSON — ${error.message}` }] };
223
+ }
224
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
225
+ return { ...empty, present: true, ok: false, problems: [{ where: file, error: 'the top level must be { "<engine>": { "blocked": ["…"] } }' }] };
226
+ }
227
+
228
+ const problems = [];
229
+ let patterns = 0;
230
+ for (const [engine, group] of Object.entries(raw)) {
231
+ if (!group || typeof group !== "object" || Array.isArray(group)) {
232
+ problems.push({ where: engine, error: "must be an object of state → patterns" });
233
+ continue;
234
+ }
235
+ for (const key of Object.keys(group)) {
236
+ if (!RULE_STATES.includes(key)) {
237
+ problems.push({ where: `${engine}.${key}`, error: `not a state the classifier reads (${RULE_STATES.join(", ")})` });
238
+ }
239
+ }
240
+ for (const state of RULE_STATES) {
241
+ if (group[state] === undefined) continue;
242
+ if (!Array.isArray(group[state])) {
243
+ problems.push({ where: `${engine}.${state}`, error: "must be an array of pattern strings" });
244
+ continue;
245
+ }
246
+ for (const pattern of group[state]) {
247
+ try { new RegExp(pattern, "im"); patterns++; }
248
+ catch (error) { problems.push({ where: `${engine}.${state}`, pattern: String(pattern), error: String(error.message || error) }); }
249
+ }
250
+ }
251
+ }
252
+ return { file, present: true, ok: problems.length === 0, patterns, problems };
253
+ }
254
+
255
+ /**
256
+ * The rule set for one engine: user overrides, then its own, then the shared.
257
+ *
258
+ * TOOLS is consulted as well as ENGINES because `herd run -- gradient agent run
259
+ * --dev` names its session after the binary, and the workflow CLIs are exactly
260
+ * the long-running processes people put in the herd next to an agent (PRD 0011
261
+ * R15). A tool's rules live in src/tools.mjs beside its install spec for the
262
+ * same reason an engine's live beside its own.
263
+ */
134
264
  export function rulesFor(engine, { userRules = loadUserRules() } = {}) {
135
- const own = ENGINES[engine]?.state || {};
265
+ const own = (Object.hasOwn(ENGINES, engine) ? ENGINES[engine]?.state : null)
266
+ || (Object.hasOwn(TOOLS, engine) ? TOOLS[engine]?.state : null)
267
+ || {};
136
268
  const user = userRules[engine] || {};
137
269
  const common = userRules.common || {};
138
270
  const merge = (state) => [
@@ -179,13 +311,18 @@ export function classify(screen, rules) {
179
311
  * someone noticed by hand.
180
312
  */
181
313
  export function reportState(name, state, { ttl = HOOK_TTL_MS, now = Date.now() } = {}) {
182
- if (!STATES.includes(state)) return { ok: false, error: new Error(`unknown state ${JSON.stringify(state)} — one of ${STATES.join(", ")}`) };
314
+ const parsed = parseState(state);
315
+ if (!parsed) {
316
+ return { ok: false, error: new Error(`unknown state ${JSON.stringify(state)} — one of ${STATES.join(", ")}${` (blocked takes :${BLOCKED_KINDS.join(", :")})`}`) };
317
+ }
183
318
  try {
184
319
  fs.mkdirSync(statusDir(), { recursive: true, mode: 0o700 });
185
320
  const file = statusFile(name);
186
- fs.writeFileSync(file, JSON.stringify({ state, at: now, ttl: Math.min(Number(ttl) || HOOK_TTL_MS, HOOK_TTL_MS) }), { mode: 0o600 });
321
+ const record = { state: parsed.state, at: now, ttl: Math.min(Number(ttl) || HOOK_TTL_MS, HOOK_TTL_MS) };
322
+ if (parsed.kind) record.kind = parsed.kind;
323
+ fs.writeFileSync(file, JSON.stringify(record), { mode: 0o600 });
187
324
  fs.chmodSync(file, 0o600);
188
- return { ok: true, state };
325
+ return { ok: true, ...parsed };
189
326
  } catch (error) {
190
327
  return { ok: false, error };
191
328
  }
@@ -199,7 +336,9 @@ export function hookReport(name, { now = Date.now() } = {}) {
199
336
  if (!raw || !STATES.includes(raw.state)) return null;
200
337
  const ttl = Math.min(Number(raw.ttl) || HOOK_TTL_MS, HOOK_TTL_MS);
201
338
  if (!Number.isFinite(raw.at) || now - raw.at > ttl) return null;
202
- return { state: raw.state, at: raw.at };
339
+ const report = { state: raw.state, at: raw.at };
340
+ if (raw.state === "blocked" && BLOCKED_KINDS.includes(raw.kind)) report.kind = raw.kind;
341
+ return report;
203
342
  }
204
343
 
205
344
  export function clearReport(name) {
@@ -218,10 +357,20 @@ export function clearReport(name) {
218
357
  * first useful question is "was anything even reading the screen?", and a
219
358
  * roster that cannot answer it sends people to read this file instead.
220
359
  */
221
- export function sessionState(session, { now = Date.now(), userRules = loadUserRules(), read = capture } = {}) {
360
+ export function sessionState(session, { now = Date.now(), userRules = loadUserRules(), read = capture, remote = remoteStatus } = {}) {
222
361
  const name = typeof session === "string" ? session : session.name;
223
362
  const meta = typeof session === "string" ? {} : session;
224
363
 
364
+ // A remote member's state is the remote's claim and nothing more (PRD 0011
365
+ // R11). It is reported with `authority: "remote"` so nobody mistakes a URL
366
+ // that answered five minutes ago for something this box just verified.
367
+ if (meta.kind === "remote") {
368
+ const claim = remote(name, { now });
369
+ return claim?.state
370
+ ? { state: claim.state, authority: "remote" }
371
+ : { state: "unknown", authority: "remote" };
372
+ }
373
+
225
374
  if (meta.alive === false) return { state: "gone", authority: "runtime" };
226
375
 
227
376
  // A finished process is done, and no screen rule gets a vote on that. This is
@@ -231,11 +380,19 @@ export function sessionState(session, { now = Date.now(), userRules = loadUserRu
231
380
  if (exited === null && meta.alive === undefined) return { state: "gone", authority: "runtime" };
232
381
 
233
382
  const hook = hookReport(name, { now });
234
- if (hook) return { state: hook.state, authority: "hook" };
383
+ if (hook) return hook.kind ? { state: hook.state, authority: "hook", blockedOn: hook.kind } : { state: hook.state, authority: "hook" };
235
384
 
236
385
  const screen = read(name);
237
386
  if (!screen) return { state: "unknown", authority: "screen" };
238
- return { state: classify(screen, rulesFor(meta.engine, { userRules })), authority: "screen" };
387
+ const state = classify(screen, rulesFor(meta.engine, { userRules }));
388
+ // `blockedOn` is only ever added when there is one to add: this object is
389
+ // spread over every roster row, so an always-present `blockedOn: undefined`
390
+ // would be a new key on every row for the benefit of none. It is also not
391
+ // called `kind` — that name already belongs to the row, where it says whether
392
+ // the member is a local pty or a URL.
393
+ if (state !== "blocked") return { state, authority: "screen" };
394
+ const kind = blockedKind(screen);
395
+ return kind ? { state, authority: "screen", blockedOn: kind } : { state, authority: "screen" };
239
396
  }
240
397
 
241
398
  /** listSessions() output, each row carrying its state. */
@@ -0,0 +1,377 @@
1
+ // The task ledger — what happened, not just what is happening (PRD 0011 R5–R7).
2
+ //
3
+ // `moshcode ps` answers "now". It is the whole reason the roster exists and it
4
+ // is genuinely all most people need at 11pm. It is also everything the herd
5
+ // remembered: `herd prompt api "…" --wait` returned, and then the evidence
6
+ // evaporated. Which prompts were submitted, when each one blocked, what came
7
+ // back, how long the human took to answer — none of it was anywhere, which made
8
+ // the herd's party trick (fan four engines out overnight) unauditable by
9
+ // construction.
10
+ //
11
+ // So every prompt mints a TASK: an id, the text that was submitted, its state
12
+ // transitions with timestamps, and the output it produced. The watch loop
13
+ // already observed every one of those transitions and threw each away after
14
+ // deciding whether to buzz a phone; this is the write inserted at that same
15
+ // decision, not a second poller.
16
+ //
17
+ // WHAT THIS IS NOT. It is not a trace of the engine. We do not own those
18
+ // runtimes, and pretending to see inside one would be paint-reading with extra
19
+ // steps. What the herd can attest to honestly is: this text went in at this
20
+ // time, the session moved through these states, and this is what was on the
21
+ // screen that had not been there before. That is what is recorded.
22
+ //
23
+ // JSONL, one file per session, 0600. The manifest's reason for 0600 applies one
24
+ // step harder here: the manifest records the argv an engine was launched with,
25
+ // and this records what the engine *said*, which regularly contains secrets the
26
+ // user never typed.
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+
30
+ import { herdDir } from "./herd.mjs";
31
+
32
+ /** Terminal states for a task: the engine stopped needing the CPU. */
33
+ export const TERMINAL_STATES = ["blocked", "done", "idle"];
34
+
35
+ /**
36
+ * Retention. An append-only file with no cap is a disk-eater with a delay on
37
+ * it, and the delay is however long the operator finds this feature useful.
38
+ */
39
+ export const MAX_TASKS_PER_SESSION = 500;
40
+ export const MAX_LEDGER_BYTES = 2 * 1024 * 1024;
41
+
42
+ /**
43
+ * How much of an artifact goes inline.
44
+ *
45
+ * The tail, not the head: an agent's answer is the last thing it printed, and
46
+ * the first 8KB of a long run is the part you already watched. Truncation is
47
+ * recorded rather than hidden, because an artifact that silently lost its
48
+ * middle is worse than one that says it did.
49
+ */
50
+ export const MAX_ARTIFACT_CHARS = 8000;
51
+
52
+ const tasksDir = () => path.join(herdDir(), "tasks");
53
+ const ledgerFile = (session) => path.join(tasksDir(), `${session}.jsonl`);
54
+ const seqFile = () => path.join(tasksDir(), "seq");
55
+
56
+ function ensureDir() {
57
+ fs.mkdirSync(tasksDir(), { recursive: true, mode: 0o700 });
58
+ }
59
+
60
+ /**
61
+ * The next task id, herd-wide.
62
+ *
63
+ * Herd-wide rather than per-session so that `herd task t-07` means one task and
64
+ * not one per member — the id is a handle people paste, and an ambiguous handle
65
+ * is not one. The counter is a file with a lock beside it; if the lock cannot
66
+ * be taken (a genuinely concurrent fan-out, or a stale lock), the id gets a
67
+ * random suffix instead of blocking. A collision is a cosmetic problem and a
68
+ * hang is not.
69
+ */
70
+ export function mintTaskId({ now = Date.now() } = {}) {
71
+ ensureDir();
72
+ const lock = `${seqFile()}.lock`;
73
+ let held = false;
74
+ for (let attempt = 0; attempt < 50 && !held; attempt++) {
75
+ try { fs.closeSync(fs.openSync(lock, "wx")); held = true; }
76
+ catch {
77
+ // A lock older than a few seconds belonged to a process that died.
78
+ try {
79
+ if (now - fs.statSync(lock).mtimeMs > 5000) fs.rmSync(lock, { force: true });
80
+ } catch { /* it went away on its own */ }
81
+ }
82
+ }
83
+ try {
84
+ let next = 1;
85
+ try { next = Math.max(1, Number(JSON.parse(fs.readFileSync(seqFile(), "utf8")).next) || 1); }
86
+ catch { /* first task on this box */ }
87
+ const id = held ? `t-${String(next).padStart(2, "0")}` : `t-${String(next).padStart(2, "0")}-${Math.random().toString(36).slice(2, 6)}`;
88
+ if (held) {
89
+ try { fs.writeFileSync(seqFile(), JSON.stringify({ next: next + 1 }), { mode: 0o600 }); }
90
+ catch { /* the id is still ours; the next one may repeat it */ }
91
+ }
92
+ return id;
93
+ } finally {
94
+ if (held) { try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ } }
95
+ }
96
+ }
97
+
98
+ /** Append one event. Never throws — a lost ledger line must not fail a prompt. */
99
+ function append(session, event) {
100
+ try {
101
+ ensureDir();
102
+ const file = ledgerFile(session);
103
+ fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
104
+ fs.chmodSync(file, 0o600);
105
+ compact(session);
106
+ return true;
107
+ } catch { return false; }
108
+ }
109
+
110
+ function readLines(session) {
111
+ let text;
112
+ try { text = fs.readFileSync(ledgerFile(session), "utf8"); }
113
+ catch { return []; }
114
+ const out = [];
115
+ for (const line of text.split("\n")) {
116
+ if (!line.trim()) continue;
117
+ // One unparseable line loses that line, not the ledger. A truncated last
118
+ // write is the ordinary way this happens and it must not hide the history
119
+ // above it.
120
+ try { out.push(JSON.parse(line)); } catch { /* skip */ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Trim to the retention cap, keeping the newest tasks whole. */
126
+ export function compact(session, { maxTasks = MAX_TASKS_PER_SESSION, maxBytes = MAX_LEDGER_BYTES } = {}) {
127
+ const file = ledgerFile(session);
128
+ let size = 0;
129
+ try { size = fs.statSync(file).size; } catch { return false; }
130
+ // This runs on every append, so the common case has to cost one stat. A task
131
+ // cannot be smaller than its own submit line, so a ledger under the byte cap
132
+ // and under `maxTasks` submit-lines' worth of bytes cannot be over either.
133
+ if (size <= maxBytes && size < maxTasks * 120) return false;
134
+ const lines = readLines(session);
135
+ const ids = [];
136
+ for (const line of lines) if (line.id && !ids.includes(line.id)) ids.push(line.id);
137
+ const overTasks = ids.length > maxTasks;
138
+ if (!overTasks && size <= maxBytes) return false;
139
+
140
+ // Keep whole tasks, newest first, until the budget is spent. A ledger cut
141
+ // mid-task would show a submission with no outcome, which reads as an agent
142
+ // that never answered rather than as a file that was trimmed.
143
+ const keep = new Set(ids.slice(-maxTasks));
144
+ let kept = lines.filter((line) => !line.id || keep.has(line.id));
145
+ while (kept.length && Buffer.byteLength(kept.map((l) => JSON.stringify(l)).join("\n")) > maxBytes) {
146
+ const oldest = kept.find((l) => l.id)?.id;
147
+ if (!oldest) break;
148
+ keep.delete(oldest);
149
+ kept = kept.filter((line) => !line.id || keep.has(line.id));
150
+ }
151
+ try {
152
+ fs.writeFileSync(file, kept.length ? `${kept.map((l) => JSON.stringify(l)).join("\n")}\n` : "", { mode: 0o600 });
153
+ return true;
154
+ } catch { return false; }
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Writing
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * A prompt was submitted. Returns the task id, which the caller carries so
163
+ * later transitions can be attributed to it.
164
+ *
165
+ * `screen` is the session's screen at submission time, kept as the baseline the
166
+ * artifact is a delta against. Storing the whole thing would double every
167
+ * ledger for the sake of text the operator has already seen.
168
+ */
169
+ export function startTask(session, text, { screen = "", now = Date.now(), state = null, id = mintTaskId({ now }) } = {}) {
170
+ append(session, { e: "submit", id, ts: now, text: String(text), state, baseline: baselineOf(screen) });
171
+ return id;
172
+ }
173
+
174
+ /**
175
+ * A state change worth remembering, attributed to a task when one is open.
176
+ *
177
+ * A *change*: repeating the state already at the end of the ledger is dropped.
178
+ * Several things poll the same session at once — a `--wait` prompt runs two
179
+ * waits back to back, and the watcher is looking at all of them anyway — and
180
+ * each keeps its own idea of what it last saw. Without this, one prompt writes
181
+ * `idle` three times and `herd task` prints a transition list where two of the
182
+ * three rows lasted zero seconds.
183
+ */
184
+ export function recordTransition(session, state, { id = null, ts = Date.now(), kind = null } = {}) {
185
+ const previous = lastRecordedState(session);
186
+ if (previous && previous.state === state && previous.id === id) return false;
187
+ const event = { e: "state", id, ts, state };
188
+ if (kind) event.kind = kind;
189
+ append(session, event);
190
+ return true;
191
+ }
192
+
193
+ /** The state at the end of the ledger, whatever wrote it. */
194
+ function lastRecordedState(session) {
195
+ const lines = readLines(session);
196
+ for (let i = lines.length - 1; i >= 0; i--) {
197
+ if (lines[i].e === "state" || lines[i].e === "end") return { state: lines[i].state, id: lines[i].id ?? null };
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** The task is over. `artifact` is what the session produced while it ran. */
203
+ export function endTask(session, id, { state = "done", artifact = "", ts = Date.now() } = {}) {
204
+ const text = String(artifact ?? "");
205
+ const truncated = text.length > MAX_ARTIFACT_CHARS;
206
+ append(session, {
207
+ e: "end", id, ts, state,
208
+ artifact: truncated ? text.slice(-MAX_ARTIFACT_CHARS) : text,
209
+ ...(truncated ? { truncated: true, artifactChars: text.length } : {}),
210
+ });
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * The last few lines of a screen, which is all a delta needs to anchor on.
216
+ *
217
+ * A whole capture as the baseline would make the ledger as big as the
218
+ * transcript. The bottom of the screen is where the new output starts, so that
219
+ * is what has to be remembered to find it again.
220
+ */
221
+ function baselineOf(screen) {
222
+ const lines = String(screen ?? "").replace(/\s+$/, "").split("\n");
223
+ return lines.slice(Math.max(0, lines.length - 8)).join("\n");
224
+ }
225
+
226
+ /**
227
+ * What appeared on screen after the baseline — the task's output.
228
+ *
229
+ * The engine redraws its whole screen constantly, so "everything after the last
230
+ * line I saw" is the only definition of new output available to something
231
+ * reading a terminal from outside. When the baseline cannot be found (a
232
+ * full-screen repaint scrolled it away, or the session cleared), the honest
233
+ * answer is the whole current screen rather than an empty artifact.
234
+ */
235
+ export function screenDelta(baseline, screen) {
236
+ const after = String(screen ?? "").replace(/\s+$/, "");
237
+ const anchor = String(baseline ?? "").replace(/\s+$/, "");
238
+ if (!anchor) return after;
239
+ // The FIRST occurrence, not the last. A short baseline — a bare shell prompt,
240
+ // an engine that had just been cleared — can appear again inside the output
241
+ // it produced, and anchoring on the last match then returns everything after
242
+ // the final prompt glyph, which is nothing. Both failures are possible; only
243
+ // one of them is safe. A few extra lines of context is an artifact somebody
244
+ // can still read, and an empty one is a lie about an agent that answered.
245
+ const at = after.indexOf(anchor);
246
+ if (at < 0) return after;
247
+ // The baseline usually ends mid-line, on the prompt glyph the engine was
248
+ // sitting at (`… $`), so the delta opens with the space between that glyph
249
+ // and what got typed. Leading blank lines and that one space are prompt
250
+ // residue, not output.
251
+ return after.slice(at + anchor.length).replace(/^\n+/, "").replace(/^[ \t]+/, "");
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Reading
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Every task in one session's ledger, oldest first.
260
+ *
261
+ * A task with no `end` event is `open`: either it is still running, or nothing
262
+ * has looked at that session since it finished. Both are true statements and
263
+ * the caller can tell them apart by asking the roster; inventing an outcome
264
+ * here would put a guess in the one place that exists to hold evidence.
265
+ */
266
+ export function readTasks(session) {
267
+ const byId = new Map();
268
+ const order = [];
269
+ for (const line of readLines(session)) {
270
+ if (!line.id) continue;
271
+ if (!byId.has(line.id)) {
272
+ byId.set(line.id, {
273
+ id: line.id, session, text: "", submitted: null, baseline: "",
274
+ transitions: [], state: null, artifact: null, truncated: false, status: "open", endedAt: null,
275
+ });
276
+ order.push(line.id);
277
+ }
278
+ const task = byId.get(line.id);
279
+ if (line.e === "submit") {
280
+ task.text = String(line.text ?? "");
281
+ task.submitted = line.ts ?? null;
282
+ task.baseline = String(line.baseline ?? "");
283
+ if (line.state) task.state = line.state;
284
+ } else if (line.e === "state") {
285
+ task.transitions.push({ ts: line.ts ?? null, state: line.state, ...(line.kind ? { kind: line.kind } : {}) });
286
+ task.state = line.state;
287
+ } else if (line.e === "end") {
288
+ task.status = "closed";
289
+ task.endedAt = line.ts ?? null;
290
+ task.state = line.state || task.state;
291
+ task.artifact = String(line.artifact ?? "");
292
+ task.truncated = Boolean(line.truncated);
293
+ task.artifactChars = line.artifactChars ?? task.artifact.length;
294
+ }
295
+ }
296
+ return order.map((id) => {
297
+ const task = byId.get(id);
298
+ return { ...task, durationMs: task.submitted && task.endedAt ? task.endedAt - task.submitted : null };
299
+ });
300
+ }
301
+
302
+ /** Every session that has a ledger. */
303
+ export function ledgerSessions() {
304
+ try {
305
+ return fs.readdirSync(tasksDir())
306
+ .filter((f) => f.endsWith(".jsonl"))
307
+ .map((f) => f.slice(0, -".jsonl".length))
308
+ .sort();
309
+ } catch { return []; }
310
+ }
311
+
312
+ /** One task by id, wherever it lives. Ids are herd-wide, so this can search. */
313
+ export function findTask(id, { sessions = ledgerSessions() } = {}) {
314
+ for (const session of sessions) {
315
+ const found = readTasks(session).find((t) => t.id === id);
316
+ if (found) return found;
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /** The open task for a session, if it has one. */
322
+ export function openTask(session) {
323
+ const tasks = readTasks(session);
324
+ for (let i = tasks.length - 1; i >= 0; i--) if (tasks[i].status === "open") return tasks[i];
325
+ return null;
326
+ }
327
+
328
+ /** The raw state history for `herd log` — transitions, task-bound or not. */
329
+ export function readLog(session) {
330
+ return readLines(session)
331
+ .filter((line) => line.e === "state" || line.e === "submit" || line.e === "end")
332
+ .map((line) => ({
333
+ ts: line.ts ?? null,
334
+ id: line.id ?? null,
335
+ state: line.e === "submit" ? (line.state || "submitted") : line.state,
336
+ event: line.e,
337
+ ...(line.kind ? { kind: line.kind } : {}),
338
+ ...(line.e === "submit" ? { text: String(line.text ?? "") } : {}),
339
+ }));
340
+ }
341
+
342
+ /**
343
+ * Time in state, per session.
344
+ *
345
+ * The interesting number is `blocked`, which is the herd's name for *human
346
+ * latency*: the agent was ready and the operator was asleep. It is the one
347
+ * figure here that is entirely within the operator's power to change, which is
348
+ * why the roster prints it with "blocked = you" next to it.
349
+ */
350
+ export function stats(session, { now = Date.now() } = {}) {
351
+ const log = readLog(session).filter((entry) => entry.state && Number.isFinite(entry.ts));
352
+ const totals = {};
353
+ let tasks = 0, blockedSpells = 0;
354
+ for (const entry of log) if (entry.event === "submit") tasks++;
355
+ for (let i = 0; i < log.length; i++) {
356
+ const state = log[i].state === "submitted" ? null : log[i].state;
357
+ if (!state) continue;
358
+ const until = log[i + 1]?.ts ?? now;
359
+ const span = Math.max(0, until - log[i].ts);
360
+ totals[state] = (totals[state] || 0) + span;
361
+ if (state === "blocked") blockedSpells++;
362
+ }
363
+ return {
364
+ session,
365
+ tasks,
366
+ blockedSpells,
367
+ totals,
368
+ from: log.length ? log[0].ts : null,
369
+ to: log.length ? now : null,
370
+ };
371
+ }
372
+
373
+ /** Drop a session's ledger — used by `kill`/`prune`, never on its own. */
374
+ export function forgetTasks(session) {
375
+ try { fs.rmSync(ledgerFile(session), { force: true }); return true; }
376
+ catch { return false; }
377
+ }