flowviant 0.108.0 → 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.
@@ -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
@@ -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
  }
@@ -25,7 +25,7 @@ export const COMMANDS = [
25
25
  name: 'start',
26
26
  usesCheckout: true,
27
27
  usage: 'flowviant [start] [--project <name|id>]',
28
- summary: "Serve this repo's project: run agents and Workbench tabs here. Plain `flowviant` does the same.",
28
+ summary: "Serve this repo's project: run agents and Terminal tabs here. Plain `flowviant` does the same.",
29
29
  details: [
30
30
  'Run it inside the repository you connected. The first run in a new repo asks you to approve this computer.',
31
31
  '--project <name|id> serve that stored project instead of the one this repo is bound to',
package/bin/lib/fleet.mjs CHANGED
@@ -628,7 +628,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
628
628
  // polling, and saying "machine online" over a daemon the server hands
629
629
  // nothing would contradict the standby line printed a moment earlier.
630
630
  if (holderState !== 'standby')
631
- info('Machine online. Open a tab in Flowviant → Workbench to start working.');
631
+ info('Machine online. Open a tab in Flowviant → Terminal to start working.');
632
632
  }
633
633
  // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
634
634
  // it was once gated on `roster.agents`, which the server sends permanently
@@ -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 <= 14_000) url.searchParams.set(param, payload);
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
+ }
@@ -83,6 +83,6 @@ export async function runMcpCommand(args = []) {
83
83
  console.log('');
84
84
  console.log('Then, in any Claude session: "file a task in Flowviant for …".');
85
85
  console.log('Tasks land as drafts on the board — nothing runs until you open');
86
- console.log('a session tab in the Workbench and type in it.');
86
+ console.log('a session tab in the Terminal and type in it.');
87
87
  console.log('');
88
88
  }
@@ -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) onThreadId?.(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
+ }
@@ -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) {
@@ -329,7 +344,7 @@ export function createWorkSessionTurns({
329
344
  // mcpFor throw this used to crash into every turn.
330
345
  await settleWorkTurn(job.id, {
331
346
  ok: false,
332
- answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
347
+ answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
333
348
  });
334
349
  return;
335
350
  }
@@ -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.108.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": {