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.
- package/README.md +23 -8
- package/carmoji.js +294 -47
- 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` |
|
|
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
|
|
116
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
163
|
-
|
|
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
|
|
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: '
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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`
|
|
680
|
-
*
|
|
681
|
-
* turn_done events.
|
|
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', '
|
|
687
|
-
'
|
|
833
|
+
events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
|
|
834
|
+
'PermissionRequest', 'PostToolUse', 'Stop'],
|
|
688
835
|
});
|
|
689
836
|
const configPath = join(codexHome(), 'config.toml');
|
|
690
|
-
if (!existsSync(configPath))
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
845
|
-
|
|
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
|
-
|
|
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
|
|
864
|
-
if (
|
|
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
|
|
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
|
|
907
|
-
//
|
|
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
|
|
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
|
|
939
|
-
if (
|
|
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
|
|
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
|
|
960
|
-
if (
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|