moshcode 0.59.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.
- package/README.md +121 -7
- package/bin/moshcode.mjs +2 -2
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +100 -6
- package/src/commands.mjs +84 -10
- package/src/cost.mjs +121 -2
- package/src/engines.mjs +32 -0
- package/src/herd-cli.mjs +812 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +43 -0
- package/src/tui.mjs +1 -1
package/src/herd-state.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|