carmoji 0.3.5 → 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 +7 -3
  2. package/carmoji.js +166 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -165,9 +165,13 @@ settles the prompt on the computer via the hook's official decision
165
165
  output. No answer within ~55 s falls through to the normal terminal
166
166
  prompt (`npx carmoji test approval` exercises the whole loop).
167
167
 
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`
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`
171
175
  as display-only (answering from the phone is planned next).
172
176
 
173
177
  Older Claude Code versions without the `PermissionRequest` hook can use
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,10 +605,13 @@ 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
616
  /** Sources whose PermissionRequest hook accepts a decision back via
566
617
  * `hookSpecificOutput.decision` (Claude Code's schema). Codex is the next
@@ -1165,14 +1216,14 @@ async function main() {
1165
1216
  // the phone shows Allow/Deny; the printed JSON settles the
1166
1217
  // permission. Timeout falls through to the normal terminal prompt.
1167
1218
  if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
1168
- const decision = await requestApprovalAll(devices, {
1219
+ const decision = (await requestApprovalAll(devices, {
1169
1220
  source,
1170
1221
  session: payload.session_id,
1171
1222
  event: 'approval_request',
1172
1223
  tool: payload.tool_name,
1173
1224
  detail: summarizeToolInput(payload),
1174
1225
  project: payload.cwd ? basename(payload.cwd) : undefined,
1175
- });
1226
+ }))?.decision;
1176
1227
  if (decision) {
1177
1228
  console.log(JSON.stringify({
1178
1229
  hookSpecificOutput: {
@@ -1191,32 +1242,73 @@ async function main() {
1191
1242
  // appear (allowlisted tools never trigger it), so blocking here —
1192
1243
  // unlike the PreToolUse gate — never delays a call that would have
1193
1244
  // 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.
1245
+ // to the terminal dialog.
1197
1246
  const canonicalEvent = EVENT_ALIASES[payload.hook_event_name]
1198
1247
  ?? payload.hook_event_name;
1199
1248
  if (canonicalEvent === 'PermissionRequest'
1200
- && ANSWERABLE_PERMISSION_SOURCES.has(source)
1201
- && payload.tool_name !== 'AskUserQuestion') {
1202
- const decision = await requestApprovalAll(devices, {
1249
+ && ANSWERABLE_PERMISSION_SOURCES.has(source)) {
1250
+ const common = {
1203
1251
  source,
1204
1252
  session: payload.session_id,
1205
1253
  host: hostname(),
1206
- event: 'approval_request',
1207
- tool: payload.tool_name,
1208
- detail: summarizeToolInput(payload),
1209
1254
  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
- }));
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;
1218
1311
  }
1219
- break;
1220
1312
  }
1221
1313
 
1222
1314
  const event = eventFromHook(payload);
@@ -1275,8 +1367,8 @@ async function main() {
1275
1367
 
1276
1368
  case 'test': {
1277
1369
  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`);
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`);
1280
1372
  process.exit(1);
1281
1373
  }
1282
1374
  const devices = loadDevices();
@@ -1287,19 +1379,44 @@ async function main() {
1287
1379
  if (arg === 'approval') {
1288
1380
  // Exercises the blocking PermissionRequest flow end to end.
1289
1381
  console.log(`Approval card sent — tap Allow or Deny on the phone (${APPROVAL_WAIT_MS / 1000}s)…`);
1290
- const decision = await requestApprovalAll(devices, {
1382
+ const decision = (await requestApprovalAll(devices, {
1291
1383
  source: 'test',
1292
1384
  session: rest[0],
1293
1385
  host: hostname(),
1294
1386
  event: 'approval_request',
1295
1387
  tool: 'Bash',
1296
1388
  detail: 'carmoji test approval — tap Allow or Deny',
1297
- }, APPROVAL_WAIT_MS);
1389
+ }, APPROVAL_WAIT_MS))?.decision;
1298
1390
  console.log(decision
1299
1391
  ? `Decision: ${decision}`
1300
1392
  : 'No answer — in a real session Claude Code would fall through to the terminal prompt.');
1301
1393
  process.exit(decision ? 0 : 1);
1302
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
+ }
1303
1420
  const [session, tokens, project] = rest;
1304
1421
  const message = { source: 'test', session, host: hostname(), ...event };
1305
1422
  if (tokens !== undefined) message.tokens = Number(tokens);
@@ -1482,11 +1599,16 @@ tool's dialect (beforeShellExecution, TaskComplete, permission_request, …);
1482
1599
  see EVENT_ALIASES in carmoji.js for the full mapping. Unknown events are
1483
1600
  silently ignored.
1484
1601
 
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:
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:
1488
1604
  {"hookSpecificOutput":{"hookEventName":"PermissionRequest",
1489
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.
1490
1612
  No tap → no output → the normal terminal prompt appears.
1491
1613
 
1492
1614
  Try it:
@@ -1511,6 +1633,7 @@ without a coding agent.
1511
1633
  permission pleading + floating "?" (permission)
1512
1634
  end contented sigh (session_end)
1513
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
1514
1637
 
1515
1638
  [session] session key — use two different values to simulate several
1516
1639
  agents at once
@@ -1558,6 +1681,7 @@ export {
1558
1681
  applyTranscriptTokenRecord,
1559
1682
  eventFromHook,
1560
1683
  normalizeDevices,
1684
+ requestApproval,
1561
1685
  stripLegacyCarMojiNotifyLine,
1562
1686
  };
1563
1687
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.5",
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": {