@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,551 @@
1
+ // The ACP host adapter (add-interlock-acp-host §3).
2
+ //
3
+ // This is the second implementation of the three-function port in
4
+ // `lib/host.mjs`, and it is an adapter — not a harness. It speaks the Agent
5
+ // Client Protocol to a user-configured agent process so that `spawn` can reach
6
+ // a model outside Claude Code. It does not own a session store, an event bus, a
7
+ // plugin registry or a message history: the loop lives in
8
+ // `bin/interlock-ship-acp`, the rules live in `bin/interlock`, and this file's
9
+ // entire job is "prompt in, JSON out".
10
+ //
11
+ // Protocol facts this file depends on, from the ACP v1 schema:
12
+ //
13
+ // transport JSON-RPC 2.0 over stdio, one message per line, no embedded \n
14
+ // handshake initialize { protocolVersion, clientCapabilities }
15
+ // session session/new { cwd, mcpServers } -> { sessionId, configOptions? }
16
+ // config session/set_config_option { sessionId, configId, value }; the
17
+ // legacy session/set_model { sessionId, modelId } is still
18
+ // accepted by agents that predate config options
19
+ // turn session/prompt { sessionId, prompt: [ContentBlock] } -> { stopReason }
20
+ // output session/update notification, update.sessionUpdate =
21
+ // "agent_message_chunk", update.content = { type: "text", text }
22
+ // permission session/request_permission { sessionId, toolCall, options },
23
+ // answered with { outcome: { outcome: "selected", optionId } }
24
+ //
25
+ // One agent per task means one *process* per task here, not merely one session.
26
+ // A fresh subprocess is the cheapest honest way to get a fresh context, and it
27
+ // removes any question about whether a given agent implementation tolerates
28
+ // concurrent prompt turns across sessions — `mapPipeline` runs a whole batch at
29
+ // once, and that width is the planner's decision, not something this adapter
30
+ // gets to renegotiate.
31
+ //
32
+ // Two capabilities are declared false on purpose: `fs` and `terminal`. Routing
33
+ // the agent's file reads and shell commands back through this driver would make
34
+ // the driver a sandbox host, which is exactly the architecture this change is
35
+ // not adopting. The agent uses its own tools in its own working directory.
36
+
37
+ import { spawn as spawnProcess } from 'node:child_process'
38
+ import { basename } from 'node:path'
39
+ import { mapPipeline, parseAgentJson, runCli, assertWorkflowHost } from '../host.mjs'
40
+ import { ACP_MODEL_MAP_ENV, parseModelMap } from './model-map.mjs'
41
+
42
+ /** Where the user names their ACP agent, e.g. `claude-code-acp` or `gemini --acp`. */
43
+ export const ACP_COMMAND_ENV = 'INTERLOCK_ACP_COMMAND'
44
+
45
+ /** Optional per-agent wall clock, in ms. 0 disables it. */
46
+ export const ACP_TIMEOUT_ENV = 'INTERLOCK_ACP_TIMEOUT_MS'
47
+
48
+ /**
49
+ * The ACP-only model map, kept as an alias of the runner's published one
50
+ * (`INTERLOCK_MODEL_MAP`'s `acp` entry). Re-exported here because this adapter's
51
+ * own tests and error messages name it, and an operator who set it before the
52
+ * runner existed keeps working (design D6).
53
+ */
54
+ export { ACP_MODEL_MAP_ENV }
55
+
56
+ /** Latest ACP major version this adapter implements. */
57
+ export const ACP_PROTOCOL_VERSION = 1
58
+
59
+ /**
60
+ * A transport timeout, not a loop cap. Every number the *loop* obeys lives in
61
+ * `lib/limits.mjs` and is published by `interlock limits`; this one only decides
62
+ * how long to wait for a subprocess that has stopped talking, and a run that
63
+ * waits forever for a wedged agent is a run nobody can report on.
64
+ */
65
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
66
+
67
+ /**
68
+ * Split a shell-ish command string into argv. Handles single and double quotes,
69
+ * which is enough for `npx some-acp-agent --flag "with space"`; anything needing
70
+ * a real shell (pipes, substitution) should be put in a wrapper script rather
71
+ * than parsed here.
72
+ *
73
+ * @param {string} raw
74
+ * @returns {{ command: string, args: string[] }}
75
+ */
76
+ export function parseAcpCommand(raw) {
77
+ const text = typeof raw === 'string' ? raw.trim() : ''
78
+ if (!text) {
79
+ throw new Error(
80
+ `${ACP_COMMAND_ENV} is not set — the ACP host needs a command that speaks ACP on stdio`
81
+ )
82
+ }
83
+ const tokens = []
84
+ let current = ''
85
+ let quote = null
86
+ for (const ch of text) {
87
+ if (quote) {
88
+ if (ch === quote) quote = null
89
+ else current += ch
90
+ continue
91
+ }
92
+ if (ch === '"' || ch === "'") {
93
+ quote = ch
94
+ continue
95
+ }
96
+ if (/\s/.test(ch)) {
97
+ if (current) tokens.push(current)
98
+ current = ''
99
+ continue
100
+ }
101
+ current += ch
102
+ }
103
+ if (quote) throw new Error(`${ACP_COMMAND_ENV} has an unterminated ${quote} quote`)
104
+ if (current) tokens.push(current)
105
+ return { command: tokens[0], args: tokens.slice(1) }
106
+ }
107
+
108
+ /**
109
+ * True when `command` is a Claude Code CLI binary that accepts `--agent`.
110
+ * Wrappers (`npx`, `claude-code-acp`) are other ACP agents and are left alone.
111
+ *
112
+ * @param {string} command
113
+ * @returns {boolean}
114
+ */
115
+ export function isClaudeCodeBinary(command) {
116
+ if (typeof command !== 'string' || !command) return false
117
+ const base = basename(command).replace(/\.exe$/i, '')
118
+ return base === 'claude' || base === 'claude-code'
119
+ }
120
+
121
+ /**
122
+ * Prepend `--agent <type>` when the ACP command is Claude Code and the spawn
123
+ * named a plugin agent. Other agents ignore `type`. An existing `--agent` on
124
+ * the configured command wins — the operator's flag is not overridden.
125
+ *
126
+ * @param {{ command?: string, args?: string[] }} parsed
127
+ * @param {string} [type]
128
+ * @returns {string[]}
129
+ */
130
+ export function spawnArgsForAgent(parsed, type) {
131
+ const args = Array.isArray(parsed && parsed.args) ? [...parsed.args] : []
132
+ if (typeof type !== 'string' || !type.trim()) return args
133
+ if (!isClaudeCodeBinary(parsed && parsed.command)) return args
134
+ if (args.some(a => a === '--agent' || (typeof a === 'string' && a.startsWith('--agent=')))) {
135
+ return args
136
+ }
137
+ return ['--agent', type.trim(), ...args]
138
+ }
139
+
140
+ /**
141
+ * Restate the result contract in the prompt. The workflow runtime enforces a
142
+ * schema on `agent()`; ACP has no equivalent, so the schema travels as text and
143
+ * `parseAgentJson` does the recovering. This adds nothing to the prompt the
144
+ * caller built — the tier ladder, the tool-economy rules and the handoff
145
+ * contract all belong to the caller — it only says how to answer.
146
+ *
147
+ * @param {{ prompt: string, schema?: object, label?: string }} req
148
+ * @returns {string}
149
+ */
150
+ export function formatSpawnPrompt(req) {
151
+ const prompt = req && typeof req.prompt === 'string' ? req.prompt : ''
152
+ if (!req || !req.schema) return prompt
153
+ return (
154
+ `${prompt}\n\n` +
155
+ `RESULT — reply with a single JSON object and nothing else. No prose before or after it, ` +
156
+ `no code fence. It must satisfy this JSON Schema:\n` +
157
+ `${JSON.stringify(req.schema)}\n` +
158
+ `If you could not do the work, still return the object and say so in its fields.`
159
+ )
160
+ }
161
+
162
+ // --- model negotiation ------------------------------------------------------
163
+
164
+ /**
165
+ * The advertised model option, if the agent advertises one.
166
+ *
167
+ * ACP does not mandate an id, so both the conventional `model` id and the
168
+ * `model` category count. Anything else — the permission `mode` option in
169
+ * particular — is left alone (design D5).
170
+ *
171
+ * @param {unknown} configOptions
172
+ * @returns {object | null}
173
+ */
174
+ function findModelOption(configOptions) {
175
+ const list = Array.isArray(configOptions) ? configOptions : []
176
+ return list.find(o => o && typeof o === 'object' && (o.id === 'model' || o.category === 'model')) || null
177
+ }
178
+
179
+ /**
180
+ * Choose which advertised value a planner slug means — exactly, or not at all.
181
+ *
182
+ * The order is the whole decision (design D2): an operator's map, then an exact
183
+ * `value`, then the first option whose `value` or display `name` contains the
184
+ * slug case-insensitively. Never a nearest match and never a default, because a
185
+ * wrong model that ran is invisible in a summary and a banner is not.
186
+ *
187
+ * @param {string} slug the planner's model, e.g. `sonnet`
188
+ * @param {object | null} option the advertised model option
189
+ * @param {Record<string, string>} [map] {@link ACP_MODEL_MAP_ENV}, already parsed
190
+ * @returns {{ value: string | null, reason: string | null }}
191
+ */
192
+ export function pickModelValue(slug, option, map = {}) {
193
+ const wanted = typeof slug === 'string' ? slug.trim() : ''
194
+ if (!wanted) return { value: null, reason: 'no model requested' }
195
+
196
+ const advertised = Array.isArray(option && option.options)
197
+ ? option.options.filter(o => o && typeof o.value === 'string' && o.value)
198
+ : []
199
+ if (!advertised.length) return { value: null, reason: 'no model option advertised' }
200
+
201
+ const mapped = map && typeof map === 'object' ? map[wanted] : undefined
202
+ if (typeof mapped === 'string' && mapped) {
203
+ return advertised.some(o => o.value === mapped)
204
+ ? { value: mapped, reason: null }
205
+ : { value: null, reason: 'mapped value not advertised' }
206
+ }
207
+
208
+ const exact = advertised.find(o => o.value === wanted)
209
+ if (exact) return { value: exact.value, reason: null }
210
+
211
+ // First advertised wins, deterministically: `sonnet` inside both
212
+ // `claude-sonnet-4` and `claude-sonnet-4-5` is a tie the agent broke by
213
+ // ordering, and an operator who cares sets the map.
214
+ const needle = wanted.toLowerCase()
215
+ const contains = advertised.find(
216
+ o =>
217
+ o.value.toLowerCase().includes(needle) ||
218
+ (typeof o.name === 'string' && o.name.toLowerCase().includes(needle))
219
+ )
220
+ if (contains) return { value: contains.value, reason: null }
221
+
222
+ return { value: null, reason: 'slug not among advertised values' }
223
+ }
224
+
225
+ // --- the wire ---------------------------------------------------------------
226
+
227
+ /**
228
+ * One prompt turn against one freshly spawned agent process.
229
+ *
230
+ * Resolves with the agent's accumulated text and the turn's stop reason. Never
231
+ * rejects for anything the agent *said* — only for a transport failure (the
232
+ * process would not start, the handshake failed, the timeout fired), which the
233
+ * caller turns into a `null` spawn result exactly as the workflow runtime does.
234
+ */
235
+ async function promptOnce({ command, args, cwd, env, prompt, meta, model, modelMap, timeoutMs, onEvent }) {
236
+ const child = spawnProcess(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
237
+
238
+ let nextId = 0
239
+ const pending = new Map()
240
+ let buffer = ''
241
+ let text = ''
242
+ let stderr = ''
243
+ let closed = null
244
+
245
+ const emit = event => {
246
+ if (typeof onEvent === 'function') {
247
+ try {
248
+ onEvent(event)
249
+ } catch {
250
+ // Telemetry must never fail a run.
251
+ }
252
+ }
253
+ }
254
+
255
+ const send = message => {
256
+ if (child.stdin.destroyed) return
257
+ child.stdin.write(`${JSON.stringify(message)}\n`)
258
+ }
259
+
260
+ const request = (method, params) =>
261
+ new Promise((resolve, reject) => {
262
+ const id = ++nextId
263
+ pending.set(id, { resolve, reject, method })
264
+ send({ jsonrpc: '2.0', id, method, params })
265
+ })
266
+
267
+ const respond = (id, result) => send({ jsonrpc: '2.0', id, result })
268
+ const respondError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } })
269
+
270
+ const failAll = err => {
271
+ for (const [, entry] of pending) entry.reject(err)
272
+ pending.clear()
273
+ }
274
+
275
+ // The agent asks the client for things too. Everything it can ask for is
276
+ // either answered mechanically (permission: yes, this is an unattended ship
277
+ // run the user started) or declined as unsupported (fs, terminal — declared
278
+ // false in the handshake, so a well-behaved agent never asks).
279
+ const handleAgentRequest = message => {
280
+ const { id, method, params } = message
281
+ if (method === 'session/request_permission') {
282
+ const options = Array.isArray(params && params.options) ? params.options : []
283
+ const allow =
284
+ options.find(o => o && o.kind === 'allow_always') ||
285
+ options.find(o => o && o.kind === 'allow_once') ||
286
+ options[0]
287
+ if (!allow) {
288
+ respond(id, { outcome: { outcome: 'cancelled' } })
289
+ return
290
+ }
291
+ emit({ type: 'permission', tool: params && params.toolCall && params.toolCall.title, optionId: allow.optionId })
292
+ respond(id, { outcome: { outcome: 'selected', optionId: allow.optionId } })
293
+ return
294
+ }
295
+ respondError(id, -32601, `interlock ACP host does not implement ${method}`)
296
+ }
297
+
298
+ const handleMessage = message => {
299
+ if (!message || typeof message !== 'object') return
300
+ if (message.id !== undefined && message.method === undefined) {
301
+ const entry = pending.get(message.id)
302
+ if (!entry) return
303
+ pending.delete(message.id)
304
+ if (message.error) {
305
+ entry.reject(
306
+ new Error(`ACP ${entry.method} failed: ${message.error.message || JSON.stringify(message.error)}`)
307
+ )
308
+ } else {
309
+ entry.resolve(message.result)
310
+ }
311
+ return
312
+ }
313
+ if (message.method === 'session/update') {
314
+ const update = (message.params && message.params.update) || {}
315
+ if (update.sessionUpdate === 'agent_message_chunk') {
316
+ const content = update.content || {}
317
+ if (content.type === 'text' && typeof content.text === 'string') text += content.text
318
+ }
319
+ return
320
+ }
321
+ if (message.id !== undefined) handleAgentRequest(message)
322
+ // Any other notification (thoughts, tool calls, plans) is log-only.
323
+ }
324
+
325
+ child.stdout.setEncoding('utf8')
326
+ child.stdout.on('data', chunk => {
327
+ buffer += chunk
328
+ let index
329
+ while ((index = buffer.indexOf('\n')) !== -1) {
330
+ const line = buffer.slice(0, index).trim()
331
+ buffer = buffer.slice(index + 1)
332
+ if (!line) continue
333
+ try {
334
+ handleMessage(JSON.parse(line))
335
+ } catch {
336
+ emit({ type: 'unparseable-line', line: line.slice(0, 200) })
337
+ }
338
+ }
339
+ })
340
+ child.stderr.setEncoding('utf8')
341
+ child.stderr.on('data', chunk => {
342
+ stderr += chunk
343
+ })
344
+
345
+ const exited = new Promise(resolve => {
346
+ child.on('error', err => {
347
+ closed = err
348
+ failAll(err)
349
+ resolve()
350
+ })
351
+ child.on('close', code => {
352
+ if (!closed && pending.size) {
353
+ failAll(new Error(`ACP agent exited (code ${code}) mid-request: ${stderr.slice(-500)}`))
354
+ }
355
+ resolve()
356
+ })
357
+ })
358
+
359
+ const budget = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 0
360
+ let timer = null
361
+ const timeout = budget
362
+ ? new Promise((_, reject) => {
363
+ timer = setTimeout(() => {
364
+ reject(new Error(`ACP agent produced no result within ${budget}ms`))
365
+ }, budget)
366
+ })
367
+ : null
368
+
369
+ /**
370
+ * Apply the planner's model to this session, or say why it could not be.
371
+ *
372
+ * Order is config option, then `session/set_model`, then not applied (design
373
+ * D3). Never throws: a model that could not be set is a degradation to report,
374
+ * not a reason to lose the turn — the prompt runs either way.
375
+ *
376
+ * @returns {Promise<{ applied: boolean, via: string | null, value: string | null, reason: string | null }>}
377
+ */
378
+ const negotiateModel = async (sessionId, session) => {
379
+ const option = findModelOption(session && session.configOptions)
380
+
381
+ // Agents that predate config options advertise nothing and still accept the
382
+ // legacy method, so it is worth one call with the raw slug before giving up.
383
+ if (!option) {
384
+ try {
385
+ await request('session/set_model', { sessionId, modelId: model })
386
+ return { applied: true, via: 'set_model', value: model, reason: null }
387
+ } catch {
388
+ return { applied: false, via: null, value: null, reason: 'no model option advertised' }
389
+ }
390
+ }
391
+
392
+ const { value, reason } = pickModelValue(model, option, modelMap)
393
+ if (!value) return { applied: false, via: null, value: null, reason }
394
+
395
+ const configId = typeof option.id === 'string' && option.id ? option.id : 'model'
396
+ try {
397
+ await request('session/set_config_option', { sessionId, configId, value })
398
+ return { applied: true, via: 'set_config_option', value, reason: null }
399
+ } catch {
400
+ try {
401
+ await request('session/set_model', { sessionId, modelId: value })
402
+ return { applied: true, via: 'set_model', value, reason: null }
403
+ } catch {
404
+ return {
405
+ applied: false,
406
+ via: null,
407
+ value,
408
+ reason: 'set_config_option and set_model both failed'
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ const turn = async () => {
415
+ const initialized = await request('initialize', {
416
+ protocolVersion: ACP_PROTOCOL_VERSION,
417
+ clientCapabilities: {
418
+ fs: { readTextFile: false, writeTextFile: false },
419
+ terminal: false
420
+ },
421
+ clientInfo: { name: 'interlock', version: '0.1.0' }
422
+ })
423
+ emit({ type: 'initialized', protocolVersion: initialized && initialized.protocolVersion })
424
+
425
+ const session = await request('session/new', { cwd, mcpServers: [] })
426
+ const sessionId = session && session.sessionId
427
+ if (!sessionId) throw new Error('ACP session/new returned no sessionId')
428
+ emit({ type: 'session', sessionId })
429
+
430
+ if (typeof model === 'string' && model) {
431
+ emit({ type: 'model-routing', requested: model, ...(await negotiateModel(sessionId, session)) })
432
+ }
433
+
434
+ const result = await request('session/prompt', {
435
+ sessionId,
436
+ prompt: [{ type: 'text', text: prompt }],
437
+ // `_meta` is ACP's reserved extension slot: agents may read it and MUST
438
+ // NOT assume anything about it. It carries the planner's model slug for
439
+ // an agent that happens to honour it, and changes nothing if none does.
440
+ ...(meta ? { _meta: meta } : {})
441
+ })
442
+ return { stopReason: (result && result.stopReason) || 'end_turn', sessionId }
443
+ }
444
+
445
+ try {
446
+ const { stopReason } = timeout ? await Promise.race([turn(), timeout]) : await turn()
447
+ return { text, stopReason, stderr }
448
+ } finally {
449
+ if (timer) clearTimeout(timer)
450
+ if (!child.stdin.destroyed) child.stdin.end()
451
+ child.kill()
452
+ await exited
453
+ }
454
+ }
455
+
456
+ // --- the host ---------------------------------------------------------------
457
+
458
+ /**
459
+ * Build a {@link import('../host.mjs').WorkflowHost} backed by an ACP agent.
460
+ *
461
+ * @param {object} [opts]
462
+ * @param {string} [opts.command] defaults to `process.env.INTERLOCK_ACP_COMMAND`
463
+ * @param {string} [opts.cwd] repo root; also the agent's session cwd
464
+ * @param {object} [opts.env]
465
+ * @param {number} [opts.timeoutMs]
466
+ * @param {(event: object) => void} [opts.onEvent]
467
+ * @returns {import('../host.mjs').WorkflowHost & { modelRouting: 'negotiated', command: string }}
468
+ */
469
+ export function createAcpHost({ command, cwd = process.cwd(), env = process.env, timeoutMs, onEvent } = {}) {
470
+ const parsed = parseAcpCommand(command || (env && env[ACP_COMMAND_ENV]))
471
+ const budget = Number.isInteger(timeoutMs)
472
+ ? timeoutMs
473
+ : Number.parseInt((env && env[ACP_TIMEOUT_ENV]) || '', 10) || DEFAULT_TIMEOUT_MS
474
+ // Parsed once, here, so a malformed map fails host creation rather than a wave.
475
+ // The runner's published map is the source; `INTERLOCK_ACP_MODEL_MAP` is its
476
+ // `acp` entry (design D6), so an agent that advertises its own ids resolves
477
+ // through the same table every other host reads.
478
+ const modelMap = parseModelMap(env || {}).acp || {}
479
+
480
+ const host = {
481
+ command: [parsed.command, ...parsed.args].join(' '),
482
+
483
+ // What this adapter can observe about itself that the registry's static
484
+ // declaration cannot: an ACP command that IS the Claude binary means the
485
+ // plugin's hooks fire and the run is spending a Claude subscription through
486
+ // the programmatic path. Declaring `local`/`false` there would suppress the
487
+ // two banners that matter most on that path (design D8).
488
+ capabilities: isClaudeCodeBinary(parsed.command)
489
+ ? { hooks: true, billing: 'claude-subscription-programmatic' }
490
+ : {},
491
+
492
+ // The planner's tier ladder — the haiku pings, the opus clamp — is applied
493
+ // per session where the agent advertises a model option, and per spawn the
494
+ // adapter says which way it went in a `model-routing` event. The driver
495
+ // banners `MODEL ROUTING UNAVAILABLE (ACP host)` from those events, and only
496
+ // for the spawns that could not have a model applied, rather than letting a
497
+ // summary imply a cost profile that was never applied. The slug still
498
+ // travels as `_meta` too, which agents may read and MUST NOT assume.
499
+ modelRouting: 'negotiated',
500
+
501
+ async spawn(req) {
502
+ const request = req && typeof req === 'object' ? req : {}
503
+ const label = typeof request.label === 'string' ? request.label : 'agent'
504
+ const started = Date.now()
505
+ try {
506
+ const { text, stopReason } = await promptOnce({
507
+ command: parsed.command,
508
+ args: spawnArgsForAgent(parsed, request.type),
509
+ // The lane's worktree under isolation, the repo root otherwise. The
510
+ // session is created with this as its cwd, so the agent's writes land
511
+ // in the worktree the runner will fold rather than in the shared tree
512
+ // two lanes are sharing (design D7).
513
+ cwd: typeof request.cwd === 'string' && request.cwd ? request.cwd : cwd,
514
+ env,
515
+ prompt: formatSpawnPrompt(request),
516
+ meta: typeof request.model === 'string' && request.model
517
+ ? { 'interlock/model': request.model }
518
+ : undefined,
519
+ model: request.model,
520
+ modelMap,
521
+ timeoutMs: budget,
522
+ onEvent: event => onEvent && onEvent({ label, ...event })
523
+ })
524
+ const result = parseAgentJson(text)
525
+ if (onEvent) {
526
+ onEvent({
527
+ type: 'spawn-done',
528
+ label,
529
+ stopReason,
530
+ ms: Date.now() - started,
531
+ parsed: Boolean(result)
532
+ })
533
+ }
534
+ return result
535
+ } catch (err) {
536
+ // Same contract as the workflow runtime: a spawn that could not produce
537
+ // a result is null, and the CLI decides what a null costs.
538
+ if (onEvent) onEvent({ type: 'spawn-failed', label, error: err && err.message })
539
+ return null
540
+ }
541
+ },
542
+
543
+ mapPipeline,
544
+
545
+ runCli(argv, opts = {}) {
546
+ return runCli(argv, { cwd, env, ...opts })
547
+ }
548
+ }
549
+
550
+ return assertWorkflowHost(host, 'ACP host')
551
+ }