@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. 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
+ }