@proagentstore/cli 0.4.32 → 0.4.34
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/dist/browser-runner/coding/engine-auth.js +55 -0
- package/dist/browser-runner/coding/engine-usage.js +79 -0
- package/dist/browser-runner/coding/headless.js +68 -1
- package/dist/browser-runner/coding/inspect.js +5 -1
- package/dist/browser-runner/coding/runtime.js +24 -3
- package/dist/browser-runner/server.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What credential the engine process ACTUALLY got — read off the merged env, on the machine
|
|
2
|
+
// where the merge happens (#248).
|
|
3
|
+
//
|
|
4
|
+
// The platform decides an engine's sign-in MODE (auto / machine / subscription / api-key) in the
|
|
5
|
+
// cloud, but the mode is only half an answer: the runner spawns the CLI with
|
|
6
|
+
// `{...process.env, ...overlay}`, so the outcome also depends on what the user's shell exports —
|
|
7
|
+
// which the cloud cannot see. "Which credential am I actually billing?" was therefore
|
|
8
|
+
// unanswerable from any surface, and the one time it went wrong it went wrong SILENTLY: a shell
|
|
9
|
+
// `ANTHROPIC_API_KEY` beat the injected subscription token, so picking "subscription" billed per
|
|
10
|
+
// token anyway (see the note on `mergeEnv`).
|
|
11
|
+
//
|
|
12
|
+
// This module derives the answer from the same env the process is spawned with, so the report is
|
|
13
|
+
// an observation, never a restatement of the setting.
|
|
14
|
+
//
|
|
15
|
+
// **Presence only, never values.** Nothing here returns, logs or echoes a key or token — the
|
|
16
|
+
// caller gets an enum. This is a transparency feature, not a credential channel.
|
|
17
|
+
/**
|
|
18
|
+
* The env var each engine reads a PER-TOKEN API key from. Deliberately duplicated from the
|
|
19
|
+
* cloud's `ENGINE_API_KEYS` (workers/api/src/lib/coding-engines.ts) rather than imported: the
|
|
20
|
+
* runner ships inside the published CLI and does not depend on the API worker — vendoring is the
|
|
21
|
+
* house rule for shared constants across packages.
|
|
22
|
+
*/
|
|
23
|
+
const API_KEY_ENV = {
|
|
24
|
+
claude: "ANTHROPIC_API_KEY",
|
|
25
|
+
gemini: "GEMINI_API_KEY",
|
|
26
|
+
codex: "OPENAI_API_KEY",
|
|
27
|
+
grok: "XAI_API_KEY",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The env var holding a SUBSCRIPTION token. Only Claude Code has one — for every other engine
|
|
31
|
+
* "subscription" and "machine" both mean the machine's own login, so there is nothing to detect.
|
|
32
|
+
*/
|
|
33
|
+
const SUBSCRIPTION_ENV = {
|
|
34
|
+
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
35
|
+
};
|
|
36
|
+
/** A var counts as set only when it holds something. `mergeEnv` deletes on empty, but an
|
|
37
|
+
* inherited `FOO=` from the shell is present-and-empty and means "no credential", not one. */
|
|
38
|
+
function has(env, name) {
|
|
39
|
+
return !!name && !!env[name] && env[name].trim() !== "";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Derive what the engine actually authenticates with from its merged spawn env.
|
|
43
|
+
*
|
|
44
|
+
* API key wins over the subscription token because that is what the CLI itself does — Claude Code
|
|
45
|
+
* prefers `ANTHROPIC_API_KEY` over `CLAUDE_CODE_OAUTH_TOKEN`. Reporting "subscription" whenever a
|
|
46
|
+
* token happened to be present would reproduce the exact illusion this exists to dispel: the
|
|
47
|
+
* setting said subscription, the bill said per-token.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveEngineAuth(clientType, env) {
|
|
50
|
+
if (has(env, API_KEY_ENV[clientType]))
|
|
51
|
+
return "api-key";
|
|
52
|
+
if (has(env, SUBSCRIPTION_ENV[clientType]))
|
|
53
|
+
return "subscription";
|
|
54
|
+
return "machine-login";
|
|
55
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine spend, taken from the CLI's OWN report (#267).
|
|
3
|
+
*
|
|
4
|
+
* The usage ledger is written at three cloud-side choke points (`runUserWorkersAi`,
|
|
5
|
+
* `recordVoiceUsage`, `recordPlatformUsage`). The coding Engine passes through none of them: it
|
|
6
|
+
* is a child process on the user's machine talking straight to Anthropic. So the single largest
|
|
7
|
+
* line item for anyone using Coder in earnest was recorded nowhere, and the Usage page showed a
|
|
8
|
+
* total with no hint that it was incomplete.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code already emits everything needed on the `result` event that ends each turn — token
|
|
11
|
+
* counts AND `total_cost_usd`, which it computes itself. That number is the one figure in the
|
|
12
|
+
* whole ledger that is NOT an estimate, so it is carried through as reported rather than being
|
|
13
|
+
* re-derived from `ai-pricing.ts` list prices.
|
|
14
|
+
*
|
|
15
|
+
* Raw (non-Claude) engines never reach this module: `HeadlessSession` only parses JSON in
|
|
16
|
+
* stream-json mode, so a Codex/Grok session produces no records at all. That is deliberate —
|
|
17
|
+
* a zero row would read as "this engine is free", which is a worse lie than a gap.
|
|
18
|
+
*/
|
|
19
|
+
const num = (v) => {
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Pick the model id out of Claude Code's `modelUsage` map, which is keyed by model.
|
|
25
|
+
*
|
|
26
|
+
* A single turn can legitimately span more than one model (a subagent on a cheaper one), and the
|
|
27
|
+
* ledger has one model column per row. The most expensive one is the honest label for the row:
|
|
28
|
+
* attributing the turn to the cheap helper model would make an expensive session look cheap in
|
|
29
|
+
* the by-model breakdown, which is the exact question that breakdown exists to answer.
|
|
30
|
+
*/
|
|
31
|
+
function pickModel(modelUsage) {
|
|
32
|
+
if (!modelUsage || typeof modelUsage !== "object")
|
|
33
|
+
return "unknown";
|
|
34
|
+
let best = "";
|
|
35
|
+
let bestCost = -1;
|
|
36
|
+
for (const [model, entry] of Object.entries(modelUsage)) {
|
|
37
|
+
const cost = num(entry?.costUSD);
|
|
38
|
+
if (cost > bestCost) {
|
|
39
|
+
bestCost = cost;
|
|
40
|
+
best = model;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return best || "unknown";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Turn a stream-json `result` event into a usage record, or null when it carries no measurement.
|
|
47
|
+
*
|
|
48
|
+
* `fallbackId` is used only when the event has no `uuid` of its own (older Claude Code builds);
|
|
49
|
+
* the caller supplies something unique per turn per process.
|
|
50
|
+
*
|
|
51
|
+
* Returns null — never a zeroed record — when there is nothing to report, for the same reason
|
|
52
|
+
* raw engines record nothing: an all-zero row is indistinguishable from "this was free".
|
|
53
|
+
*/
|
|
54
|
+
export function parseEngineUsage(ev, fallbackId) {
|
|
55
|
+
if (!ev || typeof ev !== "object")
|
|
56
|
+
return null;
|
|
57
|
+
const e = ev;
|
|
58
|
+
if (e.type !== "result")
|
|
59
|
+
return null;
|
|
60
|
+
const usage = (e.usage && typeof e.usage === "object" ? e.usage : {});
|
|
61
|
+
const inputTokens = num(usage.input_tokens);
|
|
62
|
+
const outputTokens = num(usage.output_tokens);
|
|
63
|
+
const cacheReadTokens = num(usage.cache_read_input_tokens);
|
|
64
|
+
const cacheWriteTokens = num(usage.cache_creation_input_tokens);
|
|
65
|
+
const costUsd = num(e.total_cost_usd);
|
|
66
|
+
if (!inputTokens && !outputTokens && !cacheReadTokens && !cacheWriteTokens && !costUsd)
|
|
67
|
+
return null;
|
|
68
|
+
const uuid = typeof e.uuid === "string" && e.uuid.trim() ? e.uuid.trim() : "";
|
|
69
|
+
return {
|
|
70
|
+
id: uuid || fallbackId,
|
|
71
|
+
model: pickModel(e.modelUsage),
|
|
72
|
+
inputTokens,
|
|
73
|
+
outputTokens,
|
|
74
|
+
cacheReadTokens,
|
|
75
|
+
cacheWriteTokens,
|
|
76
|
+
costUsd,
|
|
77
|
+
at: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { parseEngineUsage } from "./engine-usage.js";
|
|
2
3
|
/**
|
|
3
4
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
4
5
|
* REMOVE rather than "set to empty".
|
|
@@ -22,6 +23,16 @@ export function mergeEnv(base, overlay) {
|
|
|
22
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
24
|
import { dirname, join } from "node:path";
|
|
24
25
|
import { handlerFor } from "./handlers.js";
|
|
26
|
+
import { resolveEngineAuth } from "./engine-auth.js";
|
|
27
|
+
/**
|
|
28
|
+
* How many un-drained usage records a session holds (#267).
|
|
29
|
+
*
|
|
30
|
+
* Records are drained by the cloud on capture, which polls every 3s while a session is watched,
|
|
31
|
+
* so this only fills when nobody is looking. Dropping the OLDEST past the cap is the right
|
|
32
|
+
* direction: an unbounded queue on a long-running runner is a leak, and the alternative
|
|
33
|
+
* (refusing new records) would lose the turns that just happened rather than ancient ones.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_PENDING_USAGE = 200;
|
|
25
36
|
export class HeadlessSession {
|
|
26
37
|
config;
|
|
27
38
|
/**
|
|
@@ -53,6 +64,19 @@ export class HeadlessSession {
|
|
|
53
64
|
turnStartedAt = 0;
|
|
54
65
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
55
66
|
stopped = false;
|
|
67
|
+
/** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
|
|
68
|
+
pendingUsage = [];
|
|
69
|
+
/** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
|
|
70
|
+
usageSeq = 0;
|
|
71
|
+
/**
|
|
72
|
+
* Per-PROCESS salt for that fallback id.
|
|
73
|
+
*
|
|
74
|
+
* Without it, a runner restart resets `usageSeq` and the new session's first turn reuses the
|
|
75
|
+
* id of a turn from before the restart — which the cloud's conflict-ignoring insert would
|
|
76
|
+
* silently drop, undercounting exactly the long-lived sessions this feature exists to measure.
|
|
77
|
+
* The queue is in memory and dies with the process, so a salt can never cause a double-count.
|
|
78
|
+
*/
|
|
79
|
+
usageRunId = Math.random().toString(36).slice(2, 10);
|
|
56
80
|
/**
|
|
57
81
|
* The engine binary could not be spawned (ENOENT, not executable).
|
|
58
82
|
*
|
|
@@ -62,6 +86,24 @@ export class HeadlessSession {
|
|
|
62
86
|
* all 40 BYOK decisions re-spawning a binary that isn't there.
|
|
63
87
|
*/
|
|
64
88
|
spawnFailed = false;
|
|
89
|
+
/**
|
|
90
|
+
* What this engine IS, so the question is answerable instead of implied (#248, #247).
|
|
91
|
+
*
|
|
92
|
+
* Sessions have not used tmux since the move to stream-json, but the surface kept saying
|
|
93
|
+
* `tmuxSession`/`pagsTmuxTotal` long enough that a user's reasonable next move
|
|
94
|
+
* (`tmux attach -t …`) failed and looked like a broken engine. Stating the runtime beats
|
|
95
|
+
* removing the wrong word and leaving nothing in its place.
|
|
96
|
+
*/
|
|
97
|
+
engineRuntime = "child-process";
|
|
98
|
+
/**
|
|
99
|
+
* The credential this engine actually runs on — computed from the SAME expression `spawn`
|
|
100
|
+
* uses, so it reports what happened rather than what was configured (#248). A getter, not a
|
|
101
|
+
* cached field, because it must never drift from the env the next turn is spawned with.
|
|
102
|
+
* Presence only: no key or token value leaves this class.
|
|
103
|
+
*/
|
|
104
|
+
get authResolved() {
|
|
105
|
+
return resolveEngineAuth(this.config.clientType, mergeEnv(process.env, this.config.env));
|
|
106
|
+
}
|
|
65
107
|
constructor(config) {
|
|
66
108
|
this.config = config;
|
|
67
109
|
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
@@ -423,11 +465,21 @@ export class HeadlessSession {
|
|
|
423
465
|
this.push(` ↳ ${toolResult(block.content)}`); // ↳
|
|
424
466
|
}
|
|
425
467
|
break;
|
|
426
|
-
case "result":
|
|
468
|
+
case "result": {
|
|
427
469
|
if (ev.is_error)
|
|
428
470
|
this.push(`[error] ${ev.result ?? ev.subtype ?? "failed"}`);
|
|
471
|
+
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
472
|
+
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
473
|
+
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
474
|
+
const usage = parseEngineUsage(ev, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
|
|
475
|
+
if (usage) {
|
|
476
|
+
this.pendingUsage.push(usage);
|
|
477
|
+
if (this.pendingUsage.length > MAX_PENDING_USAGE)
|
|
478
|
+
this.pendingUsage.shift();
|
|
479
|
+
}
|
|
429
480
|
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
430
481
|
break;
|
|
482
|
+
}
|
|
431
483
|
default:
|
|
432
484
|
break;
|
|
433
485
|
}
|
|
@@ -438,6 +490,21 @@ export class HeadlessSession {
|
|
|
438
490
|
push(line) {
|
|
439
491
|
this.transcript.push(line);
|
|
440
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Hand over the measured spend since the last drain, and forget it (#267).
|
|
495
|
+
*
|
|
496
|
+
* Draining rather than re-reporting keeps a 3s capture poll from re-sending the same rows
|
|
497
|
+
* forever; the cloud's insert is keyed on {@link EngineUsageRecord.id} and ignores conflicts,
|
|
498
|
+
* so the genuine race — two callers draining at once, or a retry — still cannot double-count.
|
|
499
|
+
*
|
|
500
|
+
* A raw engine returns an empty array here, always: nothing parses its stdout for usage, so
|
|
501
|
+
* there is nothing to hand over and the cloud writes no row for it.
|
|
502
|
+
*/
|
|
503
|
+
takeUsage() {
|
|
504
|
+
const out = this.pendingUsage;
|
|
505
|
+
this.pendingUsage = [];
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
441
508
|
}
|
|
442
509
|
/** Local wall-clock "HH:MM:SS" for transcript timestamps (runner is a Node process). */
|
|
443
510
|
function stamp() {
|
|
@@ -40,7 +40,11 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
40
40
|
const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
|
|
41
41
|
switch (cmd) {
|
|
42
42
|
case "status":
|
|
43
|
-
|
|
43
|
+
// `--branch` adds ONE header line (`## main...origin/main [ahead 1]`). Without it a
|
|
44
|
+
// caller can learn a tree is dirty but never which branch it is dirty on, which is how
|
|
45
|
+
// a delegated run pushed a PR branch and left the checkout parked there unnoticed
|
|
46
|
+
// (#276). Additive: every existing consumer keeps the same file lines it always got.
|
|
47
|
+
return ["status", "--short", "--branch"];
|
|
44
48
|
case "diff":
|
|
45
49
|
return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
|
|
46
50
|
case "diff-stat":
|
|
@@ -86,8 +86,13 @@ export class CodingRuntime {
|
|
|
86
86
|
session.start();
|
|
87
87
|
return this.snapshot(input.sessionId);
|
|
88
88
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The pane the brain reasons over + the inferred run state.
|
|
91
|
+
*
|
|
92
|
+
* `drainUsage` is opt-in because draining is destructive: nine cloud call sites hit
|
|
93
|
+
* `/coding/capture` and only the two that actually write the ledger may consume the records.
|
|
94
|
+
*/
|
|
95
|
+
snapshot(sessionId, opts = {}) {
|
|
91
96
|
const session = this.require(sessionId);
|
|
92
97
|
const alive = session.alive;
|
|
93
98
|
// ALWAYS return the transcript — it holds the produced output AND the
|
|
@@ -101,6 +106,11 @@ export class CodingRuntime {
|
|
|
101
106
|
alive,
|
|
102
107
|
ready: alive ? session.ready : false,
|
|
103
108
|
runState: alive ? session.runState() : "idle",
|
|
109
|
+
// Reported even when the process is not alive: "what would this session bill?" is
|
|
110
|
+
// exactly the question asked about a session that just stopped.
|
|
111
|
+
authResolved: session.authResolved,
|
|
112
|
+
engineRuntime: session.engineRuntime,
|
|
113
|
+
...(opts.drainUsage ? { usage: session.takeUsage() } : {}),
|
|
104
114
|
};
|
|
105
115
|
}
|
|
106
116
|
/** Perform one action, then return the fresh snapshot (non-blocking, like browser act). */
|
|
@@ -122,14 +132,23 @@ export class CodingRuntime {
|
|
|
122
132
|
return this.snapshot(sessionId);
|
|
123
133
|
}
|
|
124
134
|
/** Tear down a session. */
|
|
135
|
+
/**
|
|
136
|
+
* Stop and forget a session.
|
|
137
|
+
*
|
|
138
|
+
* Returns any un-drained spend (#267) rather than discarding it with the session: the last
|
|
139
|
+
* turn of a session very often runs after the final capture poll, and ending is where that
|
|
140
|
+
* record would otherwise be lost — silently, and only for the turns at the end of every
|
|
141
|
+
* session, which is a bias rather than noise.
|
|
142
|
+
*/
|
|
125
143
|
end(sessionId) {
|
|
126
144
|
const session = this.sessions.get(sessionId);
|
|
145
|
+
const usage = session ? session.takeUsage() : [];
|
|
127
146
|
if (session) {
|
|
128
147
|
session.stop();
|
|
129
148
|
this.sessions.delete(sessionId);
|
|
130
149
|
}
|
|
131
150
|
this.takeovers.delete(sessionId);
|
|
132
|
-
return { ok: true };
|
|
151
|
+
return { ok: true, usage };
|
|
133
152
|
}
|
|
134
153
|
list() {
|
|
135
154
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
@@ -150,6 +169,8 @@ export class CodingRuntime {
|
|
|
150
169
|
clientType: s.config.clientType,
|
|
151
170
|
workDir: s.config.workDir,
|
|
152
171
|
takeover: this.takeovers.has(sessionId),
|
|
172
|
+
authResolved: s.authResolved,
|
|
173
|
+
engineRuntime: s.engineRuntime,
|
|
153
174
|
}));
|
|
154
175
|
}
|
|
155
176
|
// ── Human takeover (the "stuck" handoff) ────────────────────────────────
|
|
@@ -147,8 +147,11 @@ async function route(runner, req, res) {
|
|
|
147
147
|
return json(res, 200, runner.coding.start(b));
|
|
148
148
|
}
|
|
149
149
|
if (req.method === "POST" && path === "/coding/capture") {
|
|
150
|
+
// `drainUsage` opts this caller in to consuming the session's measured engine spend (#267).
|
|
151
|
+
// Only the cloud paths that write the ledger send it; an older cloud omits it and simply
|
|
152
|
+
// gets the snapshot it always got.
|
|
150
153
|
const b = await readJson(req);
|
|
151
|
-
return json(res, 200, runner.coding.snapshot(b.sessionId));
|
|
154
|
+
return json(res, 200, runner.coding.snapshot(b.sessionId, { drainUsage: b.drainUsage === true }));
|
|
152
155
|
}
|
|
153
156
|
if (req.method === "POST" && path === "/coding/act") {
|
|
154
157
|
const b = await readJson(req);
|