carmoji 0.3.7 → 0.3.9

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 +131 -7
  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' };
@@ -579,9 +611,50 @@ function sessionTokens(payload) {
579
611
  return state.total;
580
612
  }
581
613
 
614
+ /**
615
+ * The agent's final say for the turn. Hooks never carry assistant text,
616
+ * but Stop hands over transcript_path, and the JSONL's tail holds the
617
+ * reply — Claude records {type:"assistant", message:{content:[{type:
618
+ * "text",text}]}}, Codex rollouts {payload:{type:"agent_message",message}}.
619
+ */
620
+ function lastAssistantText(payload) {
621
+ try {
622
+ const transcript = payload.transcript_path;
623
+ if (!transcript) return undefined;
624
+ const size = statSync(transcript).size;
625
+ const window = Math.min(size, 256 * 1024);
626
+ if (window === 0) return undefined;
627
+ const fd = openSync(transcript, 'r');
628
+ const buffer = Buffer.alloc(window);
629
+ const bytesRead = readSync(fd, buffer, 0, window, size - window);
630
+ closeSync(fd);
631
+ const lines = buffer.subarray(0, bytesRead).toString('utf8').split('\n');
632
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
633
+ const line = lines[i];
634
+ if (!line.includes('"assistant"') && !line.includes('agent_message')) continue;
635
+ try {
636
+ const record = JSON.parse(line);
637
+ if (record.type === 'assistant') {
638
+ const texts = (record.message?.content ?? [])
639
+ .filter((block) => block.type === 'text' && block.text)
640
+ .map((block) => block.text);
641
+ if (texts.length) return texts.join(' ');
642
+ }
643
+ if (record.payload?.type === 'agent_message' && record.payload.message) {
644
+ return String(record.payload.message);
645
+ }
646
+ } catch { /* the window's first line may be cut mid-record */ }
647
+ }
648
+ } catch { /* no transcript, no say */ }
649
+ return undefined;
650
+ }
651
+
582
652
  const TEST_EVENTS = {
583
653
  start: { event: 'session_start' },
584
- prompt: { event: 'prompt' },
654
+ prompt: { event: 'prompt', detail: 'Fix the corner release bug' },
655
+ say: { event: 'turn_done',
656
+ detail: 'Done — corners now release on yaw alone with an 8s cap, '
657
+ + 'and cornerEnded bypasses every gate so the lean never sticks.' },
585
658
  edit: { event: 'tool_start', tool: 'Edit' },
586
659
  read: { event: 'tool_start', tool: 'Read' },
587
660
  run: { event: 'tool_start', tool: 'Bash' },
@@ -617,6 +690,12 @@ const PERMISSION_HOOK_TIMEOUT_S = 150;
617
690
  * `hookSpecificOutput.decision`. */
618
691
  const ANSWERABLE_APPROVAL_SOURCES = new Set(['claude', 'codex']);
619
692
 
693
+ /** Sources whose decision also honors `updatedPermissions` — lets the phone
694
+ * offer "Always allow" (a session-scoped rule, mirroring the terminal
695
+ * dialog's don't-ask-again row). Codex hooks ignore updatedPermissions, so
696
+ * its card stays Allow/Deny. */
697
+ const ALWAYS_ALLOW_SOURCES = new Set(['claude']);
698
+
620
699
  /** Sources whose question protocol accepts `decision.updatedInput`.
621
700
  * Codex questions use App Server requestUserInput instead of hooks, so a
622
701
  * synthetic AskUserQuestion event must remain display-only. */
@@ -1298,17 +1377,34 @@ async function main() {
1298
1377
  // pleading face + toast; the picker stays in the terminal.
1299
1378
  } else if (payload.tool_name !== 'AskUserQuestion'
1300
1379
  && ANSWERABLE_APPROVAL_SOURCES.has(source)) {
1380
+ const canAlways = ALWAYS_ALLOW_SOURCES.has(source)
1381
+ && Boolean(payload.tool_name);
1301
1382
  const decision = (await requestApprovalAll(devices, {
1302
1383
  ...common,
1303
1384
  event: 'approval_request',
1304
1385
  tool: payload.tool_name,
1305
1386
  detail: summarizeToolInput(payload),
1387
+ // Tells the phone it may offer an "Always allow" button;
1388
+ // older app builds don't know the field and show Allow/Deny.
1389
+ ...(canAlways ? { always: true } : {}),
1306
1390
  }, APPROVAL_WAIT_MS))?.decision;
1307
1391
  if (decision) {
1392
+ const settled = { behavior: decision === 'always' ? 'allow' : decision };
1393
+ if (decision === 'always' && canAlways) {
1394
+ // Session-scoped don't-ask-again rule. A bare toolName rule
1395
+ // allows every use of the tool; MCP tools (mcp__server__tool)
1396
+ // in particular reject a ruleContent specifier.
1397
+ settled.updatedPermissions = [{
1398
+ type: 'addRules',
1399
+ rules: [{ toolName: payload.tool_name }],
1400
+ behavior: 'allow',
1401
+ destination: 'session',
1402
+ }];
1403
+ }
1308
1404
  console.log(JSON.stringify({
1309
1405
  hookSpecificOutput: {
1310
1406
  hookEventName: 'PermissionRequest',
1311
- decision: { behavior: decision },
1407
+ decision: settled,
1312
1408
  },
1313
1409
  }));
1314
1410
  }
@@ -1330,15 +1426,20 @@ async function main() {
1330
1426
  // project directory, so fall back to that.
1331
1427
  const cwd = payload.cwd || process.cwd();
1332
1428
  if (cwd) message.project = basename(cwd);
1333
- // A snippet of human-readable context for the phone to show.
1429
+ // A snippet of human-readable context for the phone to show. The
1430
+ // turn's reply gets more room than a toast line — it feeds the
1431
+ // reviewable chat history, not just a caption.
1334
1432
  const detail = event.event === 'prompt'
1335
1433
  ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
1336
1434
  payload.message, payload.input, payload.text)
1337
1435
  : event.event === 'permission'
1338
1436
  ? firstString(payload.message, payload.detail,
1339
1437
  payload.tool_input ? summarizeToolInput(payload) : undefined)
1340
- : undefined;
1341
- if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
1438
+ : event.event === 'turn_done'
1439
+ ? lastAssistantText(payload)
1440
+ : undefined;
1441
+ const detailCap = event.event === 'turn_done' ? 400 : 140;
1442
+ if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, detailCap);
1342
1443
  // Plan-window usage comes from Claude Code's local transcripts;
1343
1444
  // recompute only at turn boundaries.
1344
1445
  if (source === 'claude') {
@@ -1372,8 +1473,8 @@ async function main() {
1372
1473
 
1373
1474
  case 'test': {
1374
1475
  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`);
1476
+ if (!event && !['approval', 'question', 'todos'].includes(arg)) {
1477
+ console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}|approval|question|todos> [session] [tokens] [project] — details: carmoji test --help`);
1377
1478
  process.exit(1);
1378
1479
  }
1379
1480
  const devices = loadDevices();
@@ -1391,6 +1492,7 @@ async function main() {
1391
1492
  event: 'approval_request',
1392
1493
  tool: 'Bash',
1393
1494
  detail: 'carmoji test approval — tap Allow or Deny',
1495
+ always: true,
1394
1496
  }, APPROVAL_WAIT_MS))?.decision;
1395
1497
  console.log(decision
1396
1498
  ? `Decision: ${decision}`
@@ -1422,6 +1524,28 @@ async function main() {
1422
1524
  : 'No answer — in a real session the picker would stay in the terminal.');
1423
1525
  process.exit(answer ? 0 : 1);
1424
1526
  }
1527
+ if (arg === 'todos') {
1528
+ // Seeds a five-step demo list; raise `done` call by call to walk a
1529
+ // task across the board and fire the completed-item flourish:
1530
+ // carmoji test todos [session] [done 0-5]
1531
+ const [session, doneArg] = rest;
1532
+ const done = Math.max(0, Math.min(5, Number(doneArg ?? 1) || 0));
1533
+ const steps = ['Explore the codebase', 'Write the failing test',
1534
+ 'Implement the fix', 'Run the test suite',
1535
+ 'Update the docs'];
1536
+ const todos = steps.map((text, index) => ({
1537
+ text,
1538
+ status: index < done ? 'done' : index === done ? 'active' : 'pending',
1539
+ }));
1540
+ const message = { source: 'test', session, host: hostname(),
1541
+ event: 'todos', todos };
1542
+ const results = await sendAll(devices, message);
1543
+ results.forEach((result, index) => {
1544
+ console.log(` ${deviceLabel(devices[index])}: ${
1545
+ result === 'ok' ? 'sent!' : `failed (${result})`}`);
1546
+ });
1547
+ process.exit(results.every((result) => result === 'ok') ? 0 : 1);
1548
+ }
1425
1549
  const [session, tokens, project] = rest;
1426
1550
  const message = { source: 'test', session, host: hostname(), ...event };
1427
1551
  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.9",
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": {