carmoji 0.3.4 → 0.3.6
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 +31 -21
- package/carmoji.js +430 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,18 +46,18 @@ approval.
|
|
|
46
46
|
|
|
47
47
|
## Claude Code
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
[.claude/settings.json](../.claude/settings.json). To get reactions in
|
|
51
|
-
every project:
|
|
49
|
+
To get reactions in every project:
|
|
52
50
|
|
|
53
51
|
```sh
|
|
54
|
-
npx carmoji install
|
|
52
|
+
npx carmoji install claude
|
|
55
53
|
```
|
|
56
54
|
|
|
57
55
|
merges the hooks into `~/.claude/settings.json`, preserving everything
|
|
58
56
|
already there (a backup is saved to `settings.json.bak` first, reruns are
|
|
59
57
|
no-ops, and a file that doesn't parse is refused untouched). Prefer to do
|
|
60
|
-
it by hand? `npx carmoji claude
|
|
58
|
+
it by hand? `npx carmoji config claude` prints the snippet instead. Every
|
|
59
|
+
command answers `--help`; `npx carmoji hook --help` documents the JSON the
|
|
60
|
+
hook accepts, and `npx carmoji uninstall <tool>|all` takes it all out again.
|
|
61
61
|
|
|
62
62
|
## All the other tools
|
|
63
63
|
|
|
@@ -111,6 +111,7 @@ npx carmoji test start # wakes up, excited
|
|
|
111
111
|
npx carmoji test edit # focused typing face
|
|
112
112
|
npx carmoji test run # nervous side-eye
|
|
113
113
|
npx carmoji test permission # pleading + floating "?"
|
|
114
|
+
npx carmoji test approval # Allow/Deny card; waits and prints your tap
|
|
114
115
|
npx carmoji test done # celebration with hearts
|
|
115
116
|
npx carmoji test error # dizzy collapse
|
|
116
117
|
npx carmoji test end # contented sigh
|
|
@@ -152,22 +153,31 @@ cache-write tokens; cache reads excluded). It's your real consumption, not
|
|
|
152
153
|
an official percentage of plan quota — there's no public API for that.
|
|
153
154
|
Recomputed at turn boundaries with a 5-minute cache so hooks stay fast.
|
|
154
155
|
|
|
155
|
-
## Answer permissions from the phone (Claude Code
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
156
|
+
## Answer permissions from the phone (Claude Code)
|
|
157
|
+
|
|
158
|
+
`carmoji install claude` hooks Claude Code's `PermissionRequest` event,
|
|
159
|
+
which fires **only when a permission dialog is about to appear** —
|
|
160
|
+
allowlisted tools and auto-accepted edits never trigger it, so nothing
|
|
161
|
+
ever slows down a call that would have run anyway. When it fires, the
|
|
162
|
+
phone chimes (a doorbell sound used by nothing else), shows big pleading
|
|
163
|
+
eyes, and pops an **Allow / Deny** card with the command text — your tap
|
|
164
|
+
settles the prompt on the computer via the hook's official decision
|
|
165
|
+
output. No answer within ~55 s falls through to the normal terminal
|
|
166
|
+
prompt (`npx carmoji test approval` exercises the whole loop).
|
|
167
|
+
|
|
168
|
+
Simple **AskUserQuestion** pickers ride the same hook: one single-select
|
|
169
|
+
question with 2–4 labeled options becomes a tappable option card on the
|
|
170
|
+
phone (`npx carmoji test question` demos it), and your pick is fed back
|
|
171
|
+
as the answer. Richer questions — several at once, multi-select, free
|
|
172
|
+
text — still show as a pleading toast and are answered in the terminal.
|
|
173
|
+
|
|
174
|
+
Honest limits: the Codex integration currently treats `PermissionRequest`
|
|
175
|
+
as display-only (answering from the phone is planned next).
|
|
176
|
+
|
|
177
|
+
Older Claude Code versions without the `PermissionRequest` hook can use
|
|
178
|
+
`npx carmoji config gate` instead: a legacy `PreToolUse` gate with a tool
|
|
179
|
+
matcher (e.g. `Bash`). Unlike `PermissionRequest`, that variant fires on
|
|
180
|
+
*every* matched call, allowlisted or not, so keep its matcher narrow.
|
|
171
181
|
|
|
172
182
|
## How it works
|
|
173
183
|
|
package/carmoji.js
CHANGED
|
@@ -9,15 +9,18 @@
|
|
|
9
9
|
// whose Code Buddy sheet shows <code>
|
|
10
10
|
// (pairing is additive — events fan out to every device)
|
|
11
11
|
// unpair [device] forget a paired device by name or ip[:port], or `all`
|
|
12
|
-
// install <tool
|
|
13
|
-
// uninstall <tool
|
|
12
|
+
// install <tool>|all install hooks for a tool (or every detected one)
|
|
13
|
+
// uninstall <tool>|all remove CarMoji hooks from a tool's config
|
|
14
14
|
// tools list supported tools and where their hooks live
|
|
15
15
|
// hook hook entry point, all tools (reads hook JSON on stdin;
|
|
16
16
|
// --source <tool>, --event <name>, --gate)
|
|
17
17
|
// codex-notify legacy Codex notify entry point (event JSON as last arg)
|
|
18
|
-
// test <name> send a test event
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// test <name> send a test event; `test approval` pops the Allow/Deny card
|
|
19
|
+
// config <what> print a manual config snippet: claude | codex | gate
|
|
20
|
+
//
|
|
21
|
+
// Every command answers --help; `carmoji hook --help` documents the stdin
|
|
22
|
+
// JSON it accepts. Old command spellings (install-claude, claude-config, …)
|
|
23
|
+
// keep working as hidden aliases because shipped app versions print them.
|
|
21
24
|
|
|
22
25
|
import {
|
|
23
26
|
readFileSync, writeFileSync, mkdirSync,
|
|
@@ -219,16 +222,21 @@ async function send(config, message) {
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
/**
|
|
222
|
-
* Send
|
|
223
|
-
* answers (or the timeout passes). Resolves
|
|
225
|
+
* Send a blocking card and hold the connection open until the phone
|
|
226
|
+
* answers (or the timeout passes). Resolves the phone's reply frame —
|
|
227
|
+
* `{decision:"allow"|"deny"}` for approval cards, `{answer:"<label>"}`
|
|
228
|
+
* for question cards — or null. `requireCap` names a capability the
|
|
229
|
+
* phone's ack must advertise; an ack without it resolves null right
|
|
230
|
+
* away so older app builds fall back to the display-only path instead
|
|
231
|
+
* of blocking the agent for nothing.
|
|
224
232
|
*/
|
|
225
|
-
async function requestApproval(config, message, timeoutMs = 55_000) {
|
|
233
|
+
async function requestApproval(config, message, timeoutMs = 55_000, requireCap) {
|
|
226
234
|
const { default: WebSocket } = await import('ws');
|
|
227
235
|
return new Promise((resolve) => {
|
|
228
236
|
const ws = new WebSocket(`ws://${config.host}:${config.port}`, {
|
|
229
237
|
handshakeTimeout: SEND_TIMEOUT_MS,
|
|
230
238
|
});
|
|
231
|
-
|
|
239
|
+
let timer = setTimeout(() => finish(null), timeoutMs);
|
|
232
240
|
ws.on('open', () => {
|
|
233
241
|
ws.send(JSON.stringify({ v: 1, code: config.code, ...message }));
|
|
234
242
|
});
|
|
@@ -236,8 +244,20 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
|
|
|
236
244
|
try {
|
|
237
245
|
const reply = JSON.parse(data.toString());
|
|
238
246
|
if (reply.ok === false) return finish(null);
|
|
239
|
-
if (reply.decision === 'allow' || reply.decision === 'deny'
|
|
240
|
-
|
|
247
|
+
if (reply.decision === 'allow' || reply.decision === 'deny'
|
|
248
|
+
|| typeof reply.answer === 'string') {
|
|
249
|
+
return finish(reply);
|
|
250
|
+
}
|
|
251
|
+
if (reply.hold === true) {
|
|
252
|
+
// The human is typing a free-form answer — give them longer
|
|
253
|
+
// before falling through to the terminal.
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
timer = setTimeout(() => finish(null), HOLD_WAIT_MS);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (reply.ok === true && requireCap
|
|
259
|
+
&& !(Array.isArray(reply.caps) && reply.caps.includes(requireCap))) {
|
|
260
|
+
return finish(null);
|
|
241
261
|
}
|
|
242
262
|
// A plain ack means the card is up — keep waiting for the tap.
|
|
243
263
|
} catch { /* ignore unparsable frames */ }
|
|
@@ -259,26 +279,58 @@ function sendAll(devices, message) {
|
|
|
259
279
|
}
|
|
260
280
|
|
|
261
281
|
/**
|
|
262
|
-
* Show the approval card on every paired phone; the first tap
|
|
263
|
-
* wins. The other phones' cards drop when this process exits and
|
|
264
|
-
* sockets close. Resolves null when nobody answers in time.
|
|
282
|
+
* Show the approval/question card on every paired phone; the first tap
|
|
283
|
+
* anywhere wins. The other phones' cards drop when this process exits and
|
|
284
|
+
* their sockets close. Resolves null when nobody answers in time.
|
|
265
285
|
*/
|
|
266
|
-
function requestApprovalAll(devices, message, timeoutMs) {
|
|
286
|
+
function requestApprovalAll(devices, message, timeoutMs, requireCap) {
|
|
267
287
|
if (devices.length === 0) return Promise.resolve(null);
|
|
268
288
|
return new Promise((resolve) => {
|
|
269
289
|
let pending = devices.length;
|
|
270
290
|
for (const device of devices) {
|
|
271
|
-
requestApproval(device, message, timeoutMs).then((
|
|
291
|
+
requestApproval(device, message, timeoutMs, requireCap).then((reply) => {
|
|
272
292
|
pending -= 1;
|
|
273
|
-
if (
|
|
293
|
+
if (reply || pending === 0) resolve(reply);
|
|
274
294
|
});
|
|
275
295
|
}
|
|
276
296
|
});
|
|
277
297
|
}
|
|
278
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The one AskUserQuestion shape the phone can answer (MVP): a single
|
|
301
|
+
* single-select question whose 2–4 options all have labels. Anything
|
|
302
|
+
* richer — several questions, multiSelect, free-form — returns null and
|
|
303
|
+
* stays display-only, answered in the terminal.
|
|
304
|
+
*/
|
|
305
|
+
function answerableQuestion(input) {
|
|
306
|
+
const questions = input?.questions;
|
|
307
|
+
if (!Array.isArray(questions) || questions.length !== 1) return null;
|
|
308
|
+
const [entry] = questions;
|
|
309
|
+
if (!entry || entry.multiSelect === true) return null;
|
|
310
|
+
if (typeof entry.question !== 'string' || !entry.question.trim()) return null;
|
|
311
|
+
const raw = Array.isArray(entry.options) ? entry.options : [];
|
|
312
|
+
if (raw.length < 2 || raw.length > 4) return null;
|
|
313
|
+
const options = [];
|
|
314
|
+
for (const option of raw) {
|
|
315
|
+
const label = typeof option === 'string' ? option : option?.label;
|
|
316
|
+
if (typeof label !== 'string' || !label.trim()) return null;
|
|
317
|
+
const description = typeof option?.description === 'string'
|
|
318
|
+
? option.description.replace(/\s+/g, ' ').slice(0, 200)
|
|
319
|
+
: undefined;
|
|
320
|
+
options.push({ label, description });
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
question: entry.question,
|
|
324
|
+
header: typeof entry.header === 'string' && entry.header.trim()
|
|
325
|
+
? entry.header : undefined,
|
|
326
|
+
options,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
279
330
|
function summarizeToolInput(payload) {
|
|
280
331
|
const input = payload.tool_input || {};
|
|
281
332
|
const text = input.command || input.file_path || input.description
|
|
333
|
+
|| input.questions?.[0]?.question
|
|
282
334
|
|| JSON.stringify(input);
|
|
283
335
|
return String(text).replace(/\s+/g, ' ').slice(0, 140);
|
|
284
336
|
}
|
|
@@ -548,7 +600,23 @@ function backupFile(path) {
|
|
|
548
600
|
}
|
|
549
601
|
|
|
550
602
|
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
|
|
551
|
-
'
|
|
603
|
+
'PermissionRequest', 'PostToolUse', 'Notification',
|
|
604
|
+
'Stop', 'SessionEnd'];
|
|
605
|
+
|
|
606
|
+
// PermissionRequest fires only when a permission dialog is about to appear,
|
|
607
|
+
// and the dialog waits for the hook — so the phone gets a bounded window to
|
|
608
|
+
// answer, then silence falls through to the normal terminal prompt. Tapping
|
|
609
|
+
// "Other…" on a question card sends {hold:true}, stretching that window to
|
|
610
|
+
// HOLD_WAIT_MS so there's time to type. The installed hook timeout leaves
|
|
611
|
+
// headroom over the longest wait.
|
|
612
|
+
const APPROVAL_WAIT_MS = 55_000;
|
|
613
|
+
const HOLD_WAIT_MS = 120_000;
|
|
614
|
+
const PERMISSION_HOOK_TIMEOUT_S = 150;
|
|
615
|
+
|
|
616
|
+
/** Sources whose PermissionRequest hook accepts a decision back via
|
|
617
|
+
* `hookSpecificOutput.decision` (Claude Code's schema). Codex is the next
|
|
618
|
+
* candidate once its hooks are verified to honor the reply. */
|
|
619
|
+
const ANSWERABLE_PERMISSION_SOURCES = new Set(['claude']);
|
|
552
620
|
|
|
553
621
|
// ---------------------------------------------------------------------------
|
|
554
622
|
// Multi-tool hook installers (see EVENT_ALIASES for how their events map).
|
|
@@ -676,21 +744,25 @@ function installJsonHooks(key, tool) {
|
|
|
676
744
|
for (const event of tool.events ?? CLAUDE_HOOK_EVENTS) {
|
|
677
745
|
const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
678
746
|
const command = tool.eventFlag ? `${base} --event ${event}` : base;
|
|
747
|
+
// PermissionRequest blocks while the phone shows Allow/Deny; everything
|
|
748
|
+
// else must stay too quick to ever be felt in the session.
|
|
749
|
+
const seconds = timeout(event === 'PermissionRequest'
|
|
750
|
+
? PERMISSION_HOOK_TIMEOUT_S : 5);
|
|
679
751
|
switch (tool.format) {
|
|
680
752
|
case 'claude':
|
|
681
753
|
entries.push({ matcher: '*',
|
|
682
|
-
hooks: [{ type: 'command', command, timeout:
|
|
754
|
+
hooks: [{ type: 'command', command, timeout: seconds }] });
|
|
683
755
|
break;
|
|
684
756
|
case 'codex':
|
|
685
757
|
case 'nested':
|
|
686
|
-
entries.push({ hooks: [{ type: 'command', command, timeout:
|
|
758
|
+
entries.push({ hooks: [{ type: 'command', command, timeout: seconds }] });
|
|
687
759
|
break;
|
|
688
760
|
case 'flat':
|
|
689
761
|
entries.push({ command });
|
|
690
762
|
break;
|
|
691
763
|
case 'trae':
|
|
692
764
|
entries.push({ matcher: '*', loop_limit: 5,
|
|
693
|
-
hooks: [{ type: 'command', command, timeout:
|
|
765
|
+
hooks: [{ type: 'command', command, timeout: seconds }] });
|
|
694
766
|
break;
|
|
695
767
|
case 'copilot':
|
|
696
768
|
entries.push({ type: 'command', bash: command, timeoutSec: 5 });
|
|
@@ -883,14 +955,20 @@ function installTool(key, { skipMissing = false } = {}) {
|
|
|
883
955
|
installJsonHooks(key, tool);
|
|
884
956
|
}
|
|
885
957
|
|
|
886
|
-
function uninstallTool(key) {
|
|
958
|
+
function uninstallTool(key, { skipMissing = false } = {}) {
|
|
887
959
|
const tool = TOOLS[key];
|
|
888
960
|
if (!tool) {
|
|
889
961
|
console.error(`Unknown tool "${key}". Known: ${Object.keys(TOOLS).join(', ')}`);
|
|
890
962
|
process.exit(1);
|
|
891
963
|
}
|
|
964
|
+
// `uninstall all` sweeps every known tool; stay quiet about the ones that
|
|
965
|
+
// were never installed instead of printing 14 "nothing installed" lines.
|
|
966
|
+
const nothing = () => {
|
|
967
|
+
if (!skipMissing) console.log(`${tool.name}: nothing installed.`);
|
|
968
|
+
};
|
|
892
969
|
if (tool.format === 'cline') {
|
|
893
970
|
const dir = join(homedir(), tool.config);
|
|
971
|
+
if (!existsSync(dir)) { nothing(); return; }
|
|
894
972
|
for (const event of tool.events) {
|
|
895
973
|
const path = join(dir, event);
|
|
896
974
|
if (existsSync(path) && readFileSync(path, 'utf8').includes('carmoji')) {
|
|
@@ -902,15 +980,20 @@ function uninstallTool(key) {
|
|
|
902
980
|
}
|
|
903
981
|
if (tool.format === 'kimi') {
|
|
904
982
|
const path = join(homedir(), tool.config);
|
|
905
|
-
if (existsSync(path)) {
|
|
906
|
-
|
|
983
|
+
if (!existsSync(path) || !readFileSync(path, 'utf8').includes('carmoji')) {
|
|
984
|
+
nothing();
|
|
985
|
+
return;
|
|
907
986
|
}
|
|
987
|
+
writeConfig(path, removeKimiBlocks(readFileSync(path, 'utf8')) + '\n');
|
|
908
988
|
console.log(`Removed CarMoji hooks for ${tool.name}.`);
|
|
909
989
|
return;
|
|
910
990
|
}
|
|
911
991
|
const path = key === 'codex' ? join(codexHome(), 'hooks.json')
|
|
912
992
|
: join(homedir(), tool.config);
|
|
913
|
-
if (!existsSync(path)
|
|
993
|
+
if (!existsSync(path) || !readFileSync(path, 'utf8').includes('carmoji')) {
|
|
994
|
+
nothing();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
914
997
|
const root = readJsonConfig(path);
|
|
915
998
|
if (root.hooks && typeof root.hooks === 'object') {
|
|
916
999
|
for (const [event, value] of Object.entries(root.hooks)) {
|
|
@@ -967,11 +1050,10 @@ function installCodexNotify() {
|
|
|
967
1050
|
}
|
|
968
1051
|
|
|
969
1052
|
function claudeConfigSnippet() {
|
|
970
|
-
const entry = [{ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }] }];
|
|
971
1053
|
const hooks = {};
|
|
972
|
-
for (const name of
|
|
973
|
-
|
|
974
|
-
hooks[name] =
|
|
1054
|
+
for (const name of CLAUDE_HOOK_EVENTS) {
|
|
1055
|
+
const timeout = name === 'PermissionRequest' ? PERMISSION_HOOK_TIMEOUT_S : 5;
|
|
1056
|
+
hooks[name] = [{ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout }] }];
|
|
975
1057
|
}
|
|
976
1058
|
return JSON.stringify({ hooks }, null, 2);
|
|
977
1059
|
}
|
|
@@ -979,6 +1061,17 @@ function claudeConfigSnippet() {
|
|
|
979
1061
|
async function main() {
|
|
980
1062
|
const [command, arg, ...rest] = process.argv.slice(2);
|
|
981
1063
|
|
|
1064
|
+
// `carmoji help [command]`, `carmoji --help`, or `--help` anywhere.
|
|
1065
|
+
// Real agent hooks never pass --help, so this can't shadow the hook path.
|
|
1066
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
1067
|
+
printHelp(arg);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (arg === '--help' || rest.includes('--help')) {
|
|
1071
|
+
printHelp(command);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
982
1075
|
switch (command) {
|
|
983
1076
|
case 'pair': {
|
|
984
1077
|
if (!/^\d{4}$/.test(arg || '')) {
|
|
@@ -1100,7 +1193,11 @@ async function main() {
|
|
|
1100
1193
|
|
|
1101
1194
|
case 'hook': {
|
|
1102
1195
|
// Runs inside the coding agent on every hook — must be silent, quick,
|
|
1103
|
-
// and always exit 0 so it can never disturb the session.
|
|
1196
|
+
// and always exit 0 so it can never disturb the session. The two
|
|
1197
|
+
// deliberate exceptions hold the hook open while the phone answers:
|
|
1198
|
+
// PermissionRequest (fires only when a permission dialog was about to
|
|
1199
|
+
// block the session anyway) and the legacy --gate (blocks every
|
|
1200
|
+
// matched PreToolUse).
|
|
1104
1201
|
// Flags: --source <tool> (default claude), --event <name> for tools
|
|
1105
1202
|
// whose stdin lacks the event name, --gate for phone approvals.
|
|
1106
1203
|
try {
|
|
@@ -1119,14 +1216,14 @@ async function main() {
|
|
|
1119
1216
|
// the phone shows Allow/Deny; the printed JSON settles the
|
|
1120
1217
|
// permission. Timeout falls through to the normal terminal prompt.
|
|
1121
1218
|
if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
|
|
1122
|
-
const decision = await requestApprovalAll(devices, {
|
|
1219
|
+
const decision = (await requestApprovalAll(devices, {
|
|
1123
1220
|
source,
|
|
1124
1221
|
session: payload.session_id,
|
|
1125
1222
|
event: 'approval_request',
|
|
1126
1223
|
tool: payload.tool_name,
|
|
1127
1224
|
detail: summarizeToolInput(payload),
|
|
1128
1225
|
project: payload.cwd ? basename(payload.cwd) : undefined,
|
|
1129
|
-
});
|
|
1226
|
+
}))?.decision;
|
|
1130
1227
|
if (decision) {
|
|
1131
1228
|
console.log(JSON.stringify({
|
|
1132
1229
|
hookSpecificOutput: {
|
|
@@ -1141,6 +1238,79 @@ async function main() {
|
|
|
1141
1238
|
break;
|
|
1142
1239
|
}
|
|
1143
1240
|
|
|
1241
|
+
// PermissionRequest fires only when a permission dialog is about to
|
|
1242
|
+
// appear (allowlisted tools never trigger it), so blocking here —
|
|
1243
|
+
// unlike the PreToolUse gate — never delays a call that would have
|
|
1244
|
+
// run anyway. A phone tap settles the prompt; silence falls through
|
|
1245
|
+
// to the terminal dialog.
|
|
1246
|
+
const canonicalEvent = EVENT_ALIASES[payload.hook_event_name]
|
|
1247
|
+
?? payload.hook_event_name;
|
|
1248
|
+
if (canonicalEvent === 'PermissionRequest'
|
|
1249
|
+
&& ANSWERABLE_PERMISSION_SOURCES.has(source)) {
|
|
1250
|
+
const common = {
|
|
1251
|
+
source,
|
|
1252
|
+
session: payload.session_id,
|
|
1253
|
+
host: hostname(),
|
|
1254
|
+
project: payload.cwd ? basename(payload.cwd) : undefined,
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// AskUserQuestion rides the same hook: simple option pickers get
|
|
1258
|
+
// a tappable card; the tapped label is fed back through
|
|
1259
|
+
// updatedInput.answers, keyed by the QUESTION TEXT (Claude Code
|
|
1260
|
+
// looks answers up by it — keying by header yields empty answers)
|
|
1261
|
+
// with the original `questions` kept in place (Claude Code crashes
|
|
1262
|
+
// on its absence). Richer shapes fall through to display-only.
|
|
1263
|
+
if (payload.tool_name === 'AskUserQuestion') {
|
|
1264
|
+
const question = answerableQuestion(payload.tool_input);
|
|
1265
|
+
if (question) {
|
|
1266
|
+
const reply = await requestApprovalAll(devices, {
|
|
1267
|
+
...common,
|
|
1268
|
+
event: 'question_request',
|
|
1269
|
+
tool: payload.tool_name,
|
|
1270
|
+
detail: question.question.replace(/\s+/g, ' ').slice(0, 140),
|
|
1271
|
+
question,
|
|
1272
|
+
}, APPROVAL_WAIT_MS, 'question');
|
|
1273
|
+
// Either a tapped option label or free text typed behind the
|
|
1274
|
+
// card's "Other…" button — Claude Code takes any string, the
|
|
1275
|
+
// same way its own picker's Other row does.
|
|
1276
|
+
const answer = reply?.answer?.trim();
|
|
1277
|
+
if (answer) {
|
|
1278
|
+
console.log(JSON.stringify({
|
|
1279
|
+
hookSpecificOutput: {
|
|
1280
|
+
hookEventName: 'PermissionRequest',
|
|
1281
|
+
decision: {
|
|
1282
|
+
behavior: 'allow',
|
|
1283
|
+
updatedInput: {
|
|
1284
|
+
...payload.tool_input,
|
|
1285
|
+
answers: { [question.question]: answer },
|
|
1286
|
+
},
|
|
1287
|
+
},
|
|
1288
|
+
},
|
|
1289
|
+
}));
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
// No card, no tap, or an unknown label: fall through to the
|
|
1294
|
+
// pleading face + toast; the picker stays in the terminal.
|
|
1295
|
+
} else {
|
|
1296
|
+
const decision = (await requestApprovalAll(devices, {
|
|
1297
|
+
...common,
|
|
1298
|
+
event: 'approval_request',
|
|
1299
|
+
tool: payload.tool_name,
|
|
1300
|
+
detail: summarizeToolInput(payload),
|
|
1301
|
+
}, APPROVAL_WAIT_MS))?.decision;
|
|
1302
|
+
if (decision) {
|
|
1303
|
+
console.log(JSON.stringify({
|
|
1304
|
+
hookSpecificOutput: {
|
|
1305
|
+
hookEventName: 'PermissionRequest',
|
|
1306
|
+
decision: { behavior: decision },
|
|
1307
|
+
},
|
|
1308
|
+
}));
|
|
1309
|
+
}
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1144
1314
|
const event = eventFromHook(payload);
|
|
1145
1315
|
if (!event) break;
|
|
1146
1316
|
// host lets the phone attribute plan-window usage per machine and
|
|
@@ -1197,8 +1367,8 @@ async function main() {
|
|
|
1197
1367
|
|
|
1198
1368
|
case 'test': {
|
|
1199
1369
|
const event = TEST_EVENTS[arg];
|
|
1200
|
-
if (!event) {
|
|
1201
|
-
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
|
|
1370
|
+
if (!event && arg !== 'approval' && arg !== 'question') {
|
|
1371
|
+
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval|question> [session] [tokens] [project] — details: carmoji test --help`);
|
|
1202
1372
|
process.exit(1);
|
|
1203
1373
|
}
|
|
1204
1374
|
const devices = loadDevices();
|
|
@@ -1206,6 +1376,47 @@ async function main() {
|
|
|
1206
1376
|
console.error('Not paired yet — run: carmoji pair <code>');
|
|
1207
1377
|
process.exit(1);
|
|
1208
1378
|
}
|
|
1379
|
+
if (arg === 'approval') {
|
|
1380
|
+
// Exercises the blocking PermissionRequest flow end to end.
|
|
1381
|
+
console.log(`Approval card sent — tap Allow or Deny on the phone (${APPROVAL_WAIT_MS / 1000}s)…`);
|
|
1382
|
+
const decision = (await requestApprovalAll(devices, {
|
|
1383
|
+
source: 'test',
|
|
1384
|
+
session: rest[0],
|
|
1385
|
+
host: hostname(),
|
|
1386
|
+
event: 'approval_request',
|
|
1387
|
+
tool: 'Bash',
|
|
1388
|
+
detail: 'carmoji test approval — tap Allow or Deny',
|
|
1389
|
+
}, APPROVAL_WAIT_MS))?.decision;
|
|
1390
|
+
console.log(decision
|
|
1391
|
+
? `Decision: ${decision}`
|
|
1392
|
+
: 'No answer — in a real session Claude Code would fall through to the terminal prompt.');
|
|
1393
|
+
process.exit(decision ? 0 : 1);
|
|
1394
|
+
}
|
|
1395
|
+
if (arg === 'question') {
|
|
1396
|
+
// Exercises the blocking AskUserQuestion flow end to end.
|
|
1397
|
+
console.log(`Question card sent — tap an option on the phone (${APPROVAL_WAIT_MS / 1000}s)…`);
|
|
1398
|
+
const answer = (await requestApprovalAll(devices, {
|
|
1399
|
+
source: 'test',
|
|
1400
|
+
session: rest[0],
|
|
1401
|
+
host: hostname(),
|
|
1402
|
+
event: 'question_request',
|
|
1403
|
+
tool: 'AskUserQuestion',
|
|
1404
|
+
detail: 'Which flavor should the demo pick?',
|
|
1405
|
+
question: {
|
|
1406
|
+
question: 'Which flavor should the demo pick?',
|
|
1407
|
+
header: 'Flavor',
|
|
1408
|
+
options: [
|
|
1409
|
+
{ label: 'Vanilla', description: 'The safe default' },
|
|
1410
|
+
{ label: 'Matcha', description: 'A little adventurous' },
|
|
1411
|
+
{ label: 'Durian', description: 'You know what you did' },
|
|
1412
|
+
],
|
|
1413
|
+
},
|
|
1414
|
+
}, APPROVAL_WAIT_MS, 'question'))?.answer;
|
|
1415
|
+
console.log(answer
|
|
1416
|
+
? `Answer: ${answer}`
|
|
1417
|
+
: 'No answer — in a real session the picker would stay in the terminal.');
|
|
1418
|
+
process.exit(answer ? 0 : 1);
|
|
1419
|
+
}
|
|
1209
1420
|
const [session, tokens, project] = rest;
|
|
1210
1421
|
const message = { source: 'test', session, host: hostname(), ...event };
|
|
1211
1422
|
if (tokens !== undefined) message.tokens = Number(tokens);
|
|
@@ -1248,8 +1459,14 @@ async function main() {
|
|
|
1248
1459
|
}
|
|
1249
1460
|
|
|
1250
1461
|
case 'uninstall': {
|
|
1462
|
+
if (arg === 'all') {
|
|
1463
|
+
for (const key of Object.keys(TOOLS)) {
|
|
1464
|
+
uninstallTool(key, { skipMissing: true });
|
|
1465
|
+
}
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1251
1468
|
if (!arg) {
|
|
1252
|
-
console.log(`Usage: carmoji uninstall <tool
|
|
1469
|
+
console.log(`Usage: carmoji uninstall <tool>|all\nTools: ${Object.keys(TOOLS).join(', ')}`);
|
|
1253
1470
|
process.exit(1);
|
|
1254
1471
|
}
|
|
1255
1472
|
uninstallTool(arg);
|
|
@@ -1266,50 +1483,197 @@ async function main() {
|
|
|
1266
1483
|
break;
|
|
1267
1484
|
}
|
|
1268
1485
|
|
|
1269
|
-
case '
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
installCodexHooks();
|
|
1486
|
+
case 'config':
|
|
1487
|
+
if (arg === 'claude') printClaudeConfig();
|
|
1488
|
+
else if (arg === 'codex') printCodexConfig();
|
|
1489
|
+
else if (arg === 'gate') printGateConfig();
|
|
1490
|
+
else printHelp('config');
|
|
1275
1491
|
break;
|
|
1276
1492
|
|
|
1277
1493
|
case 'install-codex-notify':
|
|
1278
1494
|
installCodexNotify();
|
|
1279
1495
|
break;
|
|
1280
1496
|
|
|
1497
|
+
// Hidden back-compat aliases — the app's setup sheet and old READMEs
|
|
1498
|
+
// print these spellings, so they must keep working.
|
|
1499
|
+
case 'install-claude':
|
|
1500
|
+
installTool('claude');
|
|
1501
|
+
break;
|
|
1502
|
+
case 'install-codex':
|
|
1503
|
+
installTool('codex');
|
|
1504
|
+
break;
|
|
1281
1505
|
case 'claude-config':
|
|
1282
|
-
|
|
1283
|
-
console.log('(Or let `carmoji install-claude` do it for you, with a backup.)\n');
|
|
1284
|
-
console.log(claudeConfigSnippet());
|
|
1506
|
+
printClaudeConfig();
|
|
1285
1507
|
break;
|
|
1286
|
-
|
|
1287
1508
|
case 'codex-config':
|
|
1288
|
-
|
|
1289
|
-
console.log(' carmoji install codex\n');
|
|
1290
|
-
console.log('For older Codex versions, add this line to ~/.codex/config.toml:\n');
|
|
1291
|
-
console.log(RUNNING_FROM_PACKAGE
|
|
1292
|
-
? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
|
|
1293
|
-
: `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`);
|
|
1509
|
+
printCodexConfig();
|
|
1294
1510
|
break;
|
|
1295
|
-
|
|
1296
1511
|
case 'gate-config':
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1512
|
+
printGateConfig();
|
|
1513
|
+
break;
|
|
1514
|
+
|
|
1515
|
+
default:
|
|
1516
|
+
printHelp();
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function printClaudeConfig() {
|
|
1521
|
+
console.log('Merge this into the project’s .claude/settings.json (or ~/.claude/settings.json for everywhere):\n');
|
|
1522
|
+
console.log('(Or let `carmoji install claude` do it for you, with a backup.)\n');
|
|
1523
|
+
console.log(claudeConfigSnippet());
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function printCodexConfig() {
|
|
1527
|
+
console.log('Modern Codex reads hooks from $CODEX_HOME/hooks.json — prefer:');
|
|
1528
|
+
console.log(' carmoji install codex\n');
|
|
1529
|
+
console.log('For older Codex versions, add this line to ~/.codex/config.toml:\n');
|
|
1530
|
+
console.log(RUNNING_FROM_PACKAGE
|
|
1531
|
+
? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
|
|
1532
|
+
: `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function printGateConfig() {
|
|
1536
|
+
console.log('Legacy/advanced: `carmoji install claude` now installs a PermissionRequest');
|
|
1537
|
+
console.log('hook that answers real permission prompts from the phone by itself.');
|
|
1538
|
+
console.log('The PreToolUse gate below instead fires on EVERY matched tool call, even');
|
|
1539
|
+
console.log('allowlisted ones — useful only on older Claude Code versions without the');
|
|
1540
|
+
console.log('PermissionRequest hook, or to force-gate specific tools.');
|
|
1541
|
+
console.log('Merge into .claude/settings.json, adjusting the matcher to the tools you want gated:\n');
|
|
1542
|
+
console.log(JSON.stringify({
|
|
1543
|
+
hooks: {
|
|
1544
|
+
PreToolUse: [{
|
|
1545
|
+
matcher: 'Bash',
|
|
1546
|
+
hooks: [{ type: 'command', command: `${HOOK_COMMAND} --gate`, timeout: 60 }],
|
|
1547
|
+
}],
|
|
1548
|
+
},
|
|
1549
|
+
}, null, 2));
|
|
1550
|
+
console.log('\nNotes: allow/deny from the phone settles the permission;');
|
|
1551
|
+
console.log('no answer within ~55s falls through to the terminal prompt as usual.');
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const USAGE = `carmoji bridge — talk to the CarMoji app on your phone
|
|
1555
|
+
|
|
1556
|
+
pair <code> [ip[:port]] pair with a phone (code from its Code Buddy sheet)
|
|
1557
|
+
unpair [<name|ip[:port]>|all] forget paired devices
|
|
1558
|
+
discover list CarMoji devices on this Wi-Fi
|
|
1559
|
+
install <tool>|all install hooks (tools: ${Object.keys(TOOLS).join(', ')})
|
|
1560
|
+
uninstall <tool>|all remove CarMoji hooks from a tool's config
|
|
1561
|
+
tools show each tool's config path and whether it's present
|
|
1562
|
+
test <event> send a fake event; \`test approval\` pops the Allow/Deny card
|
|
1563
|
+
hook entry point the agents' hooks invoke (reads JSON on stdin)
|
|
1564
|
+
config <claude|codex|gate> print a manual config snippet
|
|
1565
|
+
codex-notify legacy Codex notify entry point
|
|
1566
|
+
|
|
1567
|
+
Run a command with --help for details — \`carmoji hook --help\` documents the
|
|
1568
|
+
accepted stdin JSON, \`carmoji test --help\` lists every test event.`;
|
|
1569
|
+
|
|
1570
|
+
/** Detailed per-command help; anything without a dedicated page gets USAGE. */
|
|
1571
|
+
function printHelp(topic) {
|
|
1572
|
+
switch (topic) {
|
|
1573
|
+
case 'hook':
|
|
1574
|
+
console.log(`carmoji hook [--source <tool>] [--event <name>] [--gate]
|
|
1575
|
+
|
|
1576
|
+
Entry point the coding agents' hooks invoke: reads ONE hook event as JSON on
|
|
1577
|
+
stdin, forwards it to every paired phone, and exits 0 no matter what so it can
|
|
1578
|
+
never disturb a session. Unpaired = immediate no-op.
|
|
1579
|
+
|
|
1580
|
+
Flags:
|
|
1581
|
+
--source <tool> which agent is calling (default: claude) — sets the wire
|
|
1582
|
+
\`source\` and enables tool-specific payload handling
|
|
1583
|
+
--event <name> event name for tools whose stdin JSON lacks one
|
|
1584
|
+
(Gemini, Cursor, Trae, Copilot)
|
|
1585
|
+
--gate legacy PreToolUse gate — see \`carmoji config gate\`
|
|
1586
|
+
|
|
1587
|
+
Accepted stdin JSON — Claude Code's field names, with aliases folded in:
|
|
1588
|
+
hook_event_name or hookEventName / eventName / event / hookName / --event
|
|
1589
|
+
session_id or sessionId / conversation_id / conversationId / taskId
|
|
1590
|
+
tool_name or toolName / (Cline) preToolUse.toolName
|
|
1591
|
+
tool_input or toolArgs (JSON string) / (Cline) preToolUse.parameters
|
|
1592
|
+
cwd project folder shown on the phone (default: process cwd)
|
|
1593
|
+
message / prompt human snippet for prompt & permission toasts
|
|
1594
|
+
transcript_path lets the bridge tail token usage for the tok/s readout
|
|
1595
|
+
|
|
1596
|
+
Canonical events: ${CLAUDE_HOOK_EVENTS.join(', ')},
|
|
1597
|
+
PostToolUseFailure, SubagentStart/Stop, PreCompact/PostCompact — plus each
|
|
1598
|
+
tool's dialect (beforeShellExecution, TaskComplete, permission_request, …);
|
|
1599
|
+
see EVENT_ALIASES in carmoji.js for the full mapping. Unknown events are
|
|
1600
|
+
silently ignored.
|
|
1601
|
+
|
|
1602
|
+
Blocking: a Claude Code PermissionRequest holds the hook open up to ${APPROVAL_WAIT_MS / 1000}s
|
|
1603
|
+
while the phone shows a card, then prints the decision for the agent:
|
|
1604
|
+
{"hookSpecificOutput":{"hookEventName":"PermissionRequest",
|
|
1605
|
+
"decision":{"behavior":"allow"}}}
|
|
1606
|
+
AskUserQuestion gets an option-picker card instead when it's one
|
|
1607
|
+
single-select question with 2–4 labeled options; the tapped label — or
|
|
1608
|
+
free text typed behind the card's "Other…" button, which sends
|
|
1609
|
+
{hold:true} to stretch the wait to ${HOLD_WAIT_MS / 1000}s — comes back via
|
|
1610
|
+
decision.updatedInput.answers. Multi-question and multiSelect stay
|
|
1611
|
+
display-only.
|
|
1612
|
+
No tap → no output → the normal terminal prompt appears.
|
|
1613
|
+
|
|
1614
|
+
Try it:
|
|
1615
|
+
echo '{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Edit"}' \\
|
|
1616
|
+
| carmoji hook`);
|
|
1617
|
+
break;
|
|
1618
|
+
|
|
1619
|
+
case 'test':
|
|
1620
|
+
console.log(`carmoji test <event> [session] [tokens] [project]
|
|
1621
|
+
|
|
1622
|
+
Sends a fake event to every paired phone so you can watch the reactions
|
|
1623
|
+
without a coding agent.
|
|
1624
|
+
|
|
1625
|
+
start wakes up, excited (session_start)
|
|
1626
|
+
prompt perks up and listens (prompt)
|
|
1627
|
+
edit focused typing face (tool_start Edit)
|
|
1628
|
+
read pores over a document (tool_start Read)
|
|
1629
|
+
run nervous side-eye (tool_start Bash)
|
|
1630
|
+
heartbeat no reaction; session liveness + token sample (tool_end)
|
|
1631
|
+
done celebration with hearts (turn_done)
|
|
1632
|
+
error dizzy comic collapse (error)
|
|
1633
|
+
permission pleading + floating "?" (permission)
|
|
1634
|
+
end contented sigh (session_end)
|
|
1635
|
+
approval pops the Allow/Deny card, waits ${APPROVAL_WAIT_MS / 1000}s, prints your tap
|
|
1636
|
+
question pops an option-picker card, waits ${APPROVAL_WAIT_MS / 1000}s, prints your pick
|
|
1637
|
+
|
|
1638
|
+
[session] session key — use two different values to simulate several
|
|
1639
|
+
agents at once
|
|
1640
|
+
[tokens] cumulative output-token count for the tok/s readout
|
|
1641
|
+
[project] project name shown under the dashboard robot`);
|
|
1642
|
+
break;
|
|
1643
|
+
|
|
1644
|
+
case 'config':
|
|
1645
|
+
console.log(`carmoji config <claude|codex|gate>
|
|
1646
|
+
|
|
1647
|
+
claude the .claude/settings.json hooks snippet (what \`install claude\`
|
|
1648
|
+
writes, including the ${PERMISSION_HOOK_TIMEOUT_S}s PermissionRequest hook)
|
|
1649
|
+
codex the legacy ~/.codex/config.toml notify line for Codex versions
|
|
1650
|
+
without hooks.json (modern Codex: \`carmoji install codex\`)
|
|
1651
|
+
gate the legacy PreToolUse gate snippet — fires on every matched call;
|
|
1652
|
+
superseded by the PermissionRequest hook \`install claude\` sets up`);
|
|
1653
|
+
break;
|
|
1654
|
+
|
|
1655
|
+
case 'install':
|
|
1656
|
+
console.log(`carmoji install <tool>|all
|
|
1657
|
+
|
|
1658
|
+
Writes CarMoji's hooks into the tool's own config (a .bak backup is saved
|
|
1659
|
+
next to anything modified; reinstalling replaces earlier CarMoji entries).
|
|
1660
|
+
\`all\` installs into every tool detected on this machine.
|
|
1661
|
+
|
|
1662
|
+
Tools: ${Object.keys(TOOLS).join(', ')}
|
|
1663
|
+
\`carmoji tools\` shows each one's config path and whether it's present.`);
|
|
1664
|
+
break;
|
|
1665
|
+
|
|
1666
|
+
case 'uninstall':
|
|
1667
|
+
console.log(`carmoji uninstall <tool>|all
|
|
1668
|
+
|
|
1669
|
+
Removes every CarMoji entry from the tool's hook config, leaving other
|
|
1670
|
+
hooks untouched. \`all\` sweeps every known tool quietly.
|
|
1671
|
+
|
|
1672
|
+
Tools: ${Object.keys(TOOLS).join(', ')}`);
|
|
1309
1673
|
break;
|
|
1310
1674
|
|
|
1311
1675
|
default:
|
|
1312
|
-
console.log(
|
|
1676
|
+
console.log(USAGE);
|
|
1313
1677
|
}
|
|
1314
1678
|
}
|
|
1315
1679
|
|
|
@@ -1317,6 +1681,7 @@ export {
|
|
|
1317
1681
|
applyTranscriptTokenRecord,
|
|
1318
1682
|
eventFromHook,
|
|
1319
1683
|
normalizeDevices,
|
|
1684
|
+
requestApproval,
|
|
1320
1685
|
stripLegacyCarMojiNotifyLine,
|
|
1321
1686
|
};
|
|
1322
1687
|
|