ccc-notifier 0.4.0 → 0.6.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.
@@ -4,30 +4,31 @@ import {
4
4
  notifySlack
5
5
  } from "./chunk-NV5UOHJA.js";
6
6
  import {
7
- writeDashboardHtml
8
- } from "./chunk-2VPBBIDW.js";
9
- import "./chunk-DGXUSPS4.js";
10
- import {
11
- aggregateCodexTurn,
12
7
  collectSubagentUsage
13
- } from "./chunk-CKVK3UCA.js";
8
+ } from "./chunk-OYD6H3ZZ.js";
14
9
  import {
10
+ aggregateCodexTurn,
15
11
  aggregateNewTurn,
12
+ getUsdJpy
13
+ } from "./chunk-D4D76RGQ.js";
14
+ import {
16
15
  computeCost,
17
- getUsdJpy,
18
16
  loadPriceTable
19
- } from "./chunk-LHKBGA5K.js";
17
+ } from "./chunk-6HTETN26.js";
18
+ import {
19
+ writeDashboardHtml
20
+ } from "./chunk-4JSRGJCZ.js";
21
+ import "./chunk-DGXUSPS4.js";
22
+ import "./chunk-J5QAYTFE.js";
20
23
  import {
21
24
  isFullDashboardDue,
22
25
  makeFullDashboardState,
26
+ waitForDataLock,
23
27
  writeFullDashboardStateAtomic
24
- } from "./chunk-ZHIZZ6V5.js";
25
- import {
26
- waitForDataLock
27
- } from "./chunk-J6Y3RMMC.js";
28
- import "./chunk-J5QAYTFE.js";
28
+ } from "./chunk-27SJELD2.js";
29
29
  import {
30
30
  appendTurn,
31
+ closeCodexRootContext,
31
32
  isMuted,
32
33
  loadCursor,
33
34
  logError,
@@ -37,7 +38,7 @@ import {
37
38
  sanitizeCursor,
38
39
  saveCursor,
39
40
  todayTotalUSD
40
- } from "./chunk-26CISNOE.js";
41
+ } from "./chunk-OOAC5ULQ.js";
41
42
 
42
43
  // src/track.ts
43
44
  function isRecord(v) {
@@ -83,14 +84,23 @@ async function runTrack(stdinText, opts) {
83
84
  }
84
85
  if (!isRecord(parsed)) return;
85
86
  const input = parsed;
87
+ const isCodex = opts?.codex === true;
88
+ let activityProjectionKey = null;
89
+ if (isCodex) {
90
+ try {
91
+ activityProjectionKey = closeCodexRootContext(parsed);
92
+ } catch {
93
+ logError("track:codex-subagent-projection", new Error("activity projection was not attached"));
94
+ }
95
+ }
86
96
  const transcriptPath = input.transcript_path;
87
97
  if (typeof transcriptPath !== "string") return;
88
98
  const cfg = readConfig();
89
99
  const cacheDir = paths().cacheDir;
90
100
  const table = await loadPriceTable(cacheDir, { offline: true });
91
101
  const fx = await getUsdJpy(cfg, cacheDir);
92
- const isCodex = opts?.codex === true;
93
102
  let record;
103
+ let hasMainUsage = false;
94
104
  const commitLock = await waitForDataLock(1e3);
95
105
  if (commitLock === null) {
96
106
  logError("track:data-lock", new Error("data lock timeout; turn was not consumed"));
@@ -99,33 +109,44 @@ async function runTrack(stdinText, opts) {
99
109
  try {
100
110
  const cursor = sanitizeCursor(loadCursor(transcriptPath));
101
111
  let agg = isCodex ? await aggregateCodexTurn(transcriptPath, cursor) : await aggregateNewTurn(transcriptPath, cursor);
102
- if (agg === null) return;
103
- if (isCodex) {
112
+ if (isCodex && agg === null) return;
113
+ hasMainUsage = agg !== null;
114
+ if (isCodex && agg !== null) {
104
115
  agg = withCodexModel(agg, input.model);
105
116
  }
106
117
  let sa = null;
107
118
  if (!isCodex) {
108
119
  try {
109
- sa = await collectSubagentUsage(transcriptPath);
120
+ const excluded = new Set(cursor?.seenMessageKeys ?? []);
121
+ if (agg !== null && "messageKeys" in agg) {
122
+ for (const key of agg.messageKeys) excluded.add(key);
123
+ }
124
+ sa = await collectSubagentUsage(transcriptPath, { excludeMessageKeys: excluded });
110
125
  } catch (err) {
111
126
  logError("track:subagents", err);
112
127
  sa = null;
113
128
  }
114
129
  }
115
- const breakdown = computeCost(agg.main, agg.sidechain, table);
116
- const sessionId = agg.sessionId || (typeof input.session_id === "string" ? input.session_id : "") || "";
117
- const project = agg.cwd ?? (typeof input.cwd === "string" ? input.cwd : void 0) ?? "";
118
- const sidechainHasModels = Object.keys(agg.sidechain).length > 0;
130
+ if (agg === null && (sa === null || sa.apiCalls === 0)) {
131
+ for (const nc of sa?.newCursors ?? []) saveCursor(nc.path, nc.cursor);
132
+ return;
133
+ }
134
+ const main = agg?.main ?? {};
135
+ const sidechain = agg?.sidechain ?? {};
136
+ const breakdown = computeCost(main, sidechain, table);
137
+ const sessionId = agg?.sessionId || sa?.sessionId || (typeof input.session_id === "string" ? input.session_id : "") || "";
138
+ const project = agg?.cwd ?? sa?.cwd ?? (typeof input.cwd === "string" ? input.cwd : void 0) ?? "";
139
+ const sidechainHasModels = Object.keys(sidechain).length > 0;
119
140
  record = {
120
141
  schemaVersion: 1,
121
- ts: agg.lastTs ?? (/* @__PURE__ */ new Date()).toISOString(),
142
+ ts: agg?.lastTs ?? sa?.lastTs ?? (/* @__PURE__ */ new Date()).toISOString(),
122
143
  sessionId,
123
144
  project,
124
- gitBranch: agg.gitBranch,
125
- models: collectModels(agg.main, agg.sidechain),
126
- tokens: sumBuckets(agg.main),
127
- sidechainTokens: sidechainHasModels ? sumBuckets(agg.sidechain) : null,
128
- apiCalls: agg.apiCalls,
145
+ gitBranch: agg?.gitBranch ?? sa?.gitBranch ?? null,
146
+ models: hasMainUsage ? collectModels(main, sidechain) : collectModels(sa?.perModel ?? {}, {}),
147
+ tokens: sumBuckets(main),
148
+ sidechainTokens: sidechainHasModels ? sumBuckets(sidechain) : null,
149
+ apiCalls: agg?.apiCalls ?? 0,
129
150
  costUSD: breakdown.usd,
130
151
  costByModel: breakdown.byModel,
131
152
  // モデル別 USD(main+sidechain 合算、丸めない)
@@ -133,10 +154,11 @@ async function runTrack(stdinText, opts) {
133
154
  // 丸めない(表示時に丸める)
134
155
  fxRate: fx.rate,
135
156
  fxSource: fx.source,
136
- prompt: agg.prompt ?? ""
157
+ prompt: agg?.prompt ?? ""
137
158
  };
138
159
  if (isCodex) {
139
160
  record.source = "codex";
161
+ if (activityProjectionKey !== null) record.activityProjectionKey = activityProjectionKey;
140
162
  }
141
163
  if (breakdown.unknownModels.length > 0) {
142
164
  record.unknownModels = breakdown.unknownModels;
@@ -159,7 +181,7 @@ async function runTrack(stdinText, opts) {
159
181
  }
160
182
  }
161
183
  appendTurn(record);
162
- saveCursor(transcriptPath, agg.newCursor);
184
+ if (agg !== null) saveCursor(transcriptPath, agg.newCursor);
163
185
  if (sa !== null) {
164
186
  for (const nc of sa.newCursors) {
165
187
  saveCursor(nc.path, nc.cursor);
@@ -169,7 +191,7 @@ async function runTrack(stdinText, opts) {
169
191
  commitLock.release();
170
192
  }
171
193
  const tasks = [];
172
- if ((cfg.notify.os || cfg.notify.slack !== null) && record.costUSD >= cfg.minNotifyUSD && !isMuted()) {
194
+ if (hasMainUsage && (cfg.notify.os || cfg.notify.slack !== null) && record.costUSD >= cfg.minNotifyUSD && !isMuted()) {
173
195
  const todayUSD = cfg.includeDailyTotal ? todayTotalUSD() : void 0;
174
196
  tasks.push(notifyOS(record, cfg, todayUSD));
175
197
  tasks.push(notifySlack(record, cfg, todayUSD));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccc-notifier",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code Cost notifier (now also covers Codex CLI): per-prompt cost notifications (USD/JPY) with local history and HTML dashboard",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,324 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/store.ts
4
- import {
5
- existsSync,
6
- mkdirSync,
7
- readFileSync,
8
- writeFileSync,
9
- appendFileSync,
10
- renameSync,
11
- rmSync,
12
- statSync
13
- } from "fs";
14
- import { join } from "path";
15
- import { homedir } from "os";
16
-
17
- // src/types.ts
18
- var DEFAULT_CONFIG = {
19
- notify: { os: true, slack: null },
20
- minNotifyUSD: 0,
21
- costLabel: "api_equivalent",
22
- fx: { fallbackRate: 150, cacheHours: 12 },
23
- includeDailyTotal: true,
24
- monthlyBudgetUSD: 0,
25
- dashboard: { autoRegenerate: true, autoReloadSec: 30, days: 30 }
26
- };
27
-
28
- // src/store.ts
29
- var ERROR_LOG_MAX_BYTES = 1024 * 1024;
30
- function paths() {
31
- const home = process.env.CCCN_HOME || join(homedir(), ".ccc-notifier");
32
- const cacheDir = join(home, "cache");
33
- mkdirSync(home, { recursive: true });
34
- mkdirSync(cacheDir, { recursive: true });
35
- return {
36
- home,
37
- configFile: join(home, "config.json"),
38
- historyFile: join(home, "history.jsonl"),
39
- cursorsFile: join(home, "cursors.json"),
40
- cacheDir,
41
- errorLog: join(home, "error.log"),
42
- lastNotifyFile: join(home, "last-notify.json"),
43
- muteFile: join(home, "muted.json"),
44
- recentDashboardFile: join(home, "report.html"),
45
- fullDashboardFile: join(home, "report-all.html"),
46
- dashboardFullStateFile: join(cacheDir, "dashboard-full-state.json"),
47
- dataLockDir: join(cacheDir, "data.lock"),
48
- dataReclaimDir: join(cacheDir, "data.lock.reclaim")
49
- };
50
- }
51
- function isPlainObject(v) {
52
- return typeof v === "object" && v !== null && !Array.isArray(v);
53
- }
54
- function mergeConfig(partial) {
55
- const result = structuredClone(DEFAULT_CONFIG);
56
- if (!isPlainObject(partial)) return result;
57
- if (isPlainObject(partial.notify)) {
58
- if ("os" in partial.notify) {
59
- result.notify.os = partial.notify.os;
60
- }
61
- if ("slack" in partial.notify) {
62
- result.notify.slack = partial.notify.slack;
63
- }
64
- }
65
- if ("minNotifyUSD" in partial) {
66
- result.minNotifyUSD = partial.minNotifyUSD;
67
- }
68
- if ("costLabel" in partial) {
69
- result.costLabel = partial.costLabel;
70
- }
71
- if (isPlainObject(partial.fx)) {
72
- if ("fallbackRate" in partial.fx) {
73
- result.fx.fallbackRate = partial.fx.fallbackRate;
74
- }
75
- if ("cacheHours" in partial.fx) {
76
- result.fx.cacheHours = partial.fx.cacheHours;
77
- }
78
- }
79
- if ("includeDailyTotal" in partial) {
80
- result.includeDailyTotal = partial.includeDailyTotal;
81
- }
82
- if ("monthlyBudgetUSD" in partial) {
83
- const b = partial.monthlyBudgetUSD;
84
- if (typeof b === "number" && Number.isFinite(b) && b >= 0) {
85
- result.monthlyBudgetUSD = b;
86
- }
87
- }
88
- if (isPlainObject(partial.dashboard)) {
89
- if ("autoRegenerate" in partial.dashboard) {
90
- result.dashboard.autoRegenerate = partial.dashboard.autoRegenerate;
91
- }
92
- if ("autoReloadSec" in partial.dashboard) {
93
- result.dashboard.autoReloadSec = partial.dashboard.autoReloadSec;
94
- }
95
- if ("days" in partial.dashboard) {
96
- const days = partial.dashboard.days;
97
- if (typeof days === "number" && Number.isFinite(days) && Number.isInteger(days) && days > 0) {
98
- result.dashboard.days = days;
99
- }
100
- }
101
- }
102
- return result;
103
- }
104
- function readConfig() {
105
- const p = paths();
106
- if (!existsSync(p.configFile)) {
107
- return structuredClone(DEFAULT_CONFIG);
108
- }
109
- let raw;
110
- try {
111
- raw = readFileSync(p.configFile, "utf8");
112
- } catch (err) {
113
- logError("readConfig", err);
114
- return structuredClone(DEFAULT_CONFIG);
115
- }
116
- let parsed;
117
- try {
118
- parsed = JSON.parse(raw);
119
- } catch (err) {
120
- logError("readConfig", err);
121
- return structuredClone(DEFAULT_CONFIG);
122
- }
123
- return mergeConfig(parsed);
124
- }
125
- function readMuteState() {
126
- const p = paths();
127
- if (!existsSync(p.muteFile)) return null;
128
- try {
129
- const parsed = JSON.parse(readFileSync(p.muteFile, "utf8"));
130
- if (!isPlainObject(parsed) || !("until" in parsed)) return null;
131
- const until = parsed.until;
132
- if (until === null) return { until: null };
133
- if (typeof until === "string" && !Number.isNaN(new Date(until).getTime())) {
134
- return { until };
135
- }
136
- return null;
137
- } catch (err) {
138
- logError("readMuteState", err);
139
- return null;
140
- }
141
- }
142
- function isMuted(now = /* @__PURE__ */ new Date()) {
143
- const state = readMuteState();
144
- if (state === null) return false;
145
- if (state.until === null) return true;
146
- return new Date(state.until).getTime() > now.getTime();
147
- }
148
- function writeMuteState(state) {
149
- writeFileSync(paths().muteFile, `${JSON.stringify(state)}
150
- `, "utf8");
151
- }
152
- function clearMuteState() {
153
- rmSync(paths().muteFile, { force: true });
154
- }
155
- function loadCursor(transcriptPath) {
156
- const p = paths();
157
- if (!existsSync(p.cursorsFile)) return null;
158
- let raw;
159
- try {
160
- raw = readFileSync(p.cursorsFile, "utf8");
161
- } catch (err) {
162
- logError("loadCursor", err);
163
- return null;
164
- }
165
- let parsed;
166
- try {
167
- parsed = JSON.parse(raw);
168
- } catch (err) {
169
- logError("loadCursor", err);
170
- return null;
171
- }
172
- if (!isPlainObject(parsed)) {
173
- logError("loadCursor", new Error("cursors.json root is not an object"));
174
- return null;
175
- }
176
- const cursor = parsed[transcriptPath];
177
- return cursor ?? null;
178
- }
179
- function sanitizeCursor(raw) {
180
- if (!isPlainObject(raw)) return null;
181
- const { offset, lastUuid, lastTs, seenMessageKeys, codexTotals } = raw;
182
- if (typeof offset !== "number" || !Number.isFinite(offset)) return null;
183
- if (lastUuid !== null && typeof lastUuid !== "string") return null;
184
- if (lastTs !== null && typeof lastTs !== "string") return null;
185
- if (!Array.isArray(seenMessageKeys)) return null;
186
- const keys = [];
187
- for (const key of seenMessageKeys) {
188
- if (typeof key !== "string") return null;
189
- keys.push(key);
190
- }
191
- const cursor = { offset, lastUuid, lastTs, seenMessageKeys: keys };
192
- if (isPlainObject(codexTotals)) {
193
- const { input, cached, output } = codexTotals;
194
- if (typeof input === "number" && Number.isFinite(input) && input >= 0 && typeof cached === "number" && Number.isFinite(cached) && cached >= 0 && typeof output === "number" && Number.isFinite(output) && output >= 0) {
195
- cursor.codexTotals = { input, cached, output };
196
- }
197
- }
198
- return cursor;
199
- }
200
- function saveCursor(transcriptPath, c) {
201
- const p = paths();
202
- let dict = {};
203
- if (existsSync(p.cursorsFile)) {
204
- try {
205
- const raw = readFileSync(p.cursorsFile, "utf8");
206
- const parsed = JSON.parse(raw);
207
- if (isPlainObject(parsed)) {
208
- dict = parsed;
209
- }
210
- } catch (err) {
211
- logError("saveCursor", err);
212
- }
213
- }
214
- dict[transcriptPath] = c;
215
- const tmpFile = `${p.cursorsFile}.tmp`;
216
- writeFileSync(tmpFile, JSON.stringify(dict), "utf8");
217
- renameSync(tmpFile, p.cursorsFile);
218
- }
219
- function appendTurn(record) {
220
- const p = paths();
221
- appendFileSync(p.historyFile, JSON.stringify(record) + "\n", "utf8");
222
- }
223
- function readTurns(days) {
224
- const p = paths();
225
- if (!existsSync(p.historyFile)) return [];
226
- let raw;
227
- try {
228
- raw = readFileSync(p.historyFile, "utf8");
229
- } catch {
230
- return [];
231
- }
232
- const cutoff = typeof days === "number" ? Date.now() - days * 864e5 : null;
233
- const result = [];
234
- for (const line of raw.split("\n")) {
235
- const trimmed = line.trim();
236
- if (!trimmed) continue;
237
- let rec;
238
- try {
239
- rec = JSON.parse(trimmed);
240
- } catch {
241
- continue;
242
- }
243
- if (cutoff !== null) {
244
- const ts = Date.parse(rec.ts);
245
- if (!Number.isFinite(ts) || ts < cutoff) continue;
246
- }
247
- result.push(rec);
248
- }
249
- return result;
250
- }
251
- function todayTotalUSD() {
252
- const now = /* @__PURE__ */ new Date();
253
- const y = now.getFullYear();
254
- const m = now.getMonth();
255
- const d = now.getDate();
256
- let total = 0;
257
- for (const rec of readTurns()) {
258
- const ts = new Date(rec.ts);
259
- if (Number.isNaN(ts.getTime())) continue;
260
- if (ts.getFullYear() === y && ts.getMonth() === m && ts.getDate() === d) {
261
- total += rec.costUSD;
262
- }
263
- }
264
- return total;
265
- }
266
- function currentMonthTotals() {
267
- const now = /* @__PURE__ */ new Date();
268
- const y = now.getFullYear();
269
- const m = now.getMonth();
270
- let usd = 0;
271
- let jpy = 0;
272
- let turns = 0;
273
- for (const rec of readTurns()) {
274
- const ts = new Date(rec.ts);
275
- if (Number.isNaN(ts.getTime())) continue;
276
- if (ts.getFullYear() === y && ts.getMonth() === m) {
277
- const sa = rec.subagents?.costUSD ?? 0;
278
- usd += rec.costUSD + sa;
279
- jpy += rec.costJPY + sa * rec.fxRate;
280
- turns += 1;
281
- }
282
- }
283
- return { usd, jpy, turns };
284
- }
285
- function logError(context, err) {
286
- try {
287
- const p = paths();
288
- const iso = (/* @__PURE__ */ new Date()).toISOString();
289
- const message = err instanceof Error ? err.message : String(err);
290
- const stack = err instanceof Error ? err.stack : void 0;
291
- let entry = `[${iso}] [${context}] ${message}
292
- `;
293
- if (stack) {
294
- entry += `${stack}
295
- `;
296
- }
297
- try {
298
- const stat = statSync(p.errorLog);
299
- if (stat.size > ERROR_LOG_MAX_BYTES) {
300
- renameSync(p.errorLog, `${p.errorLog}.old`);
301
- }
302
- } catch {
303
- }
304
- appendFileSync(p.errorLog, entry, "utf8");
305
- } catch {
306
- }
307
- }
308
-
309
- export {
310
- paths,
311
- readConfig,
312
- readMuteState,
313
- isMuted,
314
- writeMuteState,
315
- clearMuteState,
316
- loadCursor,
317
- sanitizeCursor,
318
- saveCursor,
319
- appendTurn,
320
- readTurns,
321
- todayTotalUSD,
322
- currentMonthTotals,
323
- logError
324
- };