opencode-harness-kit 0.5.4 → 0.5.6

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/src/tasks.js CHANGED
@@ -195,6 +195,12 @@ export const isBlocked = (t) => t.status === "blocked"
195
195
  * and its output became one string, and the driver handed that string to a
196
196
  * shell. A separate field cannot be mistaken for a command.
197
197
  *
198
+ * The writes happen in ONE order, and the order is part of the contract: the
199
+ * previous output is removed first, then the status is written, then the new
200
+ * output is written. A field line ends the output block, so a status line
201
+ * written before the old output was removed cut that block in half - and the
202
+ * half that survived was folded into the status by the parser.
203
+ *
198
204
  * @param {string} text
199
205
  * @param {Task} task
200
206
  * @param {{ status: string, verifyOutput?: string }} outcome
@@ -202,46 +208,100 @@ export const isBlocked = (t) => t.status === "blocked"
202
208
  */
203
209
  export function markTask(text, task, { status, verifyOutput }) {
204
210
  const lines = String(text).split("\n")
205
- const boxLine = lines[task.start] ?? ""
211
+ // The lines of the task as they are RIGHT NOW.
212
+ //
213
+ // Every edit below moves the lines under it, so the range of the task is
214
+ // tracked here. The task the caller parsed was parsed before any of these
215
+ // edits, so its `end` goes stale as soon as one of them lands.
216
+ const range = { start: task.start, end: task.end }
217
+
218
+ const boxLine = lines[range.start] ?? ""
206
219
  // The indentation is part of the document: a task nested under a heading
207
220
  // keeps its nesting when it is marked.
208
- lines[task.start] = boxLine.replace(
221
+ lines[range.start] = boxLine.replace(
209
222
  /^(\s*)-\s*\[[ xX]\]/,
210
223
  (_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
211
224
  )
212
225
 
213
- let statusLine = -1
214
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
215
- const f = lines[i]?.match(fieldRe)
216
- if (f && f[1] === "status") {
217
- statusLine = i
218
- break
226
+ // The PREVIOUS output is removed FIRST, before anything else is written
227
+ // into the task.
228
+ //
229
+ // A task that is marked twice keeps one output block, not two.
230
+ //
231
+ // It used to be removed after the status line had been written. The
232
+ // status line is a field line, and a field line ends the output block, so
233
+ // the removal took the FIRST line of the old output and left its wrapped
234
+ // lines behind. The parser folds a wrapped line back into the field above
235
+ // it, so those leftovers were folded into the status - and the status read
236
+ // back as "done old line 2 old line 3" instead of "done". The task was not
237
+ // done; it was done with a story attached.
238
+ if (verifyOutput) {
239
+ const previous = outputBlock(lines, range)
240
+ if (previous) {
241
+ const removed = previous.end - previous.start + 1
242
+ lines.splice(previous.start, removed)
243
+ if (previous.end <= range.end) range.end -= removed
219
244
  }
220
245
  }
221
- if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
222
- /(\s-\s+status\s*:).*$/,
223
- `$1 ${status}`,
224
- )
225
246
 
226
- if (verifyOutput) {
227
- // A task that is marked twice keeps one output block, not two.
228
- const previous = outputBlock(lines, task)
229
- if (previous) lines.splice(previous.start, previous.end - previous.start + 1)
247
+ const statusLine = fieldLine(lines, range, "status")
248
+ if (statusLine >= 0) {
249
+ lines[statusLine] = lines[statusLine].replace(
250
+ /(\s-\s+status\s*:).*$/,
251
+ `$1 ${status}`,
252
+ )
253
+ } else {
254
+ // A task with no `status:` field had the status EVAPORATE.
255
+ //
256
+ // The status was only ever written into a status line that already
257
+ // existed, so a task marked blocked without one kept its unticked box
258
+ // and nothing else. The parser reads the status back from that box, so
259
+ // the block became "pending" - and `nextPending()` offered the blocked
260
+ // work again. The driver contract says blocked work is never retried.
261
+ //
262
+ // The status line is written where the other fields are, so the parser
263
+ // reads it back the way it reads them - and AFTER the whole block of
264
+ // the last field, not after the field line itself.
265
+ //
266
+ // Written after the field line, the status line landed INSIDE the block
267
+ // of a field that has wrapped lines under it: the output field, once the
268
+ // driver has run the task. The parser folded the wrapped lines into the
269
+ // status, so it read back as "blocked continued", the block was gone,
270
+ // and the blocked work was offered again.
271
+ const block = lastFieldBlock(lines, range)
272
+ const indent = fieldIndent(lines, range, block.field)
273
+ lines.splice(block.end + 1, 0, `${indent}- status: ${status}`)
274
+ range.end += 1
275
+ }
230
276
 
231
- const verifyLine = fieldLine(lines, task, "verify")
232
- const anchor = verifyLine >= 0 ? verifyLine : lastFieldLine(lines, task)
233
- const source = lines[anchor] ?? lines[task.start] ?? ""
277
+ if (verifyOutput) {
278
+ // The output goes into its own `output:` field rather than under the
279
+ // `verify:` line. It used to be written as continuation lines under
280
+ // `verify:`, and the parser folded a continuation back into the field
281
+ // above it - so the command and its output became one string, and the
282
+ // driver handed that string to a shell. A separate field cannot be
283
+ // mistaken for a command.
284
+ //
285
+ // It is written after the whole block of the `verify:` field, so it
286
+ // cannot land inside that block either - a wrapped line under the
287
+ // verify field would be folded into the output above it.
288
+ const verifyAt = fieldLine(lines, range, "verify")
289
+ const block =
290
+ verifyAt >= 0
291
+ ? { field: verifyAt, end: fieldBlockEnd(lines, range, verifyAt) }
292
+ : lastFieldBlock(lines, range)
234
293
  // The field line sits at the same level as the field it belongs to; only
235
294
  // the continuation lines under it are indented deeper.
236
- const fieldIndent = (source.match(/^\s*/) ?? [""])[0]
237
- const contIndent = `${fieldIndent} `
295
+ const indent = fieldIndent(lines, range, block.field)
296
+ const contIndent = `${indent} `
238
297
  const quoted = String(verifyOutput)
239
298
  .trimEnd()
240
299
  .split("\n")
241
300
  .map((l) => `${contIndent}${l}`)
242
301
  // The first line rides on the field, the rest are continuations of it.
243
- const block = [`${fieldIndent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
244
- lines.splice(anchor + 1, 0, ...block)
302
+ const written = [`${indent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
303
+ lines.splice(block.end + 1, 0, ...written)
304
+ range.end += written.length
245
305
  }
246
306
 
247
307
  return lines.join("\n")
@@ -249,46 +309,139 @@ export function markTask(text, task, { status, verifyOutput }) {
249
309
 
250
310
  /**
251
311
  * The line a named field sits on, or -1.
312
+ *
313
+ * The scan stops at a task line or a heading: the fields under a nested
314
+ * task belong to that task, not to the one above it.
315
+ *
252
316
  * @param {string[]} lines
253
- * @param {Task} task
317
+ * @param {{ start: number, end: number }} range
254
318
  * @param {string} name
255
319
  * @returns {number}
256
320
  */
257
- function fieldLine(lines, task, name) {
258
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
259
- const f = lines[i]?.match(fieldRe)
321
+ function fieldLine(lines, range, name) {
322
+ // The scan starts below the task's own line: that line is the task, not
323
+ // one of its fields.
324
+ for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
325
+ const line = lines[i] ?? ""
326
+ if (taskRe.test(line) || headingRe.test(line)) break
327
+ const f = line.match(fieldRe)
260
328
  if (f && f[1] === name) return i
261
329
  }
262
330
  return -1
263
331
  }
264
332
 
265
333
  /**
266
- * The last field line of a task, or the task's own line when it has none.
334
+ * The last line of a field's block: the field line plus the lines wrapped
335
+ * under it.
336
+ *
337
+ * A field line is where a field is written, but it is not the end of the
338
+ * field: the parser folds a wrapped line back into the value above it, so
339
+ * the end of the block is the end of the value as the parser reads it. A
340
+ * field written inside that block takes the wrapped lines with it.
341
+ *
267
342
  * @param {string[]} lines
268
- * @param {Task} task
343
+ * @param {{ start: number, end: number }} range
344
+ * @param {number} fieldAt the line the field is written on
269
345
  * @returns {number}
270
346
  */
271
- function lastFieldLine(lines, task) {
272
- let last = task.start
273
- for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
274
- if (fieldRe.test(lines[i] ?? "")) last = i
347
+ function fieldBlockEnd(lines, range, fieldAt) {
348
+ let end = fieldAt
349
+ for (let i = fieldAt + 1; i <= range.end + 1 && i < lines.length; i++) {
350
+ const line = lines[i] ?? ""
351
+ // A task, a heading or the next field ends the block.
352
+ if (taskRe.test(line) || headingRe.test(line)) break
353
+ if (fieldRe.test(line)) break
354
+ // A blank line is not part of the value, but it does not end the block
355
+ // either: the parser skips it and folds the wrapped line under it back
356
+ // into the field.
357
+ if (!line.trim()) continue
358
+ // A line that is not indented deeper is not a wrapped line.
359
+ if (!/^\s{4,}\S/.test(line)) break
360
+ end = i
361
+ }
362
+ return end
363
+ }
364
+
365
+ /**
366
+ * The last field of a task: the line it is written on, and the last line of
367
+ * its block.
368
+ *
369
+ * `field` is -1 when the task has no field of its own, and `end` is then
370
+ * the task's own line - the place a first field is written.
371
+ *
372
+ * @param {string[]} lines
373
+ * @param {{ start: number, end: number }} range
374
+ * @returns {{ field: number, end: number }}
375
+ */
376
+ function lastFieldBlock(lines, range) {
377
+ let field = -1
378
+ // The scan starts below the task's own line: that line is the task, not
379
+ // one of its fields.
380
+ for (let i = range.start + 1; i <= range.end + 1 && i < lines.length; i++) {
381
+ const line = lines[i] ?? ""
382
+ if (taskRe.test(line) || headingRe.test(line)) break
383
+ if (fieldRe.test(line)) field = i
275
384
  }
276
- return last
385
+ if (field < 0) return { field: -1, end: range.start }
386
+ return { field, end: fieldBlockEnd(lines, range, field) }
387
+ }
388
+
389
+ /**
390
+ * The indentation a field line of a task is written with.
391
+ *
392
+ * The fields of a task sit one level deeper than the task itself, so a
393
+ * field inserted into a task that has none yet follows that convention: a
394
+ * field at the task's own level reads as a task of its own, or as prose the
395
+ * parser skips.
396
+ *
397
+ * @param {string[]} lines
398
+ * @param {{ start: number, end: number }} range
399
+ * @param {number} fieldAt the line the field is written on, or -1 when the
400
+ * task has no field of its own yet
401
+ * @returns {string}
402
+ */
403
+ function fieldIndent(lines, range, fieldAt) {
404
+ const source = lines[fieldAt >= 0 ? fieldAt : range.start] ?? ""
405
+ const own = (source.match(/^\s*/) ?? [""])[0]
406
+ if (fieldAt < 0) return `${own} `
407
+ return own
277
408
  }
278
409
 
279
410
  /**
280
411
  * The lines a task's `output:` field occupies, or null when it has none.
412
+ *
413
+ * The block ends at the first line that is NOT part of the output - and a
414
+ * task line or a heading is never part of it, however deeply it is indented.
415
+ *
416
+ * The indentation heuristic alone said otherwise: a nested task indented
417
+ * four spaces or more looked like a continuation of the output above it, so
418
+ * the block swallowed it and the rewrite DELETED it. The file's own header
419
+ * promises that every byte the loop does not own survives a rewrite, and a
420
+ * task the loop never owned was destroyed by marking the task above it.
421
+ *
422
+ * A blank line at the end of the block is not part of it: it belongs to the
423
+ * file, not to the output, and the rewrite must not swallow it.
424
+ *
281
425
  * @param {string[]} lines
282
- * @param {Task} task
426
+ * @param {{ start: number, end: number }} range
283
427
  * @returns {{ start: number, end: number } | null}
284
428
  */
285
- function outputBlock(lines, task) {
286
- const start = fieldLine(lines, task, "output")
429
+ function outputBlock(lines, range) {
430
+ const start = fieldLine(lines, range, "output")
287
431
  if (start < 0) return null
288
432
  let end = start
289
- for (let i = start + 1; i <= task.end + 1 && i < lines.length; i++) {
290
- if (fieldRe.test(lines[i] ?? "")) break
291
- if (lines[i].trim() && !/^\s{4,}\S/.test(lines[i])) break
433
+ for (let i = start + 1; i <= range.end + 1 && i < lines.length; i++) {
434
+ const line = lines[i] ?? ""
435
+ // A task or a heading ends the block. This is checked BEFORE the
436
+ // indentation heuristic, because a nested task is indented deeper than
437
+ // the field it sits under - that is what nesting means here.
438
+ if (taskRe.test(line) || headingRe.test(line)) break
439
+ if (fieldRe.test(line)) break
440
+ // A blank line is not part of the output, but it does not end the block
441
+ // either: the parser skips it and folds the wrapped line under it back
442
+ // into the output.
443
+ if (!line.trim()) continue
444
+ if (!/^\s{4,}\S/.test(line)) break
292
445
  end = i
293
446
  }
294
447
  return { start, end }
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // The file is a JSONL in the project, gitignored, and never a network call.
13
13
 
14
- import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs"
14
+ import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs"
15
15
  import { join } from "node:path"
16
16
 
17
17
  /** Rotate above this, so a long-running project cannot fill its disk. */
@@ -33,10 +33,33 @@ export function logFiles(dir: string): string[] {
33
33
  return out
34
34
  }
35
35
 
36
- /** Rotate by size so a long-running project cannot fill its disk. */
37
- function rotate(file: string) {
36
+ /**
37
+ * Rotate by size so a long-running project cannot fill its disk.
38
+ *
39
+ * THREE generations are kept: the current file, `.1` and `.2`. The reader
40
+ * enumerates all three, so the writer has to produce all three.
41
+ *
42
+ * The rotation used to rename the current file straight to `.1`, and a
43
+ * rename REPLACES the destination on POSIX - so the previous generation was
44
+ * destroyed on every rotation and `.2` never came into existence. Two
45
+ * rotations after the fact, the oldest history was gone and the third
46
+ * generation the reader looks for was missing: the history a project lost
47
+ * was lost on a schedule nobody was watching.
48
+ *
49
+ * So the oldest is shifted out of the way first, and the oldest of the
50
+ * three is dropped - the disk use stays bounded at three files, and the
51
+ * generation that is dropped is the one that was already two rotations
52
+ * old.
53
+ *
54
+ * @param file the file to rotate
55
+ * @param maxBytes the size above which it is rotated
56
+ */
57
+ export function rotate(file: string, maxBytes: number = MAX_BYTES) {
38
58
  try {
39
- if (existsSync(file) && statSync(file).size > MAX_BYTES) {
59
+ if (existsSync(file) && statSync(file).size > maxBytes) {
60
+ const oldest = `${file}.2`
61
+ if (existsSync(oldest)) rmSync(oldest, { force: true })
62
+ if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest)
40
63
  renameSync(file, `${file}.1`)
41
64
  }
42
65
  } catch {
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