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.
- package/carmoji.js +131 -7
- 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:
|
|
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
|
-
:
|
|
1341
|
-
|
|
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 &&
|
|
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);
|