carmoji 0.3.1 → 0.3.3

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 +23 -8
  2. package/carmoji.js +294 -47
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -38,6 +38,12 @@ approval.
38
38
  through the system's `dns-sd`, which handles Macs full of VPN tunnels
39
39
  and virtual interfaces; other platforms use a JS mDNS fallback.
40
40
 
41
+ Pairing is additive: pair again with a second phone and every event
42
+ fans out to all paired devices (approval requests go to all of them
43
+ too — the first tap wins). When devices are already paired, `pair`
44
+ asks whether to keep them. `npx carmoji unpair` forgets a device —
45
+ by name or address, or `unpair all` for a clean slate.
46
+
41
47
  ## Claude Code
42
48
 
43
49
  Coding in *this* repo works out of the box — the hooks ship in
@@ -65,7 +71,7 @@ npx carmoji uninstall <tool>
65
71
  | Tool | Hooks written to | Notes |
66
72
  | --- | --- | --- |
67
73
  | `claude` | `~/.claude/settings.json` | full event coverage |
68
- | `codex` | `$CODEX_HOME/hooks.json` | full lifecycle via Codex hooks; replaces the old notify line |
74
+ | `codex` | `$CODEX_HOME/hooks.json` | turn, tool, and permission events via Codex hooks |
69
75
  | `gemini` | `~/.gemini/settings.json` | Before/After tool + agent events |
70
76
  | `cursor` | `~/.cursor/hooks.json` | shell/read/edit/MCP events |
71
77
  | `trae`, `traecn` | `~/.trae{,-cn}/hooks.json` | Trae IDE (Cursor-style events) |
@@ -82,6 +88,11 @@ config, reruns are idempotent, only carmoji-owned entries are ever
82
88
  removed, and unparseable files are refused untouched. `install all` skips
83
89
  tools that aren't on the machine.
84
90
 
91
+ Codex requires new or changed command hooks to be trusted. After installing,
92
+ open `/hooks` in Codex, review the CarMoji commands, and trust them. The
93
+ installer removes a direct legacy CarMoji `notify` command, or removes only
94
+ CarMoji when another notifier has it chained via `--previous-notify`.
95
+
85
96
  All of them funnel through the same `carmoji hook` entry point
86
97
  (`--source` names the tool, `--event` supplies the event name for tools
87
98
  whose payloads lack one), so the pet reacts identically no matter which
@@ -112,11 +123,15 @@ npx carmoji test end # contented sigh
112
123
  but if one agent is still blocked waiting for your approval, it returns
113
124
  to the pleading face after another agent's reaction — and stops pleading
114
125
  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
126
+ - The bridge counts each session's cumulative output tokens by tailing Claude
127
+ message-usage records or Codex `token_count` records incrementally
128
+ (per-session byte offsets in
117
129
  `~/.config/carmoji/sessions/`). While in buddy mode, the dashboard's
118
130
  speed readout switches from km/h to **tok/s** — combined across all
119
- agents — with the active agent count below it.
131
+ agents — with the active agent count below it. Codex samples arrive at hook
132
+ boundaries rather than for every streamed token, so its tok/s value is an
133
+ approximation. Codex documents its transcript format as unstable; unknown
134
+ future formats safely fall back to the working-time display.
120
135
 
121
136
  ## What the phone shows
122
137
 
@@ -151,16 +166,16 @@ permission on the computer via the hook's official decision output. No
151
166
  answer within ~55 s falls through to the normal terminal prompt.
152
167
 
153
168
  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.
169
+ to answer AskUserQuestion option pickers — and the Codex integration currently
170
+ treats `PermissionRequest` as display-only.
156
171
 
157
172
  ## How it works
158
173
 
159
174
  - The app runs an `NWListener` WebSocket server on port 8737, advertised
160
175
  via Bonjour as `_carmoji._tcp`. LAN only; every frame must carry the
161
176
  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
177
+ - `carmoji.js hook` is invoked by the coding agent's hooks with event JSON on
178
+ stdin, maps it to a wire event, sends one frame (≤0.9 s budget), and
164
179
  always exits 0 — it can never slow down or break a coding session. When
165
180
  unpaired it exits immediately.
166
181
  - Pairing state lives in `~/.config/carmoji/config.json`.
package/carmoji.js CHANGED
@@ -6,6 +6,8 @@
6
6
  //
7
7
  // Commands:
8
8
  // pair <code> discover the phone on this Wi-Fi and pair with it
9
+ // (pairing is additive — events fan out to every device)
10
+ // unpair [device] forget a paired device by name or ip[:port], or `all`
9
11
  // install <tool> install hooks for a tool (or `all`); see `tools`
10
12
  // uninstall <tool> remove CarMoji hooks from a tool's config
11
13
  // tools list supported tools and where their hooks live
@@ -19,7 +21,7 @@
19
21
  import {
20
22
  readFileSync, writeFileSync, mkdirSync,
21
23
  statSync, openSync, readSync, closeSync,
22
- copyFileSync, existsSync, readdirSync, unlinkSync,
24
+ copyFileSync, existsSync, readdirSync, unlinkSync, realpathSync,
23
25
  } from 'node:fs';
24
26
  import { spawn } from 'node:child_process';
25
27
  import { homedir, hostname } from 'node:os';
@@ -52,6 +54,30 @@ function saveConfig(config) {
52
54
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
53
55
  }
54
56
 
57
+ /**
58
+ * Paired devices from a config of either vintage: the original single
59
+ * top-level {host, port, code}, or the current {devices: [{name, host,
60
+ * port, code}, …]}. Saving always writes the multi-device shape.
61
+ */
62
+ function normalizeDevices(config) {
63
+ if (!config) return [];
64
+ const list = Array.isArray(config.devices) ? config.devices
65
+ : config.host ? [config] : [];
66
+ return list.filter((d) => d && d.host && d.port && d.code);
67
+ }
68
+
69
+ function loadDevices() {
70
+ return normalizeDevices(loadConfig());
71
+ }
72
+
73
+ function saveDevices(devices) {
74
+ saveConfig({ devices });
75
+ }
76
+
77
+ function deviceLabel(device) {
78
+ return `${device.name ?? device.host} — ${device.host}:${device.port}`;
79
+ }
80
+
55
81
  function readStdin() {
56
82
  try {
57
83
  return readFileSync(0, 'utf8');
@@ -135,6 +161,14 @@ async function discoverViaMulticastJS() {
135
161
  });
136
162
  }
137
163
 
164
+ async function askTTY(question) {
165
+ const { createInterface } = await import('node:readline/promises');
166
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
167
+ const answer = await rl.question(question);
168
+ rl.close();
169
+ return answer.trim();
170
+ }
171
+
138
172
  /** Let the user pick between several discovered phones. */
139
173
  async function chooseDevice(devices) {
140
174
  console.log('Multiple CarMoji devices found:');
@@ -143,10 +177,7 @@ async function chooseDevice(devices) {
143
177
  console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
144
178
  process.exit(1);
145
179
  }
146
- const { createInterface } = await import('node:readline/promises');
147
- const rl = createInterface({ input: process.stdin, output: process.stdout });
148
- const answer = await rl.question(`Pair with which one? [1-${devices.length}] `);
149
- rl.close();
180
+ const answer = await askTTY(`Pair with which one? [1-${devices.length}] `);
150
181
  const device = devices[Number(answer) - 1];
151
182
  if (!device) {
152
183
  console.error('Invalid choice.');
@@ -220,6 +251,30 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
220
251
  });
221
252
  }
222
253
 
254
+ /** Fan one event out to every paired phone in parallel; one slow or
255
+ * unreachable device can't delay the others past SEND_TIMEOUT_MS. */
256
+ function sendAll(devices, message) {
257
+ return Promise.all(devices.map((device) => send(device, message)));
258
+ }
259
+
260
+ /**
261
+ * Show the approval card on every paired phone; the first tap anywhere
262
+ * wins. The other phones' cards drop when this process exits and their
263
+ * sockets close. Resolves null when nobody answers in time.
264
+ */
265
+ function requestApprovalAll(devices, message, timeoutMs) {
266
+ if (devices.length === 0) return Promise.resolve(null);
267
+ return new Promise((resolve) => {
268
+ let pending = devices.length;
269
+ for (const device of devices) {
270
+ requestApproval(device, message, timeoutMs).then((decision) => {
271
+ pending -= 1;
272
+ if (decision || pending === 0) resolve(decision);
273
+ });
274
+ }
275
+ });
276
+ }
277
+
223
278
  function summarizeToolInput(payload) {
224
279
  const input = payload.tool_input || {};
225
280
  const text = input.command || input.file_path || input.description
@@ -329,7 +384,7 @@ const EVENT_ALIASES = {
329
384
  pre_tool_use: 'PreToolUse',
330
385
  post_tool_use: 'PostToolUse',
331
386
  post_tool_use_failure: 'Error',
332
- permission_request: 'Notification',
387
+ permission_request: 'PermissionRequest',
333
388
  notification: 'Notification',
334
389
  pre_tool_call: 'PreToolUse',
335
390
  post_tool_call: 'PostToolUse',
@@ -392,7 +447,8 @@ function eventFromHook(payload) {
392
447
  case 'PostToolUse': return { event: 'tool_end', tool };
393
448
  case 'PostToolUseFailure':
394
449
  case 'Error': return { event: 'error' };
395
- case 'Notification': return { event: 'permission' };
450
+ case 'PermissionRequest':
451
+ case 'Notification': return { event: 'permission', tool };
396
452
  case 'Stop': return { event: 'turn_done' };
397
453
  case 'SessionEnd': return { event: 'session_end' };
398
454
  // Liveness only: keep the session fresh without a face reaction.
@@ -404,10 +460,35 @@ function eventFromHook(payload) {
404
460
  }
405
461
  }
406
462
 
463
+ /** Fold one transcript record into a session's monotonic output-token total.
464
+ * Claude records per-message deltas; Codex token_count records carry a
465
+ * cumulative total. `codexReported` lets us convert that total to a delta and
466
+ * also handles a future reset without making the dashboard count backwards.
467
+ */
468
+ function applyTranscriptTokenRecord(state, record) {
469
+ const claudeOutput = record?.message?.usage?.output_tokens;
470
+ if (Number.isFinite(claudeOutput) && claudeOutput >= 0) {
471
+ state.total += claudeOutput;
472
+ }
473
+
474
+ const codexOutput = record?.payload?.type === 'token_count'
475
+ ? record.payload.info?.total_token_usage?.output_tokens
476
+ : undefined;
477
+ if (Number.isFinite(codexOutput) && codexOutput >= 0) {
478
+ const previous = Number.isFinite(state.codexReported)
479
+ ? state.codexReported : 0;
480
+ state.total += codexOutput >= previous
481
+ ? codexOutput - previous
482
+ : codexOutput;
483
+ state.codexReported = codexOutput;
484
+ }
485
+ return state;
486
+ }
487
+
407
488
  /**
408
489
  * 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.
490
+ * Claude or Codex transcript JSONL. A per-session byte offset is cached so
491
+ * each hook only parses lines appended since the previous one.
411
492
  */
412
493
  function sessionTokens(payload) {
413
494
  const transcript = payload.transcript_path;
@@ -416,6 +497,8 @@ function sessionTokens(payload) {
416
497
  const statePath = join(dirname(CONFIG_PATH), 'sessions', `${session}.json`);
417
498
  let state = { offset: 0, total: 0 };
418
499
  try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
500
+ if (!Number.isFinite(state.offset) || state.offset < 0) state.offset = 0;
501
+ if (!Number.isFinite(state.total) || state.total < 0) state.total = 0;
419
502
  let size;
420
503
  try { size = statSync(transcript).size; } catch { return undefined; }
421
504
  if (size < state.offset) state = { offset: 0, total: 0 }; // truncated/rotated
@@ -430,10 +513,9 @@ function sessionTokens(payload) {
430
513
  const lastNewline = chunk.lastIndexOf(0x0a);
431
514
  if (lastNewline >= 0) {
432
515
  for (const line of chunk.subarray(0, lastNewline + 1).toString('utf8').split('\n')) {
433
- if (!line.includes('"usage"')) continue;
516
+ if (!line.includes('"usage"') && !line.includes('"token_count"')) continue;
434
517
  try {
435
- const usage = JSON.parse(line)?.message?.usage;
436
- if (usage?.output_tokens) state.total += usage.output_tokens;
518
+ applyTranscriptTokenRecord(state, JSON.parse(line));
437
519
  } catch { /* ignore malformed lines */ }
438
520
  }
439
521
  state.offset += lastNewline + 1;
@@ -580,9 +662,18 @@ function installJsonHooks(key, tool) {
580
662
  const base = `${HOOK_COMMAND} --source ${source}`;
581
663
  const timeout = (seconds) => (tool.timeoutMs ? seconds * 1000 : seconds);
582
664
 
665
+ // Remove every old CarMoji entry first, including events no longer in this
666
+ // tool's supported set. This keeps upgrades idempotent when an event is
667
+ // renamed or removed (for example Codex's old SessionEnd entry).
668
+ for (const [event, value] of Object.entries(hooks)) {
669
+ if (!Array.isArray(value)) continue;
670
+ const kept = value.filter((entry) => !JSON.stringify(entry).includes('carmoji'));
671
+ if (kept.length) hooks[event] = kept;
672
+ else delete hooks[event];
673
+ }
674
+
583
675
  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'));
676
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
586
677
  const command = tool.eventFlag ? `${base} --event ${event}` : base;
587
678
  switch (tool.format) {
588
679
  case 'claude':
@@ -674,31 +765,100 @@ function installClineHooks(key, tool) {
674
765
  console.log(`Installed CarMoji hooks for ${tool.name} in ${dir}`);
675
766
  }
676
767
 
768
+ function isCarMojiNotifyCommand(args) {
769
+ if (!Array.isArray(args)) return false;
770
+ const hasSubcommand = args.includes('codex-notify');
771
+ const hasEntrypoint = args.some((arg) => typeof arg === 'string'
772
+ && (arg === 'carmoji' || /(^|[\\/])carmoji\.js$/i.test(arg)));
773
+ return hasSubcommand && hasEntrypoint;
774
+ }
775
+
776
+ /** Remove a direct or --previous-notify-nested CarMoji command. */
777
+ function stripCarMojiNotifyCommand(args, depth = 0) {
778
+ if (!Array.isArray(args) || depth > 8) return { args, changed: false };
779
+ if (isCarMojiNotifyCommand(args)) return { args: null, changed: true };
780
+
781
+ const next = [...args];
782
+ let changed = false;
783
+ for (let index = 0; index < next.length - 1; index += 1) {
784
+ if (next[index] !== '--previous-notify' || typeof next[index + 1] !== 'string') {
785
+ continue;
786
+ }
787
+ let previous;
788
+ try { previous = JSON.parse(next[index + 1]); } catch { continue; }
789
+ const stripped = stripCarMojiNotifyCommand(previous, depth + 1);
790
+ if (!stripped.changed) continue;
791
+ changed = true;
792
+ if (stripped.args === null) {
793
+ next.splice(index, 2);
794
+ index -= 1;
795
+ } else {
796
+ next[index + 1] = JSON.stringify(stripped.args);
797
+ }
798
+ }
799
+ return { args: next, changed };
800
+ }
801
+
802
+ /**
803
+ * Remove CarMoji from a single-line TOML `notify = [...]` assignment without
804
+ * deleting an outer notifier that happens to chain CarMoji. The arrays we
805
+ * emit (and the notifier wrappers used by Codex integrations) are JSON-compatible.
806
+ */
807
+ function stripLegacyCarMojiNotifyLine(line) {
808
+ const match = line.match(/^(\s*notify\s*=\s*)(\[.*\])(\s*(?:#.*)?)$/);
809
+ if (!match) return { line, changed: false, removed: false };
810
+ let args;
811
+ try { args = JSON.parse(match[2]); } catch {
812
+ return { line, changed: false, removed: false };
813
+ }
814
+ const stripped = stripCarMojiNotifyCommand(args);
815
+ if (!stripped.changed) return { line, changed: false, removed: false };
816
+ if (stripped.args === null) return { line: null, changed: true, removed: true };
817
+ return {
818
+ line: `${match[1]}${JSON.stringify(stripped.args)}${match[3]}`,
819
+ changed: true,
820
+ removed: false,
821
+ };
822
+ }
823
+
677
824
  /**
678
825
  * 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.
826
+ * The legacy `notify` command only reported finished turns; hooks.json covers
827
+ * turn, tool, and permission events, so the CarMoji notify command is removed
828
+ * to avoid double turn_done events. Other chained notifiers are preserved.
682
829
  */
683
830
  function installCodexHooks() {
684
831
  installJsonHooks('codex', {
685
832
  name: 'Codex', format: 'codex',
686
- events: ['SessionStart', 'SessionEnd', 'UserPromptSubmit',
687
- 'PreToolUse', 'PostToolUse', 'Stop'],
833
+ events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
834
+ 'PermissionRequest', 'PostToolUse', 'Stop'],
688
835
  });
689
836
  const configPath = join(codexHome(), 'config.toml');
690
- if (!existsSync(configPath)) return;
837
+ if (!existsSync(configPath)) {
838
+ console.log('Open /hooks in Codex to review and trust the new hook commands.');
839
+ return;
840
+ }
691
841
  const text = readFileSync(configPath, 'utf8');
692
842
  const lines = text.split('\n');
693
- const notifyIndex = lines.findIndex(
694
- (line) => /^\s*notify\s*=/.test(line) && line.includes('carmoji'));
843
+ const notifyIndex = lines.findIndex((line) => /^\s*notify\s*=/.test(line));
695
844
  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).');
845
+ const stripped = stripLegacyCarMojiNotifyLine(lines[notifyIndex]);
846
+ if (stripped.changed) {
847
+ backupFile(configPath);
848
+ if (stripped.line === null) lines.splice(notifyIndex, 1);
849
+ else lines[notifyIndex] = stripped.line;
850
+ writeFileSync(configPath, lines.join('\n'));
851
+ console.log(stripped.removed
852
+ ? `Removed the legacy CarMoji notify line from ${configPath}`
853
+ : `Removed legacy CarMoji from the chained notifier in ${configPath}`);
854
+ console.log('(hooks.json now covers turn completion — no double events).');
855
+ } else if (lines[notifyIndex].includes('carmoji')
856
+ && lines[notifyIndex].includes('codex-notify')) {
857
+ console.log(`Left the legacy CarMoji notify unchanged in ${configPath}`);
858
+ console.log('(its TOML shape was not safely editable; remove only the CarMoji command to avoid duplicate turn-complete events).');
859
+ }
701
860
  }
861
+ console.log('Open /hooks in Codex to review and trust the new hook commands.');
702
862
  }
703
863
 
704
864
  function installTool(key, { skipMissing = false } = {}) {
@@ -841,27 +1001,92 @@ async function main() {
841
1001
  }
842
1002
  target = devices.length === 1 ? devices[0] : await chooseDevice(devices);
843
1003
  }
844
- const config = { host: target.host, port: target.port, code: arg };
845
- const result = await send(config, { source: 'bridge', event: 'turn_done' });
1004
+ const device = { name: target.name, host: target.host,
1005
+ port: target.port, code: arg };
1006
+ const result = await send(device, { source: 'bridge', event: 'turn_done' });
846
1007
  if (result !== 'ok') {
847
1008
  console.error(result === 'bad-code'
848
1009
  ? `"${target.name}" (${target.host}:${target.port}) rejected the pairing code — check that device’s Code Buddy sheet. (Wrong phone? Pass its address: carmoji pair <code> <ip[:port]>)`
849
1010
  : `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
850
1011
  process.exit(1);
851
1012
  }
852
- saveConfig(config);
1013
+ // Pairing the same device again just refreshes its code; other
1014
+ // devices stay paired unless the user says otherwise here.
1015
+ const others = loadDevices().filter(
1016
+ (d) => !(d.host === device.host && Number(d.port) === Number(device.port)));
1017
+ let kept = others;
1018
+ if (others.length > 0) {
1019
+ console.log('Already paired with:');
1020
+ others.forEach((d) => console.log(` ${deviceLabel(d)}`));
1021
+ if (process.stdin.isTTY) {
1022
+ const answer = await askTTY(
1023
+ 'Keep them too? Events go to every paired device. [Y/n] ');
1024
+ if (/^n/i.test(answer)) {
1025
+ kept = [];
1026
+ console.log('Unpaired them — events go only to the new device.');
1027
+ }
1028
+ } else {
1029
+ console.log('Keeping them — events fan out to every paired device.'
1030
+ + ' (`carmoji unpair` removes one.)');
1031
+ }
1032
+ }
1033
+ saveDevices([...kept, device]);
853
1034
  console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
854
1035
  break;
855
1036
  }
856
1037
 
1038
+ case 'unpair': {
1039
+ const devices = loadDevices();
1040
+ if (devices.length === 0) {
1041
+ console.log('Not paired with any device.');
1042
+ break;
1043
+ }
1044
+ let goners;
1045
+ if (arg === 'all') {
1046
+ goners = devices;
1047
+ } else if (arg) {
1048
+ goners = devices.filter((d) => d.name === arg || d.host === arg
1049
+ || `${d.host}:${d.port}` === arg);
1050
+ if (goners.length === 0) {
1051
+ console.error(`No paired device matches "${arg}". Paired:`);
1052
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1053
+ process.exit(1);
1054
+ }
1055
+ } else if (devices.length === 1) {
1056
+ goners = devices;
1057
+ } else if (process.stdin.isTTY) {
1058
+ console.log('Paired devices:');
1059
+ devices.forEach((d, i) => console.log(` ${i + 1}. ${deviceLabel(d)}`));
1060
+ const answer = await askTTY(
1061
+ `Unpair which one? [1-${devices.length}, or "all"] `);
1062
+ if (answer.toLowerCase() === 'all') {
1063
+ goners = devices;
1064
+ } else {
1065
+ const device = devices[Number(answer) - 1];
1066
+ if (!device) {
1067
+ console.error('Invalid choice.');
1068
+ process.exit(1);
1069
+ }
1070
+ goners = [device];
1071
+ }
1072
+ } else {
1073
+ console.error('Several devices are paired — name one: carmoji unpair <name|ip[:port]|all>');
1074
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1075
+ process.exit(1);
1076
+ }
1077
+ saveDevices(devices.filter((d) => !goners.includes(d)));
1078
+ goners.forEach((d) => console.log(`Unpaired ${deviceLabel(d)}.`));
1079
+ break;
1080
+ }
1081
+
857
1082
  case 'hook': {
858
1083
  // Runs inside the coding agent on every hook — must be silent, quick,
859
1084
  // and always exit 0 so it can never disturb the session.
860
1085
  // Flags: --source <tool> (default claude), --event <name> for tools
861
1086
  // whose stdin lacks the event name, --gate for phone approvals.
862
1087
  try {
863
- const config = loadConfig();
864
- if (!config) break;
1088
+ const devices = loadDevices();
1089
+ if (devices.length === 0) break;
865
1090
  const flags = [arg, ...rest].filter(Boolean);
866
1091
  const flagValue = (name) => {
867
1092
  const index = flags.indexOf(name);
@@ -875,7 +1100,7 @@ async function main() {
875
1100
  // the phone shows Allow/Deny; the printed JSON settles the
876
1101
  // permission. Timeout falls through to the normal terminal prompt.
877
1102
  if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
878
- const decision = await requestApproval(config, {
1103
+ const decision = await requestApprovalAll(devices, {
879
1104
  source,
880
1105
  session: payload.session_id,
881
1106
  event: 'approval_request',
@@ -903,8 +1128,8 @@ async function main() {
903
1128
  // sum across them instead of showing whichever reported last.
904
1129
  const message = { source, session: payload.session_id,
905
1130
  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.
1131
+ // Claude and Codex transcripts power the tok/s readout; other formats
1132
+ // quietly produce no token changes.
908
1133
  const tokens = sessionTokens(payload);
909
1134
  if (tokens !== undefined) message.tokens = tokens;
910
1135
  // Gemini and Trae hooks carry no cwd — the hook itself runs in the
@@ -916,7 +1141,8 @@ async function main() {
916
1141
  ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
917
1142
  payload.message, payload.input, payload.text)
918
1143
  : event.event === 'permission'
919
- ? firstString(payload.message, payload.detail)
1144
+ ? firstString(payload.message, payload.detail,
1145
+ payload.tool_input ? summarizeToolInput(payload) : undefined)
920
1146
  : undefined;
921
1147
  if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
922
1148
  // Plan-window usage comes from Claude Code's local transcripts;
@@ -926,7 +1152,7 @@ async function main() {
926
1152
  const usage = claudeUsage(recompute);
927
1153
  if (usage) message.usage = usage;
928
1154
  }
929
- await send(config, message);
1155
+ await sendAll(devices, message);
930
1156
  } catch {
931
1157
  // Never surface errors into the coding session.
932
1158
  }
@@ -935,14 +1161,14 @@ async function main() {
935
1161
 
936
1162
  case 'codex-notify': {
937
1163
  try {
938
- const config = loadConfig();
939
- if (!config) break;
1164
+ const devices = loadDevices();
1165
+ if (devices.length === 0) break;
940
1166
  const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
941
1167
  if (payload.type === 'agent-turn-complete') {
942
1168
  const session = payload['conversation-id'] || payload.conversation_id;
943
1169
  const message = { source: 'codex', session, event: 'turn_done' };
944
1170
  if (payload.cwd) message.project = basename(payload.cwd);
945
- await send(config, message);
1171
+ await sendAll(devices, message);
946
1172
  }
947
1173
  } catch {
948
1174
  // Same rule as `hook`: stay silent.
@@ -956,8 +1182,8 @@ async function main() {
956
1182
  console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
957
1183
  process.exit(1);
958
1184
  }
959
- const config = loadConfig();
960
- if (!config) {
1185
+ const devices = loadDevices();
1186
+ if (devices.length === 0) {
961
1187
  console.error('Not paired yet — run: carmoji pair <code>');
962
1188
  process.exit(1);
963
1189
  }
@@ -967,9 +1193,12 @@ async function main() {
967
1193
  if (project) message.project = project;
968
1194
  const usage = claudeUsage(true);
969
1195
  if (usage) message.usage = usage;
970
- const result = await send(config, message);
971
- console.log(result === 'ok' ? 'Sent!' : `Failed: ${result}`);
972
- process.exit(result === 'ok' ? 0 : 1);
1196
+ const results = await sendAll(devices, message);
1197
+ results.forEach((result, index) => {
1198
+ console.log(` ${deviceLabel(devices[index])}: ${
1199
+ result === 'ok' ? 'sent!' : `failed (${result})`}`);
1200
+ });
1201
+ process.exit(results.every((result) => result === 'ok') ? 0 : 1);
973
1202
  break;
974
1203
  }
975
1204
 
@@ -1061,8 +1290,26 @@ async function main() {
1061
1290
  break;
1062
1291
 
1063
1292
  default:
1064
- console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
1293
+ console.log('carmoji bridge — usage: pair <code> [ip[:port]] | unpair [<name|ip[:port]>|all] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
1294
+ }
1295
+ }
1296
+
1297
+ export {
1298
+ applyTranscriptTokenRecord,
1299
+ eventFromHook,
1300
+ normalizeDevices,
1301
+ stripLegacyCarMojiNotifyLine,
1302
+ };
1303
+
1304
+ function isMainModule() {
1305
+ if (!process.argv[1]) return false;
1306
+ try {
1307
+ return realpathSync(process.argv[1]) === realpathSync(SCRIPT_PATH);
1308
+ } catch {
1309
+ return false;
1065
1310
  }
1066
1311
  }
1067
1312
 
1068
- main().then(() => process.exit(process.exitCode ?? 0));
1313
+ if (isMainModule()) {
1314
+ main().then(() => process.exit(process.exitCode ?? 0));
1315
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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"