@vimoxshah/tokenflow 1.1.2 → 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 (86) hide show
  1. package/CHANGELOG.md +180 -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-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-repository guard policy — `.tokenflow/policy.yaml` at a repository root.
3
+ *
4
+ * A cap declared with `tokenflow guard --set` lives in ~/.tokenflow/config.yaml:
5
+ * one machine, one person. A repository-level cap travels with the repo itself
6
+ * (checked in, reviewed in a PR, the same for everyone who clones it) and wins
7
+ * over the personal default — a data-heavy repo's sessions legitimately carry a
8
+ * bigger prompt than a docs repo's, and that is a fact about the repo, not
9
+ * about who happens to be sitting at the keyboard.
10
+ *
11
+ * Nothing here throws on a malformed file: a broken policy.yaml degrades to
12
+ * "reported, not applied" — the same posture the rest of TokenFlow takes
13
+ * toward bad input — rather than breaking the hook it is meant to configure.
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { parseYaml } from './yaml.js';
18
+ import { repoRootOf } from './repo.js';
19
+ import { loadConfig } from './config.js';
20
+
21
+ /** The five thresholds `evaluateGuard` understands. Canonical list — guard.js re-exports this. */
22
+ export const GUARD_KEYS = ['warnCostUsd', 'maxCostUsd', 'warnContextTokens', 'maxContextTokens', 'warnMarginalUsd'];
23
+
24
+ const POLICY_RELATIVE_PATH = path.join('.tokenflow', 'policy.yaml');
25
+
26
+ function isPositiveNumber(v) {
27
+ return typeof v === 'number' && Number.isFinite(v) && v > 0;
28
+ }
29
+
30
+ /**
31
+ * Read `<repo root>/.tokenflow/policy.yaml`, if this cwd sits inside a
32
+ * repository and that file exists. Invalid values are reported in `errors`
33
+ * and left out of `guard`, never thrown — a typo in a checked-in policy file
34
+ * must not take the hook down for everyone who clones the repo.
35
+ * @param {string|null|undefined} cwd
36
+ * @returns {{repoRoot:string|null, path:string|null, found:boolean, guard:object, note:string|null, errors:string[]}}
37
+ */
38
+ export function loadRepoPolicy(cwd) {
39
+ const errors = [];
40
+ const repoRoot = cwd ? repoRootOf(cwd) : null;
41
+ if (!repoRoot) return { repoRoot: null, path: null, found: false, guard: {}, note: null, errors };
42
+
43
+ const file = path.join(repoRoot, POLICY_RELATIVE_PATH);
44
+ if (!fs.existsSync(file)) return { repoRoot, path: file, found: false, guard: {}, note: null, errors };
45
+
46
+ let doc;
47
+ try {
48
+ doc = parseYaml(fs.readFileSync(file, 'utf8')) || {};
49
+ } catch (err) {
50
+ errors.push(`${file}: ${err.message}`);
51
+ return { repoRoot, path: file, found: true, guard: {}, note: null, errors };
52
+ }
53
+
54
+ const rawGuard = doc.guard && typeof doc.guard === 'object' && !Array.isArray(doc.guard) ? doc.guard : {};
55
+ const guard = {};
56
+ for (const [k, v] of Object.entries(rawGuard)) {
57
+ if (k === 'note') continue; // accepted alongside the thresholds; handled below
58
+ if (!GUARD_KEYS.includes(k)) { errors.push(`${file}: unknown guard key "${k}"`); continue; }
59
+ if (v === null || v === undefined) continue; // not declared
60
+ if (!isPositiveNumber(v)) { errors.push(`${file}: guard.${k} must be a positive number, got ${JSON.stringify(v)}`); continue; }
61
+ guard[k] = v;
62
+ }
63
+
64
+ // `note:` can sit at the top level or beside the guard block — accept both
65
+ // rather than guess which one a hand-written file used.
66
+ const noteRaw = doc.note !== undefined ? doc.note : rawGuard.note;
67
+ let note = null;
68
+ if (noteRaw !== undefined && noteRaw !== null) {
69
+ if (typeof noteRaw === 'string') note = noteRaw;
70
+ else errors.push(`${file}: note must be a string`);
71
+ }
72
+
73
+ return { repoRoot, path: file, found: true, guard, note, errors };
74
+ }
75
+
76
+ /**
77
+ * Merge a repository's declared policy over `config.guard`: the repo wins
78
+ * key-by-key, the personal config is the fallback, and an undeclared key is
79
+ * `null` — informational only, the same contract `evaluateGuard` already has.
80
+ * @param {{cwd?:string|null, config?:object}} [opt]
81
+ * @returns {{policy:object, sources:Record<string,'repo'|'config'|'default'>, repoRoot:string|null, note:string|null, errors:string[]}}
82
+ */
83
+ export function effectiveGuardPolicy({ cwd = null, config } = {}) {
84
+ const cfg = config || loadConfig();
85
+ const repo = loadRepoPolicy(cwd);
86
+ const cfgGuard = cfg.guard || {};
87
+ const policy = {};
88
+ /** @type {Record<string, 'repo'|'config'|'default'>} */
89
+ const sources = {};
90
+ for (const k of GUARD_KEYS) {
91
+ if (isPositiveNumber(repo.guard[k])) {
92
+ policy[k] = repo.guard[k];
93
+ sources[k] = 'repo';
94
+ } else if (isPositiveNumber(cfgGuard[k])) {
95
+ policy[k] = cfgGuard[k];
96
+ sources[k] = 'config';
97
+ } else {
98
+ policy[k] = null;
99
+ sources[k] = 'default';
100
+ }
101
+ }
102
+ return { policy, sources, repoRoot: repo.repoRoot, note: repo.note, errors: repo.errors };
103
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Receipts as git notes — a branch's TokenFlow receipt attached to the commit
3
+ * being pushed, under `refs/notes/tokenflow`, so it travels with the code
4
+ * with no server involved. `src/commands/hooks.js` is the only caller in this
5
+ * package (the pre-push hook body); everything here also works standalone.
6
+ *
7
+ * Every `git` invocation uses `execFileSync` with an argument array — never a
8
+ * shell string — so a branch name or path can never be interpreted as shell
9
+ * syntax.
10
+ */
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { paths } from './config.js';
17
+ import { readJson } from './store.js';
18
+ import { buildPriceBook } from './pricing.js';
19
+ import { repoRootOf, makeRepoResolver } from './repo.js';
20
+ import { buildReceipts } from '../analytics/receipt.js';
21
+ import { toReceiptV0 } from '../analytics/receipt-schema.js';
22
+ import { loadPrimaryRecords } from '../commands/receipt.js';
23
+
24
+ /** The git notes ref every receipt note in this package lives under. */
25
+ export const NOTES_REF = 'tokenflow';
26
+
27
+ /** This package's own version, read from its package.json (not the target repo's). */
28
+ function toolVersion() {
29
+ const here = path.dirname(fileURLToPath(import.meta.url));
30
+ const pkgPath = path.resolve(here, '..', '..', 'package.json');
31
+ try {
32
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '0.0.0';
33
+ } catch {
34
+ return '0.0.0'; // package.json missing or unreadable: still return a usable receipt
35
+ }
36
+ }
37
+
38
+ function resolveSha(repoPath, branch) {
39
+ return execFileSync('git', ['rev-parse', `refs/heads/${branch}`], {
40
+ cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
41
+ }).trim();
42
+ }
43
+
44
+ /**
45
+ * Compute the receipt.v0 object for one branch of one repository checkout.
46
+ * Scans every primary record this machine has recorded, then narrows to the
47
+ * repository identified from `repoPath` (the same worktree-aware resolution
48
+ * `tokenflow receipt` uses) and to the named branch.
49
+ *
50
+ * No pull request is looked up here — attaching a note happens at push time,
51
+ * before any PR necessarily exists — so the returned receipt always carries
52
+ * `pr: null` and `changedLines: null`.
53
+ *
54
+ * @param {{repoPath:string, branch:string, store?:import('./store.js').Store,
55
+ * config?:object, sha?:string}} opt
56
+ * `config` is the pricing-overrides object (the shape of pricing.json /
57
+ * buildPriceBook's argument); defaults to what's on disk. `sha` defaults to
58
+ * `git rev-parse refs/heads/<branch>` in repoPath.
59
+ * @returns {object|null} a receipt.v0 object, or null when this branch has no local sessions
60
+ */
61
+ export function buildBranchReceipt({ repoPath, branch, store, config, sha }) {
62
+ const pricingConfig = config || readJson(paths().pricing, {});
63
+ const book = buildPriceBook(pricingConfig);
64
+ const repoName = path.basename(repoRootOf(repoPath) || repoPath);
65
+
66
+ const records = loadPrimaryRecords({ store });
67
+ const result = buildReceipts(records, { book, repoOf: makeRepoResolver(), minTurns: 1 });
68
+ const R = result.repos.find((r) => r.repo === repoName);
69
+ const b = R ? R.branches.find((x) => x.key === branch) : null;
70
+ if (!b) return null;
71
+
72
+ const headSha = sha || resolveSha(repoPath, branch);
73
+ return toReceiptV0(b, { repo: repoName, headSha, toolVersion: toolVersion() });
74
+ }
75
+
76
+ /**
77
+ * Attach `receipt` to `sha` as a git note under refs/notes/tokenflow,
78
+ * overwriting any note already there (a re-push of the same sha updates it).
79
+ * @param {{repoPath:string, sha:string, receipt:object}} opt
80
+ */
81
+ export function writeNote({ repoPath, sha, receipt }) {
82
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenflow-note-'));
83
+ const file = path.join(dir, 'receipt.json');
84
+ try {
85
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
86
+ execFileSync('git', ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-F', file, sha], {
87
+ cwd: repoPath, stdio: ['ignore', 'ignore', 'pipe'],
88
+ });
89
+ } finally {
90
+ fs.rmSync(dir, { recursive: true, force: true });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Read back the receipt attached to `sha`, or null when there isn't one.
96
+ * @param {{repoPath:string, sha:string}} opt
97
+ * @returns {object|null}
98
+ */
99
+ export function readNote({ repoPath, sha }) {
100
+ try {
101
+ const out = execFileSync('git', ['notes', `--ref=${NOTES_REF}`, 'show', sha], {
102
+ cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
103
+ });
104
+ return JSON.parse(out);
105
+ } catch {
106
+ return null; // no note on this sha (or a git/JSON error) — nothing to read back
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Push refs/notes/tokenflow to `remote`. Runs with `TOKENFLOW_HOOK_NESTED=1`
112
+ * in the child's environment so a pre-push hook this triggers on the same
113
+ * repo can recognize this as the nested notes push and return immediately
114
+ * instead of recursing.
115
+ * @param {{repoPath:string, remote:string}} opt
116
+ */
117
+ export function pushNotes({ repoPath, remote }) {
118
+ execFileSync('git', ['push', remote, `refs/notes/${NOTES_REF}`], {
119
+ cwd: repoPath,
120
+ stdio: ['ignore', 'ignore', 'pipe'],
121
+ env: { ...process.env, TOKENFLOW_HOOK_NESTED: '1' },
122
+ });
123
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Repository identity from a working directory, seeing through git worktrees.
3
+ *
4
+ * The adapters record `project` as the basename of the working directory, so a
5
+ * session in `<repo>/.worktrees/<x>` is filed under `x` and one repository's
6
+ * spend is split across every worktree it ever had. Walking up from the cwd
7
+ * to `.git` and following a worktree's `gitdir:` pointer back to the main
8
+ * checkout puts them back together. No subprocess, no network; results are
9
+ * cached per cwd because a store holds thousands of distinct ones.
10
+ *
11
+ * `src/core/ingest.js` applies `repoRootOf` at ingest time too, not only when
12
+ * a receipt is built later: `project`/`repository` are corrected to the
13
+ * resolved repo name as each record is normalized, and `metadata.repoResolved`
14
+ * records whether a root was found, so a record's stored fields already carry
15
+ * the correction rather than needing every reader to redo this walk.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ /**
21
+ * @param {string} cwd
22
+ * @param {Map<string,string|null>} [cache]
23
+ * @returns {string|null} the main checkout's root, or null when no `.git` is found above `cwd`
24
+ */
25
+ export function repoRootOf(cwd, cache = new Map()) {
26
+ if (!cwd) return null;
27
+ if (cache.has(cwd)) return cache.get(cwd);
28
+ let d = cwd;
29
+ let out = null;
30
+ for (let i = 0; i < 12 && d && d !== path.dirname(d); i++) {
31
+ const g = path.join(d, '.git');
32
+ let st = null;
33
+ try { st = fs.statSync(g); } catch { /* not here; keep walking up */ }
34
+ if (st) {
35
+ if (st.isDirectory()) { out = d; break; }
36
+ // A worktree: `.git` is a file "gitdir: /main/checkout/.git/worktrees/<name>"
37
+ let txt = '';
38
+ try { txt = fs.readFileSync(g, 'utf8'); } catch { /* unreadable; fall back to this dir */ }
39
+ const m = /gitdir:\s*(.+)\s*$/m.exec(txt);
40
+ if (m) {
41
+ const gitdir = path.resolve(d, m[1].trim());
42
+ const wt = gitdir.indexOf(`${path.sep}.git${path.sep}worktrees${path.sep}`);
43
+ out = wt > -1 ? gitdir.slice(0, wt) : d;
44
+ } else {
45
+ out = d;
46
+ }
47
+ break;
48
+ }
49
+ d = path.dirname(d);
50
+ }
51
+ cache.set(cwd, out);
52
+ return out;
53
+ }
54
+
55
+ /** A resolver for the receipt builder: repository name from the recorded cwd, else what the adapter said. */
56
+ export function makeRepoResolver() {
57
+ const cache = new Map();
58
+ return (rec) => {
59
+ const cwd = rec.metadata && rec.metadata.cwd;
60
+ const root = cwd ? repoRootOf(cwd, cache) : null;
61
+ if (root) return path.basename(root);
62
+ return rec.repository || rec.project || null;
63
+ };
64
+ }
package/src/core/sync.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Multi-machine aggregation — optional, OFF by default, file-based.
2
+ * Multi-machine aggregation — optional, OFF by default, file-based (or a
3
+ * server the user names).
3
4
  *
4
5
  * Philosophy: instead of a cloud SaaS endpoint, TokenFlow syncs through a
5
6
  * folder the user already trusts (iCloud Drive, Dropbox, Syncthing mount,
@@ -14,23 +15,48 @@
14
15
  * machineName: MacBook Pro # friendly label shown in aggregated views
15
16
  * developerName: Vimox # OPTIONAL — only when the team explicitly
16
17
  * # opts into per-developer visibility (P4-B)
18
+ * receipts: true # ← default; set false to skip the ledger file
19
+ * to: https://… # OPTIONAL — POST both files to a server
20
+ * # instead of writing them into `dir`
21
+ * token: … # bearer token for `to` (or env
22
+ * # TOKENFLOW_SYNC_TOKEN)
17
23
  *
18
- * What is transmitted (per day, per provider/model):
24
+ * What is transmitted, in the daily rollup (per day, per provider/model):
19
25
  * date, tokens in/out/cache, requests, estimated cost, machineId
20
26
  * + developerName ONLY if you set it yourself (team mode, opt-in)
21
- * What is NEVER transmitted: prompts, code, file paths beyond the machine
22
- * label you chose, credentials.
23
27
  *
24
- * Conflict resolution: each machine writes ONLY its own file
25
- * (<machineId>.jsonl) — append-only, last-write-wins per line. Reads merge
26
- * all sibling files by summing per-date buckets. Offline is the natural
27
- * state: files just sync whenever the folder does.
28
+ * A SECOND file, `<machineId>.receipts.json`, carries the team's cost-per-
29
+ * branch-and-PR ledger (see src/analytics/receipt.js), whole-state and
30
+ * last-write-wins, written on every push unless `sync.receipts: false`. Per
31
+ * branch it transmits only:
32
+ * repo (basename only — never a path or URL), branch, costUsd, turns,
33
+ * sessions, subagentTurns, contextShare, first/last (ISO timestamps),
34
+ * longLived (bool), and pr — null, or {number, mergedAt} for a PR this
35
+ * branch shipped in. (`pr` comes from buildReceiptsForStore(), which has
36
+ * no PR source wired in yet — it is null on every real push today; the
37
+ * shape exists so a future cached PR list needs no format change.)
38
+ * It NEVER transmits: file paths, commit hashes, PR titles, diffs or line
39
+ * counts, prompts, or any model/code text — nothing beyond the aggregate
40
+ * numbers above.
41
+ *
42
+ * Conflict resolution: each machine writes ONLY its own files
43
+ * (<machineId>.jsonl append-only last-write-wins per line;
44
+ * <machineId>.receipts.json whole-state last-write-wins). Reads merge all
45
+ * sibling files. Offline is the natural state: files just sync whenever the
46
+ * folder does.
47
+ *
48
+ * `sync.to` (+ `sync.token` / env TOKENFLOW_SYNC_TOKEN) sends the exact same
49
+ * two files' contents to a server the user names instead of writing them
50
+ * into the shared folder — see push() below. Nothing else about the data
51
+ * changes; only the destination does.
28
52
  */
29
53
  import fs from 'node:fs';
30
54
  import path from 'node:path';
31
55
  import os from 'node:os';
32
56
  import crypto from 'node:crypto';
33
57
  import { loadConfig, paths } from './config.js';
58
+ import { Store, readJson } from './store.js';
59
+ import { buildReceiptsForStore } from './bundle.js';
34
60
 
35
61
  /** Stable, anonymous machine id: random UUID persisted locally on first use. */
36
62
  export function machineId(cfgHome = null) {
@@ -54,32 +80,25 @@ export function syncDir(cfg) {
54
80
  }
55
81
 
56
82
  /**
57
- * Export this machine's daily rollups to the shared folder.
58
- * @param {{config?: object}} opt
59
- * @returns {{file:string, days:number}}
83
+ * Read the local daily cube rollup written by the dashboard/watch pipeline
84
+ * and fold it into one JSONL line per date. Provider/model detail stays
85
+ * LOCAL; the synced line is deliberately coarse so the shared folder (or
86
+ * server) leaks minimum information.
87
+ * @param {object} cfg loaded config
88
+ * @param {string} id this machine's id
89
+ * @param {string} cubeFile path to data/cube.json, already known to exist
90
+ * @returns {string[]} one JSON-encoded line per date, oldest first
60
91
  */
61
- export function push(opt = {}) {
62
- const cfg = opt.config || loadConfig();
63
- if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
64
- const dir = ensureDir(cfg);
65
-
66
- // Read the local daily cube rollup written by the dashboard/watch pipeline.
67
- const cubeFile = `${paths().data}/cube.json`;
68
- if (!fs.existsSync(cubeFile)) return { file: null, days: 0 };
69
-
92
+ function computeDailyLines(cfg, id, cubeFile) {
70
93
  const cube = JSON.parse(fs.readFileSync(cubeFile, 'utf8'));
71
94
  const dims = cube.dims;
72
95
  const di = dims.indexOf('d'); // date
73
- const pi = dims.indexOf('p'); // provider
74
96
  const off = dims.length;
75
97
  const mIn = off + cube.measures.indexOf('in');
76
98
  const mOut = off + cube.measures.indexOf('out');
77
99
  const mReq = off + cube.measures.indexOf('req');
78
100
  const mCost = off + cube.measures.indexOf('cost');
79
101
 
80
- // Aggregate rows → one record per (date): totals across providers/models.
81
- // Provider/model detail stays LOCAL; the synced file is deliberately coarse
82
- // so the shared folder leaks minimum information.
83
102
  const byDay = new Map();
84
103
  for (const r of cube.rows) {
85
104
  const day = r[di];
@@ -91,12 +110,11 @@ export function push(opt = {}) {
91
110
  acc.estCost += r[mCost] || 0;
92
111
  }
93
112
 
94
- const id = machineId();
95
113
  const name = sanitizeName(cfg.sync.machineName || os.hostname().split('.')[0]);
96
114
  // Developer identity is included ONLY when the user explicitly set
97
115
  // sync.developerName in their own config. Absent field = anonymous machine.
98
116
  const dev = cfg.sync.developerName ? sanitizeName(cfg.sync.developerName) : null;
99
- const lines = [...byDay.values()]
117
+ return [...byDay.values()]
100
118
  .sort((a, b) => a.date.localeCompare(b.date))
101
119
  .map((d) => JSON.stringify({
102
120
  machineId: id, machineName: name,
@@ -106,12 +124,131 @@ export function push(opt = {}) {
106
124
  requests: d.requests, estCostUsd: Math.round(d.estCost * 10000) / 10000,
107
125
  exportedAt: new Date().toISOString(),
108
126
  }));
127
+ }
128
+
129
+ /** `sync.receipts: false` is the only way to skip the ledger file; absent = on. */
130
+ function receiptsAllowed(cfg) {
131
+ return cfg?.sync?.receipts !== false;
132
+ }
133
+
134
+ /**
135
+ * The whole-state receipts ledger for this machine: cost per (repo, branch),
136
+ * joined to a PR when one is known. Sourced entirely from
137
+ * buildReceiptsForStore() — this module never re-derives cost or reads a
138
+ * session transcript itself.
139
+ * @param {object} cfg loaded config
140
+ * @param {string} id this machine's id
141
+ * @returns {object} the JSON-serializable payload, allowlisted fields only
142
+ */
143
+ function buildReceiptsPayload(cfg, id) {
144
+ const pricing = readJson(paths().pricing, {});
145
+ const store = new Store();
146
+ const built = buildReceiptsForStore(store, pricing);
147
+ const receipts = [];
148
+ for (const R of built.repos) {
149
+ const repo = path.basename(R.repo || 'unknown');
150
+ for (const b of R.branches) {
151
+ receipts.push({
152
+ repo,
153
+ branch: b.key,
154
+ costUsd: b.cost,
155
+ turns: b.turns,
156
+ sessions: b.sessions,
157
+ subagentTurns: b.subagentTurns,
158
+ contextShare: b.contextShare,
159
+ first: b.first,
160
+ last: b.last,
161
+ longLived: b.longLived,
162
+ pr: b.pr ? { number: b.pr.number, mergedAt: b.pr.mergedAt ?? null } : null,
163
+ });
164
+ }
165
+ }
166
+ // Unlike the jsonl rollup (which always carries a hostname fallback), the
167
+ // machine label here is opt-in only — this file joins branch/PR identity,
168
+ // so it stays anonymous unless the user chose a label themselves.
169
+ const name = cfg?.sync?.machineName ? sanitizeName(cfg.sync.machineName) : null;
170
+ return {
171
+ schema: 1,
172
+ machineId: id,
173
+ ...(name ? { machineName: name } : {}),
174
+ generatedAt: new Date().toISOString(),
175
+ receipts,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Export this machine's daily rollups (and, unless disabled, its receipts
181
+ * ledger) to the shared folder, or POST both to a server when `to`/
182
+ * `sync.to` is set.
183
+ *
184
+ * Stays synchronous for the folder path (existing callers rely on getting
185
+ * `{file, days}` back immediately, not a Promise). The moment a destination
186
+ * server is configured, a network call is unavoidable, so that branch alone
187
+ * returns a Promise — `await push(...)` works either way.
188
+ * @param {{config?: object, to?: string|null, token?: string|null}} opt
189
+ * @returns {{file:string|null, days:number}|Promise<{file:null, days:number, pushedTo:string}>}
190
+ */
191
+ export function push(opt = {}) {
192
+ const cfg = opt.config || loadConfig();
193
+ const id = machineId();
194
+ const to = opt.to || cfg?.sync?.to || null;
195
+ const token = opt.token || cfg?.sync?.token || process.env.TOKENFLOW_SYNC_TOKEN || null;
196
+
197
+ if (to) return pushToServer({ cfg, id, to, token });
198
+
199
+ if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
200
+ const dir = ensureDir(cfg);
201
+
202
+ const cubeFile = `${paths().data}/cube.json`;
203
+ if (!fs.existsSync(cubeFile)) return { file: null, days: 0 };
109
204
 
205
+ const lines = computeDailyLines(cfg, id, cubeFile);
110
206
  const file = path.join(dir, `${id}.jsonl`);
111
207
  fs.writeFileSync(file, lines.join('\n') + (lines.length ? '\n' : ''));
208
+
209
+ if (receiptsAllowed(cfg)) {
210
+ const receiptsFile = path.join(dir, `${id}.receipts.json`);
211
+ fs.writeFileSync(receiptsFile, JSON.stringify(buildReceiptsPayload(cfg, id), null, 2));
212
+ }
213
+
112
214
  return { file, days: lines.length };
113
215
  }
114
216
 
217
+ /**
218
+ * The remote-destination branch of push(): same two files' contents, POSTed
219
+ * instead of written to a folder.
220
+ * @param {{cfg:object, id:string, to:string, token:string|null}} args
221
+ * @returns {Promise<{file:null, days:number, pushedTo:string}>}
222
+ */
223
+ async function pushToServer({ cfg, id, to, token }) {
224
+ if (cfg?.sync?.enabled !== true) throw new Error('sync is disabled (sync.enabled: false)');
225
+ if (!/^https?:\/\//i.test(to)) throw new Error(`sync.to must be an http(s) URL, got: ${to}`);
226
+
227
+ const cubeFile = `${paths().data}/cube.json`;
228
+ const lines = fs.existsSync(cubeFile) ? computeDailyLines(cfg, id, cubeFile) : [];
229
+ const files = { [`${id}.jsonl`]: lines.join('\n') + (lines.length ? '\n' : '') };
230
+ if (receiptsAllowed(cfg)) {
231
+ files[`${id}.receipts.json`] = JSON.stringify(buildReceiptsPayload(cfg, id), null, 2);
232
+ }
233
+
234
+ const url = `${to.replace(/\/+$/, '')}/api/rollup`;
235
+ const res = await fetch(url, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'content-type': 'application/json',
239
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
240
+ },
241
+ body: JSON.stringify({ machineId: id, files }),
242
+ });
243
+ if (!res.ok) {
244
+ // Drain the body so the connection can be reused, but never surface it —
245
+ // a server's error page is not ours to print.
246
+ try { await res.text(); } catch { /* ignore */ }
247
+ throw new Error(`sync push to ${to} failed: ${res.status} ${res.statusText}`);
248
+ }
249
+ return { file: null, days: lines.length, pushedTo: to };
250
+ }
251
+
115
252
  /**
116
253
  * Merge every sibling machine's file into combined daily totals.
117
254
  * @returns {{machines: string[], days: Array}}
package/src/core/team.js CHANGED
Binary file
@@ -24,9 +24,36 @@ function candidatePorts() {
24
24
  return [...new Set([configured, 7799, 7800, 8799].filter(Boolean))];
25
25
  }
26
26
 
27
+ /**
28
+ * The base stylesheet followed by every registered view's stylesheet, in name
29
+ * order, concatenated in the same sequence the live page links them.
30
+ *
31
+ * The dev page gets these as one `<link>` per view, injected by app.js. A
32
+ * snapshot has no server to fetch them from, so they are inlined here instead —
33
+ * miss this and every registered tab loses its styles the moment the file is
34
+ * saved. Order matters: view rules must be able to override the base.
35
+ *
36
+ * @param {string} root repository root
37
+ * @returns {string}
38
+ */
39
+ function collectCss(root) {
40
+ const uiDir = path.join(root, 'src', 'ui');
41
+ const parts = [fs.readFileSync(path.join(uiDir, 'styles.css'), 'utf8')];
42
+ const viewCssDir = path.join(uiDir, 'styles');
43
+ let files = [];
44
+ try {
45
+ files = fs.readdirSync(viewCssDir).filter((f) => f.endsWith('.css')).sort();
46
+ } catch { /* no per-view stylesheets yet: the base sheet is the whole answer */ }
47
+ for (const f of files) {
48
+ parts.push(`/* --- ${path.posix.join('src/ui/styles', f)} --- */`);
49
+ parts.push(fs.readFileSync(path.join(viewCssDir, f), 'utf8'));
50
+ }
51
+ return parts.join('\n');
52
+ }
53
+
27
54
  export function buildSnapshot({ maxRecords = 20000, title = 'Tokenflow' } = {}) {
28
55
  const ROOT = rootDir();
29
- const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'styles.css'), 'utf8');
56
+ const css = collectCss(ROOT);
30
57
  const html = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'index.html'), 'utf8');
31
58
  const js = bundle(path.join(ROOT, 'src', 'ui', 'app.js'), { root: ROOT });
32
59
  // A snapshot that does not parse is worse than a failed export, so check the
@@ -103,6 +103,27 @@ export function renderXbar(status, opt = {}) {
103
103
  }
104
104
  }
105
105
 
106
+ // ---- live sessions --------------------------------------------------------
107
+ // Older status.json files (written before this field existed) simply lack
108
+ // it, so this whole block is null-guarded rather than assumed present.
109
+ const live = status.liveSessions?.sessions || [];
110
+ if (live.length) {
111
+ lines.push('---');
112
+ lines.push(`Live now (${live.length}) | font-size=11`);
113
+ for (const s of live.slice(0, 3)) {
114
+ const glyph = GLYPH[s.guard?.level] || '';
115
+ const where = s.project || s.repository || s.branch || '';
116
+ lines.push(`${glyph}${s.model || 'session'}${where ? ` · ${where}` : ''}${s.costUsd != null ? ` · ${money(s.costUsd)}` : ''} | font-size=12`);
117
+ }
118
+ }
119
+
120
+ // ---- guard ------------------------------------------------------------
121
+ const guard = status.guard?.lastVerdict;
122
+ if (guard && guard.level !== 'ok') {
123
+ lines.push('---');
124
+ lines.push(`${GLYPH[guard.level] || ''}Guard: ${guard.reasons?.[0] || guard.level} | font-size=12`);
125
+ }
126
+
106
127
  // ---- freshness + actions ------------------------------------------------
107
128
  lines.push('---');
108
129
  const fr = status.freshness || {};