amicus 4.8.1 → 4.9.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.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +36 -15
  10. package/electron/offer-session.js +51 -0
  11. package/electron/setup-ui.js +18 -8
  12. package/electron/workspace-ui/live-dead-seats.js +163 -91
  13. package/electron/workspace-ui/workspace-banners.js +30 -7
  14. package/electron/workspace-ui/workspace-matrix.js +23 -3
  15. package/electron/workspace-ui/workspace-seats.js +95 -79
  16. package/package.json +1 -1
  17. package/schemas/council-run.schema.json +2 -2
  18. package/schemas/council-tally.schema.json +17 -1
  19. package/schemas/council-verdict.schema.json +12 -4
  20. package/schemas/run.schema.json +6 -1
  21. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  22. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  23. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  24. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  25. package/skills/second-opinion/SKILL.md +151 -36
  26. package/src/cli-council-run-bench.js +98 -6
  27. package/src/cli-handlers-council-run.js +18 -6
  28. package/src/cli-handlers-council.js +57 -7
  29. package/src/cli-handlers-doctor.js +1 -1
  30. package/src/cli.js +3 -1
  31. package/src/council/anonymize.js +2 -1
  32. package/src/council/briefings-chair-task.js +161 -0
  33. package/src/council/briefings-chair.js +33 -8
  34. package/src/council/briefings-debate.js +79 -13
  35. package/src/council/briefings-stage2-task.js +236 -0
  36. package/src/council/briefings-stage2.js +103 -26
  37. package/src/council/briefings-task.js +167 -0
  38. package/src/council/briefings.js +41 -4
  39. package/src/council/chair-fallback.js +95 -0
  40. package/src/council/debate.js +38 -21
  41. package/src/council/findings.js +3 -2
  42. package/src/council/ledger.js +2 -2
  43. package/src/council/parse-stage2.js +63 -15
  44. package/src/council/report-cost.js +61 -0
  45. package/src/council/report-html.js +26 -4
  46. package/src/council/report-md.js +30 -2
  47. package/src/council/report.js +40 -37
  48. package/src/council/run-assemble.js +21 -6
  49. package/src/council/run-chair.js +44 -95
  50. package/src/council/run-debate-revote.js +81 -49
  51. package/src/council/run-debate.js +51 -34
  52. package/src/council/run-finish.js +5 -3
  53. package/src/council/run-retry-keys.js +4 -4
  54. package/src/council/run-retry-launch.js +4 -4
  55. package/src/council/run-retry-notes.js +72 -15
  56. package/src/council/run-stage1-launch.js +4 -4
  57. package/src/council/run-stage1-rows.js +9 -6
  58. package/src/council/run-stage2.js +81 -47
  59. package/src/council/run-stages.js +9 -21
  60. package/src/council/run-stats-entry.js +46 -1
  61. package/src/council/run.js +28 -13
  62. package/src/council/seats.js +2 -2
  63. package/src/council/stage1-bind.js +3 -2
  64. package/src/council/verdict-seat-loss.js +124 -0
  65. package/src/council/verdict.js +108 -99
  66. package/src/headless.js +256 -49
  67. package/src/mcp-council-bench.js +64 -3
  68. package/src/mcp-council-run.js +10 -3
  69. package/src/mcp-server.js +52 -12
  70. package/src/mcp-tools.js +41 -5
  71. package/src/observe/council-legs.js +2 -2
  72. package/src/opencode-client.js +19 -1
  73. package/src/pack/pack-forward.js +15 -12
  74. package/src/pack/pack-resolve.js +1 -1
  75. package/src/prompt-builder.js +17 -1
  76. package/src/sidecar/fanout-leg.js +26 -0
  77. package/src/sidecar/fanout.js +1 -1
  78. package/src/sidecar/list-council.js +178 -0
  79. package/src/sidecar/list-limit.js +3 -1
  80. package/src/sidecar/list-search.js +2 -1
  81. package/src/sidecar/models.js +8 -1
  82. package/src/sidecar/read.js +34 -10
  83. package/src/template/render.js +16 -7
  84. package/src/utils/alias-shadow-writer.js +220 -0
  85. package/src/utils/alias-shadow.js +294 -0
  86. package/src/utils/config.js +1 -1
  87. package/src/utils/curated-models.js +11 -3
  88. package/src/utils/degrade.js +12 -5
  89. package/src/utils/doctor-alias-check.js +2 -5
  90. package/src/utils/engine-log-parse.js +289 -0
  91. package/src/utils/engine-log-tail.js +114 -0
  92. package/src/utils/engine-log.js +250 -0
  93. package/src/utils/engine-skew-records.js +146 -0
  94. package/src/utils/engine-skew.js +300 -0
  95. package/src/utils/gateway-router.js +10 -2
  96. package/src/utils/model-catalog.js +1 -1
  97. package/src/utils/result-schema.js +10 -0
  98. package/src/utils/text-sanitize.js +81 -0
  99. package/src/utils/ttft.js +57 -0
  100. package/src/utils/untrusted-fence.js +111 -1
  101. package/src/workspace/fold-format.js +28 -7
  102. package/src/workspace/live-normalize.js +2 -1
  103. package/src/workspace/matrix-model.js +6 -2
  104. package/src/workspace/run-detail.js +33 -7
  105. package/src/workspace/seat-space.js +10 -6
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @module utils/engine-log-parse
3
+ * Line-shape parsing for the engine log: level, session, message.
4
+ *
5
+ * Is this line an ERROR, is it about THIS session, and where does its human
6
+ * message begin.
7
+ *
8
+ * EXTRACTED from src/utils/engine-log.js (v4.9 W10 round 2). That module is the
9
+ * I/O half — candidate dirs, mtime order, the scan budget and its memo, with the
10
+ * bounded tail reads themselves in `./engine-log-tail.js` since PR #206;
11
+ * everything here is a pure function of ONE line, which is where every
12
+ * correlation and truncation finding has landed since. `engine-log.js`
13
+ * re-exports all of it, so a consumer still has one import site.
14
+ *
15
+ * TWO LINE FORMATS are in the wild and both are matched:
16
+ * logfmt (1.17.x): `… level=ERROR … session.id=ses_<id> error=<msg>`
17
+ * columnar (1.2.x): `ERROR <iso> +Nms service=… id=ses_<id> <msg>`
18
+ * A session-identity FIELD is the correlation key in both — never a bare
19
+ * mention (round-3 review A1; see lineIsAboutSession).
20
+ *
21
+ * WHAT IS NOT HERE: the excerpt sanitizer, which moved to `./text-sanitize.js`
22
+ * in round 3 when `engine-skew.js` became its second caller. The TOKENIZER
23
+ * stayed: logfmt/columnar token shapes are this module's domain, and all three
24
+ * of its consumers live in this file.
25
+ *
26
+ * PURE: no I/O, no throwing paths that a caller has to guard.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ // The excerpt sanitizer MOVED to ./text-sanitize.js (round 3) once `engine-skew`
32
+ // became its second caller — see that module's docblock. Re-exported below, as
33
+ // the same function object, so every existing import site stays valid.
34
+ const { collapseExcerpt, MAX_EXCERPT_CHARS } = require('./text-sanitize');
35
+
36
+ /** A `key=value` token — the structural shape in both formats. */
37
+ const PAIR_TOKEN = /^[\w.[\]-]+=/;
38
+ /**
39
+ * The columnar header run before the message: `ERROR <iso> +Nms`. A LEVEL word,
40
+ * an ISO date, or a SIGNED duration — and nothing else.
41
+ *
42
+ * A bare number is deliberately NOT header-shaped (round-2 review B5): the
43
+ * header run ends the prefix on a pairless line, so anything it accepts is
44
+ * something it EATS — the older `[+-]?\d[\w.:+-]*` would have swallowed the `3`
45
+ * of `3 retries exhausted`.
46
+ */
47
+ const HEADER_TOKEN = /^(?:[A-Z]+|\d{4}-\d\d-\d\d[\w:.+-]*|[+-]\d[\w.]*)$/;
48
+
49
+ /**
50
+ * Which format is this line? A columnar line OPENS with a bare level token; a
51
+ * logfmt line opens with a `key=value`.
52
+ *
53
+ * Round-2 review A2: the shape decides, NOT the presence of the substring
54
+ * `error=` somewhere in the text. Running the logfmt extractor first on every
55
+ * line cut columnar messages that merely CONTAINED `error=` — measured before
56
+ * the fix, `… id=ses_… retry limit reached, error=true was set in config` came
57
+ * back as `true was set in config`.
58
+ */
59
+ function isColumnarLine(line) {
60
+ return /^\s*[A-Z]+(\s|$)/.test(line);
61
+ }
62
+
63
+ /**
64
+ * The line's tokens: whitespace-separated, except that a QUOTED value
65
+ * (`key="…"`) holds together however much whitespace it contains, backslash
66
+ * escapes honoured so an embedded `\"` does not end it (round-2 review B5).
67
+ *
68
+ * ONE DIALECT, THREE CONSUMERS (round-3 reviews B2 and C5). "Where does a field
69
+ * end and prose begin" had three substring answers, each wrong somewhere: a
70
+ * whitespace split read `error="lost session.id=<them> mid-write"` as two
71
+ * fields, the second a session identity; the `error=` extractor matched that
72
+ * substring anywhere, so `msg="db error=timeout"` became the message. One
73
+ * tokenizer, and all three agree by construction.
74
+ *
75
+ * `at` is the token's offset in `line` — what lets a caller slice the ORIGINAL
76
+ * text rather than re-join tokens and lose its spacing.
77
+ * @param {string} line
78
+ * @returns {{text: string, at: number}[]}
79
+ */
80
+ function tokenize(line) {
81
+ const text = String(line);
82
+ const tokens = [];
83
+ let i = 0;
84
+ while (i < text.length) {
85
+ if (/\s/.test(text[i])) { i += 1; continue; }
86
+ const at = i;
87
+ let quoted = false;
88
+ while (i < text.length && (quoted || !/\s/.test(text[i]))) {
89
+ if (quoted && text[i] === '\\') { i += 2; continue; }
90
+ if (text[i] === '"') { quoted = !quoted; }
91
+ i += 1;
92
+ }
93
+ tokens.push({ text: text.slice(at, i), at });
94
+ }
95
+ return tokens;
96
+ }
97
+
98
+ /**
99
+ * The index of the STRUCTURAL RUN's last token, or -1 when the line opens with
100
+ * prose. The run is `ERROR <iso> +Nms service=… id=ses_…` at a columnar line's
101
+ * start, and the whole `key=value` sequence of a logfmt line.
102
+ *
103
+ * Deliberately not "up to the LAST key=value on the line" (W10 round-1 review
104
+ * B2): an engine error naming a setting mid-sentence — `could not parse foo=bar
105
+ * in the config` — would lose everything before it. Once the run ends, the rest
106
+ * of the line is text, `=` and all.
107
+ *
108
+ * HEADER tokens count only until the first pair; after one, an all-caps or
109
+ * numeric word is message text (`… id=ses_x FATAL disk error`). But they do
110
+ * ADVANCE the run, letting a PAIRLESS columnar line shed its header (round-2 B5).
111
+ * @param {{text: string, at: number}[]} tokens
112
+ * @returns {number}
113
+ */
114
+ function structuralRunEnd(tokens) {
115
+ let runEnd = -1;
116
+ let seenPair = false;
117
+ for (let i = 0; i < tokens.length; i++) {
118
+ if (PAIR_TOKEN.test(tokens[i].text)) { seenPair = true; runEnd = i; continue; }
119
+ if (!seenPair && HEADER_TOKEN.test(tokens[i].text)) { runEnd = i; continue; }
120
+ break;
121
+ }
122
+ return runEnd;
123
+ }
124
+
125
+ /** A leading QUOTED run's content, escapes decoded by `tokenize`'s own rule. */
126
+ function unquote(raw) {
127
+ let out = '';
128
+ for (let i = 1; i < raw.length; i++) {
129
+ if (raw[i] === '\\' && i + 1 < raw.length) { out += raw[i + 1]; i += 1; continue; }
130
+ if (raw[i] === '"') { break; }
131
+ out += raw[i];
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * The logfmt `error=` value: quoted content when quoted, else the rest of the
138
+ * line, since an unquoted engine error is a sentence, not one token, and is
139
+ * conventionally last. Null when the line has no such key.
140
+ *
141
+ * A TOP-LEVEL key only (round-3 review C5). The old match was a substring sweep,
142
+ * so an `error=` inside another field's quoted value was taken as the message —
143
+ * MEASURED before the fix, `… msg="db error=timeout" error=connection refused`
144
+ * returned `timeout" error=connection refused`. Tokenizing first makes the
145
+ * boundary structural instead of textual.
146
+ */
147
+ function errorFieldValue(line) {
148
+ const text = String(line);
149
+ for (const token of tokenize(text)) {
150
+ if (!token.text.startsWith('error=')) { continue; }
151
+ const value = token.text.slice('error='.length);
152
+ return value.startsWith('"') ? unquote(value) : text.slice(token.at + 'error='.length);
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Everything after the structural run — exactly where a columnar message
159
+ * begins. With no structural token at all the whole line comes back, so an
160
+ * unrecognized future format degrades to "slightly noisy" rather than silence.
161
+ */
162
+ function afterStructuralRun(line) {
163
+ const text = String(line);
164
+ const tokens = tokenize(text);
165
+ const runEnd = structuralRunEnd(tokens);
166
+ if (runEnd === -1) { return text.trim(); }
167
+ const next = tokens[runEnd + 1];
168
+ return next ? text.slice(next.at).trim() : '';
169
+ }
170
+
171
+ /** The human part of an ERROR line, chosen by the line's SHAPE. */
172
+ function extractMessage(line) {
173
+ if (!isColumnarLine(line)) {
174
+ const value = errorFieldValue(line);
175
+ if (value !== null) { return value; }
176
+ }
177
+ return afterStructuralRun(line);
178
+ }
179
+
180
+ /** A token character. MEASURED (2026-08-25) over this machine's own engine logs:
181
+ * 369 distinct ids, each exactly `ses_` + 26 characters, every one drawn from
182
+ * `[A-Za-z0-9]` — no `-`, `_`, or `.`. `_` counts here anyway: an id cannot
183
+ * contain one, so a `_` beside a match means the token is longer than an id. */
184
+ function isTokenCharCode(code) {
185
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 90)
186
+ || (code >= 97 && code <= 122) || code === 95;
187
+ }
188
+
189
+ /**
190
+ * Does `text` mention session `needle` as a WHOLE id?
191
+ *
192
+ * `String.includes` is wrong here: ids share prefixes, so `ses_abc` matches
193
+ * `ses_abc123`'s line and a leg would quote a stranger's failure as its own
194
+ * (W10 round-1 review A1). BOTH edges are anchored — round-1 anchored only the
195
+ * right one, so `Xses_abc` and `prev_ses_abc` still matched (round-2 review B6).
196
+ * A char-code boundary test rather than a regex: this runs per candidate field
197
+ * of every ERROR line in a tail read, once per dead seat in a wave.
198
+ */
199
+ function mentionsSession(line, needle) {
200
+ let from = 0;
201
+ for (;;) {
202
+ const at = line.indexOf(needle, from);
203
+ if (at === -1) { return false; }
204
+ const after = at + needle.length;
205
+ if ((at === 0 || !isTokenCharCode(line.charCodeAt(at - 1)))
206
+ && (after >= line.length || !isTokenCharCode(line.charCodeAt(after)))) { return true; }
207
+ from = at + 1;
208
+ }
209
+ }
210
+
211
+ /** The field names that carry SESSION identity, in both formats. Exact
212
+ * spellings: `parent.id` and `message.id` also end in `id` and name something
213
+ * else entirely; treating them as identity hands us a foreign line. */
214
+ const SESSION_FIELDS = new Set(['id', 'session', 'sessions', 'sessionid', 'session.id']);
215
+ const FIELD_TOKEN = /^([\w.[\]-]+)=(.*)$/;
216
+
217
+ /**
218
+ * ERROR level in either format: columnar's leading `ERROR` word, or logfmt's
219
+ * `level=ERROR` read as a TOP-LEVEL FIELD of the structural run.
220
+ *
221
+ * A FIELD, not a substring (#201 final-round tail C1). The old logfmt half was
222
+ * `/(^|\s)level=ERROR(\s|$)/` over the raw line — the last substring sweep left
223
+ * in this module, the shape rounds 2 and 3 replaced in the format test (A2),
224
+ * the `error=` extractor (C5) and the ownership scan (B2). A quoted value that
225
+ * merely CONTAINS the delimited text promoted the whole line, and an INFO line
226
+ * is ordinary prose about a retry: MEASURED before the fix, `… level=INFO …
227
+ * msg="upstream logged level=ERROR moments ago" fixture fell back to the cache`
228
+ * was reported as the leg's cause of death as `fixture fell back to the cache`.
229
+ * The same run bound applies as everywhere else: past the fields, `level=ERROR`
230
+ * is text a human wrote, `=` and all.
231
+ *
232
+ * The substring is still the FIRST test, just no longer the LAST word: it is a
233
+ * necessary condition, so a line without it rejects on one `indexOf` and never
234
+ * tokenizes — which is what keeps this the cheap half of the pair in
235
+ * `engine-log-tail.js :: newestExcerptInFile`.
236
+ */
237
+ function isErrorLine(line) {
238
+ const text = String(line);
239
+ if (/^\s*ERROR\b/.test(text)) { return true; }
240
+ if (text.indexOf('level=ERROR') === -1) { return false; }
241
+ const tokens = tokenize(text);
242
+ const runEnd = structuralRunEnd(tokens);
243
+ for (let i = 0; i <= runEnd; i++) {
244
+ const field = FIELD_TOKEN.exec(tokens[i].text);
245
+ if (field && field[1].toLowerCase() === 'level' && field[2] === 'ERROR') { return true; }
246
+ }
247
+ return false;
248
+ }
249
+
250
+ /**
251
+ * Is this line ABOUT session `needle` — not merely one that mentions it?
252
+ *
253
+ * OWNERSHIP COMES ONLY FROM A SESSION-IDENTITY FIELD IN THE STRUCTURAL RUN. A
254
+ * line carrying no such field is NEVER attributed — no exception, no fallback.
255
+ *
256
+ * Round 2 established the first half (a whole-token MENTION is not ownership:
257
+ * `… session.id=ses_other parent.id=<us> error="…"` was returning another
258
+ * session's failure as this leg's own) but kept a bare-mention fallback for
259
+ * lines with no session field, and scanned fields by splitting on whitespace.
260
+ * Round-3 reviews A1+B2 measured both leaks: a foreign line's FREE TEXT still
261
+ * decided ownership whenever the line named no session in a field, and a
262
+ * `session.id=`-shaped token INSIDE free text — a storage path, an `error=`
263
+ * sentence, another field's quoted value — was read as a field and voted.
264
+ *
265
+ * PRECISION OVER RECALL is the lead's ruling, and the trade is cheap one way
266
+ * only: a missed attribution falls through to the byte-identical message this
267
+ * module's whole miss path is pinned on (and the skew clause is independent of
268
+ * it), while a wrong one states a stranger's failure as this leg's own.
269
+ */
270
+ function lineIsAboutSession(line, needle) {
271
+ const tokens = tokenize(line);
272
+ const runEnd = structuralRunEnd(tokens);
273
+ let ours = false;
274
+ for (let i = 0; i <= runEnd; i++) {
275
+ const field = FIELD_TOKEN.exec(tokens[i].text);
276
+ if (!field || !SESSION_FIELDS.has(field[1].toLowerCase())) { continue; }
277
+ if (mentionsSession(field[2], needle)) { ours = true; }
278
+ }
279
+ return ours;
280
+ }
281
+
282
+ module.exports = {
283
+ isErrorLine,
284
+ extractMessage,
285
+ collapseExcerpt,
286
+ mentionsSession,
287
+ lineIsAboutSession,
288
+ MAX_EXCERPT_CHARS,
289
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @module utils/engine-log-tail
3
+ * One engine-log FILE: read its tail, find the newest usable excerpt in it.
4
+ *
5
+ * Extracted from `./engine-log.js` in PR #206's fix round, for the reason that
6
+ * file's two earlier extractions happened (`./engine-log-parse.js` in W10 round
7
+ * 2, `./text-sanitize.js` in round 3): it sat at exactly 300/300 lines, and the
8
+ * A1+B2 fix — a MISS may not be served from the warm scan memo — needs both new
9
+ * code and the disclosure of its residual. A same-line-count swap would have
10
+ * had to pay for those by deleting prose three review rounds argued for, so the
11
+ * stable neighbour moved instead and moved UNCHANGED.
12
+ *
13
+ * THE SPLIT. `./engine-log.js` decides WHICH files to open and in what order,
14
+ * and holds the total byte budget one lookup may spend. This file spends it,
15
+ * one file at a time: how much of a file to read, how to decode it safely, and
16
+ * which line in it answers. The line SHAPE rules — what an ERROR line is, which
17
+ * line is about a session, where its message begins — are `./engine-log-parse.js`.
18
+ *
19
+ * BEST-EFFORT, like its caller: an unreadable file yields null and no bytes
20
+ * charged, never a throw. A log read must not become the failure it reports on.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const {
26
+ isErrorLine, extractMessage, collapseExcerpt, lineIsAboutSession,
27
+ } = require('./engine-log-parse');
28
+
29
+ /** Read at most the last 256 KiB of any candidate. The legacy single file is
30
+ * 2.4 MB on the reference machine, and this runs on a leg's death path (often
31
+ * once per dead seat in a wave) — whole-file reads are not an acceptable cost
32
+ * for a diagnostic nicety. */
33
+ const MAX_TAIL_BYTES = 256 * 1024;
34
+
35
+ /** Read the last MAX_TAIL_BYTES of a file, or `budget` bytes if that is less.
36
+ * When the read started mid-file the first line is dropped: it is a fragment,
37
+ * and slicing at a byte offset can also land inside a multi-byte UTF-8
38
+ * sequence. `bytes` is what the read actually spent, so the caller can charge
39
+ * it against its own scan budget (`engine-log.js :: MAX_SCAN_BYTES`).
40
+ * @returns {{text: string, bytes: number}} */
41
+ function readTail(file, fsImpl, budget) {
42
+ const size = fsImpl.statSync(file).size;
43
+ const cap = Math.min(MAX_TAIL_BYTES, Math.max(0, budget));
44
+ const start = Math.max(0, size - cap);
45
+ const length = Math.min(size, cap);
46
+ if (!(length > 0)) { return { text: '', bytes: 0 }; }
47
+ const fd = fsImpl.openSync(file, 'r');
48
+ let text;
49
+ try {
50
+ const buf = Buffer.alloc(length);
51
+ // Decode only what was actually read: a short read would otherwise decode
52
+ // the allocation's zero-fill as NUL characters, which no whitespace
53
+ // collapse strips and which would ride into the excerpt.
54
+ const bytesRead = fsImpl.readSync(fd, buf, 0, length, start);
55
+ text = buf.toString('utf-8', 0, Number.isFinite(bytesRead) ? bytesRead : length);
56
+ } finally {
57
+ try { fsImpl.closeSync(fd); } catch (_e) { /* best effort */ }
58
+ }
59
+ if (start > 0) {
60
+ const firstBreak = text.indexOf('\n');
61
+ text = firstBreak === -1 ? '' : text.slice(firstBreak + 1);
62
+ }
63
+ return { text, bytes: length };
64
+ }
65
+
66
+ /**
67
+ * The NEWEST usable excerpt in this file for `needle`. Logs are append-ordered,
68
+ * so scanning backwards reaches the newest match first — and the newest is the
69
+ * one that killed the leg (a session can log several errors while it degrades).
70
+ *
71
+ * ABOUT, not merely mentioning (round-2 review A1, tightened in round 3): a line
72
+ * whose structural session field names someone else is skipped even when our id
73
+ * appears elsewhere on it, and the walk continues to an older line that is
74
+ * really ours.
75
+ *
76
+ * USABLE, not merely matching (round-3 review C4). A matching line whose message
77
+ * part is empty used to end the file's scan, so the fallthrough skipped to the
78
+ * next FILE and never reached the older line in THIS one that actually says what
79
+ * happened — and those two are typically neighbours, the real failure followed by
80
+ * a terse message-less line as the session tears down. The excerpt is therefore
81
+ * built here, inside the walk, and an empty one just keeps walking. It costs no
82
+ * extra I/O: the tail is already in memory, so the byte budget is unchanged.
83
+ *
84
+ * `isErrorLine` runs FIRST because it is the cheaper test — it rejects a
85
+ * non-ERROR line on one `indexOf` and tokenizes only when the level substring
86
+ * is actually present (#201 tail C1), while ownership tokenizes every line it
87
+ * is handed. Only ERROR lines can ever answer.
88
+ *
89
+ * `tails` is the caller's memo (#201 tail C2); a cached entry carries the bytes
90
+ * its ORIGINAL read spent, charged again here, so the budget accounts
91
+ * identically warm or cold and both walk the same files to the same stop. A
92
+ * FAILED read is deliberately not cached — an unreadable file is a transient.
93
+ * @returns {{excerpt: string|null, bytes: number}} `bytes` is the budget spent.
94
+ */
95
+ function newestExcerptInFile(file, needle, fsImpl, budget, tails) {
96
+ let read = tails && tails.get(file);
97
+ if (!read) {
98
+ try { read = readTail(file, fsImpl, budget); } catch (_e) { return { excerpt: null, bytes: 0 }; }
99
+ if (tails) { tails.set(file, read); }
100
+ }
101
+ const lines = read.text.split(/\r?\n/);
102
+ for (let i = lines.length - 1; i >= 0; i--) {
103
+ const line = lines[i];
104
+ if (!isErrorLine(line) || !lineIsAboutSession(line, needle)) { continue; }
105
+ const excerpt = collapseExcerpt(extractMessage(line));
106
+ if (excerpt) { return { excerpt, bytes: read.bytes }; }
107
+ }
108
+ return { excerpt: null, bytes: read.bytes };
109
+ }
110
+
111
+ // `MAX_TAIL_BYTES` and `readTail` stay internal: nothing outside this file
112
+ // consumes either, and a constant in a `Key Exports` cell reads as a function
113
+ // it is not (engine-log.js's rule).
114
+ module.exports = { newestExcerptInFile };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @module utils/engine-log
3
+ * Resolve the OpenCode engine's own error line for one session.
4
+ *
5
+ * A leg that dies silent can then quote the truth instead of a guess.
6
+ *
7
+ * WHY (#133). The NO_OUTPUT_BACKSTOP message reports only what the deadline
8
+ * observed — deliberately, since the guess it replaced ("likely a listed-but-
9
+ * not-serving model or a dead endpoint") sent 30 minutes of a real outage's
10
+ * debugging at model ids and API keys. But the engine had written its real
11
+ * failure to its own log at the exact timestamp of every dead session the
12
+ * whole time. Reporting silence while the cause sits on disk unread is the
13
+ * "correct but SILENT degrade" the product principle forbids as hard as a
14
+ * crash. This module reads it; src/headless.js appends it to the reason.
15
+ *
16
+ * MEASURED (2026-08-25). The long-quoted single-file premise —
17
+ * `~/.local/share/opencode/log/opencode.log` — is STALE. The current engine
18
+ * writes ONE timestamped file PER PROCESS (`2026-08-25T185532.log`), and the
19
+ * two schemes coexist across machines: the reference machine still carries a
20
+ * 2.4 MB legacy `opencode.log`, while this one holds 12 timestamped files and
21
+ * NO `opencode.log` at all. A resolver that opened only the legacy name would
22
+ * therefore return nothing on a current install — silently, forever.
23
+ *
24
+ * THE TWO LINE FORMATS (logfmt at 1.17.x, columnar at 1.2.x), which line is an
25
+ * ERROR, which line is about this session, and where its message begins all
26
+ * live in `./engine-log-parse.js` — extracted in W10 round 2 and re-exported
27
+ * below. Reading ONE file's tail and finding the newest usable line in it is
28
+ * `./engine-log-tail.js`, extracted in PR #206's fix round. This file is what
29
+ * is left of the I/O half: which files to open, in what order, the total bytes
30
+ * one lookup may spend — and how long one scan is reused (#201 tail C2).
31
+ *
32
+ * THAT REUSE SERVES HITS ONLY (PR #206 round-1 A1+B2). A MISS re-lists and
33
+ * re-reads before it is believed, because the engine writes a leg's error WHEN
34
+ * that leg dies — possibly after the cached scan, possibly into a file it has
35
+ * only just rolled over to — so an absence in a previous call's listing and
36
+ * tails is not an absence on disk. The accepted residual runs the other way,
37
+ * and round 3 states its REAL bound. What a warm HIT serves is the
38
+ * newest-for-THIS-SESSION line as of the moment the slot was built, so the
39
+ * TTL bounds the MISSED WINDOW — lines written in the last ≤10 seconds — and
40
+ * bounds nothing about the AGE of what is served: the GAP between the quoted
41
+ * line and the line it misses is unbounded, because the cached answer can be
42
+ * arbitrarily old and still have been newest when it was read. A minutes-old
43
+ * error can therefore be quoted while a fatal line written two seconds ago
44
+ * sits unread. What it quotes is still a genuine ERROR line for THIS leg,
45
+ * which is a true diagnostic; suppression — reporting silence while the cause
46
+ * sits on disk unread — is the failure this module exists to end.
47
+ *
48
+ * EVERYTHING HERE IS BEST-EFFORT: every path returns null rather than
49
+ * throwing. A log read must never break a leg's death report — the report is
50
+ * the product; the excerpt is a bonus on top of it.
51
+ */
52
+
53
+ 'use strict';
54
+
55
+ const os = require('os');
56
+ const path = require('path');
57
+ const {
58
+ isErrorLine, extractMessage, collapseExcerpt, mentionsSession, lineIsAboutSession,
59
+ } = require('./engine-log-parse');
60
+ const { newestExcerptInFile } = require('./engine-log-tail');
61
+
62
+ /**
63
+ * The TOTAL tail bytes one lookup may read across all candidates — 8 full tail
64
+ * reads' worth (`engine-log-tail.js :: MAX_TAIL_BYTES`, 256 KiB). The bound that
65
+ * used to sit here was "the 3 newest files by mtime", and it broke exactly where
66
+ * this module matters most (round-2 review
67
+ * B1): the engine writes one log PER PROCESS, so in a mass-death wave every
68
+ * seat has its own file and the SURVIVORS keep writing — the dead leg's own log
69
+ * gets pushed out of the top 3 by the legs that did NOT die. A byte budget
70
+ * bounds the same cost (I/O, not file handles) without letting the answer's
71
+ * position in the mtime order decide whether it is ever read.
72
+ */
73
+ const MAX_SCAN_BYTES = 2 * 1024 * 1024;
74
+ /**
75
+ * How long ONE scan (the listing plus the tails it read) is reused (#201 tail
76
+ * C2). MEASURED, not guessed: this runs on a leg's DEATH PATH and legs die in
77
+ * WAVES — a wave's seats launch together, so their backstop deadlines land
78
+ * together, each observed on a 2-second poll tick (`POLL_INTERVAL_MS`,
79
+ * src/headless.js:80). Ten seconds is five of those ticks: wide enough for a
80
+ * whole wave to share one scan, short enough that a LATER failure gets a
81
+ * genuinely fresh read instead of a stale answer about an older death. Not a
82
+ * knob — any second-granularity value trades the same way.
83
+ */
84
+ const SCAN_CACHE_TTL_MS = 10 * 1000;
85
+ const scanCaches = new WeakMap();
86
+
87
+ /**
88
+ * The live scan slot — `{key, at, files, tails}` — freshly reset when absent,
89
+ * stale, or built for other dirs. Bounded BY CONSTRUCTION: ONE slot per fs
90
+ * impl, holding one listing and one scan's worth of tails (MAX_SCAN_BYTES);
91
+ * another dir set evicts rather than accumulating. Keyed by the fs IMPL:
92
+ * production always passes the same `require('fs')` object, so a wave of seats
93
+ * shares one slot, while a test seam (or a hostile one) is a fresh object with
94
+ * a slot of its own — the fs seam is this module's whole testability and a
95
+ * shared cache would quietly break it. `engineErrorForSession` also DROPS the
96
+ * live slot behind a MISS, so the pass after one builds a new one.
97
+ */
98
+ function scanSlot(fsImpl, key) {
99
+ const now = Date.now();
100
+ let slot = scanCaches.get(fsImpl);
101
+ if (!slot || slot.key !== key || now - slot.at >= SCAN_CACHE_TTL_MS) {
102
+ slot = { key, at: now, files: null, tails: new Map() };
103
+ scanCaches.set(fsImpl, slot);
104
+ }
105
+ return slot;
106
+ }
107
+
108
+ /**
109
+ * Ordered, de-duplicated candidate engine-log DIRECTORIES, most specific first
110
+ * — the same order as `src/utils/auth-json.js :: authJsonCandidates` (same
111
+ * engine, same data root), whose comment records the measurement that on
112
+ * Windows OpenCode still writes to `~/.local/share/opencode`, which is why the
113
+ * `.local/share` path stays FIRST after XDG rather than APPDATA. Order is a
114
+ * listing order, not a precedence: all of them are searched (see
115
+ * existingEngineLogDirs), and mtime decides which file answers.
116
+ * @param {NodeJS.ProcessEnv} [env]
117
+ * @returns {string[]}
118
+ */
119
+ function engineLogDirCandidates(env = process.env) {
120
+ const home = os.homedir();
121
+ const candidates = [];
122
+ if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'log')); }
123
+ candidates.push(path.join(home, '.local', 'share', 'opencode', 'log'));
124
+ if (process.platform === 'win32') {
125
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
126
+ candidates.push(path.join(appData, 'opencode', 'log'));
127
+ }
128
+ return [...new Set(candidates)];
129
+ }
130
+
131
+ /**
132
+ * EVERY candidate directory that exists, in candidate order — NOT "the first
133
+ * one that exists" (W10 round-1 review A2). The dirs are alternative homes for
134
+ * the SAME engine's logs, and existence says nothing about which one the live
135
+ * engine writes to: a stale `$XDG_DATA_HOME`, or an empty dir left by a
136
+ * previous install, would otherwise shadow the dir holding the answer — this
137
+ * module's own silent-forever miss, one layer up from where it was fixed.
138
+ * @returns {string[]}
139
+ */
140
+ function existingEngineLogDirs(env, fsImpl) {
141
+ const dirs = [];
142
+ for (const dir of engineLogDirCandidates(env)) {
143
+ try { if (fsImpl.existsSync(dir)) { dirs.push(dir); } } catch (_e) { /* try the next */ }
144
+ }
145
+ return dirs;
146
+ }
147
+
148
+ /**
149
+ * EVERY `*.log` across all candidate dirs, newest first by mtime. The order is
150
+ * taken over the UNION, not per directory, so which dir a file sits in never
151
+ * outranks how recently the engine wrote to it.
152
+ *
153
+ * Not truncated here: the scan reads down this list until it finds a match or
154
+ * spends MAX_SCAN_BYTES (see engineErrorForSession). Listing is a stat per
155
+ * file; only the READS are the cost worth bounding. This also retires the
156
+ * legacy `opencode.log`'s reserved slot — on a single-file machine it is the
157
+ * newest file anyway (it is written continuously), and on a migrated machine it
158
+ * is stale and simply sorts last, which is the same answer the reserved slot
159
+ * was there to produce.
160
+ * @param {string[]} dirs
161
+ * @returns {string[]} absolute paths, newest first
162
+ */
163
+ function candidateLogFiles(dirs, fsImpl) {
164
+ const found = [];
165
+ for (const dir of dirs) {
166
+ let names;
167
+ try { names = fsImpl.readdirSync(dir); } catch (_e) { continue; }
168
+ for (const name of names) {
169
+ if (!String(name).endsWith('.log')) { continue; }
170
+ const full = path.join(dir, name);
171
+ try {
172
+ found.push({ full, mtimeMs: fsImpl.statSync(full).mtimeMs || 0 });
173
+ } catch (_e) { /* vanished or unreadable — skip it */ }
174
+ }
175
+ }
176
+ found.sort((a, b) => b.mtimeMs - a.mtimeMs);
177
+ return found.map((f) => f.full);
178
+ }
179
+
180
+ /**
181
+ * The engine's own error line for a session, as a one-line excerpt.
182
+ *
183
+ * @param {string} sessionId - `ses_…` (createSession's return) or the bare id.
184
+ * @param {object} [options]
185
+ * @param {string} [options.dataDir] - XDG-style DATA dir override; the module
186
+ * appends `opencode/log` itself, exactly as `$XDG_DATA_HOME` behaves in
187
+ * src/utils/auth-json.js. Tests point this at a synthetic fixture tree. When
188
+ * given it is the ONLY dir searched — an explicit override is a statement,
189
+ * not one more candidate.
190
+ * @param {NodeJS.ProcessEnv} [options.env] - environment for dir resolution.
191
+ * @param {object} [options.fs] - fs seam (needs existsSync/readdirSync/
192
+ * statSync/openSync/readSync/closeSync); defaults to the real module.
193
+ * @returns {string|null} excerpt, or null on EVERY miss path (no dir, no file,
194
+ * no line whose own session field names us, no ERROR line, every match empty,
195
+ * unreadable file, hostile fs, budget spent).
196
+ */
197
+ function engineErrorForSession(sessionId, options = {}) {
198
+ try {
199
+ if (!sessionId || typeof sessionId !== 'string') { return null; }
200
+ const fsImpl = options.fs || require('fs');
201
+ const dirs = options.dataDir
202
+ ? [path.join(options.dataDir, 'opencode', 'log')]
203
+ : existingEngineLogDirs(options.env || process.env, fsImpl);
204
+ if (!dirs.length) { return null; }
205
+ const needle = sessionId.startsWith('ses_') ? sessionId : `ses_${sessionId}`;
206
+ const key = dirs.join('\0');
207
+ // TWO passes at most. Pass 1 may run on a WARM slot — the listing and tails
208
+ // another seat that died in the same window already read (#201 tail C2, see
209
+ // SCAN_CACHE_TTL_MS): the SCAN is memoized, never the ANSWER, so each seat
210
+ // still walks the lines for its OWN id. A HIT off that slot is served; a
211
+ // MISS is not (PR #206 round-1 A1+B2, see the module header) — pass 2 drops
212
+ // the slot and re-lists and re-reads before believing the absence.
213
+ for (let pass = 0; pass < 2; pass++) {
214
+ const slot = scanSlot(fsImpl, key);
215
+ const cold = !slot.files; // a slot built just now carries no listing yet
216
+ if (cold) { slot.files = candidateLogFiles(dirs, fsImpl); }
217
+ // Newest first, reading until the answer turns up or the byte budget is
218
+ // spent — NOT "the N newest files" (round-2 review B1, see MAX_SCAN_BYTES).
219
+ let budget = MAX_SCAN_BYTES;
220
+ for (const file of slot.files) {
221
+ if (budget <= 0) { break; }
222
+ const found = newestExcerptInFile(file, needle, fsImpl, budget, slot.tails);
223
+ budget -= found.bytes;
224
+ if (found.excerpt) { return found.excerpt; }
225
+ }
226
+ if (cold) { break; } // this pass read the disk itself: nothing to distrust
227
+ scanCaches.delete(fsImpl);
228
+ }
229
+ return null;
230
+ } catch (_e) {
231
+ return null; // a diagnostic read must never become the failure it reports on
232
+ }
233
+ }
234
+
235
+ // The line-shape helpers are RE-EXPORTED from their new home in
236
+ // `engine-log-parse.js` so this module stays the single import site a consumer
237
+ // needs (tests/engine-log.test.js pins that they are the same function objects,
238
+ // not copies). The scan budget and the TTL beside it — like the tail bound now
239
+ // in `engine-log-tail.js` — are deliberately NOT exported: nothing outside
240
+ // consumes them, and a constant in a `Key Exports` cell reads as a function it
241
+ // is not.
242
+ module.exports = {
243
+ engineErrorForSession,
244
+ engineLogDirCandidates,
245
+ isErrorLine,
246
+ extractMessage,
247
+ collapseExcerpt,
248
+ mentionsSession,
249
+ lineIsAboutSession,
250
+ };