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.
Files changed (3) hide show
  1. package/README.md +31 -21
  2. package/carmoji.js +430 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,18 +46,18 @@ approval.
46
46
 
47
47
  ## Claude Code
48
48
 
49
- Coding in *this* repo works out of the box — the hooks ship in
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-claude
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-config` prints the snippet instead.
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, opt-in)
156
-
157
- ```sh
158
- npx carmoji gate-config
159
- ```
160
-
161
- prints a `PreToolUse` hook snippet with a tool matcher (e.g. `Bash`). With
162
- it installed, whenever Claude wants to run a matched tool the phone chimes
163
- (a doorbell sound used by nothing else), shows big pleading eyes, and pops
164
- an **Allow / Deny** card with the command text your tap settles the
165
- permission on the computer via the hook's official decision output. No
166
- answer within ~55 s falls through to the normal terminal prompt.
167
-
168
- Honest limits: this decides *tool permissions* only hooks expose no way
169
- to answer AskUserQuestion option pickers and the Codex integration currently
170
- treats `PermissionRequest` as display-only.
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> install hooks for a tool (or `all`); see `tools`
13
- // uninstall <tool> remove CarMoji hooks from a tool's config
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: start|prompt|edit|read|run|heartbeat|done|error|permission|end
19
- // claude-config print the .claude/settings.json hooks snippet
20
- // codex-config print the ~/.codex/config.toml notify line (legacy)
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 an approval_request and hold the connection open until the phone
223
- * answers (or the timeout passes). Resolves "allow", "deny", or null.
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
- const timer = setTimeout(() => finish(null), timeoutMs);
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
- finish(reply.decision);
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 anywhere
263
- * wins. The other phones' cards drop when this process exits and their
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((decision) => {
291
+ requestApproval(device, message, timeoutMs, requireCap).then((reply) => {
272
292
  pending -= 1;
273
- if (decision || pending === 0) resolve(decision);
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
- 'PostToolUse', 'Notification', 'Stop', 'SessionEnd'];
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: timeout(5) }] });
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: timeout(5) }] });
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: timeout(5) }] });
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
- writeConfig(path, removeKimiBlocks(readFileSync(path, 'utf8')) + '\n');
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)) { console.log(`${tool.name}: nothing installed.`); return; }
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 ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
973
- 'PostToolUse', 'Notification', 'Stop', 'SessionEnd']) {
974
- hooks[name] = entry;
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>\nTools: ${Object.keys(TOOLS).join(', ')}`);
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 'install-claude':
1270
- installClaude();
1271
- break;
1272
-
1273
- case 'install-codex':
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
- console.log('Merge this into the project’s .claude/settings.json (or ~/.claude/settings.json for everywhere):\n');
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
- console.log('Modern Codex reads hooks from $CODEX_HOME/hooks.json — prefer:');
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
- console.log('Opt-in: answer Claude Code permission prompts from the phone.');
1298
- console.log('Merge into .claude/settings.json, adjusting the matcher to the tools you want gated:\n');
1299
- console.log(JSON.stringify({
1300
- hooks: {
1301
- PreToolUse: [{
1302
- matcher: 'Bash',
1303
- hooks: [{ type: 'command', command: `${HOOK_COMMAND} --gate`, timeout: 60 }],
1304
- }],
1305
- },
1306
- }, null, 2));
1307
- console.log('\nNotes: allow/deny from the phone settles the permission;');
1308
- console.log('no answer within ~55s falls through to the terminal prompt as usual.');
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('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');
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {