@renzrollon/interlock 1.4.0 → 1.4.2
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -0
- package/hooks/mod.mjs +542 -112
- package/lib/doctor.mjs +7 -6
- package/lib/meter-time.mjs +43 -0
- package/lib/meter-timeline.mjs +161 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.2",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog 1.1.0, and
|
|
6
6
|
this project adheres to Semantic Versioning 2.0.0.
|
|
7
7
|
|
|
8
|
+
## 1.4.2 — 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A plugin install no longer opens every session with a preflight warning.** The `binaries` check warned whenever `interlock` and `interlock-graph` were not on `PATH`, even when both were in the plugin's `bin/`. The SessionStart hook runs the doctor without that directory on `PATH`, so every plugin user saw `interlock preflight OK (1 warning).` and a `warn binaries` band above the prompt at every session start, for a state nobody had to change. A binary in the plugin's `bin/` is now `ok`, so a clean install draws no band. A binary found neither on `PATH` nor in `bin/` still fails.
|
|
13
|
+
|
|
14
|
+
## 1.4.1 — 2026-10-09
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **The ship meter pane reads as a timeline.** In 1.4.0 the `agents` section was a flat list in first-seen order: relay pings, the planner and lane workers were interleaved, nothing said when anything happened, and lanes that ran in parallel looked like lanes that ran in turn.
|
|
19
|
+
- **The `timeline` section replaces it.** There is one line per CLI step as it crossed, such as `run-batch · wave 2 · batch 1/2 · 3 in parallel` or `verify · wave 2 · skipped: <reason>`. The relay agent that carried the step is folded into that line: `cli <id>`, its served model, its request count and its duration.
|
|
20
|
+
- **Each step's agents** are indented beneath it in the order they started, with name, id, model, turn word and a dim requests-and-tokens line. A spawn no agent has picked up yet reads `waiting`. A relay whose line printed no step, such as `interlock limits`, stands at its own time, and agents nothing names are listed last under `unmatched`.
|
|
21
|
+
- **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
|
|
22
|
+
- **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
|
|
23
|
+
- **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
|
|
24
|
+
- **Wave cards group their lanes by batch**, under a line with the batch's dispatch time and `N in parallel` or `1 lane`. The lanes are indented beneath it and no longer repeat the batch cell. A lane's note adds its duration once its turn ended.
|
|
25
|
+
- **Each section's body is indented** under its heading.
|
|
26
|
+
- **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
|
|
27
|
+
- **New modules:** the words and the order come from two new Node-free modules, `lib/meter-time.mjs` and `lib/meter-timeline.mjs`, pinned by `test/spine/meter-time.test.mjs` and `test/spine/meter-timeline.test.mjs`.
|
|
28
|
+
- **Layout probe:** a probe on 2.1.295 showed the gutter keeps a hanging indent when a line wraps, inside a card too. The pane is as tall as the engine seats it, and a long run scrolls.
|
|
29
|
+
- **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **A run resumed across the 1.4.0 upgrade does not re-run its lanes.** The 1.4.0 entry said it would, because the runtime caches an agent by its label. The runtime keys an agent on its prompt and its options together, the label among them, but a lane's label is the title its step carried, and on a resume the completed relays hand back the steps they first printed. So every lane the run had already dispatched is spawned under its old title and returns its saved result. `docs/04` now says the replay cache keys on the prompt and the options, not on the prompt text alone, and says why an upgrade does not rename the lanes a resume replays.
|
|
34
|
+
- **`docs/07` no longer says the board names a lane exactly as its spawn.** Since 1.4.0 the spawn is shown as `task 1.1 · …`, while the board, the briefing file, the worktree and the trajectory line name the lane by its label, `1.1`.
|
|
35
|
+
|
|
8
36
|
## 1.4.0 — 2026-10-09
|
|
9
37
|
|
|
10
38
|
### Changed
|
package/hooks/mod.mjs
CHANGED
|
@@ -75,9 +75,23 @@
|
|
|
75
75
|
// own briefing, and that Read or Bash call names the spawn's briefing path:
|
|
76
76
|
// the module joins the agent to the spawn's row by it, through the same
|
|
77
77
|
// briefing hash. A driver line from an agent no row names marks a relay; any
|
|
78
|
-
// other agent reads `unmatched agent`, said once beneath the rows.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// other agent reads `unmatched agent`, said once beneath the rows. A ping wraps
|
|
79
|
+
// the relay: another command first, a heredoc, then `interlock … --json`, then
|
|
80
|
+
// an echoed exit status. The driver is the `interlock` that begins a command,
|
|
81
|
+
// and the step is the JSON object in that result, so the wrap still registers
|
|
82
|
+
// its lanes. Every colour is a theme key from `lib/meter-palette.mjs` and
|
|
83
|
+
// names a meaning, never a threshold: the engine's own figures carry none.
|
|
84
|
+
//
|
|
85
|
+
// THE STEP TIMELINE. The agents list's place is a timeline: one line per step
|
|
86
|
+
// as it crossed, the agent whose Bash call printed it folded into that line,
|
|
87
|
+
// the agents a step spawned one indent beneath it in the order they started,
|
|
88
|
+
// relays no step names at their own time, and the agents nothing names last.
|
|
89
|
+
// Every timed line carries the local `HH:MM:SS` of a reading the module took
|
|
90
|
+
// itself (the launch's, a step's stamp, an agent's first request), blank
|
|
91
|
+
// where the clock gave none; durations are humanised. The order and the
|
|
92
|
+
// step's words are `lib/meter-timeline.mjs`'s, the time words
|
|
93
|
+
// `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
|
|
94
|
+
// batch's sub-header, timed by when that batch was first dispatched.
|
|
81
95
|
//
|
|
82
96
|
// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
|
|
83
97
|
// design D5-D8). Outside a run the module draws two things, both from the
|
|
@@ -113,6 +127,8 @@ import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch,
|
|
|
113
127
|
import { LIMITS } from '../lib/limits.mjs'
|
|
114
128
|
import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
|
|
115
129
|
import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
|
|
130
|
+
import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
|
|
131
|
+
import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
|
|
116
132
|
import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
|
|
117
133
|
import {
|
|
118
134
|
adoptPreflight,
|
|
@@ -155,8 +171,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
|
|
|
155
171
|
const NO_LANES_LINE = 'no lanes dispatched yet'
|
|
156
172
|
const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
|
|
157
173
|
const BOARD_FAILED_LINE = 'wave board could not be drawn'
|
|
158
|
-
// The actions that dispatch a batch of lanes, and so place the board's cursor on one.
|
|
159
|
-
const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
|
|
160
174
|
// The session start's two panes, each opened by the command of its name (design D6).
|
|
161
175
|
const PREFLIGHT_PANE = 'interlock-preflight'
|
|
162
176
|
const HANDOFF_PANE = 'interlock-handoff'
|
|
@@ -173,8 +187,8 @@ const LEDGER = { plugin: 'interlock', key: 'ledger' }
|
|
|
173
187
|
// a missing origin count nothing.
|
|
174
188
|
const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
|
|
175
189
|
const GUARD = 'interlock guard'
|
|
176
|
-
// The line `cli()` in workflows/ship.js hands the ping (design D2).
|
|
177
|
-
|
|
190
|
+
// The line `cli()` in workflows/ship.js hands the ping (design D2). A ping
|
|
191
|
+
// wraps it, so the driver is found by `driverInvocation`, not a line start.
|
|
178
192
|
// The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
|
|
179
193
|
const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
|
|
180
194
|
// What an agent row and a turn-end toast read for an agent no row and no driver line names.
|
|
@@ -186,6 +200,15 @@ const PATH_BEFORE = /[\s\/'"=]/
|
|
|
186
200
|
const PATH_AFTER = /[\w.+-]/
|
|
187
201
|
/** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
|
|
188
202
|
const NAME_PROPS = { bold: true, color: PALETTE.identity }
|
|
203
|
+
const DIM = { dimColor: true }
|
|
204
|
+
// The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
|
|
205
|
+
// and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
|
|
206
|
+
const GUTTER_COLUMNS = 10
|
|
207
|
+
const INDENT = 2
|
|
208
|
+
// One column, so a frame never moves the line. Stepped while a run is in
|
|
209
|
+
// flight, on the interval that already recomputes the quiet word.
|
|
210
|
+
const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
211
|
+
const NO_STEP_LINE = 'no step has crossed yet'
|
|
189
212
|
/** The props the run header and each section heading are drawn with. */
|
|
190
213
|
const HEADING_PROPS = { bold: true, color: PALETTE.accent }
|
|
191
214
|
const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
|
|
@@ -230,6 +253,13 @@ function freshRun() {
|
|
|
230
253
|
// activity (null until the clock answers), the word it makes, and the one
|
|
231
254
|
// interval that recomputes it, known by its handle and its generation.
|
|
232
255
|
lastActivityAt: null,
|
|
256
|
+
// The reading the last stamp took (null when the clock failed), and the
|
|
257
|
+
// launch's own: the timeline's times, never guessed.
|
|
258
|
+
stampedAt: null,
|
|
259
|
+
launchedAt: null,
|
|
260
|
+
// Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
|
|
261
|
+
steps: [],
|
|
262
|
+
batchAt: new Map(),
|
|
233
263
|
quietWord: null,
|
|
234
264
|
tick: null,
|
|
235
265
|
generation: 0,
|
|
@@ -251,7 +281,11 @@ function freshRun() {
|
|
|
251
281
|
dispatched: null,
|
|
252
282
|
halt: null,
|
|
253
283
|
planNamed: false,
|
|
254
|
-
recordedNamed: false
|
|
284
|
+
recordedNamed: false,
|
|
285
|
+
// The in-flight mark, stepped by the one quiet interval, and whether this
|
|
286
|
+
// run has asked the pane to follow its latest line.
|
|
287
|
+
pulse: 0,
|
|
288
|
+
followed: false
|
|
255
289
|
}
|
|
256
290
|
}
|
|
257
291
|
|
|
@@ -308,7 +342,8 @@ function position() {
|
|
|
308
342
|
if (run.change) parts.push(run.change)
|
|
309
343
|
if (run.action) parts.push(run.action)
|
|
310
344
|
if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
|
|
311
|
-
|
|
345
|
+
const batch = batchText(run.batchIndex, run.batchCount)
|
|
346
|
+
if (batch) parts.push(batch)
|
|
312
347
|
return parts.join(' · ')
|
|
313
348
|
}
|
|
314
349
|
|
|
@@ -376,16 +411,17 @@ async function stamp($) {
|
|
|
376
411
|
const held = run
|
|
377
412
|
const now = await clockNow($)
|
|
378
413
|
if (held !== run) return false
|
|
414
|
+
run.stampedAt = now
|
|
379
415
|
if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
|
|
380
416
|
return setWord(null)
|
|
381
417
|
}
|
|
382
418
|
|
|
383
419
|
/**
|
|
384
420
|
* Starts the run's one interval (design D2), the host's own timer: each period
|
|
385
|
-
* reads the clock and recomputes the word,
|
|
386
|
-
*
|
|
387
|
-
* returns at once. A host that will not start one leaves
|
|
388
|
-
* recomputes the word whenever it is drawn.
|
|
421
|
+
* reads the clock and recomputes the word, draws when the word changed, and
|
|
422
|
+
* steps the in-flight mark. A period that finds another run or another
|
|
423
|
+
* interval in its place returns at once. A host that will not start one leaves
|
|
424
|
+
* the pane, which recomputes the word whenever it is drawn.
|
|
389
425
|
*/
|
|
390
426
|
function startTick($) {
|
|
391
427
|
const generation = ++generations
|
|
@@ -396,7 +432,14 @@ function startTick($) {
|
|
|
396
432
|
if (!isMine(tick)) return
|
|
397
433
|
try {
|
|
398
434
|
const now = await clockNow($)
|
|
399
|
-
if (isMine(tick)
|
|
435
|
+
if (!isMine(tick)) return
|
|
436
|
+
const changed = setWord(quietWord(quietMs(run.lastActivityAt, now)))
|
|
437
|
+
const pulsed = inFlight()
|
|
438
|
+
if (pulsed) run.pulse++
|
|
439
|
+
if (!isMine(tick)) return
|
|
440
|
+
if (changed) redrawWord($)
|
|
441
|
+
else if (pulsed) $.ui.invalidate('ui.render')
|
|
442
|
+
await followBottom($)
|
|
400
443
|
} catch (err) {
|
|
401
444
|
$.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
|
|
402
445
|
}
|
|
@@ -409,6 +452,28 @@ function startTick($) {
|
|
|
409
452
|
}
|
|
410
453
|
}
|
|
411
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Asks the pane to follow its latest line, once, so a run that grows keeps the
|
|
457
|
+
* tail in view until the person scrolls. A host that will not scroll yet is
|
|
458
|
+
* asked again on the next step or tick. Never throws.
|
|
459
|
+
*/
|
|
460
|
+
async function followBottom($) {
|
|
461
|
+
if (!session.interactive || run.phase !== 'live' || run.followed) return
|
|
462
|
+
const held = run
|
|
463
|
+
try {
|
|
464
|
+
const moved = await $.ui.scroll({ to: 'end', in: PANE })
|
|
465
|
+
if (held === run && scrollAccepted(moved)) held.followed = true
|
|
466
|
+
} catch {
|
|
467
|
+
// The pane may not be placed yet; the next step or tick asks again.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** A scroll landed when the host answered an object and did not refuse it. */
|
|
472
|
+
function scrollAccepted(moved) {
|
|
473
|
+
if (!moved || typeof moved !== 'object') return false
|
|
474
|
+
return !Object.prototype.hasOwnProperty.call(moved, 'den' + 'y')
|
|
475
|
+
}
|
|
476
|
+
|
|
412
477
|
/** Ends the run's interval, if it has one: a cancelled timer never fires again. */
|
|
413
478
|
function stopTick() {
|
|
414
479
|
if (run.tick) run.tick.cancel()
|
|
@@ -671,6 +736,126 @@ function noteBriefing($, agentId, text) {
|
|
|
671
736
|
}
|
|
672
737
|
}
|
|
673
738
|
|
|
739
|
+
/** Whether `i` is the end of a command token. */
|
|
740
|
+
function tokenBoundary(command, i) {
|
|
741
|
+
if (i >= command.length) return true
|
|
742
|
+
const c = command[i]
|
|
743
|
+
return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === ';' || c === '&' || c === '|' || c === '<' || c === '>'
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The index just after a heredoc that opens at `at` (`<<` or `<<-`), or `at`
|
|
748
|
+
* when that is not a heredoc. An unclosed body consumes the rest of the line,
|
|
749
|
+
* the way the shell does, so a word inside it is not a command.
|
|
750
|
+
*/
|
|
751
|
+
function skipHeredoc(command, at) {
|
|
752
|
+
let i = at + 2
|
|
753
|
+
let strip = false
|
|
754
|
+
if (command[i] === '-') {
|
|
755
|
+
strip = true
|
|
756
|
+
i++
|
|
757
|
+
}
|
|
758
|
+
while (command[i] === ' ' || command[i] === '\t') i++
|
|
759
|
+
let quote = null
|
|
760
|
+
if (command[i] === "'" || command[i] === '"') {
|
|
761
|
+
quote = command[i]
|
|
762
|
+
i++
|
|
763
|
+
}
|
|
764
|
+
const start = i
|
|
765
|
+
while (i < command.length && /[A-Za-z0-9_-]/.test(command[i])) i++
|
|
766
|
+
const word = command.slice(start, i)
|
|
767
|
+
if (!word) return at
|
|
768
|
+
if (quote && command[i] === quote) i++
|
|
769
|
+
const nl = command.indexOf('\n', i)
|
|
770
|
+
if (nl < 0) return command.length
|
|
771
|
+
let pos = nl + 1
|
|
772
|
+
while (pos <= command.length) {
|
|
773
|
+
const next = command.indexOf('\n', pos)
|
|
774
|
+
const end = next < 0 ? command.length : next
|
|
775
|
+
let line = command.slice(pos, end)
|
|
776
|
+
if (line.endsWith('\r')) line = line.slice(0, -1)
|
|
777
|
+
if (strip) line = line.replace(/^\t+/, '')
|
|
778
|
+
if (line === word) return next < 0 ? command.length : next + 1
|
|
779
|
+
if (next < 0) return command.length
|
|
780
|
+
pos = next + 1
|
|
781
|
+
}
|
|
782
|
+
return command.length
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** `command` from `start` through the invocation, stopping at the next unquoted separator. */
|
|
786
|
+
function sliceInvocation(command, start) {
|
|
787
|
+
let quote = null
|
|
788
|
+
let i = start
|
|
789
|
+
while (i < command.length) {
|
|
790
|
+
const c = command[i]
|
|
791
|
+
if (quote) {
|
|
792
|
+
if (c === '\\' && quote === '"') {
|
|
793
|
+
i += 2
|
|
794
|
+
continue
|
|
795
|
+
}
|
|
796
|
+
if (c === quote) quote = null
|
|
797
|
+
i++
|
|
798
|
+
continue
|
|
799
|
+
}
|
|
800
|
+
if (c === "'" || c === '"') {
|
|
801
|
+
quote = c
|
|
802
|
+
i++
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
805
|
+
if (c === '\n' || c === ';' || c === '|' || c === '&') break
|
|
806
|
+
i++
|
|
807
|
+
}
|
|
808
|
+
return command.slice(start, i).trim()
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The `interlock` invocation a shell line actually runs, or null.
|
|
813
|
+
*
|
|
814
|
+
* A ping writes a file, then runs `interlock … --json`, then echoes an exit
|
|
815
|
+
* status. The invocation is the `interlock` token that begins a command (the
|
|
816
|
+
* line, or after `;`, `&`, `|` or a newline), not one quoted or sitting inside
|
|
817
|
+
* a heredoc. The slice stops at the next separator, so the relay name is the
|
|
818
|
+
* subcommand and not the rest of the script.
|
|
819
|
+
*/
|
|
820
|
+
function driverInvocation(command) {
|
|
821
|
+
if (typeof command !== 'string' || !command) return null
|
|
822
|
+
let quote = null
|
|
823
|
+
let ready = true
|
|
824
|
+
for (let i = 0; i < command.length; i++) {
|
|
825
|
+
const c = command[i]
|
|
826
|
+
if (quote) {
|
|
827
|
+
if (c === '\\' && quote === '"') {
|
|
828
|
+
i++
|
|
829
|
+
continue
|
|
830
|
+
}
|
|
831
|
+
if (c === quote) quote = null
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
if (c === "'" || c === '"') {
|
|
835
|
+
quote = c
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
if (c === '<' && command[i + 1] === '<') {
|
|
839
|
+
const skipped = skipHeredoc(command, i)
|
|
840
|
+
if (skipped > i) {
|
|
841
|
+
i = skipped - 1
|
|
842
|
+
ready = true
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (c === ' ' || c === '\t' || c === '\r') continue
|
|
847
|
+
if (c === '\n' || c === ';' || c === '|' || c === '&') {
|
|
848
|
+
ready = true
|
|
849
|
+
continue
|
|
850
|
+
}
|
|
851
|
+
if (ready && command.startsWith('interlock', i) && tokenBoundary(command, i + 'interlock'.length)) {
|
|
852
|
+
return sliceInvocation(command, i)
|
|
853
|
+
}
|
|
854
|
+
ready = false
|
|
855
|
+
}
|
|
856
|
+
return null
|
|
857
|
+
}
|
|
858
|
+
|
|
674
859
|
/**
|
|
675
860
|
* `run next` for `interlock run next --results … --json`: the subcommand a
|
|
676
861
|
* driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
|
|
@@ -783,18 +968,75 @@ function noteIdleStep($, r) {
|
|
|
783
968
|
}
|
|
784
969
|
}
|
|
785
970
|
|
|
971
|
+
/** A step record: an object with a string `action`, as the driver guards it. */
|
|
972
|
+
function isStep(value) {
|
|
973
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof value.action === 'string'
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** The index just after the JSON object that opens at `start`, or -1. */
|
|
977
|
+
function jsonEnd(text, start) {
|
|
978
|
+
let depth = 0
|
|
979
|
+
let quote = null
|
|
980
|
+
for (let i = start; i < text.length; i++) {
|
|
981
|
+
const c = text[i]
|
|
982
|
+
if (quote) {
|
|
983
|
+
if (c === '\\') {
|
|
984
|
+
i++
|
|
985
|
+
continue
|
|
986
|
+
}
|
|
987
|
+
if (c === quote) quote = null
|
|
988
|
+
continue
|
|
989
|
+
}
|
|
990
|
+
if (c === '"' || c === "'") {
|
|
991
|
+
quote = c
|
|
992
|
+
continue
|
|
993
|
+
}
|
|
994
|
+
if (c === '{') depth++
|
|
995
|
+
else if (c === '}') {
|
|
996
|
+
depth--
|
|
997
|
+
if (depth === 0) return i + 1
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return -1
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The step object in a result that is not itself JSON: a ping prints a line,
|
|
1005
|
+
* then the step, then an exit status. A stamped step wins; otherwise the
|
|
1006
|
+
* first object with an action.
|
|
1007
|
+
*/
|
|
1008
|
+
function findStep(text) {
|
|
1009
|
+
let fallback = null
|
|
1010
|
+
for (let i = 0; i < text.length; i++) {
|
|
1011
|
+
if (text[i] !== '{') continue
|
|
1012
|
+
const end = jsonEnd(text, i)
|
|
1013
|
+
if (end < 0) continue
|
|
1014
|
+
let value
|
|
1015
|
+
try {
|
|
1016
|
+
value = JSON.parse(text.slice(i, end))
|
|
1017
|
+
} catch {
|
|
1018
|
+
continue
|
|
1019
|
+
}
|
|
1020
|
+
if (!isStep(value)) continue
|
|
1021
|
+
if (value.schema === 'interlock.run-step/1') return value
|
|
1022
|
+
if (!fallback) fallback = value
|
|
1023
|
+
}
|
|
1024
|
+
return fallback
|
|
1025
|
+
}
|
|
1026
|
+
|
|
786
1027
|
/** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
|
|
787
1028
|
function readRecord(text) {
|
|
788
1029
|
if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
|
|
789
|
-
|
|
1030
|
+
const trimmed = text.trim()
|
|
790
1031
|
try {
|
|
791
|
-
value = JSON.parse(
|
|
1032
|
+
const value = JSON.parse(trimmed)
|
|
1033
|
+
if (isStep(value)) return { record: value }
|
|
1034
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
|
|
1035
|
+
return { problem: 'no action' }
|
|
792
1036
|
} catch {
|
|
793
|
-
|
|
1037
|
+
const found = findStep(trimmed)
|
|
1038
|
+
return found ? { record: found } : { problem: 'not JSON' }
|
|
794
1039
|
}
|
|
795
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
|
|
796
|
-
if (typeof value.action !== 'string') return { problem: 'no action' }
|
|
797
|
-
return { record: value }
|
|
798
1040
|
}
|
|
799
1041
|
|
|
800
1042
|
function agentOf(id) {
|
|
@@ -813,13 +1055,24 @@ function agentOf(id) {
|
|
|
813
1055
|
// The two words after `interlock` in the first driver line it ran while no row named it.
|
|
814
1056
|
relay: null,
|
|
815
1057
|
// Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
|
|
816
|
-
endToasted: false
|
|
1058
|
+
endToasted: false,
|
|
1059
|
+
// The reading at its first request, taken once (null when the clock failed then), and at its turn end.
|
|
1060
|
+
stepped: false,
|
|
1061
|
+
firstAt: null,
|
|
1062
|
+
endAt: null
|
|
817
1063
|
}
|
|
818
1064
|
run.agents.set(id, a)
|
|
819
1065
|
}
|
|
820
1066
|
return a
|
|
821
1067
|
}
|
|
822
1068
|
|
|
1069
|
+
/** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
|
|
1070
|
+
function firstRequest(a) {
|
|
1071
|
+
if (a.stepped) return
|
|
1072
|
+
a.stepped = true
|
|
1073
|
+
a.firstAt = run.stampedAt
|
|
1074
|
+
}
|
|
1075
|
+
|
|
823
1076
|
/** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
|
|
824
1077
|
function addUsage(a, usage) {
|
|
825
1078
|
a.requests++
|
|
@@ -905,7 +1158,34 @@ function takeBoard($, record) {
|
|
|
905
1158
|
if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
|
|
906
1159
|
}
|
|
907
1160
|
|
|
908
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* One step as the timeline draws it, its fields the record's own: the time the
|
|
1163
|
+
* stamp read, its position, a skip or halt reason, the size of a plan summary
|
|
1164
|
+
* it carried, the agent whose Bash call printed it, and the spawns it named.
|
|
1165
|
+
*/
|
|
1166
|
+
function noteStep(record, relayId, spawns) {
|
|
1167
|
+
const step = {
|
|
1168
|
+
n: run.steps.length,
|
|
1169
|
+
at: run.stampedAt,
|
|
1170
|
+
action: record.action,
|
|
1171
|
+
wave: record.wave,
|
|
1172
|
+
waveIndex: record.waveIndex,
|
|
1173
|
+
batchIndex: record.batchIndex,
|
|
1174
|
+
batchCount: record.batchCount,
|
|
1175
|
+
skipped: record.skipped,
|
|
1176
|
+
reason: record.reason,
|
|
1177
|
+
planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
|
|
1178
|
+
relayId: str(relayId),
|
|
1179
|
+
spawns
|
|
1180
|
+
}
|
|
1181
|
+
run.steps.push(step)
|
|
1182
|
+
if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
|
|
1183
|
+
const at = `${record.waveIndex}:${record.batchIndex}`
|
|
1184
|
+
if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function applyRecord($, record, relayId) {
|
|
909
1189
|
const held = run
|
|
910
1190
|
await stamp($)
|
|
911
1191
|
if (held !== run) return
|
|
@@ -915,8 +1195,10 @@ async function applyRecord($, record) {
|
|
|
915
1195
|
if (record.wave !== undefined) run.wave = record.wave
|
|
916
1196
|
run.batchIndex = record.batchIndex
|
|
917
1197
|
run.batchCount = record.batchCount
|
|
1198
|
+
const spawns = []
|
|
918
1199
|
for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
|
|
919
1200
|
if (!s || typeof s !== 'object' || !str(s.label)) continue
|
|
1201
|
+
spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
|
|
920
1202
|
run.rows.set(s.label, {
|
|
921
1203
|
label: s.label,
|
|
922
1204
|
title: str(s.title) || s.label,
|
|
@@ -930,6 +1212,7 @@ async function applyRecord($, record) {
|
|
|
930
1212
|
batch: BATCH_ACTIONS.has(record.action)
|
|
931
1213
|
})
|
|
932
1214
|
}
|
|
1215
|
+
noteStep(record, relayId, spawns)
|
|
933
1216
|
takeBoard($, record)
|
|
934
1217
|
for (const banner of Array.isArray(record.banners) ? record.banners : []) {
|
|
935
1218
|
if (typeof banner !== 'string' || !banner) continue
|
|
@@ -950,6 +1233,7 @@ async function applyRecord($, record) {
|
|
|
950
1233
|
}
|
|
951
1234
|
setStatus($)
|
|
952
1235
|
$.ui.invalidate('ui.render')
|
|
1236
|
+
await followBottom($)
|
|
953
1237
|
}
|
|
954
1238
|
|
|
955
1239
|
async function openPane($, id = PANE, title = TITLE) {
|
|
@@ -1106,53 +1390,169 @@ function sessionLines(usage, failure) {
|
|
|
1106
1390
|
}
|
|
1107
1391
|
|
|
1108
1392
|
/**
|
|
1109
|
-
* `
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1393
|
+
* `started <local date and time> · last activity <local time>` (the start left
|
|
1394
|
+
* out when the launch read no time, `last activity unknown` when no stamp did),
|
|
1395
|
+
* then the quiet word while a live run is quiet, as the line and the word
|
|
1396
|
+
* apart. The word is recomputed from the clock at each draw, so a pane opened
|
|
1397
|
+
* after the host stopped the interval still shows it (design D2, D5).
|
|
1113
1398
|
*/
|
|
1114
1399
|
async function lastActivityText($) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1400
|
+
const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
|
|
1401
|
+
if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
|
|
1117
1402
|
const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
|
|
1118
|
-
return { line:
|
|
1403
|
+
return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** An agent id as a line shows it: its first eight characters. */
|
|
1407
|
+
const id8 = id => String(id).slice(0, 8)
|
|
1408
|
+
|
|
1409
|
+
/** `1 request`, `N requests`. */
|
|
1410
|
+
const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
|
|
1411
|
+
|
|
1412
|
+
/** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
|
|
1413
|
+
const turnText = a =>
|
|
1414
|
+
a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
|
|
1415
|
+
|
|
1416
|
+
/** Whether the host ended the agent's turn without an answer. */
|
|
1417
|
+
const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
|
|
1418
|
+
|
|
1419
|
+
/** A live run still has work out: no step yet, an agent still going, or a lane not yet answered. */
|
|
1420
|
+
function inFlight() {
|
|
1421
|
+
if (run.phase !== 'live') return false
|
|
1422
|
+
if (!run.action) return true
|
|
1423
|
+
for (const a of run.agents.values()) if (!a.reason) return true
|
|
1424
|
+
for (const r of run.rows.values()) {
|
|
1425
|
+
const a = r.sha ? agentForSha(r.sha) : null
|
|
1426
|
+
if (!a || !a.reason) return true
|
|
1427
|
+
}
|
|
1428
|
+
return false
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** The current in-flight frame, or `''` before the first interval and once the work has landed. */
|
|
1432
|
+
function frame() {
|
|
1433
|
+
if (run.pulse === 0 || !inFlight()) return ''
|
|
1434
|
+
return PULSE_FRAMES[(run.pulse - 1) % PULSE_FRAMES.length]
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** A leading space and the frame, or `''` when the mark is not showing. */
|
|
1438
|
+
function markSuffix() {
|
|
1439
|
+
const mark = frame()
|
|
1440
|
+
return mark ? ` ${mark}` : ''
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/** A lane note, with the frame appended while that lane is still running or waiting. */
|
|
1444
|
+
function noted(text) {
|
|
1445
|
+
if (typeof text !== 'string' || !/ (?:running|waiting)$/.test(text)) return text
|
|
1446
|
+
return `${text}${markSuffix()}`
|
|
1119
1447
|
}
|
|
1120
1448
|
|
|
1121
1449
|
/**
|
|
1122
|
-
* An agent's first line: its name, its id and models dim,
|
|
1123
|
-
* A joined agent is named in the identity colour, a relay
|
|
1124
|
-
* agent dim and italic. The turn word is the host's, coloured
|
|
1125
|
-
* a relay's running or answered turn stays dim, and only its
|
|
1450
|
+
* An agent's first line, one wrapping text: its name, its id and models dim,
|
|
1451
|
+
* then its turn word. A joined agent is named in the identity colour, a relay
|
|
1452
|
+
* dim, an unmatched agent dim and italic. The turn word is the host's, coloured
|
|
1453
|
+
* by what it means; a relay's running or answered turn stays dim, and only its
|
|
1454
|
+
* abnormal end is coloured.
|
|
1126
1455
|
*/
|
|
1127
|
-
function agentLine(Text, a,
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
Text,
|
|
1134
|
-
null,
|
|
1135
|
-
h(Text, nameProps, name || UNMATCHED),
|
|
1136
|
-
h(Text, { dimColor: true }, ` · ${a.id} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1456
|
+
function agentLine(Text, a, name, kind) {
|
|
1457
|
+
const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
|
|
1458
|
+
const quiet = kind === 'relay' && !abnormal(a)
|
|
1459
|
+
const spans = [
|
|
1460
|
+
h(Text, nameProps, name),
|
|
1461
|
+
h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1137
1462
|
' · ',
|
|
1138
|
-
h(Text, quiet ?
|
|
1463
|
+
h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
|
|
1464
|
+
]
|
|
1465
|
+
if (!a.reason) {
|
|
1466
|
+
const mark = markSuffix()
|
|
1467
|
+
if (mark) spans.push(mark)
|
|
1468
|
+
}
|
|
1469
|
+
return h(Text, { wrap: 'wrap' }, ...spans)
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* One timed line (layout probe P3): the local time in a fixed dim gutter,
|
|
1474
|
+
* blank when no time was read, then the content in a column that wraps beside
|
|
1475
|
+
* it, `depth` steps in. Each content line is one Text with its coloured words
|
|
1476
|
+
* nested inside it, never a row of sibling Texts.
|
|
1477
|
+
*/
|
|
1478
|
+
function gutterRow(Box, Text, key, at, depth, ...content) {
|
|
1479
|
+
return h(
|
|
1480
|
+
Box,
|
|
1481
|
+
{ key, flexDirection: 'row', flexShrink: 0 },
|
|
1482
|
+
h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
|
|
1483
|
+
h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
|
|
1139
1484
|
)
|
|
1140
1485
|
}
|
|
1141
1486
|
|
|
1487
|
+
/**
|
|
1488
|
+
* A step's line: its own words, then the agent whose Bash call printed it,
|
|
1489
|
+
* dim, with its served models, its requests and, once its turn ended, how long
|
|
1490
|
+
* it took; the relay's turn word only when the host ended it without an answer.
|
|
1491
|
+
*/
|
|
1492
|
+
function stepLine(Text, step) {
|
|
1493
|
+
const spans = [stepText(step)]
|
|
1494
|
+
if (step.relayId) {
|
|
1495
|
+
const a = run.agents.get(step.relayId)
|
|
1496
|
+
const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
|
|
1497
|
+
spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
|
|
1498
|
+
if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
|
|
1499
|
+
else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
|
|
1500
|
+
}
|
|
1501
|
+
return h(Text, { wrap: 'wrap' }, ...spans)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
|
|
1506
|
+
* line. An agent a step spawned and an agent no step names keep the requests
|
|
1507
|
+
* and tokens line beneath their own; a relay's figures ride its step's line or
|
|
1508
|
+
* its own one line, and its token breakdown is the trajectory's.
|
|
1509
|
+
*/
|
|
1510
|
+
function drawTimeline($, e) {
|
|
1511
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1512
|
+
const entries = timeline({
|
|
1513
|
+
steps: run.steps,
|
|
1514
|
+
agents: [...run.agents.values()],
|
|
1515
|
+
launch: { at: run.launchedAt, name: run.workflowName }
|
|
1516
|
+
})
|
|
1517
|
+
const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
|
|
1518
|
+
const out = []
|
|
1519
|
+
for (const entry of entries) {
|
|
1520
|
+
const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
|
|
1521
|
+
if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
|
|
1522
|
+
else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
|
|
1523
|
+
else if (entry.kind === 'spawn' && entry.agent) {
|
|
1524
|
+
out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
|
|
1525
|
+
} else if (entry.kind === 'spawn') {
|
|
1526
|
+
const waiting = [h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting')]
|
|
1527
|
+
const mark = markSuffix()
|
|
1528
|
+
if (mark) waiting.push(mark)
|
|
1529
|
+
out.push(row(h(Text, { wrap: 'wrap' }, ...waiting)))
|
|
1530
|
+
} else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
|
|
1531
|
+
else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key, flexShrink: 0 }, h(Text, DIM, 'unmatched')))
|
|
1532
|
+
else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
|
|
1533
|
+
}
|
|
1534
|
+
if (entries.some(entry => entry.kind === 'unmatched')) {
|
|
1535
|
+
out.push(h(Box, { key: 'agents-unmatched', flexShrink: 0, paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
|
|
1536
|
+
}
|
|
1537
|
+
if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step', flexShrink: 0 }, h(Text, DIM, NO_STEP_LINE)))
|
|
1538
|
+
return out
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1142
1541
|
/** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
|
|
1143
1542
|
function flatRow($, e, r) {
|
|
1144
1543
|
const { Box, Text } = $.ui.resolve(e)
|
|
1145
1544
|
const a = agentForSha(r.sha)
|
|
1146
1545
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1147
1546
|
const state = a ? a.reason || 'running' : 'waiting'
|
|
1547
|
+
const live = state === 'running' || state === 'waiting' ? `${state}${markSuffix()}` : state
|
|
1148
1548
|
return h(
|
|
1149
1549
|
Box,
|
|
1150
|
-
{ key: `wave-row-${r.label}
|
|
1550
|
+
{ key: `wave-row-${r.label}`, flexShrink: 0 },
|
|
1151
1551
|
h(
|
|
1152
1552
|
Text,
|
|
1153
1553
|
null,
|
|
1154
1554
|
h(Text, NAME_PROPS, r.title),
|
|
1155
|
-
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${
|
|
1555
|
+
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${live}`
|
|
1156
1556
|
)
|
|
1157
1557
|
)
|
|
1158
1558
|
}
|
|
@@ -1180,7 +1580,7 @@ function laneNotes() {
|
|
|
1180
1580
|
for (const r of run.rows.values()) {
|
|
1181
1581
|
const a = agentForSha(r.sha)
|
|
1182
1582
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1183
|
-
notes[r.label] = `served ${served}${a ? ` · ${a
|
|
1583
|
+
notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
|
|
1184
1584
|
}
|
|
1185
1585
|
return notes
|
|
1186
1586
|
}
|
|
@@ -1193,11 +1593,13 @@ function boardKey(key) {
|
|
|
1193
1593
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1194
1594
|
}
|
|
1195
1595
|
|
|
1196
|
-
/**
|
|
1596
|
+
/**
|
|
1597
|
+
* The width a card and the renderer share: the section body (the pane's body
|
|
1598
|
+
* less the body's indent beneath its heading), capped at the published default.
|
|
1599
|
+
*/
|
|
1197
1600
|
function boardColumns(e) {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
: LIMITS.waveBoardDefaultColumns
|
|
1601
|
+
const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
|
|
1602
|
+
const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
|
|
1201
1603
|
return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
|
|
1202
1604
|
}
|
|
1203
1605
|
|
|
@@ -1226,14 +1628,14 @@ function idSpans(Text, parts) {
|
|
|
1226
1628
|
}
|
|
1227
1629
|
|
|
1228
1630
|
/**
|
|
1229
|
-
* A lane's
|
|
1230
|
-
*
|
|
1631
|
+
* A lane's label, model, tier and effort cells, space-joined as the board
|
|
1632
|
+
* prints them, the label a span in the identity colour. The batch cell is the
|
|
1633
|
+
* sub-header the lane sits under, so the lane does not repeat it.
|
|
1231
1634
|
*/
|
|
1232
1635
|
function headSpans(Text, parts) {
|
|
1233
|
-
const before = [parts.batch].filter(Boolean).join(' ')
|
|
1234
1636
|
const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
|
|
1235
|
-
if (!parts.label) return [
|
|
1236
|
-
return [
|
|
1637
|
+
if (!parts.label) return [after]
|
|
1638
|
+
return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
|
|
1237
1639
|
}
|
|
1238
1640
|
|
|
1239
1641
|
/** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
|
|
@@ -1242,16 +1644,17 @@ const isCurrentLane = parts =>
|
|
|
1242
1644
|
(parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
|
|
1243
1645
|
|
|
1244
1646
|
/**
|
|
1245
|
-
* A lane in its card
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1647
|
+
* A lane in its card, one step in beneath its batch's sub-header with a blank
|
|
1648
|
+
* gutter: the fixed cells as one Text, the gist wrapping beneath, then the
|
|
1649
|
+
* host's note, dim. The coloured words are spans nested in that one Text,
|
|
1650
|
+
* because a row of separate elements shrinks each into its own column and
|
|
1651
|
+
* breaks words apart once the line is wider than the card.
|
|
1249
1652
|
*/
|
|
1250
1653
|
function drawLane($, e, row) {
|
|
1251
1654
|
const { Box, Text } = $.ui.resolve(e)
|
|
1252
1655
|
const key = boardKey(row.key)
|
|
1253
1656
|
const parts = row.parts
|
|
1254
|
-
if (!parts) return h(Box, { key }, h(Text, { wrap: 'truncate-end' }, row.text))
|
|
1657
|
+
if (!parts) return h(Box, { key, flexShrink: 0 }, h(Text, { wrap: 'truncate-end' }, row.text))
|
|
1255
1658
|
const line = [
|
|
1256
1659
|
...headSpans(Text, parts),
|
|
1257
1660
|
...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
|
|
@@ -1260,28 +1663,57 @@ function drawLane($, e, row) {
|
|
|
1260
1663
|
]
|
|
1261
1664
|
const kids = [
|
|
1262
1665
|
h(Text, { wrap: 'wrap' }, ...line),
|
|
1263
|
-
parts.gist ? h(Box, { key: `${key}-gist
|
|
1264
|
-
parts.note ? h(Box, { key: `${key}-note
|
|
1666
|
+
parts.gist ? h(Box, { key: `${key}-gist`, flexShrink: 0 }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
|
|
1667
|
+
parts.note ? h(Box, { key: `${key}-note`, flexShrink: 0 }, h(Text, { dimColor: true, wrap: 'wrap' }, noted(parts.note))) : null
|
|
1265
1668
|
].filter(Boolean)
|
|
1266
|
-
return
|
|
1669
|
+
return gutterRow(Box, Text, key, null, 1, ...kids)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* A card's lanes, grouped by the batch the board printed for each: before each
|
|
1674
|
+
* group a gutter line with the time that batch was first dispatched and how
|
|
1675
|
+
* many lanes it runs side by side. It carries no state word: the cursor card's
|
|
1676
|
+
* border already marks where the run is.
|
|
1677
|
+
*/
|
|
1678
|
+
function drawBatches($, e, index, lanes) {
|
|
1679
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1680
|
+
const groups = new Map()
|
|
1681
|
+
for (const row of lanes) {
|
|
1682
|
+
const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
|
|
1683
|
+
if (!groups.has(batch)) groups.set(batch, [])
|
|
1684
|
+
groups.get(batch).push(row)
|
|
1685
|
+
}
|
|
1686
|
+
const out = []
|
|
1687
|
+
for (const [batch, rows] of groups) {
|
|
1688
|
+
const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
|
|
1689
|
+
if (k !== null) {
|
|
1690
|
+
const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
|
|
1691
|
+
out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
|
|
1692
|
+
}
|
|
1693
|
+
out.push(...rows.map(row => drawLane($, e, row)))
|
|
1694
|
+
}
|
|
1695
|
+
return out
|
|
1267
1696
|
}
|
|
1268
1697
|
|
|
1269
1698
|
function drawBoardCards($, e, board, width) {
|
|
1270
1699
|
const { Box, Text } = $.ui.resolve(e)
|
|
1271
|
-
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1700
|
+
const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
|
|
1272
1701
|
const out = []
|
|
1273
1702
|
let index = null
|
|
1274
1703
|
let kids = null
|
|
1704
|
+
let lanes = null
|
|
1275
1705
|
// Whether the card being filled holds a lane at the cursor: its border is then the current colour.
|
|
1276
1706
|
let current = false
|
|
1277
1707
|
const flush = () => {
|
|
1278
1708
|
if (index === null) return
|
|
1709
|
+
kids.push(...drawBatches($, e, index, lanes))
|
|
1279
1710
|
out.push(
|
|
1280
1711
|
h(
|
|
1281
1712
|
Box,
|
|
1282
1713
|
{
|
|
1283
1714
|
key: `wave-card-${index}`,
|
|
1284
1715
|
flexDirection: 'column',
|
|
1716
|
+
flexShrink: 0,
|
|
1285
1717
|
borderStyle: 'single',
|
|
1286
1718
|
...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
|
|
1287
1719
|
width,
|
|
@@ -1294,6 +1726,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1294
1726
|
)
|
|
1295
1727
|
index = null
|
|
1296
1728
|
kids = null
|
|
1729
|
+
lanes = null
|
|
1297
1730
|
current = false
|
|
1298
1731
|
}
|
|
1299
1732
|
for (const row of board) {
|
|
@@ -1301,15 +1734,15 @@ function drawBoardCards($, e, board, width) {
|
|
|
1301
1734
|
if (row.key.startsWith('wave:')) {
|
|
1302
1735
|
flush()
|
|
1303
1736
|
index = row.key.slice('wave:'.length)
|
|
1304
|
-
kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1737
|
+
kids = [h(Box, { key: boardKey(row.key), flexShrink: 0 }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1738
|
+
lanes = []
|
|
1305
1739
|
continue
|
|
1306
1740
|
}
|
|
1307
1741
|
if (row.key.startsWith('lane:')) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
kids.push(lane)
|
|
1742
|
+
if (lanes) {
|
|
1743
|
+
lanes.push(row)
|
|
1311
1744
|
if (isCurrentLane(row.parts)) current = true
|
|
1312
|
-
} else out.push(
|
|
1745
|
+
} else out.push(drawLane($, e, row))
|
|
1313
1746
|
continue
|
|
1314
1747
|
}
|
|
1315
1748
|
flush()
|
|
@@ -1330,11 +1763,11 @@ function drawBoardCards($, e, board, width) {
|
|
|
1330
1763
|
*/
|
|
1331
1764
|
function drawWaves($, e) {
|
|
1332
1765
|
const { Box, Text } = $.ui.resolve(e)
|
|
1333
|
-
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1766
|
+
const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
|
|
1334
1767
|
const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
|
|
1335
1768
|
const rows = [...run.rows.values()]
|
|
1336
1769
|
if (!run.plan) {
|
|
1337
|
-
if (!rows.length) return [h(Text, { dimColor: true }, NO_LANES_LINE)]
|
|
1770
|
+
if (!rows.length) return [h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, `${NO_LANES_LINE}${markSuffix()}`))]
|
|
1338
1771
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1339
1772
|
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
|
|
1340
1773
|
}
|
|
@@ -1356,8 +1789,12 @@ function drawWaves($, e) {
|
|
|
1356
1789
|
|
|
1357
1790
|
async function drawPane($, e) {
|
|
1358
1791
|
const { Box, Text } = $.ui.resolve(e)
|
|
1359
|
-
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1360
|
-
const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1792
|
+
const keyed = (key, text, props) => h(Box, { key, flexShrink: 0 }, h(Text, props || null, text))
|
|
1793
|
+
const heading = text => h(Box, { flexShrink: 0, marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1794
|
+
// Each section's body sits one indent in beneath its heading. The pane
|
|
1795
|
+
// scrolls by the tree's own height; a column that can shrink collapses into
|
|
1796
|
+
// the viewport, and the latest lines then sit past the last offset.
|
|
1797
|
+
const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', flexShrink: 0, paddingLeft: INDENT }, ...kids)
|
|
1361
1798
|
// The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
|
|
1362
1799
|
const guardLine = async () => {
|
|
1363
1800
|
const guard = await launchGuardText($)
|
|
@@ -1365,40 +1802,27 @@ async function drawPane($, e) {
|
|
|
1365
1802
|
}
|
|
1366
1803
|
if (run.phase === 'idle' && !run.summary) {
|
|
1367
1804
|
// The guard runs in every session, so its line stands here too (design D5).
|
|
1368
|
-
return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
|
|
1805
|
+
return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, h(Text, { dimColor: true }, NO_RUN_LINE), await guardLine())
|
|
1369
1806
|
}
|
|
1370
1807
|
|
|
1371
1808
|
const out = []
|
|
1372
|
-
out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
|
|
1373
|
-
out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
|
|
1809
|
+
out.push(h(Box, { flexShrink: 0 }, h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`)))
|
|
1810
|
+
out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}${markSuffix()}`))
|
|
1374
1811
|
const activity = await lastActivityText($)
|
|
1375
1812
|
out.push(
|
|
1376
1813
|
h(
|
|
1377
1814
|
Box,
|
|
1378
|
-
{ key: 'last-activity' },
|
|
1815
|
+
{ key: 'last-activity', flexShrink: 0 },
|
|
1379
1816
|
h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
|
|
1380
1817
|
)
|
|
1381
1818
|
)
|
|
1382
|
-
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1819
|
+
out.push(h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, RECORD_LINE)))
|
|
1383
1820
|
|
|
1384
1821
|
out.push(heading('waves'))
|
|
1385
|
-
out.push(...drawWaves($, e))
|
|
1822
|
+
out.push(body('waves-body', ...drawWaves($, e)))
|
|
1386
1823
|
|
|
1387
|
-
out.push(heading('
|
|
1388
|
-
|
|
1389
|
-
for (const a of run.agents.values()) {
|
|
1390
|
-
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1391
|
-
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1392
|
-
out.push(
|
|
1393
|
-
h(
|
|
1394
|
-
Box,
|
|
1395
|
-
{ key: `agent-${a.id}`, flexDirection: 'column' },
|
|
1396
|
-
h(Box, null, agentLine(Text, a, done)),
|
|
1397
|
-
h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
|
|
1398
|
-
)
|
|
1399
|
-
)
|
|
1400
|
-
}
|
|
1401
|
-
if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
|
|
1824
|
+
out.push(heading('timeline'))
|
|
1825
|
+
out.push(body('timeline-body', ...drawTimeline($, e)))
|
|
1402
1826
|
|
|
1403
1827
|
// One plain read feeds the session lines and the plan windows; a breakdown
|
|
1404
1828
|
// is never asked for, because only the plain call is free (design D4).
|
|
@@ -1412,38 +1836,37 @@ async function drawPane($, e) {
|
|
|
1412
1836
|
}
|
|
1413
1837
|
const [contextLine, costLine] = sessionLines(usage, failure)
|
|
1414
1838
|
out.push(heading('session'))
|
|
1415
|
-
out.push(keyed('session-context', contextLine))
|
|
1416
|
-
out.push(keyed('session-cost', costLine))
|
|
1839
|
+
out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
|
|
1417
1840
|
|
|
1418
1841
|
out.push(heading('plan windows'))
|
|
1419
1842
|
const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
|
|
1420
|
-
|
|
1421
|
-
|
|
1843
|
+
const planLines = []
|
|
1844
|
+
if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
|
|
1845
|
+
else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
|
|
1422
1846
|
for (const w of windows) {
|
|
1423
1847
|
if (!w || typeof w !== 'object') continue
|
|
1424
1848
|
const kind = String(w.kind)
|
|
1425
|
-
|
|
1849
|
+
planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
|
|
1426
1850
|
}
|
|
1851
|
+
out.push(body('plan-windows-body', ...planLines))
|
|
1427
1852
|
|
|
1428
1853
|
out.push(heading('banners'))
|
|
1429
1854
|
out.push(
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
{ key: 'banners', flexDirection: 'column' },
|
|
1855
|
+
body(
|
|
1856
|
+
'banners',
|
|
1433
1857
|
...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
|
|
1434
1858
|
)
|
|
1435
1859
|
)
|
|
1436
1860
|
|
|
1437
1861
|
out.push(heading('refusals'))
|
|
1438
1862
|
const denials = guardDenialLine(run.guardDenials)
|
|
1439
|
-
|
|
1440
|
-
out.push(await guardLine())
|
|
1863
|
+
out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
|
|
1441
1864
|
|
|
1442
1865
|
if (run.summary) {
|
|
1443
1866
|
out.push(heading('close summary'))
|
|
1444
|
-
out.push(
|
|
1867
|
+
out.push(body('summary', h(Text, null, run.summary)))
|
|
1445
1868
|
}
|
|
1446
|
-
return h(Box, { flexDirection: 'column' }, ...out)
|
|
1869
|
+
return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, ...out)
|
|
1447
1870
|
}
|
|
1448
1871
|
|
|
1449
1872
|
export function register(on) {
|
|
@@ -1456,7 +1879,7 @@ export function register(on) {
|
|
|
1456
1879
|
try {
|
|
1457
1880
|
await $.command.register({
|
|
1458
1881
|
name: PANE,
|
|
1459
|
-
description: 'Open the Interlock ship meter: the live run, its waves,
|
|
1882
|
+
description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
|
|
1460
1883
|
immediate: true
|
|
1461
1884
|
})
|
|
1462
1885
|
} catch (err) {
|
|
@@ -1573,17 +1996,20 @@ export function register(on) {
|
|
|
1573
1996
|
run.workflowName = str(r.result.workflowName)
|
|
1574
1997
|
run.transcriptDir = str(r.result.transcriptDir)
|
|
1575
1998
|
run.lastActivityAt = now
|
|
1999
|
+
run.launchedAt = now
|
|
1576
2000
|
$.ui.invalidate('ui.render')
|
|
1577
2001
|
const placed = await openPane($)
|
|
1578
2002
|
if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
|
|
1579
2003
|
if (run === launched) startTick($)
|
|
2004
|
+
if (run === launched) await followBottom($)
|
|
1580
2005
|
return r
|
|
1581
2006
|
})
|
|
1582
2007
|
|
|
1583
2008
|
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
1584
2009
|
if (!session.interactive) return next(e)
|
|
1585
2010
|
const command = typeof e.command === 'string' ? e.command : ''
|
|
1586
|
-
const
|
|
2011
|
+
const invocation = driverInvocation(command)
|
|
2012
|
+
const driver = invocation !== null
|
|
1587
2013
|
// The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
|
|
1588
2014
|
const lines = specLines(command)
|
|
1589
2015
|
const reader = lines.length ? specReader($, lines) : null
|
|
@@ -1599,7 +2025,7 @@ export function register(on) {
|
|
|
1599
2025
|
// briefing it reads joins it to its lane, and a driver line names a relay.
|
|
1600
2026
|
if (run.phase === 'live' && str(e.agentId)) {
|
|
1601
2027
|
noteBriefing($, e.agentId, command)
|
|
1602
|
-
if (driver) noteRelay($, e.agentId,
|
|
2028
|
+
if (driver) noteRelay($, e.agentId, invocation)
|
|
1603
2029
|
}
|
|
1604
2030
|
const r = await next(e)
|
|
1605
2031
|
// Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
|
|
@@ -1607,8 +2033,8 @@ export function register(on) {
|
|
|
1607
2033
|
if (driver) {
|
|
1608
2034
|
try {
|
|
1609
2035
|
const { record, problem } = readRecord(r.text)
|
|
1610
|
-
if (!record) $.ui.log(`interlock meter: ${
|
|
1611
|
-
else if (held === run) await applyRecord($, record)
|
|
2036
|
+
if (!record) $.ui.log(`interlock meter: ${invocation.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
|
|
2037
|
+
else if (held === run) await applyRecord($, record, str(e.agentId))
|
|
1612
2038
|
else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
|
|
1613
2039
|
} catch (err) {
|
|
1614
2040
|
$.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
|
|
@@ -1642,7 +2068,9 @@ export function register(on) {
|
|
|
1642
2068
|
on('turn.step', async function* ($, e, next) {
|
|
1643
2069
|
if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
|
|
1644
2070
|
// A run agent's request starting, and its answer arriving, are each activity (design D1).
|
|
2071
|
+
const held = run
|
|
1645
2072
|
if (await stamp($)) redrawWord($)
|
|
2073
|
+
if (held === run) firstRequest(agentOf(e.agentId))
|
|
1646
2074
|
const result = yield* next(e)
|
|
1647
2075
|
if (await stamp($)) redrawWord($)
|
|
1648
2076
|
try {
|
|
@@ -1670,7 +2098,9 @@ export function register(on) {
|
|
|
1670
2098
|
$.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
|
|
1671
2099
|
}
|
|
1672
2100
|
}
|
|
2101
|
+
const held = run
|
|
1673
2102
|
if (await stamp($)) redrawWord($)
|
|
2103
|
+
if (held === run) a.endAt = run.stampedAt
|
|
1674
2104
|
$.ui.invalidate('ui.render')
|
|
1675
2105
|
}
|
|
1676
2106
|
return next(e)
|
package/lib/doctor.mjs
CHANGED
|
@@ -535,9 +535,11 @@ function checkPlugin(pluginRoot) {
|
|
|
535
535
|
|
|
536
536
|
/**
|
|
537
537
|
* `interlock` and `interlock-graph` are on PATH inside a Claude Code session
|
|
538
|
-
* because the host injects the plugin's `bin
|
|
539
|
-
* that
|
|
540
|
-
*
|
|
538
|
+
* because the host injects the plugin's `bin/` — but not in the SessionStart
|
|
539
|
+
* hook that runs this doctor, nor in a plain shell. A copy in the plugin's
|
|
540
|
+
* `bin/` is a complete install, so it is `ok`: a `warn` here would be drawn
|
|
541
|
+
* above the prompt at every session start for a state nobody has to change.
|
|
542
|
+
* Only a binary found in neither place is a failure.
|
|
541
543
|
*/
|
|
542
544
|
function checkBinaries(pluginRoot, env) {
|
|
543
545
|
const found = []
|
|
@@ -552,9 +554,8 @@ function checkBinaries(pluginRoot, env) {
|
|
|
552
554
|
}
|
|
553
555
|
const bundled = absent.filter(name => existsSync(join(pluginRoot, 'bin', name)))
|
|
554
556
|
if (bundled.length === absent.length) {
|
|
555
|
-
return check('binaries', '
|
|
556
|
-
evidence: [...found, ...bundled.map(n => `bundled: ${join(pluginRoot, 'bin', n)}`)]
|
|
557
|
-
fix: `Nothing to do for a run inside Claude Code. For a plain shell: export PATH="${join(pluginRoot, 'bin')}:$PATH"`
|
|
557
|
+
return check('binaries', 'ok', `${absent.join(', ')} run from the plugin's bin/ (${join(pluginRoot, 'bin')}), which Claude Code puts on PATH inside a session`, {
|
|
558
|
+
evidence: [...found, ...bundled.map(n => `bundled: ${join(pluginRoot, 'bin', n)}`)]
|
|
558
559
|
})
|
|
559
560
|
}
|
|
560
561
|
return check('binaries', 'fail', `${absent.join(', ')} resolve neither on PATH nor in the plugin's bin/`, {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The ship meter's time words: the clock reading a line was stamped with, and
|
|
2
|
+
// how long a turn took, as a person reads them.
|
|
3
|
+
//
|
|
4
|
+
// Each reading is the engine's own (`$.clock.now()` in the hooks module), so a
|
|
5
|
+
// time is either a real reading or absent: absent draws as nothing, never as a
|
|
6
|
+
// guessed time. The wall-clock words are the local time of the machine the
|
|
7
|
+
// engine runs on, from `Date`'s own local fields. The duration bands are unit
|
|
8
|
+
// conversions (seconds, minutes, hours), not thresholds: no band is a verdict
|
|
9
|
+
// on the figure it formats.
|
|
10
|
+
//
|
|
11
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
12
|
+
// imports this file.
|
|
13
|
+
|
|
14
|
+
const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
|
|
15
|
+
const pad = n => String(n).padStart(2, '0')
|
|
16
|
+
|
|
17
|
+
/** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
|
|
18
|
+
export function clockText(ms) {
|
|
19
|
+
if (!isTime(ms)) return ''
|
|
20
|
+
const d = new Date(ms)
|
|
21
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The local date and time of a reading, `YYYY-MM-DD HH:MM:SS`; `''` for no reading. */
|
|
25
|
+
export function stampText(ms) {
|
|
26
|
+
if (!isTime(ms)) return ''
|
|
27
|
+
const d = new Date(ms)
|
|
28
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockText(ms)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A duration in milliseconds as a person reads it: under a minute, seconds to
|
|
33
|
+
* one decimal, cut down (`41.2s`, `0.8s`, never `60.0s`); under an hour, whole
|
|
34
|
+
* minutes and seconds (`2m 14s`); else hours and minutes (`1h 03m`). `''` for
|
|
35
|
+
* no figure, a negative one or one that is not a finite number.
|
|
36
|
+
*/
|
|
37
|
+
export function durationText(ms) {
|
|
38
|
+
if (!isTime(ms) || ms < 0) return ''
|
|
39
|
+
if (ms < 60_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
|
|
40
|
+
const seconds = Math.floor(ms / 1000)
|
|
41
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
|
|
42
|
+
return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
|
|
43
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The ship meter's step timeline: what each step the CLI printed says, and the
|
|
2
|
+
// order the pane lays the steps, the agents they spawned and the agents no
|
|
3
|
+
// step names in.
|
|
4
|
+
//
|
|
5
|
+
// A step's words are its own fields, verbatim: the action, its position, how
|
|
6
|
+
// many lanes a batch dispatched, a skipped verification's reason, a halt's
|
|
7
|
+
// reason and the plan size a step relayed. No word here is a verdict, a cause
|
|
8
|
+
// or a state the step did not carry.
|
|
9
|
+
//
|
|
10
|
+
// The order is pure data over plain inputs, so it is pinned under Node
|
|
11
|
+
// (`test/spine/meter-timeline.test.mjs`) and the hooks module only draws it:
|
|
12
|
+
//
|
|
13
|
+
// - The top level is the launch, every step and every relay agent no step
|
|
14
|
+
// names, laid by time. An entry with a time sorts by it, ties keeping their
|
|
15
|
+
// input order (the launch, then the steps as they crossed, then the relays as
|
|
16
|
+
// first seen); an entry with no time follows every timed one, in input order.
|
|
17
|
+
// - Beneath a step, at depth 1, the agents it spawned: a spawn is joined to an
|
|
18
|
+
// agent by its briefing hash, one agent per spawn in step order (an agent
|
|
19
|
+
// beyond the spawns that carry its hash stays with the last of them). The
|
|
20
|
+
// joined spawns are laid by their agent's first request time, by the same
|
|
21
|
+
// rule; the spawns no agent has joined yet follow, `waiting`, in the step's
|
|
22
|
+
// spawn order. A step's relay is drawn on the step's own line and nowhere
|
|
23
|
+
// else.
|
|
24
|
+
// - Last, the agents nothing names, under one head, in first-seen order.
|
|
25
|
+
//
|
|
26
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
27
|
+
// imports this file.
|
|
28
|
+
|
|
29
|
+
/** The actions that dispatch a batch of lanes. */
|
|
30
|
+
export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
|
|
31
|
+
|
|
32
|
+
const isInt = v => Number.isInteger(v)
|
|
33
|
+
const isTime = v => typeof v === 'number' && Number.isFinite(v)
|
|
34
|
+
const str = v => (typeof v === 'string' && v ? v : null)
|
|
35
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
36
|
+
|
|
37
|
+
/** `batch <j>/<k>`, one-based, as the status line says it; `null` when the step carries no position. */
|
|
38
|
+
export function batchText(batchIndex, batchCount) {
|
|
39
|
+
return isInt(batchIndex) && isInt(batchCount) ? `batch ${batchIndex + 1}/${batchCount}` : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many lanes run side by side: `N in parallel`, `1 lane`, or `null` for none. */
|
|
43
|
+
export function laneCountText(n) {
|
|
44
|
+
if (!isInt(n) || n < 1) return null
|
|
45
|
+
return n === 1 ? '1 lane' : `${n} in parallel`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The waves a plan summary holds, its test wave counted; `null` for anything that is not a summary. */
|
|
49
|
+
export function planWaveCount(plan) {
|
|
50
|
+
if (plan === null || typeof plan !== 'object' || !Array.isArray(plan.waves)) return null
|
|
51
|
+
return plan.waves.length + (plan.testWave ? 1 : 0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A step's words, from its own fields only. */
|
|
55
|
+
export function stepText(step) {
|
|
56
|
+
const s = step && typeof step === 'object' ? step : {}
|
|
57
|
+
const parts = [String(s.action)]
|
|
58
|
+
if (s.wave !== undefined && s.wave !== null) parts.push(`wave ${s.wave}`)
|
|
59
|
+
const batch = batchText(s.batchIndex, s.batchCount)
|
|
60
|
+
if (batch) parts.push(batch)
|
|
61
|
+
if (BATCH_ACTIONS.has(s.action)) {
|
|
62
|
+
const lanes = laneCountText(Array.isArray(s.spawns) ? s.spawns.length : 0)
|
|
63
|
+
if (lanes) parts.push(lanes)
|
|
64
|
+
}
|
|
65
|
+
if (s.action === 'verify' && s.skipped === true) parts.push(str(s.reason) ? `skipped: ${s.reason}` : 'skipped')
|
|
66
|
+
if (s.action === 'halt' && str(s.reason)) parts.push(s.reason)
|
|
67
|
+
if (isInt(s.planWaves)) parts.push(`plan: ${plural(s.planWaves, 'wave')}`)
|
|
68
|
+
return parts.join(' · ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The entries with a time laid by it, ties in input order; then the entries with none, in input order. */
|
|
72
|
+
function byTime(entries) {
|
|
73
|
+
const timed = entries.map((entry, i) => ({ entry, i })).filter(({ entry }) => isTime(entry.at))
|
|
74
|
+
timed.sort((a, b) => a.entry.at - b.entry.at || a.i - b.i)
|
|
75
|
+
return [...timed.map(({ entry }) => entry), ...entries.filter(entry => !isTime(entry.at))]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The timeline as ordered display entries, each `{ key, kind, at, depth, ... }`:
|
|
80
|
+
* `launch` (`name`), `step` (`step`), `spawn` (`step`, `spawn`, `agent` or
|
|
81
|
+
* `null` while it waits), `relay` (`agent`, a relay agent no step names),
|
|
82
|
+
* `unmatched-head`, and `unmatched` (`agent`). An agent's entry is keyed
|
|
83
|
+
* `agent-<id>`, a waiting spawn's `spawn-<n>-<label>`, a step's `step-<n>`.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* steps?: {n: number, at: number|null, action: string, wave?: unknown, waveIndex?: unknown, batchIndex?: unknown,
|
|
87
|
+
* batchCount?: unknown, skipped?: unknown, reason?: unknown, planWaves?: number|null, relayId?: string|null,
|
|
88
|
+
* spawns: {label: string, title: string, sha: string|null}[]}[],
|
|
89
|
+
* agents?: {id: string, sha?: string|null, relay?: string|null, firstAt?: number|null}[],
|
|
90
|
+
* launch?: {at: number|null, name?: string|null}|null
|
|
91
|
+
* }} input
|
|
92
|
+
* @returns {object[]}
|
|
93
|
+
*/
|
|
94
|
+
export function timeline({ steps = [], agents = [], launch = null } = {}) {
|
|
95
|
+
const relays = new Set(steps.map(step => str(step.relayId)).filter(Boolean))
|
|
96
|
+
|
|
97
|
+
// Each spawn carrying a hash, in step order, and the agents carrying it, first seen first.
|
|
98
|
+
const slots = new Map()
|
|
99
|
+
const spawnsOf = step => (Array.isArray(step.spawns) ? step.spawns : [])
|
|
100
|
+
steps.forEach((step, i) =>
|
|
101
|
+
spawnsOf(step).forEach((spawn, j) => {
|
|
102
|
+
const sha = str(spawn.sha)
|
|
103
|
+
if (!sha) return
|
|
104
|
+
if (!slots.has(sha)) slots.set(sha, [])
|
|
105
|
+
slots.get(sha).push({ pos: `${i}:${j}`, agents: [] })
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
const placed = new Set()
|
|
109
|
+
const counts = new Map()
|
|
110
|
+
for (const agent of agents) {
|
|
111
|
+
const sha = str(agent.sha)
|
|
112
|
+
if (!sha || relays.has(agent.id) || !slots.has(sha)) continue
|
|
113
|
+
const held = slots.get(sha)
|
|
114
|
+
const at = counts.get(sha) || 0
|
|
115
|
+
held[Math.min(at, held.length - 1)].agents.push(agent)
|
|
116
|
+
counts.set(sha, at + 1)
|
|
117
|
+
placed.add(agent.id)
|
|
118
|
+
}
|
|
119
|
+
const joinedBy = new Map()
|
|
120
|
+
for (const held of slots.values()) for (const slot of held) joinedBy.set(slot.pos, slot.agents)
|
|
121
|
+
|
|
122
|
+
const top = []
|
|
123
|
+
if (launch && typeof launch === 'object') {
|
|
124
|
+
top.push({ key: 'launch', kind: 'launch', at: isTime(launch.at) ? launch.at : null, depth: 0, name: str(launch.name) })
|
|
125
|
+
}
|
|
126
|
+
for (const step of steps) top.push({ key: `step-${step.n}`, kind: 'step', at: isTime(step.at) ? step.at : null, depth: 0, step })
|
|
127
|
+
const orphans = agents.filter(agent => str(agent.relay) && !relays.has(agent.id) && !placed.has(agent.id))
|
|
128
|
+
for (const agent of orphans) {
|
|
129
|
+
top.push({ key: `agent-${agent.id}`, kind: 'relay', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 0, agent })
|
|
130
|
+
placed.add(agent.id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out = []
|
|
134
|
+
for (const entry of byTime(top)) {
|
|
135
|
+
out.push(entry)
|
|
136
|
+
if (entry.kind !== 'step') continue
|
|
137
|
+
const i = steps.indexOf(entry.step)
|
|
138
|
+
const joined = []
|
|
139
|
+
const waiting = []
|
|
140
|
+
for (const [j, spawn] of spawnsOf(entry.step).entries()) {
|
|
141
|
+
const held = joinedBy.get(`${i}:${j}`) || []
|
|
142
|
+
if (!held.length) {
|
|
143
|
+
waiting.push({ key: `spawn-${entry.step.n}-${spawn.label}`, kind: 'spawn', at: null, depth: 1, step: entry.step, spawn, agent: null })
|
|
144
|
+
}
|
|
145
|
+
for (const agent of held) {
|
|
146
|
+
const at = isTime(agent.firstAt) ? agent.firstAt : null
|
|
147
|
+
joined.push({ key: `agent-${agent.id}`, kind: 'spawn', at, depth: 1, step: entry.step, spawn, agent })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out.push(...byTime(joined), ...waiting)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const unmatched = agents.filter(agent => !relays.has(agent.id) && !placed.has(agent.id))
|
|
154
|
+
if (unmatched.length) {
|
|
155
|
+
out.push({ key: 'agents-unmatched-head', kind: 'unmatched-head', at: null, depth: 0 })
|
|
156
|
+
for (const agent of unmatched) {
|
|
157
|
+
out.push({ key: `agent-${agent.id}`, kind: 'unmatched', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 1, agent })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|