flowviant 0.108.1 → 0.109.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/bin/lib/claudeAuth.mjs +3 -10
- package/bin/lib/claudeStream.mjs +14 -1
- package/bin/lib/fleetRoster.mjs +36 -1
- package/bin/lib/isoStamp.mjs +23 -0
- package/bin/lib/runTurn.mjs +30 -3
- package/bin/lib/runtimeLimits.mjs +365 -0
- package/bin/lib/turnUsage.mjs +36 -0
- package/bin/lib/wikiRunner.mjs +15 -1
- package/bin/lib/workAgentPlans.mjs +25 -4
- package/bin/lib/workSessionTurns.mjs +26 -1
- package/package.json +1 -1
package/bin/lib/claudeAuth.mjs
CHANGED
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
|
|
59
59
|
import { readFileSync } from 'node:fs';
|
|
60
60
|
import { join } from 'node:path';
|
|
61
|
+
// A stamp that may be seconds or milliseconds, to ISO — or null. Shared with
|
|
62
|
+
// runtimeLimits.mjs, which reads the plan windows' reset stamps the same way.
|
|
63
|
+
import { isoFromStamp } from './isoStamp.mjs';
|
|
61
64
|
|
|
62
65
|
/**
|
|
63
66
|
* The variables that can carry auth into the CLI from the environment.
|
|
@@ -72,16 +75,6 @@ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAU
|
|
|
72
75
|
/** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
|
|
73
76
|
const CRED_REL = ['.claude', '.credentials.json'];
|
|
74
77
|
|
|
75
|
-
/** A stamp that may be seconds or milliseconds, to ISO — or null if it is
|
|
76
|
-
* neither. Same tolerance the rest of the daemon applies to numbers it did not
|
|
77
|
-
* write: an unreadable stamp is ABSENT, never zero and never "now". */
|
|
78
|
-
function isoFromStamp(v) {
|
|
79
|
-
const n = Number(v);
|
|
80
|
-
if (!Number.isFinite(n) || n <= 0) return null;
|
|
81
|
-
const ms = n > 1e11 ? n : n * 1000;
|
|
82
|
-
const d = new Date(ms);
|
|
83
|
-
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
84
|
-
}
|
|
85
78
|
|
|
86
79
|
/**
|
|
87
80
|
* What the CLI on this machine will resolve, as far as it can be seen without
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function usageFromResult(ev) {
|
|
|
82
82
|
// container that reports no spend, which is indistinguishable from an older
|
|
83
83
|
// daemon by design. A callback that is silent when it breaks has to be called
|
|
84
84
|
// directly by something.
|
|
85
|
-
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
|
|
85
|
+
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit }) {
|
|
86
86
|
let ev;
|
|
87
87
|
try {
|
|
88
88
|
ev = JSON.parse(line);
|
|
@@ -203,5 +203,18 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
203
203
|
const msg = listed || ev.error?.message || ev.error || ev.subtype;
|
|
204
204
|
appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
|
|
205
205
|
}
|
|
206
|
+
} else if (ev.type === 'rate_limit_event') {
|
|
207
|
+
/**
|
|
208
|
+
* HOW CLOSE THE PLAN IS TO ITS LIMIT, IN THE CLI'S OWN NUMBERS (0.109.0).
|
|
209
|
+
*
|
|
210
|
+
* MEASURED on Claude Code 2.1.283, 2026-09-28: an ordinary turn carries
|
|
211
|
+
* one (status `allowed` at 3% — not only near a threshold), BEFORE its
|
|
212
|
+
* `result` line — `{status, resetsAt, rateLimitType, unifiedWindows:
|
|
213
|
+
* {five_hour: {utilization, resetsAt}, seven_day: {…}}, …}`. It was
|
|
214
|
+
* dropped by the fall-through until now. Handed over whole
|
|
215
|
+
* (runtimeLimits.mjs reads it); it is not the model speaking, so it
|
|
216
|
+
* appends no text and emits no activity.
|
|
217
|
+
*/
|
|
218
|
+
if (ev.rate_limit_info && typeof ev.rate_limit_info === 'object') onRateLimit?.(ev.rate_limit_info);
|
|
206
219
|
}
|
|
207
220
|
}
|
package/bin/lib/fleetRoster.mjs
CHANGED
|
@@ -33,9 +33,18 @@ import { detectRuntimes, runtimesReport } from './runtimeDetection.mjs';
|
|
|
33
33
|
import { knownMcpServers, knownSkills } from './runtimeCapabilities.mjs';
|
|
34
34
|
import { effectiveMaxTurns } from './admission.mjs';
|
|
35
35
|
import { runtimeOfferings } from './runtimeOfferings.mjs';
|
|
36
|
+
import { RUNTIME_LIMITS_PARAM_MAX, runtimeLimitsReport } from './runtimeLimits.mjs';
|
|
36
37
|
|
|
37
38
|
let warnedEnvIdentity = false;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* HOW LONG THE POLL'S URL MAY GROW BEFORE AN OPTIONAL READOUT STAYS HOME.
|
|
42
|
+
* The runtime offerings and the plan-limit report are each added only while
|
|
43
|
+
* the whole URL stays under this; the identity and admission params after
|
|
44
|
+
* them are short and always ride.
|
|
45
|
+
*/
|
|
46
|
+
const POLL_URL_BUDGET = 14_000;
|
|
47
|
+
|
|
39
48
|
/**
|
|
40
49
|
* HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
|
|
41
50
|
*
|
|
@@ -255,11 +264,37 @@ export async function fetchRoster(
|
|
|
255
264
|
for (const [param, report] of Object.entries(offerings)) {
|
|
256
265
|
if (Object.keys(report).length) {
|
|
257
266
|
const payload = JSON.stringify(report);
|
|
258
|
-
if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <=
|
|
267
|
+
if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= POLL_URL_BUDGET) url.searchParams.set(param, payload);
|
|
259
268
|
}
|
|
260
269
|
}
|
|
261
270
|
} catch { /* local readouts never fail the poll */ }
|
|
262
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* HOW CLOSE EACH CLI'S PLAN IS TO ITS LIMIT (`rtl`, 0.109.0) — the vendor's
|
|
274
|
+
* own numbers, learned off turns this daemon already ran (runtimeLimits.mjs):
|
|
275
|
+
* `{claude?, codex?}`, each `{plan, status, at, windows: [{id, minutes,
|
|
276
|
+
* usedPct, resetsAt, at}]}`.
|
|
277
|
+
*
|
|
278
|
+
* A DAEMON→SERVER REPORT, so no floor, and the three states the other
|
|
279
|
+
* learned readouts keep: NOT SENT until something was learned (absent is
|
|
280
|
+
* "nothing learned yet" as much as "an older daemon"), a report once one
|
|
281
|
+
* was. Bounded twice — the report's own byte cap, the app's
|
|
282
|
+
* `RUNTIME_LIMITS_PARAM_MAX`, and the URL budget, where it is the readout
|
|
283
|
+
* that stays home first: nothing else on the poll yields to it.
|
|
284
|
+
*/
|
|
285
|
+
try {
|
|
286
|
+
const limits = runtimeLimitsReport();
|
|
287
|
+
if (limits) {
|
|
288
|
+
const payload = JSON.stringify(limits);
|
|
289
|
+
if (
|
|
290
|
+
Buffer.byteLength(payload, 'utf8') <= RUNTIME_LIMITS_PARAM_MAX &&
|
|
291
|
+
url.toString().length + encodeURIComponent(payload).length + 5 <= POLL_URL_BUDGET
|
|
292
|
+
)
|
|
293
|
+
url.searchParams.set('rtl', payload);
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
/* a readout — the poll must never fail on one */
|
|
297
|
+
}
|
|
263
298
|
// THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
|
|
264
299
|
//
|
|
265
300
|
// It carried two more params while the secrets vault existed: `envv` (the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN EPOCH STAMP THE DAEMON DID NOT WRITE, AS ISO — or null.
|
|
3
|
+
*
|
|
4
|
+
* One home (2026-09-28) because two readers of other programs' files need the
|
|
5
|
+
* same tolerance: `claudeAuth.mjs` reads Claude Code's OAuth expiries, and
|
|
6
|
+
* `runtimeLimits.mjs` reads the plan windows' `resetsAt` (Claude's stream) and
|
|
7
|
+
* `resets_at` (Codex's rollout). Neither vendor promises a unit, so a value
|
|
8
|
+
* above 1e11 is read as milliseconds and anything else as seconds — 1e11
|
|
9
|
+
* seconds is the year 5138, and 1e11 milliseconds is 1973, so the two ranges
|
|
10
|
+
* a real stamp can fall in never meet.
|
|
11
|
+
*
|
|
12
|
+
* AN UNREADABLE STAMP IS ABSENT, never zero and never "now": a non-number, a
|
|
13
|
+
* NaN, an Infinity, zero or a negative all answer null. A leaf module on
|
|
14
|
+
* purpose — it imports nothing, so either reader may import it without a cycle.
|
|
15
|
+
*/
|
|
16
|
+
export function isoFromStamp(v) {
|
|
17
|
+
if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
|
|
18
|
+
const n = Number(v);
|
|
19
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
20
|
+
const ms = n > 1e11 ? n : n * 1000;
|
|
21
|
+
const d = new Date(ms);
|
|
22
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
23
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { resolveTurnProfile } from './turnProfile.mjs';
|
|
|
17
17
|
import { claudePermFor } from './claudePosture.mjs';
|
|
18
18
|
import { handleStreamLine } from './claudeStream.mjs';
|
|
19
19
|
import { never, workLanesStopped } from './standDownGate.mjs';
|
|
20
|
+
import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
|
|
@@ -48,7 +49,7 @@ export function cliEnv(mcpEnv) {
|
|
|
48
49
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
49
50
|
// off and keep the raw text passthrough + line sentinels.
|
|
50
51
|
export function runTurn(opts) {
|
|
51
|
-
const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId } = opts;
|
|
52
|
+
const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId } = opts;
|
|
52
53
|
/**
|
|
53
54
|
* AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
|
|
54
55
|
* A stand-down that drains a deploy keeps this process alive; without this
|
|
@@ -229,16 +230,37 @@ export function runTurn(opts) {
|
|
|
229
230
|
const appendText = (t) => {
|
|
230
231
|
out += t;
|
|
231
232
|
};
|
|
233
|
+
/**
|
|
234
|
+
* THE PLAN'S WINDOWS, LEARNED OFF EVERY TURN THIS DAEMON RUNS (0.109.0).
|
|
235
|
+
*
|
|
236
|
+
* This is the one spawn point — agent turns, Terminal tabs, capture
|
|
237
|
+
* chats, the planner, the pre-review and the wiki all come through here
|
|
238
|
+
* — so it is the one place that sees every CLI's word on its plan.
|
|
239
|
+
* Claude says it on the stream (`rate_limit_event`), recorded as it
|
|
240
|
+
* arrives; a caller's own `onRateLimit` still hears it. Codex writes it
|
|
241
|
+
* into the thread's rollout file instead, so the thread this turn spoke
|
|
242
|
+
* under (announced on `thread.started`, else the one it resumed) is read
|
|
243
|
+
* AFTER the child closes, on the next tick — the turn's answer never
|
|
244
|
+
* waits on a file read. See runtimeLimits.mjs.
|
|
245
|
+
*/
|
|
246
|
+
const onRate = (info) => {
|
|
247
|
+
recordClaudeRateLimit(info);
|
|
248
|
+
onRateLimit?.(info);
|
|
249
|
+
};
|
|
250
|
+
let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
|
|
232
251
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
233
252
|
const onLine = (line) => {
|
|
234
253
|
if (!rt.parse)
|
|
235
|
-
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage });
|
|
254
|
+
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit: onRate });
|
|
236
255
|
const ev = rt.parse(line, cwd);
|
|
237
256
|
if (!ev) return;
|
|
238
257
|
// The conversation id, when the runtime announces one (codex's
|
|
239
258
|
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
240
259
|
// every dispatch path — see zero behavior change.
|
|
241
|
-
if (ev.threadId)
|
|
260
|
+
if (ev.threadId) {
|
|
261
|
+
if (rt.id === 'codex') codexThread = ev.threadId;
|
|
262
|
+
onThreadId?.(ev.threadId);
|
|
263
|
+
}
|
|
242
264
|
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
243
265
|
// `onUsage` Claude's result event feeds, so a caller charges a turn
|
|
244
266
|
// identically whichever CLI ran it.
|
|
@@ -294,6 +316,11 @@ export function runTurn(opts) {
|
|
|
294
316
|
child.on('close', () => {
|
|
295
317
|
if (buf.trim()) onLine(buf);
|
|
296
318
|
resolve(out);
|
|
319
|
+
// After the answer is handed back, never before it: see `onRate`.
|
|
320
|
+
if (codexThread) {
|
|
321
|
+
const thread = codexThread;
|
|
322
|
+
setImmediate(() => void learnCodexLimits(thread));
|
|
323
|
+
}
|
|
297
324
|
});
|
|
298
325
|
return;
|
|
299
326
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW CLOSE EACH CLI'S PLAN IS TO ITS LIMIT, IN THE VENDOR'S OWN NUMBERS
|
|
3
|
+
* (2026-09-28, 0.109.0) — a per-process cache of what the CLIs said about
|
|
4
|
+
* their plan windows, relayed on the roster poll as `rtl`.
|
|
5
|
+
*
|
|
6
|
+
* THE OWNER'S CARVE-OUT, stated once here because every reader leans on it:
|
|
7
|
+
* "show activity, never capacity" still stands for Flowviant's own bounds. A
|
|
8
|
+
* plan window is not Flowviant's capacity — it is the VENDOR measuring its own
|
|
9
|
+
* plan, and this file only carries that measurement to Project settings ›
|
|
10
|
+
* Machines, where it reads as "used", never "left" or "free". Nothing here
|
|
11
|
+
* decides, gates, parks or schedules anything off these numbers.
|
|
12
|
+
*
|
|
13
|
+
* TWO SOURCES, ONE SHAPE:
|
|
14
|
+
*
|
|
15
|
+
* · CLAUDE CODE says it on the stream. Every `-p --output-format stream-json`
|
|
16
|
+
* turn carries a `rate_limit_event` BEFORE its `result` line, and the
|
|
17
|
+
* daemon used to drop it. MEASURED on Claude Code 2.1.283, 2026-09-28
|
|
18
|
+
* (`claude -p "say hi" --model haiku --output-format stream-json
|
|
19
|
+
* --verbose`): it fires on an ordinary turn (status `allowed` at 3%), not
|
|
20
|
+
* only near a threshold; `utilization` is a 0–1 FRACTION (hence
|
|
21
|
+
* CLAUDE_UTILIZATION_SCALE = 100 — one constant, no crossover heuristic);
|
|
22
|
+
* `resetsAt` is epoch SECONDS; `unifiedWindows` names `five_hour` and
|
|
23
|
+
* `seven_day`. claudeStream.mjs hands the event's `rate_limit_info` here.
|
|
24
|
+
* · CODEX says it in a file. `codex exec --json` (0.156.1) puts no limits on
|
|
25
|
+
* stdout; its rollout `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<thread>
|
|
26
|
+
* .jsonl` (folder = the LOCAL date the thread STARTED; a resumed thread
|
|
27
|
+
* appends to its original file) carries `event_msg` → `token_count`
|
|
28
|
+
* payloads with `rate_limits: {primary, secondary, plan_type, …}`. After a
|
|
29
|
+
* codex turn closes, runTurn.mjs asks `learnCodexLimits` to read the tail.
|
|
30
|
+
*
|
|
31
|
+
* THREE STATES, the way every machine report here keeps them: null = nothing
|
|
32
|
+
* learned (the param is not sent), a measured value, and — inside a window —
|
|
33
|
+
* null for a number the CLI did not give. An absent or garbage percentage is
|
|
34
|
+
* NULL, never 0: "0% used" is a claim, and nobody measured it.
|
|
35
|
+
*
|
|
36
|
+
* THE BOUNDS are the app's (`packages/shared/src/schemas/runtimeLimits.ts`
|
|
37
|
+
* exports the same four names and values; `scripts/check-app-parity.mjs`
|
|
38
|
+
* rule 7 holds them equal): the server re-applies them, so a looser copy here
|
|
39
|
+
* would only have a report dropped at the boundary.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { readdir, open, stat } from 'node:fs/promises';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { join } from 'node:path';
|
|
45
|
+
import { isoFromStamp } from './isoStamp.mjs';
|
|
46
|
+
import { claudeAuthContext } from './claudeAuth.mjs';
|
|
47
|
+
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
48
|
+
import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
|
|
49
|
+
|
|
50
|
+
/** Claude's `utilization` is a fraction (0.03 = 3%) — MEASURED, see above. */
|
|
51
|
+
export const CLAUDE_UTILIZATION_SCALE = 100;
|
|
52
|
+
/** The whole `rtl` param, as JSON bytes. */
|
|
53
|
+
export const RUNTIME_LIMITS_PARAM_MAX = 2000;
|
|
54
|
+
/** Windows per CLI entry. */
|
|
55
|
+
export const RUNTIME_LIMIT_WINDOWS_MAX = 6;
|
|
56
|
+
/** A window id's length (`five_hour`, `seven_day`, `m1440`). */
|
|
57
|
+
export const RUNTIME_LIMIT_ID_MAX = 40;
|
|
58
|
+
/** A plan slug's length (`max`, `pro`, `prolite`). */
|
|
59
|
+
export const RUNTIME_LIMIT_PLAN_MAX = 32;
|
|
60
|
+
|
|
61
|
+
const WINDOW_ID = new RegExp(`^[a-z0-9_]{1,${RUNTIME_LIMIT_ID_MAX}}$`);
|
|
62
|
+
const PLAN_SLUG = new RegExp(`^[A-Za-z0-9_-]{1,${RUNTIME_LIMIT_PLAN_MAX}}$`);
|
|
63
|
+
const CLAUDE_STATUSES = new Set(['allowed', 'allowed_warning', 'rejected']);
|
|
64
|
+
/** The two windows Claude names whose length is known; any other is null. */
|
|
65
|
+
const CLAUDE_WINDOW_MINUTES = { five_hour: 300, seven_day: 10080 };
|
|
66
|
+
/** A percentage outside this is garbage, not a reading. */
|
|
67
|
+
const PCT_MAX = 1000;
|
|
68
|
+
/** The rollout's tail — the last token_count sits near the end of a file
|
|
69
|
+
* that grows to many megabytes over a long thread. */
|
|
70
|
+
const ROLLOUT_TAIL_BYTES = 256 * 1024;
|
|
71
|
+
/** Day directories walked for a rollout, newest first. */
|
|
72
|
+
const ROLLOUT_DAYS_MAX = 62;
|
|
73
|
+
const PLAN_TTL_MS = 60_000;
|
|
74
|
+
|
|
75
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
76
|
+
|
|
77
|
+
/** A percentage to one decimal, or null — never 0 for "not said". */
|
|
78
|
+
function pctOf(v, scale = 1) {
|
|
79
|
+
if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
|
|
80
|
+
const n = Number(v);
|
|
81
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
82
|
+
const pct = Math.round(n * scale * 10) / 10;
|
|
83
|
+
return pct <= PCT_MAX ? pct : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const planOf = (v) => (typeof v === 'string' && PLAN_SLUG.test(v) ? v : null);
|
|
87
|
+
|
|
88
|
+
/** Per CLI: `{ status, at (ms), plan?, windows: Map<id, window> }`, or null. */
|
|
89
|
+
let claudeState = null;
|
|
90
|
+
let codexState = null;
|
|
91
|
+
|
|
92
|
+
/** Keep a window map to the bound, dropping the oldest reading first. */
|
|
93
|
+
function capWindows(windows) {
|
|
94
|
+
while (windows.size > RUNTIME_LIMIT_WINDOWS_MAX) {
|
|
95
|
+
let oldest = null;
|
|
96
|
+
for (const w of windows.values()) if (!oldest || w.at < oldest.at) oldest = w;
|
|
97
|
+
windows.delete(oldest.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* RECORD WHAT CLAUDE CODE'S `rate_limit_event` SAID.
|
|
103
|
+
*
|
|
104
|
+
* `unifiedWindows` names every window the plan has, each with its own
|
|
105
|
+
* utilization and reset; an older CLI that sends only the single
|
|
106
|
+
* `rateLimitType` window (with whatever `utilization`/`resetsAt` ride beside
|
|
107
|
+
* it) is read as that one window. Windows MERGE by id, each with its own `at`:
|
|
108
|
+
* an event that speaks about one window never erases what an earlier event
|
|
109
|
+
* said about another. The status is the latest event's.
|
|
110
|
+
*/
|
|
111
|
+
export function recordClaudeRateLimit(info, now = Date.now()) {
|
|
112
|
+
if (!info || typeof info !== 'object' || Array.isArray(info)) return;
|
|
113
|
+
const at = iso(now);
|
|
114
|
+
const state = (claudeState ??= { status: null, at: null, windows: new Map() });
|
|
115
|
+
const put = (id, utilization, resetsAt) => {
|
|
116
|
+
if (typeof id !== 'string' || !WINDOW_ID.test(id)) return;
|
|
117
|
+
state.windows.set(id, {
|
|
118
|
+
id,
|
|
119
|
+
minutes: CLAUDE_WINDOW_MINUTES[id] ?? null,
|
|
120
|
+
usedPct: pctOf(utilization, CLAUDE_UTILIZATION_SCALE),
|
|
121
|
+
resetsAt: isoFromStamp(resetsAt),
|
|
122
|
+
at,
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const unified = info.unifiedWindows;
|
|
126
|
+
if (unified && typeof unified === 'object' && !Array.isArray(unified)) {
|
|
127
|
+
for (const [id, w] of Object.entries(unified)) {
|
|
128
|
+
if (w && typeof w === 'object') put(id, w.utilization, w.resetsAt);
|
|
129
|
+
}
|
|
130
|
+
} else if (typeof info.rateLimitType === 'string') {
|
|
131
|
+
put(info.rateLimitType, info.utilization, info.resetsAt);
|
|
132
|
+
}
|
|
133
|
+
capWindows(state.windows);
|
|
134
|
+
state.status = CLAUDE_STATUSES.has(info.status) ? info.status : 'other';
|
|
135
|
+
state.at = at;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* RECORD WHAT A CODEX ROLLOUT'S `rate_limits` SAID.
|
|
140
|
+
*
|
|
141
|
+
* `primary` and `secondary` are the plan's windows, named by their length:
|
|
142
|
+
* 300 minutes is `five_hour` and 10080 is `seven_day` (Claude's own ids, so
|
|
143
|
+
* the app words them once), any other length `m<N>`. The snapshot is WHOLE —
|
|
144
|
+
* each token_count carries every window the plan has — so it replaces the
|
|
145
|
+
* previous one rather than merging: a window the plan no longer has must not
|
|
146
|
+
* linger. `resets_at` is seconds. Codex gives the plan slug itself
|
|
147
|
+
* (`plan_type`); `rate_limit_reached_type` is non-null once a window is hit.
|
|
148
|
+
*/
|
|
149
|
+
export function recordCodexRateLimits(rl, now = Date.now()) {
|
|
150
|
+
if (!rl || typeof rl !== 'object' || Array.isArray(rl)) return;
|
|
151
|
+
const at = iso(now);
|
|
152
|
+
const windows = new Map();
|
|
153
|
+
for (const [slot, w] of [['primary', rl.primary], ['secondary', rl.secondary]]) {
|
|
154
|
+
if (!w || typeof w !== 'object') continue;
|
|
155
|
+
const m = Number(w.window_minutes);
|
|
156
|
+
const minutes = Number.isInteger(m) && m > 0 ? m : null;
|
|
157
|
+
const id = minutes === 300 ? 'five_hour' : minutes === 10080 ? 'seven_day' : minutes ? `m${minutes}` : slot;
|
|
158
|
+
if (!WINDOW_ID.test(id)) continue;
|
|
159
|
+
windows.set(id, { id, minutes, usedPct: pctOf(w.used_percent), resetsAt: isoFromStamp(w.resets_at), at });
|
|
160
|
+
}
|
|
161
|
+
codexState = {
|
|
162
|
+
plan: planOf(rl.plan_type),
|
|
163
|
+
status: rl.rate_limit_reached_type != null ? 'rejected' : 'allowed',
|
|
164
|
+
at,
|
|
165
|
+
windows,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** threadId → rollout path, per CODEX_HOME. Bounded: a long-lived daemon
|
|
170
|
+
* runs many threads and the map must not grow with them. */
|
|
171
|
+
const rolloutPaths = new Map();
|
|
172
|
+
const ROLLOUT_PATHS_MAX = 64;
|
|
173
|
+
|
|
174
|
+
const newestFirst = async (dir, re) => {
|
|
175
|
+
try {
|
|
176
|
+
return (await readdir(dir)).filter((n) => re.test(n)).sort().reverse();
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Find `rollout-…-<threadId>.jsonl` by NAME — the id is matched against
|
|
183
|
+
* what `readdir` returned and never joined into a path. */
|
|
184
|
+
async function findRollout(root, threadId) {
|
|
185
|
+
const suffix = `-${threadId}.jsonl`;
|
|
186
|
+
let days = 0;
|
|
187
|
+
for (const y of await newestFirst(root, /^\d{4}$/)) {
|
|
188
|
+
for (const m of await newestFirst(join(root, y), /^\d{2}$/)) {
|
|
189
|
+
for (const d of await newestFirst(join(root, y, m), /^\d{2}$/)) {
|
|
190
|
+
if (++days > ROLLOUT_DAYS_MAX) return null;
|
|
191
|
+
const dayDir = join(root, y, m, d);
|
|
192
|
+
const hit = (await newestFirst(dayDir, /^rollout-.*\.jsonl$/)).find((n) => n.endsWith(suffix));
|
|
193
|
+
if (hit) return join(dayDir, hit);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The last token_count's non-null `rate_limits` in the file's tail, or null. */
|
|
201
|
+
async function lastRateLimits(file) {
|
|
202
|
+
const fh = await open(file, 'r');
|
|
203
|
+
try {
|
|
204
|
+
const { size } = await fh.stat();
|
|
205
|
+
const len = Math.min(size, ROLLOUT_TAIL_BYTES);
|
|
206
|
+
const buf = Buffer.alloc(len);
|
|
207
|
+
await fh.read(buf, 0, len, size - len);
|
|
208
|
+
const lines = buf.toString('utf8').split('\n');
|
|
209
|
+
// A tail that starts mid-file starts mid-line: that first piece is not JSON.
|
|
210
|
+
if (size > len) lines.shift();
|
|
211
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
212
|
+
const line = lines[i];
|
|
213
|
+
if (!line.includes('"token_count"') || !line.includes('"rate_limits"')) continue;
|
|
214
|
+
let ev;
|
|
215
|
+
try {
|
|
216
|
+
ev = JSON.parse(line);
|
|
217
|
+
} catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const p = ev?.payload;
|
|
221
|
+
if (p?.type === 'token_count' && p.rate_limits && typeof p.rate_limits === 'object') return p.rate_limits;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
} finally {
|
|
225
|
+
await fh.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* LEARN A CODEX THREAD'S PLAN WINDOWS FROM ITS ROLLOUT, after its turn closed.
|
|
231
|
+
*
|
|
232
|
+
* Best-effort from end to end, and it never throws: an id of the wrong shape,
|
|
233
|
+
* a missing CODEX_HOME, a rollout older than the walk, an unreadable file or a
|
|
234
|
+
* tail with no `rate_limits` all change nothing. Returns whether it learned.
|
|
235
|
+
*/
|
|
236
|
+
export async function learnCodexLimits(threadId, { env = process.env, now } = {}) {
|
|
237
|
+
try {
|
|
238
|
+
if (typeof threadId !== 'string' || !CODEX_THREAD_RE.test(threadId)) return false;
|
|
239
|
+
const root = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
|
|
240
|
+
const key = `${root}\0${threadId}`;
|
|
241
|
+
let file = rolloutPaths.get(key) ?? null;
|
|
242
|
+
if (file) {
|
|
243
|
+
try {
|
|
244
|
+
await stat(file);
|
|
245
|
+
} catch {
|
|
246
|
+
rolloutPaths.delete(key);
|
|
247
|
+
file = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!file) {
|
|
251
|
+
file = await findRollout(root, threadId);
|
|
252
|
+
if (!file) return false;
|
|
253
|
+
rolloutPaths.set(key, file);
|
|
254
|
+
if (rolloutPaths.size > ROLLOUT_PATHS_MAX) rolloutPaths.delete(rolloutPaths.keys().next().value);
|
|
255
|
+
}
|
|
256
|
+
const rl = await lastRateLimits(file);
|
|
257
|
+
if (!rl) return false;
|
|
258
|
+
recordCodexRateLimits(rl, now ?? Date.now());
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* CLAUDE'S PLAN SLUG (`max`, `pro`…) off the OAuth store `claudeAuth.mjs`
|
|
267
|
+
* already reads — never a token, only the plan name it keeps. Read at most
|
|
268
|
+
* once a minute; null when unknown (a keychain machine, an env-var key).
|
|
269
|
+
*/
|
|
270
|
+
let planMemo = null;
|
|
271
|
+
export function claudePlanReading(now = Date.now()) {
|
|
272
|
+
if (planMemo && now - planMemo.readAt < PLAN_TTL_MS) return planMemo;
|
|
273
|
+
let plan = null;
|
|
274
|
+
try {
|
|
275
|
+
plan = planOf(claudeAuthContext().subscriptionType);
|
|
276
|
+
} catch {
|
|
277
|
+
plan = null;
|
|
278
|
+
}
|
|
279
|
+
planMemo = { plan, readAt: now };
|
|
280
|
+
return planMemo;
|
|
281
|
+
}
|
|
282
|
+
export function claudePlan() {
|
|
283
|
+
return claudePlanReading().plan;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const windowOrder = (a, b) =>
|
|
287
|
+
(a.minutes ?? Infinity) - (b.minutes ?? Infinity) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
288
|
+
|
|
289
|
+
/** One CLI's entry on the wire — every key present, newest windows kept. */
|
|
290
|
+
function entryOf({ plan, status, at, windows }) {
|
|
291
|
+
const list = [...windows.values()]
|
|
292
|
+
.filter((w) => WINDOW_ID.test(w.id))
|
|
293
|
+
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
|
294
|
+
.slice(0, RUNTIME_LIMIT_WINDOWS_MAX)
|
|
295
|
+
.sort(windowOrder)
|
|
296
|
+
.map((w) => ({ id: w.id, minutes: w.minutes, usedPct: w.usedPct, resetsAt: w.resetsAt, at: w.at }));
|
|
297
|
+
return { plan: planOf(plan), status: status ?? null, at: at ?? null, windows: list };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const bytes = (v) => Buffer.byteLength(JSON.stringify(v), 'utf8');
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* THE REPORT, FITTED TO `maxBytes`: windows are dropped oldest reading first
|
|
304
|
+
* until it fits, and a report that still does not fit with none left is null
|
|
305
|
+
* (not sent) rather than cut mid-entry. Pure; exported for its test.
|
|
306
|
+
*/
|
|
307
|
+
export function fitRuntimeLimits(report, maxBytes = RUNTIME_LIMITS_PARAM_MAX) {
|
|
308
|
+
if (!report) return null;
|
|
309
|
+
const out = Object.fromEntries(
|
|
310
|
+
Object.entries(report).map(([k, e]) => [k, { ...e, windows: [...(e.windows ?? [])] }])
|
|
311
|
+
);
|
|
312
|
+
while (bytes(out) > maxBytes) {
|
|
313
|
+
let oldest = null;
|
|
314
|
+
for (const [k, e] of Object.entries(out)) {
|
|
315
|
+
e.windows.forEach((w, i) => {
|
|
316
|
+
if (!oldest || String(w.at) < String(oldest.w.at)) oldest = { k, i, w };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (!oldest) return null;
|
|
320
|
+
out[oldest.k].windows.splice(oldest.i, 1);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* WHAT THE ROSTER POLL SENDS AS `rtl` — `{ claude?, codex? }`, or null when
|
|
327
|
+
* nothing is learned.
|
|
328
|
+
*
|
|
329
|
+
* Claude's entry needs Claude INSTALLED here (the detection the poll already
|
|
330
|
+
* runs) and something to say: its plan from the OAuth store, or a window a
|
|
331
|
+
* turn reported. A plan with no window yet has no status to relay, so its
|
|
332
|
+
* `status` is null — unknown, never a guessed `allowed`. Codex's entry exists
|
|
333
|
+
* once a rollout taught it. `detected` and `plan` are the test's handles.
|
|
334
|
+
*/
|
|
335
|
+
export function runtimeLimitsReport({ detected, plan, now = Date.now() } = {}) {
|
|
336
|
+
const out = {};
|
|
337
|
+
let claudeInstalled = false;
|
|
338
|
+
try {
|
|
339
|
+
claudeInstalled = Boolean((detected ?? detectRuntimes()).find((d) => d.id === 'claude')?.installed);
|
|
340
|
+
} catch {
|
|
341
|
+
claudeInstalled = false;
|
|
342
|
+
}
|
|
343
|
+
if (claudeInstalled) {
|
|
344
|
+
const reading = plan === undefined ? claudePlanReading(now) : { plan: planOf(plan), readAt: now };
|
|
345
|
+
const learned = claudeState && claudeState.windows.size > 0;
|
|
346
|
+
if (reading.plan || learned) {
|
|
347
|
+
out.claude = entryOf({
|
|
348
|
+
plan: reading.plan,
|
|
349
|
+
status: claudeState?.status ?? null,
|
|
350
|
+
at: claudeState?.at ?? iso(reading.readAt),
|
|
351
|
+
windows: claudeState?.windows ?? new Map(),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (codexState) out.codex = entryOf(codexState);
|
|
356
|
+
return Object.keys(out).length ? fitRuntimeLimits(out) : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Test-only: forget everything learned, the plan memo and the path cache. */
|
|
360
|
+
export function resetRuntimeLimitsForTest() {
|
|
361
|
+
claudeState = null;
|
|
362
|
+
codexState = null;
|
|
363
|
+
planMemo = null;
|
|
364
|
+
rolloutPaths.clear();
|
|
365
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE TURN'S TOKENS, ADDED UP ACROSS THE SPAWNS THAT MADE IT (2026-09-28).
|
|
3
|
+
*
|
|
4
|
+
* The agent lane SETS its usage — one spawn, one `result` — and leaves the
|
|
5
|
+
* adding-up to the server. The Terminal tab, the capture chat and the wiki
|
|
6
|
+
* are different: a tab turn whose resume came back empty runs a fresh retry
|
|
7
|
+
* in the same turn, and BOTH spawns spent real tokens; reporting only the
|
|
8
|
+
* second would under-count exactly the turns that went wrong. So those lanes
|
|
9
|
+
* sum what each spawn reported before the one settle carries it.
|
|
10
|
+
*
|
|
11
|
+
* The wire shape is the agent lane's, unchanged: `{input, output,
|
|
12
|
+
* cacheCreate, cacheRead, runtime}`. Null-safe on both sides — a spawn that
|
|
13
|
+
* reported nothing adds nothing, and nothing reported at all stays null
|
|
14
|
+
* (never four zeros: "spent nothing" is a claim nobody measured). Each counter
|
|
15
|
+
* is coerced and floored the way `usageFromResult` reads the CLI's numbers.
|
|
16
|
+
* The runtime is the first one named: one turn runs on one CLI.
|
|
17
|
+
*/
|
|
18
|
+
const n = (v) => {
|
|
19
|
+
const x = Number(v);
|
|
20
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function addUsage(a, b) {
|
|
24
|
+
const has = (u) => Boolean(u) && typeof u === 'object';
|
|
25
|
+
if (!has(a) && !has(b)) return null;
|
|
26
|
+
const x = has(a) ? a : {};
|
|
27
|
+
const y = has(b) ? b : {};
|
|
28
|
+
const runtime = x.runtime ?? y.runtime;
|
|
29
|
+
return {
|
|
30
|
+
input: n(x.input) + n(y.input),
|
|
31
|
+
output: n(x.output) + n(y.output),
|
|
32
|
+
cacheCreate: n(x.cacheCreate) + n(y.cacheCreate),
|
|
33
|
+
cacheRead: n(x.cacheRead) + n(y.cacheRead),
|
|
34
|
+
...(runtime ? { runtime } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/bin/lib/wikiRunner.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
|
|
|
38
38
|
import { THINK_MARKER } from './runtimeEvents.mjs';
|
|
39
39
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
40
40
|
import { postToFleet } from './fleetPost.mjs';
|
|
41
|
+
import { addUsage } from './turnUsage.mjs';
|
|
41
42
|
|
|
42
43
|
export function createWikiRunner({
|
|
43
44
|
repoRoot,
|
|
@@ -372,6 +373,17 @@ export function createWikiRunner({
|
|
|
372
373
|
* installing one, and a thrown turn is exactly the transient case.
|
|
373
374
|
*/
|
|
374
375
|
let sweepCompleted = false;
|
|
376
|
+
/**
|
|
377
|
+
* WHAT THIS TASK'S TURN SPENT, AS THE CLI COUNTED IT (2026-09-28),
|
|
378
|
+
* tagged with the CLI. It rides the DONE frame only — the one frame a
|
|
379
|
+
* task sends exactly once, which is what lets the server charge it
|
|
380
|
+
* without a claim of its own; the throttled mid-turn frames stay a
|
|
381
|
+
* readout. Null (and absent from the frame) when no CLI ran.
|
|
382
|
+
*/
|
|
383
|
+
let usage = null;
|
|
384
|
+
const onUsage = (rt) => (u) => {
|
|
385
|
+
usage = addUsage(usage, { ...u, runtime: rt });
|
|
386
|
+
};
|
|
375
387
|
try {
|
|
376
388
|
// Immediate frame so the cover shows the daemon feed right away (the
|
|
377
389
|
// "reading your code" phase), not a static message, while Claude warms up.
|
|
@@ -444,6 +456,7 @@ export function createWikiRunner({
|
|
|
444
456
|
label: c.cyan('[wiki]'),
|
|
445
457
|
streamJson: true,
|
|
446
458
|
onActivity,
|
|
459
|
+
onUsage: onUsage(wikiRt),
|
|
447
460
|
// FREE SKILLS, off a turn that was running anyway. This stream is
|
|
448
461
|
// already parsed and its init event already carries the CLI's own
|
|
449
462
|
// resolved skill set — the same fact a tab turn teaches — so the
|
|
@@ -501,6 +514,7 @@ export function createWikiRunner({
|
|
|
501
514
|
label: c.cyan('[wiki]'),
|
|
502
515
|
streamJson: true,
|
|
503
516
|
onActivity,
|
|
517
|
+
onUsage: onUsage(wikiRt),
|
|
504
518
|
// Same free harvest as the sweep above.
|
|
505
519
|
onInit: (i) => {
|
|
506
520
|
recordSkills(i.skills);
|
|
@@ -564,7 +578,7 @@ export function createWikiRunner({
|
|
|
564
578
|
}
|
|
565
579
|
// Terminal frame so the app cover clears promptly (don't wait for the
|
|
566
580
|
// freshness window to lapse). force-sent past the throttle.
|
|
567
|
-
await postWikiProgress(frame({ done: true }), true);
|
|
581
|
+
await postWikiProgress(frame({ done: true, ...(usage ? { usage } : {}) }), true);
|
|
568
582
|
// Safety net: the wiki turn is read-only on the repo by CONTRACT, but
|
|
569
583
|
// permission enforcement is a curated tool list, not a path jail —
|
|
570
584
|
// discard anything a confused turn wrote to the worktree so it can
|
|
@@ -5,14 +5,15 @@ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mj
|
|
|
5
5
|
import { git, isSafePathSegment } from './git.mjs';
|
|
6
6
|
import { parseProposal } from './agentPlan.mjs';
|
|
7
7
|
import { c, note } from './ui.mjs';
|
|
8
|
-
import { runTurn } from './runTurn.mjs';
|
|
8
|
+
import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
9
9
|
import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
|
|
10
10
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
11
11
|
import { RUNTIMES } from './runtimes.mjs';
|
|
12
|
-
import { pickRuntimeFor } from './runtimeDetection.mjs';
|
|
12
|
+
import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
|
|
13
13
|
import { removeProbeTranscript } from './runtimeCapabilities.mjs';
|
|
14
14
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
15
15
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
16
|
+
import { addUsage } from './turnUsage.mjs';
|
|
16
17
|
|
|
17
18
|
export function createWorkAgentPlans({
|
|
18
19
|
postBestEffort,
|
|
@@ -23,6 +24,10 @@ export function createWorkAgentPlans({
|
|
|
23
24
|
admit,
|
|
24
25
|
placeLocks,
|
|
25
26
|
workChildren,
|
|
27
|
+
// The real CLI driver and picker; injectable only so the lane can be driven
|
|
28
|
+
// in a test without spending a model turn (the wiki runner's shape).
|
|
29
|
+
runTurn = defaultRunTurn,
|
|
30
|
+
pickRuntimeFor = defaultPickRuntimeFor,
|
|
26
31
|
}) {
|
|
27
32
|
const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
|
|
28
33
|
const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
|
|
@@ -297,6 +302,15 @@ export function createWorkAgentPlans({
|
|
|
297
302
|
let planSession = null;
|
|
298
303
|
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
299
304
|
let wedged = false;
|
|
305
|
+
/**
|
|
306
|
+
* WHAT THE PLANNER SPENT, AS THE CLI COUNTED IT (2026-09-28) — on every
|
|
307
|
+
* report that follows a spawn: a proposal, a reply that did not parse, a
|
|
308
|
+
* wedged turn and a thrown one all sent the requests they sent. The
|
|
309
|
+
* server charges it once per press, behind a claim bound to the lease, so
|
|
310
|
+
* a report the retry posts again adds nothing. Null until the CLI reports.
|
|
311
|
+
*/
|
|
312
|
+
let usage = null;
|
|
313
|
+
const spent = () => (usage ? { usage } : {});
|
|
300
314
|
// Said BEFORE the lock is asked for, because a reader only waits when a
|
|
301
315
|
// writer holds the checkout or is queued ahead of it — which is exactly
|
|
302
316
|
// this condition, read one line before we join the queue.
|
|
@@ -341,6 +355,11 @@ export function createWorkAgentPlans({
|
|
|
341
355
|
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
342
356
|
planSession = i.sessionId.trim();
|
|
343
357
|
},
|
|
358
|
+
// Tagged with the CLI that counted it; one turn, so the sum is the
|
|
359
|
+
// one report (turnUsage.mjs keeps it null-safe).
|
|
360
|
+
onUsage: (u) => {
|
|
361
|
+
usage = addUsage(usage, { ...u, runtime: rt });
|
|
362
|
+
},
|
|
344
363
|
onSpawn: (ch) => {
|
|
345
364
|
planChild = ch;
|
|
346
365
|
// No id: a Deploy press is not a task, and the snapshot's per-task
|
|
@@ -374,7 +393,7 @@ export function createWorkAgentPlans({
|
|
|
374
393
|
out = await Promise.race([turn, capped]);
|
|
375
394
|
});
|
|
376
395
|
} catch (e) {
|
|
377
|
-
await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
|
|
396
|
+
await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
|
|
378
397
|
return;
|
|
379
398
|
} finally {
|
|
380
399
|
if (planTimer) clearTimeout(planTimer);
|
|
@@ -394,6 +413,7 @@ export function createWorkAgentPlans({
|
|
|
394
413
|
await postAgentPlan({
|
|
395
414
|
id,
|
|
396
415
|
error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
|
|
416
|
+
...spent(),
|
|
397
417
|
});
|
|
398
418
|
return;
|
|
399
419
|
}
|
|
@@ -407,10 +427,11 @@ export function createWorkAgentPlans({
|
|
|
407
427
|
error: out.trim()
|
|
408
428
|
? `the plan did not come back as JSON: ${envScrub(out).slice(0, 300)}`
|
|
409
429
|
: 'the planning turn produced no output — the CLI may be signed out',
|
|
430
|
+
...spent(),
|
|
410
431
|
});
|
|
411
432
|
return;
|
|
412
433
|
}
|
|
413
|
-
await postAgentPlan({ id, proposal, sessionRef: repoRoot });
|
|
434
|
+
await postAgentPlan({ id, proposal, sessionRef: repoRoot, ...spent() });
|
|
414
435
|
};
|
|
415
436
|
|
|
416
437
|
const processAgentPlanJobs = (jobs) => {
|
|
@@ -19,7 +19,8 @@ import { git, isSafePathSegment } from './git.mjs';
|
|
|
19
19
|
import { processStartTime } from './procRegistry.mjs';
|
|
20
20
|
import { c, note, ok, warn } from './ui.mjs';
|
|
21
21
|
import { mcpFor } from './claude.mjs';
|
|
22
|
-
import { runTurn } from './runTurn.mjs';
|
|
22
|
+
import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
23
|
+
import { addUsage } from './turnUsage.mjs';
|
|
23
24
|
import {
|
|
24
25
|
SYSTEM_WORK,
|
|
25
26
|
SYSTEM_CAPTURE,
|
|
@@ -87,6 +88,9 @@ export function createWorkSessionTurns({
|
|
|
87
88
|
noteSessionGroup,
|
|
88
89
|
reportPlaceWorktrees,
|
|
89
90
|
burstListeners,
|
|
91
|
+
// The real CLI driver; injectable only so the lane can be driven in a test
|
|
92
|
+
// without spending a model turn (the wiki runner's shape).
|
|
93
|
+
runTurn = defaultRunTurn,
|
|
90
94
|
}) {
|
|
91
95
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
92
96
|
const MAX_WORK_TRIES = 3;
|
|
@@ -159,6 +163,17 @@ export function createWorkSessionTurns({
|
|
|
159
163
|
* words were lost and the turn was re-offered and re-run. Null until
|
|
160
164
|
* the turn gets as far as building one. */
|
|
161
165
|
let toolLog = null;
|
|
166
|
+
/**
|
|
167
|
+
* WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-28) — declared
|
|
168
|
+
* out here for the same reason as the tool log: the `catch` spreads it
|
|
169
|
+
* into the failed settle. SUMMED over both spawns (turnUsage.mjs): a
|
|
170
|
+
* resume that came back empty and its fresh retry both spent tokens.
|
|
171
|
+
* Null until a CLI reports, so every refusal before a spawn carries
|
|
172
|
+
* none, which is what `...(usage ? … : {})` rests on. The server
|
|
173
|
+
* charges it once per turn, behind a one-shot claim, so a settle that
|
|
174
|
+
* sits in the retry queue and is posted again adds nothing.
|
|
175
|
+
*/
|
|
176
|
+
let usage = null;
|
|
162
177
|
try {
|
|
163
178
|
const tries = workAttempts.get(job.id) ?? 0;
|
|
164
179
|
if (tries >= MAX_WORK_TRIES) {
|
|
@@ -646,6 +661,11 @@ export function createWorkSessionTurns({
|
|
|
646
661
|
},
|
|
647
662
|
// The structured twin of the line above — see toolLog.
|
|
648
663
|
onToolEvent: pushToolEvent,
|
|
664
|
+
// The CLI's own token counts, tagged with which CLI counted them
|
|
665
|
+
// — ADDED, not set: both runTurn calls below share these args.
|
|
666
|
+
onUsage: (u) => {
|
|
667
|
+
usage = addUsage(usage, { ...u, runtime: rt.id });
|
|
668
|
+
},
|
|
649
669
|
// What this CLI says it can be asked for by name. Harvested off
|
|
650
670
|
// the init event the stream already carries — no probe, no scan,
|
|
651
671
|
// no extra spawn — and reported on the next roster poll so the
|
|
@@ -805,6 +825,8 @@ export function createWorkSessionTurns({
|
|
|
805
825
|
// Whatever it DID before coming back empty is exactly the
|
|
806
826
|
// question a failed turn's log answers.
|
|
807
827
|
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
828
|
+
// …and an empty fork still spent what it spent.
|
|
829
|
+
...(usage ? { usage } : {}),
|
|
808
830
|
});
|
|
809
831
|
warn('adopt turn produced no output — settled as failed');
|
|
810
832
|
return;
|
|
@@ -842,6 +864,7 @@ export function createWorkSessionTurns({
|
|
|
842
864
|
// on the settled message (the live copy on the record is cleared
|
|
843
865
|
// at settle). Already scrubbed at collection.
|
|
844
866
|
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
867
|
+
...(usage ? { usage } : {}),
|
|
845
868
|
});
|
|
846
869
|
if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
847
870
|
else warn('session turn produced no output — settled as failed');
|
|
@@ -855,6 +878,8 @@ export function createWorkSessionTurns({
|
|
|
855
878
|
// "What did it do before it failed" is exactly the question a
|
|
856
879
|
// crashed turn's log answers — same spread as the main settle.
|
|
857
880
|
...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
|
|
881
|
+
// A turn that threw after its CLI ran still spent what it spent.
|
|
882
|
+
...(usage ? { usage } : {}),
|
|
858
883
|
});
|
|
859
884
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
860
885
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|