backpass 0.1.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/LICENSE +21 -0
- package/README.md +406 -0
- package/bin/backpass.js +4 -0
- package/package.json +62 -0
- package/src/acpx.js +576 -0
- package/src/agents.js +389 -0
- package/src/analyze.js +289 -0
- package/src/apply/lavish.js +128 -0
- package/src/apply/terminal.js +119 -0
- package/src/apply/writer.js +101 -0
- package/src/bootstrap.js +74 -0
- package/src/cli.js +261 -0
- package/src/commands/analyze.js +88 -0
- package/src/commands/apply.js +103 -0
- package/src/commands/bootstrap.js +172 -0
- package/src/commands/init.js +59 -0
- package/src/commands/propose.js +136 -0
- package/src/commands/run.js +95 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/status.js +143 -0
- package/src/commands/usage.js +25 -0
- package/src/config.js +249 -0
- package/src/diff.js +305 -0
- package/src/discovery/adapters/claude.js +77 -0
- package/src/discovery/adapters/codex.js +162 -0
- package/src/discovery/adapters/cursor-cli.js +109 -0
- package/src/discovery/adapters/cursor-ide.js +130 -0
- package/src/discovery/adapters/grok.js +107 -0
- package/src/discovery/adapters/opencode.js +151 -0
- package/src/discovery/adapters/pi.js +87 -0
- package/src/discovery/adapters/shared.js +195 -0
- package/src/discovery/adapters/sqlite.js +50 -0
- package/src/discovery/association.js +100 -0
- package/src/discovery/index.js +226 -0
- package/src/discovery/self.js +62 -0
- package/src/distill.js +182 -0
- package/src/fold.js +214 -0
- package/src/gap-ledger.js +174 -0
- package/src/logger.js +74 -0
- package/src/memory.js +244 -0
- package/src/progress.js +29 -0
- package/src/prompts/analysis.md +48 -0
- package/src/prompts/annotate.md +48 -0
- package/src/prompts/synthesis.md +98 -0
- package/src/prompts.js +36 -0
- package/src/proposal.js +430 -0
- package/src/redact.js +36 -0
- package/src/repo.js +118 -0
- package/src/sample.js +99 -0
- package/src/skills.js +207 -0
- package/src/state.js +202 -0
- package/src/subprocess.js +47 -0
- package/src/synthesize.js +287 -0
- package/src/tokens.js +48 -0
- package/src/tui/index.js +336 -0
- package/src/tui/render.js +487 -0
- package/src/tui/term.js +130 -0
- package/src/tui/theme.js +111 -0
- package/src/workspace.js +162 -0
- package/templates/apply.html +928 -0
package/src/acpx.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { warn } from "./logger.js";
|
|
4
|
+
import { runCapture } from "./subprocess.js";
|
|
5
|
+
import * as piStore from "./discovery/adapters/pi.js";
|
|
6
|
+
import { readJsonl } from "./discovery/adapters/shared.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The acpx execution layer (design section 4).
|
|
10
|
+
*
|
|
11
|
+
* backpass owns no API keys. Every model call goes through acpx to a harness the user
|
|
12
|
+
* has already authenticated, which is also why this is the only module that knows how
|
|
13
|
+
* models are invoked - acpx self-describes as alpha, so the blast radius of a change in
|
|
14
|
+
* its CLI surface stops here.
|
|
15
|
+
*
|
|
16
|
+
* v1 deliberately uses plain `exec` one-shots and short-lived named sessions. acpx
|
|
17
|
+
* flows are marked experimental upstream and are the v2 path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const ACPX_BIN = process.env.BACKPASS_ACPX_BIN || "acpx";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* backpass's user-facing harness names versus acpx's agent registry. The only
|
|
24
|
+
* mismatch today is grok: backpass calls the harness `grok` everywhere (config,
|
|
25
|
+
* discovery, --harness) while acpx registers it as `grok-build`. Translate at this
|
|
26
|
+
* boundary only, so the user never has to know.
|
|
27
|
+
*/
|
|
28
|
+
const ACPX_AGENT_NAMES = { grok: "grok-build" };
|
|
29
|
+
|
|
30
|
+
export function acpxAgentName(agent) {
|
|
31
|
+
return ACPX_AGENT_NAMES[agent] || agent;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The session config-option id each adapter uses for reasoning effort. There is no
|
|
36
|
+
* shared ACP name for it and `acpx status` does not expose config options, so this
|
|
37
|
+
* small table is measured (see the ordered-defaults design report) rather than
|
|
38
|
+
* derived. Adapters absent here (grok, opencode) advertise no effort option at all;
|
|
39
|
+
* effort is then skipped with a report note, never silently.
|
|
40
|
+
*/
|
|
41
|
+
export const EFFORT_OPTION_KEYS = { codex: "reasoning_effort", claude: "effort", pi: "thought_level" };
|
|
42
|
+
|
|
43
|
+
export function effortOptionKey(agent) {
|
|
44
|
+
return EFFORT_OPTION_KEYS[agent] || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AcpxError extends Error {
|
|
48
|
+
constructor(message, { stdout = "", stderr = "", code = null, timedOut = false, spawnError = null } = {}) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "AcpxError";
|
|
51
|
+
this.stdout = stdout;
|
|
52
|
+
this.stderr = stderr;
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.timedOut = timedOut;
|
|
55
|
+
this.spawnError = spawnError;
|
|
56
|
+
/** Set when the adapter has no session support at all (not an availability verdict). */
|
|
57
|
+
this.unsupported = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string[]} args
|
|
63
|
+
* @param {{ timeoutMs?: number, cwd?: string, input?: string }} [options]
|
|
64
|
+
*/
|
|
65
|
+
function run(args, options = {}) {
|
|
66
|
+
return runCapture(ACPX_BIN, args, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function notFoundError(result) {
|
|
70
|
+
return new AcpxError(`acpx not found on PATH (looked for "${ACPX_BIN}")`, result);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Availability verdicts for a failed acpx call. Only a *classifiable* failure is a
|
|
75
|
+
* reason to drop a candidate and fall through to the next one; anything else (a
|
|
76
|
+
* timeout on a long prompt, garbage output) stays a plain error so a run never
|
|
77
|
+
* silently switches models after real work has started.
|
|
78
|
+
*
|
|
79
|
+
* acpx reports these on stderr as `[acpx] error: RUNTIME AUTH_REQUIRED ...` and
|
|
80
|
+
* `Cannot apply --model "x": the ACP agent did not advertise that model`.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ stderr?: string, spawnError?: { code?: string } | null, timedOut?: boolean }} failure
|
|
83
|
+
* @returns {"unauthenticated" | "model-unavailable" | "unreachable" | null}
|
|
84
|
+
*/
|
|
85
|
+
export function classifyAcpxFailure(failure) {
|
|
86
|
+
if (!failure) return null;
|
|
87
|
+
if (failure.spawnError?.code === "ENOENT") return "unreachable";
|
|
88
|
+
const text = failure.stderr || "";
|
|
89
|
+
if (/AUTH_REQUIRED|authentication required/i.test(text)) return "unauthenticated";
|
|
90
|
+
if (/did not advertise that model/i.test(text)) return "model-unavailable";
|
|
91
|
+
if (/\b(ENOENT|command not found|not found on PATH|failed to spawn|spawn .* ENOENT)\b/i.test(text)) {
|
|
92
|
+
return "unreachable";
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* acpx prints a per-run accounting line: `[acpx] tokens: input=10 output=47 ... total=34194`.
|
|
99
|
+
* @returns {Record<string, number> | null}
|
|
100
|
+
*/
|
|
101
|
+
export function parseTokenLine(text) {
|
|
102
|
+
const match = /\[acpx\]\s+tokens:\s+(.+)/.exec(text || "");
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
/** @type {Record<string, number>} */
|
|
105
|
+
const usage = {};
|
|
106
|
+
for (const pair of match[1].trim().split(/\s+/)) {
|
|
107
|
+
const [key, value] = pair.split("=");
|
|
108
|
+
const n = Number(value);
|
|
109
|
+
if (key && Number.isFinite(n)) usage[key] = n;
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(usage).length ? usage : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Strip acpx's own accounting/status lines from the model's answer. */
|
|
115
|
+
export function stripAcpxNoise(text) {
|
|
116
|
+
return (text || "")
|
|
117
|
+
.split("\n")
|
|
118
|
+
.filter((line) => !line.startsWith("[acpx]"))
|
|
119
|
+
.join("\n")
|
|
120
|
+
.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pull a JSON object out of a model reply, tolerating prose or a fenced block around it.
|
|
125
|
+
*/
|
|
126
|
+
export function extractJson(text) {
|
|
127
|
+
const cleaned = stripAcpxNoise(text);
|
|
128
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(cleaned);
|
|
129
|
+
const candidates = [];
|
|
130
|
+
if (fenced) candidates.push(fenced[1]);
|
|
131
|
+
candidates.push(cleaned);
|
|
132
|
+
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
const trimmed = candidate.trim();
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(trimmed);
|
|
137
|
+
} catch {
|
|
138
|
+
// fall through to brace scanning
|
|
139
|
+
}
|
|
140
|
+
const start = trimmed.indexOf("{");
|
|
141
|
+
const end = trimmed.lastIndexOf("}");
|
|
142
|
+
if (start !== -1 && end > start) {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
145
|
+
} catch {
|
|
146
|
+
// keep trying
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Permission modes. `approveAll` is what native editing needs: the harness's own
|
|
155
|
+
* `edit`/`write` tools are approved, which is why every call that sets it runs with a
|
|
156
|
+
* staging workspace as `cwd` (`src/workspace.js`), never the repo. acpx's policy rules
|
|
157
|
+
* match tool kinds, not paths, so the workspace is the blast-radius boundary.
|
|
158
|
+
*/
|
|
159
|
+
function baseArgs({ cwd, model, timeoutSeconds, approveReads, approveAll = false, suppressReads }) {
|
|
160
|
+
const args = [];
|
|
161
|
+
if (cwd) args.push("--cwd", cwd);
|
|
162
|
+
if (approveAll) args.push("--approve-all");
|
|
163
|
+
else if (approveReads) args.push("--approve-reads");
|
|
164
|
+
else args.push("--deny-all");
|
|
165
|
+
if (suppressReads) args.push("--suppress-reads");
|
|
166
|
+
args.push("--non-interactive-permissions", "deny");
|
|
167
|
+
if (timeoutSeconds) args.push("--timeout", String(timeoutSeconds));
|
|
168
|
+
if (model) args.push("--model", model);
|
|
169
|
+
args.push("--format", "quiet");
|
|
170
|
+
return args;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `acpx --version`, used to key the probe cache. Null when acpx is missing. */
|
|
174
|
+
export async function acpxVersion({ timeoutMs = 10_000 } = {}) {
|
|
175
|
+
const result = await run(["--version"], { timeoutMs });
|
|
176
|
+
if (result.code !== 0) return null;
|
|
177
|
+
const line = firstLine(result.stdout);
|
|
178
|
+
return line || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Zero-token availability probe: spawn the adapter, handshake, create a session,
|
|
183
|
+
* read what it advertises, close it. For codex / pi / grok, `sessions new` is a
|
|
184
|
+
* real auth gate (ACP -32000); for claude it is not, which is why `src/agents.js`
|
|
185
|
+
* checks `claude auth status` before ever calling this.
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<{ verdict: "ok" | "unauthenticated" | "model-unavailable" | "unreachable" | "timeout",
|
|
188
|
+
* detail: string, availableModels: string[] }>}
|
|
189
|
+
*/
|
|
190
|
+
export async function probeSession({ agent, sessionName, cwd = undefined, timeoutMs = 20_000 }) {
|
|
191
|
+
const acpxAgent = acpxAgentName(agent);
|
|
192
|
+
const created = await run([acpxAgent, "sessions", "new", "--name", sessionName], { timeoutMs, cwd });
|
|
193
|
+
if (created.spawnError?.code === "ENOENT") throw notFoundError(created);
|
|
194
|
+
if (created.timedOut) {
|
|
195
|
+
return {
|
|
196
|
+
verdict: "timeout",
|
|
197
|
+
detail: `probe timed out after ${Math.round(timeoutMs / 1000)}s`,
|
|
198
|
+
availableModels: [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (created.code !== 0) {
|
|
202
|
+
const verdict = classifyAcpxFailure(created) || "unreachable";
|
|
203
|
+
return { verdict, detail: firstLine(created.stderr) || `exit ${created.code}`, availableModels: [] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const status = await run(["--format", "json", acpxAgent, "status", "-s", sessionName], { timeoutMs, cwd });
|
|
208
|
+
let availableModels = [];
|
|
209
|
+
if (status.code === 0) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(status.stdout.trim().split("\n").at(-1) || "{}");
|
|
212
|
+
if (Array.isArray(parsed.availableModels)) availableModels = parsed.availableModels.map(String);
|
|
213
|
+
} catch {
|
|
214
|
+
// Leave the list empty; the caller decides whether it needs one.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { verdict: "ok", detail: "", availableModels };
|
|
218
|
+
} finally {
|
|
219
|
+
const closed = await run([acpxAgent, "sessions", "close", sessionName], { timeoutMs, cwd });
|
|
220
|
+
if (closed.code !== 0) warn(`could not close acpx probe session ${sessionName}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Tier 1 - one-shot analysis call (design section 5).
|
|
226
|
+
*
|
|
227
|
+
* `--approve-reads` is what makes the cheap-first escape hatch work: the agent may open
|
|
228
|
+
* the raw transcript when a claim needs it, but writes are never approved.
|
|
229
|
+
*/
|
|
230
|
+
export async function execOneShot({
|
|
231
|
+
agent,
|
|
232
|
+
model = null,
|
|
233
|
+
promptFile,
|
|
234
|
+
cwd,
|
|
235
|
+
timeoutSeconds = 300,
|
|
236
|
+
promptRetries = 1,
|
|
237
|
+
approveReads = true,
|
|
238
|
+
suppressReads = true,
|
|
239
|
+
}) {
|
|
240
|
+
const args = [
|
|
241
|
+
...baseArgs({ cwd, model, timeoutSeconds, approveReads, suppressReads }),
|
|
242
|
+
"--prompt-retries",
|
|
243
|
+
String(promptRetries),
|
|
244
|
+
acpxAgentName(agent),
|
|
245
|
+
"exec",
|
|
246
|
+
"--file",
|
|
247
|
+
promptFile,
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const startedAt = Date.now();
|
|
251
|
+
const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd });
|
|
252
|
+
if (result.spawnError && result.spawnError.code === "ENOENT") throw notFoundError(result);
|
|
253
|
+
if (result.timedOut) {
|
|
254
|
+
throw new AcpxError(`acpx ${agent} exec timed out after ${timeoutSeconds}s`, result);
|
|
255
|
+
}
|
|
256
|
+
if (result.code !== 0) {
|
|
257
|
+
throw new AcpxError(`acpx ${agent} exec failed (exit ${result.code})`, result);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
261
|
+
const usage = parseTokenLine(combined) ?? recoverUsageFromStore({ agent, promptFile, cwd, startedAt });
|
|
262
|
+
return { text: stripAcpxNoise(result.stdout), usage, raw: result.stdout };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A named session that stays open across turns (design section 5).
|
|
267
|
+
*
|
|
268
|
+
* Reasoning effort is a session config option on every adapter that has one, and
|
|
269
|
+
* `exec` cannot set it - so any call that wants effort applied goes through a session.
|
|
270
|
+
* Synthesis also needs more than one turn in the same context: the agent edits the
|
|
271
|
+
* staging copy, then annotates the changes backpass measured. Adapters that do not
|
|
272
|
+
* advertise an effort option skip that step with a report line - never silently.
|
|
273
|
+
*
|
|
274
|
+
* Resolves to the handle, or throws an `AcpxError` (`unsupported: true` when the adapter
|
|
275
|
+
* has no session support at all, which `sessionPrompt` turns into an exec fallback).
|
|
276
|
+
*
|
|
277
|
+
* @returns {Promise<{ notes: string[],
|
|
278
|
+
* prompt: (options: { promptFile: string, timeoutSeconds?: number, promptRetries?: number,
|
|
279
|
+
* approveReads?: boolean, approveAll?: boolean, suppressReads?: boolean }) =>
|
|
280
|
+
* Promise<{ text: string, usage: Record<string, number> | null, raw: string, notes: string[] }>,
|
|
281
|
+
* close: () => Promise<void> }>}
|
|
282
|
+
*/
|
|
283
|
+
export async function openSession({ agent, model = null, effort = null, sessionName, cwd }) {
|
|
284
|
+
const notes = [];
|
|
285
|
+
const acpxAgent = acpxAgentName(agent);
|
|
286
|
+
const created = await run([acpxAgent, "sessions", "new", "--name", sessionName], { timeoutMs: 60_000, cwd });
|
|
287
|
+
if (created.spawnError && created.spawnError.code === "ENOENT") throw notFoundError(created);
|
|
288
|
+
if (created.code !== 0) {
|
|
289
|
+
// An auth or spawn failure must surface as such so the caller can fall through.
|
|
290
|
+
if (classifyAcpxFailure(created)) {
|
|
291
|
+
throw new AcpxError(`acpx ${agent} session create failed: ${firstLine(created.stderr)}`, created);
|
|
292
|
+
}
|
|
293
|
+
const err = new AcpxError(
|
|
294
|
+
`acpx ${agent} has no session support: ${firstLine(created.stderr) || `exit ${created.code}`}`,
|
|
295
|
+
created,
|
|
296
|
+
);
|
|
297
|
+
err.unsupported = true;
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const startedAt = Date.now();
|
|
302
|
+
let closed = false;
|
|
303
|
+
let firstPromptFile = null;
|
|
304
|
+
let storeUsageSeen = null;
|
|
305
|
+
|
|
306
|
+
const close = async () => {
|
|
307
|
+
if (closed) return;
|
|
308
|
+
closed = true;
|
|
309
|
+
const result = await run([acpxAgent, "sessions", "close", sessionName], { timeoutMs: 30_000, cwd });
|
|
310
|
+
if (result.code !== 0) warn(`could not close acpx session ${sessionName}`);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if (model) {
|
|
315
|
+
const set = await run([acpxAgent, "-s", sessionName, "set", "model", model], { timeoutMs: 60_000, cwd });
|
|
316
|
+
if (set.code !== 0) {
|
|
317
|
+
if (classifyAcpxFailure(set) === "model-unavailable") {
|
|
318
|
+
throw new AcpxError(`acpx ${agent} rejected model ${model}: ${firstLine(set.stderr)}`, set);
|
|
319
|
+
}
|
|
320
|
+
notes.push(`could not set model=${model} on ${agent}: ${firstLine(set.stderr)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (effort) {
|
|
324
|
+
const key = effortOptionKey(agent);
|
|
325
|
+
const set = key
|
|
326
|
+
? await run([acpxAgent, "-s", sessionName, "set", key, effort], { timeoutMs: 60_000, cwd })
|
|
327
|
+
: null;
|
|
328
|
+
if (!set || set.code !== 0) {
|
|
329
|
+
notes.push(`${agent} does not advertise a reasoning-effort option; ran without effort=${effort}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
await close();
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const prompt = async ({
|
|
338
|
+
promptFile,
|
|
339
|
+
timeoutSeconds = 900,
|
|
340
|
+
promptRetries = 1,
|
|
341
|
+
approveReads = true,
|
|
342
|
+
approveAll = false,
|
|
343
|
+
suppressReads = true,
|
|
344
|
+
}) => {
|
|
345
|
+
if (closed) throw new AcpxError(`acpx ${agent} session ${sessionName} is closed`);
|
|
346
|
+
firstPromptFile = firstPromptFile || promptFile;
|
|
347
|
+
const args = [
|
|
348
|
+
...baseArgs({ cwd, model: null, timeoutSeconds, approveReads, approveAll, suppressReads }),
|
|
349
|
+
"--prompt-retries",
|
|
350
|
+
String(promptRetries),
|
|
351
|
+
acpxAgent,
|
|
352
|
+
"-s",
|
|
353
|
+
sessionName,
|
|
354
|
+
"--file",
|
|
355
|
+
promptFile,
|
|
356
|
+
];
|
|
357
|
+
const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd });
|
|
358
|
+
if (result.timedOut) throw new AcpxError(`acpx ${agent} session prompt timed out after ${timeoutSeconds}s`, result);
|
|
359
|
+
if (result.code !== 0) throw new AcpxError(`acpx ${agent} session prompt failed (exit ${result.code})`, result);
|
|
360
|
+
|
|
361
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
362
|
+
/** @type {Record<string, number> | null} */
|
|
363
|
+
let usage = parseTokenLine(combined);
|
|
364
|
+
if (!usage) {
|
|
365
|
+
// The harness store holds the whole session's usage; this turn is the increase.
|
|
366
|
+
const cumulative = recoverUsageFromStore({ agent, promptFile: firstPromptFile, cwd, startedAt });
|
|
367
|
+
usage = cumulative ? subtractUsage(cumulative, storeUsageSeen) : null;
|
|
368
|
+
if (cumulative) storeUsageSeen = cumulative;
|
|
369
|
+
}
|
|
370
|
+
return { text: stripAcpxNoise(result.stdout), usage, raw: result.stdout, notes };
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return { notes, prompt, close };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @returns {Record<string, number>} */
|
|
377
|
+
function subtractUsage(current, previous) {
|
|
378
|
+
if (!previous) return current;
|
|
379
|
+
/** @type {Record<string, number>} */
|
|
380
|
+
const out = {};
|
|
381
|
+
for (const [key, value] of Object.entries(current)) out[key] = value - (previous[key] || 0);
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* One prompt through a short-lived named session: open, prompt, close. The analysis
|
|
387
|
+
* pass uses this whenever an effort is configured. An adapter without session support
|
|
388
|
+
* falls back to a one-shot `exec`, and says so.
|
|
389
|
+
*/
|
|
390
|
+
export async function sessionPrompt({
|
|
391
|
+
agent,
|
|
392
|
+
model = null,
|
|
393
|
+
effort = null,
|
|
394
|
+
sessionName,
|
|
395
|
+
promptFile,
|
|
396
|
+
cwd,
|
|
397
|
+
timeoutSeconds = 900,
|
|
398
|
+
promptRetries = 1,
|
|
399
|
+
approveReads = true,
|
|
400
|
+
suppressReads = true,
|
|
401
|
+
}) {
|
|
402
|
+
let session;
|
|
403
|
+
try {
|
|
404
|
+
session = await openSession({ agent, model, effort, sessionName, cwd });
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (!(err instanceof AcpxError) || !err.unsupported) throw err;
|
|
407
|
+
const notes = [`session unsupported for ${agent}; fell back to exec one-shot`];
|
|
408
|
+
if (effort) notes.push(`${agent} cannot set effort without a session; ran without effort=${effort}`);
|
|
409
|
+
const fallback = await execOneShot({
|
|
410
|
+
agent,
|
|
411
|
+
model,
|
|
412
|
+
promptFile,
|
|
413
|
+
cwd,
|
|
414
|
+
timeoutSeconds,
|
|
415
|
+
promptRetries,
|
|
416
|
+
approveReads,
|
|
417
|
+
suppressReads,
|
|
418
|
+
});
|
|
419
|
+
return { ...fallback, notes };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
return await session.prompt({ promptFile, timeoutSeconds, promptRetries, approveReads, suppressReads });
|
|
424
|
+
} finally {
|
|
425
|
+
await session.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function firstLine(text) {
|
|
430
|
+
return (text || "").split("\n").find((l) => l.trim()) || "";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Harness-store usage fallback.
|
|
435
|
+
*
|
|
436
|
+
* acpx prints its `[acpx] tokens:` line only when the ACP adapter returns `usage` in
|
|
437
|
+
* the `session/prompt` result. codex and claude do; pi-acp (0.0.31) answers with a bare
|
|
438
|
+
* `{ stopReason }` - but pi itself records per-turn usage in its own session file
|
|
439
|
+
* (`~/.pi/agent/sessions/<escaped-cwd>/<ts>_<id>.jsonl`), the same store discovery
|
|
440
|
+
* reads. A plain `exec` leaves no session id to look the file up by, so the handle is
|
|
441
|
+
* the prompt text itself: pi stores it verbatim as the session's first user message.
|
|
442
|
+
* The newest file under this cwd, modified during the call, whose first user message
|
|
443
|
+
* equals the prompt we sent, is ours; its assistant turns' `usage` sum is the answer,
|
|
444
|
+
* mapped onto acpx's key names so the two sources add up in one table.
|
|
445
|
+
*
|
|
446
|
+
* Strictly fail-soft: any miss (no store, no match, no usage fields) is null, which the
|
|
447
|
+
* report prints as "not reported by pi" exactly as before.
|
|
448
|
+
*/
|
|
449
|
+
const STORE_USAGE_RECOVERY = { pi: recoverPiUsage };
|
|
450
|
+
|
|
451
|
+
/** Slack for a harness that stamps the session timestamp slightly before we spawned it. */
|
|
452
|
+
const STORE_MTIME_SLACK_MS = 5_000;
|
|
453
|
+
|
|
454
|
+
/** @returns {Record<string, number> | null} */
|
|
455
|
+
export function recoverUsageFromStore({ agent, promptFile, cwd, startedAt }) {
|
|
456
|
+
const recover = STORE_USAGE_RECOVERY[agent];
|
|
457
|
+
if (!recover) return null;
|
|
458
|
+
try {
|
|
459
|
+
return recover({ promptFile, cwd, startedAt }) || null;
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const PI_USAGE_KEYS = {
|
|
466
|
+
input: "input",
|
|
467
|
+
output: "output",
|
|
468
|
+
cacheRead: "cache_read",
|
|
469
|
+
cacheWrite: "cache_write",
|
|
470
|
+
reasoning: "reasoning",
|
|
471
|
+
totalTokens: "total",
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
function recoverPiUsage({ promptFile, cwd, startedAt }) {
|
|
475
|
+
const prompt = fs.readFileSync(promptFile, "utf8").trim();
|
|
476
|
+
if (!prompt) return null;
|
|
477
|
+
const wanted = new Set([cwd, realpathOrNull(cwd)].filter(Boolean));
|
|
478
|
+
const since = (startedAt || 0) - STORE_MTIME_SLACK_MS;
|
|
479
|
+
|
|
480
|
+
const candidates = piStore
|
|
481
|
+
.enumerate()
|
|
482
|
+
.filter((c) => c.mtimeMs >= since)
|
|
483
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
484
|
+
|
|
485
|
+
for (const candidate of candidates) {
|
|
486
|
+
const descriptor = piStore.classify(candidate);
|
|
487
|
+
if (!descriptor || !wanted.has(descriptor.cwd)) continue;
|
|
488
|
+
const entries = readJsonl(candidate.path);
|
|
489
|
+
const firstUser = entries.find((e) => e.type === "message" && e.message?.role === "user");
|
|
490
|
+
if (!firstUser || piMessageText(firstUser.message.content).trim() !== prompt) continue;
|
|
491
|
+
|
|
492
|
+
const usage = {};
|
|
493
|
+
for (const entry of entries) {
|
|
494
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
495
|
+
const turn = entry.message.usage;
|
|
496
|
+
if (!turn || typeof turn !== "object") continue;
|
|
497
|
+
for (const [piKey, key] of Object.entries(PI_USAGE_KEYS)) {
|
|
498
|
+
if (Number.isFinite(turn[piKey])) usage[key] = (usage[key] || 0) + turn[piKey];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return Object.keys(usage).length ? usage : null;
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function piMessageText(content) {
|
|
507
|
+
if (typeof content === "string") return content;
|
|
508
|
+
if (!Array.isArray(content)) return "";
|
|
509
|
+
return content
|
|
510
|
+
.map((b) => (typeof b === "string" ? b : b?.type === "text" ? (b.text ?? "") : ""))
|
|
511
|
+
.filter(Boolean)
|
|
512
|
+
.join("\n");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function realpathOrNull(p) {
|
|
516
|
+
try {
|
|
517
|
+
return fs.realpathSync(p);
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Per-call usage accounting (design section 9).
|
|
525
|
+
*
|
|
526
|
+
* A record keeps the harness next to the (possibly null) usage, so when neither acpx
|
|
527
|
+
* nor the harness store yielded numbers the report can say *who* stayed silent instead
|
|
528
|
+
* of printing a meaningless "n/a".
|
|
529
|
+
*
|
|
530
|
+
* @typedef {{ agent: string, usage: Record<string, number> | null }} UsageRecord
|
|
531
|
+
*/
|
|
532
|
+
|
|
533
|
+
/** @returns {UsageRecord} */
|
|
534
|
+
export function usageRecord(agent, result) {
|
|
535
|
+
return { agent, usage: result?.usage || null };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Sum the usage maps of the records that reported one. */
|
|
539
|
+
export function sumUsage(records) {
|
|
540
|
+
const total = {};
|
|
541
|
+
for (const record of records) {
|
|
542
|
+
const usage = record?.usage ?? record;
|
|
543
|
+
if (!usage || typeof usage !== "object") continue;
|
|
544
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
545
|
+
if (Number.isFinite(value)) total[key] = (total[key] || 0) + value;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return total;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function formatUsage(usage) {
|
|
552
|
+
if (!usage || !Object.keys(usage).length) return "n/a";
|
|
553
|
+
return Object.entries(usage)
|
|
554
|
+
.map(([k, v]) => `${k}=${v.toLocaleString("en-US")}`)
|
|
555
|
+
.join(" ");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Describe a pass's usage for the report, or null when the pass made no model calls
|
|
560
|
+
* (everything cached, or a different command ran it) - the caller then prints nothing.
|
|
561
|
+
*
|
|
562
|
+
* @param {(UsageRecord | null | undefined)[]} records
|
|
563
|
+
* @returns {string | null}
|
|
564
|
+
*/
|
|
565
|
+
export function describeUsage(records) {
|
|
566
|
+
const calls = (records || []).filter((r) => r && typeof r === "object" && "agent" in r);
|
|
567
|
+
if (!calls.length) return null;
|
|
568
|
+
const reported = calls.filter((r) => r.usage && Object.keys(r.usage).length);
|
|
569
|
+
if (!reported.length) {
|
|
570
|
+
const agents = [...new Set(calls.map((r) => r.agent))].join(", ");
|
|
571
|
+
return `not reported by ${agents}`;
|
|
572
|
+
}
|
|
573
|
+
const text = formatUsage(sumUsage(reported));
|
|
574
|
+
if (reported.length === calls.length) return text;
|
|
575
|
+
return `${text} (${reported.length} of ${calls.length} calls reported)`;
|
|
576
|
+
}
|