@cliftonc/finius 0.1.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/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/branding.js +28 -0
  4. package/dist/cli/backfill.js +122 -0
  5. package/dist/cli/claude-settings.js +54 -0
  6. package/dist/cli/codex-config.js +60 -0
  7. package/dist/cli/codex.js +97 -0
  8. package/dist/cli/config.js +41 -0
  9. package/dist/cli/doctor.js +159 -0
  10. package/dist/cli/hook.js +70 -0
  11. package/dist/cli/identity.js +163 -0
  12. package/dist/cli/import.js +61 -0
  13. package/dist/cli/index.js +70 -0
  14. package/dist/cli/install.js +23 -0
  15. package/dist/cli/password.js +14 -0
  16. package/dist/cli/serve.js +63 -0
  17. package/dist/cli/setup.js +314 -0
  18. package/dist/cli/ui.js +15 -0
  19. package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
  20. package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
  21. package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
  22. package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
  23. package/dist/client/assets/index-6OIY_8fO.css +1 -0
  24. package/dist/client/assets/index-9aN8py7_.js +1 -0
  25. package/dist/client/assets/index-B-sjMmTS.js +1636 -0
  26. package/dist/client/assets/index-B4HbP3X6.js +1 -0
  27. package/dist/client/assets/index-B9wgN1BV.js +1636 -0
  28. package/dist/client/assets/index-BHlFz1Th.js +1652 -0
  29. package/dist/client/assets/index-BJyvYca7.js +1636 -0
  30. package/dist/client/assets/index-BKBTeJLz.js +1 -0
  31. package/dist/client/assets/index-BN6CbirS.js +1444 -0
  32. package/dist/client/assets/index-BW4_7xR6.js +1460 -0
  33. package/dist/client/assets/index-BaLElA30.js +1 -0
  34. package/dist/client/assets/index-BaQ02V5d.css +1 -0
  35. package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
  36. package/dist/client/assets/index-Bie86XRc.js +1 -0
  37. package/dist/client/assets/index-Bijt5al-.css +1 -0
  38. package/dist/client/assets/index-BikJP2HS.js +1636 -0
  39. package/dist/client/assets/index-BkwrvP-J.js +1 -0
  40. package/dist/client/assets/index-BwVuUJSv.js +1 -0
  41. package/dist/client/assets/index-BweXI4-D.css +1 -0
  42. package/dist/client/assets/index-BwqdHcDE.js +1 -0
  43. package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
  44. package/dist/client/assets/index-C2RmKzem.js +1636 -0
  45. package/dist/client/assets/index-CHz-iKIQ.js +1 -0
  46. package/dist/client/assets/index-CIGl5oW_.js +1646 -0
  47. package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
  48. package/dist/client/assets/index-Ca9UVGK1.js +1 -0
  49. package/dist/client/assets/index-CeWDkmJN.js +1 -0
  50. package/dist/client/assets/index-CpsNq0zm.css +1 -0
  51. package/dist/client/assets/index-CrUS6abD.css +1 -0
  52. package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
  53. package/dist/client/assets/index-D1ktp0pp.js +1 -0
  54. package/dist/client/assets/index-D3BoYpFi.css +1 -0
  55. package/dist/client/assets/index-D59GxlrT.js +1636 -0
  56. package/dist/client/assets/index-D5Wkww8x.css +1 -0
  57. package/dist/client/assets/index-DC94jMGe.js +1 -0
  58. package/dist/client/assets/index-DFcIBkv1.js +1652 -0
  59. package/dist/client/assets/index-DmKj5Jqc.css +1 -0
  60. package/dist/client/assets/index-Dx52i05H.js +1465 -0
  61. package/dist/client/assets/index-L3GnPzmU.css +1 -0
  62. package/dist/client/assets/index-OZADsKet.js +1652 -0
  63. package/dist/client/assets/index-Qt124kj1.js +1652 -0
  64. package/dist/client/assets/index-nHzwQ3EM.js +1 -0
  65. package/dist/client/assets/index-s9Mg6LTO.js +1 -0
  66. package/dist/client/assets/index-ye8oxz8P.js +1 -0
  67. package/dist/client/assets/index-yqJS7tUY.css +1 -0
  68. package/dist/client/favicon.svg +35 -0
  69. package/dist/client/finius-dashboard.png +0 -0
  70. package/dist/client/index.html +38 -0
  71. package/dist/server/app.js +285 -0
  72. package/dist/server/claude.js +124 -0
  73. package/dist/server/codex.js +94 -0
  74. package/dist/server/events.js +12 -0
  75. package/dist/server/index.js +119 -0
  76. package/dist/server/otel.js +231 -0
  77. package/dist/server/pricing-backfill.js +41 -0
  78. package/dist/server/pricing.js +138 -0
  79. package/dist/server/queue.js +35 -0
  80. package/dist/server/storage/blob.js +17 -0
  81. package/dist/server/storage/query-helpers.js +104 -0
  82. package/dist/server/storage/sqlite.js +1167 -0
  83. package/dist/server/transcripts.js +46 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/shared/api-types.js +1 -0
  86. package/package.json +72 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clifton Cunningham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ <div align="center">
2
+
3
+ <img src="public/favicon.svg" alt="Finius" width="96" height="96" />
4
+
5
+ # Finius
6
+
7
+ **Local-first Claude Code usage & cost tracker.**
8
+
9
+ A [Hono](https://hono.dev) server ingests OTLP HTTP/JSON metrics & logs (and JSONL transcripts) into
10
+ SQLite; a React + Vite dashboard renders cost, token, session, person, and model breakdowns with live
11
+ SSE updates. Everything runs on your machine — no data leaves your laptop.
12
+
13
+ <img src="public/finius-dashboard.png" alt="Finius dashboard showing local usage and cost analytics" width="900" />
14
+
15
+ </div>
16
+
17
+ ## Quick start (local)
18
+
19
+ The fastest way to get running locally is the `finius` CLI. You need **Node 22.5+** (Finius uses the
20
+ built-in `node:sqlite` module; developed on Node 24).
21
+
22
+ ```bash
23
+ npx @cliftonc/finius # first run: installs finius globally, then walks you through setup
24
+ finius serve # start the server + dashboard at http://localhost:8787
25
+ finius import all # optional: import old Claude Code + Codex sessions
26
+ ```
27
+
28
+ Then launch Claude Code in a new terminal and start coding — the dashboard at
29
+ **http://localhost:8787** updates live as telemetry arrives.
30
+
31
+ That's it. Three things just happened:
32
+
33
+ 1. **`npx @cliftonc/finius`** installed `finius` globally and ran `finius setup`, which saved
34
+ `~/.finius/config.json` and — with your consent — edited `~/.claude/settings.json` to add the OTLP
35
+ env vars plus a `SessionEnd` + `PreCompact` hook (`finius hook`) that uploads each session
36
+ transcript.
37
+ 2. **`finius serve`** started a single process exposing the API **and** the dashboard on one port.
38
+ Its data lives under `~/.finius` (override with `FINIUS_DB_PATH` / `FINIUS_BLOB_DIR`).
39
+ 3. Any Claude Code session you run now reports usage to that local server. If you ran
40
+ `finius import all`, Finius also backfilled historical Claude Code and Codex transcripts already on
41
+ disk. Use `finius import claude` or `finius import codex` to import only one agent.
42
+
43
+ Re-run `finius setup` any time to reconfigure, or `finius doctor` to diagnose telemetry that isn't
44
+ arriving.
45
+
46
+ ## Running from source (development)
47
+
48
+ Prefer to hack on Finius itself? Clone the repo and run the dev servers.
49
+
50
+ ### 1. Install & start
51
+
52
+ ```bash
53
+ npm install
54
+ npm run dev
55
+ ```
56
+
57
+ `npm run dev` runs the API and UI together (via `concurrently`):
58
+
59
+ - **UI** → http://localhost:5173 (Vite dev server; proxies `/api`, `/otlp`, `/events` to the API)
60
+ - **API** → http://localhost:8787
61
+
62
+ Run them separately if you prefer: `npm run dev:server` (API only) or `npm run dev:client` (UI only).
63
+
64
+ ### 2. Point Claude Code at the server
65
+
66
+ In the shell where you launch Claude Code, use the bundled helper — it checks the server is up,
67
+ exports the OTLP env vars, then runs `claude`:
68
+
69
+ ```bash
70
+ ./scripts/run-claude.sh # forwards any extra args to `claude`
71
+ ```
72
+
73
+ Or export the variables manually:
74
+
75
+ ```bash
76
+ export CLAUDE_CODE_ENABLE_TELEMETRY=1
77
+ export OTEL_METRICS_EXPORTER=otlp
78
+ export OTEL_LOGS_EXPORTER=otlp
79
+ export OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json
80
+ export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/json
81
+ export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://localhost:8787/otlp/v1/metrics
82
+ export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:8787/otlp/v1/logs
83
+ claude
84
+ ```
85
+
86
+ Run a Claude Code session and the dashboard updates live (SSE) as telemetry arrives.
87
+
88
+ ### 3. (Optional) production build
89
+
90
+ ```bash
91
+ npm run build # tsc -> dist + vite build -> dist/client
92
+ npm start # node dist/server/index.js, serves the built UI from dist/client on :8787
93
+ ```
94
+
95
+ When a build exists, `npm start` serves the UI and API from the single port http://localhost:8787.
96
+
97
+ ## Using the dashboard
98
+
99
+ - **Home** — KPIs (cost, tokens, cache, lines, edits, sessions, people), tokens/cost/lines/edits
100
+ charts over time, plus Models / Users / Sources breakdowns.
101
+ - **Sessions**, **People**, **Models** — lists ranked by recency / cost. Click any row (or any Home
102
+ breakdown row) to drill into the Home view filtered to that session, person, or model.
103
+ - **Filters** — time range, source, user, and model selects apply everywhere. All view and filter
104
+ state lives in the URL query string, so any view is shareable/bookmarkable and back/forward works.
105
+
106
+ ## Importing transcripts
107
+
108
+ Besides live OTLP telemetry, you can backfill from JSONL transcripts:
109
+
110
+ - `POST /api/import/jsonl` — body `{ content, source?, sessionId? }` (or raw JSONL text).
111
+ - `POST /api/import/claude-hook` — body `{ transcript_path, session_id?, cwd? }`; reads a local
112
+ Claude Code transcript file (restricted to `~/.claude/projects` or the given `cwd`).
113
+
114
+ Imports are idempotent — re-sending the same file (matched by content hash) is detected and skipped.
115
+ The original transcript is stored as a file and can be viewed from the session drill-down
116
+ (`GET /api/sessions/:id/transcript`).
117
+
118
+ ## Storage
119
+
120
+ Data is stored in `data/finius.sqlite` by default. Override with `FINIUS_DB_PATH=/path/to/db.sqlite`.
121
+ Override the API port with `PORT`. Imported transcript files live under `<db-dir>/transcripts`
122
+ (override with `FINIUS_BLOB_DIR`).
123
+
124
+ Dashboard reads are served from a pre-aggregated hourly `metric_rollup`; raw `metric_points` keep
125
+ the full-resolution data for the live view and drill-downs.
126
+
127
+ ### Raw batch retention
128
+
129
+ The full OTLP payload of each ingest batch is kept in `raw_batches` only to allow replaying history
130
+ into new metric classifications. Control it with:
131
+
132
+ - `FINIUS_RAW_PAYLOADS=retain` (default) | `off` — `off` keeps only the dedup hash, not the payload.
133
+ - `FINIUS_RAW_RETENTION_DAYS=7` (default) — age cutoff used by the prune endpoint below.
134
+ - `FINIUS_CRON_TOKEN=<secret>` — enables `POST /api/maintenance/prune-raw-batches`. Without it the
135
+ endpoint is disabled (returns 503). Wire a cron to it:
136
+
137
+ ```bash
138
+ curl -fsS -X POST -H "Authorization: Bearer $FINIUS_CRON_TOKEN" \
139
+ http://127.0.0.1:8787/api/maintenance/prune-raw-batches
140
+ ```
141
+
142
+ ## Other commands
143
+
144
+ ```bash
145
+ npm test # vitest run
146
+ npm run typecheck # tsc --noEmit (strict)
147
+ ```
@@ -0,0 +1,28 @@
1
+ import pc from "picocolors";
2
+ // Shared brand presentation with NO interactive-prompt dependency, so both the CLI (cli/ui.ts) and
3
+ // the long-running server (server/index.ts) can render the wordmark without pulling in @clack/prompts.
4
+ export const BANNER_LINES = [
5
+ "███████╗██╗███╗ ██╗██╗██╗ ██╗███████╗",
6
+ "██╔════╝██║████╗ ██║██║██║ ██║██╔════╝",
7
+ "█████╗ ██║██╔██╗ ██║██║██║ ██║███████╗",
8
+ "██╔══╝ ██║██║╚██╗██║██║██║ ██║╚════██║",
9
+ "██║ ██║██║ ╚████║██║╚██████╔╝███████║",
10
+ "╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝"
11
+ ];
12
+ export const TAGLINE = "Local-first AI coding usage & cost tracker";
13
+ // The colored wordmark + tagline. `subtitle` names the running command (e.g. "setup", "serve").
14
+ export function renderBanner(subtitle) {
15
+ const art = BANNER_LINES.map((line) => pc.cyan(line)).join("\n");
16
+ const sub = subtitle ? ` ${pc.dim("·")} ${pc.cyan(subtitle)}` : "";
17
+ return `\n${art}\n${pc.dim(TAGLINE)}${sub}\n`;
18
+ }
19
+ export function banner(subtitle) {
20
+ process.stdout.write(renderBanner(subtitle));
21
+ }
22
+ // An aligned label/value block (dim labels, padded to a common width). ANSI-safe because padding is
23
+ // computed on the plain label text before any coloring is applied to the value.
24
+ export function panel(rows) {
25
+ const width = Math.max(0, ...rows.map(([label]) => label.length));
26
+ return rows.map(([label, value]) => ` ${pc.dim(label.padEnd(width))} ${value}`).join("\n");
27
+ }
28
+ export { pc };
@@ -0,0 +1,122 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { log, spinner } from "@clack/prompts";
5
+ import { loadConfig, resolveAuthToken, resolveServerUrl } from "./config.js";
6
+ import { resolveIdentity } from "./identity.js";
7
+ import { pc } from "./ui.js";
8
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
9
+ // Recursively collect files under `dir` whose basename matches `match`. Safe on missing dirs.
10
+ export function walkFiles(dir, match) {
11
+ const out = [];
12
+ const walk = (d) => {
13
+ let names;
14
+ try {
15
+ names = readdirSync(d);
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const name of names) {
21
+ const p = join(d, name);
22
+ let st;
23
+ try {
24
+ st = statSync(p);
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ if (st.isDirectory())
30
+ walk(p);
31
+ else if (st.isFile() && match(name))
32
+ out.push(p);
33
+ }
34
+ };
35
+ walk(dir);
36
+ return out;
37
+ }
38
+ // Every Claude Code transcript on disk (~/.claude/projects/**/<session>.jsonl).
39
+ export function findClaudeTranscripts() {
40
+ return walkFiles(CLAUDE_PROJECTS_DIR, (n) => n.endsWith(".jsonl"));
41
+ }
42
+ // Upload one transcript file to the server. Idempotent server-side (content hash / replace-by-session).
43
+ // Attributes the upload to a user: an explicit `identity` if given, else resolved from config by format
44
+ // (so both the setup backfill and the Codex hook attach a user with no extra threading).
45
+ export async function uploadTranscript(path, opts) {
46
+ let content;
47
+ try {
48
+ content = readFileSync(path, "utf8");
49
+ }
50
+ catch {
51
+ return "failed";
52
+ }
53
+ if (!content.trim())
54
+ return "failed";
55
+ const config = loadConfig();
56
+ const endpoint = `${resolveServerUrl()}/api/import/jsonl`;
57
+ const headers = { "content-type": "application/json" };
58
+ const token = resolveAuthToken(config);
59
+ if (token)
60
+ headers.authorization = `Bearer ${token}`;
61
+ const identity = opts.identity ?? resolveIdentity(opts.format === "codex" ? "codex" : "claude", config, opts.cwd);
62
+ try {
63
+ const res = await fetch(endpoint, {
64
+ method: "POST",
65
+ headers,
66
+ body: JSON.stringify({
67
+ content,
68
+ source: opts.source,
69
+ format: opts.format,
70
+ sessionId: opts.sessionId,
71
+ userEmail: identity?.email,
72
+ userAccountId: identity?.accountId,
73
+ userId: identity?.userId,
74
+ githubLogin: identity?.githubLogin,
75
+ displayName: identity?.displayName
76
+ }),
77
+ signal: AbortSignal.timeout(60_000)
78
+ });
79
+ if (!res.ok) {
80
+ process.stderr.write(`\nfinius: ${endpoint} returned ${res.status}\n`);
81
+ return "failed";
82
+ }
83
+ const body = (await res.json().catch(() => ({})));
84
+ return body.duplicate ? "duplicate" : "ok";
85
+ }
86
+ catch (err) {
87
+ process.stderr.write(`\nfinius: upload to ${endpoint} failed (${err.message})\n`);
88
+ return "failed";
89
+ }
90
+ }
91
+ // Upload a list of transcripts ONE AT A TIME — never concurrently, which would swamp the server with
92
+ // large bodies — rendering a live spinner with progress. Returns tallied outcomes.
93
+ export async function backfill(files, opts) {
94
+ const result = { uploaded: 0, duplicate: 0, failed: 0, total: files.length };
95
+ if (files.length === 0) {
96
+ log.info(`${opts.label}: nothing to import.`);
97
+ return result;
98
+ }
99
+ const total = files.length;
100
+ const s = spinner();
101
+ s.start(`Importing ${opts.label}`);
102
+ let done = 0;
103
+ for (const file of files) {
104
+ const outcome = await uploadTranscript(file, { source: opts.source, format: opts.format });
105
+ if (outcome === "ok")
106
+ result.uploaded++;
107
+ else if (outcome === "duplicate")
108
+ result.duplicate++;
109
+ else
110
+ result.failed++;
111
+ done++;
112
+ s.message(`Importing ${opts.label} ${pc.dim(`(${done}/${total})`)}`);
113
+ }
114
+ const summary = `${opts.label}: ${pc.green(`${result.uploaded} new`)}, ${result.duplicate} already-present` +
115
+ (result.failed > 0 ? `, ${pc.red(`${result.failed} failed`)}` : "") +
116
+ ` (of ${result.total}).`;
117
+ s.stop(summary);
118
+ if (result.failed > 0) {
119
+ log.warn("Failures usually mean the server isn't running — `finius serve`, then re-run setup.");
120
+ }
121
+ return result;
122
+ }
@@ -0,0 +1,54 @@
1
+ // Pure helpers for merging Finius configuration into Claude Code's ~/.claude/settings.json.
2
+ // Kept side-effect-free (no fs) so they're easy to unit test; setup.ts handles the actual read/write.
3
+ // Hook events that upload the session transcript. SessionEnd covers /clear and exit; PreCompact
4
+ // captures the full transcript before a compaction shrinks the context window.
5
+ export const TELEMETRY_HOOK_EVENTS = ["SessionEnd", "PreCompact"];
6
+ export const HOOK_TIMEOUT_SECONDS = 60;
7
+ // Substring used to recognize (and replace) a previously-installed Finius hook. The package is named
8
+ // "finius" and is virtually always installed under a path containing it, so this reliably matches our
9
+ // own hook command without touching unrelated user hooks.
10
+ const FINIUS_MARKER = "finius";
11
+ // Point Claude Code's OTLP exporters at the given Finius server. Preserves any existing env vars.
12
+ // When `authToken` is set (Secure Mode), also send it on every OTLP export via the standard
13
+ // Authorization header; when absent, strip any header we previously wrote so toggling auth off cleans
14
+ // up. Tokens are URL-safe hex, so the single space in "Bearer " is the only special char and the OTel
15
+ // JS header parser (comma/equals-delimited key=value) preserves it.
16
+ export function withTelemetryEnv(settings, serverUrl, authToken) {
17
+ settings.env = {
18
+ ...settings.env,
19
+ CLAUDE_CODE_ENABLE_TELEMETRY: "1",
20
+ OTEL_METRICS_EXPORTER: "otlp",
21
+ OTEL_LOGS_EXPORTER: "otlp",
22
+ // Finius ingests OTLP/JSON. Set both the generic and per-signal protocol keys — some OTel SDK
23
+ // versions only read the generic one, and its default (http/protobuf) would be rejected.
24
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
25
+ OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "http/json",
26
+ OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json",
27
+ OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: `${serverUrl}/otlp/v1/metrics`,
28
+ OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${serverUrl}/otlp/v1/logs`,
29
+ // Flush quickly so usage shows up in seconds rather than the 60s/5s defaults.
30
+ OTEL_METRIC_EXPORT_INTERVAL: "10000",
31
+ OTEL_LOGS_EXPORT_INTERVAL: "5000"
32
+ };
33
+ if (authToken) {
34
+ settings.env.OTEL_EXPORTER_OTLP_HEADERS = `Authorization=Bearer ${authToken}`;
35
+ }
36
+ else {
37
+ delete settings.env.OTEL_EXPORTER_OTLP_HEADERS;
38
+ }
39
+ return settings;
40
+ }
41
+ // Install the Finius transcript-upload hook for each telemetry event. Any prior Finius hook group is
42
+ // dropped first so re-running setup never duplicates the entry; unrelated user hooks are preserved.
43
+ export function withFiniusHook(settings, command) {
44
+ const group = { hooks: [{ type: "command", command, timeout: HOOK_TIMEOUT_SECONDS }] };
45
+ const hooks = settings.hooks ?? (settings.hooks = {});
46
+ for (const event of TELEMETRY_HOOK_EVENTS) {
47
+ const kept = (hooks[event] ?? []).filter((g) => !isFiniusGroup(g));
48
+ hooks[event] = [...kept, group];
49
+ }
50
+ return settings;
51
+ }
52
+ function isFiniusGroup(group) {
53
+ return group.hooks?.some((h) => h.command?.includes(FINIUS_MARKER)) ?? false;
54
+ }
@@ -0,0 +1,60 @@
1
+ // Pure helpers for merging Finius config into OpenAI Codex's ~/.codex/config.toml. Like
2
+ // claude-settings.ts these are side-effect-free (no fs) so they unit-test cleanly; codex.ts does the
3
+ // read/write. Codex's config is TOML and we ship no TOML parser, so Finius owns a single clearly
4
+ // delimited block appended at EOF — valid TOML because it only opens fresh top-level tables. Re-running
5
+ // setup replaces that block in place; existing user tables are never touched, and we refuse to add a
6
+ // table the user already defines (so we can never produce a duplicate-key parse error).
7
+ export const CODEX_HOOK_EVENT = "Stop";
8
+ const BLOCK_BEGIN = "# >>> finius (managed — safe to delete this whole block) >>>";
9
+ const BLOCK_END = "# <<< finius (managed) <<<";
10
+ export function withFiniusCodexBlock(toml, opts) {
11
+ const outside = stripFiniusBlock(toml);
12
+ const hasUserHooks = /^\s*\[\[?\s*hooks(\.|\s*\])/m.test(outside);
13
+ const hasUserOtel = /^\s*\[\s*otel\s*\]/m.test(outside);
14
+ const wantHook = !!opts.hookCommand;
15
+ const wantOtel = !!opts.otlpLogsEndpoint;
16
+ const addedHook = wantHook && !hasUserHooks;
17
+ const addedOtel = wantOtel && !hasUserOtel;
18
+ const parts = [];
19
+ if (addedHook) {
20
+ parts.push(`[[hooks.${CODEX_HOOK_EVENT}]]`, `[[hooks.${CODEX_HOOK_EVENT}.hooks]]`, `type = "command"`, `command = ${tomlString(opts.hookCommand)}`, "");
21
+ }
22
+ if (addedOtel) {
23
+ const headers = opts.authToken
24
+ ? `, headers = { Authorization = ${tomlString(`Bearer ${opts.authToken}`)} }`
25
+ : "";
26
+ parts.push("[otel]", `environment = "finius"`, "log_user_prompt = false", `exporter = { otlp-http = { endpoint = ${tomlString(opts.otlpLogsEndpoint)}, protocol = "json"${headers} } }`, "");
27
+ }
28
+ const result = {
29
+ toml: outside,
30
+ changed: outside !== toml,
31
+ addedHook,
32
+ addedOtel,
33
+ skippedHook: wantHook && hasUserHooks,
34
+ skippedOtel: wantOtel && hasUserOtel
35
+ };
36
+ if (parts.length === 0)
37
+ return result;
38
+ const block = [BLOCK_BEGIN, ...parts, BLOCK_END].join("\n");
39
+ const base = outside.replace(/\s*$/, "");
40
+ result.toml = base.length ? `${base}\n\n${block}\n` : `${block}\n`;
41
+ result.changed = true;
42
+ return result;
43
+ }
44
+ // Remove a previously-written Finius block (between the markers), leaving the rest untouched.
45
+ export function stripFiniusBlock(toml) {
46
+ const begin = toml.indexOf(BLOCK_BEGIN);
47
+ if (begin === -1)
48
+ return toml;
49
+ const endIdx = toml.indexOf(BLOCK_END, begin);
50
+ const before = toml.slice(0, begin).replace(/\s*$/, "");
51
+ const after = endIdx === -1 ? "" : toml.slice(endIdx + BLOCK_END.length).replace(/^\s*\n/, "");
52
+ const joined = after ? `${before}\n${after}` : `${before}\n`;
53
+ return joined.replace(/\n{3,}/g, "\n\n");
54
+ }
55
+ export function hasFiniusCodexBlock(toml) {
56
+ return toml.includes(BLOCK_BEGIN);
57
+ }
58
+ function tomlString(value) {
59
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
60
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { withFiniusCodexBlock } from "./codex-config.js";
5
+ import { uploadTranscript, walkFiles } from "./backfill.js";
6
+ // Codex stores everything under ~/.codex (override with CODEX_HOME, as the app itself does).
7
+ export const CODEX_HOME = process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), ".codex");
8
+ export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
9
+ const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
10
+ export const CODEX_SOURCE = "codex-cli-jsonl";
11
+ // Codex is "installed" if its home dir or the macOS app bundle is present.
12
+ export function isCodexInstalled() {
13
+ return existsSync(CODEX_HOME) || existsSync("/Applications/Codex.app");
14
+ }
15
+ // Apply (or refresh) the Finius-managed block in ~/.codex/config.toml. Pure merge in codex-config.ts;
16
+ // this just does the read/write. Creates the file if missing.
17
+ export function applyCodexConfig(opts) {
18
+ const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf8") : "";
19
+ const result = withFiniusCodexBlock(current, opts);
20
+ if (result.changed) {
21
+ mkdirSync(dirname(CODEX_CONFIG_PATH), { recursive: true });
22
+ writeFileSync(CODEX_CONFIG_PATH, result.toml, "utf8");
23
+ }
24
+ return result;
25
+ }
26
+ // Every Codex rollout transcript on disk (~/.codex/sessions/**/rollout-*.jsonl).
27
+ export function findCodexRollouts() {
28
+ return walkFiles(CODEX_SESSIONS_DIR, (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"));
29
+ }
30
+ // `finius codex-hook` — invoked by Codex's Stop hook. Codex passes a JSON payload on stdin (shape not
31
+ // fully documented), so we read it best-effort but don't depend on it: we locate the session's rollout
32
+ // file ourselves (by session id if present, else the most-recently-modified rollout) and upload it.
33
+ // The server replaces the session's prior Codex points on every upload, so re-firing per turn is safe.
34
+ // Always resolves 0 — a hook must never block or fail the agent.
35
+ export async function runCodexHook() {
36
+ let payload = {};
37
+ try {
38
+ const raw = await readStdin();
39
+ if (raw.trim())
40
+ payload = JSON.parse(raw);
41
+ }
42
+ catch {
43
+ // unparseable stdin — fall back to "newest rollout"
44
+ }
45
+ const sessionId = firstString(payload, ["session_id", "sessionId", "conversation_id", "id", "thread_id"]);
46
+ const explicit = firstString(payload, ["rollout_path", "transcript_path", "path", "rollout"]);
47
+ const cwd = firstString(payload, ["cwd", "workdir", "working_directory"]);
48
+ const rollout = explicit && existsSync(explicit) ? explicit : findRollout(sessionId);
49
+ if (!rollout)
50
+ return 0;
51
+ // uploadTranscript resolves the Codex identity from config (git-fallback uses cwd) on its own.
52
+ await uploadTranscript(rollout, { source: CODEX_SOURCE, format: "codex", sessionId, cwd });
53
+ return 0;
54
+ }
55
+ // Newest rollout under ~/.codex/sessions/**, optionally restricted to those whose filename contains
56
+ // the given session id (Codex names files `rollout-<ts>-<sessionId>.jsonl`).
57
+ export function findRollout(sessionId) {
58
+ const all = findCodexRollouts();
59
+ if (!all.length)
60
+ return null;
61
+ const pool = sessionId ? all.filter((f) => f.includes(sessionId)) : all;
62
+ const candidates = pool.length ? pool : all;
63
+ let best = candidates[0];
64
+ let bestMtime = mtime(best);
65
+ for (const f of candidates.slice(1)) {
66
+ const m = mtime(f);
67
+ if (m > bestMtime) {
68
+ best = f;
69
+ bestMtime = m;
70
+ }
71
+ }
72
+ return best;
73
+ }
74
+ function mtime(path) {
75
+ try {
76
+ return statSync(path).mtimeMs;
77
+ }
78
+ catch {
79
+ return 0;
80
+ }
81
+ }
82
+ function firstString(obj, keys) {
83
+ for (const key of keys) {
84
+ const value = obj[key];
85
+ if (typeof value === "string" && value.length > 0)
86
+ return value;
87
+ }
88
+ return undefined;
89
+ }
90
+ async function readStdin() {
91
+ if (process.stdin.isTTY)
92
+ return "";
93
+ const chunks = [];
94
+ for await (const chunk of process.stdin)
95
+ chunks.push(chunk);
96
+ return Buffer.concat(chunks).toString("utf8");
97
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ // Everything the CLI owns lives under ~/.finius (override with FINIUS_HOME).
5
+ export const FINIUS_HOME = process.env.FINIUS_HOME ? resolve(process.env.FINIUS_HOME) : join(homedir(), ".finius");
6
+ export const CONFIG_PATH = join(FINIUS_HOME, "config.json");
7
+ export const DEFAULT_SERVER_URL = "http://localhost:8787";
8
+ export function configExists() {
9
+ return existsSync(CONFIG_PATH);
10
+ }
11
+ export function loadConfig() {
12
+ if (!existsSync(CONFIG_PATH))
13
+ return null;
14
+ try {
15
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
16
+ return {
17
+ ...raw,
18
+ serverUrl: normalizeUrl(raw.serverUrl) || DEFAULT_SERVER_URL
19
+ };
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ // This machine's credential for Secure Mode. Only minted session tokens are valid on protected
26
+ // endpoints; the master password is accepted solely by /api/auth/login.
27
+ export function resolveAuthToken(config) {
28
+ return config?.authToken ?? undefined;
29
+ }
30
+ export function saveConfig(config) {
31
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
32
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
33
+ }
34
+ // Resolution order: explicit env override → saved config → built-in default.
35
+ export function resolveServerUrl() {
36
+ return normalizeUrl(process.env.FINIUS_SERVER_URL) || loadConfig()?.serverUrl || DEFAULT_SERVER_URL;
37
+ }
38
+ // Trim whitespace and any trailing slashes so we can safely append paths.
39
+ export function normalizeUrl(value) {
40
+ return (value ?? "").trim().replace(/\/+$/, "");
41
+ }