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.
- package/README.md +24 -7
- package/carmoji.js +152 -2
- 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
|
|
42
|
+
every project:
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
|
-
npx carmoji claude
|
|
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
|
|
56
|
+
npx carmoji install-codex
|
|
52
57
|
```
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|