@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/host/acp.mjs
ADDED
|
@@ -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
|
+
}
|