viewable-one 0.0.0-stage → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/acp.mjs
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
import { imageAttachments } from "./imageAttachments.mjs";
|
|
2
|
+
// SPEAKING ACP, and translating it into the only event stream this runner
|
|
3
|
+
// knows.
|
|
4
|
+
//
|
|
5
|
+
// Added 2026-09-02 for brief 15 decision 8 ("Engine layer: ACP beside the
|
|
6
|
+
// existing Claude runner"), on the findings of the spike in
|
|
7
|
+
// spikes/acp-2026-09-02/. That spike's `capture/*.jsonl` is the evidence for
|
|
8
|
+
// every shape asserted below; where a line here says "verified", it means a
|
|
9
|
+
// real adapter sent it, not that a doc implied it.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS FILE EXISTS AT ALL, rather than a second `args` row in agents.mjs.
|
|
12
|
+
// Claude Code speaks `--output-format stream-json`: one JSON object per line
|
|
13
|
+
// on stdout, and the runner's whole apparatus - the live terminal file, the
|
|
14
|
+
// stall watchdog's tool_use/tool_result pairing, the result envelope that
|
|
15
|
+
// carries cost and turn count, the session id extraction - reads that format
|
|
16
|
+
// and nothing else. ACP speaks JSON-RPC 2.0 over the same pipe, with the
|
|
17
|
+
// events arriving as `session/update` NOTIFICATIONS and the turn's outcome as
|
|
18
|
+
// a RESPONSE to a request the client made. Nothing about it looks like a
|
|
19
|
+
// stream-json line.
|
|
20
|
+
//
|
|
21
|
+
// So this module owns the whole difference. It runs the adapter, speaks the
|
|
22
|
+
// protocol, and writes stream-json lines into a stream of its own that the
|
|
23
|
+
// runner's `attachChild` reads exactly as it reads a real Claude child's
|
|
24
|
+
// stdout. The mapping, and it is the point of the file:
|
|
25
|
+
//
|
|
26
|
+
// agent_message_chunk -> {"type":"assistant", message.content:[{type:"text"}]}
|
|
27
|
+
// tool_call (pending) -> {"type":"assistant", message.content:[{type:"tool_use", id}]}
|
|
28
|
+
// tool_call_update -> {"type":"user", message.content:[{type:"tool_result", tool_use_id}]}
|
|
29
|
+
// (completed/failed) ... which is what re-arms the stall watchdog
|
|
30
|
+
// usage_update -> {"type":"assistant", message.usage:{input_tokens}}
|
|
31
|
+
// ({used, size}) ... which is outcome.contextFill, for free
|
|
32
|
+
// session/prompt reply -> {"type":"result", is_error, total_cost_usd, num_turns}
|
|
33
|
+
// session/new reply -> {"type":"system", session_id} (the sidecar's id)
|
|
34
|
+
//
|
|
35
|
+
// The consequence is that `runRequest`, `formatVerdict`, `describeOutcome`,
|
|
36
|
+
// the live log ring and the frame need NO branch for this engine. An ACP
|
|
37
|
+
// cost is an ESTIMATE and the envelope says so (see ACP_PRICES).
|
|
38
|
+
//
|
|
39
|
+
// WHAT IS DELIBERATELY NOT MAPPED. `available_commands_update` (the local
|
|
40
|
+
// skill list - noise), `current_mode_update`, and the `_meta.claudeCode.*`
|
|
41
|
+
// side channels. A line the runner would ignore is a line worth not writing:
|
|
42
|
+
// the log is capped, and the frame draws it.
|
|
43
|
+
|
|
44
|
+
import { spawn } from "node:child_process";
|
|
45
|
+
import { PassThrough } from "node:stream";
|
|
46
|
+
import { Readable, Writable } from "node:stream";
|
|
47
|
+
import { createRequire } from "node:module";
|
|
48
|
+
import { accessSync, constants, readFileSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
50
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
51
|
+
|
|
52
|
+
import { DEFAULTS, attachChild } from "./child.mjs";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- the money
|
|
55
|
+
//
|
|
56
|
+
// ACP REPORTS NO DOLLARS, and that is the one thing the bridge already had
|
|
57
|
+
// that this engine gives up. The spike checked it rather than assumed it:
|
|
58
|
+
// `usage_update` carries `{used, size}` - tokens - and the `session/prompt`
|
|
59
|
+
// response carries `usage` and `_meta.quota.model_usage` with per-model token
|
|
60
|
+
// counts, and NO cost field anywhere. The ACP schema allows an optional
|
|
61
|
+
// `cost {amount, currency}` on `usage_update`; claude-agent-acp 0.73.0 does
|
|
62
|
+
// not send one.
|
|
63
|
+
//
|
|
64
|
+
// So the figure on an ACP receipt is COMPUTED HERE, from token counts times
|
|
65
|
+
// the table below, and every reader is told so: the envelope carries
|
|
66
|
+
// `cost_source: "estimate"` and the session sidecar carries `costSource: "estimate"`, so the
|
|
67
|
+
// receipt says the figure is an estimate.
|
|
68
|
+
//
|
|
69
|
+
// THE TABLE IS A GUESS WITH A DATE ON IT, not a measurement.
|
|
70
|
+
//
|
|
71
|
+
// - Checked 2026-09-02 against Anthropic's published list prices per
|
|
72
|
+
// million tokens. Input/output are the list figures; cache write is the
|
|
73
|
+
// documented ~1.25x of input and cache read the documented ~0.1x.
|
|
74
|
+
// - A session on a Claude subscription (which is how every session on this
|
|
75
|
+
// machine runs - the adapter authenticates through the CLI's own
|
|
76
|
+
// credentials, no API key) is not billed per token AT ALL. The estimate
|
|
77
|
+
// is therefore an answer to "what would this have cost on the API",
|
|
78
|
+
// which is the only comparable number.
|
|
79
|
+
// - Prices move. When one does, this table is wrong and nothing will say
|
|
80
|
+
// so. That is the price of the engine, and it is why the number is
|
|
81
|
+
// labelled everywhere it is shown rather than in a comment here alone.
|
|
82
|
+
//
|
|
83
|
+
// Keyed by the model id the adapter reports in `_meta.quota.model_usage`
|
|
84
|
+
// (the capture names `claude-opus-4-6` and `claude-haiku-4-5-20251001`), and
|
|
85
|
+
// matched by longest PREFIX so a dated snapshot resolves to its family.
|
|
86
|
+
/** How long Stop waits for the adapter to ACT on `session/cancel` before the
|
|
87
|
+
* kill behind it starts. Two seconds is a round trip on a pipe with room to
|
|
88
|
+
* spare, and it is only ever paid by an adapter that is ignoring the cancel -
|
|
89
|
+
* one that answers ends the wait the moment its turn comes back. See
|
|
90
|
+
* cancelTurn, and brief 21's first windows-latest run for why it exists. */
|
|
91
|
+
export const CANCEL_GRACE_MS = 2_000;
|
|
92
|
+
|
|
93
|
+
const price = (input, output) => ({
|
|
94
|
+
input,
|
|
95
|
+
output,
|
|
96
|
+
// The documented multipliers, not separately published figures.
|
|
97
|
+
cacheWrite: input * 1.25,
|
|
98
|
+
cacheRead: input * 0.1,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** USD per MILLION tokens. See the block comment above before trusting it. */
|
|
102
|
+
export const ACP_PRICES = {
|
|
103
|
+
"claude-opus-5": price(5, 25),
|
|
104
|
+
"claude-opus-4-8": price(5, 25),
|
|
105
|
+
"claude-opus-4-7": price(5, 25),
|
|
106
|
+
"claude-opus-4-6": price(5, 25),
|
|
107
|
+
"claude-sonnet-5": price(2, 10),
|
|
108
|
+
"claude-sonnet-4-6": price(3, 15),
|
|
109
|
+
"claude-sonnet-4-5": price(3, 15),
|
|
110
|
+
"claude-haiku-4-5": price(1, 5),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// What an unrecognised model is priced at. NOT zero: a model this table has
|
|
114
|
+
// never heard of that silently costs nothing is a receipt that lies low. Opus
|
|
115
|
+
// rates are the most expensive row, so an unknown model is over-estimated
|
|
116
|
+
// rather than under-estimated.
|
|
117
|
+
const UNKNOWN_MODEL_PRICE = ACP_PRICES["claude-opus-5"];
|
|
118
|
+
|
|
119
|
+
/** The price row for a model id, by longest prefix. */
|
|
120
|
+
export function priceFor(model) {
|
|
121
|
+
if (typeof model !== "string" || model.trim() === "") return UNKNOWN_MODEL_PRICE;
|
|
122
|
+
let best = null;
|
|
123
|
+
for (const [id, row] of Object.entries(ACP_PRICES)) {
|
|
124
|
+
if (model.startsWith(id) && (best === null || id.length > best.id.length)) {
|
|
125
|
+
best = { id, row };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return best ? best.row : UNKNOWN_MODEL_PRICE;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Dollars for one `_meta.quota.model_usage` entry list, or for the flat
|
|
132
|
+
* `usage` object when the per-model breakdown is absent.
|
|
133
|
+
*
|
|
134
|
+
* The field names are the adapter's, taken from the capture:
|
|
135
|
+
* `{totalTokens, inputTokens, cachedInputTokens, cachedWriteTokens,
|
|
136
|
+
* outputTokens, reasoningOutputTokens}`. `totalTokens` is NOT summed - it
|
|
137
|
+
* already includes the others, and adding it would triple-count.
|
|
138
|
+
*
|
|
139
|
+
* Returns null when there is nothing to price, so a caller can leave the
|
|
140
|
+
* figure off the receipt entirely rather than print a confident $0.00. */
|
|
141
|
+
export function estimateCostUsd(usage) {
|
|
142
|
+
const rows = Array.isArray(usage?._meta?.quota?.model_usage)
|
|
143
|
+
? usage._meta.quota.model_usage
|
|
144
|
+
: Array.isArray(usage?.model_usage)
|
|
145
|
+
? usage.model_usage
|
|
146
|
+
: null;
|
|
147
|
+
const per = (counts, model) => {
|
|
148
|
+
const p = priceFor(model);
|
|
149
|
+
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
150
|
+
return (
|
|
151
|
+
(n(counts?.inputTokens) * p.input +
|
|
152
|
+
n(counts?.outputTokens) * p.output +
|
|
153
|
+
// Reasoning tokens are output tokens that were billed as output.
|
|
154
|
+
n(counts?.reasoningOutputTokens) * p.output +
|
|
155
|
+
n(counts?.cachedWriteTokens) * p.cacheWrite +
|
|
156
|
+
n(counts?.cachedInputTokens) * p.cacheRead) /
|
|
157
|
+
1_000_000
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
if (rows && rows.length > 0) {
|
|
161
|
+
let total = 0;
|
|
162
|
+
for (const row of rows) total += per(row?.token_count, row?.model);
|
|
163
|
+
return total;
|
|
164
|
+
}
|
|
165
|
+
// The flat shape, whose field names differ from the per-model one
|
|
166
|
+
// (cachedReadTokens vs cachedInputTokens). Both are in the capture.
|
|
167
|
+
const flat = usage?.usage ?? usage;
|
|
168
|
+
if (flat && typeof flat === "object") {
|
|
169
|
+
const counts = {
|
|
170
|
+
inputTokens: flat.inputTokens,
|
|
171
|
+
outputTokens: flat.outputTokens,
|
|
172
|
+
cachedInputTokens: flat.cachedReadTokens ?? flat.cachedInputTokens,
|
|
173
|
+
cachedWriteTokens: flat.cachedWriteTokens,
|
|
174
|
+
};
|
|
175
|
+
const any = Object.values(counts).some((v) => Number.isFinite(Number(v)) && Number(v) > 0);
|
|
176
|
+
if (any) return per(counts, null);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --------------------------------------------------------- the permission door
|
|
182
|
+
//
|
|
183
|
+
// ACP puts the allowlist ON THE CLIENT, which is the shape the bridge wanted
|
|
184
|
+
// all along. `session/request_permission` arrives as a REQUEST carrying
|
|
185
|
+
// `toolCall.name` - the raw Claude tool name, `mcp__spikeboard__spike_ping`
|
|
186
|
+
// in the capture - and `options` labelled by `kind`: allow_once, allow_always,
|
|
187
|
+
// reject_once, reject_always. The spike answered from a regex list and the
|
|
188
|
+
// turn continued, so this is verified rather than designed.
|
|
189
|
+
//
|
|
190
|
+
// CLAUDE_TOOLS is the list, unchanged and un-forked: the same strings the
|
|
191
|
+
// `claude` engine hands to `--allowedTools`, matched here instead of there.
|
|
192
|
+
// One list, two enforcement points, no second place to keep in step.
|
|
193
|
+
//
|
|
194
|
+
// ALLOW_ONCE, NEVER ALLOW_ALWAYS. `allow_always` is cheaper - the adapter
|
|
195
|
+
// stops asking - and it is wrong here, because "always" outlives the decision
|
|
196
|
+
// that justified it: `Bash(git:*)` allows `git push` on the strength of a
|
|
197
|
+
// `git status` the model asked about first. Asking every time costs a
|
|
198
|
+
// round trip inside the adapter's own process and keeps every answer a
|
|
199
|
+
// decision about the call in front of it.
|
|
200
|
+
|
|
201
|
+
/** One entry of CLAUDE_TOOLS, split into the tool and its argument pattern.
|
|
202
|
+
* `Read` -> {tool:"Read", arg:null}; `Bash(git:*)` -> {tool:"Bash", arg:"git:*"}. */
|
|
203
|
+
function parseRule(rule) {
|
|
204
|
+
const match = /^([A-Za-z_][\w-]*)\((.*)\)$/.exec(String(rule ?? "").trim());
|
|
205
|
+
return match ? { tool: match[1], arg: match[2] } : { tool: String(rule ?? "").trim(), arg: null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Does the allowlist cover this tool call?
|
|
209
|
+
*
|
|
210
|
+
* Three matches, and no fourth:
|
|
211
|
+
* exact `Read` covers the tool named Read.
|
|
212
|
+
* mcp namespace `mcp__glytch` covers `mcp__glytch__report`, because Claude
|
|
213
|
+
* Code namespaces an MCP server's tools that way and the
|
|
214
|
+
* allowlist names the SERVER (renamed from `board` to
|
|
215
|
+
* `glytch` 2026-09-12, brief 31, Move 3b - see agents.mjs's
|
|
216
|
+
* CLAUDE_TOOLS).
|
|
217
|
+
* command prefix `Bash(git:*)` covers a Bash call whose command starts with
|
|
218
|
+
* `git`. The `:*` suffix is Claude Code's own spelling.
|
|
219
|
+
*
|
|
220
|
+
* Anything else is rejected. A rule this function cannot understand matches
|
|
221
|
+
* NOTHING - failing closed is the only safe direction for a door that spends
|
|
222
|
+
* money and writes files. */
|
|
223
|
+
export function toolCallAllowed(toolCall, allowTools = []) {
|
|
224
|
+
const name = typeof toolCall?.name === "string" && toolCall.name.trim() !== ""
|
|
225
|
+
? toolCall.name.trim()
|
|
226
|
+
// The capture shows `title` carrying the same string when `name` is
|
|
227
|
+
// absent on an update; the permission request itself always has `name`.
|
|
228
|
+
: typeof toolCall?.title === "string"
|
|
229
|
+
? toolCall.title.trim()
|
|
230
|
+
: "";
|
|
231
|
+
if (name === "") return false;
|
|
232
|
+
const command =
|
|
233
|
+
typeof toolCall?.rawInput?.command === "string" ? toolCall.rawInput.command.trim() : "";
|
|
234
|
+
for (const raw of allowTools) {
|
|
235
|
+
const { tool, arg } = parseRule(raw);
|
|
236
|
+
if (tool === "") continue;
|
|
237
|
+
if (arg === null) {
|
|
238
|
+
if (name === tool) return true;
|
|
239
|
+
// The MCP namespace, and only as a namespace: `mcp__glytch` must not
|
|
240
|
+
// match `mcp__glytched__x`, so the separator is part of the test.
|
|
241
|
+
if (name.startsWith(`${tool}__`)) return true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (name !== tool) continue;
|
|
245
|
+
const prefix = arg.endsWith(":*") ? arg.slice(0, -2) : arg;
|
|
246
|
+
if (prefix === "" || command === "") continue;
|
|
247
|
+
if (command === prefix || command.startsWith(`${prefix} `)) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The option id to answer a permission request with.
|
|
253
|
+
*
|
|
254
|
+
* Picked by the option's own `kind` rather than by position: the adapter
|
|
255
|
+
* labels them, the labels are stable, and an index is a bet on an order
|
|
256
|
+
* nobody promised. Returns null when the agent offered nothing usable, which
|
|
257
|
+
* the caller turns into `outcome: "cancelled"` - the protocol's own answer
|
|
258
|
+
* for "the client cannot decide this". */
|
|
259
|
+
export function permissionOptionId(options, allow) {
|
|
260
|
+
const list = Array.isArray(options) ? options : [];
|
|
261
|
+
const of = (kind) => list.find((o) => o?.kind === kind)?.optionId ?? null;
|
|
262
|
+
return allow
|
|
263
|
+
? of("allow_once")
|
|
264
|
+
: of("reject_once") ?? of("reject_always");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ------------------------------------------------------------ the executable
|
|
268
|
+
//
|
|
269
|
+
// THE ADAPTER SHIPS ITS OWN CLAUDE CODE, and it is not the one this machine
|
|
270
|
+
// runs. claude-agent-acp 0.73.0 wraps @anthropic-ai/claude-agent-sdk 0.3.257,
|
|
271
|
+
// whose darwin-arm64 binary reports 2.1.257; the `claude` on this machine's
|
|
272
|
+
// PATH is 2.1.34. Two Claude Codes on one box, one of them invisible, is how
|
|
273
|
+
// a session behaves differently from every other session on the board for a
|
|
274
|
+
// reason nobody can see - and every bound in this bridge (the tool fence, the
|
|
275
|
+
// disallowed list, the flags in agents.mjs) was checked against 2.1.34.
|
|
276
|
+
//
|
|
277
|
+
// CLAUDE_CODE_EXECUTABLE pins the run to a chosen binary. The spike ran that
|
|
278
|
+
// way from the first prompt and this does the same. The order is: what the
|
|
279
|
+
// caller already set, then the bridge's own PM_CLAUDE_BIN (so the fake binary
|
|
280
|
+
// in the test suite is honoured here exactly as it is for the claude engine),
|
|
281
|
+
// then whatever `claude` is on PATH. If none of those resolve, the variable
|
|
282
|
+
// is left unset and the adapter uses its bundled copy - which is a working
|
|
283
|
+
// session rather than a refusal, and the receipt says which happened.
|
|
284
|
+
export function resolveClaudeExecutable(env = process.env) {
|
|
285
|
+
const explicit = env.CLAUDE_CODE_EXECUTABLE?.trim();
|
|
286
|
+
if (explicit) return { path: explicit, source: "CLAUDE_CODE_EXECUTABLE" };
|
|
287
|
+
const pinned = env.PM_CLAUDE_BIN?.trim();
|
|
288
|
+
if (pinned && isAbsolute(pinned) && canExecute(pinned)) {
|
|
289
|
+
return { path: pinned, source: "PM_CLAUDE_BIN" };
|
|
290
|
+
}
|
|
291
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
292
|
+
if (dir.trim() === "") continue;
|
|
293
|
+
const candidate = join(dir, "claude");
|
|
294
|
+
if (canExecute(candidate)) return { path: candidate, source: "PATH" };
|
|
295
|
+
}
|
|
296
|
+
return { path: null, source: "none (the adapter's bundled Claude Code will run)" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function canExecute(path) {
|
|
300
|
+
try {
|
|
301
|
+
accessSync(path, constants.X_OK);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Where the adapter's stdio entrypoint is, or null.
|
|
309
|
+
*
|
|
310
|
+
* Resolved from bridge/, which is where `npm install` puts it, and
|
|
311
|
+
* overridable by env - which is both the escape hatch for an odd install and
|
|
312
|
+
* the seam the tests drive, because a fake adapter is the only way to test
|
|
313
|
+
* the handshake, a rejected permission and a cancel without spending.
|
|
314
|
+
*
|
|
315
|
+
* THE SPECIFIER NAMES A FILE, NOT A PACKAGE, and that is not fussiness.
|
|
316
|
+
* claude-agent-acp's `main` is `dist/lib.js` - the LIBRARY, for embedding the
|
|
317
|
+
* adapter in another program - while the thing that speaks JSON-RPC on stdio
|
|
318
|
+
* is `dist/index.js`, its `bin`. Resolving the package name gets the wrong
|
|
319
|
+
* file, or (because the package exports only an `import` condition for ".")
|
|
320
|
+
* throws outright, which is what it did the first time this ran. The spike
|
|
321
|
+
* used the same path for the same reason. */
|
|
322
|
+
export function adapterEntry(specifier, env = process.env) {
|
|
323
|
+
const override = env.PM_ACP_ADAPTER?.trim();
|
|
324
|
+
if (override) return override;
|
|
325
|
+
try {
|
|
326
|
+
return createRequire(import.meta.url).resolve(specifier);
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ----------------------------------------------------------- the translation
|
|
333
|
+
|
|
334
|
+
/** The stream-json line an ACP `session/update` becomes, or null for the
|
|
335
|
+
* kinds the runner has no use for. Pure, so the mapping is testable without
|
|
336
|
+
* a process. */
|
|
337
|
+
export function lineForUpdate(update, sessionId) {
|
|
338
|
+
const kind = update?.sessionUpdate;
|
|
339
|
+
const base = sessionId ? { session_id: sessionId } : {};
|
|
340
|
+
if (kind === "agent_message_chunk" || kind === "agent_thought_chunk") {
|
|
341
|
+
const text = update?.content?.type === "text" ? update.content.text : null;
|
|
342
|
+
if (typeof text !== "string" || text === "") return null;
|
|
343
|
+
return {
|
|
344
|
+
...base,
|
|
345
|
+
type: "assistant",
|
|
346
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (kind === "tool_call") {
|
|
350
|
+
if (typeof update?.toolCallId !== "string") return null;
|
|
351
|
+
// NO `usage` ON THIS LINE, and it is not an oversight. The runner's
|
|
352
|
+
// scanner is an if/else-if chain in which a usage field wins over a
|
|
353
|
+
// content field - an assistant event carrying both records the usage and
|
|
354
|
+
// never sees the tool_use, so the stall watchdog would never arm. The
|
|
355
|
+
// two facts therefore travel as two lines.
|
|
356
|
+
return {
|
|
357
|
+
...base,
|
|
358
|
+
type: "assistant",
|
|
359
|
+
message: {
|
|
360
|
+
role: "assistant",
|
|
361
|
+
content: [
|
|
362
|
+
{ type: "tool_use", id: update.toolCallId, name: update.title ?? "tool", input: update.rawInput ?? {} },
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (kind === "tool_call_update") {
|
|
368
|
+
// Only a TERMINAL update closes the tool. The capture shows four updates
|
|
369
|
+
// for one call - rawInput filling in, then content, then status - and
|
|
370
|
+
// closing on the first would disarm the stall watchdog while the tool was
|
|
371
|
+
// still running, which is the exact condition it exists to distinguish.
|
|
372
|
+
const status = update?.status;
|
|
373
|
+
if (status !== "completed" && status !== "failed") return null;
|
|
374
|
+
if (typeof update?.toolCallId !== "string") return null;
|
|
375
|
+
return {
|
|
376
|
+
...base,
|
|
377
|
+
type: "user",
|
|
378
|
+
message: {
|
|
379
|
+
role: "user",
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: "tool_result",
|
|
383
|
+
tool_use_id: update.toolCallId,
|
|
384
|
+
is_error: status === "failed",
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (kind === "usage_update") {
|
|
391
|
+
const used = Number(update?.used);
|
|
392
|
+
if (!Number.isFinite(used)) return null;
|
|
393
|
+
// contextFill = input + cache_read + cache_creation (child.mjs), and ACP
|
|
394
|
+
// gives one number for the whole window. It goes in as input_tokens so
|
|
395
|
+
// the sum is that number and nothing is invented for the other two.
|
|
396
|
+
return {
|
|
397
|
+
...base,
|
|
398
|
+
type: "assistant",
|
|
399
|
+
message: {
|
|
400
|
+
role: "assistant",
|
|
401
|
+
usage: { input_tokens: used, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The result envelope one `session/prompt` response becomes.
|
|
409
|
+
*
|
|
410
|
+
* `is_error` follows the stop reason: `end_turn` and `max_turn_request`
|
|
411
|
+
* finished; `refusal` and `cancelled` did not. An unknown stop reason is NOT
|
|
412
|
+
* an error - a future adapter inventing one must not turn finished work into
|
|
413
|
+
* a failed receipt, which is the mistake that cost 26 sessions their verdict
|
|
414
|
+
* (child.mjs, "a finished stream is finished"). */
|
|
415
|
+
export function resultEnvelope({ response, sessionId, turns, durationMs, priorCostUsd = 0 }) {
|
|
416
|
+
const stop = response?.stopReason;
|
|
417
|
+
const isError = stop === "refusal" || stop === "cancelled";
|
|
418
|
+
// CUMULATIVE, because the runner keeps only the LAST envelope (child.mjs,
|
|
419
|
+
// "keep the LAST one") and writes its total_cost_usd into the session
|
|
420
|
+
// sidecar, which is what the receipt shows. Claude Code's own
|
|
421
|
+
// total_cost_usd is a session total; ACP
|
|
422
|
+
// reports per-TURN usage and nothing else, so a per-turn figure here would
|
|
423
|
+
// charge a five-turn session for its fifth turn only.
|
|
424
|
+
//
|
|
425
|
+
// Caught on the first real two-turn run: turn one estimated $0.0313 (it
|
|
426
|
+
// paid the cache WRITE), turn two $0.0026, and the receipt would have said
|
|
427
|
+
// $0.0026 for the pair. num_turns and duration_ms were already cumulative,
|
|
428
|
+
// which is exactly why the mismatch was invisible until the money was
|
|
429
|
+
// looked at.
|
|
430
|
+
const turnCost = estimateCostUsd(response);
|
|
431
|
+
const cost = turnCost === null && priorCostUsd === 0 ? null : priorCostUsd + (turnCost ?? 0);
|
|
432
|
+
return {
|
|
433
|
+
type: "result",
|
|
434
|
+
subtype: isError ? `error_${stop}` : "success",
|
|
435
|
+
is_error: isError,
|
|
436
|
+
stop_reason: stop ?? "unknown",
|
|
437
|
+
session_id: sessionId,
|
|
438
|
+
num_turns: turns,
|
|
439
|
+
duration_ms: durationMs,
|
|
440
|
+
...(cost !== null ? { total_cost_usd: cost, cost_source: "estimate" } : {}),
|
|
441
|
+
usage: response?.usage ?? null,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --------------------------------------------------------------- the runner
|
|
446
|
+
|
|
447
|
+
/** Run one request on an ACP adapter, and give back what `runChild` and
|
|
448
|
+
* `runStreamingChild` give back.
|
|
449
|
+
*
|
|
450
|
+
* The option list is theirs, deliberately: `runRequest` builds ONE options
|
|
451
|
+
* object and hands it to whichever run function the engine table names, so a
|
|
452
|
+
* field this engine does not use (images on the one-shot path) is ignored the
|
|
453
|
+
* way the other two ignore fields they do not use. `acp` is the only addition
|
|
454
|
+
* and the other two never read it.
|
|
455
|
+
*
|
|
456
|
+
* PERSISTENT VERSUS ONE-SHOT is decided the same way runStreamingChild
|
|
457
|
+
* decides whether to watch for replies: by whether a replyDir was passed. A
|
|
458
|
+
* persistent request holds the session open after its first turn and delivers
|
|
459
|
+
* each reply file as a SECOND `session/prompt` into the same sessionId - the
|
|
460
|
+
* spike proved that works and costs no stdin trick. A one-shot request ends
|
|
461
|
+
* when its single turn does. */
|
|
462
|
+
export function runAcpChild(opts) {
|
|
463
|
+
const {
|
|
464
|
+
bin,
|
|
465
|
+
args = [],
|
|
466
|
+
cwd,
|
|
467
|
+
env,
|
|
468
|
+
maxMs,
|
|
469
|
+
maxLogBytes = DEFAULTS.maxLogBytes,
|
|
470
|
+
killGraceMs = DEFAULTS.killGraceMs,
|
|
471
|
+
stallMs = DEFAULTS.stallMs,
|
|
472
|
+
onChunk,
|
|
473
|
+
prompt,
|
|
474
|
+
images,
|
|
475
|
+
replyDir,
|
|
476
|
+
replyPrefix,
|
|
477
|
+
replyPollMs = 2000,
|
|
478
|
+
answerPoll,
|
|
479
|
+
answerPollMs = 4000,
|
|
480
|
+
stopCheck,
|
|
481
|
+
stopPollMs = 2000,
|
|
482
|
+
acp = {},
|
|
483
|
+
} = opts;
|
|
484
|
+
|
|
485
|
+
return new Promise((resolve) => {
|
|
486
|
+
try { imageAttachments(images); } catch (error) { resolve({ ok: false, log: "", spawnError: error.message }); return; }
|
|
487
|
+
if (typeof args[0] !== "string" || args[0].trim() === "") {
|
|
488
|
+
resolve({
|
|
489
|
+
ok: false,
|
|
490
|
+
log: "",
|
|
491
|
+
spawnError:
|
|
492
|
+
`The ACP adapter for this engine is not installed. Run \`npm install\` in bridge/ ` +
|
|
493
|
+
`(it pulls ${acp.adapter ?? "the adapter"} and @agentclientprotocol/sdk), or set ` +
|
|
494
|
+
`PM_ACP_ADAPTER to the adapter's entrypoint.`,
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The pinned binary. Set on the CHILD's environment rather than the
|
|
500
|
+
// runner's, so one ACP session cannot change what the next one runs.
|
|
501
|
+
const pinned = resolveClaudeExecutable(env ?? {});
|
|
502
|
+
const childEnvironment = { ...(env ?? {}) };
|
|
503
|
+
if (pinned.path) childEnvironment.CLAUDE_CODE_EXECUTABLE = pinned.path;
|
|
504
|
+
|
|
505
|
+
let child;
|
|
506
|
+
try {
|
|
507
|
+
child = spawn(bin, args, { cwd, env: childEnvironment, stdio: ["pipe", "pipe", "pipe"] });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
resolve({ ok: false, spawnError: error instanceof Error ? error.message : String(error), log: "" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// An EPIPE on the adapter's stdin must not throw out of the daemon. Same
|
|
513
|
+
// reasoning as runStreamingChild's: the guarded writes below report a
|
|
514
|
+
// dead pipe by their own return value.
|
|
515
|
+
child.stdin?.on("error", () => {});
|
|
516
|
+
|
|
517
|
+
// THE TRANSLATED STREAM. attachChild reads this as if it were the child's
|
|
518
|
+
// own stdout; the adapter's real stdout is JSON-RPC and goes to the SDK.
|
|
519
|
+
// stderr is the adapter's own, unchanged - a crash reason is one line and
|
|
520
|
+
// it belongs in the log the frame draws.
|
|
521
|
+
const translated = new PassThrough();
|
|
522
|
+
let sessionId = null;
|
|
523
|
+
const emit = (obj) => {
|
|
524
|
+
if (obj === null) return;
|
|
525
|
+
try {
|
|
526
|
+
translated.write(`${JSON.stringify(obj)}\n`);
|
|
527
|
+
} catch { /* the stream is gone; the outcome is already settling */ }
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const facade = {
|
|
531
|
+
stdout: translated,
|
|
532
|
+
stderr: child.stderr,
|
|
533
|
+
kill: (signal) => child.kill(signal),
|
|
534
|
+
on: (event, fn) => {
|
|
535
|
+
// Only the two attachChild listens for. Forwarding the rest would
|
|
536
|
+
// hand it events about a pipe it does not own.
|
|
537
|
+
if (event === "error" || event === "close") child.on(event, fn);
|
|
538
|
+
return facade;
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const handle = attachChild(facade, {
|
|
543
|
+
resolve,
|
|
544
|
+
maxMs,
|
|
545
|
+
maxLogBytes,
|
|
546
|
+
killGraceMs,
|
|
547
|
+
onChunk,
|
|
548
|
+
stopPollMs,
|
|
549
|
+
// THE STOP PATH, and the one place this engine has something better
|
|
550
|
+
// than SIGTERM. `session/cancel` lets the adapter stop its model
|
|
551
|
+
// request and flush its pending updates; the kill that attachChild does
|
|
552
|
+
// next is the backstop for an adapter that ignores it. Wrapping the
|
|
553
|
+
// caller's stopCheck rather than adding a second watcher keeps the stop
|
|
554
|
+
// file consumed exactly once.
|
|
555
|
+
stopCheck: stopCheck
|
|
556
|
+
? async () => {
|
|
557
|
+
if (!(await stopCheck())) return false;
|
|
558
|
+
// THE STOP IS DECIDED HERE, before the cancel is sent and before
|
|
559
|
+
// the wait inside it. `cancelTurn` now waits for the turn to come
|
|
560
|
+
// back cancelled, and a turn coming back can settle the outcome on
|
|
561
|
+
// its own - a one-shot ends when its queue drains and stdin closes
|
|
562
|
+
// - so a flag set only after `stopCheck` returned would arrive
|
|
563
|
+
// after `settle` had already read it, and a stopped session would
|
|
564
|
+
// report itself as one that simply finished. attachChild sets the
|
|
565
|
+
// same field for the same reason a moment later; setting it twice
|
|
566
|
+
// is free.
|
|
567
|
+
handle.state.stoppedByUser = true;
|
|
568
|
+
await cancelTurn();
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
: undefined,
|
|
572
|
+
// Identical to runStreamingChild's, and for the identical reason: a
|
|
573
|
+
// session whose last turn ended is DONE, and the wall clock closing its
|
|
574
|
+
// reply window is bookkeeping. See child.mjs.
|
|
575
|
+
onWallClock: (state) => { if (!state.sawResult) state.timedOut = true; },
|
|
576
|
+
okOf: (state, code) =>
|
|
577
|
+
state.sawResult
|
|
578
|
+
? !state.resultWasError
|
|
579
|
+
: !state.timedOut && !state.stoppedByUser && !state.stalled && code === 0,
|
|
580
|
+
extraFields: (state) => ({
|
|
581
|
+
stalled: state.stalled,
|
|
582
|
+
...(state.stalled ? { stallMs } : {}),
|
|
583
|
+
finished: state.sawResult,
|
|
584
|
+
finishedWithError: state.sawResult && state.resultWasError,
|
|
585
|
+
acpSessionId: sessionId,
|
|
586
|
+
// Which Claude Code actually ran, on the receipt rather than in
|
|
587
|
+
// anyone's head. The whole point of pinning is that it is checkable.
|
|
588
|
+
claudeExecutable: pinned.path,
|
|
589
|
+
claudeExecutableSource: pinned.source,
|
|
590
|
+
}),
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ------------------------------------------------------------- protocol
|
|
594
|
+
|
|
595
|
+
let connection = null;
|
|
596
|
+
let turns = 0;
|
|
597
|
+
let cancelled = false;
|
|
598
|
+
// Every turn's estimate, added up, because the receipt reads the last
|
|
599
|
+
// envelope only. See resultEnvelope.
|
|
600
|
+
let costSoFar = 0;
|
|
601
|
+
const startedAt = Date.now();
|
|
602
|
+
|
|
603
|
+
const cancelTurn = async () => {
|
|
604
|
+
cancelled = true;
|
|
605
|
+
if (!sessionId || !connection) return;
|
|
606
|
+
try {
|
|
607
|
+
await connection.agent.notify("session/cancel", { sessionId });
|
|
608
|
+
} catch { /* the adapter may already be gone */ return; }
|
|
609
|
+
// THE CANCEL NEEDS A MOMENT OF ITS OWN (2026-09-07, brief 21, the first
|
|
610
|
+
// windows-latest run). Sending the notification and returning made the
|
|
611
|
+
// comment above this block true only by accident of how POSIX kills
|
|
612
|
+
// work: `child.kill("SIGTERM")` is asynchronous delivery, so the
|
|
613
|
+
// adapter's event loop drained the cancel line it already had queued
|
|
614
|
+
// before its signal handler ran. Windows has no such moment - taskkill
|
|
615
|
+
// ends the process outright - and the adapter was killed with the cancel
|
|
616
|
+
// still unread, having stopped nothing and flushed nothing.
|
|
617
|
+
//
|
|
618
|
+
// So the stop waits for the in-flight turn to come BACK cancelled,
|
|
619
|
+
// bounded: an adapter that answers is given exactly as long as it takes,
|
|
620
|
+
// and one that ignores the cancel costs CANCEL_GRACE_MS before the kill
|
|
621
|
+
// that was always the backstop for it.
|
|
622
|
+
const until = Date.now() + CANCEL_GRACE_MS;
|
|
623
|
+
while (promptBusy && !handle.settled() && Date.now() < until) {
|
|
624
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// A serial queue for prompts. The adapter advertises promptQueueing, but
|
|
629
|
+
// the spike did not exercise it, so this sends the next prompt only after
|
|
630
|
+
// the previous turn has returned its stop reason. A reply that arrives
|
|
631
|
+
// mid-turn waits rather than racing an untested path.
|
|
632
|
+
const queue = [];
|
|
633
|
+
let promptBusy = false;
|
|
634
|
+
let ended = false;
|
|
635
|
+
|
|
636
|
+
let supportsImages = false;
|
|
637
|
+
const promptBlocks = (text, imageUrls) => {
|
|
638
|
+
const attachments = imageAttachments(imageUrls);
|
|
639
|
+
if (attachments.length && !supportsImages) throw new Error("This ACP adapter does not support reference images. Choose an image-capable agent and try again.");
|
|
640
|
+
return [{ type: "text", text }, ...attachments.map((image) => ({ type: "image", mimeType: image.mimeType, data: image.data }))];
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const runTurn = async (text, imageUrls) => {
|
|
644
|
+
if (!connection || !sessionId || cancelled) return;
|
|
645
|
+
turns += 1;
|
|
646
|
+
// A new turn means whatever envelope stood no longer describes the
|
|
647
|
+
// session: the stall clock restarts and the wall clock stops reading
|
|
648
|
+
// as "the reply window closed on finished work".
|
|
649
|
+
handle.markSent();
|
|
650
|
+
const at = Date.now();
|
|
651
|
+
let response;
|
|
652
|
+
try {
|
|
653
|
+
response = await connection.agent.request("session/prompt", {
|
|
654
|
+
sessionId,
|
|
655
|
+
prompt: promptBlocks(text, imageUrls),
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// A failed prompt is a failed TURN, not a crashed runner. It becomes
|
|
659
|
+
// an error envelope so the receipt says so in the same words a
|
|
660
|
+
// Claude Code error result would.
|
|
661
|
+
emit({
|
|
662
|
+
type: "result",
|
|
663
|
+
subtype: "error_during_execution",
|
|
664
|
+
is_error: true,
|
|
665
|
+
session_id: sessionId,
|
|
666
|
+
num_turns: turns,
|
|
667
|
+
duration_ms: Date.now() - at,
|
|
668
|
+
error: String(error?.message ?? error),
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const envelope = resultEnvelope({
|
|
673
|
+
response,
|
|
674
|
+
sessionId,
|
|
675
|
+
turns,
|
|
676
|
+
durationMs: Date.now() - startedAt,
|
|
677
|
+
priorCostUsd: costSoFar,
|
|
678
|
+
});
|
|
679
|
+
if (typeof envelope.total_cost_usd === "number") costSoFar = envelope.total_cost_usd;
|
|
680
|
+
emit(envelope);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const pump = async () => {
|
|
684
|
+
if (promptBusy) return;
|
|
685
|
+
promptBusy = true;
|
|
686
|
+
while (queue.length > 0 && !handle.settled() && !cancelled) {
|
|
687
|
+
const next = queue.shift();
|
|
688
|
+
await runTurn(next.text, next.images);
|
|
689
|
+
}
|
|
690
|
+
promptBusy = false;
|
|
691
|
+
// A one-shot request is over when its only turn is: closing stdin ends
|
|
692
|
+
// the adapter (verified, `stdinCloseEndsProcess` in the spike summary),
|
|
693
|
+
// which is what settles the outcome.
|
|
694
|
+
if (!replyDir && queue.length === 0 && !ended) {
|
|
695
|
+
ended = true;
|
|
696
|
+
try { child.stdin?.end(); } catch { /* already gone */ }
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const push = (text, imageUrls) => {
|
|
701
|
+
if (handle.settled() || cancelled || typeof text !== "string" || text.trim() === "") return;
|
|
702
|
+
queue.push({ text, images: imageUrls });
|
|
703
|
+
void pump();
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// --------------------------------------------------------- reply files
|
|
707
|
+
//
|
|
708
|
+
// THE SAME DOOR the streaming child has, behind a different knock. A
|
|
709
|
+
// reply file is delivered as a second `session/prompt` into the same
|
|
710
|
+
// sessionId rather than as a line on stdin, and everything else about it
|
|
711
|
+
// is unchanged: sorted by name, consumed BEFORE it is sent (an unlink
|
|
712
|
+
// that fails after a successful write is how a human's message arrives
|
|
713
|
+
// twice), a `from` label wrapped around a peer's words.
|
|
714
|
+
const watchReplies = () => {
|
|
715
|
+
if (!replyDir || !replyPrefix) return;
|
|
716
|
+
const watcher = handle.every(async () => {
|
|
717
|
+
if (handle.settled()) { clearInterval(watcher); return; }
|
|
718
|
+
try {
|
|
719
|
+
const files = (await readdir(replyDir))
|
|
720
|
+
.filter((f) => f.startsWith(replyPrefix) && f.endsWith(".json"))
|
|
721
|
+
.sort();
|
|
722
|
+
for (const file of files) {
|
|
723
|
+
if (handle.settled()) break;
|
|
724
|
+
const full = join(replyDir, file);
|
|
725
|
+
let text = null;
|
|
726
|
+
let replyImages;
|
|
727
|
+
let fromField = null;
|
|
728
|
+
try {
|
|
729
|
+
const data = JSON.parse(await readFile(full, "utf8"));
|
|
730
|
+
text = typeof data.message === "string" ? data.message : null;
|
|
731
|
+
replyImages = data.images;
|
|
732
|
+
fromField =
|
|
733
|
+
typeof data.from === "string" && data.from.trim() !== "" ? data.from.trim() : null;
|
|
734
|
+
} catch { continue; }
|
|
735
|
+
try { promptBlocks(text ?? "", replyImages); } catch (error) {
|
|
736
|
+
emit({ type: "result", subtype: "error_during_execution", is_error: true, session_id: sessionId, errors: [error.message] });
|
|
737
|
+
cancelled = true;
|
|
738
|
+
handle.kill();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
try { await unlink(full); } catch { continue; }
|
|
742
|
+
if (text) push(fromField ? `[message from ${fromField}] ${text}` : text, replyImages);
|
|
743
|
+
}
|
|
744
|
+
} catch { /* replyDir may not exist yet */ }
|
|
745
|
+
}, replyPollMs);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// Answers the human gave on the canvas after ask_user had already timed
|
|
749
|
+
// out. Same contract as runStreamingChild: onDelivered runs AFTER the
|
|
750
|
+
// message is accepted, never before, or a ruling is marked delivered to a
|
|
751
|
+
// session that never heard it.
|
|
752
|
+
let answerBusy = false;
|
|
753
|
+
const watchAnswers = () => {
|
|
754
|
+
if (!answerPoll) return;
|
|
755
|
+
const collect = async () => {
|
|
756
|
+
if (handle.settled()) return;
|
|
757
|
+
if (answerBusy) return;
|
|
758
|
+
answerBusy = true;
|
|
759
|
+
try {
|
|
760
|
+
for (const answer of await answerPoll()) {
|
|
761
|
+
if (handle.settled()) break;
|
|
762
|
+
push(answer.text);
|
|
763
|
+
if (answer.onDelivered) await answer.onDelivered();
|
|
764
|
+
}
|
|
765
|
+
} catch { /* a poll that throws is not an answer either way */ }
|
|
766
|
+
answerBusy = false;
|
|
767
|
+
};
|
|
768
|
+
handle.every(collect, answerPollMs);
|
|
769
|
+
answerPoll.subscribe?.(collect);
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// THE STALL WATCHDOG, byte for byte the rule runStreamingChild uses -
|
|
773
|
+
// armed only when the MODEL should be producing output (a turn open, no
|
|
774
|
+
// tool awaiting its result) - because the events it reads are now the
|
|
775
|
+
// same events. This is the whole reason the translation above bothers to
|
|
776
|
+
// emit tool_use/tool_result pairs at all.
|
|
777
|
+
const armStallWatchdog = () => {
|
|
778
|
+
const watcher = handle.every(() => {
|
|
779
|
+
const state = handle.state;
|
|
780
|
+
if (state.settled) return;
|
|
781
|
+
const armed = !state.sawResult && state.openToolUses.size === 0;
|
|
782
|
+
if (armed && Date.now() - state.lastStdoutAt >= stallMs) {
|
|
783
|
+
state.stalled = true;
|
|
784
|
+
handle.kill();
|
|
785
|
+
clearInterval(watcher);
|
|
786
|
+
}
|
|
787
|
+
}, Math.min(10_000, stallMs / 4));
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------- boot
|
|
791
|
+
|
|
792
|
+
const boot = async () => {
|
|
793
|
+
const sdk = await import("@agentclientprotocol/sdk");
|
|
794
|
+
const app = sdk
|
|
795
|
+
.client({ name: "pm-bridge" })
|
|
796
|
+
.onNotification("session/update", ({ params }) => {
|
|
797
|
+
if (!sessionId) sessionId = params?.sessionId ?? null;
|
|
798
|
+
emit(lineForUpdate(params?.update, sessionId));
|
|
799
|
+
})
|
|
800
|
+
.onRequest("session/request_permission", ({ params }) => {
|
|
801
|
+
const allow = toolCallAllowed(params?.toolCall, acp.allowTools ?? []);
|
|
802
|
+
const optionId = permissionOptionId(params?.options, allow);
|
|
803
|
+
// The decision goes in the LOG, as a tool_result-shaped note beside
|
|
804
|
+
// the call it answered, because a session that quietly did less
|
|
805
|
+
// than it was asked to is the failure this bridge keeps meeting.
|
|
806
|
+
emit({
|
|
807
|
+
session_id: sessionId,
|
|
808
|
+
type: "system",
|
|
809
|
+
subtype: "permission",
|
|
810
|
+
tool: params?.toolCall?.name ?? params?.toolCall?.title ?? null,
|
|
811
|
+
decision: allow ? "allow_once" : "reject_once",
|
|
812
|
+
option_id: optionId,
|
|
813
|
+
});
|
|
814
|
+
return optionId
|
|
815
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
816
|
+
: { outcome: { outcome: "cancelled" } };
|
|
817
|
+
})
|
|
818
|
+
// The two filesystem capabilities the client declares below. Served
|
|
819
|
+
// honestly rather than refused: an adapter told it may read files and
|
|
820
|
+
// then answered -32601 is an adapter that has been lied to.
|
|
821
|
+
.onRequest("fs/read_text_file", ({ params }) => ({
|
|
822
|
+
content: readFileSync(params.path, "utf8"),
|
|
823
|
+
}))
|
|
824
|
+
.onRequest("fs/write_text_file", ({ params }) => {
|
|
825
|
+
writeFileSync(params.path, params.content, "utf8");
|
|
826
|
+
// A write through this request leaves no tool call in the log, so
|
|
827
|
+
// it gets its own line: the in-place warning reads it (#735 review).
|
|
828
|
+
emit({ session_id: sessionId, type: "system", subtype: "file_write", path: params.path });
|
|
829
|
+
return null;
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const stream = sdk.ndJsonStream(
|
|
833
|
+
Writable.toWeb(child.stdin),
|
|
834
|
+
Readable.toWeb(child.stdout),
|
|
835
|
+
);
|
|
836
|
+
connection = app.connect(stream);
|
|
837
|
+
|
|
838
|
+
const init = await connection.agent.request("initialize", {
|
|
839
|
+
protocolVersion: Number(acp.protocolVersion ?? 1),
|
|
840
|
+
clientInfo: { name: "pm-bridge", version: "0.2.0" },
|
|
841
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
supportsImages = init?.agentCapabilities?.promptCapabilities?.image === true;
|
|
845
|
+
if (imageAttachments(images).length && !supportsImages) throw new Error("This ACP adapter does not support reference images. No model turn was started.");
|
|
846
|
+
|
|
847
|
+
// AUTHENTICATION IS NOT ATTEMPTED, EVER. An adapter that offers auth
|
|
848
|
+
// methods is one whose credentials are missing, and logging a session
|
|
849
|
+
// in from a daemon nobody is watching is not this runner's decision to
|
|
850
|
+
// make. It says so and stops, which is one refusal receipt instead of a
|
|
851
|
+
// hung session. (Codex is exactly this case - the spike got
|
|
852
|
+
// `-32000 Authentication required` from session/new and stopped there.)
|
|
853
|
+
const authMethods = Array.isArray(init?.authMethods) ? init.authMethods : [];
|
|
854
|
+
|
|
855
|
+
const params = {
|
|
856
|
+
cwd,
|
|
857
|
+
mcpServers: Array.isArray(acp.mcpServers) ? acp.mcpServers : [],
|
|
858
|
+
};
|
|
859
|
+
let created;
|
|
860
|
+
// RESUME IS UNTESTED and is treated as such. `loadSession: true` was
|
|
861
|
+
// advertised in the spike's initialize reply, but no session was ever
|
|
862
|
+
// loaded, so this TRIES and falls back to a fresh session rather than
|
|
863
|
+
// failing the run over a path nobody has watched work.
|
|
864
|
+
const resume = typeof acp.resumeSessionId === "string" ? acp.resumeSessionId.trim() : "";
|
|
865
|
+
if (resume !== "" && init?.agentCapabilities?.loadSession === true) {
|
|
866
|
+
try {
|
|
867
|
+
created = await connection.agent.request("session/load", { ...params, sessionId: resume });
|
|
868
|
+
sessionId = resume;
|
|
869
|
+
} catch (error) {
|
|
870
|
+
emit({
|
|
871
|
+
type: "system",
|
|
872
|
+
subtype: "resume_failed",
|
|
873
|
+
detail: `session/load failed (${String(error?.message ?? error)}); starting a fresh session`,
|
|
874
|
+
});
|
|
875
|
+
created = undefined;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (!created && !sessionId) {
|
|
879
|
+
try {
|
|
880
|
+
created = await connection.agent.request("session/new", params);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (authMethods.length > 0) {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`${acp.label ?? "the adapter"} needs authentication (${authMethods
|
|
885
|
+
.map((m) => m?.id)
|
|
886
|
+
.filter(Boolean)
|
|
887
|
+
.join(", ")}) and this runner never logs anything in. ` +
|
|
888
|
+
`Sign in with the engine's own CLI, then file the request again. ` +
|
|
889
|
+
`(${String(error?.message ?? error)})`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
sessionId = created?.sessionId ?? null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// The one line the sidecar reads. extractSessionIdFromLog takes the
|
|
898
|
+
// FIRST session_id in the log, so this has to come before any update
|
|
899
|
+
// line - which it does, because nothing has been prompted yet.
|
|
900
|
+
emit({
|
|
901
|
+
type: "system",
|
|
902
|
+
subtype: "init",
|
|
903
|
+
session_id: sessionId,
|
|
904
|
+
agent: init?.agentInfo?.name ?? null,
|
|
905
|
+
agent_version: init?.agentInfo?.version ?? null,
|
|
906
|
+
claude_executable: pinned.path,
|
|
907
|
+
claude_executable_source: pinned.source,
|
|
908
|
+
cost_source: "estimate",
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// MODEL AND MODE, through the method the spike found by getting the
|
|
912
|
+
// other one wrong: `session/set_config_option`, NOT
|
|
913
|
+
// `session/set_session_config_option`, which this adapter answers
|
|
914
|
+
// -32601. Both attempts are in capture/claude.probe.jsonl.
|
|
915
|
+
//
|
|
916
|
+
// A config option this adapter does not offer is SKIPPED rather than
|
|
917
|
+
// sent and failed: the options list comes back on session/new, so
|
|
918
|
+
// asking for a model the engine has no select for is a question that
|
|
919
|
+
// can be answered without a round trip. Effort is exactly that case -
|
|
920
|
+
// claude-agent-acp 0.73.0 offers no reasoning option at all - and
|
|
921
|
+
// commandFor has already recorded it as ignored.
|
|
922
|
+
const offered = Array.isArray(created?.configOptions) ? created.configOptions : [];
|
|
923
|
+
const set = async (configId, value) => {
|
|
924
|
+
const option = offered.find((o) => o?.id === configId);
|
|
925
|
+
if (!option || value === undefined || value === null || value === "") return;
|
|
926
|
+
const allowed = Array.isArray(option.options) ? option.options : [];
|
|
927
|
+
if (allowed.length > 0 && !allowed.some((o) => o?.value === value)) {
|
|
928
|
+
emit({ type: "system", subtype: "config_skipped", option: configId, value });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
await connection.agent.request("session/set_config_option", { sessionId, configId, value });
|
|
933
|
+
emit({ type: "system", subtype: "config", option: configId, value });
|
|
934
|
+
} catch (error) {
|
|
935
|
+
emit({
|
|
936
|
+
type: "system",
|
|
937
|
+
subtype: "config_failed",
|
|
938
|
+
option: configId,
|
|
939
|
+
value,
|
|
940
|
+
detail: String(error?.message ?? error),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
// acceptEdits, the same permission mode the `claude` engine passes.
|
|
945
|
+
// Never `plan`: a headless session in plan mode is parked for good
|
|
946
|
+
// (agents.mjs, CLAUDE_DISALLOWED_TOOLS), and here plan mode is simply a
|
|
947
|
+
// mode nobody selects - which is the whole of what the disallowed list
|
|
948
|
+
// was buying on this path.
|
|
949
|
+
await set("mode", acp.mode ?? "acceptEdits");
|
|
950
|
+
await set("model", typeof acp.model === "string" ? acp.model.trim() : "");
|
|
951
|
+
|
|
952
|
+
watchReplies();
|
|
953
|
+
watchAnswers();
|
|
954
|
+
armStallWatchdog();
|
|
955
|
+
push(prompt, images);
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
boot().catch((error) => {
|
|
959
|
+
// A protocol failure before the first turn is a session that never
|
|
960
|
+
// started. It is emitted as an error envelope AND written to the log,
|
|
961
|
+
// then the adapter is closed - so the receipt carries the reason
|
|
962
|
+
// instead of an empty log and a bare exit code.
|
|
963
|
+
const detail = String(error?.message ?? error);
|
|
964
|
+
translated.write(`\n[acp] ${detail}\n`);
|
|
965
|
+
emit({
|
|
966
|
+
type: "result",
|
|
967
|
+
subtype: "error_during_execution",
|
|
968
|
+
is_error: true,
|
|
969
|
+
session_id: sessionId,
|
|
970
|
+
num_turns: turns,
|
|
971
|
+
error: detail,
|
|
972
|
+
});
|
|
973
|
+
try { child.stdin?.end(); } catch { /* already gone */ }
|
|
974
|
+
// An adapter that does not take the hint still has the grace period.
|
|
975
|
+
setTimeout(() => { if (!handle.settled()) handle.kill(); }, 2000).unref?.();
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
}
|