tokentracker-cli 0.21.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +457 -0
- package/README.ko.md +457 -0
- package/README.md +45 -6
- package/README.zh-CN.md +45 -6
- package/dashboard/dist/assets/{Card-BlTjrLNe.js → Card-CD18G4Ge.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DKY_Mi9v.js +64 -0
- package/dashboard/dist/assets/DevicePage-BkavlAal.js +1 -0
- package/dashboard/dist/assets/{FadeIn-BPRZGKdg.js → FadeIn-CVNJ4aZy.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DUExMcbl.js → HeaderGithubStar-COu1Xy3I.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-PLJuh2m5.js → IpCheckPage-B2HjZ3vY.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-B85OvE31.js → LandingPage-9PSLFnys.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-dz85pWmv.js → LeaderboardPage-CQT5dBHU.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfilePage-BVntzReT.js → LeaderboardProfilePage-aPP-Raey.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-BSmYsOGT.js → LimitsPage-eFrAHmoA.js} +2 -2
- package/dashboard/dist/assets/{LoginPage-YxDKzTXr.js → LoginPage-CczTNZ_P.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-C_Cq5Cd8.js → PopoverPopup-CEvWSWgZ.js} +2 -2
- package/dashboard/dist/assets/{ProviderIcon-rOxGmW9Z.js → ProviderIcon-ewev19y3.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-taBxq6ux.js +1 -0
- package/dashboard/dist/assets/SkillsPage-cqHO3rMB.js +1 -0
- package/dashboard/dist/assets/{WidgetsPage-CHnlcaHs.js → WidgetsPage-B53b1hwG.js} +1 -1
- package/dashboard/dist/assets/WrappedPage-DwAhprTa.js +1 -0
- package/dashboard/dist/assets/check-JnFJsHgI.js +1 -0
- package/dashboard/dist/assets/{chevron-down-CrDKy3YX.js → chevron-down-zOKEzHdv.js} +1 -1
- package/dashboard/dist/assets/{download-oH8QYt7L.js → download-BZZ4vKc1.js} +1 -1
- package/dashboard/dist/assets/{leaderboard-columns-B3psEJVP.js → leaderboard-columns-BNGlMUsD.js} +1 -1
- package/dashboard/dist/assets/main-A_x5MMU-.css +1 -0
- package/dashboard/dist/assets/{main-DFkO2vMJ.js → main-D0Irg9xR.js} +62 -17
- package/dashboard/dist/assets/{use-limits-display-prefs-DyeDzQ-s.js → use-limits-display-prefs-BTuSZo27.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CxdbRzEd.js → use-native-settings-BPXVZrWe.js} +1 -1
- package/dashboard/dist/assets/{use-reduced-motion-BPcu3IT5.js → use-reduced-motion-HUOV_JD1.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CmEZ5jjP.js → use-usage-limits-G6-vCBcN.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +3 -2
- package/src/cli.js +11 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/status.js +199 -1
- package/src/commands/sync.js +85 -2
- package/src/commands/wrapped.js +150 -0
- package/src/lib/local-api.js +37 -2
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +913 -0
- package/src/lib/wrapped-aggregator.js +225 -0
- package/dashboard/dist/assets/DashboardPage-Dn3eiHhn.js +0 -1
- package/dashboard/dist/assets/SettingsPage-DzaUSufR.js +0 -1
- package/dashboard/dist/assets/SkillsPage-BoKJH6Il.js +0 -1
- package/dashboard/dist/assets/main-DX38hz5f.css +0 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
|
|
7
|
+
const { resolveTrackerPaths } = require("../lib/tracker-paths");
|
|
8
|
+
const { aggregateWrapped, formatCompact } = require("../lib/wrapped-aggregator");
|
|
9
|
+
|
|
10
|
+
async function readQueueRows(queuePath) {
|
|
11
|
+
if (!fs.existsSync(queuePath)) return [];
|
|
12
|
+
const raw = await fs.promises.readFile(queuePath, "utf8");
|
|
13
|
+
const rows = [];
|
|
14
|
+
for (const line of raw.split("\n")) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
try {
|
|
17
|
+
rows.push(JSON.parse(line));
|
|
18
|
+
} catch (_e) {
|
|
19
|
+
// skip malformed line — production queues sometimes hold a partial
|
|
20
|
+
// tail row mid-write that becomes valid on the next read.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return rows;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const out = { year: null, json: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === "--json") out.json = true;
|
|
31
|
+
else if (a === "--year") {
|
|
32
|
+
const v = argv[++i];
|
|
33
|
+
const y = Number(v);
|
|
34
|
+
if (!Number.isInteger(y) || y < 2000 || y > 2100) {
|
|
35
|
+
throw new Error(`--year expects a 4-digit year, got: ${v}`);
|
|
36
|
+
}
|
|
37
|
+
out.year = y;
|
|
38
|
+
} else throw new Error(`Unknown option: ${a}`);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderAscii(wrapped) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
const w = 60;
|
|
46
|
+
const border = "═".repeat(w - 2);
|
|
47
|
+
const center = (s) => {
|
|
48
|
+
const text = String(s);
|
|
49
|
+
const padTotal = Math.max(0, w - 2 - text.length);
|
|
50
|
+
const left = Math.floor(padTotal / 2);
|
|
51
|
+
const right = padTotal - left;
|
|
52
|
+
return `║${" ".repeat(left)}${text}${" ".repeat(right)}║`;
|
|
53
|
+
};
|
|
54
|
+
const row = (label, value) => {
|
|
55
|
+
const text = ` ${label}: ${value}`;
|
|
56
|
+
const right = Math.max(0, w - 2 - text.length);
|
|
57
|
+
return `║${text}${" ".repeat(right)}║`;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
lines.push(`╔${border}╗`);
|
|
61
|
+
lines.push(center(""));
|
|
62
|
+
lines.push(center(`TokenTracker Wrapped · ${wrapped.year}`));
|
|
63
|
+
lines.push(center(""));
|
|
64
|
+
lines.push(`╠${border}╣`);
|
|
65
|
+
lines.push(row("Total tokens", formatCompact(wrapped.totals.tokens)));
|
|
66
|
+
lines.push(row("Conversations", wrapped.totals.conversations.toLocaleString("en-US")));
|
|
67
|
+
lines.push(row("Active days", `${wrapped.totals.active_days} / 365`));
|
|
68
|
+
lines.push(row("Tools used", String(wrapped.totals.sources)));
|
|
69
|
+
lines.push(row("Models used", String(wrapped.totals.models)));
|
|
70
|
+
if (wrapped.peak_hour) {
|
|
71
|
+
lines.push(
|
|
72
|
+
row(
|
|
73
|
+
"Peak hour",
|
|
74
|
+
`${String(wrapped.peak_hour.hour).padStart(2, "0")}:00 UTC (${formatCompact(wrapped.peak_hour.tokens)})`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (wrapped.longest_streak.days > 0) {
|
|
79
|
+
lines.push(
|
|
80
|
+
row("Longest streak", `${wrapped.longest_streak.days} days (${wrapped.longest_streak.from} → ${wrapped.longest_streak.to})`),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
lines.push(`╠${border}╣`);
|
|
84
|
+
|
|
85
|
+
if (wrapped.top.sources.length > 0) {
|
|
86
|
+
lines.push(row("Top tools", ""));
|
|
87
|
+
for (const s of wrapped.top.sources.slice(0, 3)) {
|
|
88
|
+
lines.push(row(` ${s.source}`, `${formatCompact(s.tokens)} (${(s.share * 100).toFixed(0)}%)`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (wrapped.top.models.length > 0) {
|
|
92
|
+
lines.push(`╠${border}╣`);
|
|
93
|
+
lines.push(row("Top models", ""));
|
|
94
|
+
for (const m of wrapped.top.models.slice(0, 3)) {
|
|
95
|
+
lines.push(row(` ${m.model}`, `${formatCompact(m.tokens)} (${(m.share * 100).toFixed(0)}%)`));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (wrapped.top.days.length > 0) {
|
|
99
|
+
lines.push(`╠${border}╣`);
|
|
100
|
+
lines.push(row("Top days", ""));
|
|
101
|
+
for (const d of wrapped.top.days.slice(0, 3)) {
|
|
102
|
+
lines.push(row(` ${d.day}`, formatCompact(d.tokens)));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (wrapped.highlights.length > 0) {
|
|
106
|
+
lines.push(`╠${border}╣`);
|
|
107
|
+
const max = w - 4; // 2 borders + 2 padding
|
|
108
|
+
for (const h of wrapped.highlights) {
|
|
109
|
+
// Wrap long highlights into multiple rows.
|
|
110
|
+
for (let start = 0; start < h.length; start += max) {
|
|
111
|
+
const segment = h.slice(start, start + max);
|
|
112
|
+
const padded = ` ${segment}`;
|
|
113
|
+
const right = Math.max(0, w - 2 - padded.length);
|
|
114
|
+
lines.push(`║${padded}${" ".repeat(right)}║`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
lines.push(`╚${border}╝`);
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function cmdWrapped(argv = []) {
|
|
123
|
+
const opts = parseArgs(argv);
|
|
124
|
+
const home = os.homedir();
|
|
125
|
+
const { trackerDir } = await resolveTrackerPaths({ home });
|
|
126
|
+
const queuePath = path.join(trackerDir, "queue.jsonl");
|
|
127
|
+
|
|
128
|
+
const rows = await readQueueRows(queuePath);
|
|
129
|
+
if (rows.length === 0) {
|
|
130
|
+
if (opts.json) {
|
|
131
|
+
process.stdout.write(JSON.stringify({ error: "no data" }, null, 2) + "\n");
|
|
132
|
+
} else {
|
|
133
|
+
process.stdout.write(
|
|
134
|
+
"No queue data found yet. Run `tracker sync` first to ingest some history.\n",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const wrapped = aggregateWrapped(rows, opts.year ? { year: opts.year } : {});
|
|
141
|
+
|
|
142
|
+
if (opts.json) {
|
|
143
|
+
process.stdout.write(JSON.stringify(wrapped, null, 2) + "\n");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write(renderAscii(wrapped) + "\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { cmdWrapped };
|
package/src/lib/local-api.js
CHANGED
|
@@ -175,6 +175,12 @@ function aggregateByDay(rows, timeZoneContext = null) {
|
|
|
175
175
|
a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
176
176
|
a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
177
177
|
a.conversation_count += row.conversation_count || 0;
|
|
178
|
+
|
|
179
|
+
if (!a.models) {
|
|
180
|
+
a.models = {};
|
|
181
|
+
}
|
|
182
|
+
const model = row.model || "unknown";
|
|
183
|
+
a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
|
|
178
184
|
}
|
|
179
185
|
return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
|
|
180
186
|
}
|
|
@@ -999,6 +1005,17 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
999
1005
|
return true;
|
|
1000
1006
|
}
|
|
1001
1007
|
|
|
1008
|
+
// --- wrapped (year-end summary, à la Spotify Wrapped) ---
|
|
1009
|
+
if (p === "/functions/tokentracker-wrapped") {
|
|
1010
|
+
const yearParam = url.searchParams.get("year");
|
|
1011
|
+
const year = yearParam ? Number(yearParam) : null;
|
|
1012
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1013
|
+
const { aggregateWrapped } = require("./wrapped-aggregator");
|
|
1014
|
+
const summary = aggregateWrapped(rows, year ? { year } : {});
|
|
1015
|
+
json(res, { scope, excluded_sources: excludedSources, ...summary });
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1002
1019
|
// --- usage-summary ---
|
|
1003
1020
|
if (p === "/functions/tokentracker-usage-summary") {
|
|
1004
1021
|
const from = url.searchParams.get("from") || "";
|
|
@@ -1111,14 +1128,32 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1111
1128
|
const day = cursor.toISOString().slice(0, 10);
|
|
1112
1129
|
const data = byDay.get(day);
|
|
1113
1130
|
const billable = data?.billable_total_tokens || 0;
|
|
1114
|
-
cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable) });
|
|
1131
|
+
cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable), models: data?.models || null });
|
|
1115
1132
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
1116
1133
|
}
|
|
1117
1134
|
const weeksArr = [];
|
|
1118
1135
|
for (let i = 0; i < cells.length; i += 7) {
|
|
1119
1136
|
weeksArr.push(cells.slice(i, i + 7));
|
|
1120
1137
|
}
|
|
1121
|
-
|
|
1138
|
+
|
|
1139
|
+
let totalCostUsd = 0;
|
|
1140
|
+
for (const d of daily) {
|
|
1141
|
+
if (d.day >= from && d.day <= to) {
|
|
1142
|
+
totalCostUsd += d.total_cost_usd || 0;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
json(res, {
|
|
1147
|
+
from,
|
|
1148
|
+
to,
|
|
1149
|
+
scope,
|
|
1150
|
+
excluded_sources: excludedSources,
|
|
1151
|
+
week_starts_on: "sun",
|
|
1152
|
+
active_days: cells.filter((c) => c.billable_total_tokens > 0).length,
|
|
1153
|
+
streak_days: 0,
|
|
1154
|
+
weeks: weeksArr,
|
|
1155
|
+
total_cost_usd: totalCostUsd
|
|
1156
|
+
});
|
|
1122
1157
|
return true;
|
|
1123
1158
|
}
|
|
1124
1159
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive-mode detection.
|
|
3
|
+
*
|
|
4
|
+
* Token Tracker normally collects token usage by installing SessionEnd hooks
|
|
5
|
+
* into supported CLIs (Claude Code, Codex CLI, Gemini CLI, …). The hook
|
|
6
|
+
* fires `notify.cjs` after each turn → sync runs immediately → data is
|
|
7
|
+
* fresh within seconds.
|
|
8
|
+
*
|
|
9
|
+
* On environments where hook installation fails — WSL with read-only mount,
|
|
10
|
+
* UNC paths on Windows, locked `settings.json`, sandboxed processes —
|
|
11
|
+
* SessionEnd hooks never fire, but the underlying CLIs *still* write
|
|
12
|
+
* session logs (`~/.claude/projects/`, `~/.gemini/sessions/`, etc.). Our
|
|
13
|
+
* parsers can read those logs directly without the hook; the only loss is
|
|
14
|
+
* "data is fresh within seconds" → it's now "data is fresh after the next
|
|
15
|
+
* scheduled `tracker sync`".
|
|
16
|
+
*
|
|
17
|
+
* This module surfaces that fact: for each hook-driven provider, decide
|
|
18
|
+
* whether the local install is in **passive mode** (no hook + log dir
|
|
19
|
+
* present). `tracker status` reports it, and the dashboard can show a
|
|
20
|
+
* single banner asking the user whether to acknowledge & continue in
|
|
21
|
+
* passive mode or attempt re-install (`tracker init`).
|
|
22
|
+
*
|
|
23
|
+
* Detection is purely best-effort — we never *fail* sync because hooks are
|
|
24
|
+
* missing; we just label the source so users / AI agents know latency is
|
|
25
|
+
* minutes, not seconds.
|
|
26
|
+
*/
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const path = require("node:path");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} PassiveProvider
|
|
34
|
+
* @property {string} name - canonical provider name
|
|
35
|
+
* @property {boolean} hook_expected - whether this provider supports hooks
|
|
36
|
+
* @property {boolean} hook_installed - true when the hook is set
|
|
37
|
+
* @property {boolean} logs_present - true when the provider's log dir
|
|
38
|
+
* exists and contains at least one
|
|
39
|
+
* session file we can parse
|
|
40
|
+
* @property {boolean} passive - true iff hook_expected &&
|
|
41
|
+
* !hook_installed && logs_present
|
|
42
|
+
* @property {string|null} hook_failure_reason - if hook_expected and not
|
|
43
|
+
* installed, why we think it failed
|
|
44
|
+
* (e.g. "settings.json not writable",
|
|
45
|
+
* "config dir not found")
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
function dirHasFile(dir, predicate) {
|
|
49
|
+
if (!dir || !fs.existsSync(dir)) return false;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const full = path.join(dir, entry.name);
|
|
58
|
+
// Let the predicate accept either a file OR a directory name (so
|
|
59
|
+
// "sessions" or "tmp" as a bare subdirectory still counts).
|
|
60
|
+
if (!predicate || predicate(full, entry.name, entry.isDirectory())) return true;
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
// Shallow recurse — at most 1 level (covers projects/<name>/*.jsonl)
|
|
63
|
+
if (dirHasFile(full, predicate)) return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function classifyWritableFailure(filePath) {
|
|
70
|
+
if (!filePath) return "no path";
|
|
71
|
+
try {
|
|
72
|
+
fs.accessSync(filePath, fs.constants.W_OK);
|
|
73
|
+
return "writable (hook may have been removed externally)";
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e && e.code === "ENOENT") return "settings file missing";
|
|
76
|
+
if (e && e.code === "EACCES") return "permission denied";
|
|
77
|
+
if (e && e.code === "EROFS") return "read-only filesystem";
|
|
78
|
+
return e?.code || "unknown";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect passive mode for each hook-driven provider.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {string} opts.home - user home directory
|
|
87
|
+
* @param {Object} opts.hookStatus - per-provider hook-installed booleans
|
|
88
|
+
* (already collected by status.js); shape: { claude, gemini, codex,
|
|
89
|
+
* every_code, opencode, openclaw, codebuddy, grok }
|
|
90
|
+
* @returns {PassiveProvider[]}
|
|
91
|
+
*/
|
|
92
|
+
function detectPassiveProviders({ home, hookStatus }) {
|
|
93
|
+
const out = [];
|
|
94
|
+
|
|
95
|
+
// Claude Code — logs at ~/.claude/projects/<name>/*.jsonl
|
|
96
|
+
out.push(buildEntry({
|
|
97
|
+
name: "claude",
|
|
98
|
+
hookExpected: true,
|
|
99
|
+
hookInstalled: Boolean(hookStatus?.claude),
|
|
100
|
+
logsDir: path.join(home, ".claude", "projects"),
|
|
101
|
+
logsPredicate: (full) => full.endsWith(".jsonl"),
|
|
102
|
+
settingsPath: path.join(home, ".claude", "settings.json"),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// Gemini CLI — logs at ~/.gemini/tmp/<session-id>/logs.json
|
|
106
|
+
// (path varies by version; presence of ~/.gemini/sessions or
|
|
107
|
+
// ~/.gemini/tmp is enough to call it "active")
|
|
108
|
+
out.push(buildEntry({
|
|
109
|
+
name: "gemini",
|
|
110
|
+
hookExpected: true,
|
|
111
|
+
hookInstalled: Boolean(hookStatus?.gemini),
|
|
112
|
+
logsDir: path.join(home, ".gemini"),
|
|
113
|
+
logsPredicate: (_full, name, isDir) =>
|
|
114
|
+
(isDir && (name === "tmp" || name === "sessions")) || name === "logs.json",
|
|
115
|
+
settingsPath: path.join(home, ".gemini", "settings.json"),
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// Codex CLI — logs at ~/.codex/sessions/
|
|
119
|
+
// Match either a "sessions" subdir (even if empty — the CLI created it)
|
|
120
|
+
// or any *.jsonl session file.
|
|
121
|
+
out.push(buildEntry({
|
|
122
|
+
name: "codex",
|
|
123
|
+
hookExpected: true,
|
|
124
|
+
hookInstalled: Boolean(hookStatus?.codex_notify ?? hookStatus?.codex),
|
|
125
|
+
logsDir: path.join(home, ".codex"),
|
|
126
|
+
logsPredicate: (_full, name, isDir) =>
|
|
127
|
+
(isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
|
|
128
|
+
settingsPath: path.join(home, ".codex", "config.toml"),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// Every Code — same shape as Codex, different home
|
|
132
|
+
out.push(buildEntry({
|
|
133
|
+
name: "every_code",
|
|
134
|
+
hookExpected: true,
|
|
135
|
+
hookInstalled: Boolean(hookStatus?.every_code_notify ?? hookStatus?.every_code),
|
|
136
|
+
logsDir: path.join(home, ".code"),
|
|
137
|
+
logsPredicate: (_full, name, isDir) =>
|
|
138
|
+
(isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
|
|
139
|
+
settingsPath: path.join(home, ".code", "config.toml"),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// CodeBuddy — Claude-fork; hook in ~/.codebuddy/settings.json
|
|
143
|
+
out.push(buildEntry({
|
|
144
|
+
name: "codebuddy",
|
|
145
|
+
hookExpected: true,
|
|
146
|
+
hookInstalled: Boolean(hookStatus?.codebuddy),
|
|
147
|
+
logsDir: path.join(home, ".codebuddy"),
|
|
148
|
+
logsPredicate: (_full, name) => name === "projects" || name.endsWith(".jsonl"),
|
|
149
|
+
settingsPath: path.join(home, ".codebuddy", "settings.json"),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildEntry({ name, hookExpected, hookInstalled, logsDir, logsPredicate, settingsPath }) {
|
|
156
|
+
const logsPresent = dirHasFile(logsDir, logsPredicate);
|
|
157
|
+
const passive = hookExpected && !hookInstalled && logsPresent;
|
|
158
|
+
let reason = null;
|
|
159
|
+
if (hookExpected && !hookInstalled) {
|
|
160
|
+
reason = classifyWritableFailure(settingsPath);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
name,
|
|
164
|
+
hook_expected: hookExpected,
|
|
165
|
+
hook_installed: hookInstalled,
|
|
166
|
+
logs_present: logsPresent,
|
|
167
|
+
passive,
|
|
168
|
+
hook_failure_reason: reason,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convenience boolean: is at least one provider in passive mode?
|
|
174
|
+
*/
|
|
175
|
+
function isPassiveModeActive(providers) {
|
|
176
|
+
return providers.some((p) => p.passive);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
detectPassiveProviders,
|
|
181
|
+
isPassiveModeActive,
|
|
182
|
+
// Exposed for unit testing
|
|
183
|
+
dirHasFile,
|
|
184
|
+
classifyWritableFailure,
|
|
185
|
+
};
|