@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +142 -0
- package/bin/interlock +413 -65
- 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 +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- 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 +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- 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 +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
package/lib/host/claude-cli.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
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
|
|
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
|
-
[
|
|
587
|
+
[
|
|
588
|
+
...fixedArgs,
|
|
589
|
+
...claudeArgs(request, { model: routed.value, effort: effort.value, permissionMode, plugin, permissionPrompts })
|
|
590
|
+
],
|
|
245
591
|
{
|
|
246
|
-
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 } =
|
|
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
|