@renzrollon/interlock 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +135 -1
  4. package/bin/interlock +409 -49
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +149 -41
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +80 -11
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1177 -84
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +53 -16
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. package/workflows/ship.js +75 -51
package/hooks/mod.mjs ADDED
@@ -0,0 +1,655 @@
1
+ // The ship meter: a hooks module that draws a ship run while it runs, and the
2
+ // in-process form of the launch guard.
3
+ //
4
+ // Every hook here observes but one branch. The CLI decides; this module shows
5
+ // what the CLI already said, the moment it says it: the step each `interlock`
6
+ // call printed (read off the ping's Bash result), the per-agent figures each
7
+ // model request reported, and the plan windows the session reports. It joins
8
+ // nothing into any record, and recognises no banner by its wording: it shows
9
+ // the `banners` list the CLI emitted. The close summary and the receipt are
10
+ // the record; the pane says so.
11
+ //
12
+ // THE ONE BRANCH THAT DECIDES (guard-ship-relaunch-in-process design D3). The
13
+ // Workflow hook refuses a ship launch when the session recorded one newer than
14
+ // its last human prompt: the settings hook `hooks/guard-relaunch.mjs` reads the
15
+ // same rule over a file, and this reads it over the engine's own prompt origin
16
+ // and session state, before the engine or the settings layer sees the call.
17
+ // The verdict and its words are `lib/launch-rule.mjs`'s, never this file's;
18
+ // `test/spine/mod-pins.test.mjs` pins that the refusal is that verdict, once.
19
+ // A guard that cannot read its facts allows, says so on the debug log, and
20
+ // leaves the settings form to decide.
21
+ //
22
+ // It runs in the engine's own environment, with no Node and `$` as its only
23
+ // way out, so it imports nothing from the plugin that reaches `node:`:
24
+ // `lib/launch-rule.mjs` is Node-free by design, and the briefing-hash
25
+ // expression is restated below rather than imported, because
26
+ // `lib/agent-usage.mjs` imports `node:fs` (design D4, D5). It imports nothing
27
+ // from `claude-code` either: Claude Code 2.1.274 refuses a module that passes
28
+ // `$` into a function imported from there, so the state is read and written
29
+ // with `$.state` directly. What it may call and must never contain is pinned
30
+ // by `test/spine/mod-pins.test.mjs` (design D8); its behaviour by
31
+ // `test/mod/meter.test.ts` and `test/mod/relaunch.test.ts` under
32
+ // `claude plugin test`.
33
+ //
34
+ // The meter's state is module-level (design D3): a reload starts it over, a run
35
+ // launched before the reload is not drawn again, and the first of its steps to
36
+ // cross the module is named once on the debug log. The guard's record is the session's (`$.state`, declared in
37
+ // types/index.d.ts): it survives a reload, and /clear, /resume and /branch
38
+ // empty it, which allows the next launch. The meter's run ends on that same
39
+ // boundary, the classic session-start event with a `clear`, `resume` or `fork`
40
+ // source, so the two things the module keeps are reset together
41
+ // (show-quiet-time-and-reset-the-meter-on-clear design D4).
42
+ //
43
+ // THE QUIET FIGURE (same change, design D1-D3, D5). While a run is live the
44
+ // module stamps the engine's time of each thing the run does, and one interval
45
+ // the host runs for it recomputes how long the run has been quiet. Once that
46
+ // reaches the published threshold, `quiet <n> min` follows the position on the
47
+ // status line, the spinner and the pane. The word names no cause, and the
48
+ // threshold and the period are `lib/meter-quiet.mjs`'s, read from
49
+ // `interlock limits`; this file holds neither number.
50
+
51
+ import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch, withPrompt } from '../lib/launch-rule.mjs'
52
+ import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
53
+
54
+ const PANE = 'interlock-meter'
55
+ const TITLE = 'Interlock'
56
+ const RECORD_LINE = 'live figures; the close summary and the receipt are the record'
57
+ const NO_RUN_LINE = 'no ship run is live in this session'
58
+ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
59
+
60
+ // The launch guard's record in the session's state: the one key the plugin's
61
+ // type contract declares (types/index.d.ts; guard-ship-relaunch-in-process D2).
62
+ const LEDGER = { plugin: 'interlock', key: 'ledger' }
63
+ // The prompt origins a person stands behind: their own Enter, the Remote
64
+ // Control bridge, the SDK host's turn (design D3). The completion wake, a
65
+ // schedule, a peer, a channel, a coordinator, an observer, a plugin's own and
66
+ // a missing origin count nothing.
67
+ const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
68
+ const GUARD = 'interlock guard'
69
+ // The line `cli()` in workflows/ship.js hands the ping (design D2).
70
+ const DRIVER_LINE = /^interlock\s/
71
+ // The bootstrap line a lane's spawn prompt carries (as `lib/agent-usage.mjs` reads it).
72
+ const BRIEFING_SHA = /Expected sha256: ([0-9a-f]{64})/g
73
+ const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
74
+ // The classic session-start sources that start the session over, as the person
75
+ // typed them (design D4). `startup` and `compact` are not among them.
76
+ const BOUNDARIES = new Map([
77
+ ['clear', '/clear'],
78
+ ['resume', '/resume'],
79
+ ['fork', '/branch']
80
+ ])
81
+
82
+ let session = { interactive: false, surface: null }
83
+ let run = freshRun()
84
+ // Counts every interval this environment started, so a period can tell its own (design D2).
85
+ let generations = 0
86
+
87
+ function freshRun() {
88
+ return {
89
+ // idle → live (accepted launch) → closing (a close or halt step) → closed (the close record)
90
+ phase: 'idle',
91
+ runId: null,
92
+ workflowName: null,
93
+ transcriptDir: null,
94
+ change: null,
95
+ action: null,
96
+ wave: undefined,
97
+ batchIndex: undefined,
98
+ batchCount: undefined,
99
+ rows: new Map(),
100
+ banners: [],
101
+ toasted: new Set(),
102
+ agents: new Map(),
103
+ summary: null,
104
+ exitCode: null,
105
+ // The quiet figure (design D1, D2): the engine's time of the run's last
106
+ // activity (null until the clock answers), the word it makes, and the one
107
+ // interval that recomputes it, known by its handle and its generation.
108
+ lastActivityAt: null,
109
+ quietWord: null,
110
+ tick: null,
111
+ generation: 0,
112
+ clockFailed: false,
113
+ // Whether a step that crossed with no live run has been named on the debug log.
114
+ idleStepNamed: false
115
+ }
116
+ }
117
+
118
+ const watching = () => session.interactive && (run.phase === 'live' || run.phase === 'closing')
119
+ const isInt = v => Number.isInteger(v)
120
+ const str = v => (typeof v === 'string' && v ? v : null)
121
+
122
+ const messageOf = err => (err && err.message) || String(err)
123
+ const asRecord = value => (value && typeof value === 'object' && Array.isArray(value.launches) ? value : emptyRecord())
124
+
125
+ /**
126
+ * What the launch guard decides over: the session's record and the time now. A
127
+ * missing or malformed record is empty; one that cannot be read at all is
128
+ * empty too, and named on the debug log. An empty record allows (design D5).
129
+ */
130
+ async function guardFacts($) {
131
+ try {
132
+ const held = await $.state.get(LEDGER)
133
+ return { record: asRecord(held.value), now: await $.clock.now() }
134
+ } catch (err) {
135
+ $.ui.log(`${GUARD}: cannot establish this session's launches, allowing: ${messageOf(err)}`, { to: 'debug' })
136
+ // No time, not a made-up one: the empty record allows before the rule reads
137
+ // it, and the meter stamps no launch time it does not have (design D1).
138
+ return { record: emptyRecord(), now: null }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Change the session's record with `change(record, at)`, `at` the time now as
144
+ * ISO. Written only over the version it read and read again when another write
145
+ * landed first, as the engine's `update` does. A write that fails is named on
146
+ * the debug log and never thrown: it runs after the call it records did.
147
+ */
148
+ async function changeRecord($, what, change) {
149
+ try {
150
+ const at = new Date(await $.clock.now()).toISOString()
151
+ let held = await $.state.get(LEDGER)
152
+ for (;;) {
153
+ const written = await $.state.set(LEDGER, change(asRecord(held.value), at), { ifVersion: held.version })
154
+ if (written.isSet) return
155
+ const again = await $.state.get(LEDGER)
156
+ if (again.version === held.version) throw new Error(`the write missed at version ${held.version} with no other write`)
157
+ held = again
158
+ }
159
+ } catch (err) {
160
+ $.ui.log(`${GUARD}: ${what} not recorded: ${messageOf(err)}`, { to: 'debug' })
161
+ }
162
+ }
163
+
164
+ /** `<change> · <action> · wave <w> · batch <j>/<k>`, each position omitted when the step carries none (design D6). */
165
+ function position() {
166
+ const parts = []
167
+ if (run.change) parts.push(run.change)
168
+ if (run.action) parts.push(run.action)
169
+ if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
170
+ if (isInt(run.batchIndex) && isInt(run.batchCount)) parts.push(`batch ${run.batchIndex + 1}/${run.batchCount}`)
171
+ return parts.join(' · ')
172
+ }
173
+
174
+ /** The position, then the quiet word while it is set: what the status line and the spinner say (design D5). */
175
+ const shown = () => [position(), run.quietWord].filter(Boolean).join(' · ')
176
+
177
+ function statusText() {
178
+ const text = shown()
179
+ return text ? `interlock: ${text}` : undefined
180
+ }
181
+
182
+ /**
183
+ * The engine's time now, or `null` when the clock cannot be read. A read that
184
+ * fails is named once per run on the debug log and never thrown (design D1).
185
+ */
186
+ async function clockNow($) {
187
+ const held = run
188
+ try {
189
+ const now = await $.clock.now()
190
+ if (Number.isFinite(now)) return now
191
+ throw new Error(`the clock answered ${String(now)}`)
192
+ } catch (err) {
193
+ if (!held.clockFailed) {
194
+ held.clockFailed = true
195
+ $.ui.log(
196
+ `interlock meter: the clock cannot be read; the last activity time stands and no quiet figure is shown: ${messageOf(err)}`,
197
+ { to: 'debug' }
198
+ )
199
+ }
200
+ return null
201
+ }
202
+ }
203
+
204
+ /** Sets the quiet word; true when it changed. */
205
+ function setWord(word) {
206
+ if (run.quietWord === word) return false
207
+ run.quietWord = word
208
+ return true
209
+ }
210
+
211
+ /** Draws the word where it shows: the status line while the run is live, the pane and the spinner. */
212
+ function redrawWord($) {
213
+ if (run.phase === 'live') $.ui.status(statusText())
214
+ $.ui.invalidate('ui.render')
215
+ }
216
+
217
+ /**
218
+ * Activity (design D1): the run's last activity is now, and the quiet word is
219
+ * gone. A clock that cannot be read leaves the stamp as it was and the word
220
+ * absent, never a guessed time. True when the word changed, so the caller redraws.
221
+ */
222
+ async function stamp($) {
223
+ const held = run
224
+ const now = await clockNow($)
225
+ if (held !== run) return false
226
+ if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
227
+ return setWord(null)
228
+ }
229
+
230
+ /**
231
+ * Starts the run's one interval (design D2), the host's own timer: each period
232
+ * reads the clock and recomputes the word, and draws only when the word
233
+ * changed. A period that finds another run or another interval in its place
234
+ * returns at once. A host that will not start one leaves the pane, which
235
+ * recomputes the word whenever it is drawn.
236
+ */
237
+ function startTick($) {
238
+ const generation = ++generations
239
+ run.generation = generation
240
+ const isMine = tick => run.generation === generation && run.tick === tick
241
+ try {
242
+ const tick = $.clock.every(TICK_MS, async () => {
243
+ if (!isMine(tick)) return
244
+ try {
245
+ const now = await clockNow($)
246
+ if (isMine(tick) && setWord(quietWord(quietMs(run.lastActivityAt, now)))) redrawWord($)
247
+ } catch (err) {
248
+ $.ui.log(`interlock meter: a quiet-figure period failed: ${messageOf(err)}`, { to: 'debug' })
249
+ }
250
+ })
251
+ run.tick = tick
252
+ } catch (err) {
253
+ $.ui.log(`interlock meter: no interval for the quiet figure; the pane computes it when drawn: ${messageOf(err)}`, {
254
+ to: 'debug'
255
+ })
256
+ }
257
+ }
258
+
259
+ /** Ends the run's interval, if it has one: a cancelled timer never fires again. */
260
+ function stopTick() {
261
+ if (run.tick) run.tick.cancel()
262
+ run.tick = null
263
+ run.quietWord = null
264
+ }
265
+
266
+ /** Names, once per run record, a step that crossed with no live run (design D4). */
267
+ function nameIdleStep($) {
268
+ run.idleStepNamed = true
269
+ $.ui.log(
270
+ 'interlock meter: a step crossed with no live run and is not drawn ' +
271
+ "(a run launched before a /clear, /resume or /branch, or before this module reloaded, is no longer this session's)",
272
+ { to: 'debug' }
273
+ )
274
+ }
275
+
276
+ /** A driver-line call with no live run: passed on whole, its result read only to name the first step (design D4). */
277
+ async function idleStep($, e, next) {
278
+ if (run.phase !== 'idle' || run.idleStepNamed) return next(e)
279
+ const r = await next(e)
280
+ try {
281
+ if (readRecord(r.text).record && run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
282
+ } catch {
283
+ // Reading the result is all this does; the call and its result are the engine's.
284
+ }
285
+ return r
286
+ }
287
+
288
+ /** The step record in a Bash result's text, guarded as the driver guards it: an object with a string `action`. */
289
+ function readRecord(text) {
290
+ if (typeof text !== 'string' || !text.trim()) return { problem: 'no result text' }
291
+ let value
292
+ try {
293
+ value = JSON.parse(text)
294
+ } catch {
295
+ return { problem: 'not JSON' }
296
+ }
297
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
298
+ if (typeof value.action !== 'string') return { problem: 'no action' }
299
+ return { record: value }
300
+ }
301
+
302
+ function agentOf(id) {
303
+ let a = run.agents.get(id)
304
+ if (!a) {
305
+ a = { id, requests: 0, known: 0, partial: false, sums: null, models: [], reason: null, durationMs: null, sha: null }
306
+ run.agents.set(id, a)
307
+ }
308
+ return a
309
+ }
310
+
311
+ /** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
312
+ function addUsage(a, usage) {
313
+ a.requests++
314
+ if (!usage || typeof usage !== 'object') {
315
+ a.partial = true
316
+ return
317
+ }
318
+ a.known++
319
+ if (!a.sums) a.sums = Object.fromEntries(TOKEN_FIELDS.map(f => [f, 0]))
320
+ for (const f of TOKEN_FIELDS) {
321
+ a.sums[f] = a.sums[f] === null || typeof usage[f] !== 'number' ? null : a.sums[f] + usage[f]
322
+ }
323
+ if (str(usage.model) && !a.models.includes(usage.model)) a.models.push(usage.model)
324
+ }
325
+
326
+ async function applyRecord($, record) {
327
+ const held = run
328
+ await stamp($)
329
+ if (held !== run) return
330
+ const isClose = record.then === null && typeof record.summary === 'string' && typeof record.exitCode === 'number'
331
+ if (str(record.change)) run.change = record.change
332
+ run.action = record.action
333
+ if (record.wave !== undefined) run.wave = record.wave
334
+ run.batchIndex = record.batchIndex
335
+ run.batchCount = record.batchCount
336
+ for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
337
+ if (!s || typeof s !== 'object' || !str(s.label)) continue
338
+ run.rows.set(s.label, {
339
+ label: s.label,
340
+ title: str(s.title) || s.label,
341
+ kind: str(s.kind),
342
+ model: str(s.model),
343
+ effort: str(s.effort),
344
+ sha: str(s.promptSha256)
345
+ })
346
+ }
347
+ for (const banner of Array.isArray(record.banners) ? record.banners : []) {
348
+ if (typeof banner !== 'string' || !banner) continue
349
+ if (!run.banners.includes(banner)) run.banners.push(banner)
350
+ if (!run.toasted.has(banner)) {
351
+ run.toasted.add(banner)
352
+ $.ui.toast(banner)
353
+ }
354
+ }
355
+ if (typeof record.summary === 'string' && (isClose || record.action === 'close')) run.summary = record.summary
356
+ if (isClose) {
357
+ run.exitCode = record.exitCode
358
+ run.phase = 'closed'
359
+ stopTick()
360
+ $.ui.status(undefined)
361
+ } else if (record.action === 'close' || record.action === 'halt') {
362
+ run.phase = 'closing'
363
+ stopTick()
364
+ $.ui.status(undefined)
365
+ } else {
366
+ $.ui.status(statusText())
367
+ }
368
+ $.ui.invalidate('ui.render')
369
+ }
370
+
371
+ async function openPane($) {
372
+ return $.ui.open({ id: PANE, title: TITLE, closeOnEscape: true })
373
+ }
374
+
375
+ function tokensText(a) {
376
+ if (a.known === 0) return a.requests ? 'tokens unknown (partial)' : 'no requests yet'
377
+ const f = n => (n === null ? '?' : String(n))
378
+ const s = a.sums
379
+ return (
380
+ `in ${f(s.input_tokens)} · out ${f(s.output_tokens)} · cache read ${f(s.cache_read_input_tokens)} · ` +
381
+ `cache write ${f(s.cache_creation_input_tokens)}${a.partial ? ' (partial)' : ''}`
382
+ )
383
+ }
384
+
385
+ function agentForSha(sha) {
386
+ if (!sha) return null
387
+ for (const a of run.agents.values()) if (a.sha === sha) return a
388
+ return null
389
+ }
390
+
391
+ function laneForAgent(a) {
392
+ if (!a.sha) return null
393
+ for (const r of run.rows.values()) if (r.sha === a.sha) return r.label
394
+ return null
395
+ }
396
+
397
+ /**
398
+ * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
399
+ * live run is quiet. The word is recomputed from the clock at each draw, so a
400
+ * pane opened after the host stopped the interval still shows it (design D2, D5).
401
+ */
402
+ async function lastActivityText($) {
403
+ if (run.lastActivityAt === null) return 'last activity unknown'
404
+ const at = new Date(run.lastActivityAt).toISOString()
405
+ const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
406
+ return `last activity ${at}${word ? ` · ${word}` : ''}`
407
+ }
408
+
409
+ async function drawPane($, e) {
410
+ const { Box, Text } = $.ui.resolve(e)
411
+ const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
412
+ if (run.phase === 'idle' && !run.summary) {
413
+ return h(Box, { flexDirection: 'column' }, h(Text, { dimColor: true }, NO_RUN_LINE))
414
+ }
415
+
416
+ const out = []
417
+ out.push(h(Text, { bold: true }, `${run.change || 'ship run'}${run.runId ? ` · run ${run.runId}` : ''}`))
418
+ out.push(keyed('action', `action: ${run.action || 'starting'}${run.phase === 'closed' ? ' (closed)' : ''}`))
419
+ out.push(keyed('last-activity', await lastActivityText($)))
420
+ out.push(h(Text, { dimColor: true }, RECORD_LINE))
421
+
422
+ out.push(h(Text, { bold: true }, 'waves'))
423
+ if (!run.rows.size) out.push(h(Text, { dimColor: true }, 'no lanes dispatched yet'))
424
+ for (const r of run.rows.values()) {
425
+ const a = agentForSha(r.sha)
426
+ const served = a && a.models.length ? a.models.join(', ') : '?'
427
+ const state = a ? a.reason || 'running' : 'waiting'
428
+ out.push(
429
+ keyed(
430
+ `wave-row-${r.label}`,
431
+ `${r.title} · ${r.kind || 'agent'} · routed ${r.model || '?'} · served ${served} · effort ${r.effort || '?'} · ${state}`
432
+ )
433
+ )
434
+ }
435
+
436
+ out.push(h(Text, { bold: true }, 'agents'))
437
+ if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
438
+ for (const a of run.agents.values()) {
439
+ const lane = laneForAgent(a)
440
+ const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
441
+ const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
442
+ out.push(
443
+ keyed(
444
+ `agent-${a.id}`,
445
+ `${lane || 'lane unknown'} · ${a.id} · ${a.models.join(', ') || 'model not reported'} · ${n} · ${tokensText(a)} · ${done}`
446
+ )
447
+ )
448
+ }
449
+
450
+ out.push(h(Text, { bold: true }, 'plan windows'))
451
+ let windows = []
452
+ try {
453
+ const usage = await $.session.usage()
454
+ windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
455
+ } catch {
456
+ windows = []
457
+ }
458
+ if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
459
+ for (const w of windows) {
460
+ if (!w || typeof w !== 'object') continue
461
+ const kind = String(w.kind)
462
+ out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
463
+ }
464
+
465
+ out.push(h(Text, { bold: true }, 'banners'))
466
+ out.push(
467
+ h(
468
+ Box,
469
+ { key: 'banners', flexDirection: 'column' },
470
+ ...(run.banners.length ? run.banners.map(b => h(Text, null, b)) : [h(Text, { dimColor: true }, 'none so far')])
471
+ )
472
+ )
473
+
474
+ if (run.summary) {
475
+ out.push(h(Text, { bold: true }, 'close summary'))
476
+ out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
477
+ }
478
+ return h(Box, { flexDirection: 'column' }, ...out)
479
+ }
480
+
481
+ export function register(on) {
482
+ on('session.start', async ($, e, next) => {
483
+ session = { interactive: e.isInteractive === true, surface: e.surface || null }
484
+ stopTick()
485
+ run = freshRun()
486
+ try {
487
+ await $.command.register({
488
+ name: PANE,
489
+ description: 'Open the Interlock ship meter: the live run, its waves, agents, plan windows and banners',
490
+ immediate: true
491
+ })
492
+ } catch (err) {
493
+ $.ui.log(`interlock meter: /interlock-meter not registered: ${(err && err.message) || err}`, { to: 'debug' })
494
+ }
495
+ let version = 'unknown'
496
+ try {
497
+ version = (await $.session.version()).version || 'unknown'
498
+ } catch {
499
+ version = 'unknown'
500
+ }
501
+ if (session.interactive) {
502
+ $.ui.log(`interlock meter: engine ${version}, drawing on ${session.surface || 'an unnamed surface'}`, { to: 'transcript' })
503
+ } else {
504
+ $.ui.log(`interlock meter: engine ${version}, nothing draws here`, { to: 'debug' })
505
+ }
506
+ return next(e)
507
+ })
508
+
509
+ // A human prompt re-arms the next ship launch; nothing else does (design D3).
510
+ // The prompt is passed on as it came: never rewritten, never dropped.
511
+ on('prompt.submit', async ($, e, next) => {
512
+ const kind = e.origin && typeof e.origin.kind === 'string' ? e.origin.kind : 'unstamped'
513
+ if (HUMAN_ORIGINS.has(kind)) await changeRecord($, 'human prompt', withPrompt)
514
+ else $.ui.log(`${GUARD}: a ${kind} prompt is not a human prompt; not counted`, { to: 'debug' })
515
+ return next(e)
516
+ })
517
+
518
+ // A relayed task notification, named and passed on, never consumed. The
519
+ // completion wake of a run does not come this way (it is a prompt above).
520
+ on('session.receive', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
521
+ $.ui.log(`${GUARD}: a task-notification delivery is not a human prompt; passed on, not counted`, { to: 'debug' })
522
+ return next(e)
523
+ })
524
+
525
+ on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
526
+ // The launch guard, in every session: a ship launch with no human prompt
527
+ // since this session's last is refused, in the rule's words, before the
528
+ // engine or the settings layer sees it (design D3, D4).
529
+ const { record, now } = await guardFacts($)
530
+ const launch = isShipLaunch(e, record)
531
+ const verdict = decideLaunch(record, now)
532
+ if (launch && verdict.decision === 'deny') return { deny: verdict.reason }
533
+ const r = await next(e)
534
+ const accepted = launch && r.isError !== true && isAcceptedLaunch(r.result)
535
+ if (accepted) {
536
+ await changeRecord($, 'ship launch', (current, at) =>
537
+ withLaunch(current, {
538
+ at,
539
+ runId: str(r.result.runId),
540
+ workflowName: str(r.result.workflowName),
541
+ scriptPath: str(r.result.scriptPath)
542
+ })
543
+ )
544
+ }
545
+
546
+ // The meter: an accepted ship launch makes the run live, its first activity
547
+ // at the time the guard read (design D1), and starts its interval.
548
+ if (!session.interactive || !accepted) return r
549
+ stopTick()
550
+ run = freshRun()
551
+ const launched = run
552
+ run.phase = 'live'
553
+ run.runId = str(r.result.runId)
554
+ run.workflowName = str(r.result.workflowName)
555
+ run.transcriptDir = str(r.result.transcriptDir)
556
+ run.lastActivityAt = now
557
+ $.ui.invalidate('ui.render')
558
+ const placed = await openPane($)
559
+ if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
560
+ if (run === launched) startTick($)
561
+ return r
562
+ })
563
+
564
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
565
+ if (!session.interactive || typeof e.command !== 'string' || !DRIVER_LINE.test(e.command)) return next(e)
566
+ if (!watching()) return idleStep($, e, next)
567
+ const held = run
568
+ const r = await next(e)
569
+ try {
570
+ const { record, problem } = readRecord(r.text)
571
+ if (!record) $.ui.log(`interlock meter: ${e.command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
572
+ else if (held === run) await applyRecord($, record)
573
+ else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
574
+ } catch (err) {
575
+ $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
576
+ }
577
+ return r
578
+ })
579
+
580
+ on('turn.step', async function* ($, e, next) {
581
+ if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
582
+ // A run agent's request starting, and its answer arriving, are each activity (design D1).
583
+ if (await stamp($)) redrawWord($)
584
+ const result = yield* next(e)
585
+ if (await stamp($)) redrawWord($)
586
+ try {
587
+ addUsage(agentOf(e.agentId), result ? result.usage : null)
588
+ $.ui.invalidate('ui.render')
589
+ } catch {
590
+ // A tally that cannot be kept is a figure the pane does not show; the step is the engine's.
591
+ }
592
+ return result
593
+ })
594
+
595
+ on('turn.complete', async ($, e, next) => {
596
+ if (session.interactive && run.phase === 'live' && e.agentId) {
597
+ const a = agentOf(e.agentId)
598
+ a.reason = str(e.reason)
599
+ a.durationMs = typeof e.durationMs === 'number' ? e.durationMs : null
600
+ if (await stamp($)) redrawWord($)
601
+ $.ui.invalidate('ui.render')
602
+ }
603
+ return next(e)
604
+ })
605
+
606
+ on('agent.spawn', async ($, e, next) => {
607
+ const r = await next(e)
608
+ if (session.interactive && run.phase === 'live') {
609
+ if (r && str(r.agentId) && typeof e.prompt === 'string') {
610
+ const found = [...e.prompt.matchAll(BRIEFING_SHA)]
611
+ if (found.length) agentOf(r.agentId).sha = found[found.length - 1][1]
612
+ }
613
+ if (await stamp($)) redrawWord($)
614
+ }
615
+ return r
616
+ })
617
+
618
+ // The session boundary (design D4). /clear, /resume and /branch start the
619
+ // session over: the engine empties `$.state`, the launch guard's record with
620
+ // it, and `session.start` does not fire. The meter ends the run it holds on
621
+ // the same event. A compaction is the same session and the same run, and a
622
+ // source this cannot read changes nothing. The pane is left open: it redraws
623
+ // to the no-run text, which is true, and the person dismisses it.
624
+ on('classic.SessionStart', async ($, e, next) => {
625
+ if (!session.interactive) return next(e)
626
+ try {
627
+ const boundary = BOUNDARIES.get(e.source)
628
+ if (boundary) {
629
+ const ended = run
630
+ stopTick()
631
+ run = freshRun()
632
+ $.ui.status(undefined)
633
+ $.ui.invalidate('ui.render')
634
+ const what = ended.phase === 'idle' ? 'no run was held' : `run ${ended.runId || '(no id)'} is no longer drawn`
635
+ $.ui.log(`interlock meter: ${boundary} started the session over; ${what}`, { to: 'debug' })
636
+ }
637
+ } catch (err) {
638
+ $.ui.log(`interlock meter: the session boundary was not applied: ${messageOf(err)}`, { to: 'debug' })
639
+ }
640
+ return next(e)
641
+ })
642
+
643
+ on('ui.render', { component: 'Spinner' }, ($, e, next) => {
644
+ if (!session.interactive || run.phase !== 'live') return next(e)
645
+ const suffix = shown()
646
+ return suffix ? next({ ...e, props: { ...e.props, suffix } }) : next(e)
647
+ })
648
+
649
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
650
+
651
+ on('command.run', { command: PANE }, async $ => {
652
+ await openPane($)
653
+ return {}
654
+ })
655
+ }