carmoji 0.3.7 → 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 (2) hide show
  1. package/carmoji.js +81 -3
  2. package/package.json +1 -1
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' };
@@ -617,6 +649,12 @@ const PERMISSION_HOOK_TIMEOUT_S = 150;
617
649
  * `hookSpecificOutput.decision`. */
618
650
  const ANSWERABLE_APPROVAL_SOURCES = new Set(['claude', 'codex']);
619
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
+
620
658
  /** Sources whose question protocol accepts `decision.updatedInput`.
621
659
  * Codex questions use App Server requestUserInput instead of hooks, so a
622
660
  * synthetic AskUserQuestion event must remain display-only. */
@@ -1298,17 +1336,34 @@ async function main() {
1298
1336
  // pleading face + toast; the picker stays in the terminal.
1299
1337
  } else if (payload.tool_name !== 'AskUserQuestion'
1300
1338
  && ANSWERABLE_APPROVAL_SOURCES.has(source)) {
1339
+ const canAlways = ALWAYS_ALLOW_SOURCES.has(source)
1340
+ && Boolean(payload.tool_name);
1301
1341
  const decision = (await requestApprovalAll(devices, {
1302
1342
  ...common,
1303
1343
  event: 'approval_request',
1304
1344
  tool: payload.tool_name,
1305
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 } : {}),
1306
1349
  }, APPROVAL_WAIT_MS))?.decision;
1307
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
+ }
1308
1363
  console.log(JSON.stringify({
1309
1364
  hookSpecificOutput: {
1310
1365
  hookEventName: 'PermissionRequest',
1311
- decision: { behavior: decision },
1366
+ decision: settled,
1312
1367
  },
1313
1368
  }));
1314
1369
  }
@@ -1372,8 +1427,8 @@ async function main() {
1372
1427
 
1373
1428
  case 'test': {
1374
1429
  const event = TEST_EVENTS[arg];
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`);
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`);
1377
1432
  process.exit(1);
1378
1433
  }
1379
1434
  const devices = loadDevices();
@@ -1391,6 +1446,7 @@ async function main() {
1391
1446
  event: 'approval_request',
1392
1447
  tool: 'Bash',
1393
1448
  detail: 'carmoji test approval — tap Allow or Deny',
1449
+ always: true,
1394
1450
  }, APPROVAL_WAIT_MS))?.decision;
1395
1451
  console.log(decision
1396
1452
  ? `Decision: ${decision}`
@@ -1422,6 +1478,28 @@ async function main() {
1422
1478
  : 'No answer — in a real session the picker would stay in the terminal.');
1423
1479
  process.exit(answer ? 0 : 1);
1424
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
+ }
1425
1503
  const [session, tokens, project] = rest;
1426
1504
  const message = { source: 'test', session, host: hostname(), ...event };
1427
1505
  if (tokens !== undefined) message.tokens = Number(tokens);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.7",
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": {