carmoji 0.3.6 → 0.3.8
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 -11
- package/carmoji.js +100 -17
- 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,14 +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
|
-
question with 2–4 labeled options becomes a tappable option
|
|
170
|
-
phone (`npx carmoji test question` demos it), and your pick is
|
|
171
|
-
as the answer. Richer questions — several at once, multi-select,
|
|
172
|
-
text — still show as a pleading toast and are answered in the terminal.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
176
177
|
|
|
177
178
|
Older Claude Code versions without the `PermissionRequest` hook can use
|
|
178
179
|
`npx carmoji config gate` instead: a legacy `PreToolUse` gate with a tool
|
package/carmoji.js
CHANGED
|
@@ -245,6 +245,7 @@ async function requestApproval(config, message, timeoutMs = 55_000, requireCap)
|
|
|
245
245
|
const reply = JSON.parse(data.toString());
|
|
246
246
|
if (reply.ok === false) return finish(null);
|
|
247
247
|
if (reply.decision === 'allow' || reply.decision === 'deny'
|
|
248
|
+
|| reply.decision === 'always'
|
|
248
249
|
|| typeof reply.answer === 'string') {
|
|
249
250
|
return finish(reply);
|
|
250
251
|
}
|
|
@@ -487,10 +488,41 @@ function normalizePayload(payload, eventFlag) {
|
|
|
487
488
|
return payload;
|
|
488
489
|
}
|
|
489
490
|
|
|
491
|
+
/** Tools that carry the agent's own task list in their input. */
|
|
492
|
+
const TODO_TOOLS = new Set(['TodoWrite', 'update_plan']);
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The agent's task list, normalized from Claude's TodoWrite
|
|
496
|
+
* (`todos: [{content, status, activeForm}]`) or Codex's update_plan
|
|
497
|
+
* (`plan: [{step, status}]`). Every call carries the whole list, so the
|
|
498
|
+
* phone can render progress and diff for freshly completed items.
|
|
499
|
+
*/
|
|
500
|
+
function todosFromInput(input) {
|
|
501
|
+
const raw = Array.isArray(input?.todos) ? input.todos
|
|
502
|
+
: Array.isArray(input?.plan) ? input.plan : undefined;
|
|
503
|
+
if (!raw) return undefined;
|
|
504
|
+
const normalize = (status) =>
|
|
505
|
+
['completed', 'complete', 'done'].includes(status) ? 'done'
|
|
506
|
+
: ['in_progress', 'active'].includes(status) ? 'active' : 'pending';
|
|
507
|
+
const items = raw.slice(0, 12).map((item) => ({
|
|
508
|
+
text: String(item.content ?? item.step ?? item.text ?? '')
|
|
509
|
+
.replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
510
|
+
status: normalize(item.status),
|
|
511
|
+
})).filter((item) => item.text);
|
|
512
|
+
return items.length ? items : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
490
515
|
/** Map a normalized hook payload to a wire event, or null to ignore. */
|
|
491
516
|
function eventFromHook(payload) {
|
|
492
517
|
const raw = payload.hook_event_name;
|
|
493
518
|
const tool = payload.tool_name ?? EVENT_TOOL_HINTS[raw];
|
|
519
|
+
// The task list rides its own event: updating it is bookkeeping, not
|
|
520
|
+
// "running a command", so it must not pull the nervous tool face.
|
|
521
|
+
// Pre and Post both carry the list; sending twice is a no-op diff.
|
|
522
|
+
if (TODO_TOOLS.has(tool)) {
|
|
523
|
+
const todos = todosFromInput(payload.tool_input);
|
|
524
|
+
if (todos) return { event: 'todos', todos };
|
|
525
|
+
}
|
|
494
526
|
switch (EVENT_ALIASES[raw] ?? raw) {
|
|
495
527
|
case 'SessionStart': return { event: 'session_start' };
|
|
496
528
|
case 'UserPromptSubmit': return { event: 'prompt' };
|
|
@@ -613,10 +645,20 @@ const APPROVAL_WAIT_MS = 55_000;
|
|
|
613
645
|
const HOLD_WAIT_MS = 120_000;
|
|
614
646
|
const PERMISSION_HOOK_TIMEOUT_S = 150;
|
|
615
647
|
|
|
616
|
-
/** Sources whose PermissionRequest hook accepts
|
|
617
|
-
* `hookSpecificOutput.decision
|
|
618
|
-
|
|
619
|
-
|
|
648
|
+
/** Sources whose PermissionRequest hook accepts Allow/Deny through
|
|
649
|
+
* `hookSpecificOutput.decision`. */
|
|
650
|
+
const ANSWERABLE_APPROVAL_SOURCES = new Set(['claude', 'codex']);
|
|
651
|
+
|
|
652
|
+
/** Sources whose decision also honors `updatedPermissions` — lets the phone
|
|
653
|
+
* offer "Always allow" (a session-scoped rule, mirroring the terminal
|
|
654
|
+
* dialog's don't-ask-again row). Codex hooks ignore updatedPermissions, so
|
|
655
|
+
* its card stays Allow/Deny. */
|
|
656
|
+
const ALWAYS_ALLOW_SOURCES = new Set(['claude']);
|
|
657
|
+
|
|
658
|
+
/** Sources whose question protocol accepts `decision.updatedInput`.
|
|
659
|
+
* Codex questions use App Server requestUserInput instead of hooks, so a
|
|
660
|
+
* synthetic AskUserQuestion event must remain display-only. */
|
|
661
|
+
const ANSWERABLE_QUESTION_SOURCES = new Set(['claude']);
|
|
620
662
|
|
|
621
663
|
// ---------------------------------------------------------------------------
|
|
622
664
|
// Multi-tool hook installers (see EVENT_ALIASES for how their events map).
|
|
@@ -1245,8 +1287,7 @@ async function main() {
|
|
|
1245
1287
|
// to the terminal dialog.
|
|
1246
1288
|
const canonicalEvent = EVENT_ALIASES[payload.hook_event_name]
|
|
1247
1289
|
?? payload.hook_event_name;
|
|
1248
|
-
if (canonicalEvent === 'PermissionRequest'
|
|
1249
|
-
&& ANSWERABLE_PERMISSION_SOURCES.has(source)) {
|
|
1290
|
+
if (canonicalEvent === 'PermissionRequest') {
|
|
1250
1291
|
const common = {
|
|
1251
1292
|
source,
|
|
1252
1293
|
session: payload.session_id,
|
|
@@ -1260,7 +1301,8 @@ async function main() {
|
|
|
1260
1301
|
// looks answers up by it — keying by header yields empty answers)
|
|
1261
1302
|
// with the original `questions` kept in place (Claude Code crashes
|
|
1262
1303
|
// on its absence). Richer shapes fall through to display-only.
|
|
1263
|
-
if (payload.tool_name === 'AskUserQuestion'
|
|
1304
|
+
if (payload.tool_name === 'AskUserQuestion'
|
|
1305
|
+
&& ANSWERABLE_QUESTION_SOURCES.has(source)) {
|
|
1264
1306
|
const question = answerableQuestion(payload.tool_input);
|
|
1265
1307
|
if (question) {
|
|
1266
1308
|
const reply = await requestApprovalAll(devices, {
|
|
@@ -1292,18 +1334,36 @@ async function main() {
|
|
|
1292
1334
|
}
|
|
1293
1335
|
// No card, no tap, or an unknown label: fall through to the
|
|
1294
1336
|
// pleading face + toast; the picker stays in the terminal.
|
|
1295
|
-
} else
|
|
1337
|
+
} else if (payload.tool_name !== 'AskUserQuestion'
|
|
1338
|
+
&& ANSWERABLE_APPROVAL_SOURCES.has(source)) {
|
|
1339
|
+
const canAlways = ALWAYS_ALLOW_SOURCES.has(source)
|
|
1340
|
+
&& Boolean(payload.tool_name);
|
|
1296
1341
|
const decision = (await requestApprovalAll(devices, {
|
|
1297
1342
|
...common,
|
|
1298
1343
|
event: 'approval_request',
|
|
1299
1344
|
tool: payload.tool_name,
|
|
1300
1345
|
detail: summarizeToolInput(payload),
|
|
1346
|
+
// Tells the phone it may offer an "Always allow" button;
|
|
1347
|
+
// older app builds don't know the field and show Allow/Deny.
|
|
1348
|
+
...(canAlways ? { always: true } : {}),
|
|
1301
1349
|
}, APPROVAL_WAIT_MS))?.decision;
|
|
1302
1350
|
if (decision) {
|
|
1351
|
+
const settled = { behavior: decision === 'always' ? 'allow' : decision };
|
|
1352
|
+
if (decision === 'always' && canAlways) {
|
|
1353
|
+
// Session-scoped don't-ask-again rule. A bare toolName rule
|
|
1354
|
+
// allows every use of the tool; MCP tools (mcp__server__tool)
|
|
1355
|
+
// in particular reject a ruleContent specifier.
|
|
1356
|
+
settled.updatedPermissions = [{
|
|
1357
|
+
type: 'addRules',
|
|
1358
|
+
rules: [{ toolName: payload.tool_name }],
|
|
1359
|
+
behavior: 'allow',
|
|
1360
|
+
destination: 'session',
|
|
1361
|
+
}];
|
|
1362
|
+
}
|
|
1303
1363
|
console.log(JSON.stringify({
|
|
1304
1364
|
hookSpecificOutput: {
|
|
1305
1365
|
hookEventName: 'PermissionRequest',
|
|
1306
|
-
decision:
|
|
1366
|
+
decision: settled,
|
|
1307
1367
|
},
|
|
1308
1368
|
}));
|
|
1309
1369
|
}
|
|
@@ -1367,8 +1427,8 @@ async function main() {
|
|
|
1367
1427
|
|
|
1368
1428
|
case 'test': {
|
|
1369
1429
|
const event = TEST_EVENTS[arg];
|
|
1370
|
-
if (!event &&
|
|
1371
|
-
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval|question> [session] [tokens] [project] — details: carmoji test --help`);
|
|
1430
|
+
if (!event && !['approval', 'question', 'todos'].includes(arg)) {
|
|
1431
|
+
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval|question|todos> [session] [tokens] [project] — details: carmoji test --help`);
|
|
1372
1432
|
process.exit(1);
|
|
1373
1433
|
}
|
|
1374
1434
|
const devices = loadDevices();
|
|
@@ -1386,10 +1446,11 @@ async function main() {
|
|
|
1386
1446
|
event: 'approval_request',
|
|
1387
1447
|
tool: 'Bash',
|
|
1388
1448
|
detail: 'carmoji test approval — tap Allow or Deny',
|
|
1449
|
+
always: true,
|
|
1389
1450
|
}, APPROVAL_WAIT_MS))?.decision;
|
|
1390
1451
|
console.log(decision
|
|
1391
1452
|
? `Decision: ${decision}`
|
|
1392
|
-
: 'No answer — in a real session
|
|
1453
|
+
: 'No answer — in a real session the coding agent would fall through to the terminal prompt.');
|
|
1393
1454
|
process.exit(decision ? 0 : 1);
|
|
1394
1455
|
}
|
|
1395
1456
|
if (arg === 'question') {
|
|
@@ -1417,6 +1478,28 @@ async function main() {
|
|
|
1417
1478
|
: 'No answer — in a real session the picker would stay in the terminal.');
|
|
1418
1479
|
process.exit(answer ? 0 : 1);
|
|
1419
1480
|
}
|
|
1481
|
+
if (arg === 'todos') {
|
|
1482
|
+
// Seeds a five-step demo list; raise `done` call by call to walk a
|
|
1483
|
+
// task across the board and fire the completed-item flourish:
|
|
1484
|
+
// carmoji test todos [session] [done 0-5]
|
|
1485
|
+
const [session, doneArg] = rest;
|
|
1486
|
+
const done = Math.max(0, Math.min(5, Number(doneArg ?? 1) || 0));
|
|
1487
|
+
const steps = ['Explore the codebase', 'Write the failing test',
|
|
1488
|
+
'Implement the fix', 'Run the test suite',
|
|
1489
|
+
'Update the docs'];
|
|
1490
|
+
const todos = steps.map((text, index) => ({
|
|
1491
|
+
text,
|
|
1492
|
+
status: index < done ? 'done' : index === done ? 'active' : 'pending',
|
|
1493
|
+
}));
|
|
1494
|
+
const message = { source: 'test', session, host: hostname(),
|
|
1495
|
+
event: 'todos', todos };
|
|
1496
|
+
const results = await sendAll(devices, message);
|
|
1497
|
+
results.forEach((result, index) => {
|
|
1498
|
+
console.log(` ${deviceLabel(devices[index])}: ${
|
|
1499
|
+
result === 'ok' ? 'sent!' : `failed (${result})`}`);
|
|
1500
|
+
});
|
|
1501
|
+
process.exit(results.every((result) => result === 'ok') ? 0 : 1);
|
|
1502
|
+
}
|
|
1420
1503
|
const [session, tokens, project] = rest;
|
|
1421
1504
|
const message = { source: 'test', session, host: hostname(), ...event };
|
|
1422
1505
|
if (tokens !== undefined) message.tokens = Number(tokens);
|
|
@@ -1599,16 +1682,16 @@ tool's dialect (beforeShellExecution, TaskComplete, permission_request, …);
|
|
|
1599
1682
|
see EVENT_ALIASES in carmoji.js for the full mapping. Unknown events are
|
|
1600
1683
|
silently ignored.
|
|
1601
1684
|
|
|
1602
|
-
Blocking: a Claude Code PermissionRequest holds the hook open up to ${APPROVAL_WAIT_MS / 1000}s
|
|
1685
|
+
Blocking: a Claude Code or Codex PermissionRequest holds the hook open up to ${APPROVAL_WAIT_MS / 1000}s
|
|
1603
1686
|
while the phone shows a card, then prints the decision for the agent:
|
|
1604
1687
|
{"hookSpecificOutput":{"hookEventName":"PermissionRequest",
|
|
1605
1688
|
"decision":{"behavior":"allow"}}}
|
|
1606
|
-
AskUserQuestion gets an option-picker card instead when
|
|
1607
|
-
single-select question with 2–4 labeled options; the tapped label
|
|
1608
|
-
free text typed behind the card's "Other…" button, which sends
|
|
1689
|
+
For Claude Code, AskUserQuestion gets an option-picker card instead when
|
|
1690
|
+
it's one single-select question with 2–4 labeled options; the tapped label
|
|
1691
|
+
— or free text typed behind the card's "Other…" button, which sends
|
|
1609
1692
|
{hold:true} to stretch the wait to ${HOLD_WAIT_MS / 1000}s — comes back via
|
|
1610
1693
|
decision.updatedInput.answers. Multi-question and multiSelect stay
|
|
1611
|
-
display-only.
|
|
1694
|
+
display-only; Codex questions use App Server requestUserInput instead.
|
|
1612
1695
|
No tap → no output → the normal terminal prompt appears.
|
|
1613
1696
|
|
|
1614
1697
|
Try it:
|