carmoji 0.3.5 → 0.3.7

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 +12 -7
  2. package/carmoji.js +177 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -153,10 +153,11 @@ cache-write tokens; cache reads excluded). It's your real consumption, not
153
153
  an official percentage of plan quota — there's no public API for that.
154
154
  Recomputed at turn boundaries with a 5-minute cache so hooks stay fast.
155
155
 
156
- ## Answer permissions from the phone (Claude Code)
156
+ ## Answer permissions from the phone (Claude Code and Codex)
157
157
 
158
- `carmoji install claude` hooks Claude Code's `PermissionRequest` event,
159
- which fires **only when a permission dialog is about to appear** —
158
+ `carmoji install claude` and `carmoji install codex` hook each agent's
159
+ `PermissionRequest` event, which fires **only when a permission dialog is
160
+ about to appear** —
160
161
  allowlisted tools and auto-accepted edits never trigger it, so nothing
161
162
  ever slows down a call that would have run anyway. When it fires, the
162
163
  phone chimes (a doorbell sound used by nothing else), shows big pleading
@@ -165,10 +166,14 @@ settles the prompt on the computer via the hook's official decision
165
166
  output. No answer within ~55 s falls through to the normal terminal
166
167
  prompt (`npx carmoji test approval` exercises the whole loop).
167
168
 
168
- Honest limits: this decides *tool permissions* only AskUserQuestion
169
- option pickers show as a pleading toast but must be answered in the
170
- terminal and the Codex integration currently treats `PermissionRequest`
171
- as display-only (answering from the phone is planned next).
169
+ For Claude Code, simple **AskUserQuestion** pickers ride the same hook: one
170
+ single-select question with 2–4 labeled options becomes a tappable option
171
+ card on the phone (`npx carmoji test question` demos it), and your pick is
172
+ fed back as the answer. Richer questions several at once, multi-select,
173
+ free text — still show as a pleading toast and are answered in the terminal.
174
+ Codex questions use its App Server `requestUserInput` protocol rather than
175
+ lifecycle hooks, so they remain terminal-only; Codex Allow/Deny permission
176
+ approvals do work from the phone.
172
177
 
173
178
  Older Claude Code versions without the `PermissionRequest` hook can use
174
179
  `npx carmoji config gate` instead: a legacy `PreToolUse` gate with a tool
package/carmoji.js CHANGED
@@ -222,16 +222,21 @@ async function send(config, message) {
222
222
  }
223
223
 
224
224
  /**
225
- * Send an approval_request and hold the connection open until the phone
226
- * 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.
227
232
  */
228
- async function requestApproval(config, message, timeoutMs = 55_000) {
233
+ async function requestApproval(config, message, timeoutMs = 55_000, requireCap) {
229
234
  const { default: WebSocket } = await import('ws');
230
235
  return new Promise((resolve) => {
231
236
  const ws = new WebSocket(`ws://${config.host}:${config.port}`, {
232
237
  handshakeTimeout: SEND_TIMEOUT_MS,
233
238
  });
234
- const timer = setTimeout(() => finish(null), timeoutMs);
239
+ let timer = setTimeout(() => finish(null), timeoutMs);
235
240
  ws.on('open', () => {
236
241
  ws.send(JSON.stringify({ v: 1, code: config.code, ...message }));
237
242
  });
@@ -239,8 +244,20 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
239
244
  try {
240
245
  const reply = JSON.parse(data.toString());
241
246
  if (reply.ok === false) return finish(null);
242
- if (reply.decision === 'allow' || reply.decision === 'deny') {
243
- 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);
244
261
  }
245
262
  // A plain ack means the card is up — keep waiting for the tap.
246
263
  } catch { /* ignore unparsable frames */ }
@@ -262,23 +279,54 @@ function sendAll(devices, message) {
262
279
  }
263
280
 
264
281
  /**
265
- * Show the approval card on every paired phone; the first tap anywhere
266
- * wins. The other phones' cards drop when this process exits and their
267
- * 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.
268
285
  */
269
- function requestApprovalAll(devices, message, timeoutMs) {
286
+ function requestApprovalAll(devices, message, timeoutMs, requireCap) {
270
287
  if (devices.length === 0) return Promise.resolve(null);
271
288
  return new Promise((resolve) => {
272
289
  let pending = devices.length;
273
290
  for (const device of devices) {
274
- requestApproval(device, message, timeoutMs).then((decision) => {
291
+ requestApproval(device, message, timeoutMs, requireCap).then((reply) => {
275
292
  pending -= 1;
276
- if (decision || pending === 0) resolve(decision);
293
+ if (reply || pending === 0) resolve(reply);
277
294
  });
278
295
  }
279
296
  });
280
297
  }
281
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
+
282
330
  function summarizeToolInput(payload) {
283
331
  const input = payload.tool_input || {};
284
332
  const text = input.command || input.file_path || input.description
@@ -557,15 +605,22 @@ const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
557
605
 
558
606
  // PermissionRequest fires only when a permission dialog is about to appear,
559
607
  // and the dialog waits for the hook — so the phone gets a bounded window to
560
- // answer, then silence falls through to the normal terminal prompt. The
561
- // installed hook timeout leaves headroom over the bridge's wait.
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.
562
612
  const APPROVAL_WAIT_MS = 55_000;
563
- const PERMISSION_HOOK_TIMEOUT_S = 75;
613
+ const HOLD_WAIT_MS = 120_000;
614
+ const PERMISSION_HOOK_TIMEOUT_S = 150;
564
615
 
565
- /** Sources whose PermissionRequest hook accepts a decision back via
566
- * `hookSpecificOutput.decision` (Claude Code's schema). Codex is the next
567
- * candidate once its hooks are verified to honor the reply. */
568
- const ANSWERABLE_PERMISSION_SOURCES = new Set(['claude']);
616
+ /** Sources whose PermissionRequest hook accepts Allow/Deny through
617
+ * `hookSpecificOutput.decision`. */
618
+ const ANSWERABLE_APPROVAL_SOURCES = new Set(['claude', 'codex']);
619
+
620
+ /** Sources whose question protocol accepts `decision.updatedInput`.
621
+ * Codex questions use App Server requestUserInput instead of hooks, so a
622
+ * synthetic AskUserQuestion event must remain display-only. */
623
+ const ANSWERABLE_QUESTION_SOURCES = new Set(['claude']);
569
624
 
570
625
  // ---------------------------------------------------------------------------
571
626
  // Multi-tool hook installers (see EVENT_ALIASES for how their events map).
@@ -1165,14 +1220,14 @@ async function main() {
1165
1220
  // the phone shows Allow/Deny; the printed JSON settles the
1166
1221
  // permission. Timeout falls through to the normal terminal prompt.
1167
1222
  if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
1168
- const decision = await requestApprovalAll(devices, {
1223
+ const decision = (await requestApprovalAll(devices, {
1169
1224
  source,
1170
1225
  session: payload.session_id,
1171
1226
  event: 'approval_request',
1172
1227
  tool: payload.tool_name,
1173
1228
  detail: summarizeToolInput(payload),
1174
1229
  project: payload.cwd ? basename(payload.cwd) : undefined,
1175
- });
1230
+ }))?.decision;
1176
1231
  if (decision) {
1177
1232
  console.log(JSON.stringify({
1178
1233
  hookSpecificOutput: {
@@ -1191,32 +1246,74 @@ async function main() {
1191
1246
  // appear (allowlisted tools never trigger it), so blocking here —
1192
1247
  // unlike the PreToolUse gate — never delays a call that would have
1193
1248
  // run anyway. A phone tap settles the prompt; silence falls through
1194
- // to the terminal dialog. AskUserQuestion also arrives as a
1195
- // PermissionRequest, but the phone has no option picker, so it drops
1196
- // to the display-only path below.
1249
+ // to the terminal dialog.
1197
1250
  const canonicalEvent = EVENT_ALIASES[payload.hook_event_name]
1198
1251
  ?? payload.hook_event_name;
1199
- if (canonicalEvent === 'PermissionRequest'
1200
- && ANSWERABLE_PERMISSION_SOURCES.has(source)
1201
- && payload.tool_name !== 'AskUserQuestion') {
1202
- const decision = await requestApprovalAll(devices, {
1252
+ if (canonicalEvent === 'PermissionRequest') {
1253
+ const common = {
1203
1254
  source,
1204
1255
  session: payload.session_id,
1205
1256
  host: hostname(),
1206
- event: 'approval_request',
1207
- tool: payload.tool_name,
1208
- detail: summarizeToolInput(payload),
1209
1257
  project: payload.cwd ? basename(payload.cwd) : undefined,
1210
- }, APPROVAL_WAIT_MS);
1211
- if (decision) {
1212
- console.log(JSON.stringify({
1213
- hookSpecificOutput: {
1214
- hookEventName: 'PermissionRequest',
1215
- decision: { behavior: decision },
1216
- },
1217
- }));
1258
+ };
1259
+
1260
+ // AskUserQuestion rides the same hook: simple option pickers get
1261
+ // a tappable card; the tapped label is fed back through
1262
+ // updatedInput.answers, keyed by the QUESTION TEXT (Claude Code
1263
+ // looks answers up by it — keying by header yields empty answers)
1264
+ // with the original `questions` kept in place (Claude Code crashes
1265
+ // on its absence). Richer shapes fall through to display-only.
1266
+ if (payload.tool_name === 'AskUserQuestion'
1267
+ && ANSWERABLE_QUESTION_SOURCES.has(source)) {
1268
+ const question = answerableQuestion(payload.tool_input);
1269
+ if (question) {
1270
+ const reply = await requestApprovalAll(devices, {
1271
+ ...common,
1272
+ event: 'question_request',
1273
+ tool: payload.tool_name,
1274
+ detail: question.question.replace(/\s+/g, ' ').slice(0, 140),
1275
+ question,
1276
+ }, APPROVAL_WAIT_MS, 'question');
1277
+ // Either a tapped option label or free text typed behind the
1278
+ // card's "Other…" button — Claude Code takes any string, the
1279
+ // same way its own picker's Other row does.
1280
+ const answer = reply?.answer?.trim();
1281
+ if (answer) {
1282
+ console.log(JSON.stringify({
1283
+ hookSpecificOutput: {
1284
+ hookEventName: 'PermissionRequest',
1285
+ decision: {
1286
+ behavior: 'allow',
1287
+ updatedInput: {
1288
+ ...payload.tool_input,
1289
+ answers: { [question.question]: answer },
1290
+ },
1291
+ },
1292
+ },
1293
+ }));
1294
+ break;
1295
+ }
1296
+ }
1297
+ // No card, no tap, or an unknown label: fall through to the
1298
+ // pleading face + toast; the picker stays in the terminal.
1299
+ } else if (payload.tool_name !== 'AskUserQuestion'
1300
+ && ANSWERABLE_APPROVAL_SOURCES.has(source)) {
1301
+ const decision = (await requestApprovalAll(devices, {
1302
+ ...common,
1303
+ event: 'approval_request',
1304
+ tool: payload.tool_name,
1305
+ detail: summarizeToolInput(payload),
1306
+ }, APPROVAL_WAIT_MS))?.decision;
1307
+ if (decision) {
1308
+ console.log(JSON.stringify({
1309
+ hookSpecificOutput: {
1310
+ hookEventName: 'PermissionRequest',
1311
+ decision: { behavior: decision },
1312
+ },
1313
+ }));
1314
+ }
1315
+ break;
1218
1316
  }
1219
- break;
1220
1317
  }
1221
1318
 
1222
1319
  const event = eventFromHook(payload);
@@ -1275,8 +1372,8 @@ async function main() {
1275
1372
 
1276
1373
  case 'test': {
1277
1374
  const event = TEST_EVENTS[arg];
1278
- if (!event && arg !== 'approval') {
1279
- console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval> [session] [tokens] [project] — details: carmoji test --help`);
1375
+ if (!event && arg !== 'approval' && arg !== 'question') {
1376
+ console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval|question> [session] [tokens] [project] — details: carmoji test --help`);
1280
1377
  process.exit(1);
1281
1378
  }
1282
1379
  const devices = loadDevices();
@@ -1287,19 +1384,44 @@ async function main() {
1287
1384
  if (arg === 'approval') {
1288
1385
  // Exercises the blocking PermissionRequest flow end to end.
1289
1386
  console.log(`Approval card sent — tap Allow or Deny on the phone (${APPROVAL_WAIT_MS / 1000}s)…`);
1290
- const decision = await requestApprovalAll(devices, {
1387
+ const decision = (await requestApprovalAll(devices, {
1291
1388
  source: 'test',
1292
1389
  session: rest[0],
1293
1390
  host: hostname(),
1294
1391
  event: 'approval_request',
1295
1392
  tool: 'Bash',
1296
1393
  detail: 'carmoji test approval — tap Allow or Deny',
1297
- }, APPROVAL_WAIT_MS);
1394
+ }, APPROVAL_WAIT_MS))?.decision;
1298
1395
  console.log(decision
1299
1396
  ? `Decision: ${decision}`
1300
- : 'No answer — in a real session Claude Code would fall through to the terminal prompt.');
1397
+ : 'No answer — in a real session the coding agent would fall through to the terminal prompt.');
1301
1398
  process.exit(decision ? 0 : 1);
1302
1399
  }
1400
+ if (arg === 'question') {
1401
+ // Exercises the blocking AskUserQuestion flow end to end.
1402
+ console.log(`Question card sent — tap an option on the phone (${APPROVAL_WAIT_MS / 1000}s)…`);
1403
+ const answer = (await requestApprovalAll(devices, {
1404
+ source: 'test',
1405
+ session: rest[0],
1406
+ host: hostname(),
1407
+ event: 'question_request',
1408
+ tool: 'AskUserQuestion',
1409
+ detail: 'Which flavor should the demo pick?',
1410
+ question: {
1411
+ question: 'Which flavor should the demo pick?',
1412
+ header: 'Flavor',
1413
+ options: [
1414
+ { label: 'Vanilla', description: 'The safe default' },
1415
+ { label: 'Matcha', description: 'A little adventurous' },
1416
+ { label: 'Durian', description: 'You know what you did' },
1417
+ ],
1418
+ },
1419
+ }, APPROVAL_WAIT_MS, 'question'))?.answer;
1420
+ console.log(answer
1421
+ ? `Answer: ${answer}`
1422
+ : 'No answer — in a real session the picker would stay in the terminal.');
1423
+ process.exit(answer ? 0 : 1);
1424
+ }
1303
1425
  const [session, tokens, project] = rest;
1304
1426
  const message = { source: 'test', session, host: hostname(), ...event };
1305
1427
  if (tokens !== undefined) message.tokens = Number(tokens);
@@ -1482,11 +1604,16 @@ tool's dialect (beforeShellExecution, TaskComplete, permission_request, …);
1482
1604
  see EVENT_ALIASES in carmoji.js for the full mapping. Unknown events are
1483
1605
  silently ignored.
1484
1606
 
1485
- Blocking: a Claude Code PermissionRequest (any tool except AskUserQuestion)
1486
- holds the hook open up to ${APPROVAL_WAIT_MS / 1000}s while the phone shows Allow/Deny, then
1487
- prints the decision for the agent:
1607
+ Blocking: a Claude Code or Codex PermissionRequest holds the hook open up to ${APPROVAL_WAIT_MS / 1000}s
1608
+ while the phone shows a card, then prints the decision for the agent:
1488
1609
  {"hookSpecificOutput":{"hookEventName":"PermissionRequest",
1489
1610
  "decision":{"behavior":"allow"}}}
1611
+ For Claude Code, AskUserQuestion gets an option-picker card instead when
1612
+ it's one single-select question with 2–4 labeled options; the tapped label
1613
+ — or free text typed behind the card's "Other…" button, which sends
1614
+ {hold:true} to stretch the wait to ${HOLD_WAIT_MS / 1000}s — comes back via
1615
+ decision.updatedInput.answers. Multi-question and multiSelect stay
1616
+ display-only; Codex questions use App Server requestUserInput instead.
1490
1617
  No tap → no output → the normal terminal prompt appears.
1491
1618
 
1492
1619
  Try it:
@@ -1511,6 +1638,7 @@ without a coding agent.
1511
1638
  permission pleading + floating "?" (permission)
1512
1639
  end contented sigh (session_end)
1513
1640
  approval pops the Allow/Deny card, waits ${APPROVAL_WAIT_MS / 1000}s, prints your tap
1641
+ question pops an option-picker card, waits ${APPROVAL_WAIT_MS / 1000}s, prints your pick
1514
1642
 
1515
1643
  [session] session key — use two different values to simulate several
1516
1644
  agents at once
@@ -1558,6 +1686,7 @@ export {
1558
1686
  applyTranscriptTokenRecord,
1559
1687
  eventFromHook,
1560
1688
  normalizeDevices,
1689
+ requestApproval,
1561
1690
  stripLegacyCarMojiNotifyLine,
1562
1691
  };
1563
1692
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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": {