carmoji 0.1.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.
Files changed (3) hide show
  1. package/README.md +120 -0
  2. package/carmoji.js +475 -0
  3. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # CarMoji Bridge
2
+
3
+ Forwards coding events from **Claude Code** and **Codex** on your computer
4
+ to the CarMoji pet on your phone, over a WebSocket on the same Wi-Fi.
5
+ The pet wakes up when a session starts, "types" while the agent edits,
6
+ gets nervous while tests run, celebrates finished turns, and pleads with
7
+ big puppy eyes when the agent is waiting for your approval.
8
+
9
+ ## Setup (once)
10
+
11
+ 1. On the phone: open CarMoji, tap the laptop icon (top-left), turn on
12
+ **Listen on this Wi-Fi**, and note the 4-digit pairing code.
13
+ 2. On your computer (Node.js ≥ 18):
14
+
15
+ ```sh
16
+ npx carmoji pair <code>
17
+ ```
18
+
19
+ The phone is discovered automatically via Bonjour — no IP addresses.
20
+ Pairing sends a test event, so the pet celebrates when it works.
21
+
22
+ With several phones (or a simulator) on the network, `pair` lists what
23
+ it found and asks which one you mean. You can also skip discovery and
24
+ pair by the address shown on the Code Buddy sheet:
25
+
26
+ ```sh
27
+ npx carmoji pair <code> 192.168.1.23:8737
28
+ ```
29
+
30
+ (Working from a clone of this repo, `cd bridge && npm install &&
31
+ node carmoji.js …` does the same thing.)
32
+
33
+ `npx carmoji discover` lists every CarMoji it can see without
34
+ pairing — handy for checking connectivity. On macOS discovery goes
35
+ through the system's `dns-sd`, which handles Macs full of VPN tunnels
36
+ and virtual interfaces; other platforms use a JS mDNS fallback.
37
+
38
+ ## Claude Code
39
+
40
+ Coding in *this* repo works out of the box — the hooks ship in
41
+ [.claude/settings.json](../.claude/settings.json). To get reactions in
42
+ every project, print the snippet and merge it into `~/.claude/settings.json`:
43
+
44
+ ```sh
45
+ npx carmoji claude-config
46
+ ```
47
+
48
+ ## Codex
49
+
50
+ ```sh
51
+ npx carmoji codex-config
52
+ ```
53
+
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)).
58
+
59
+ ## Try it without a coding agent
60
+
61
+ ```sh
62
+ npx carmoji test start # wakes up, excited
63
+ npx carmoji test edit # focused typing face
64
+ npx carmoji test run # nervous side-eye
65
+ npx carmoji test permission # pleading + floating "?"
66
+ npx carmoji test done # celebration with hearts
67
+ npx carmoji test error # dizzy collapse
68
+ npx carmoji test end # contented sigh
69
+ ```
70
+
71
+ ## Multiple agents & token speed
72
+
73
+ - Every message carries the Claude Code `session_id` (or Codex conversation
74
+ id), so several agents can run at once. The pet plays the latest event,
75
+ but if one agent is still blocked waiting for your approval, it returns
76
+ to the pleading face after another agent's reaction — and stops pleading
77
+ as soon as the blocked session moves again.
78
+ - The bridge counts each session's cumulative output tokens by tailing the
79
+ transcript incrementally (per-session byte offsets in
80
+ `~/.config/carmoji/sessions/`). While in buddy mode, the dashboard's
81
+ speed readout switches from km/h to **tok/s** — combined across all
82
+ agents — with the active agent count below it.
83
+
84
+ ## What the phone shows
85
+
86
+ In buddy mode the dashboard swaps the car for a little robot sprite that
87
+ scans while agents work, goes wide-eyed with a "?" when one waits for your
88
+ approval, and dozes when everyone's idle — with a dot per connected agent
89
+ and the active project's name underneath. The laptop button gets a green
90
+ dot while agents are live, and GPS pauses to save battery. Prompt snippets
91
+ and permission messages (first ~140 chars) are sent to the phone as brief
92
+ toasts; they stay on your LAN and are only accepted by the paired device.
93
+
94
+ ## Answer permissions from the phone (Claude Code, opt-in)
95
+
96
+ ```sh
97
+ npx carmoji gate-config
98
+ ```
99
+
100
+ prints a `PreToolUse` hook snippet with a tool matcher (e.g. `Bash`). With
101
+ it installed, whenever Claude wants to run a matched tool the phone chimes
102
+ (a doorbell sound used by nothing else), shows big pleading eyes, and pops
103
+ an **Allow / Deny** card with the command text — your tap settles the
104
+ permission on the computer via the hook's official decision output. No
105
+ answer within ~55 s falls through to the normal terminal prompt.
106
+
107
+ Honest limits: this decides *tool permissions* only — hooks expose no way
108
+ to answer AskUserQuestion option pickers — and Codex's notify surface has
109
+ no decision channel at all, so it stays display-only.
110
+
111
+ ## How it works
112
+
113
+ - The app runs an `NWListener` WebSocket server on port 8737, advertised
114
+ via Bonjour as `_carmoji._tcp`. LAN only; every frame must carry the
115
+ pairing code.
116
+ - `carmoji.js hook` is invoked by Claude Code's hooks with the event JSON
117
+ on stdin, maps it to a wire event, sends one frame (≤0.9 s budget), and
118
+ always exits 0 — it can never slow down or break a coding session. When
119
+ unpaired it exits immediately.
120
+ - Pairing state lives in `~/.config/carmoji/config.json`.
package/carmoji.js ADDED
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env node
2
+ // CarMoji bridge: forwards coding events from Claude Code / Codex hooks to
3
+ // the CarMoji app over a LAN WebSocket. The phone advertises itself via
4
+ // Bonjour as `_carmoji._tcp`; `pair` discovers it once and caches the
5
+ // address + pairing code in ~/.config/carmoji/config.json.
6
+ //
7
+ // Commands:
8
+ // pair <code> discover the phone on this Wi-Fi and pair with it
9
+ // hook Claude Code hook entry point (reads hook JSON on stdin)
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
12
+ // claude-config print the .claude/settings.json hooks snippet
13
+ // codex-config print the ~/.codex/config.toml notify line
14
+
15
+ import {
16
+ readFileSync, writeFileSync, mkdirSync,
17
+ statSync, openSync, readSync, closeSync,
18
+ } from 'node:fs';
19
+ import { spawn } from 'node:child_process';
20
+ import { homedir } from 'node:os';
21
+ import { join, dirname, basename } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const CONFIG_PATH = join(homedir(), '.config', 'carmoji', 'config.json');
25
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
26
+ // Installed through npm/npx the script lives in an ephemeral cache, so
27
+ // printed configs must go through `npx` rather than an absolute path.
28
+ const RUNNING_FROM_PACKAGE = SCRIPT_PATH.includes('node_modules')
29
+ || SCRIPT_PATH.includes('_npx');
30
+ const HOOK_COMMAND = RUNNING_FROM_PACKAGE
31
+ ? 'npx -y carmoji hook'
32
+ : `node "${SCRIPT_PATH}" hook`;
33
+ // Never make the user's coding session wait on us.
34
+ const SEND_TIMEOUT_MS = 900;
35
+ const DISCOVER_TIMEOUT_MS = 10_000;
36
+
37
+ function loadConfig() {
38
+ try {
39
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function saveConfig(config) {
46
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
47
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
48
+ }
49
+
50
+ function readStdin() {
51
+ try {
52
+ return readFileSync(0, 'utf8');
53
+ } catch {
54
+ return '';
55
+ }
56
+ }
57
+
58
+ /** Run dns-sd (which never exits on its own) for a bounded time. */
59
+ function runDnssd(args, ms) {
60
+ return new Promise((resolve) => {
61
+ const child = spawn('dns-sd', args);
62
+ let output = '';
63
+ child.stdout.on('data', (data) => { output += data; });
64
+ child.on('error', () => resolve(''));
65
+ setTimeout(() => {
66
+ child.kill();
67
+ resolve(output);
68
+ }, ms);
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Discovery via macOS's own mDNSResponder. The pure-JS fallback binds
74
+ * multicast to one interface and goes blind on Macs with VPN tunnels and
75
+ * virtual adapters; dns-sd browses every interface correctly.
76
+ */
77
+ async function discoverViaDnssd() {
78
+ const browse = await runDnssd(['-B', '_carmoji._tcp', 'local.'], 2500);
79
+ const names = new Set();
80
+ for (const line of browse.split('\n')) {
81
+ const match = line.match(/\sAdd\s+\d+\s+\d+\s+local\.\s+_carmoji\._tcp\.\s+(.+)$/);
82
+ if (match) names.add(match[1].trim());
83
+ }
84
+ const devices = [];
85
+ for (const name of names) {
86
+ const resolved = await runDnssd(['-L', name, '_carmoji._tcp', 'local.'], 2500);
87
+ const reach = resolved.match(/can be reached at\s+(\S+?):(\d+)/);
88
+ if (!reach) continue;
89
+ const lookup = await runDnssd(['-G', 'v4', reach[1]], 2500);
90
+ const address = lookup.match(/\sAdd\s.*?\s(\d+\.\d+\.\d+\.\d+)\s/);
91
+ if (address) devices.push({ host: address[1], port: Number(reach[2]), name });
92
+ }
93
+ return devices;
94
+ }
95
+
96
+ /**
97
+ * Find every CarMoji app on this network. On macOS ask mDNSResponder via
98
+ * dns-sd first; fall back to the JS mDNS implementation elsewhere (or if
99
+ * dns-sd came up empty).
100
+ */
101
+ async function discoverAll() {
102
+ if (process.platform === 'darwin') {
103
+ const devices = await discoverViaDnssd();
104
+ if (devices.length > 0) return devices;
105
+ }
106
+ return discoverViaMulticastJS();
107
+ }
108
+
109
+ async function discoverViaMulticastJS() {
110
+ const { Bonjour } = await import('bonjour-service');
111
+ const bonjour = new Bonjour();
112
+ const found = new Map();
113
+ return new Promise((resolve) => {
114
+ const deadline = setTimeout(finish, DISCOVER_TIMEOUT_MS);
115
+ let grace = null;
116
+ const browser = bonjour.find({ type: 'carmoji' }, (service) => {
117
+ const host = (service.addresses || []).find((a) => a.includes('.'));
118
+ if (!host) return;
119
+ found.set(`${host}:${service.port}`,
120
+ { host, port: service.port, name: service.name });
121
+ grace ??= setTimeout(finish, 1500);
122
+ });
123
+ function finish() {
124
+ clearTimeout(deadline);
125
+ clearTimeout(grace);
126
+ browser.stop();
127
+ bonjour.destroy();
128
+ resolve([...found.values()]);
129
+ }
130
+ });
131
+ }
132
+
133
+ /** Let the user pick between several discovered phones. */
134
+ async function chooseDevice(devices) {
135
+ console.log('Multiple CarMoji devices found:');
136
+ devices.forEach((d, i) => console.log(` ${i + 1}. ${d.name} — ${d.host}:${d.port}`));
137
+ if (!process.stdin.isTTY) {
138
+ console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
139
+ process.exit(1);
140
+ }
141
+ const { createInterface } = await import('node:readline/promises');
142
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
143
+ const answer = await rl.question(`Pair with which one? [1-${devices.length}] `);
144
+ rl.close();
145
+ const device = devices[Number(answer) - 1];
146
+ if (!device) {
147
+ console.error('Invalid choice.');
148
+ process.exit(1);
149
+ }
150
+ return device;
151
+ }
152
+
153
+ /**
154
+ * Send one event frame and wait briefly for the ack.
155
+ * Returns "ok", an error string from the phone, or "unreachable".
156
+ */
157
+ async function send(config, message) {
158
+ const { default: WebSocket } = await import('ws');
159
+ return new Promise((resolve) => {
160
+ const ws = new WebSocket(`ws://${config.host}:${config.port}`, {
161
+ handshakeTimeout: SEND_TIMEOUT_MS,
162
+ });
163
+ const timer = setTimeout(() => finish('unreachable'), SEND_TIMEOUT_MS);
164
+ ws.on('open', () => {
165
+ ws.send(JSON.stringify({ v: 1, code: config.code, ...message }));
166
+ });
167
+ ws.on('message', (data) => {
168
+ try {
169
+ const reply = JSON.parse(data.toString());
170
+ finish(reply.ok ? 'ok' : reply.error || 'rejected');
171
+ } catch {
172
+ finish('ok');
173
+ }
174
+ });
175
+ ws.on('error', () => finish('unreachable'));
176
+ function finish(result) {
177
+ clearTimeout(timer);
178
+ ws.terminate();
179
+ resolve(result);
180
+ }
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Send an approval_request and hold the connection open until the phone
186
+ * answers (or the timeout passes). Resolves "allow", "deny", or null.
187
+ */
188
+ async function requestApproval(config, message, timeoutMs = 55_000) {
189
+ const { default: WebSocket } = await import('ws');
190
+ return new Promise((resolve) => {
191
+ const ws = new WebSocket(`ws://${config.host}:${config.port}`, {
192
+ handshakeTimeout: SEND_TIMEOUT_MS,
193
+ });
194
+ const timer = setTimeout(() => finish(null), timeoutMs);
195
+ ws.on('open', () => {
196
+ ws.send(JSON.stringify({ v: 1, code: config.code, ...message }));
197
+ });
198
+ ws.on('message', (data) => {
199
+ try {
200
+ const reply = JSON.parse(data.toString());
201
+ if (reply.ok === false) return finish(null);
202
+ if (reply.decision === 'allow' || reply.decision === 'deny') {
203
+ finish(reply.decision);
204
+ }
205
+ // A plain ack means the card is up — keep waiting for the tap.
206
+ } catch { /* ignore unparsable frames */ }
207
+ });
208
+ ws.on('error', () => finish(null));
209
+ ws.on('close', () => finish(null));
210
+ function finish(result) {
211
+ clearTimeout(timer);
212
+ ws.terminate();
213
+ resolve(result);
214
+ }
215
+ });
216
+ }
217
+
218
+ function summarizeToolInput(payload) {
219
+ const input = payload.tool_input || {};
220
+ const text = input.command || input.file_path || input.description
221
+ || JSON.stringify(input);
222
+ return String(text).replace(/\s+/g, ' ').slice(0, 140);
223
+ }
224
+
225
+ /** Map a Claude Code hook payload to a wire event, or null to ignore. */
226
+ function eventFromClaudeHook(payload) {
227
+ switch (payload.hook_event_name) {
228
+ case 'SessionStart': return { event: 'session_start' };
229
+ case 'UserPromptSubmit': return { event: 'prompt' };
230
+ case 'PreToolUse': return { event: 'tool_start', tool: payload.tool_name };
231
+ // tool_end doesn't animate the face; it's a session heartbeat and a
232
+ // fresh token sample for the tok/s readout.
233
+ case 'PostToolUse': return { event: 'tool_end', tool: payload.tool_name };
234
+ case 'Notification': return { event: 'permission' };
235
+ case 'Stop': return { event: 'turn_done' };
236
+ case 'SessionEnd': return { event: 'session_end' };
237
+ default: return null;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Cumulative output tokens for this session, counted incrementally from the
243
+ * transcript JSONL: a per-session byte offset is cached so each hook only
244
+ * parses lines appended since the previous one.
245
+ */
246
+ function sessionTokens(payload) {
247
+ const transcript = payload.transcript_path;
248
+ const session = payload.session_id;
249
+ if (!transcript || !session) return undefined;
250
+ const statePath = join(dirname(CONFIG_PATH), 'sessions', `${session}.json`);
251
+ let state = { offset: 0, total: 0 };
252
+ try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
253
+ let size;
254
+ try { size = statSync(transcript).size; } catch { return undefined; }
255
+ if (size < state.offset) state = { offset: 0, total: 0 }; // truncated/rotated
256
+ if (size > state.offset) {
257
+ const fd = openSync(transcript, 'r');
258
+ const buffer = Buffer.alloc(size - state.offset);
259
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, state.offset);
260
+ closeSync(fd);
261
+ const chunk = buffer.subarray(0, bytesRead);
262
+ // Only consume complete lines; a partially-written last line is left
263
+ // for the next hook invocation.
264
+ const lastNewline = chunk.lastIndexOf(0x0a);
265
+ if (lastNewline >= 0) {
266
+ for (const line of chunk.subarray(0, lastNewline + 1).toString('utf8').split('\n')) {
267
+ if (!line.includes('"usage"')) continue;
268
+ try {
269
+ const usage = JSON.parse(line)?.message?.usage;
270
+ if (usage?.output_tokens) state.total += usage.output_tokens;
271
+ } catch { /* ignore malformed lines */ }
272
+ }
273
+ state.offset += lastNewline + 1;
274
+ mkdirSync(dirname(statePath), { recursive: true });
275
+ writeFileSync(statePath, JSON.stringify(state));
276
+ }
277
+ }
278
+ return state.total;
279
+ }
280
+
281
+ const TEST_EVENTS = {
282
+ start: { event: 'session_start' },
283
+ prompt: { event: 'prompt' },
284
+ edit: { event: 'tool_start', tool: 'Edit' },
285
+ run: { event: 'tool_start', tool: 'Bash' },
286
+ heartbeat: { event: 'tool_end', tool: 'Edit' },
287
+ done: { event: 'turn_done' },
288
+ error: { event: 'error' },
289
+ permission: { event: 'permission' },
290
+ end: { event: 'session_end' },
291
+ };
292
+
293
+ function claudeConfigSnippet() {
294
+ const entry = [{ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }] }];
295
+ const hooks = {};
296
+ for (const name of ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
297
+ 'PostToolUse', 'Notification', 'Stop', 'SessionEnd']) {
298
+ hooks[name] = entry;
299
+ }
300
+ return JSON.stringify({ hooks }, null, 2);
301
+ }
302
+
303
+ async function main() {
304
+ const [command, arg, ...rest] = process.argv.slice(2);
305
+
306
+ switch (command) {
307
+ case 'pair': {
308
+ if (!/^\d{4}$/.test(arg || '')) {
309
+ console.error('Usage: carmoji pair <4-digit code from the app’s Code Buddy sheet> [ip[:port]]');
310
+ process.exit(1);
311
+ }
312
+ let target;
313
+ const manual = rest[0];
314
+ if (manual) {
315
+ // Manual addressing for networks with several phones (the app's
316
+ // Code Buddy sheet shows the address) or when mDNS is blocked.
317
+ const [host, port] = manual.split(':');
318
+ target = { host, port: Number(port) || 8737, name: host };
319
+ } else {
320
+ console.log('Looking for CarMoji on this network…');
321
+ const devices = await discoverAll();
322
+ if (devices.length === 0) {
323
+ console.error('No CarMoji found. Is the app open, "Listen on this Wi-Fi" on, and the phone on the same network?');
324
+ console.error('You can also pair by address: carmoji pair <code> <ip[:port]>');
325
+ process.exit(1);
326
+ }
327
+ target = devices.length === 1 ? devices[0] : await chooseDevice(devices);
328
+ }
329
+ const config = { host: target.host, port: target.port, code: arg };
330
+ const result = await send(config, { source: 'bridge', event: 'turn_done' });
331
+ if (result !== 'ok') {
332
+ console.error(result === 'bad-code'
333
+ ? `"${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]>)`
334
+ : `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
335
+ process.exit(1);
336
+ }
337
+ saveConfig(config);
338
+ console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
339
+ break;
340
+ }
341
+
342
+ case 'hook': {
343
+ // Runs inside Claude Code on every hook — must be silent, quick, and
344
+ // always exit 0 so it can never disturb the session.
345
+ try {
346
+ const config = loadConfig();
347
+ if (!config) break;
348
+ const payload = JSON.parse(readStdin() || '{}');
349
+
350
+ // Gate mode (opt-in, see `gate-config`): PreToolUse blocks while
351
+ // the phone shows Allow/Deny; the printed JSON settles the
352
+ // permission. Timeout falls through to the normal terminal prompt.
353
+ if (arg === '--gate' && payload.hook_event_name === 'PreToolUse') {
354
+ const decision = await requestApproval(config, {
355
+ source: 'claude',
356
+ session: payload.session_id,
357
+ event: 'approval_request',
358
+ tool: payload.tool_name,
359
+ detail: summarizeToolInput(payload),
360
+ project: payload.cwd ? basename(payload.cwd) : undefined,
361
+ });
362
+ if (decision) {
363
+ console.log(JSON.stringify({
364
+ hookSpecificOutput: {
365
+ hookEventName: 'PreToolUse',
366
+ permissionDecision: decision,
367
+ permissionDecisionReason: decision === 'allow'
368
+ ? 'Approved from CarMoji'
369
+ : 'Denied from CarMoji',
370
+ },
371
+ }));
372
+ }
373
+ break;
374
+ }
375
+
376
+ const event = eventFromClaudeHook(payload);
377
+ if (!event) break;
378
+ const message = { source: 'claude', session: payload.session_id, ...event };
379
+ const tokens = sessionTokens(payload);
380
+ if (tokens !== undefined) message.tokens = tokens;
381
+ if (payload.cwd) message.project = basename(payload.cwd);
382
+ // A snippet of human-readable context for the phone to show.
383
+ const detail = payload.hook_event_name === 'UserPromptSubmit' ? payload.prompt
384
+ : payload.hook_event_name === 'Notification' ? payload.message
385
+ : undefined;
386
+ if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
387
+ await send(config, message);
388
+ } catch {
389
+ // Never surface errors into the coding session.
390
+ }
391
+ break;
392
+ }
393
+
394
+ case 'codex-notify': {
395
+ try {
396
+ const config = loadConfig();
397
+ if (!config) break;
398
+ const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
399
+ if (payload.type === 'agent-turn-complete') {
400
+ const session = payload['conversation-id'] || payload.conversation_id;
401
+ const message = { source: 'codex', session, event: 'turn_done' };
402
+ if (payload.cwd) message.project = basename(payload.cwd);
403
+ await send(config, message);
404
+ }
405
+ } catch {
406
+ // Same rule as `hook`: stay silent.
407
+ }
408
+ break;
409
+ }
410
+
411
+ case 'test': {
412
+ const event = TEST_EVENTS[arg];
413
+ if (!event) {
414
+ console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
415
+ process.exit(1);
416
+ }
417
+ const config = loadConfig();
418
+ if (!config) {
419
+ console.error('Not paired yet — run: carmoji pair <code>');
420
+ process.exit(1);
421
+ }
422
+ const [session, tokens, project] = rest;
423
+ const message = { source: 'test', session, ...event };
424
+ if (tokens !== undefined) message.tokens = Number(tokens);
425
+ if (project) message.project = project;
426
+ const result = await send(config, message);
427
+ console.log(result === 'ok' ? 'Sent!' : `Failed: ${result}`);
428
+ process.exit(result === 'ok' ? 0 : 1);
429
+ break;
430
+ }
431
+
432
+ case 'discover': {
433
+ console.log('Looking for CarMoji devices on this network…');
434
+ const devices = await discoverAll();
435
+ if (devices.length === 0) {
436
+ console.log('None found. Check the app is open with "Listen on this Wi-Fi" on; you can always pair by address.');
437
+ process.exit(1);
438
+ }
439
+ devices.forEach((d) => console.log(` ${d.name} — ${d.host}:${d.port}`));
440
+ break;
441
+ }
442
+
443
+ case 'claude-config':
444
+ console.log('Merge this into the project’s .claude/settings.json (or ~/.claude/settings.json for everywhere):\n');
445
+ console.log(claudeConfigSnippet());
446
+ break;
447
+
448
+ case 'codex-config':
449
+ console.log('Add this line to ~/.codex/config.toml:\n');
450
+ console.log(RUNNING_FROM_PACKAGE
451
+ ? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
452
+ : `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`);
453
+ break;
454
+
455
+ case 'gate-config':
456
+ console.log('Opt-in: answer Claude Code permission prompts from the phone.');
457
+ console.log('Merge into .claude/settings.json, adjusting the matcher to the tools you want gated:\n');
458
+ console.log(JSON.stringify({
459
+ hooks: {
460
+ PreToolUse: [{
461
+ matcher: 'Bash',
462
+ hooks: [{ type: 'command', command: `${HOOK_COMMAND} --gate`, timeout: 60 }],
463
+ }],
464
+ },
465
+ }, null, 2));
466
+ console.log('\nNotes: allow/deny from the phone settles the permission;');
467
+ console.log('no answer within ~55s falls through to the terminal prompt as usual.');
468
+ break;
469
+
470
+ default:
471
+ console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | test <event> | hook [--gate] | codex-notify | claude-config | codex-config | gate-config');
472
+ }
473
+ }
474
+
475
+ main().then(() => process.exit(process.exitCode ?? 0));
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "carmoji",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "carmoji": "./carmoji.js"
8
+ },
9
+ "files": [
10
+ "carmoji.js",
11
+ "README.md"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/qiudaomao/CarMoji.git",
16
+ "directory": "bridge"
17
+ },
18
+ "keywords": [
19
+ "claude-code",
20
+ "codex",
21
+ "desk-pet",
22
+ "ios",
23
+ "websocket",
24
+ "bonjour",
25
+ "hooks"
26
+ ],
27
+ "author": "Qiu Daomao",
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "bonjour-service": "^1.2.1",
34
+ "ws": "^8.18.0"
35
+ }
36
+ }