@renzrollon/interlock 1.4.1 → 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 +6 -0
- package/hooks/mod.mjs +307 -48
- package/lib/doctor.mjs +7 -6
- 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,12 @@ 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
|
+
|
|
8
14
|
## 1.4.1 — 2026-10-09
|
|
9
15
|
|
|
10
16
|
### Changed
|
package/hooks/mod.mjs
CHANGED
|
@@ -75,9 +75,12 @@
|
|
|
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.
|
|
81
84
|
//
|
|
82
85
|
// THE STEP TIMELINE. The agents list's place is a timeline: one line per step
|
|
83
86
|
// as it crossed, the agent whose Bash call printed it folded into that line,
|
|
@@ -184,8 +187,8 @@ const LEDGER = { plugin: 'interlock', key: 'ledger' }
|
|
|
184
187
|
// a missing origin count nothing.
|
|
185
188
|
const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
|
|
186
189
|
const GUARD = 'interlock guard'
|
|
187
|
-
// The line `cli()` in workflows/ship.js hands the ping (design D2).
|
|
188
|
-
|
|
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.
|
|
189
192
|
// The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
|
|
190
193
|
const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
|
|
191
194
|
// What an agent row and a turn-end toast read for an agent no row and no driver line names.
|
|
@@ -202,6 +205,9 @@ const DIM = { dimColor: true }
|
|
|
202
205
|
// and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
|
|
203
206
|
const GUTTER_COLUMNS = 10
|
|
204
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
205
211
|
const NO_STEP_LINE = 'no step has crossed yet'
|
|
206
212
|
/** The props the run header and each section heading are drawn with. */
|
|
207
213
|
const HEADING_PROPS = { bold: true, color: PALETTE.accent }
|
|
@@ -275,7 +281,11 @@ function freshRun() {
|
|
|
275
281
|
dispatched: null,
|
|
276
282
|
halt: null,
|
|
277
283
|
planNamed: false,
|
|
278
|
-
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
|
|
279
289
|
}
|
|
280
290
|
}
|
|
281
291
|
|
|
@@ -408,10 +418,10 @@ async function stamp($) {
|
|
|
408
418
|
|
|
409
419
|
/**
|
|
410
420
|
* Starts the run's one interval (design D2), the host's own timer: each period
|
|
411
|
-
* reads the clock and recomputes the word,
|
|
412
|
-
*
|
|
413
|
-
* returns at once. A host that will not start one leaves
|
|
414
|
-
* 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.
|
|
415
425
|
*/
|
|
416
426
|
function startTick($) {
|
|
417
427
|
const generation = ++generations
|
|
@@ -422,7 +432,14 @@ function startTick($) {
|
|
|
422
432
|
if (!isMine(tick)) return
|
|
423
433
|
try {
|
|
424
434
|
const now = await clockNow($)
|
|
425
|
-
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($)
|
|
426
443
|
} catch (err) {
|
|
427
444
|
$.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
|
|
428
445
|
}
|
|
@@ -435,6 +452,28 @@ function startTick($) {
|
|
|
435
452
|
}
|
|
436
453
|
}
|
|
437
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
|
+
|
|
438
477
|
/** Ends the run's interval, if it has one: a cancelled timer never fires again. */
|
|
439
478
|
function stopTick() {
|
|
440
479
|
if (run.tick) run.tick.cancel()
|
|
@@ -697,6 +736,126 @@ function noteBriefing($, agentId, text) {
|
|
|
697
736
|
}
|
|
698
737
|
}
|
|
699
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
|
+
|
|
700
859
|
/**
|
|
701
860
|
* `run next` for `interlock run next --results … --json`: the subcommand a
|
|
702
861
|
* driver line relays, as `workflows/ship.js` names its relay (`argv.slice(0, 2)`),
|
|
@@ -809,18 +968,75 @@ function noteIdleStep($, r) {
|
|
|
809
968
|
}
|
|
810
969
|
}
|
|
811
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
|
+
|
|
812
1027
|
/** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
|
|
813
1028
|
function readRecord(text) {
|
|
814
1029
|
if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
|
|
815
|
-
|
|
1030
|
+
const trimmed = text.trim()
|
|
816
1031
|
try {
|
|
817
|
-
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' }
|
|
818
1036
|
} catch {
|
|
819
|
-
|
|
1037
|
+
const found = findStep(trimmed)
|
|
1038
|
+
return found ? { record: found } : { problem: 'not JSON' }
|
|
820
1039
|
}
|
|
821
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
|
|
822
|
-
if (typeof value.action !== 'string') return { problem: 'no action' }
|
|
823
|
-
return { record: value }
|
|
824
1040
|
}
|
|
825
1041
|
|
|
826
1042
|
function agentOf(id) {
|
|
@@ -1017,6 +1233,7 @@ async function applyRecord($, record, relayId) {
|
|
|
1017
1233
|
}
|
|
1018
1234
|
setStatus($)
|
|
1019
1235
|
$.ui.invalidate('ui.render')
|
|
1236
|
+
await followBottom($)
|
|
1020
1237
|
}
|
|
1021
1238
|
|
|
1022
1239
|
async function openPane($, id = PANE, title = TITLE) {
|
|
@@ -1199,6 +1416,36 @@ const turnText = a =>
|
|
|
1199
1416
|
/** Whether the host ended the agent's turn without an answer. */
|
|
1200
1417
|
const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
|
|
1201
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()}`
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1202
1449
|
/**
|
|
1203
1450
|
* An agent's first line, one wrapping text: its name, its id and models dim,
|
|
1204
1451
|
* then its turn word. A joined agent is named in the identity colour, a relay
|
|
@@ -1209,14 +1456,17 @@ const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
|
|
|
1209
1456
|
function agentLine(Text, a, name, kind) {
|
|
1210
1457
|
const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
|
|
1211
1458
|
const quiet = kind === 'relay' && !abnormal(a)
|
|
1212
|
-
|
|
1213
|
-
Text,
|
|
1214
|
-
{ wrap: 'wrap' },
|
|
1459
|
+
const spans = [
|
|
1215
1460
|
h(Text, nameProps, name),
|
|
1216
1461
|
h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1217
1462
|
' · ',
|
|
1218
1463
|
h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
|
|
1219
|
-
|
|
1464
|
+
]
|
|
1465
|
+
if (!a.reason) {
|
|
1466
|
+
const mark = markSuffix()
|
|
1467
|
+
if (mark) spans.push(mark)
|
|
1468
|
+
}
|
|
1469
|
+
return h(Text, { wrap: 'wrap' }, ...spans)
|
|
1220
1470
|
}
|
|
1221
1471
|
|
|
1222
1472
|
/**
|
|
@@ -1228,7 +1478,7 @@ function agentLine(Text, a, name, kind) {
|
|
|
1228
1478
|
function gutterRow(Box, Text, key, at, depth, ...content) {
|
|
1229
1479
|
return h(
|
|
1230
1480
|
Box,
|
|
1231
|
-
{ key, flexDirection: 'row' },
|
|
1481
|
+
{ key, flexDirection: 'row', flexShrink: 0 },
|
|
1232
1482
|
h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
|
|
1233
1483
|
h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
|
|
1234
1484
|
)
|
|
@@ -1273,15 +1523,18 @@ function drawTimeline($, e) {
|
|
|
1273
1523
|
else if (entry.kind === 'spawn' && entry.agent) {
|
|
1274
1524
|
out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
|
|
1275
1525
|
} else if (entry.kind === 'spawn') {
|
|
1276
|
-
|
|
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)))
|
|
1277
1530
|
} else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
|
|
1278
|
-
else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key }, h(Text, DIM, 'unmatched')))
|
|
1531
|
+
else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key, flexShrink: 0 }, h(Text, DIM, 'unmatched')))
|
|
1279
1532
|
else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
|
|
1280
1533
|
}
|
|
1281
1534
|
if (entries.some(entry => entry.kind === 'unmatched')) {
|
|
1282
|
-
out.push(h(Box, { key: 'agents-unmatched', paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
|
|
1535
|
+
out.push(h(Box, { key: 'agents-unmatched', flexShrink: 0, paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
|
|
1283
1536
|
}
|
|
1284
|
-
if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
|
|
1537
|
+
if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step', flexShrink: 0 }, h(Text, DIM, NO_STEP_LINE)))
|
|
1285
1538
|
return out
|
|
1286
1539
|
}
|
|
1287
1540
|
|
|
@@ -1291,14 +1544,15 @@ function flatRow($, e, r) {
|
|
|
1291
1544
|
const a = agentForSha(r.sha)
|
|
1292
1545
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1293
1546
|
const state = a ? a.reason || 'running' : 'waiting'
|
|
1547
|
+
const live = state === 'running' || state === 'waiting' ? `${state}${markSuffix()}` : state
|
|
1294
1548
|
return h(
|
|
1295
1549
|
Box,
|
|
1296
|
-
{ key: `wave-row-${r.label}
|
|
1550
|
+
{ key: `wave-row-${r.label}`, flexShrink: 0 },
|
|
1297
1551
|
h(
|
|
1298
1552
|
Text,
|
|
1299
1553
|
null,
|
|
1300
1554
|
h(Text, NAME_PROPS, r.title),
|
|
1301
|
-
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${
|
|
1555
|
+
` · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${live}`
|
|
1302
1556
|
)
|
|
1303
1557
|
)
|
|
1304
1558
|
}
|
|
@@ -1400,7 +1654,7 @@ function drawLane($, e, row) {
|
|
|
1400
1654
|
const { Box, Text } = $.ui.resolve(e)
|
|
1401
1655
|
const key = boardKey(row.key)
|
|
1402
1656
|
const parts = row.parts
|
|
1403
|
-
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))
|
|
1404
1658
|
const line = [
|
|
1405
1659
|
...headSpans(Text, parts),
|
|
1406
1660
|
...(parts.state ? [' ', h(Text, stateProps(parts.state), parts.state)] : []),
|
|
@@ -1409,8 +1663,8 @@ function drawLane($, e, row) {
|
|
|
1409
1663
|
]
|
|
1410
1664
|
const kids = [
|
|
1411
1665
|
h(Text, { wrap: 'wrap' }, ...line),
|
|
1412
|
-
parts.gist ? h(Box, { key: `${key}-gist
|
|
1413
|
-
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
|
|
1414
1668
|
].filter(Boolean)
|
|
1415
1669
|
return gutterRow(Box, Text, key, null, 1, ...kids)
|
|
1416
1670
|
}
|
|
@@ -1443,7 +1697,7 @@ function drawBatches($, e, index, lanes) {
|
|
|
1443
1697
|
|
|
1444
1698
|
function drawBoardCards($, e, board, width) {
|
|
1445
1699
|
const { Box, Text } = $.ui.resolve(e)
|
|
1446
|
-
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))
|
|
1447
1701
|
const out = []
|
|
1448
1702
|
let index = null
|
|
1449
1703
|
let kids = null
|
|
@@ -1459,6 +1713,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1459
1713
|
{
|
|
1460
1714
|
key: `wave-card-${index}`,
|
|
1461
1715
|
flexDirection: 'column',
|
|
1716
|
+
flexShrink: 0,
|
|
1462
1717
|
borderStyle: 'single',
|
|
1463
1718
|
...(current ? { borderColor: PALETTE.current } : { borderDimColor: true }),
|
|
1464
1719
|
width,
|
|
@@ -1479,7 +1734,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1479
1734
|
if (row.key.startsWith('wave:')) {
|
|
1480
1735
|
flush()
|
|
1481
1736
|
index = row.key.slice('wave:'.length)
|
|
1482
|
-
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)))]
|
|
1483
1738
|
lanes = []
|
|
1484
1739
|
continue
|
|
1485
1740
|
}
|
|
@@ -1508,11 +1763,11 @@ function drawBoardCards($, e, board, width) {
|
|
|
1508
1763
|
*/
|
|
1509
1764
|
function drawWaves($, e) {
|
|
1510
1765
|
const { Box, Text } = $.ui.resolve(e)
|
|
1511
|
-
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))
|
|
1512
1767
|
const line = (key, text) => keyed(key, text, { wrap: 'truncate-end' })
|
|
1513
1768
|
const rows = [...run.rows.values()]
|
|
1514
1769
|
if (!run.plan) {
|
|
1515
|
-
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()}`))]
|
|
1516
1771
|
const dispatched = run.dispatched !== null || rows.some(r => r.batch)
|
|
1517
1772
|
return [...(dispatched ? [keyed('wave-board-unrelayed', UNRELAYED_LINE)] : []), ...rows.map(r => flatRow($, e, r))]
|
|
1518
1773
|
}
|
|
@@ -1534,10 +1789,12 @@ function drawWaves($, e) {
|
|
|
1534
1789
|
|
|
1535
1790
|
async function drawPane($, e) {
|
|
1536
1791
|
const { Box, Text } = $.ui.resolve(e)
|
|
1537
|
-
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1538
|
-
const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1539
|
-
// Each section's body sits one indent in beneath its heading.
|
|
1540
|
-
|
|
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)
|
|
1541
1798
|
// The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
|
|
1542
1799
|
const guardLine = async () => {
|
|
1543
1800
|
const guard = await launchGuardText($)
|
|
@@ -1545,21 +1802,21 @@ async function drawPane($, e) {
|
|
|
1545
1802
|
}
|
|
1546
1803
|
if (run.phase === 'idle' && !run.summary) {
|
|
1547
1804
|
// The guard runs in every session, so its line stands here too (design D5).
|
|
1548
|
-
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())
|
|
1549
1806
|
}
|
|
1550
1807
|
|
|
1551
1808
|
const out = []
|
|
1552
|
-
out.push(h(Text, HEADING_PROPS, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
|
|
1553
|
-
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()}`))
|
|
1554
1811
|
const activity = await lastActivityText($)
|
|
1555
1812
|
out.push(
|
|
1556
1813
|
h(
|
|
1557
1814
|
Box,
|
|
1558
|
-
{ key: 'last-activity' },
|
|
1815
|
+
{ key: 'last-activity', flexShrink: 0 },
|
|
1559
1816
|
h(Text, null, activity.line, ...(activity.word ? [' · ', h(Text, { color: PALETTE.warn }, activity.word)] : []))
|
|
1560
1817
|
)
|
|
1561
1818
|
)
|
|
1562
|
-
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1819
|
+
out.push(h(Box, { flexShrink: 0 }, h(Text, { dimColor: true }, RECORD_LINE)))
|
|
1563
1820
|
|
|
1564
1821
|
out.push(heading('waves'))
|
|
1565
1822
|
out.push(body('waves-body', ...drawWaves($, e)))
|
|
@@ -1609,7 +1866,7 @@ async function drawPane($, e) {
|
|
|
1609
1866
|
out.push(heading('close summary'))
|
|
1610
1867
|
out.push(body('summary', h(Text, null, run.summary)))
|
|
1611
1868
|
}
|
|
1612
|
-
return h(Box, { flexDirection: 'column' }, ...out)
|
|
1869
|
+
return h(Box, { key: 'meter-root', flexDirection: 'column', flexShrink: 0 }, ...out)
|
|
1613
1870
|
}
|
|
1614
1871
|
|
|
1615
1872
|
export function register(on) {
|
|
@@ -1744,13 +2001,15 @@ export function register(on) {
|
|
|
1744
2001
|
const placed = await openPane($)
|
|
1745
2002
|
if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
|
|
1746
2003
|
if (run === launched) startTick($)
|
|
2004
|
+
if (run === launched) await followBottom($)
|
|
1747
2005
|
return r
|
|
1748
2006
|
})
|
|
1749
2007
|
|
|
1750
2008
|
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
1751
2009
|
if (!session.interactive) return next(e)
|
|
1752
2010
|
const command = typeof e.command === 'string' ? e.command : ''
|
|
1753
|
-
const
|
|
2011
|
+
const invocation = driverInvocation(command)
|
|
2012
|
+
const driver = invocation !== null
|
|
1754
2013
|
// The spec flow's keyed lines, read only while a spec run is live (observe-the-spec-run-live design D3).
|
|
1755
2014
|
const lines = specLines(command)
|
|
1756
2015
|
const reader = lines.length ? specReader($, lines) : null
|
|
@@ -1766,7 +2025,7 @@ export function register(on) {
|
|
|
1766
2025
|
// briefing it reads joins it to its lane, and a driver line names a relay.
|
|
1767
2026
|
if (run.phase === 'live' && str(e.agentId)) {
|
|
1768
2027
|
noteBriefing($, e.agentId, command)
|
|
1769
|
-
if (driver) noteRelay($, e.agentId,
|
|
2028
|
+
if (driver) noteRelay($, e.agentId, invocation)
|
|
1770
2029
|
}
|
|
1771
2030
|
const r = await next(e)
|
|
1772
2031
|
// Any Bash call during the run is read for a guard's refusal (guard-commit); only a driver line for a step.
|
|
@@ -1774,7 +2033,7 @@ export function register(on) {
|
|
|
1774
2033
|
if (driver) {
|
|
1775
2034
|
try {
|
|
1776
2035
|
const { record, problem } = readRecord(r.text)
|
|
1777
|
-
if (!record) $.ui.log(`interlock meter: ${
|
|
2036
|
+
if (!record) $.ui.log(`interlock meter: ${invocation.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
|
|
1778
2037
|
else if (held === run) await applyRecord($, record, str(e.agentId))
|
|
1779
2038
|
else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
|
|
1780
2039
|
} catch (err) {
|
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/`, {
|