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.
- package/README.md +12 -7
- package/carmoji.js +177 -48
- 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`
|
|
159
|
-
which fires **only when a permission dialog is
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
as
|
|
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
|
|
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,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.
|
|
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
|
-
/** Sources whose PermissionRequest hook accepts
|
|
566
|
-
* `hookSpecificOutput.decision
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
|
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
|
|
1486
|
-
|
|
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
|
|