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.
- package/README.md +17 -8
- package/carmoji.js +150 -27
- 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` |
|
|
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
|
|
116
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
163
|
-
|
|
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: '
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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`
|
|
680
|
-
*
|
|
681
|
-
* turn_done events.
|
|
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', '
|
|
687
|
-
'
|
|
778
|
+
events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
|
|
779
|
+
'PermissionRequest', 'PostToolUse', 'Stop'],
|
|
688
780
|
});
|
|
689
781
|
const configPath = join(codexHome(), 'config.toml');
|
|
690
|
-
if (!existsSync(configPath))
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
907
|
-
//
|
|
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
|
-
|
|
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.
|
|
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"
|