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.
- package/README.md +7 -3
- package/carmoji.js +166 -42
- 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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
226
|
-
* 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
266
|
-
* wins. The other phones' cards drop when this process exits and
|
|
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((
|
|
291
|
+
requestApproval(device, message, timeoutMs, requireCap).then((reply) => {
|
|
275
292
|
pending -= 1;
|
|
276
|
-
if (
|
|
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.
|
|
561
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
|
1486
|
-
|
|
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
|
|