moshcode 0.31.0 → 0.33.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.
@@ -0,0 +1,227 @@
1
+ // Semantic state for herd sessions (PRD 0009 R6–R8).
2
+ //
3
+ // The roster's whole value is the state column. Everything else it shows —
4
+ // name, engine, cwd — you already knew when you started the session; "which one
5
+ // stopped to ask me something" is the thing you cannot get any other way.
6
+ //
7
+ // ONE AUTHORITY PER SESSION. herdr's rule, adopted because the failure it
8
+ // prevents is real: an engine hook that reports `working` and a screen rule
9
+ // that reads `blocked` cannot both be right, and a roster that flickers between
10
+ // them is worse than one that says `unknown`. So a session with a live hook
11
+ // report is read from the hook and the screen rules are not consulted at all.
12
+ //
13
+ // Screen rules are the fallback, and they are the part that rots — engines
14
+ // change their prompts between releases and nothing tells us. Three things make
15
+ // that survivable: rules ship next to each engine's install spec in
16
+ // src/engines.mjs so they version together, `unknown` is always a safe answer
17
+ // and never blocks anything, and a user can add or override a pattern in
18
+ // ~/.moshcode/herd/rules.json without waiting for a release.
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { ENGINES } from "./engines.mjs";
23
+ import { capture, herdDir, sessionExited } from "./herd.mjs";
24
+
25
+ /** The vocabulary the roster, notifications, and `wait` all share. */
26
+ export const STATES = ["working", "blocked", "done", "idle", "unknown"];
27
+
28
+ /**
29
+ * `gone` is deliberately not in STATES: it is not a state an agent is in, it is
30
+ * the absence of one. It exists so the roster can show what a reboot took and
31
+ * `moshcode restore` has something to rebuild from.
32
+ */
33
+ export const ALL_STATES = [...STATES, "gone"];
34
+
35
+ /** How long a hook's report stays authoritative before the screen takes over. */
36
+ export const HOOK_TTL_MS = 15 * 60 * 1000;
37
+
38
+ const statusDir = () => path.join(herdDir(), "status");
39
+ const statusFile = (name) => path.join(statusDir(), `${name}.json`);
40
+
41
+ /**
42
+ * Terminal escapes have to go before anything is matched.
43
+ *
44
+ * tmux's capture-pane already hands back plain text, but the pty substrate's
45
+ * transcript is the raw stream — every colour change, cursor move and
46
+ * alternate-screen switch still in it. A rule like /Do you want to/ will miss
47
+ * when the engine coloured half the sentence.
48
+ */
49
+ export function stripAnsi(text) {
50
+ return String(text)
51
+ // CSI, OSC and the two-character escapes, in that order.
52
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
53
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
54
+ .replace(/\x1b[@-Z\\-_]/g, "")
55
+ .replace(/\r(?!\n)/g, "\n");
56
+ }
57
+
58
+ /**
59
+ * Patterns that hold across engines.
60
+ *
61
+ * Every one of these is a *terminal-shaped* question — a y/n, a numbered menu
62
+ * selector, a "press enter" — rather than a word that happens to appear in
63
+ * agent output. "Approve" on its own would match an agent writing about an
64
+ * approvals feature; `[y/N]` at the end of a screen would not.
65
+ */
66
+ export const COMMON_RULES = {
67
+ blocked: [
68
+ /\[y\/n\]/i,
69
+ /\((?:y(?:es)?\/n(?:o)?)\)\s*[:?]?\s*$/im,
70
+ /\bdo you want to\b/i,
71
+ /\bpress (?:enter|return) to continue\b/i,
72
+ // The cursor on a numbered menu. Engines do not agree on the glyph —
73
+ // Claude Code draws ❯, Codex draws › — and the plain > is there for the
74
+ // ones that use ASCII. Observed, not guessed.
75
+ /^\s*[❯›▸>]\s*\d+\.\s+\S/m,
76
+ /\bwaiting for (?:your )?(?:approval|confirmation)\b/i,
77
+ ],
78
+ working: [
79
+ /\besc(?:ape)? to interrupt\b/i,
80
+ /\bctrl\+c to (?:stop|cancel|interrupt)\b/i,
81
+ /\bpress esc to cancel\b/i,
82
+ ],
83
+ idle: [],
84
+ };
85
+
86
+ /**
87
+ * User overrides, so a rule that rots can be fixed on the box it rots on.
88
+ *
89
+ * Shape mirrors the engine table: { "<engine>": { blocked: ["…"], … } }, with
90
+ * patterns as strings because JSON has no regex literal. `common` is accepted
91
+ * as an engine name to extend the shared set. Never throws — a malformed rules
92
+ * file must not take down the roster.
93
+ */
94
+ export function loadUserRules(file = path.join(herdDir(), "rules.json")) {
95
+ let raw;
96
+ try { raw = JSON.parse(fs.readFileSync(file, "utf8")); }
97
+ catch { return {}; }
98
+ if (!raw || typeof raw !== "object") return {};
99
+ const out = {};
100
+ for (const [engine, group] of Object.entries(raw)) {
101
+ if (!group || typeof group !== "object") continue;
102
+ const compiled = {};
103
+ for (const state of ["blocked", "working", "idle"]) {
104
+ const patterns = Array.isArray(group[state]) ? group[state] : [];
105
+ compiled[state] = patterns.flatMap((p) => {
106
+ try { return [new RegExp(p, "im")]; }
107
+ catch { return []; } // one bad pattern loses that pattern, not the file
108
+ });
109
+ }
110
+ out[engine] = compiled;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** The rule set for one engine: user overrides, then its own, then the shared. */
116
+ export function rulesFor(engine, { userRules = loadUserRules() } = {}) {
117
+ const own = ENGINES[engine]?.state || {};
118
+ const user = userRules[engine] || {};
119
+ const common = userRules.common || {};
120
+ const merge = (state) => [
121
+ ...(user[state] || []),
122
+ ...(own[state] || []),
123
+ ...(common[state] || []),
124
+ ...(COMMON_RULES[state] || []),
125
+ ];
126
+ return { blocked: merge("blocked"), working: merge("working"), idle: merge("idle") };
127
+ }
128
+
129
+ /**
130
+ * Classify a screen.
131
+ *
132
+ * Order is not arbitrary. `blocked` is checked first because it is the only
133
+ * state that costs the user something to miss, and because a blocked engine's
134
+ * screen frequently still carries the "esc to interrupt" hint from the work it
135
+ * was doing a moment ago. `idle` last, and only on a positive match, so a quiet
136
+ * screen nobody has written a rule for reports `unknown` instead of a
137
+ * confident lie.
138
+ */
139
+ export function classify(screen, rules) {
140
+ const text = stripAnsi(screen);
141
+ if (!text.trim()) return "unknown";
142
+ // Only the bottom of the screen decides. An agent that printed a y/n prompt
143
+ // twenty lines ago and moved on is not blocked, and scrollback is full of
144
+ // sentences that look like prompts.
145
+ const lines = text.split("\n");
146
+ const tail = lines.slice(Math.max(0, lines.length - 25)).join("\n");
147
+ for (const state of ["blocked", "working", "idle"]) {
148
+ if ((rules[state] || []).some((re) => re.test(tail))) return state;
149
+ }
150
+ return "unknown";
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Tier 1: the hook report
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Record an authoritative state, written by an engine's own lifecycle hook via
159
+ * `moshcode herd report`. `ttl` is in milliseconds and bounded: a hook that
160
+ * claims authority forever would leave a crashed agent reading `working` until
161
+ * someone noticed by hand.
162
+ */
163
+ export function reportState(name, state, { ttl = HOOK_TTL_MS, now = Date.now() } = {}) {
164
+ if (!STATES.includes(state)) return { ok: false, error: new Error(`unknown state ${JSON.stringify(state)} — one of ${STATES.join(", ")}`) };
165
+ try {
166
+ fs.mkdirSync(statusDir(), { recursive: true, mode: 0o700 });
167
+ const file = statusFile(name);
168
+ fs.writeFileSync(file, JSON.stringify({ state, at: now, ttl: Math.min(Number(ttl) || HOOK_TTL_MS, HOOK_TTL_MS) }), { mode: 0o600 });
169
+ fs.chmodSync(file, 0o600);
170
+ return { ok: true, state };
171
+ } catch (error) {
172
+ return { ok: false, error };
173
+ }
174
+ }
175
+
176
+ /** The live hook report for a session, or null when there is none worth trusting. */
177
+ export function hookReport(name, { now = Date.now() } = {}) {
178
+ let raw;
179
+ try { raw = JSON.parse(fs.readFileSync(statusFile(name), "utf8")); }
180
+ catch { return null; }
181
+ if (!raw || !STATES.includes(raw.state)) return null;
182
+ const ttl = Math.min(Number(raw.ttl) || HOOK_TTL_MS, HOOK_TTL_MS);
183
+ if (!Number.isFinite(raw.at) || now - raw.at > ttl) return null;
184
+ return { state: raw.state, at: raw.at };
185
+ }
186
+
187
+ export function clearReport(name) {
188
+ try { fs.rmSync(statusFile(name), { force: true }); return true; }
189
+ catch { return false; }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // The answer
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * The state of one session, and where that answer came from.
198
+ *
199
+ * `authority` is returned alongside the state on purpose: when a rule rots, the
200
+ * first useful question is "was anything even reading the screen?", and a
201
+ * roster that cannot answer it sends people to read this file instead.
202
+ */
203
+ export function sessionState(session, { now = Date.now(), userRules = loadUserRules(), read = capture } = {}) {
204
+ const name = typeof session === "string" ? session : session.name;
205
+ const meta = typeof session === "string" ? {} : session;
206
+
207
+ if (meta.alive === false) return { state: "gone", authority: "runtime" };
208
+
209
+ // A finished process is done, and no screen rule gets a vote on that. This is
210
+ // the one thing the runtime knows for certain.
211
+ const exited = meta.exited ?? sessionExited(name);
212
+ if (exited === true) return { state: "done", authority: "runtime" };
213
+ if (exited === null && meta.alive === undefined) return { state: "gone", authority: "runtime" };
214
+
215
+ const hook = hookReport(name, { now });
216
+ if (hook) return { state: hook.state, authority: "hook" };
217
+
218
+ const screen = read(name);
219
+ if (!screen) return { state: "unknown", authority: "screen" };
220
+ return { state: classify(screen, rulesFor(meta.engine, { userRules })), authority: "screen" };
221
+ }
222
+
223
+ /** listSessions() output, each row carrying its state. */
224
+ export function withState(sessions, options = {}) {
225
+ const userRules = options.userRules ?? loadUserRules();
226
+ return sessions.map((s) => ({ ...s, ...sessionState(s, { ...options, userRules }) }));
227
+ }