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.
Files changed (3) hide show
  1. package/README.md +12 -11
  2. package/carmoji.js +100 -17
  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,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
- 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`
175
- 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.
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 a decision back via
617
- * `hookSpecificOutput.decision` (Claude Code's schema). Codex is the next
618
- * candidate once its hooks are verified to honor the reply. */
619
- const ANSWERABLE_PERMISSION_SOURCES = new Set(['claude']);
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: { behavior: 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 && arg !== 'approval' && arg !== 'question') {
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 Claude Code would fall through to the terminal prompt.');
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 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
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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": {