carmoji 0.3.1 → 0.3.2

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 +17 -8
  2. package/carmoji.js +150 -27
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -65,7 +65,7 @@ npx carmoji uninstall <tool>
65
65
  | Tool | Hooks written to | Notes |
66
66
  | --- | --- | --- |
67
67
  | `claude` | `~/.claude/settings.json` | full event coverage |
68
- | `codex` | `$CODEX_HOME/hooks.json` | full lifecycle via Codex hooks; replaces the old notify line |
68
+ | `codex` | `$CODEX_HOME/hooks.json` | turn, tool, and permission events via Codex hooks |
69
69
  | `gemini` | `~/.gemini/settings.json` | Before/After tool + agent events |
70
70
  | `cursor` | `~/.cursor/hooks.json` | shell/read/edit/MCP events |
71
71
  | `trae`, `traecn` | `~/.trae{,-cn}/hooks.json` | Trae IDE (Cursor-style events) |
@@ -82,6 +82,11 @@ config, reruns are idempotent, only carmoji-owned entries are ever
82
82
  removed, and unparseable files are refused untouched. `install all` skips
83
83
  tools that aren't on the machine.
84
84
 
85
+ Codex requires new or changed command hooks to be trusted. After installing,
86
+ open `/hooks` in Codex, review the CarMoji commands, and trust them. The
87
+ installer removes a direct legacy CarMoji `notify` command, or removes only
88
+ CarMoji when another notifier has it chained via `--previous-notify`.
89
+
85
90
  All of them funnel through the same `carmoji hook` entry point
86
91
  (`--source` names the tool, `--event` supplies the event name for tools
87
92
  whose payloads lack one), so the pet reacts identically no matter which
@@ -112,11 +117,15 @@ npx carmoji test end # contented sigh
112
117
  but if one agent is still blocked waiting for your approval, it returns
113
118
  to the pleading face after another agent's reaction — and stops pleading
114
119
  as soon as the blocked session moves again.
115
- - The bridge counts each session's cumulative output tokens by tailing the
116
- transcript incrementally (per-session byte offsets in
120
+ - The bridge counts each session's cumulative output tokens by tailing Claude
121
+ message-usage records or Codex `token_count` records incrementally
122
+ (per-session byte offsets in
117
123
  `~/.config/carmoji/sessions/`). While in buddy mode, the dashboard's
118
124
  speed readout switches from km/h to **tok/s** — combined across all
119
- agents — with the active agent count below it.
125
+ agents — with the active agent count below it. Codex samples arrive at hook
126
+ boundaries rather than for every streamed token, so its tok/s value is an
127
+ approximation. Codex documents its transcript format as unstable; unknown
128
+ future formats safely fall back to the working-time display.
120
129
 
121
130
  ## What the phone shows
122
131
 
@@ -151,16 +160,16 @@ permission on the computer via the hook's official decision output. No
151
160
  answer within ~55 s falls through to the normal terminal prompt.
152
161
 
153
162
  Honest limits: this decides *tool permissions* only — hooks expose no way
154
- to answer AskUserQuestion option pickers — and Codex's notify surface has
155
- no decision channel at all, so it stays display-only.
163
+ to answer AskUserQuestion option pickers — and the Codex integration currently
164
+ treats `PermissionRequest` as display-only.
156
165
 
157
166
  ## How it works
158
167
 
159
168
  - The app runs an `NWListener` WebSocket server on port 8737, advertised
160
169
  via Bonjour as `_carmoji._tcp`. LAN only; every frame must carry the
161
170
  pairing code.
162
- - `carmoji.js hook` is invoked by Claude Code's hooks with the event JSON
163
- on stdin, maps it to a wire event, sends one frame (≤0.9 s budget), and
171
+ - `carmoji.js hook` is invoked by the coding agent's hooks with event JSON on
172
+ stdin, maps it to a wire event, sends one frame (≤0.9 s budget), and
164
173
  always exits 0 — it can never slow down or break a coding session. When
165
174
  unpaired it exits immediately.
166
175
  - Pairing state lives in `~/.config/carmoji/config.json`.
package/carmoji.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import {
20
20
  readFileSync, writeFileSync, mkdirSync,
21
21
  statSync, openSync, readSync, closeSync,
22
- copyFileSync, existsSync, readdirSync, unlinkSync,
22
+ copyFileSync, existsSync, readdirSync, unlinkSync, realpathSync,
23
23
  } from 'node:fs';
24
24
  import { spawn } from 'node:child_process';
25
25
  import { homedir, hostname } from 'node:os';
@@ -329,7 +329,7 @@ const EVENT_ALIASES = {
329
329
  pre_tool_use: 'PreToolUse',
330
330
  post_tool_use: 'PostToolUse',
331
331
  post_tool_use_failure: 'Error',
332
- permission_request: 'Notification',
332
+ permission_request: 'PermissionRequest',
333
333
  notification: 'Notification',
334
334
  pre_tool_call: 'PreToolUse',
335
335
  post_tool_call: 'PostToolUse',
@@ -392,7 +392,8 @@ function eventFromHook(payload) {
392
392
  case 'PostToolUse': return { event: 'tool_end', tool };
393
393
  case 'PostToolUseFailure':
394
394
  case 'Error': return { event: 'error' };
395
- case 'Notification': return { event: 'permission' };
395
+ case 'PermissionRequest':
396
+ case 'Notification': return { event: 'permission', tool };
396
397
  case 'Stop': return { event: 'turn_done' };
397
398
  case 'SessionEnd': return { event: 'session_end' };
398
399
  // Liveness only: keep the session fresh without a face reaction.
@@ -404,10 +405,35 @@ function eventFromHook(payload) {
404
405
  }
405
406
  }
406
407
 
408
+ /** Fold one transcript record into a session's monotonic output-token total.
409
+ * Claude records per-message deltas; Codex token_count records carry a
410
+ * cumulative total. `codexReported` lets us convert that total to a delta and
411
+ * also handles a future reset without making the dashboard count backwards.
412
+ */
413
+ function applyTranscriptTokenRecord(state, record) {
414
+ const claudeOutput = record?.message?.usage?.output_tokens;
415
+ if (Number.isFinite(claudeOutput) && claudeOutput >= 0) {
416
+ state.total += claudeOutput;
417
+ }
418
+
419
+ const codexOutput = record?.payload?.type === 'token_count'
420
+ ? record.payload.info?.total_token_usage?.output_tokens
421
+ : undefined;
422
+ if (Number.isFinite(codexOutput) && codexOutput >= 0) {
423
+ const previous = Number.isFinite(state.codexReported)
424
+ ? state.codexReported : 0;
425
+ state.total += codexOutput >= previous
426
+ ? codexOutput - previous
427
+ : codexOutput;
428
+ state.codexReported = codexOutput;
429
+ }
430
+ return state;
431
+ }
432
+
407
433
  /**
408
434
  * Cumulative output tokens for this session, counted incrementally from the
409
- * transcript JSONL: a per-session byte offset is cached so each hook only
410
- * parses lines appended since the previous one.
435
+ * Claude or Codex transcript JSONL. A per-session byte offset is cached so
436
+ * each hook only parses lines appended since the previous one.
411
437
  */
412
438
  function sessionTokens(payload) {
413
439
  const transcript = payload.transcript_path;
@@ -416,6 +442,8 @@ function sessionTokens(payload) {
416
442
  const statePath = join(dirname(CONFIG_PATH), 'sessions', `${session}.json`);
417
443
  let state = { offset: 0, total: 0 };
418
444
  try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
445
+ if (!Number.isFinite(state.offset) || state.offset < 0) state.offset = 0;
446
+ if (!Number.isFinite(state.total) || state.total < 0) state.total = 0;
419
447
  let size;
420
448
  try { size = statSync(transcript).size; } catch { return undefined; }
421
449
  if (size < state.offset) state = { offset: 0, total: 0 }; // truncated/rotated
@@ -430,10 +458,9 @@ function sessionTokens(payload) {
430
458
  const lastNewline = chunk.lastIndexOf(0x0a);
431
459
  if (lastNewline >= 0) {
432
460
  for (const line of chunk.subarray(0, lastNewline + 1).toString('utf8').split('\n')) {
433
- if (!line.includes('"usage"')) continue;
461
+ if (!line.includes('"usage"') && !line.includes('"token_count"')) continue;
434
462
  try {
435
- const usage = JSON.parse(line)?.message?.usage;
436
- if (usage?.output_tokens) state.total += usage.output_tokens;
463
+ applyTranscriptTokenRecord(state, JSON.parse(line));
437
464
  } catch { /* ignore malformed lines */ }
438
465
  }
439
466
  state.offset += lastNewline + 1;
@@ -580,9 +607,18 @@ function installJsonHooks(key, tool) {
580
607
  const base = `${HOOK_COMMAND} --source ${source}`;
581
608
  const timeout = (seconds) => (tool.timeoutMs ? seconds * 1000 : seconds);
582
609
 
610
+ // Remove every old CarMoji entry first, including events no longer in this
611
+ // tool's supported set. This keeps upgrades idempotent when an event is
612
+ // renamed or removed (for example Codex's old SessionEnd entry).
613
+ for (const [event, value] of Object.entries(hooks)) {
614
+ if (!Array.isArray(value)) continue;
615
+ const kept = value.filter((entry) => !JSON.stringify(entry).includes('carmoji'));
616
+ if (kept.length) hooks[event] = kept;
617
+ else delete hooks[event];
618
+ }
619
+
583
620
  for (const event of tool.events ?? CLAUDE_HOOK_EVENTS) {
584
- const entries = (Array.isArray(hooks[event]) ? hooks[event] : [])
585
- .filter((entry) => !JSON.stringify(entry).includes('carmoji'));
621
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
586
622
  const command = tool.eventFlag ? `${base} --event ${event}` : base;
587
623
  switch (tool.format) {
588
624
  case 'claude':
@@ -674,31 +710,100 @@ function installClineHooks(key, tool) {
674
710
  console.log(`Installed CarMoji hooks for ${tool.name} in ${dir}`);
675
711
  }
676
712
 
713
+ function isCarMojiNotifyCommand(args) {
714
+ if (!Array.isArray(args)) return false;
715
+ const hasSubcommand = args.includes('codex-notify');
716
+ const hasEntrypoint = args.some((arg) => typeof arg === 'string'
717
+ && (arg === 'carmoji' || /(^|[\\/])carmoji\.js$/i.test(arg)));
718
+ return hasSubcommand && hasEntrypoint;
719
+ }
720
+
721
+ /** Remove a direct or --previous-notify-nested CarMoji command. */
722
+ function stripCarMojiNotifyCommand(args, depth = 0) {
723
+ if (!Array.isArray(args) || depth > 8) return { args, changed: false };
724
+ if (isCarMojiNotifyCommand(args)) return { args: null, changed: true };
725
+
726
+ const next = [...args];
727
+ let changed = false;
728
+ for (let index = 0; index < next.length - 1; index += 1) {
729
+ if (next[index] !== '--previous-notify' || typeof next[index + 1] !== 'string') {
730
+ continue;
731
+ }
732
+ let previous;
733
+ try { previous = JSON.parse(next[index + 1]); } catch { continue; }
734
+ const stripped = stripCarMojiNotifyCommand(previous, depth + 1);
735
+ if (!stripped.changed) continue;
736
+ changed = true;
737
+ if (stripped.args === null) {
738
+ next.splice(index, 2);
739
+ index -= 1;
740
+ } else {
741
+ next[index + 1] = JSON.stringify(stripped.args);
742
+ }
743
+ }
744
+ return { args: next, changed };
745
+ }
746
+
747
+ /**
748
+ * Remove CarMoji from a single-line TOML `notify = [...]` assignment without
749
+ * deleting an outer notifier that happens to chain CarMoji. The arrays we
750
+ * emit (and the notifier wrappers used by Codex integrations) are JSON-compatible.
751
+ */
752
+ function stripLegacyCarMojiNotifyLine(line) {
753
+ const match = line.match(/^(\s*notify\s*=\s*)(\[.*\])(\s*(?:#.*)?)$/);
754
+ if (!match) return { line, changed: false, removed: false };
755
+ let args;
756
+ try { args = JSON.parse(match[2]); } catch {
757
+ return { line, changed: false, removed: false };
758
+ }
759
+ const stripped = stripCarMojiNotifyCommand(args);
760
+ if (!stripped.changed) return { line, changed: false, removed: false };
761
+ if (stripped.args === null) return { line: null, changed: true, removed: true };
762
+ return {
763
+ line: `${match[1]}${JSON.stringify(stripped.args)}${match[3]}`,
764
+ changed: true,
765
+ removed: false,
766
+ };
767
+ }
768
+
677
769
  /**
678
770
  * Codex: hooks.json in $CODEX_HOME (Claude-style events, nested entries).
679
- * The legacy `notify` line only reported finished turns; hooks.json covers
680
- * the full lifecycle, so a carmoji notify line is removed to avoid double
681
- * turn_done events. `install-codex-notify` remains for older Codex versions.
771
+ * The legacy `notify` command only reported finished turns; hooks.json covers
772
+ * turn, tool, and permission events, so the CarMoji notify command is removed
773
+ * to avoid double turn_done events. Other chained notifiers are preserved.
682
774
  */
683
775
  function installCodexHooks() {
684
776
  installJsonHooks('codex', {
685
777
  name: 'Codex', format: 'codex',
686
- events: ['SessionStart', 'SessionEnd', 'UserPromptSubmit',
687
- 'PreToolUse', 'PostToolUse', 'Stop'],
778
+ events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
779
+ 'PermissionRequest', 'PostToolUse', 'Stop'],
688
780
  });
689
781
  const configPath = join(codexHome(), 'config.toml');
690
- if (!existsSync(configPath)) return;
782
+ if (!existsSync(configPath)) {
783
+ console.log('Open /hooks in Codex to review and trust the new hook commands.');
784
+ return;
785
+ }
691
786
  const text = readFileSync(configPath, 'utf8');
692
787
  const lines = text.split('\n');
693
- const notifyIndex = lines.findIndex(
694
- (line) => /^\s*notify\s*=/.test(line) && line.includes('carmoji'));
788
+ const notifyIndex = lines.findIndex((line) => /^\s*notify\s*=/.test(line));
695
789
  if (notifyIndex >= 0) {
696
- backupFile(configPath);
697
- lines.splice(notifyIndex, 1);
698
- writeFileSync(configPath, lines.join('\n'));
699
- console.log(`Removed the legacy carmoji notify line from ${configPath}`);
700
- console.log('(hooks.json now covers turn completion — no double events).');
790
+ const stripped = stripLegacyCarMojiNotifyLine(lines[notifyIndex]);
791
+ if (stripped.changed) {
792
+ backupFile(configPath);
793
+ if (stripped.line === null) lines.splice(notifyIndex, 1);
794
+ else lines[notifyIndex] = stripped.line;
795
+ writeFileSync(configPath, lines.join('\n'));
796
+ console.log(stripped.removed
797
+ ? `Removed the legacy CarMoji notify line from ${configPath}`
798
+ : `Removed legacy CarMoji from the chained notifier in ${configPath}`);
799
+ console.log('(hooks.json now covers turn completion — no double events).');
800
+ } else if (lines[notifyIndex].includes('carmoji')
801
+ && lines[notifyIndex].includes('codex-notify')) {
802
+ console.log(`Left the legacy CarMoji notify unchanged in ${configPath}`);
803
+ console.log('(its TOML shape was not safely editable; remove only the CarMoji command to avoid duplicate turn-complete events).');
804
+ }
701
805
  }
806
+ console.log('Open /hooks in Codex to review and trust the new hook commands.');
702
807
  }
703
808
 
704
809
  function installTool(key, { skipMissing = false } = {}) {
@@ -903,8 +1008,8 @@ async function main() {
903
1008
  // sum across them instead of showing whichever reported last.
904
1009
  const message = { source, session: payload.session_id,
905
1010
  host: hostname(), ...event };
906
- // Claude-format transcripts power the tok/s readout; the forks share
907
- // the format, and sessionTokens quietly no-ops for everyone else.
1011
+ // Claude and Codex transcripts power the tok/s readout; other formats
1012
+ // quietly produce no token changes.
908
1013
  const tokens = sessionTokens(payload);
909
1014
  if (tokens !== undefined) message.tokens = tokens;
910
1015
  // Gemini and Trae hooks carry no cwd — the hook itself runs in the
@@ -916,7 +1021,8 @@ async function main() {
916
1021
  ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
917
1022
  payload.message, payload.input, payload.text)
918
1023
  : event.event === 'permission'
919
- ? firstString(payload.message, payload.detail)
1024
+ ? firstString(payload.message, payload.detail,
1025
+ payload.tool_input ? summarizeToolInput(payload) : undefined)
920
1026
  : undefined;
921
1027
  if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
922
1028
  // Plan-window usage comes from Claude Code's local transcripts;
@@ -1065,4 +1171,21 @@ async function main() {
1065
1171
  }
1066
1172
  }
1067
1173
 
1068
- main().then(() => process.exit(process.exitCode ?? 0));
1174
+ export {
1175
+ applyTranscriptTokenRecord,
1176
+ eventFromHook,
1177
+ stripLegacyCarMojiNotifyLine,
1178
+ };
1179
+
1180
+ function isMainModule() {
1181
+ if (!process.argv[1]) return false;
1182
+ try {
1183
+ return realpathSync(process.argv[1]) === realpathSync(SCRIPT_PATH);
1184
+ } catch {
1185
+ return false;
1186
+ }
1187
+ }
1188
+
1189
+ if (isMainModule()) {
1190
+ main().then(() => process.exit(process.exitCode ?? 0));
1191
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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": {
7
7
  "carmoji": "./carmoji.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
9
12
  "files": [
10
13
  "carmoji.js",
11
14
  "README.md"