@renzrollon/interlock 1.1.0 → 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 +142 -0
  4. package/bin/interlock +413 -65
  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 +153 -45
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +83 -13
  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 +1193 -85
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +285 -83
  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
@@ -11,8 +11,14 @@
11
11
  // envelope.structured_output the schema-validated object (schemaEnforced)
12
12
  // envelope.usage { input_tokens, output_tokens, ... }
13
13
  // --agent <id> --model <slug> --permission-mode <mode> --allowedTools <list>
14
+ // --permission-prompts none only outside bypass, only when --help lists it
14
15
  // prompt on stdin
15
16
  //
17
+ // Beside the result, every spawn reports a HOST RECORD (design D7): what the
18
+ // envelope said about the spawn whatever the exit code, plus the models the
19
+ // lane session's own transcript names. The runner forwards it to the CLI on
20
+ // `--host-records`; the CLI decides every banner from it.
21
+ //
16
22
  // Two things are worth stating because they are not obvious:
17
23
  //
18
24
  // 1. `--permission-mode bypassPermissions` does NOT disarm this repository's
@@ -35,14 +41,24 @@
35
41
  // for separate metered credit. The runner banners it (design D8); the adapter
36
42
  // only declares which path it is on.
37
43
 
38
- import { existsSync } from 'node:fs'
44
+ import { spawnSync } from 'node:child_process'
45
+ import { existsSync, realpathSync } from 'node:fs'
46
+ import { homedir } from 'node:os'
39
47
  import { dirname, join } from 'node:path'
40
48
  import { fileURLToPath } from 'node:url'
49
+ import { isSafeSegment, summarizeTranscriptFile } from '../agent-usage.mjs'
41
50
  import { assertWorkflowHost, execCapture, mapPipeline, runCli } from '../host.mjs'
51
+ import { projectSlug } from '../project-slug.mjs'
52
+ import { MAX_AGENT_ERRORS, MAX_TEXT } from '../run-log.mjs'
53
+ import { CLAUDE_COMMAND_ENV, claudeCommandArgv, parseClaudeVersion } from './claude-env.mjs'
54
+ import { EFFORT_REASONS, effortRoutingEvent, resolveEffort } from './effort.mjs'
42
55
  import { parseModelMap, resolveModel } from './model-map.mjs'
43
56
 
44
- /** Override the binary, for a wrapper or a fixture. */
45
- export const CLAUDE_COMMAND_ENV = 'INTERLOCK_CLAUDE_COMMAND'
57
+ /**
58
+ * Override the binary, for a wrapper or a fixture. Defined in `./claude-env.mjs`
59
+ * — the doctor resolves the binary too, and may not load this adapter to do it.
60
+ */
61
+ export { CLAUDE_COMMAND_ENV }
46
62
 
47
63
  /** Override the unattended permission mode. */
48
64
  export const CLAUDE_PERMISSION_MODE_ENV = 'INTERLOCK_CLAUDE_PERMISSION_MODE'
@@ -56,6 +72,12 @@ export const CLAUDE_TIMEOUT_ENV = 'INTERLOCK_CLAUDE_TIMEOUT_MS'
56
72
  */
57
73
  const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
58
74
 
75
+ /**
76
+ * How long the one-time `--help` probe may take before it is given up as
77
+ * failed (design D30). A transport timeout, not a loop cap.
78
+ */
79
+ const DEFAULT_PROBE_TIMEOUT_MS = 10 * 1000
80
+
59
81
  /**
60
82
  * The unattended default, kept on the strength of the live probe above: a
61
83
  * PreToolUse `deny` still blocks under it, so the guards this repository ships
@@ -63,6 +85,17 @@ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
63
85
  */
64
86
  export const DEFAULT_PERMISSION_MODE = 'bypassPermissions'
65
87
 
88
+ /** The mode that never prompts, so there is no prompt to suppress under it. */
89
+ const BYPASS_PERMISSION_MODE = 'bypassPermissions'
90
+
91
+ /**
92
+ * The run's banner when prompts cannot be suppressed (design D10). The adapter
93
+ * raises it; the runner forwards it to the close through `--host-banners`.
94
+ */
95
+ export const PERMISSION_PROMPTS_BANNER =
96
+ 'PERMISSION PROMPTS NOT SUPPRESSED (claude): the installed CLI does not list --permission-prompts, ' +
97
+ 'so a lane that needs a person waits'
98
+
66
99
  /** This checkout, if it is the Interlock plugin — the directory `--plugin-dir` names. */
67
100
  export function pluginRoot() {
68
101
  const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
@@ -78,11 +111,14 @@ export function pluginRoot() {
78
111
  * @param {object} req the step's spawn request
79
112
  * @param {object} opts
80
113
  * @param {string|null} opts.model the resolved model id, or null
114
+ * @param {string|null} [opts.effort] the effort level to pass, or null; only ever set on a verified-flag host
81
115
  * @param {string} opts.permissionMode
82
116
  * @param {string|null} opts.plugin `--plugin-dir` target, or null
117
+ * @param {'flag'|'unsupported'} [opts.permissionPrompts] the help probe's verdict on
118
+ * `--permission-prompts`; only `flag` ever passes it (design D10)
83
119
  * @returns {string[]}
84
120
  */
85
- export function claudeArgs(req, { model, permissionMode, plugin } = {}) {
121
+ export function claudeArgs(req, { model, effort, permissionMode, plugin, permissionPrompts } = {}) {
86
122
  const request = req && typeof req === 'object' ? req : {}
87
123
  const args = ['-p', '--output-format', 'json']
88
124
  if (request.schema) args.push('--json-schema', JSON.stringify(request.schema))
@@ -94,7 +130,20 @@ export function claudeArgs(req, { model, permissionMode, plugin } = {}) {
94
130
  args.push('--agent', request.type.trim())
95
131
  }
96
132
  if (typeof model === 'string' && model) args.push('--model', model)
97
- args.push('--permission-mode', permissionMode || DEFAULT_PERMISSION_MODE)
133
+ // Directly after --model, and only when the caller verified the flag exists:
134
+ // a CLI that does not know it rejects the whole invocation (design D30).
135
+ if (typeof effort === 'string' && effort) args.push('--effort', effort)
136
+ const mode = permissionMode || DEFAULT_PERMISSION_MODE
137
+ args.push('--permission-mode', mode)
138
+ // Directly after the mode it qualifies, only on a CLI whose help lists it (a
139
+ // CLI that does not know it rejects the whole invocation), and never under
140
+ // bypass, which asks nobody anything, so the default unattended argv is
141
+ // unchanged (design D10). Session persistence is deliberately left ON: the
142
+ // lane's transcript is where its served models are read, and a persisted
143
+ // session is one an operator can resume.
144
+ if (permissionPrompts === 'flag' && mode !== BYPASS_PERMISSION_MODE) {
145
+ args.push('--permission-prompts', 'none')
146
+ }
98
147
  if (Array.isArray(request.tools) && request.tools.length) {
99
148
  args.push('--allowedTools', request.tools.join(','))
100
149
  }
@@ -148,8 +197,112 @@ function readCacheUsage(usage) {
148
197
  }
149
198
  }
150
199
 
200
+ /** A plain object, not an array and not null. */
201
+ function isRecord(value) {
202
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
203
+ }
204
+
205
+ /** A non-empty string from the envelope, or `null` for a field it omitted or could not have meant. */
206
+ function envelopeText(value) {
207
+ return typeof value === 'string' && value ? value : null
208
+ }
209
+
210
+ /** One envelope error as bounded text: a string as it came, anything else as its JSON. */
211
+ function errorText(entry) {
212
+ if (typeof entry === 'string') return entry
213
+ if (isRecord(entry) && typeof entry.message === 'string') return entry.message
214
+ try {
215
+ return JSON.stringify(entry) ?? ''
216
+ } catch {
217
+ return ''
218
+ }
219
+ }
220
+
151
221
  /**
152
- * The result and the usage out of the CLI's JSON envelope.
222
+ * The host's observation of one spawn, read off the envelope (design D7).
223
+ *
224
+ * Every field is copied BY NAME and is `null` when the envelope omits it or
225
+ * carries a value of the wrong type — never a `0`, `false` or `[]` standing in
226
+ * for something the host did not say. Two fields are not copied but derived:
227
+ *
228
+ * - `sessionModels` is the key list of `modelUsage`, the envelope's per-model
229
+ * breakdown. It is SESSION-scoped: every captured envelope listed a
230
+ * host-internal `bedrock.claude-haiku-4-5` call beside the session's own
231
+ * model, even on a one-turn run with no tool, so these keys say which models
232
+ * the session called, not which one served the lane's turns. The adapter
233
+ * reads that turn-scoped list from the lane's transcript instead
234
+ * ({@link readLaneTranscript}).
235
+ * - `resultMissing` is a success with no object `structured_output` on an exit
236
+ * that was not a non-zero integer — anthropics/claude-code#82258.
237
+ *
238
+ * `hostCostUsd` is `total_cost_usd`, which is a CLIENT-SIDE ESTIMATE the CLI
239
+ * computes from its own price list. It is carried as the host's figure, never
240
+ * summed with or substituted for `MODEL_PRICES` dollars.
241
+ */
242
+ function hostRecord(envelope, exitCode, timedOut) {
243
+ const errors = Array.isArray(envelope.errors)
244
+ ? envelope.errors
245
+ .map(errorText)
246
+ .filter(text => text.trim())
247
+ .slice(0, MAX_AGENT_ERRORS)
248
+ .map(text => text.slice(0, MAX_TEXT))
249
+ : null
250
+ const denials = Array.isArray(envelope.permission_denials)
251
+ ? {
252
+ count: envelope.permission_denials.length,
253
+ // The tool names only. A denial's `tool_input` — a command line, a file
254
+ // body — never travels into the record.
255
+ tools: [
256
+ ...new Set(
257
+ envelope.permission_denials
258
+ .map(denial => (isRecord(denial) ? envelopeText(denial.tool_name) : null))
259
+ .filter(Boolean)
260
+ )
261
+ ]
262
+ }
263
+ : null
264
+ const subtype = envelopeText(envelope.subtype)
265
+ const numTurns = envelope.num_turns
266
+ const structured = envelope.structured_output
267
+ return {
268
+ parsed: true,
269
+ subtype,
270
+ isError: typeof envelope.is_error === 'boolean' ? envelope.is_error : null,
271
+ terminalReason: envelopeText(envelope.terminal_reason),
272
+ errors,
273
+ permissionDenials: denials,
274
+ sessionId: envelopeText(envelope.session_id),
275
+ numTurns: Number.isInteger(numTurns) && numTurns >= 0 ? numTurns : null,
276
+ sessionModels: isRecord(envelope.modelUsage) ? Object.keys(envelope.modelUsage) : null,
277
+ hostCostUsd: envelopeCount(envelope.total_cost_usd) ?? null,
278
+ exitCode,
279
+ timedOut,
280
+ resultMissing:
281
+ subtype === 'success' && !(exitCode !== null && exitCode !== 0) && !isRecord(structured)
282
+ }
283
+ }
284
+
285
+ /** The record of a spawn whose stdout was not an envelope: the exit, and nothing invented. */
286
+ function unparsedRecord(exitCode, timedOut) {
287
+ return {
288
+ parsed: false,
289
+ subtype: null,
290
+ isError: null,
291
+ terminalReason: null,
292
+ errors: null,
293
+ permissionDenials: null,
294
+ sessionId: null,
295
+ numTurns: null,
296
+ sessionModels: null,
297
+ hostCostUsd: null,
298
+ exitCode,
299
+ timedOut,
300
+ resultMissing: null
301
+ }
302
+ }
303
+
304
+ /**
305
+ * The result, the usage and the host record out of the CLI's JSON envelope.
153
306
  *
154
307
  * `structured_output` is the whole result: the CLI validated it against the
155
308
  * schema, so there is nothing to recover from prose the way the ACP adapter has
@@ -160,22 +313,30 @@ function readCacheUsage(usage) {
160
313
  * not a host that reported nothing: both halves come back `null` and every
161
314
  * downstream figure is absent rather than zero.
162
315
  *
316
+ * `host` is always an object (design D7): what the host said about the spawn,
317
+ * read whatever the exit code, so a lane the host stopped is named by the
318
+ * host's own subtype rather than by "no result". It travels beside the result
319
+ * on the adapter's events, never inside it.
320
+ *
163
321
  * @param {string} stdout
164
- * @returns {{result: object|null, usage: object|null}}
322
+ * @param {{exitCode?: number|null, timedOut?: boolean}} [opts] what the process did, as the caller observed it
323
+ * @returns {{result: object|null, usage: object|null, host: object}}
165
324
  */
166
- export function readClaudeEnvelope(stdout) {
325
+ export function readClaudeEnvelope(stdout, { exitCode = null, timedOut = false } = {}) {
326
+ const exit = Number.isInteger(exitCode) ? exitCode : null
327
+ const timed = timedOut === true
167
328
  let envelope
168
329
  try {
169
330
  envelope = JSON.parse(typeof stdout === 'string' ? stdout : '')
170
331
  } catch {
171
- return { result: null, usage: null }
332
+ return { result: null, usage: null, host: unparsedRecord(exit, timed) }
172
333
  }
173
- if (!envelope || typeof envelope !== 'object') return { result: null, usage: null }
334
+ if (!isRecord(envelope)) return { result: null, usage: null, host: unparsedRecord(exit, timed) }
335
+ const host = hostRecord(envelope, exit, timed)
174
336
  const structured = envelope.structured_output
175
- const result =
176
- structured && typeof structured === 'object' && !Array.isArray(structured) ? structured : null
337
+ const result = isRecord(structured) ? structured : null
177
338
  const usage = envelope.usage && typeof envelope.usage === 'object' ? envelope.usage : null
178
- if (!usage) return { result, usage: null }
339
+ if (!usage) return { result, usage: null, host }
179
340
  const cache = readCacheUsage(usage)
180
341
  return {
181
342
  result,
@@ -185,7 +346,169 @@ export function readClaudeEnvelope(stdout) {
185
346
  // Spread, so an envelope with no cache fields carries no cache keys at all
186
347
  // — absent, which is what "this spawn was not measured for cache" means.
187
348
  ...(cache || {})
349
+ },
350
+ host
351
+ }
352
+ }
353
+
354
+ /**
355
+ * The models that served a lane's own turns, read from the lane session's
356
+ * transcript (design D7).
357
+ *
358
+ * The envelope's `modelUsage` is session-scoped (see {@link readClaudeEnvelope});
359
+ * the transcript names the model of every assistant turn the lane itself took,
360
+ * which is the TURN-scoped observation a substitution verdict prefers. The host
361
+ * writes it at `<config>/projects/<projectSlug(realpath(cwd))>/<sessionId>.jsonl`,
362
+ * `<config>` being `CLAUDE_CONFIG_DIR` when set and `$HOME/.claude` otherwise —
363
+ * the layout the 2.1.274 probe's `transcript_path` showed, with the cwd
364
+ * realpath'd. It exists because the adapter never disables session persistence.
365
+ *
366
+ * Observation only, never a verdict, and it never throws: a transcript that
367
+ * cannot be read is `servedModels: null` with the reason, and the record keeps
368
+ * its session-scoped models, so the verdict degrades to the session scope
369
+ * rather than to nothing.
370
+ *
371
+ * @param {{cwd?: string, sessionId?: string|null, env?: Record<string, string|undefined>}} [opts]
372
+ * @returns {{servedModels: string[]|null, reason: string|null}}
373
+ */
374
+ export function readLaneTranscript({ cwd, sessionId, env } = {}) {
375
+ const absent = reason => ({ servedModels: null, reason })
376
+ try {
377
+ if (!isSafeSegment(sessionId)) {
378
+ return absent(`the session id ${JSON.stringify(sessionId ?? null)} cannot name a transcript file`)
379
+ }
380
+ if (typeof cwd !== 'string' || !cwd) return absent('the spawn named no working directory')
381
+ const source = env && typeof env === 'object' ? env : {}
382
+ const config =
383
+ (typeof source.CLAUDE_CONFIG_DIR === 'string' && source.CLAUDE_CONFIG_DIR.trim()) ||
384
+ join((typeof source.HOME === 'string' && source.HOME.trim()) || homedir(), '.claude')
385
+ let real
386
+ try {
387
+ real = realpathSync(cwd)
388
+ } catch (err) {
389
+ return absent(`the lane directory ${cwd} could not be resolved: ${(err && err.code) || (err && err.message) || err}`)
390
+ }
391
+ const path = join(config, 'projects', projectSlug(real), `${sessionId}.jsonl`)
392
+ if (!existsSync(path)) return absent(`no lane transcript at ${path}`)
393
+ const summary = summarizeTranscriptFile(path)
394
+ if (!summary.transcript.parsed) return absent(summary.reason || `the lane transcript at ${path} could not be read`)
395
+ if (!Array.isArray(summary.models) || !summary.models.length) {
396
+ return absent(`the lane transcript at ${path} names no served model${summary.reason ? ` (${summary.reason})` : ''}`)
188
397
  }
398
+ return { servedModels: summary.models, reason: null }
399
+ } catch (err) {
400
+ return absent(`the lane transcript could not be read: ${(err && err.message) || err}`)
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Ask the installed CLI's own help which of the probed flags it lists (design
406
+ * D30, D10): `--effort` and `--permission-prompts`, from ONE `--help` run, so
407
+ * the adapter's one-time probe stays one spawn however many flags sit behind it.
408
+ *
409
+ * Synchronous on purpose: the runner reads `host.capabilities` immediately after
410
+ * creation and sends them to `run start`. Never throws; a probe that cannot
411
+ * answer is `unsupported` for every flag, effort with its own reason, so the
412
+ * degradation is spoken and no unverified flag is ever sent.
413
+ *
414
+ * @param {string} bin
415
+ * @param {string[]} [fixedArgs]
416
+ * @param {{cwd?: string, env?: object, timeoutMs?: number}} [opts]
417
+ * @returns {{effort: {capability: 'flag'|'unsupported', reason: string|null}, permissionPrompts: 'flag'|'unsupported'}}
418
+ */
419
+ export function probeHelpFlags(bin, fixedArgs = [], { cwd = process.cwd(), env = process.env, timeoutMs } = {}) {
420
+ const failed = { effort: { capability: 'unsupported', reason: EFFORT_REASONS.probeFailed }, permissionPrompts: 'unsupported' }
421
+ try {
422
+ const out = spawnSync(bin, [...(Array.isArray(fixedArgs) ? fixedArgs : []), '--help'], {
423
+ cwd,
424
+ env,
425
+ encoding: 'utf8',
426
+ timeout: Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_PROBE_TIMEOUT_MS,
427
+ killSignal: 'SIGKILL',
428
+ input: ''
429
+ })
430
+ if (out.error || out.status !== 0) return failed
431
+ const help = `${out.stdout || ''}\n${out.stderr || ''}`
432
+ // An option line that declares the flag — ` --effort <level>` or
433
+ // ` -e, --effort <level>` — not any mention of it. Help prose that names
434
+ // a flag (a deprecation note, another option's description) must not pass
435
+ // a flag the CLI would then reject on every spawn.
436
+ const lists = flag => new RegExp(`^[ \\t]*(?:-[A-Za-z0-9],[ \\t]*)?--${flag}(?=[\\s=<[]|$)`, 'm').test(help)
437
+ return {
438
+ effort: lists('effort')
439
+ ? { capability: 'flag', reason: null }
440
+ : { capability: 'unsupported', reason: EFFORT_REASONS.noFlag },
441
+ permissionPrompts: lists('permission-prompts') ? 'flag' : 'unsupported'
442
+ }
443
+ } catch {
444
+ return failed
445
+ }
446
+ }
447
+
448
+ /**
449
+ * The same `--help` probe the Claude adapter runs, for a caller that is not
450
+ * building that host.
451
+ *
452
+ * The Workflow runtime applies effort through `agent()`, which this process
453
+ * cannot watch. What it can watch is whether the installed CLI lists `--effort`.
454
+ * `interlock run start --host workflow` records that verdict instead of
455
+ * assuming the flag. The command is `$INTERLOCK_CLAUDE_COMMAND` or `claude`,
456
+ * split on whitespace the same way {@link createClaudeHost} splits it.
457
+ *
458
+ * @param {Record<string, string|undefined>} [env]
459
+ * @param {{probeTimeoutMs?: number, cwd?: string}} [opts]
460
+ * @returns {{capability: 'flag'|'unsupported', reason: string|null}}
461
+ */
462
+ export function probeClaudeEffort(env = process.env, { probeTimeoutMs, cwd = process.cwd() } = {}) {
463
+ const source = env && typeof env === 'object' ? env : {}
464
+ const raw =
465
+ (typeof source[CLAUDE_COMMAND_ENV] === 'string' && source[CLAUDE_COMMAND_ENV].trim()) || 'claude'
466
+ const argv = String(raw).trim().split(/\s+/)
467
+ return probeHelpFlags(argv[0], argv.slice(1), {
468
+ cwd,
469
+ env: source,
470
+ timeoutMs: Number.isInteger(probeTimeoutMs) && probeTimeoutMs > 0 ? probeTimeoutMs : DEFAULT_PROBE_TIMEOUT_MS
471
+ }).effort
472
+ }
473
+
474
+ /**
475
+ * Ask the installed CLI its version, for `interlock run start --host workflow`
476
+ * (design D2). The run program reads the model-routing variables from its own
477
+ * environment, and what they mean depends on the host: from 2.1.251 the plain
478
+ * subagent-model variable sets only the default. This is how the run learns
479
+ * which side of that line it is on.
480
+ *
481
+ * Same command, same split and same timeout as {@link probeClaudeEffort}.
482
+ * Never throws: a probe that cannot answer is `{ version: null, reason }`, and
483
+ * the run takes the conservative reading and says the version was unknown.
484
+ *
485
+ * @param {Record<string, string|undefined>} [env]
486
+ * @param {{probeTimeoutMs?: number, cwd?: string}} [opts]
487
+ * @returns {{version: string|null, reason: string|null}}
488
+ */
489
+ export function probeClaudeVersion(env = process.env, { probeTimeoutMs, cwd = process.cwd() } = {}) {
490
+ try {
491
+ const source = env && typeof env === 'object' ? env : {}
492
+ const [bin, ...fixedArgs] = claudeCommandArgv(source)
493
+ const named = [bin, ...fixedArgs, '--version'].join(' ')
494
+ const out = spawnSync(bin, [...fixedArgs, '--version'], {
495
+ cwd,
496
+ env: source,
497
+ encoding: 'utf8',
498
+ timeout: Number.isInteger(probeTimeoutMs) && probeTimeoutMs > 0 ? probeTimeoutMs : DEFAULT_PROBE_TIMEOUT_MS,
499
+ killSignal: 'SIGKILL',
500
+ input: ''
501
+ })
502
+ if (out.error) return { version: null, reason: `\`${named}\` failed: ${out.error.message}` }
503
+ if (out.status !== 0) {
504
+ return { version: null, reason: `\`${named}\` exited ${out.status === null ? `on ${out.signal}` : out.status}` }
505
+ }
506
+ const version = parseClaudeVersion(out.stdout || '')
507
+ return version
508
+ ? { version, reason: null }
509
+ : { version: null, reason: `\`${named}\` printed no version` }
510
+ } catch (err) {
511
+ return { version: null, reason: `the version probe could not run: ${(err && err.message) || String(err)}` }
189
512
  }
190
513
  }
191
514
 
@@ -197,9 +520,10 @@ export function readClaudeEnvelope(stdout) {
197
520
  * @param {string} [opts.cwd] repo root; a spawn's own `cwd` overrides it per lane
198
521
  * @param {object} [opts.env]
199
522
  * @param {number} [opts.timeoutMs]
523
+ * @param {number} [opts.probeTimeoutMs] wall clock for the one-time `--help` probe
200
524
  * @param {(event: object) => void} [opts.onEvent]
201
525
  */
202
- export function createClaudeHost({ command, cwd = process.cwd(), env = process.env, timeoutMs, onEvent } = {}) {
526
+ export function createClaudeHost({ command, cwd = process.cwd(), env = process.env, timeoutMs, probeTimeoutMs, onEvent } = {}) {
203
527
  const source = env && typeof env === 'object' ? env : {}
204
528
  const raw = (typeof command === 'string' && command.trim()) || source[CLAUDE_COMMAND_ENV] || 'claude'
205
529
  const argv = String(raw).trim().split(/\s+/)
@@ -212,12 +536,27 @@ export function createClaudeHost({ command, cwd = process.cwd(), env = process.e
212
536
  // Parsed once, here: a malformed map fails host creation rather than a wave.
213
537
  const modelMap = parseModelMap(source)
214
538
  const plugin = pluginRoot()
539
+ // Probed once, here, so the verdicts are in `capabilities` before any spawn.
540
+ const flags = probeHelpFlags(bin, fixedArgs, {
541
+ cwd,
542
+ env: source,
543
+ timeoutMs: Number.isInteger(probeTimeoutMs) && probeTimeoutMs > 0 ? probeTimeoutMs : DEFAULT_PROBE_TIMEOUT_MS
544
+ })
545
+ const probe = flags.effort
546
+ const permissionPrompts = flags.permissionPrompts
215
547
 
216
548
  const host = {
217
549
  command: [bin, ...fixedArgs].join(' '),
218
550
  // The only capability this adapter can observe about itself. Everything else
219
551
  // is the registry's static declaration.
220
- capabilities: plugin ? {} : { hooks: false },
552
+ capabilities: { ...(plugin ? {} : { hooks: false }), effort: probe.capability },
553
+ // What this host degraded on, raised once at creation: the runner folds
554
+ // these into the close through `--host-banners` beside its own. A run that
555
+ // could prompt (any mode but bypass) on a CLI that cannot be told not to is
556
+ // a lane that waits for a person nobody sent (design D10).
557
+ banners: Object.freeze(
558
+ permissionMode !== BYPASS_PERMISSION_MODE && permissionPrompts !== 'flag' ? [PERMISSION_PROMPTS_BANNER] : []
559
+ ),
221
560
 
222
561
  async spawn(req) {
223
562
  const request = req && typeof req === 'object' ? req : {}
@@ -235,20 +574,50 @@ export function createClaudeHost({ command, cwd = process.cwd(), env = process.e
235
574
  reason: routed.reason
236
575
  })
237
576
  }
577
+ // Resolved from this host's own probe, not from an effective capability a
578
+ // host built without `createHost` would not have.
579
+ const effort = resolveEffort(request.effort, probe.capability, probe.reason)
238
580
  if (!plugin && onEvent) {
239
581
  onEvent({ type: 'agent-prefix-skipped', label, reason: 'this checkout is not the interlock plugin' })
240
582
  }
241
583
 
242
- const { code, stdout, stderr, timedOut } = await execCapture(
584
+ const laneCwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd
585
+ const { code, stdout, stderr, timedOut, started: ran } = await execCapture(
243
586
  bin,
244
- [...fixedArgs, ...claudeArgs(request, { model: routed.value, permissionMode, plugin })],
587
+ [
588
+ ...fixedArgs,
589
+ ...claudeArgs(request, { model: routed.value, effort: effort.value, permissionMode, plugin, permissionPrompts })
590
+ ],
245
591
  {
246
- cwd: typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd,
592
+ cwd: laneCwd,
247
593
  env: source,
248
594
  input: request.prompt,
249
595
  timeoutMs: budget
250
596
  }
251
597
  )
598
+ // After the process started, not before: "applied" means the flag reached
599
+ // a CLI that ran, and a spawn that never started is only a failed spawn.
600
+ const routedEffort = ran ? effortRoutingEvent(label, effort) : null
601
+ if (routedEffort && onEvent) onEvent(routedEffort)
602
+
603
+ // The envelope is read WHATEVER the exit code (design D7): a lane the host
604
+ // stopped prints its reason — `error_max_turns`, the errors — on stdout,
605
+ // and a failed spawn that discarded it would be named only "no result".
606
+ const envelope = readClaudeEnvelope(stdout, { exitCode: code, timedOut })
607
+ const transcript = envelope.host.sessionId
608
+ ? readLaneTranscript({ cwd: laneCwd, sessionId: envelope.host.sessionId, env: source })
609
+ : { servedModels: null, reason: 'the envelope named no session id, so no lane transcript was read' }
610
+ // The host's observation, on its own channel beside the result (design
611
+ // D7). The runner forwards it to the CLI, which decides every banner.
612
+ const hostSeen = {
613
+ ...envelope.host,
614
+ servedModels: transcript.servedModels,
615
+ transcriptReason: transcript.reason,
616
+ // The value this spawn was sent after the map, which is what a served
617
+ // model is compared against — not the planner's slug.
618
+ modelRouted: routed.value ?? null,
619
+ usage: envelope.usage
620
+ }
252
621
 
253
622
  if (code !== 0) {
254
623
  if (onEvent) {
@@ -257,15 +626,16 @@ export function createClaudeHost({ command, cwd = process.cwd(), env = process.e
257
626
  label,
258
627
  code,
259
628
  timedOut,
260
- error: stderr.trim().slice(-500) || `claude exited ${code}`
629
+ error: stderr.trim().slice(-500) || `claude exited ${code}`,
630
+ host: hostSeen
261
631
  })
262
632
  }
263
633
  return null
264
634
  }
265
635
 
266
- const { result, usage } = readClaudeEnvelope(stdout)
636
+ const { result, usage } = envelope
267
637
  if (onEvent) {
268
- onEvent({ type: 'spawn-done', label, ms: Date.now() - started, parsed: Boolean(result), usage })
638
+ onEvent({ type: 'spawn-done', label, ms: Date.now() - started, parsed: Boolean(result), usage, host: hostSeen })
269
639
  }
270
640
  if (!result) return null
271
641
  // Usage rides beside the result rather than inside it: the result's shape