carmoji 0.1.0 → 0.2.1

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 (3) hide show
  1. package/README.md +24 -7
  2. package/carmoji.js +152 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,22 +39,30 @@ big puppy eyes when the agent is waiting for your approval.
39
39
 
40
40
  Coding in *this* repo works out of the box — the hooks ship in
41
41
  [.claude/settings.json](../.claude/settings.json). To get reactions in
42
- every project, print the snippet and merge it into `~/.claude/settings.json`:
42
+ every project:
43
43
 
44
44
  ```sh
45
- npx carmoji claude-config
45
+ npx carmoji install-claude
46
46
  ```
47
47
 
48
+ merges the hooks into `~/.claude/settings.json`, preserving everything
49
+ already there (a backup is saved to `settings.json.bak` first, reruns are
50
+ no-ops, and a file that doesn't parse is refused untouched). Prefer to do
51
+ it by hand? `npx carmoji claude-config` prints the snippet instead.
52
+
48
53
  ## Codex
49
54
 
50
55
  ```sh
51
- npx carmoji codex-config
56
+ npx carmoji install-codex
52
57
  ```
53
58
 
54
- prints the `notify = [...]` line for `~/.codex/config.toml`. Codex's notify
55
- surface only reports completed turns, so the pet celebrates finished work
56
- but doesn't see individual edits (tail `~/.codex/sessions/` is a possible
57
- future upgrade see [docs/ROADMAP.md](../docs/ROADMAP.md)).
59
+ sets the `notify` program in `~/.codex/config.toml` (backup to
60
+ `config.toml.bak`; an existing notify line is replaced with a printed
61
+ warning). `npx carmoji codex-config` prints the line if you'd rather paste
62
+ it yourself. Codex's notify surface only reports completed turns, so the
63
+ pet celebrates finished work but doesn't see individual edits (tail
64
+ `~/.codex/sessions/` is a possible future upgrade — see
65
+ [docs/ROADMAP.md](../docs/ROADMAP.md)).
58
66
 
59
67
  ## Try it without a coding agent
60
68
 
@@ -91,6 +99,15 @@ dot while agents are live, and GPS pauses to save battery. Prompt snippets
91
99
  and permission messages (first ~140 chars) are sent to the phone as brief
92
100
  toasts; they stay on your LAN and are only accepted by the paired device.
93
101
 
102
+ ## Plan-window usage
103
+
104
+ The buddy dashboard's corner shows your rolling **5-hour and 7-day token
105
+ totals** ("5H 2.5M / 7D 13.7M"), summed from the local Claude Code
106
+ transcripts the same way community usage tools do (input + output +
107
+ cache-write tokens; cache reads excluded). It's your real consumption, not
108
+ an official percentage of plan quota — there's no public API for that.
109
+ Recomputed at turn boundaries with a 5-minute cache so hooks stay fast.
110
+
94
111
  ## Answer permissions from the phone (Claude Code, opt-in)
95
112
 
96
113
  ```sh
package/carmoji.js CHANGED
@@ -8,13 +8,14 @@
8
8
  // pair <code> discover the phone on this Wi-Fi and pair with it
9
9
  // hook Claude Code hook entry point (reads hook JSON on stdin)
10
10
  // codex-notify Codex notify entry point (event JSON as last argument)
11
- // test <name> send a test event: start|prompt|edit|run|done|error|permission|end
11
+ // test <name> send a test event: start|prompt|edit|read|run|heartbeat|done|error|permission|end
12
12
  // claude-config print the .claude/settings.json hooks snippet
13
13
  // codex-config print the ~/.codex/config.toml notify line
14
14
 
15
15
  import {
16
16
  readFileSync, writeFileSync, mkdirSync,
17
17
  statSync, openSync, readSync, closeSync,
18
+ copyFileSync, existsSync, readdirSync,
18
19
  } from 'node:fs';
19
20
  import { spawn } from 'node:child_process';
20
21
  import { homedir } from 'node:os';
@@ -222,6 +223,65 @@ function summarizeToolInput(payload) {
222
223
  return String(text).replace(/\s+/g, ' ').slice(0, 140);
223
224
  }
224
225
 
226
+ /**
227
+ * Rolling 5-hour / 7-day token totals summed from the local Claude Code
228
+ * transcripts — the same data community usage tools read. Counts input,
229
+ * output, and cache-write tokens (cache reads are nearly free and would
230
+ * drown the numbers). Cached for 5 minutes because a full scan reads a
231
+ * week of JSONL; only turn boundaries may recompute — per-tool hooks just
232
+ * reuse the cache so they stay fast.
233
+ */
234
+ function claudeUsage(allowRecompute) {
235
+ const cachePath = join(dirname(CONFIG_PATH), 'usage-cache.json');
236
+ try {
237
+ const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
238
+ if (!allowRecompute || Date.now() - cached.computedAt < 300_000) {
239
+ return cached.usage;
240
+ }
241
+ } catch {
242
+ if (!allowRecompute) return undefined;
243
+ }
244
+ const now = Date.now();
245
+ const h5cut = now - 5 * 3600e3;
246
+ const d7cut = now - 7 * 86400e3;
247
+ let h5 = 0;
248
+ let d7 = 0;
249
+ const stack = [join(homedir(), '.claude', 'projects')];
250
+ while (stack.length) {
251
+ const dir = stack.pop();
252
+ let entries;
253
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
254
+ for (const entry of entries) {
255
+ const path = join(dir, entry.name);
256
+ if (entry.isDirectory()) { stack.push(path); continue; }
257
+ if (!entry.name.endsWith('.jsonl')) continue;
258
+ try {
259
+ if (statSync(path).mtimeMs < d7cut) continue;
260
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
261
+ if (!line.includes('"usage"')) continue;
262
+ try {
263
+ const record = JSON.parse(line);
264
+ const usage = record.message?.usage;
265
+ if (!usage) continue;
266
+ const ts = Date.parse(record.timestamp);
267
+ if (!(ts >= d7cut)) continue;
268
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0)
269
+ + (usage.cache_creation_input_tokens || 0);
270
+ d7 += tokens;
271
+ if (ts >= h5cut) h5 += tokens;
272
+ } catch { /* skip malformed lines */ }
273
+ }
274
+ } catch { /* unreadable file */ }
275
+ }
276
+ }
277
+ const usage = { h5, d7 };
278
+ try {
279
+ mkdirSync(dirname(cachePath), { recursive: true });
280
+ writeFileSync(cachePath, JSON.stringify({ computedAt: now, usage }));
281
+ } catch { /* cache is best-effort */ }
282
+ return usage;
283
+ }
284
+
225
285
  /** Map a Claude Code hook payload to a wire event, or null to ignore. */
226
286
  function eventFromClaudeHook(payload) {
227
287
  switch (payload.hook_event_name) {
@@ -282,6 +342,7 @@ const TEST_EVENTS = {
282
342
  start: { event: 'session_start' },
283
343
  prompt: { event: 'prompt' },
284
344
  edit: { event: 'tool_start', tool: 'Edit' },
345
+ read: { event: 'tool_start', tool: 'Read' },
285
346
  run: { event: 'tool_start', tool: 'Bash' },
286
347
  heartbeat: { event: 'tool_end', tool: 'Edit' },
287
348
  done: { event: 'turn_done' },
@@ -290,6 +351,80 @@ const TEST_EVENTS = {
290
351
  end: { event: 'session_end' },
291
352
  };
292
353
 
354
+ /** Copy `path` to `path.bak` before modifying it; returns the backup path. */
355
+ function backupFile(path) {
356
+ const bak = `${path}.bak`;
357
+ copyFileSync(path, bak);
358
+ return bak;
359
+ }
360
+
361
+ const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
362
+ 'PostToolUse', 'Notification', 'Stop', 'SessionEnd'];
363
+
364
+ /** Merge the CarMoji hooks into ~/.claude/settings.json, keeping a backup. */
365
+ function installClaude() {
366
+ const path = join(homedir(), '.claude', 'settings.json');
367
+ let settings = {};
368
+ if (existsSync(path)) {
369
+ try {
370
+ settings = JSON.parse(readFileSync(path, 'utf8'));
371
+ } catch {
372
+ console.error(`${path} is not valid JSON — fix it first (nothing was changed).`);
373
+ process.exit(1);
374
+ }
375
+ }
376
+ if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {};
377
+
378
+ let added = 0;
379
+ for (const name of CLAUDE_HOOK_EVENTS) {
380
+ const entries = Array.isArray(settings.hooks[name]) ? settings.hooks[name]
381
+ : (settings.hooks[name] = []);
382
+ if (JSON.stringify(entries).includes('carmoji')) continue; // already ours
383
+ entries.push({ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }] });
384
+ added++;
385
+ }
386
+ if (added === 0) {
387
+ console.log(`CarMoji hooks already installed in ${path}`);
388
+ return;
389
+ }
390
+ const bak = existsSync(path) ? backupFile(path) : null;
391
+ mkdirSync(dirname(path), { recursive: true });
392
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
393
+ console.log(`Added CarMoji hooks to ${added} event(s) in ${path}`);
394
+ if (bak) console.log(`Backup saved to ${bak}`);
395
+ console.log('New Claude Code sessions will pick them up.');
396
+ }
397
+
398
+ /** Set the CarMoji notify program in ~/.codex/config.toml, keeping a backup. */
399
+ function installCodex() {
400
+ const path = join(homedir(), '.codex', 'config.toml');
401
+ const notifyLine = RUNNING_FROM_PACKAGE
402
+ ? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
403
+ : `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`;
404
+
405
+ let text = existsSync(path) ? readFileSync(path, 'utf8') : '';
406
+ const lines = text.split('\n');
407
+ const notifyIndex = lines.findIndex((line) => /^\s*notify\s*=/.test(line));
408
+
409
+ if (notifyIndex >= 0 && lines[notifyIndex].includes('carmoji')) {
410
+ console.log(`CarMoji notify already installed in ${path}`);
411
+ return;
412
+ }
413
+ const bak = text ? backupFile(path) : null;
414
+ if (notifyIndex >= 0) {
415
+ console.log(`Replacing existing notify: ${lines[notifyIndex].trim()}`);
416
+ lines[notifyIndex] = notifyLine;
417
+ text = lines.join('\n');
418
+ } else {
419
+ // notify is a top-level key, so it must sit before any [section].
420
+ text = `${notifyLine}\n${text}`;
421
+ }
422
+ mkdirSync(dirname(path), { recursive: true });
423
+ writeFileSync(path, text.endsWith('\n') ? text : `${text}\n`);
424
+ console.log(`Installed CarMoji notify in ${path}`);
425
+ if (bak) console.log(`Backup saved to ${bak}`);
426
+ }
427
+
293
428
  function claudeConfigSnippet() {
294
429
  const entry = [{ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }] }];
295
430
  const hooks = {};
@@ -384,6 +519,10 @@ async function main() {
384
519
  : payload.hook_event_name === 'Notification' ? payload.message
385
520
  : undefined;
386
521
  if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
522
+ // Plan-window usage: recompute only at turn boundaries.
523
+ const recompute = ['SessionStart', 'Stop'].includes(payload.hook_event_name);
524
+ const usage = claudeUsage(recompute);
525
+ if (usage) message.usage = usage;
387
526
  await send(config, message);
388
527
  } catch {
389
528
  // Never surface errors into the coding session.
@@ -423,6 +562,8 @@ async function main() {
423
562
  const message = { source: 'test', session, ...event };
424
563
  if (tokens !== undefined) message.tokens = Number(tokens);
425
564
  if (project) message.project = project;
565
+ const usage = claudeUsage(true);
566
+ if (usage) message.usage = usage;
426
567
  const result = await send(config, message);
427
568
  console.log(result === 'ok' ? 'Sent!' : `Failed: ${result}`);
428
569
  process.exit(result === 'ok' ? 0 : 1);
@@ -440,8 +581,17 @@ async function main() {
440
581
  break;
441
582
  }
442
583
 
584
+ case 'install-claude':
585
+ installClaude();
586
+ break;
587
+
588
+ case 'install-codex':
589
+ installCodex();
590
+ break;
591
+
443
592
  case 'claude-config':
444
593
  console.log('Merge this into the project’s .claude/settings.json (or ~/.claude/settings.json for everywhere):\n');
594
+ console.log('(Or let `carmoji install-claude` do it for you, with a backup.)\n');
445
595
  console.log(claudeConfigSnippet());
446
596
  break;
447
597
 
@@ -468,7 +618,7 @@ async function main() {
468
618
  break;
469
619
 
470
620
  default:
471
- console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | test <event> | hook [--gate] | codex-notify | claude-config | codex-config | gate-config');
621
+ console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install-claude | install-codex | test <event> | hook [--gate] | codex-notify | claude-config | codex-config | gate-config');
472
622
  }
473
623
  }
474
624
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Bring your CarMoji pet to life from Claude Code / Codex — a LAN bridge that streams coding events to the CarMoji iOS app",
5
5
  "type": "module",
6
6
  "bin": {