@vimoxshah/tokenflow 1.1.1 → 1.2.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow guard` — a circuit breaker for the session you are in.
|
|
3
|
+
*
|
|
4
|
+
* Spend across agent sessions follows a power law: a handful of long,
|
|
5
|
+
* subagent-heavy sessions carry most of the bill, and each of their turns
|
|
6
|
+
* costs more than the last because it re-sends everything so far. Every
|
|
7
|
+
* dashboard shows that after the fact. This command runs *during* the session,
|
|
8
|
+
* as a Claude Code hook: it reads the live transcript the same way the
|
|
9
|
+
* anthropic adapter does, prices it with the same table, and reports the
|
|
10
|
+
* running spend, the size of the prompt now being re-sent, and the cost of the
|
|
11
|
+
* last few turns — then warns or blocks against thresholds YOU declared.
|
|
12
|
+
*
|
|
13
|
+
* tokenflow guard as a hook: reads the hook JSON on stdin
|
|
14
|
+
* tokenflow guard --session <f> judge one transcript file, print a report
|
|
15
|
+
* tokenflow guard --set warnCostUsd=25,maxCostUsd=200
|
|
16
|
+
* tokenflow guard --install print the settings.json hooks block (does not write it)
|
|
17
|
+
* tokenflow guard --policy [--cwd <dir>] show the effective policy and where each value came from
|
|
18
|
+
* tokenflow guard --install --codex [--apply] wire up Codex CLI's `notify` (warns only, see docs/guard-codex.md)
|
|
19
|
+
* tokenflow guard --codex-notify <json> Codex's notify program, called after every turn
|
|
20
|
+
*
|
|
21
|
+
* Contract with Claude Code hooks (code.claude.com/docs/en/hooks):
|
|
22
|
+
* - stdin carries {session_id, transcript_path, cwd, hook_event_name, ...}
|
|
23
|
+
* - exit 0 + JSON on stdout: `hookSpecificOutput.additionalContext` reaches
|
|
24
|
+
* the model, `systemMessage` reaches the user
|
|
25
|
+
* - exit 2 blocks (PreToolUse blocks the tool call, UserPromptSubmit rejects
|
|
26
|
+
* the prompt) with stderr as the reason. Stop and SessionStart are never
|
|
27
|
+
* blocked here: preventing a session from stopping is not a saving.
|
|
28
|
+
*
|
|
29
|
+
* Nothing is invented: with no `guard:` thresholds in config or in a repo's
|
|
30
|
+
* `.tokenflow/policy.yaml` (src/core/policy.js — a cap that travels with the
|
|
31
|
+
* repository, and wins over the personal config) the hook is informational
|
|
32
|
+
* and never blocks. Incremental: the transcript's byte offset and open
|
|
33
|
+
* streaming groups are remembered per session under $TOKENFLOW_HOME/guard/,
|
|
34
|
+
* so a 500 MB transcript is read once, not per tool call.
|
|
35
|
+
*/
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import os from 'node:os';
|
|
39
|
+
import url from 'node:url';
|
|
40
|
+
import { loadConfig, saveConfig, merge, paths } from '../core/config.js';
|
|
41
|
+
import { readJson, writeJson } from '../core/store.js';
|
|
42
|
+
import { buildPriceBook } from '../core/pricing.js';
|
|
43
|
+
import { BUILTIN_MODEL_RULES } from '../core/model-map.js';
|
|
44
|
+
import { enrich, walk } from '../core/ingest.js';
|
|
45
|
+
import { getProvider } from '../core/registry.js';
|
|
46
|
+
import { evaluateGuard, renderGuard } from '../analytics/receipt.js';
|
|
47
|
+
import { GUARD_KEYS, effectiveGuardPolicy } from '../core/policy.js';
|
|
48
|
+
import { notify } from '../core/notify.js';
|
|
49
|
+
|
|
50
|
+
export { GUARD_KEYS };
|
|
51
|
+
const BLOCKABLE_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit']);
|
|
52
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
53
|
+
/** The bin this repo ships, resolved from this module rather than trusted CLI plumbing. */
|
|
54
|
+
const DEFAULT_BIN_PATH = path.join(HERE, '..', '..', 'bin', 'tokenflow.js');
|
|
55
|
+
|
|
56
|
+
function makeCtx(config, book) {
|
|
57
|
+
return {
|
|
58
|
+
config,
|
|
59
|
+
tz: config.timezone || null,
|
|
60
|
+
home: os.homedir(),
|
|
61
|
+
user: config.identity?.user || os.userInfo().username,
|
|
62
|
+
machine: config.identity?.machine || os.hostname(),
|
|
63
|
+
priceBook: book,
|
|
64
|
+
rules: [...(config.modelMappings || []).map((r) => ({ ...r, label: r.label || r.provider })), ...BUILTIN_MODEL_RULES],
|
|
65
|
+
log: () => {},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read one transcript (or the part of it not read before) into normalized records.
|
|
71
|
+
* @param {string} file transcript path
|
|
72
|
+
* @param {{config?:object, book?:object, start?:number, state?:object}} [opt]
|
|
73
|
+
* @returns {{records:object[], offset:number, state:object}}
|
|
74
|
+
*/
|
|
75
|
+
export function readTranscript(file, opt = {}) {
|
|
76
|
+
const config = opt.config || loadConfig();
|
|
77
|
+
const book = opt.book || buildPriceBook(readJson(paths().pricing, {}));
|
|
78
|
+
const provider = getProvider('anthropic');
|
|
79
|
+
if (!provider) throw new Error('the anthropic adapter is not loaded');
|
|
80
|
+
const stat = fs.statSync(file);
|
|
81
|
+
const ref = {
|
|
82
|
+
key: path.basename(path.dirname(file)),
|
|
83
|
+
path: file,
|
|
84
|
+
stat,
|
|
85
|
+
start: opt.start || 0,
|
|
86
|
+
state: opt.state ? structuredClone(opt.state) : {},
|
|
87
|
+
gen: 1,
|
|
88
|
+
label: 'guard',
|
|
89
|
+
};
|
|
90
|
+
const ctx = makeCtx(config, book);
|
|
91
|
+
const records = [];
|
|
92
|
+
let seq = 0;
|
|
93
|
+
const emit = (partial) => {
|
|
94
|
+
const rec = enrich(partial, { ctx, provider, seq: seq++, fileRef: ref });
|
|
95
|
+
if (rec) records.push(rec);
|
|
96
|
+
};
|
|
97
|
+
const res = provider.ingestFile(ref, ctx, emit);
|
|
98
|
+
return { records, offset: res && typeof res.offset === 'number' ? res.offset : stat.size, state: ref.state };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function guardDir() {
|
|
102
|
+
return path.join(paths().root, 'guard');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Records kept from earlier reads are replayed into the verdict so the running
|
|
107
|
+
* totals are exact without re-reading the file. Streaming groups that continued
|
|
108
|
+
* arrive as deltas carrying the same request id: merge, do not append. Shared
|
|
109
|
+
* by the Claude Code transcript path and the Codex rollout path — the two
|
|
110
|
+
* sources produce the same shape of streaming deltas.
|
|
111
|
+
* @param {object[]} kept records carried over from the cache
|
|
112
|
+
* @param {object[]} records freshly read records (may re-report an open group)
|
|
113
|
+
*/
|
|
114
|
+
function mergeReplayed(kept, records) {
|
|
115
|
+
const byId = new Map(kept.map((r) => [r.request_id || r.id, r]));
|
|
116
|
+
for (const r of records) {
|
|
117
|
+
const k = r.request_id || r.id;
|
|
118
|
+
const prev = byId.get(k);
|
|
119
|
+
if (prev && r.request_id) {
|
|
120
|
+
for (const f of ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'cache_refresh_tokens', 'reasoning_tokens']) {
|
|
121
|
+
if (r[f] !== null && r[f] !== undefined) prev[f] = (prev[f] === null || prev[f] === undefined ? 0 : prev[f]) + r[f];
|
|
122
|
+
}
|
|
123
|
+
if (r.estimated_cost !== null && r.estimated_cost !== undefined) prev.estimated_cost = (prev.estimated_cost ?? 0) + r.estimated_cost;
|
|
124
|
+
prev.timestamp = r.timestamp || prev.timestamp;
|
|
125
|
+
} else {
|
|
126
|
+
byId.set(k, r);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...byId.values()];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `evaluateGuard` is pure and knows nothing about where a threshold came
|
|
134
|
+
* from. This reproduces its own `>=` comparisons (max checked before warn,
|
|
135
|
+
* same order: cost, context, marginal) against the SAME numbers the verdict
|
|
136
|
+
* already carries, purely to find out which keys fired — then appends one
|
|
137
|
+
* reason when any of those keys came from a repo's `.tokenflow/policy.yaml`
|
|
138
|
+
* rather than the user's own config.
|
|
139
|
+
* @param {ReturnType<typeof evaluateGuard>} verdict
|
|
140
|
+
* @param {ReturnType<typeof effectiveGuardPolicy>} eff
|
|
141
|
+
*/
|
|
142
|
+
function annotateSource(verdict, eff) {
|
|
143
|
+
const p = eff.policy;
|
|
144
|
+
const fired = [];
|
|
145
|
+
if (p.maxCostUsd !== null && verdict.cost !== null && verdict.cost >= p.maxCostUsd) fired.push('maxCostUsd');
|
|
146
|
+
else if (p.warnCostUsd !== null && verdict.cost !== null && verdict.cost >= p.warnCostUsd) fired.push('warnCostUsd');
|
|
147
|
+
if (p.maxContextTokens !== null && verdict.contextTokens !== null && verdict.contextTokens >= p.maxContextTokens) fired.push('maxContextTokens');
|
|
148
|
+
else if (p.warnContextTokens !== null && verdict.contextTokens !== null && verdict.contextTokens >= p.warnContextTokens) fired.push('warnContextTokens');
|
|
149
|
+
if (p.warnMarginalUsd !== null && verdict.marginalCostPerTurn !== null && verdict.marginalCostPerTurn >= p.warnMarginalUsd) fired.push('warnMarginalUsd');
|
|
150
|
+
|
|
151
|
+
const repoKeys = fired.filter((k) => eff.sources[k] === 'repo');
|
|
152
|
+
if (!repoKeys.length) return verdict;
|
|
153
|
+
const label = repoKeys.length > 1 ? `caps ${repoKeys.join(', ')}` : `cap ${repoKeys[0]}`;
|
|
154
|
+
const note = eff.note ? `: ${eff.note}` : '';
|
|
155
|
+
return { ...verdict, reasons: [...verdict.reasons, `${label} from .tokenflow/policy.yaml${note}`] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Evaluate a session from its transcript, resuming from the cached read position.
|
|
160
|
+
* @param {{transcript_path:string, session_id?:string, cwd?:string}} payload
|
|
161
|
+
* @param {{config?:object, book?:object, cache?:boolean}} [opt]
|
|
162
|
+
*/
|
|
163
|
+
export function evaluateSession(payload, opt = {}) {
|
|
164
|
+
const config = opt.config || loadConfig();
|
|
165
|
+
const book = opt.book || buildPriceBook(readJson(paths().pricing, {}));
|
|
166
|
+
const file = payload.transcript_path;
|
|
167
|
+
const sid = payload.session_id || path.basename(file, '.jsonl');
|
|
168
|
+
const useCache = opt.cache !== false;
|
|
169
|
+
const cacheFile = path.join(guardDir(), `${sid.replace(/[^\w.-]/g, '_')}.json`);
|
|
170
|
+
const cached = useCache ? readJson(cacheFile, null) : null;
|
|
171
|
+
const stat = fs.statSync(file);
|
|
172
|
+
|
|
173
|
+
// A transcript that shrank was rewritten: start over.
|
|
174
|
+
const resume = cached && cached.offset <= stat.size ? cached : null;
|
|
175
|
+
const { records, offset, state } = readTranscript(file, { config, book, start: resume ? resume.offset : 0, state: resume ? resume.state : {} });
|
|
176
|
+
|
|
177
|
+
const kept = resume ? resume.records : [];
|
|
178
|
+
const all = mergeReplayed(kept, records);
|
|
179
|
+
// Claude Code sends `cwd` in the hook payload (see the module doc above);
|
|
180
|
+
// a repo's `.tokenflow/policy.yaml` at that cwd wins over ~/.tokenflow/config.yaml.
|
|
181
|
+
const eff = effectiveGuardPolicy({ cwd: payload.cwd, config });
|
|
182
|
+
const verdict = annotateSource(evaluateGuard(all, eff.policy, book), eff);
|
|
183
|
+
|
|
184
|
+
if (useCache) {
|
|
185
|
+
fs.mkdirSync(guardDir(), { recursive: true });
|
|
186
|
+
writeJson(cacheFile, { offset, state, records: all.map(slim), updated: new Date().toISOString() });
|
|
187
|
+
}
|
|
188
|
+
return { verdict, records: all, offset };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Only the fields the verdict needs; a transcript's metadata stays in the transcript. */
|
|
192
|
+
function slim(r) {
|
|
193
|
+
return {
|
|
194
|
+
id: r.id, request_id: r.request_id, timestamp: r.timestamp, model: r.model, model_family: r.model_family,
|
|
195
|
+
provider: r.provider, source: r.source, measurement: r.measurement, category: r.category,
|
|
196
|
+
session_id: r.session_id, service_tier: r.service_tier,
|
|
197
|
+
input_tokens: r.input_tokens, output_tokens: r.output_tokens,
|
|
198
|
+
cache_read_tokens: r.cache_read_tokens, cache_write_tokens: r.cache_write_tokens,
|
|
199
|
+
cache_refresh_tokens: r.cache_refresh_tokens, reasoning_tokens: r.reasoning_tokens,
|
|
200
|
+
estimated_cost: r.estimated_cost, cost_basis: r.cost_basis,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================ Codex guard ===
|
|
205
|
+
//
|
|
206
|
+
// Codex CLI (~/.codex) has no blocking hook: its `notify` setting in
|
|
207
|
+
// ~/.codex/config.toml runs a program once after every turn, with one JSON
|
|
208
|
+
// argument (docs.openai.com/codex → config-advanced.md, "Notifications").
|
|
209
|
+
// That JSON currently looks like:
|
|
210
|
+
// {"type":"agent-turn-complete","thread-id":"...","turn-id":"...",
|
|
211
|
+
// "cwd":"...","input-messages":[...],"last-assistant-message":"..."}
|
|
212
|
+
// The last two carry the user's and the model's own words. TokenFlow reads
|
|
213
|
+
// counts and metadata only — never prompt or code content — so this path
|
|
214
|
+
// touches exactly `thread-id` and `cwd` and nothing else in that object.
|
|
215
|
+
// Because there is no blocking hook, this can only WARN (an OS notification);
|
|
216
|
+
// it never stops a Codex turn.
|
|
217
|
+
|
|
218
|
+
function defaultCodexHome() {
|
|
219
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Where to look for rollout files. Overridable (`opt.sessionsRoot` or `TOKENFLOW_CODEX_SESSIONS`) so tests never touch a real ~/.codex. */
|
|
223
|
+
function codexSessionsRoots(opt = {}) {
|
|
224
|
+
if (opt.sessionsRoot) return [opt.sessionsRoot];
|
|
225
|
+
if (process.env.TOKENFLOW_CODEX_SESSIONS) return [process.env.TOKENFLOW_CODEX_SESSIONS];
|
|
226
|
+
const home = opt.codexHome || defaultCodexHome();
|
|
227
|
+
return [path.join(home, 'sessions'), path.join(home, 'archived_sessions')];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<ts>-<thread-id>.jsonl` — the newest match if more than one exists. */
|
|
231
|
+
function findCodexRollout(threadId, opt = {}) {
|
|
232
|
+
const suffix = `-${threadId}.jsonl`;
|
|
233
|
+
for (const root of codexSessionsRoots(opt)) {
|
|
234
|
+
if (!fs.existsSync(root)) continue;
|
|
235
|
+
const hits = walk(root, (name) => name.startsWith('rollout-') && name.endsWith(suffix));
|
|
236
|
+
if (hits.length) return hits.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Evaluate one Codex `notify` payload: locate its rollout file, ingest it
|
|
243
|
+
* incrementally with the same openai adapter `tokenflow refresh` uses, judge
|
|
244
|
+
* it against the effective policy for that cwd, and notify on warn/block.
|
|
245
|
+
*
|
|
246
|
+
* Never rejects — every failure mode (no thread id, no rollout file yet, the
|
|
247
|
+
* adapter missing) resolves to `{ skipped: true, reason }` or `{ error }` so
|
|
248
|
+
* a caller that does not await this (see `run()`) never produces an unhandled
|
|
249
|
+
* rejection.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} payload Codex's notify JSON, already parsed. Only
|
|
252
|
+
* `payload['thread-id']` and `payload['cwd']` are ever read.
|
|
253
|
+
* @param {{config?:object, book?:object, cache?:boolean, sessionsRoot?:string,
|
|
254
|
+
* rolloutFile?:string, notify?:Function}} [opt]
|
|
255
|
+
*/
|
|
256
|
+
export async function evaluateCodexNotify(payload, opt = {}) {
|
|
257
|
+
try {
|
|
258
|
+
const threadId = payload['thread-id'];
|
|
259
|
+
const cwd = payload['cwd'] ?? null;
|
|
260
|
+
if (!threadId || typeof threadId !== 'string') {
|
|
261
|
+
return { skipped: true, reason: 'the notify payload has no "thread-id"' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const config = opt.config || loadConfig();
|
|
265
|
+
const book = opt.book || buildPriceBook(readJson(paths().pricing, {}));
|
|
266
|
+
const cacheFile = path.join(guardDir(), `codex-${threadId.replace(/[^\w.-]/g, '_')}.json`);
|
|
267
|
+
const useCache = opt.cache !== false;
|
|
268
|
+
const cached = useCache ? readJson(cacheFile, null) : null;
|
|
269
|
+
|
|
270
|
+
// The resolved path is cached too, so a live session's later turns do not
|
|
271
|
+
// repay the cost of walking ~/.codex/sessions on every single notify call.
|
|
272
|
+
let file = cached && cached.file && fs.existsSync(cached.file) ? cached.file : null;
|
|
273
|
+
if (!file) file = opt.rolloutFile || findCodexRollout(threadId, opt);
|
|
274
|
+
if (!file) return { skipped: true, reason: `no rollout file found for thread ${threadId}` };
|
|
275
|
+
|
|
276
|
+
const provider = getProvider('openai');
|
|
277
|
+
if (!provider) return { skipped: true, reason: 'the openai adapter is not loaded' };
|
|
278
|
+
|
|
279
|
+
const stat = fs.statSync(file);
|
|
280
|
+
const resume = cached && cached.offset <= stat.size ? cached : null;
|
|
281
|
+
|
|
282
|
+
const ctx = makeCtx(config, book);
|
|
283
|
+
const ref = {
|
|
284
|
+
key: threadId,
|
|
285
|
+
path: file,
|
|
286
|
+
stat,
|
|
287
|
+
start: resume ? resume.offset : 0,
|
|
288
|
+
state: resume ? structuredClone(resume.state) : {},
|
|
289
|
+
gen: 1,
|
|
290
|
+
label: 'guard-codex',
|
|
291
|
+
};
|
|
292
|
+
const fresh = [];
|
|
293
|
+
let seq = 0;
|
|
294
|
+
const emit = (partial) => {
|
|
295
|
+
const rec = enrich(partial, { ctx, provider, seq: seq++, fileRef: ref });
|
|
296
|
+
if (rec) fresh.push(rec);
|
|
297
|
+
};
|
|
298
|
+
const res = await provider.ingestFile(ref, ctx, emit);
|
|
299
|
+
const offset = res && typeof res.offset === 'number' ? res.offset : stat.size;
|
|
300
|
+
|
|
301
|
+
const kept = resume ? resume.records : [];
|
|
302
|
+
const all = mergeReplayed(kept, fresh);
|
|
303
|
+
const eff = effectiveGuardPolicy({ cwd, config });
|
|
304
|
+
const verdict = annotateSource(evaluateGuard(all, eff.policy, book), eff);
|
|
305
|
+
|
|
306
|
+
// Persist the cache before notifying: notify() is fire-and-forget by
|
|
307
|
+
// design (spawns a detached process), and a caller that does not await
|
|
308
|
+
// this whole function (the CLI path, since Codex ignores our exit code)
|
|
309
|
+
// may let the process exit before that continuation runs. The bookkeeping
|
|
310
|
+
// that matters for correctness must not depend on it.
|
|
311
|
+
if (useCache) {
|
|
312
|
+
fs.mkdirSync(guardDir(), { recursive: true });
|
|
313
|
+
writeJson(cacheFile, { file, offset, state: ref.state, records: all.map(slim), updated: new Date().toISOString() });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let notified = false;
|
|
317
|
+
if (verdict.level !== 'ok') {
|
|
318
|
+
const send = opt.notify || notify;
|
|
319
|
+
const codexLevel = verdict.level === 'block' ? 'over the declared cap (warning only — Codex has no blocking hook)' : 'warning';
|
|
320
|
+
try {
|
|
321
|
+
await send({ title: `TokenFlow guard (Codex) — ${codexLevel}`, body: renderGuard(verdict) });
|
|
322
|
+
notified = true;
|
|
323
|
+
} catch { /* best-effort, same posture as every other notify() call in this project */ }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { verdict, notified, file, threadId, cwd, policySources: eff.sources, repoRoot: eff.repoRoot };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return { error: err.message };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The exact `notify = [...]` TOML line for ~/.codex/config.toml. */
|
|
333
|
+
export function codexNotifySnippet({ execPath = process.execPath, binPath = DEFAULT_BIN_PATH } = {}) {
|
|
334
|
+
return `notify = ${JSON.stringify([execPath, binPath, 'guard', '--codex-notify'])}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function codexInstallInstructions(line) {
|
|
338
|
+
return [
|
|
339
|
+
'Add this to ~/.codex/config.toml (Codex allows exactly one `notify` program —',
|
|
340
|
+
'do not add this if a `notify = [...]` line is already there):',
|
|
341
|
+
'',
|
|
342
|
+
line,
|
|
343
|
+
'',
|
|
344
|
+
'Codex runs this once after every turn with one JSON argument on the command line.',
|
|
345
|
+
'`tokenflow guard --codex-notify` reads only "thread-id" and "cwd" from it, judges that',
|
|
346
|
+
'session against the same guard policy (~/.tokenflow/config.yaml, overridden by that',
|
|
347
|
+
'repository\'s .tokenflow/policy.yaml), and sends one OS notification on warn or block.',
|
|
348
|
+
'Codex CLI has no blocking hook, so this can only WARN — see docs/guard-codex.md.',
|
|
349
|
+
'',
|
|
350
|
+
'Apply this automatically: tokenflow guard --install --codex --apply',
|
|
351
|
+
].join('\n');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Append the `notify` line to ~/.codex/config.toml — but only when the file
|
|
356
|
+
* exists and declares no `notify` key yet. Never overwrites an existing key.
|
|
357
|
+
* @param {{configPath?:string, snippet?:string}} [opt]
|
|
358
|
+
*/
|
|
359
|
+
export function applyCodexInstall(opt = {}) {
|
|
360
|
+
const line = opt.snippet || codexNotifySnippet();
|
|
361
|
+
const configPath = opt.configPath || path.join(defaultCodexHome(), 'config.toml');
|
|
362
|
+
const dir = path.dirname(configPath);
|
|
363
|
+
if (!fs.existsSync(dir)) {
|
|
364
|
+
return { applied: false, reason: `${dir} does not exist — Codex CLI does not look installed on this machine`, line, configPath };
|
|
365
|
+
}
|
|
366
|
+
let existing = '';
|
|
367
|
+
if (fs.existsSync(configPath)) {
|
|
368
|
+
existing = fs.readFileSync(configPath, 'utf8');
|
|
369
|
+
if (/^\s*notify\s*=/m.test(existing)) {
|
|
370
|
+
return { applied: false, reason: `${configPath} already declares a "notify" key — not overwriting it`, line, configPath };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const sep = !existing ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
374
|
+
fs.writeFileSync(configPath, `${existing}${sep}${line}\n`);
|
|
375
|
+
return { applied: true, line, configPath };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Translate a verdict into what a Claude Code hook must print and how it must exit.
|
|
380
|
+
* @param {ReturnType<typeof evaluateGuard>} v
|
|
381
|
+
* @param {string|null} eventName hook_event_name from the payload
|
|
382
|
+
* @returns {{exitCode:number, stdout:string|null, stderr:string|null}}
|
|
383
|
+
*/
|
|
384
|
+
export function hookOutput(v, eventName) {
|
|
385
|
+
if (v.level === 'ok') return { exitCode: 0, stdout: null, stderr: null };
|
|
386
|
+
const headline = `TokenFlow guard: ${v.reasons.join('; ')}.`;
|
|
387
|
+
const advice = v.suggestion ? ` ${v.suggestion}` : '';
|
|
388
|
+
if (v.level === 'block' && eventName && BLOCKABLE_EVENTS.has(eventName)) {
|
|
389
|
+
return {
|
|
390
|
+
exitCode: 2,
|
|
391
|
+
stdout: null,
|
|
392
|
+
stderr: `${headline}${advice} Raise the cap with \`tokenflow guard --set maxCostUsd=<n>\` or clear it with \`tokenflow guard --set maxCostUsd=\`.`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const out = {
|
|
396
|
+
hookSpecificOutput: {
|
|
397
|
+
hookEventName: eventName || 'UserPromptSubmit',
|
|
398
|
+
additionalContext: `${headline}${advice} Prefer the smallest next step; avoid re-reading files already in context.`,
|
|
399
|
+
systemMessage: `${headline}${advice}`,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
return { exitCode: 0, stdout: JSON.stringify(out), stderr: null };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The hooks block to add to ~/.claude/settings.json. Printed, never written. */
|
|
406
|
+
export function installSnippet(binPath = 'tokenflow') {
|
|
407
|
+
const cmd = `${binPath} guard`;
|
|
408
|
+
const settings = {
|
|
409
|
+
hooks: {
|
|
410
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: cmd, timeout: 20 }] }],
|
|
411
|
+
PreToolUse: [{ matcher: 'Agent|Task', hooks: [{ type: 'command', command: cmd, timeout: 20 }] }],
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
return [
|
|
415
|
+
'Add this to ~/.claude/settings.json (merge into an existing "hooks" object):',
|
|
416
|
+
'',
|
|
417
|
+
JSON.stringify(settings, null, 2),
|
|
418
|
+
'',
|
|
419
|
+
'UserPromptSubmit runs once per prompt you send; PreToolUse on Agent|Task runs before each',
|
|
420
|
+
'subagent is spawned — the two moments where a session\'s cost curve bends.',
|
|
421
|
+
'Then declare what you want guarded, e.g.:',
|
|
422
|
+
' tokenflow guard --set warnCostUsd=25,warnContextTokens=200000,maxCostUsd=200',
|
|
423
|
+
'Without thresholds the hook is informational and never blocks.',
|
|
424
|
+
].join('\n');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* @param {string} spec "k=v,k=v" — an empty value clears the key
|
|
429
|
+
* @returns {object} the new guard section
|
|
430
|
+
*/
|
|
431
|
+
export function applySet(spec, cfg = loadConfig()) {
|
|
432
|
+
const guard = { ...(cfg.guard || {}) };
|
|
433
|
+
for (const part of String(spec).split(',')) {
|
|
434
|
+
if (!part.trim()) continue;
|
|
435
|
+
const eq = part.indexOf('=');
|
|
436
|
+
const k = (eq > -1 ? part.slice(0, eq) : part).trim();
|
|
437
|
+
const v = eq > -1 ? part.slice(eq + 1).trim() : '';
|
|
438
|
+
if (!GUARD_KEYS.includes(k)) {
|
|
439
|
+
const e = /** @type {Error & {hint?:string}} */ (new Error(`unknown guard key "${k}"`));
|
|
440
|
+
e.hint = `known keys: ${GUARD_KEYS.join(', ')}`;
|
|
441
|
+
throw e;
|
|
442
|
+
}
|
|
443
|
+
if (v === '') { guard[k] = null; continue; }
|
|
444
|
+
const n = Number(v);
|
|
445
|
+
if (!(n > 0)) throw new Error(`${k} must be a positive number, got "${v}"`);
|
|
446
|
+
guard[k] = n;
|
|
447
|
+
}
|
|
448
|
+
saveConfig(merge(cfg, { guard }));
|
|
449
|
+
return guard;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function readStdinJson() {
|
|
453
|
+
try {
|
|
454
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
455
|
+
if (!raw.trim()) return null;
|
|
456
|
+
return JSON.parse(raw);
|
|
457
|
+
} catch {
|
|
458
|
+
return null; // no stdin, or not JSON: not a hook invocation
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* CLI entry. Returns what to print and the exit code; the bin decides the stream.
|
|
464
|
+
* The `--codex-notify` branch returns a Promise (see the note at its call site);
|
|
465
|
+
* every other branch returns synchronously.
|
|
466
|
+
* @param {object} flags
|
|
467
|
+
* @returns {{stdout:string|null, stderr:string|null, exitCode:number}|Promise<{stdout:string|null, stderr:string|null, exitCode:number}>}
|
|
468
|
+
*/
|
|
469
|
+
export function run(flags = {}) {
|
|
470
|
+
if (flags.install && flags.codex) {
|
|
471
|
+
const line = codexNotifySnippet();
|
|
472
|
+
if (!flags.apply) return { stdout: codexInstallInstructions(line), stderr: null, exitCode: 0 };
|
|
473
|
+
const res = applyCodexInstall({ snippet: line });
|
|
474
|
+
if (!res.applied) return { stdout: `${res.reason}\n\n${codexInstallInstructions(line)}`, stderr: null, exitCode: 0 };
|
|
475
|
+
return { stdout: `✓ appended to ${res.configPath}:\n ${res.line}\n\nRestart Codex CLI for it to take effect.`, stderr: null, exitCode: 0 };
|
|
476
|
+
}
|
|
477
|
+
if (flags.install) return { stdout: installSnippet(flags.bin || 'tokenflow'), stderr: null, exitCode: 0 };
|
|
478
|
+
if (typeof flags.set === 'string') {
|
|
479
|
+
const g = applySet(flags.set);
|
|
480
|
+
const lines = GUARD_KEYS.map((k) => ` ${k.padEnd(18)} ${g[k] === null || g[k] === undefined ? '—' : g[k]}`);
|
|
481
|
+
return { stdout: ['guard thresholds:', ...lines].join('\n'), stderr: null, exitCode: 0 };
|
|
482
|
+
}
|
|
483
|
+
if (flags.policy) {
|
|
484
|
+
const cwd = typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : process.cwd();
|
|
485
|
+
const eff = effectiveGuardPolicy({ cwd, config: loadConfig() });
|
|
486
|
+
const lines = [
|
|
487
|
+
`guard policy for ${cwd}`,
|
|
488
|
+
eff.repoRoot ? ` repository ${eff.repoRoot}` : ' repository (none found above this directory)',
|
|
489
|
+
...GUARD_KEYS.map((k) => ` ${k.padEnd(18)} ${eff.policy[k] === null ? '—' : eff.policy[k]} [${eff.sources[k]}]`),
|
|
490
|
+
];
|
|
491
|
+
if (eff.note) lines.push(` note ${eff.note}`);
|
|
492
|
+
for (const e of eff.errors) lines.push(` ! ${e}`);
|
|
493
|
+
return { stdout: lines.join('\n'), stderr: null, exitCode: 0 };
|
|
494
|
+
}
|
|
495
|
+
if (typeof flags['codex-notify'] === 'string') {
|
|
496
|
+
let payload;
|
|
497
|
+
try {
|
|
498
|
+
payload = JSON.parse(flags['codex-notify']);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
return { stdout: null, stderr: `tokenflow guard --codex-notify: invalid JSON payload (${err.message})`, exitCode: 1 };
|
|
501
|
+
}
|
|
502
|
+
// Codex ignores this program's stdout/exit code (it is a fire-and-forget
|
|
503
|
+
// `notify` side-channel), so there is nothing to lose by returning the
|
|
504
|
+
// promise rather than detaching from it — see docs/guard-codex.md and
|
|
505
|
+
// the "Integration edits" note about awaiting `run()` in bin/tokenflow.js.
|
|
506
|
+
return evaluateCodexNotify(payload).then((r) => {
|
|
507
|
+
if (r.error) return { stdout: null, stderr: `tokenflow guard --codex-notify: ${r.error}`, exitCode: 0 };
|
|
508
|
+
if (r.skipped || r.verdict.level === 'ok') return { stdout: null, stderr: null, exitCode: 0 };
|
|
509
|
+
return { stdout: renderGuard(r.verdict), stderr: null, exitCode: 0 };
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (typeof flags.session === 'string') {
|
|
513
|
+
const file = flags.session.startsWith('~') ? path.join(os.homedir(), flags.session.slice(1)) : flags.session;
|
|
514
|
+
const cwd = typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : process.cwd();
|
|
515
|
+
const { verdict } = evaluateSession({ transcript_path: file, cwd }, { cache: false });
|
|
516
|
+
return { stdout: renderGuard(verdict), stderr: null, exitCode: 0 };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const payload = readStdinJson();
|
|
520
|
+
if (!payload || typeof payload.transcript_path !== 'string') {
|
|
521
|
+
return {
|
|
522
|
+
stdout: null,
|
|
523
|
+
stderr: 'tokenflow guard expects a Claude Code hook payload on stdin, or --session <transcript.jsonl>, --set k=v, --install, --policy, --codex-notify <json>',
|
|
524
|
+
exitCode: 1,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (!fs.existsSync(payload.transcript_path)) return { stdout: null, stderr: null, exitCode: 0 };
|
|
528
|
+
const { verdict } = evaluateSession(payload);
|
|
529
|
+
const out = hookOutput(verdict, payload.hook_event_name || null);
|
|
530
|
+
return out;
|
|
531
|
+
}
|