@renzrollon/interlock 1.1.1 → 1.2.1
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/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- package/.claude-plugin/marketplace.json +0 -19
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
|
+
}
|