opencode-harness-kit 0.5.5 → 0.5.7

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/CHANGELOG.md CHANGED
@@ -12,6 +12,79 @@ is not happening.
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.5.7] — 2026-10-21
16
+
17
+ Published. The tag is what cut it: `git tag v0.5.7 && git push origin main
18
+ v0.5.7`, and the publish job watches for exactly that.
19
+
20
+ A patch, and a small one: the shipped profiles are refreshed against the
21
+ live catalogue, and a test that pinned the catalogue's old answer stops
22
+ pinning it.
23
+
24
+ ### Changed
25
+
26
+ - **The profiles are refreshed against the live catalogue.**
27
+ `oc-harness refresh --all` is the kit's own command for this, and it had
28
+ not been run since the 19th. The catalogue now says that
29
+ `minimax-m3.1-flash-free` takes a temperature, and that
30
+ `longcat-2.5-preview-free` does not; the profiles said the opposite in one
31
+ case each. The `verified` date is the day the profile was last checked
32
+ against the catalogue, and leaving it stale after a check makes it a lie.
33
+
34
+ Nothing in the generated config changes: the kit sets no temperature at
35
+ all, so the profile's answer only decides what the header says.
36
+
37
+ ### Fixed
38
+
39
+ - **A test failed on a day when nothing in the kit had changed.** The test
40
+ for "temperature is only emitted for models that support it" asserted
41
+ that one particular model does not support one - a value the catalogue
42
+ owns and changes. The test now asks whether the rule holds: no config the
43
+ kit writes may carry a temperature, and every profile has to say whether
44
+ its model takes one. A test that pins a value the world owns fails for
45
+ the wrong reason.
46
+
47
+ ## [0.5.6] — 2026-10-21
48
+
49
+ Published. The tag is what cut it: `git tag v0.5.6 && git push origin main
50
+ v0.5.6`, and the publish job watches for exactly that.
51
+
52
+ A patch, and it is a patch of the same kind as 0.5.5: the three holes the
53
+ guard had left, one measurement that did not measure what it claimed, and
54
+ one measurement that had never produced a number at all.
55
+
56
+ ### Fixed
57
+
58
+ - **`rm /tmp/x -rf` was allowed.** The rule that blocks a recursive delete
59
+ read the first flag and then looked for a path after it, so a flag placed
60
+ after the path was invisible - and GNU rm permutes its options. The rule
61
+ now asks two separate questions: is any flag recursive or force, and is
62
+ there a path.
63
+
64
+ - **`chmod --recursive 777 /` was allowed**, by the rule that blocks
65
+ `chmod 777`: the rule's flag position only accepted short letter flags,
66
+ and a long option put the mode out of reach.
67
+
68
+ - **`curl <url> | grep y | sh` was allowed.** The pipe-to-a-shell rule
69
+ required the download to feed the shell directly, and a filter in between
70
+ hid the shell from it. A filter does not change what the shell runs.
71
+
72
+ - **The pruner measurement and the pruner's own log measured different
73
+ things.** The log reports the REQUEST's size delta - the part before the
74
+ transform against the part after it - and the measurement script counted
75
+ the TEXT inside each part, so the two disagreed and only the log's number
76
+ matched the kit's claim. The script measures the request now, the way the
77
+ pruner does, and reports the text measure beside it under its own name.
78
+ The gate is computed from the request measure.
79
+
80
+ - **The lane record was never written.** Not once, in any session, on any
81
+ machine. The plugin read the lane name from `agent` on the assistant
82
+ message, and opencode's assistant message has no `agent` field - so
83
+ `oc-harness lanes` was reading a file that could not exist, and the
84
+ advice to cut the lanes nobody calls had nothing behind it. The name is
85
+ on the request, and the assistant message names that request's user
86
+ message as its parent, so the two join there.
87
+
15
88
  ## [0.5.5] — 2026-10-21
16
89
 
17
90
  Published. The tag is what cut it: `git tag v0.5.5 && git push origin main
@@ -1396,7 +1469,9 @@ silent in the worst way:
1396
1469
 
1397
1470
  ---
1398
1471
 
1399
- [Unreleased]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.5...HEAD
1472
+ [Unreleased]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.7...HEAD
1473
+ [0.5.7]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.6...v0.5.7
1474
+ [0.5.6]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.5...v0.5.6
1400
1475
  [0.5.5]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.4...v0.5.5
1401
1476
  [0.5.4]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.3...v0.5.4
1402
1477
  [0.5.3]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.2...v0.5.3
package/dist/index.js CHANGED
@@ -36,12 +36,11 @@ var BASH_DENYLIST = [
36
36
  // asked about by the config - the config asks about everything it does
37
37
  // not name, and asking is the safe direction.
38
38
  //
39
- // A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
40
- // the guard's rule needs the flag before the path, and the glob needs
41
- // the flag in front. The config asks, so a human decides. Closing that
42
- // hole is a change to the guard's rule in src/rules.js, which reads the
43
- // tokens - a glob here could only name one spelling of it, and the
44
- // spelling would still be the one it missed.
39
+ // A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
40
+ // the tokens and does not care which order they come in. The glob cannot
41
+ // name it - a glob here could only name one spelling of it, and the
42
+ // spelling would still be the one it missed. So the config asks about it,
43
+ // and a human decides, and the code-level block is the guard's.
45
44
  "rm -f *",
46
45
  "rm -r *",
47
46
  "rm -R *",
@@ -432,24 +431,61 @@ function runsScript(stage) {
432
431
  }
433
432
  function pipesScriptIntoShell(command) {
434
433
  const stages = pipeStages(command);
435
- for (let i = 1; i < stages.length; i++) {
436
- if (!DOWNLOADERS.test(stages[i - 1])) continue;
437
- if (!runsScript(stages[i])) continue;
438
- return true;
434
+ for (let i = 0; i < stages.length; i++) {
435
+ if (!DOWNLOADERS.test(stages[i])) continue;
436
+ for (let j = i + 1; j < stages.length; j++) {
437
+ if (!runsScript(stages[j])) continue;
438
+ return true;
439
+ }
439
440
  }
440
441
  return false;
441
442
  }
443
+ function isDestructiveRmFlag(token) {
444
+ if (/^--(recursive|force)$/i.test(token)) return true;
445
+ return /^-[a-zA-Z]*[rf]/i.test(token);
446
+ }
442
447
  function isRecursiveDelete(text) {
443
448
  const tokens = tokenize(text);
444
449
  if (tokens.length < 2) return false;
445
450
  if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
451
+ let destructive = false;
452
+ let hasPath = false;
446
453
  for (let i = 1; i < tokens.length; i++) {
447
454
  const token = tokens[i];
448
- if (!token.startsWith("-")) continue;
449
- if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue;
450
- return tokens.slice(i + 1).some((t) => !t.startsWith("-"));
455
+ if (token === "--") {
456
+ hasPath = tokens.length > i + 1;
457
+ break;
458
+ }
459
+ if (!token.startsWith("-")) {
460
+ hasPath = true;
461
+ continue;
462
+ }
463
+ if (isDestructiveRmFlag(token)) destructive = true;
451
464
  }
452
- return false;
465
+ return destructive && hasPath;
466
+ }
467
+ var CHMOD_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--reference"]);
468
+ function chmodMode(text) {
469
+ const tokens = tokenize(text);
470
+ if (tokens.length < 2) return null;
471
+ if (baseName(tokens[0]).toLowerCase() !== "chmod") return null;
472
+ for (let i = 1; i < tokens.length; i++) {
473
+ const token = tokens[i];
474
+ if (token === "--") continue;
475
+ if (token.startsWith("--")) {
476
+ if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++;
477
+ continue;
478
+ }
479
+ if (token.startsWith("-")) continue;
480
+ if (/^\d+$/.test(token)) return token;
481
+ return null;
482
+ }
483
+ return null;
484
+ }
485
+ function opensPermissions(text) {
486
+ const mode = chmodMode(text);
487
+ if (mode === null) return false;
488
+ return /^0*777$/.test(mode);
453
489
  }
454
490
  var ASK_GATED = [/^\s*git\s+push\b/];
455
491
  var BASH_RULES = [
@@ -495,10 +531,11 @@ var BASH_RULES = [
495
531
  {
496
532
  name: "chmod 777",
497
533
  scope: "segment",
498
- // The mode may be written with leading zeros: `0777` and `777` are the
499
- // same mode, and a rule that required the bare digits let one spelling
500
- // of it through.
501
- test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
534
+ // The mode is read as an OPERAND rather than as a literal, because the
535
+ // flags in front of it can be long. `chmod --recursive 777 /` is the
536
+ // same chmod as `chmod -R 777 /`, and a rule that only knew the short
537
+ // flag cluster let the long spelling through.
538
+ check: (text) => opensPermissions(text),
502
539
  why: "opens permissions on everything"
503
540
  },
504
541
  {
@@ -1958,7 +1995,26 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1958
1995
  const started = Date.now();
1959
1996
  const opened = /* @__PURE__ */ new Set();
1960
1997
  const closed = /* @__PURE__ */ new Set();
1998
+ const requests = /* @__PURE__ */ new Map();
1999
+ const REQUEST_MEMORY = 500;
2000
+ const rememberRequest = (input) => {
2001
+ const id = input?.message?.id;
2002
+ if (typeof id !== "string") return;
2003
+ const model = input?.model;
2004
+ requests.set(id, {
2005
+ agent: typeof input?.agent === "string" ? input.agent : void 0,
2006
+ model: typeof model?.modelID === "string" ? model.modelID : void 0,
2007
+ provider: typeof model?.providerID === "string" ? model.providerID : void 0
2008
+ });
2009
+ if (requests.size > REQUEST_MEMORY) {
2010
+ const oldest = requests.keys().next();
2011
+ if (!oldest.done) requests.delete(oldest.value);
2012
+ }
2013
+ };
1961
2014
  return {
2015
+ "chat.params": async (input) => {
2016
+ rememberRequest(input);
2017
+ },
1962
2018
  event: async ({ event }) => {
1963
2019
  const type = event?.type;
1964
2020
  if (typeof type !== "string") return;
@@ -1967,7 +2023,7 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1967
2023
  return;
1968
2024
  }
1969
2025
  if (type === "message.updated") {
1970
- const lane = laneOf(event);
2026
+ const lane = laneOf(event, requests);
1971
2027
  if (!lane || !lane.id) return;
1972
2028
  if (lane.ms !== void 0) {
1973
2029
  if (closed.has(lane.id)) return;
@@ -1991,15 +2047,16 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
1991
2047
  }
1992
2048
  };
1993
2049
  };
1994
- function laneOf(event) {
2050
+ function laneOf(event, requests) {
1995
2051
  const e = event;
1996
2052
  const info = e?.properties?.info ?? e?.properties;
1997
2053
  if (!info || typeof info !== "object") return null;
1998
2054
  if (info.role && info.role !== "assistant") return null;
1999
2055
  const id = typeof info.id === "string" ? info.id : void 0;
2000
- const agent = typeof info.agent === "string" ? info.agent : void 0;
2001
- const modelId = info.model?.modelID ?? info.modelID;
2002
- const providerId = info.model?.providerID ?? info.providerID;
2056
+ const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : void 0;
2057
+ const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : void 0);
2058
+ const modelId = parent?.model ?? info.model?.modelID ?? info.modelID;
2059
+ const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID;
2003
2060
  const time = info.time ?? {};
2004
2061
  const created = typeof time.created === "number" ? time.created : void 0;
2005
2062
  const completed = typeof time.completed === "number" ? time.completed : void 0;
@@ -323,10 +323,28 @@ comparable, and the gate uses the number that IS comparable.
323
323
  `eval/measure-prune.mjs` is that number. opencode stores the message parts
324
324
  unpruned - the pruner rewrites the outgoing request, not the history - so the
325
325
  stored parts of a real session are exactly what the pruner was given, and
326
- replaying the real pruner over them measures exactly what it removed. On one
327
- install: **80.5% of the context
328
- across 60 real sessions**, 6,608 parts stubbed - the number
329
- `npm run measure:prune` prints.
326
+ replaying the real pruner over them measures exactly what it removed.
327
+
328
+ It reports **two** measures, because the script and the pruner's own log used
329
+ to answer two different questions and only one of them is this kit's claim:
330
+
331
+ - **REQUEST** - the size of the request that goes back to the model, counted
332
+ on the parts as they are serialised. This is the same definition
333
+ `savedChars` uses in `prune.jsonl`, so the script and the log ask the same
334
+ question of the same parts and their numbers reconcile rather than
335
+ disagree: the script's request delta IS the sum of the log's `savedChars`
336
+ for the same passes. It is the primary number and the one the `--min-saved`
337
+ gate is computed from, because "a smaller request" is what this kit claims.
338
+ - **TEXT** - the text inside the parts: how much text was replaced. It is the
339
+ larger of the two, because the request measure also carries the parts the
340
+ pruner left alone, and because the stub's own metadata is part of the
341
+ request and counts against the saving.
342
+
343
+ On one install: **80.5% of the text across 60 real sessions**, 6,608 parts
344
+ stubbed. That is the TEXT measure - the number `npm run measure:prune`
345
+ printed before the two were separated, when the script measured text and
346
+ labelled it "context". The REQUEST measure on the same install is lower, and
347
+ it is the one the gate checks.
330
348
 
331
349
  The controlled comparison of the two arms is `eval/ab.mjs`, and its result is
332
350
  in `eval/RESULTS.md`.
@@ -374,6 +392,19 @@ label and its own metadata. Such a pass is still logged, because a part was
374
392
  stubbed, which is the event worth recording, and the negative number is the
375
393
  honest report of it.
376
394
 
395
+ The measurement script measures the same thing on purpose.
396
+ `eval/measure-prune.mjs` counts a part with the pruner's own expression -
397
+ `JSON.stringify(part).length` - before and after the pass, and reports it as
398
+ the REQUEST measure, so the two reconcile by construction instead of by
399
+ coincidence: replaying twelve real sessions through the real pruner, the
400
+ script measured a request delta of **6,049** characters and the log the
401
+ pruner wrote during that replay said `savedChars: 6049`. The script also
402
+ reports the TEXT measure, because the two answer different questions and the
403
+ difference is worth stating rather than hiding: the request measure counts
404
+ the stub's own metadata against the saving and carries the parts the pruner
405
+ left alone, so it is the smaller of the two, and it can be negative where the
406
+ text measure is positive.
407
+
377
408
  There is no minimum size under which a part is left alone. This file used to
378
409
  claim a 200 byte floor for `reasoning` parts; there was never such a floor in
379
410
  the code, and a reasoning part shorter than the stub - about seventy-eight
@@ -20,6 +20,31 @@
20
20
  //
21
21
  // The gate is on the CONTROLLED number. The install-wide one stays an
22
22
  // observation.
23
+ //
24
+ // TWO MEASURES, both reported, neither hidden, because they answer two
25
+ // different questions:
26
+ //
27
+ // REQUEST - how much did the REQUEST that goes back to the model shrink.
28
+ // Counted the way the pruner's own log counts it: the size of a PART,
29
+ // before the pass and after it. `partSize` in src/prune.ts is
30
+ // `JSON.stringify(part).length`, and `savedChars` in `.space/prune.jsonl`
31
+ // is the delta of that number. The measure below is the SAME expression,
32
+ // so the script and the log ask the same question of the same parts and
33
+ // cannot disagree about what they mean. The delta is the same number
34
+ // too: the parts outside the pruner's window are untouched, so they
35
+ // contribute nothing to it either way.
36
+ //
37
+ // TEXT - how much TEXT was replaced. The text inside the parts, which is
38
+ // what this script used to measure and what it still reports.
39
+ //
40
+ // The two are not the same number twice. The request measure also carries
41
+ // the parts the pruner left alone, and the stub's own metadata is part of
42
+ // the request and is not text. So the request measure is the smaller of
43
+ // the two, and it can be negative where the text measure is positive - a
44
+ // stub that costs more than the output it replaced.
45
+ //
46
+ // The gate is on the REQUEST measure, because that is the claim the kit
47
+ // makes: a smaller request.
23
48
 
24
49
  import { existsSync, mkdtempSync, rmSync } from "node:fs"
25
50
  import { homedir, tmpdir } from "node:os"
@@ -186,29 +211,79 @@ const hook = await HarnessPlugin(
186
211
  const transform = hook["experimental.chat.messages.transform"]
187
212
 
188
213
  /**
189
- * The size this measurement counts.
214
+ * The size of a PART, as it sits in the request.
215
+ *
216
+ * THIS IS THE PRUNER'S OWN DEFINITION, EXPRESSION FOR EXPRESSION.
217
+ * `partSize` in src/prune.ts is `JSON.stringify(part).length`, and
218
+ * `savedChars` in `.space/prune.jsonl` is that number's delta. This is the
219
+ * same expression on the same objects, so the script and the log measure
220
+ * the same thing and the two numbers are reconcilable by construction
221
+ * rather than by coincidence.
222
+ *
223
+ * CHARACTERS, not bytes: a UTF-8 character is more than one byte, and an
224
+ * agent's output is not ASCII. The pruner's measure counts characters
225
+ * too, and the byte figures are reported beside them, not instead of
226
+ * them.
190
227
  *
191
- * CHARACTERS, not bytes: `part.text.length`. The report labelled that
192
- * "bytes", which is wrong for anything that is not ASCII - and an agent's
193
- * output is not ASCII. Both numbers are reported.
228
+ * A part that cannot be serialised counts as zero, exactly as the
229
+ * pruner's own measure does: a measurement that throws is worse than a
230
+ * measurement that counts the part as nothing.
231
+ *
232
+ * @param {any} part
233
+ * @returns {number}
234
+ */
235
+ function requestPartSize(part) {
236
+ try {
237
+ const json = JSON.stringify(part)
238
+ return typeof json === "string" ? json.length : 0
239
+ } catch {
240
+ return 0
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The same part, in bytes.
194
246
  *
195
- * This counts the TEXT of every part, while the pruner's own log counts the
196
- * PART - and only the parts the pruner touched. So the two numbers answer
197
- * two different questions and are not the same number twice: this one asks
198
- * how much TEXT the pruner removed from a stored session, the log asks how
199
- * much the REQUEST that went out shrank. The stub the pruner leaves behind
200
- * carries about fifty characters of metadata of its own, so the log's
201
- * saving is the smaller of the two, and it can even be negative where this
202
- * one is positive. The gap is stated rather than hidden.
247
+ * @param {any} part
248
+ * @returns {number}
203
249
  */
204
- function sizeOf(part) {
250
+ function requestPartBytes(part) {
251
+ try {
252
+ const json = JSON.stringify(part)
253
+ return typeof json === "string" ? Buffer.byteLength(json, "utf8") : 0
254
+ } catch {
255
+ return 0
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The TEXT inside a part - the SECONDARY measure.
261
+ *
262
+ * `part.text` for a text or reasoning part, `part.state.output` for a tool
263
+ * part. This is what the script measured before, and it is still reported,
264
+ * because it answers a different question: how much text was replaced.
265
+ *
266
+ * It is not the claim the kit makes. The text measure cannot see the
267
+ * metadata a stub puts in a part's place, and it cannot see the parts the
268
+ * pruner left alone, so it is the larger of the two numbers and it can be
269
+ * positive where the request measure is negative.
270
+ *
271
+ * @param {any} part
272
+ * @returns {number}
273
+ */
274
+ function textSize(part) {
205
275
  if (typeof part?.text === "string") return part.text.length
206
276
  if (typeof part?.state?.output === "string") return part.state.output.length
207
277
  return 0
208
278
  }
209
279
 
210
- /** The same text, in bytes. */
211
- function byteSizeOf(part) {
280
+ /**
281
+ * The same text, in bytes.
282
+ *
283
+ * @param {any} part
284
+ * @returns {number}
285
+ */
286
+ function textBytes(part) {
212
287
  if (typeof part?.text === "string") return Buffer.byteLength(part.text, "utf8")
213
288
  if (typeof part?.state?.output === "string") {
214
289
  return Buffer.byteLength(part.state.output, "utf8")
@@ -216,10 +291,32 @@ function byteSizeOf(part) {
216
291
  return 0
217
292
  }
218
293
 
219
- let totalBefore = 0
220
- let totalAfter = 0
221
- let bytesBefore = 0
222
- let bytesAfter = 0
294
+ /**
295
+ * The size of a whole message list, by one measure.
296
+ *
297
+ * @param {{parts: any[]}[]} messages
298
+ * @param {(part: any) => number} size
299
+ * @returns {number}
300
+ */
301
+ function measureAll(messages, size) {
302
+ let total = 0
303
+ for (const m of messages) {
304
+ for (const p of m.parts) total += size(p)
305
+ }
306
+ return total
307
+ }
308
+
309
+ // The REQUEST measure: the request that goes back to the model, before the
310
+ // pass and after it, counted the way the pruner's log counts it.
311
+ let reqCharsBefore = 0
312
+ let reqCharsAfter = 0
313
+ let reqBytesBefore = 0
314
+ let reqBytesAfter = 0
315
+ // The TEXT measure: the text inside the parts, before and after.
316
+ let textCharsBefore = 0
317
+ let textCharsAfter = 0
318
+ let textBytesBefore = 0
319
+ let textBytesAfter = 0
223
320
  let measured = 0
224
321
  let stubbed = 0
225
322
  const perSession = []
@@ -227,35 +324,38 @@ const perSession = []
227
324
  for (const s of sessions) {
228
325
  const messages = sessionMessages(s.id)
229
326
  if (!messages.length) continue
230
- const before = messages.reduce(
231
- (n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
232
- 0,
233
- )
234
- const beforeBytes = messages.reduce(
235
- (n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
236
- 0,
237
- )
238
327
  // The pruner mutates in place, so the copy has to be the real thing.
239
- const reasoningBefore = messages.flatMap((m) =>
240
- m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
241
- )
242
328
  const copy = messages.map((m) => ({
243
329
  info: m.info,
244
330
  parts: m.parts.map((p) => (p.state ? { ...p, state: { ...p.state } } : { ...p })),
245
331
  }))
246
- await transform({}, { messages: copy })
247
- const after = copy.reduce(
248
- (n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
249
- 0,
250
- )
251
- if (!before) continue
252
- totalBefore += before
253
- totalAfter += after
254
- bytesBefore += beforeBytes
255
- bytesAfter += copy.reduce(
256
- (n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
257
- 0,
332
+ // BEFORE is measured on the COPY - on the very objects the pruner is about
333
+ // to see. The copy serialises identically to the part it was made from
334
+ // (same keys, same order, same values), and measuring one set of objects
335
+ // before and after is the point: a "before" taken from a different object
336
+ // than the "after" is two measurements of two things.
337
+ const reqCharsBeforeSession = measureAll(copy, requestPartSize)
338
+ const reqBytesBeforeSession = measureAll(copy, requestPartBytes)
339
+ const textCharsBeforeSession = measureAll(copy, textSize)
340
+ const textBytesBeforeSession = measureAll(copy, textBytes)
341
+ const reasoningBefore = copy.flatMap((m) =>
342
+ m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
258
343
  )
344
+ await transform({}, { messages: copy })
345
+ const reqCharsAfterSession = measureAll(copy, requestPartSize)
346
+ const reqBytesAfterSession = measureAll(copy, requestPartBytes)
347
+ const textCharsAfterSession = measureAll(copy, textSize)
348
+ const textBytesAfterSession = measureAll(copy, textBytes)
349
+ // A session with nothing in it has no percentage to report.
350
+ if (reqCharsBeforeSession <= 0) continue
351
+ reqCharsBefore += reqCharsBeforeSession
352
+ reqCharsAfter += reqCharsAfterSession
353
+ reqBytesBefore += reqBytesBeforeSession
354
+ reqBytesAfter += reqBytesAfterSession
355
+ textCharsBefore += textCharsBeforeSession
356
+ textCharsAfter += textCharsAfterSession
357
+ textBytesBefore += textBytesBeforeSession
358
+ textBytesAfter += textBytesAfterSession
259
359
  measured++
260
360
  // The pruner marks TOOL parts with `metadata.pruned` and REPLACES reasoning
261
361
  // parts, so counting only the marked ones undercounts what was stubbed.
@@ -272,24 +372,60 @@ for (const s of sessions) {
272
372
  stubbed += cut
273
373
  perSession.push({
274
374
  session: s.id,
275
- bytes_before: before,
276
- bytes_after: after,
277
- saved_pct: Math.round((1 - after / before) * 1000) / 10,
375
+ // REQUEST - the size of the request, before and after the pass.
376
+ request_chars_before: reqCharsBeforeSession,
377
+ request_chars_after: reqCharsAfterSession,
378
+ request_bytes_before: reqBytesBeforeSession,
379
+ request_bytes_after: reqBytesAfterSession,
380
+ request_saved_pct: pctOf(reqCharsBeforeSession, reqCharsAfterSession),
381
+ // TEXT - the text inside the parts, before and after the pass.
382
+ text_chars_before: textCharsBeforeSession,
383
+ text_chars_after: textCharsAfterSession,
384
+ text_saved_pct: pctOf(textCharsBeforeSession, textCharsAfterSession),
278
385
  parts_stubbed: cut,
279
386
  tool_parts_stubbed: toolCut,
280
387
  reasoning_parts_stubbed: reasoningCut,
281
388
  })
282
389
  }
283
390
 
284
- const savedPct =
285
- totalBefore > 0 ? Math.round((1 - totalAfter / totalBefore) * 1000) / 10 : null
391
+ /**
392
+ * A percentage, to two decimals, or null when there is no percentage to
393
+ * report.
394
+ *
395
+ * Two decimals, not one: an effect of 0.05% is a real effect, and one
396
+ * decimal renders it as "0%" - a number that says "nothing was removed"
397
+ * about a pass that removed seven thousand characters. That is the exact
398
+ * failure this script exists to catch, so it is not repeated in the
399
+ * reporting of it.
400
+ *
401
+ * @param {number} before
402
+ * @param {number} after
403
+ * @returns {number | null}
404
+ */
405
+ function pctOf(before, after) {
406
+ if (!(before > 0)) return null
407
+ return Math.round((1 - after / before) * 10000) / 100
408
+ }
409
+
410
+ /** A percentage for printing. */
411
+ function fmtPct(n) {
412
+ return n === null ? "n/a" : `${n}%`
413
+ }
414
+
415
+ // The REQUEST measure's percentage. This is the one the gate is computed
416
+ // from, because it is the claim the kit makes: a smaller request.
417
+ const requestSavedPct = pctOf(reqCharsBefore, reqCharsAfter)
418
+ // The TEXT measure's percentage: how much text was replaced.
419
+ const textSavedPct = pctOf(textCharsBefore, textCharsAfter)
286
420
 
287
421
  const failures = []
288
422
  if (minSaved !== null) {
289
423
  if (measured === 0) {
290
424
  failures.push("no session had assistant messages to measure")
291
- } else if (savedPct === null || savedPct < minSaved) {
292
- failures.push(`the pruner removed ${savedPct ?? "n/a"}% of the context, expected at least ${minSaved}%`)
425
+ } else if (requestSavedPct === null || requestSavedPct < minSaved) {
426
+ failures.push(
427
+ `the pruner removed ${requestSavedPct ?? "n/a"}% of the request, expected at least ${minSaved}%`,
428
+ )
293
429
  }
294
430
  }
295
431
 
@@ -303,11 +439,21 @@ process.on("exit", () => {
303
439
  const report = {
304
440
  db: DB,
305
441
  sessions_measured: measured,
306
- characters_before: totalBefore,
307
- characters_after: totalAfter,
308
- bytes_before: bytesBefore,
309
- bytes_after: bytesAfter,
310
- saved_pct: savedPct,
442
+ // REQUEST measure - the size of the request, counted the way the pruner's
443
+ // own log counts it (the size of a PART). This is the number the gate is
444
+ // computed from.
445
+ request_chars_before: reqCharsBefore,
446
+ request_chars_after: reqCharsAfter,
447
+ request_bytes_before: reqBytesBefore,
448
+ request_bytes_after: reqBytesAfter,
449
+ request_saved_chars: reqCharsBefore - reqCharsAfter,
450
+ request_saved_pct: requestSavedPct,
451
+ // TEXT measure - the text inside the parts. How much text was replaced.
452
+ text_chars_before: textCharsBefore,
453
+ text_chars_after: textCharsAfter,
454
+ text_bytes_before: textBytesBefore,
455
+ text_bytes_after: textBytesAfter,
456
+ text_saved_pct: textSavedPct,
311
457
  parts_stubbed: stubbed,
312
458
  per_session: perSession,
313
459
  verdict: failures.length ? "FAIL" : "PASS",
@@ -325,17 +471,36 @@ console.log(
325
471
  ` sessions ${measured} (last ${DAYS}d, at least ${MIN_MESSAGES} assistant messages each)`,
326
472
  )
327
473
  console.log("")
328
- console.log(` context before ${totalBefore.toLocaleString("en-US")} characters`)
329
- console.log(` context after ${totalAfter.toLocaleString("en-US")} characters`)
330
- console.log(` in bytes ${bytesBefore.toLocaleString("en-US")} -> ${bytesAfter.toLocaleString("en-US")}`)
331
- console.log(` removed ${savedPct === null ? "n/a" : savedPct + "%"}`)
474
+ console.log(" REQUEST measure - how much the request that goes back to the model shrank.")
475
+ console.log(" The size of a PART, before and after the pass: the same definition the")
476
+ console.log(" pruner's own log uses (src/prune.ts `partSize`).")
477
+ console.log(` request context before ${reqCharsBefore.toLocaleString("en-US")} characters`)
478
+ console.log(` request context after ${reqCharsAfter.toLocaleString("en-US")} characters`)
479
+ console.log(` request saved ${(reqCharsBefore - reqCharsAfter).toLocaleString("en-US")} characters`)
480
+ console.log(` request in bytes ${reqBytesBefore.toLocaleString("en-US")} -> ${reqBytesAfter.toLocaleString("en-US")}`)
481
+ console.log(` removed ${fmtPct(requestSavedPct)}`)
482
+ console.log(" <- the --min-saved gate is computed from this number, and the")
483
+ console.log(" saved figure is the same number the pruner's log writes as")
484
+ console.log(" savedChars for the same pass.")
485
+ console.log("")
486
+ console.log(" TEXT measure - how much text was replaced. The text inside the parts.")
487
+ console.log(` text before ${textCharsBefore.toLocaleString("en-US")} characters`)
488
+ console.log(` text after ${textCharsAfter.toLocaleString("en-US")} characters`)
489
+ console.log(` text saved ${(textCharsBefore - textCharsAfter).toLocaleString("en-US")} characters`)
490
+ console.log(` text in bytes ${textBytesBefore.toLocaleString("en-US")} -> ${textBytesAfter.toLocaleString("en-US")}`)
491
+ console.log(` removed ${fmtPct(textSavedPct)}`)
492
+ console.log("")
332
493
  console.log(` parts stubbed ${stubbed}`)
333
494
  if (perSession.length) {
334
495
  console.log("")
335
- const worst = [...perSession].sort((a, b) => a.saved_pct - b.saved_pct).slice(0, 5)
336
- console.log(" the five sessions the pruner helped least:")
496
+ const worst = [...perSession]
497
+ .sort((a, b) => a.request_saved_pct - b.request_saved_pct)
498
+ .slice(0, 5)
499
+ console.log(" the five sessions the pruner helped least, by the request measure:")
337
500
  for (const p of worst) {
338
- console.log(` ${p.saved_pct}% ${p.bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`)
501
+ console.log(
502
+ ` ${fmtPct(p.request_saved_pct)} ${p.request_bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`,
503
+ )
339
504
  }
340
505
  }
341
506
  if (failures.length) {
@@ -343,6 +508,6 @@ if (failures.length) {
343
508
  console.log(` FAIL ${failures.join("; ")}`)
344
509
  } else if (minSaved !== null) {
345
510
  console.log("")
346
- console.log(` PASS the pruner removed at least ${minSaved}% of the context.`)
511
+ console.log(` PASS the pruner removed at least ${minSaved}% of the request.`)
347
512
  }
348
513
  process.exit(failures.length ? 1 : 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": null,
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-21",
9
9
  "retention": "may-train",
10
10
  "maxRetention": "may-train",
11
11
  "notes": "Every lane gets the model built for it: beam plans, Nemotron 3 Ultra reviews, Lightning sweeps, MiMo sees, GLM audits, Super VL reasons with the effort turned off. Five of the ten providers state that collected data may be used to improve the model, or log the session, so the profile as a whole is may-train and the generated config says so in its header. Install it for public or throwaway repositories.",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "supports": {
17
17
  "reasoning": true,
18
- "temperature": false,
18
+ "temperature": true,
19
19
  "toolCall": true,
20
20
  "structuredOutput": false,
21
21
  "attachment": true
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": null,
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-21",
9
9
  "retention": "zero-retention",
10
10
  "maxRetention": "zero-retention",
11
11
  "notes": "The default. Every lane resolves to a model whose provider states a zero-retention policy: no training, nothing kept. The cost is that six of the ten jobs run on a second-choice model - the planner loses beam (30-day retention) and the reviewer loses Nemotron 3 Ultra - so this is the floor, not the ceiling. The chains are ordered within the ceiling rather than copied from `fleet-all`: `locate` takes deepseek because it publishes a `low` effort level that longcat does not, and `vision` takes minimax because it reads an image in 7 seconds. `fleet-all` gives every lane the model built for it.",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "supports": {
17
17
  "reasoning": true,
18
- "temperature": false,
18
+ "temperature": true,
19
19
  "toolCall": true,
20
20
  "structuredOutput": false,
21
21
  "attachment": true
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "longcat-2.5-preview-free",
7
7
  "family": "longcat",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-21",
9
9
  "retention": "zero-retention",
10
10
  "notes": "Meituan. 1M context with image input, and a reasoning toggle rather than a tier, so no variant is written. It read a screenshot correctly in a live call, which is what makes it the zero-retention fallback for the vision lane. Measured at 9.4-13.2s. A preview endpoint: it can be withdrawn without a rename.",
11
11
  "limits": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "supports": {
16
16
  "reasoning": true,
17
- "temperature": true,
17
+ "temperature": false,
18
18
  "toolCall": true,
19
19
  "structuredOutput": false,
20
20
  "attachment": true
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": "minimax",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-21",
9
9
  "retention": "zero-retention",
10
10
  "notes": "Flagship coding and agent model on the free tier: 512k output tokens, the widest of any free route, and the fastest measured image reader at 7.7s. Tool calls are stable. It does not support temperature, and models.dev publishes no structured_output flag for it, so JSON output is requested in the prompt rather than negotiated with the API. Measured at 3.1s for a one-word reply.",
11
11
  "limits": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "supports": {
16
16
  "reasoning": true,
17
- "temperature": false,
17
+ "temperature": true,
18
18
  "toolCall": true,
19
19
  "structuredOutput": false,
20
20
  "attachment": true
package/src/denylist.js CHANGED
@@ -39,12 +39,11 @@ export const BASH_DENYLIST = [
39
39
  // asked about by the config - the config asks about everything it does
40
40
  // not name, and asking is the safe direction.
41
41
  //
42
- // A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
43
- // the guard's rule needs the flag before the path, and the glob needs
44
- // the flag in front. The config asks, so a human decides. Closing that
45
- // hole is a change to the guard's rule in src/rules.js, which reads the
46
- // tokens - a glob here could only name one spelling of it, and the
47
- // spelling would still be the one it missed.
42
+ // A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
43
+ // the tokens and does not care which order they come in. The glob cannot
44
+ // name it - a glob here could only name one spelling of it, and the
45
+ // spelling would still be the one it missed. So the config asks about it,
46
+ // and a human decides, and the code-level block is the guard's.
48
47
  "rm -f *",
49
48
  "rm -r *",
50
49
  "rm -R *",
package/src/rules.js CHANGED
@@ -615,26 +615,63 @@ function runsScript(stage) {
615
615
  * still a pipe into a shell, because the `;` ends the word and not the
616
616
  * pipe.
617
617
  *
618
+ * The shell does not have to be the stage right after the download. A filter
619
+ * in between (`curl x | grep y | sh`) does not change what the shell RUNS -
620
+ * it changes only what the agent sees on the way there - so the chain is
621
+ * the same download-and-execute. The rule used to look at the stage after
622
+ * the download only, so a filter in the middle hid the shell from it.
623
+ *
624
+ * The order still matters: the shell has to come AFTER the download.
625
+ * `sh | grep y` runs a shell over a pipe it did not fetch, and there is
626
+ * nothing downloaded for it to execute.
627
+ *
618
628
  * @param {string} command
619
629
  * @returns {boolean}
620
630
  */
621
631
  function pipesScriptIntoShell(command) {
622
632
  const stages = pipeStages(command)
623
- for (let i = 1; i < stages.length; i++) {
624
- if (!DOWNLOADERS.test(stages[i - 1])) continue
625
- if (!runsScript(stages[i])) continue
626
- return true
633
+ for (let i = 0; i < stages.length; i++) {
634
+ if (!DOWNLOADERS.test(stages[i])) continue
635
+ for (let j = i + 1; j < stages.length; j++) {
636
+ if (!runsScript(stages[j])) continue
637
+ return true
638
+ }
627
639
  }
628
640
  return false
629
641
  }
630
642
 
643
+ /**
644
+ * Whether a token is a flag that makes an rm destructive.
645
+ *
646
+ * The short form is a CLUSTER of letters, so `-rf`, `-fr` and `-R` all name
647
+ * a recursive delete and `-f` names a forced one. The long form is GNU's
648
+ * own name for the same two flags.
649
+ *
650
+ * @param {string} token
651
+ * @returns {boolean}
652
+ */
653
+ function isDestructiveRmFlag(token) {
654
+ if (/^--(recursive|force)$/i.test(token)) return true
655
+ return /^-[a-zA-Z]*[rf]/i.test(token)
656
+ }
657
+
631
658
  /**
632
659
  * Whether a command is an rm with a flag that makes it destructive.
633
660
  *
634
- * The flag can be a short cluster (`-rf`) or the long GNU name of the same
635
- * two flags (`--recursive --force`), and it can be anywhere among the
636
- * options. A rule that only knew the short form allowed the long one, which
637
- * is the same command.
661
+ * The flags and the paths are read SEPARATELY, because GNU rm PERMUTES its
662
+ * options: the flags can come after the path, and `rm /tmp/x -rf` is the
663
+ * same delete as `rm -rf /tmp/x`. The rule used to return at the FIRST flag
664
+ * token and then look for a path AFTER it, so a flag that came after the
665
+ * path was invisible and the delete the rule exists to stop went through.
666
+ *
667
+ * So the question is not "is the first flag recursive" but "is ANY of the
668
+ * flags recursive or force, AND is there a path" - a delete of one named
669
+ * file (`rm file.txt`, `rm -i notes.md`) is not a recursive delete, and a
670
+ * command with no path at all (`rm`, `rm -rf`) is not a delete.
671
+ *
672
+ * `--` ends the options, so every word after it is a path whatever it looks
673
+ * like. That makes `rm -- -rf` a delete of a file NAMED `-rf`, and not a
674
+ * recursive delete.
638
675
  *
639
676
  * @param {string} text the segment, with its prefixes stripped
640
677
  * @returns {boolean}
@@ -643,15 +680,88 @@ function isRecursiveDelete(text) {
643
680
  const tokens = tokenize(text)
644
681
  if (tokens.length < 2) return false
645
682
  if (baseName(tokens[0]).toLowerCase() !== "rm") return false
683
+ let destructive = false
684
+ let hasPath = false
646
685
  for (let i = 1; i < tokens.length; i++) {
647
686
  const token = tokens[i]
648
- if (!token.startsWith("-")) continue
649
- if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue
650
- // The flag needs something to destroy: `rm --recursive` on its own does
651
- // nothing, and a rule that blocks it blocks a typo.
652
- return tokens.slice(i + 1).some((t) => !t.startsWith("-"))
687
+ if (token === "--") {
688
+ // Everything after `--` is a path, so the words that follow it are
689
+ // the path this rm destroys.
690
+ hasPath = tokens.length > i + 1
691
+ break
692
+ }
693
+ if (!token.startsWith("-")) {
694
+ hasPath = true
695
+ continue
696
+ }
697
+ if (isDestructiveRmFlag(token)) destructive = true
653
698
  }
654
- return false
699
+ return destructive && hasPath
700
+ }
701
+
702
+ /**
703
+ * The chmod options that carry their value in the NEXT word.
704
+ *
705
+ * `chmod --reference FILE` takes the mode from another file, so the word
706
+ * after it is not the mode. A scanner that read that word as the mode would
707
+ * see `chmod --reference 777 file` as "chmod 777", and that command is a
708
+ * file named 777 rather than a mode.
709
+ */
710
+ const CHMOD_VALUE_OPTIONS = new Set(["--reference"])
711
+
712
+ /**
713
+ * The mode a chmod command sets, or null when it sets none.
714
+ *
715
+ * The mode is the first numeric OPERAND, and every option in front of it is
716
+ * skipped - INCLUDING the long forms. The rule used to accept only a short
717
+ * letter cluster in the flag position, so GNU's own `--recursive` put the
718
+ * mode out of the rule's reach: `chmod --recursive 777 /` was allowed while
719
+ * `chmod -R 777 /` was blocked, and the mode is the same mode.
720
+ *
721
+ * A symbolic mode (`a=rwx`) is not a numeric one, so it is not a mode this
722
+ * function returns. That is a policy gap and not a spelling one, and it is
723
+ * left alone on purpose.
724
+ *
725
+ * @param {string} text the segment, with its prefixes stripped
726
+ * @returns {string | null} the mode, as it was written
727
+ */
728
+ function chmodMode(text) {
729
+ const tokens = tokenize(text)
730
+ if (tokens.length < 2) return null
731
+ if (baseName(tokens[0]).toLowerCase() !== "chmod") return null
732
+ for (let i = 1; i < tokens.length; i++) {
733
+ const token = tokens[i]
734
+ if (token === "--") continue
735
+ if (token.startsWith("--")) {
736
+ // `--flag=value` carries its own value, and a boolean long option
737
+ // carries none. Only the option that takes the next word swallows
738
+ // it, and that word is then not the mode.
739
+ if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++
740
+ continue
741
+ }
742
+ if (token.startsWith("-")) continue
743
+ // The first numeric operand is the mode; the words after it are the
744
+ // files the mode is applied to.
745
+ if (/^\d+$/.test(token)) return token
746
+ return null
747
+ }
748
+ return null
749
+ }
750
+
751
+ /**
752
+ * Whether a chmod command sets the mode that opens permissions to everyone.
753
+ *
754
+ * The mode may be written with leading zeros: `0777` and `777` are the same
755
+ * mode, and a rule that required the bare digits let one spelling of it
756
+ * through.
757
+ *
758
+ * @param {string} text the segment, with its prefixes stripped
759
+ * @returns {boolean}
760
+ */
761
+ function opensPermissions(text) {
762
+ const mode = chmodMode(text)
763
+ if (mode === null) return false
764
+ return /^0*777$/.test(mode)
655
765
  }
656
766
 
657
767
  /**
@@ -711,10 +821,11 @@ export const BASH_RULES = [
711
821
  {
712
822
  name: "chmod 777",
713
823
  scope: "segment",
714
- // The mode may be written with leading zeros: `0777` and `777` are the
715
- // same mode, and a rule that required the bare digits let one spelling
716
- // of it through.
717
- test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
824
+ // The mode is read as an OPERAND rather than as a literal, because the
825
+ // flags in front of it can be long. `chmod --recursive 777 /` is the
826
+ // same chmod as `chmod -R 777 /`, and a rule that only knew the short
827
+ // flag cluster let the long spelling through.
828
+ check: (text) => opensPermissions(text),
718
829
  why: "opens permissions on everything",
719
830
  },
720
831
  {
package/src/telemetry.ts CHANGED
@@ -27,10 +27,24 @@
27
27
  // one with a duration, so "how long does this lane take" and "was this lane
28
28
  // called at all" are answered from the same file without guessing.
29
29
  //
30
- // The `chat.message` hook is deliberately not used for this. Measured on
31
- // OpenCode 1.19, its `agent` field is undefined, so it cannot attribute a
32
- // call to a lane; and a second counter that cannot be correlated with the
33
- // first would double-count every call.
30
+ // WHERE THE LANE NAME COMES FROM
31
+ //
32
+ // Not from the message. opencode's AssistantMessage has no `agent` field - it
33
+ // has id, role, model, tokens, time and parentID - and the plugin read the
34
+ // agent from exactly that missing field. The lane record was therefore never
35
+ // written: not once, in any session, on any machine. The measurement that
36
+ // decides whether the fleet stays at ten lanes was reading a file that could
37
+ // not exist.
38
+ //
39
+ // The agent name is on the REQUEST. `chat.params` carries the agent and the
40
+ // model of the call that is about to be made, and the user message that call
41
+ // belongs to; the assistant message names that user message as its parent, so
42
+ // the two join.
43
+ //
44
+ // The `chat.message` hook is not used for this. Measured on OpenCode 1.19 its
45
+ // `agent` field is undefined, while `chat.params` declares it as required -
46
+ // and a second counter that cannot be correlated with the first would
47
+ // double-count every call.
34
48
  //
35
49
  // WHAT A DENIAL LOOKS LIKE HERE
36
50
  //
@@ -69,7 +83,47 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
69
83
  const opened = new Set<string>()
70
84
  const closed = new Set<string>()
71
85
 
86
+ // The lane a request ran on, and the model it ran on, keyed by the id of
87
+ // the user message the request was made from.
88
+ //
89
+ // The assistant message names that user message as its parent, so this is
90
+ // how a call is attributed to a lane.
91
+ //
92
+ // The map is bounded: a long session makes a lot of requests, and the map
93
+ // is only a bridge between a request and the assistant message that
94
+ // answers it. The oldest entries are dropped once it grows past what a
95
+ // session needs, so the plugin cannot grow without limit.
96
+ const requests = new Map<string, { agent?: string; model?: string; provider?: string }>()
97
+ const REQUEST_MEMORY = 500
98
+
99
+ /**
100
+ * Remember which lane a request was made from.
101
+ *
102
+ * @param {any} input the `chat.params` input
103
+ */
104
+ const rememberRequest = (input: any) => {
105
+ const id = input?.message?.id
106
+ if (typeof id !== "string") return
107
+ const model = input?.model
108
+ requests.set(id, {
109
+ agent: typeof input?.agent === "string" ? input.agent : undefined,
110
+ model: typeof model?.modelID === "string" ? model.modelID : undefined,
111
+ provider: typeof model?.providerID === "string" ? model.providerID : undefined,
112
+ })
113
+ if (requests.size > REQUEST_MEMORY) {
114
+ // The oldest first: a Map keeps its insertion order.
115
+ const oldest = requests.keys().next()
116
+ if (!oldest.done) requests.delete(oldest.value)
117
+ }
118
+ }
119
+
72
120
  return {
121
+ "chat.params": async (input: any) => {
122
+ // The lane and the model of the call that is about to be made. This is
123
+ // the only place opencode says which agent is running.
124
+ rememberRequest(input)
125
+ },
126
+
73
127
  event: async ({ event }: any) => {
74
128
  // Events are provider-specific and opencode may add shapes we have never
75
129
  // seen. Anything unrecognised is ignored rather than trusted.
@@ -91,7 +145,7 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
91
145
  // on which model, and how long it took. That record is what makes "cut
92
146
  // the lanes nobody uses" a measurement instead of a guess.
93
147
  if (type === "message.updated") {
94
- const lane = laneOf(event)
148
+ const lane = laneOf(event, requests)
95
149
  if (!lane || !lane.id) return
96
150
  if (lane.ms !== undefined) {
97
151
  // The completed event. Written once, and only if the open record
@@ -142,7 +196,10 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
142
196
  *
143
197
  * The message id is what makes the record countable: one message is one call.
144
198
  */
145
- function laneOf(event: unknown): {
199
+ function laneOf(
200
+ event: unknown,
201
+ requests: Map<string, { agent?: string; model?: string; provider?: string }>,
202
+ ): {
146
203
  id?: string
147
204
  agent?: string
148
205
  model?: string
@@ -159,9 +216,12 @@ function laneOf(event: unknown): {
159
216
  if (info.role && info.role !== "assistant") return null
160
217
 
161
218
  const id = typeof info.id === "string" ? info.id : undefined
162
- const agent = typeof info.agent === "string" ? info.agent : undefined
163
- const modelId = info.model?.modelID ?? info.modelID
164
- const providerId = info.model?.providerID ?? info.providerID
219
+ // The request this message answers. The message does not name the agent;
220
+ // the request does, and the parent id is the join.
221
+ const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : undefined
222
+ const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : undefined)
223
+ const modelId = parent?.model ?? info.model?.modelID ?? info.modelID
224
+ const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID
165
225
  const time = info.time ?? {}
166
226
  const created = typeof time.created === "number" ? time.created : undefined
167
227
  const completed = typeof time.completed === "number" ? time.completed : undefined