@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -34,6 +34,8 @@ export const MILESTONE_EVENTS = [
34
34
  'pushed',
35
35
  'subagent.spawned',
36
36
  'artifact.created',
37
+ 'task.completed',
38
+ 'checklist.created',
37
39
  'status.posted',
38
40
  ];
39
41
  const MILESTONE_SET = new Set(MILESTONE_EVENTS);
@@ -238,6 +240,8 @@ export const EVENT_STYLE = {
238
240
  'pushed': { glyph: '↥', color: chalk.yellow, label: 'pushed' },
239
241
  'subagent.spawned': { glyph: '⑂', color: chalk.magenta, label: 'sub-agent spawned' },
240
242
  'artifact.created': { glyph: '▤', color: chalk.cyan, label: 'artifact' },
243
+ 'task.completed': { glyph: '✓', color: chalk.green, label: 'task completed' },
244
+ 'checklist.created': { glyph: '☐', color: chalk.cyan, label: 'checklist created' },
241
245
  'status.posted': { glyph: '▸', color: chalk.white, label: 'status' },
242
246
  'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
243
247
  };
@@ -266,15 +270,17 @@ export function formatActivityLine(ev, opts = {}) {
266
270
  * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
267
271
  *
268
272
  * Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
269
- * Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
270
- * error is swallowed so a logging hiccup never blocks a tool call.
273
+ * Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
274
+ * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
275
+ * a logging hiccup never blocks a tool call.
271
276
  */
272
277
  export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
273
278
  """Append agent-activity events for \`agents feed\` / \`agents activity\`.
274
279
 
275
280
  Bound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,
276
- MultiEdit). One append-only file per session; read-only tools never trigger it
277
- because the manifest matcher excludes them.
281
+ MultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One
282
+ append-only file per session; read-only tools never trigger it because the
283
+ manifest matcher excludes them.
278
284
 
279
285
  Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
280
286
  sub-agent -- skip so only the top-level agent logs its own activity.
@@ -293,7 +299,7 @@ MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
293
299
  MILESTONE_EVENTS = {
294
300
  "plan.created", "pr.opened", "pr.merged", "worktree.created",
295
301
  "worktree.removed", "commit.created", "pushed", "subagent.spawned",
296
- "artifact.created", "status.posted",
302
+ "artifact.created", "task.completed", "checklist.created", "status.posted",
297
303
  }
298
304
 
299
305
  # Deliverable file types + locations -- a Write here is a recognizable artifact
@@ -304,6 +310,14 @@ ARTIFACT_EXTS = {
304
310
  }
305
311
  ARTIFACT_DIR_HINTS = ("/tmp/", "/downloads/", "/.agents/artifacts/")
306
312
 
313
+ # Checklist tools across harnesses. The value is the key that holds the item list.
314
+ CHECKLIST_TOOLS = {
315
+ "TodoWrite": "todos",
316
+ "todo_write": "todos",
317
+ "TaskUpdate": "tasks",
318
+ "update_plan": "plan",
319
+ }
320
+
307
321
 
308
322
  def is_artifact(file_path):
309
323
  low = (file_path or "").lower()
@@ -379,11 +393,418 @@ def extract_url(tool_response):
379
393
  return m.group(0).rstrip(").,") if m else None
380
394
 
381
395
 
396
+ def _checklist_items(tool_input, kind):
397
+ """Extract normalized checklist items from tool input."""
398
+ if not isinstance(tool_input, dict):
399
+ return []
400
+ if kind == "todos":
401
+ arr = tool_input.get("todos") or []
402
+ elif kind == "tasks":
403
+ arr = tool_input.get("tasks") or []
404
+ if not arr and "taskId" in tool_input:
405
+ arr = [tool_input]
406
+ elif kind == "plan":
407
+ arr = tool_input.get("plan") or []
408
+ else:
409
+ return []
410
+ if not isinstance(arr, list):
411
+ return []
412
+
413
+ items = []
414
+ for t in arr:
415
+ if not isinstance(t, dict):
416
+ continue
417
+ subject = (
418
+ t.get("content") or
419
+ t.get("text") or
420
+ t.get("step") or
421
+ t.get("title") or
422
+ t.get("description") or
423
+ t.get("activeForm") or
424
+ ""
425
+ )
426
+ if not isinstance(subject, str):
427
+ subject = str(subject)
428
+ subject = subject.strip()
429
+ item_id = t.get("id") or t.get("taskId") or subject
430
+ if not item_id:
431
+ continue
432
+ status = str(t.get("status", "") or "").lower()
433
+ items.append({"id": item_id, "subject": subject, "status": status})
434
+ return items
435
+
436
+
437
+ def _read_transcript_checklists(transcript_path, current_tool, current_items):
438
+ """Tail-read the session transcript and return the previous checklist state.
439
+
440
+ The most recent checklist entry is assumed to be the current tool call if
441
+ it carries the same ids; skip that one and return the next older checklist
442
+ state (or None if there isn't one).
443
+ """
444
+ if not transcript_path or not os.path.exists(transcript_path):
445
+ return None
446
+ try:
447
+ size = os.path.getsize(transcript_path)
448
+ start = max(0, size - 512 * 1024)
449
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
450
+ f.seek(start)
451
+ if start > 0:
452
+ f.readline() # drop a leading partial line
453
+ lines = f.readlines()
454
+ except Exception:
455
+ return None
456
+
457
+ current_ids = {str(item.get("id")) for item in current_items if item.get("id")}
458
+ skipped_current = False
459
+ for line in reversed(lines):
460
+ line = line.strip()
461
+ if not line:
462
+ continue
463
+ try:
464
+ record = json.loads(line)
465
+ except Exception:
466
+ continue
467
+ if not isinstance(record, dict):
468
+ continue
469
+
470
+ tool_uses = []
471
+ if isinstance(record.get("tool_use"), list):
472
+ tool_uses = record["tool_use"]
473
+ elif record.get("name"):
474
+ tool_uses = [record]
475
+ elif record.get("tool_name"):
476
+ tool_uses = [record]
477
+
478
+ for tu in reversed(tool_uses):
479
+ if not isinstance(tu, dict):
480
+ continue
481
+ name = tu.get("name") or tu.get("tool_name") or ""
482
+ if name not in CHECKLIST_TOOLS:
483
+ continue
484
+ kind = CHECKLIST_TOOLS[name]
485
+ args = tu.get("input") or tu.get("tool_input") or tu.get("arguments") or {}
486
+ if isinstance(args, str):
487
+ try:
488
+ args = json.loads(args)
489
+ except Exception:
490
+ continue
491
+ items = _checklist_items(args, kind)
492
+ if not items:
493
+ continue
494
+ ids = {str(item.get("id")) for item in items if item.get("id")}
495
+ # Skip the most recent matching checklist entry once; that is the
496
+ # current call already reflected in the transcript.
497
+ if not skipped_current and name == current_tool and ids == current_ids:
498
+ skipped_current = True
499
+ continue
500
+ return items
501
+ return None
502
+
503
+
504
+ def _has_previous_task_create(transcript_path, current_tool_use_id=""):
505
+ """Return True if the transcript contains a TaskCreate before the current one."""
506
+ if not transcript_path or not os.path.exists(transcript_path):
507
+ return False
508
+ try:
509
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
510
+ lines = f.readlines()
511
+ except Exception:
512
+ return False
513
+
514
+ for line in reversed(lines):
515
+ line = line.strip()
516
+ if not line:
517
+ continue
518
+ try:
519
+ record = json.loads(line)
520
+ except Exception:
521
+ continue
522
+ if not isinstance(record, dict):
523
+ continue
524
+
525
+ tool_uses = []
526
+ msg = record.get("message", {})
527
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
528
+ tool_uses = [
529
+ c for c in msg["content"]
530
+ if isinstance(c, dict) and c.get("type") == "tool_use"
531
+ ]
532
+ elif isinstance(record.get("tool_use"), list):
533
+ tool_uses = record["tool_use"]
534
+ elif record.get("name"):
535
+ tool_uses = [record]
536
+
537
+ for tu in reversed(tool_uses):
538
+ if not isinstance(tu, dict):
539
+ continue
540
+ if (tu.get("name") or tu.get("tool_name")) == "TaskCreate":
541
+ if tu.get("id") != current_tool_use_id:
542
+ return True
543
+ return False
544
+
545
+
546
+ def _claude_task_state(transcript_path, exclude_task_id=None):
547
+ """Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.
548
+
549
+ TaskCreate provides the subject (and id via toolUseResult); TaskUpdate
550
+ provides the status. If exclude_task_id is given, the last TaskUpdate for
551
+ that id is skipped (it is the current call already reflected in the
552
+ transcript).
553
+ """
554
+ state = {}
555
+ if not transcript_path or not os.path.exists(transcript_path):
556
+ return state
557
+ try:
558
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
559
+ lines = f.readlines()
560
+ except Exception:
561
+ return state
562
+
563
+ updates = [] # (task_id, status, line_index)
564
+ for idx, line in enumerate(lines):
565
+ line = line.strip()
566
+ if not line:
567
+ continue
568
+ try:
569
+ record = json.loads(line)
570
+ except Exception:
571
+ continue
572
+ if not isinstance(record, dict):
573
+ continue
574
+
575
+ tool_uses = []
576
+ msg = record.get("message", {})
577
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
578
+ tool_uses = [
579
+ c for c in msg["content"]
580
+ if isinstance(c, dict) and c.get("type") == "tool_use"
581
+ ]
582
+ elif isinstance(record.get("tool_use"), list):
583
+ tool_uses = record["tool_use"]
584
+ elif record.get("name"):
585
+ tool_uses = [record]
586
+
587
+ for tu in tool_uses:
588
+ if not isinstance(tu, dict):
589
+ continue
590
+ name = tu.get("name") or ""
591
+ args = tu.get("input") or tu.get("tool_input") or {}
592
+ if isinstance(args, str):
593
+ try:
594
+ args = json.loads(args)
595
+ except Exception:
596
+ continue
597
+ if name == "TaskCreate":
598
+ subject = (
599
+ args.get("subject") or
600
+ args.get("description") or
601
+ args.get("title") or
602
+ ""
603
+ )
604
+ if subject:
605
+ # Link subject to the tool_use id so the result can map it.
606
+ tool_id = tu.get("id")
607
+ if tool_id:
608
+ state.setdefault("__pending_subject", {})[tool_id] = subject
609
+
610
+ tool_result = record.get("toolUseResult") or {}
611
+ if not isinstance(tool_result, dict):
612
+ continue
613
+ task = tool_result.get("task")
614
+ if isinstance(task, dict):
615
+ task_id = str(task.get("id") or task.get("taskId") or "")
616
+ if task_id:
617
+ state.setdefault(task_id, {})
618
+ if task.get("subject"):
619
+ state[task_id]["subject"] = task["subject"]
620
+ if task.get("status"):
621
+ state[task_id]["status"] = str(task.get("status")).lower()
622
+ # Link any pending subject from the matching tool_use id.
623
+ tool_use_id = record.get("tool_use_id") or ""
624
+ pending = state.get("__pending_subject", {})
625
+ if tool_use_id and tool_use_id in pending:
626
+ state[task_id]["subject"] = pending[tool_use_id]
627
+ task_id = str(tool_result.get("taskId") or "")
628
+ status_change = tool_result.get("statusChange") or {}
629
+ status = status_change.get("to") or tool_result.get("status")
630
+ if task_id and status:
631
+ state.setdefault(task_id, {})
632
+ state[task_id]["status"] = str(status).lower()
633
+ updates.append((task_id, str(status).lower(), idx))
634
+
635
+ if exclude_task_id and updates:
636
+ for i in range(len(updates) - 1, -1, -1):
637
+ if updates[i][0] == exclude_task_id:
638
+ del updates[i]
639
+ break
640
+ # Rebuild statuses from remaining updates.
641
+ for task_id in list(state.keys()):
642
+ if task_id.startswith("__"):
643
+ continue
644
+ if "subject" not in state[task_id]:
645
+ del state[task_id]
646
+ else:
647
+ state[task_id].pop("status", None)
648
+ for task_id, status, _ in updates:
649
+ if task_id in state:
650
+ state[task_id]["status"] = status
651
+
652
+ # Drop tasks removed from the checklist (deleted/cancelled): they are gone
653
+ # from the user-visible list, so they must not count toward the N/M total.
654
+ for task_id in list(state.keys()):
655
+ if task_id.startswith("__"):
656
+ continue
657
+ if state[task_id].get("status") in ("deleted", "cancelled", "canceled", "removed"):
658
+ del state[task_id]
659
+
660
+ # Drop internal bookkeeping.
661
+ state.pop("__pending_subject", None)
662
+ return state
663
+
664
+
665
+ def _checklist_events(payload, hook_event):
666
+ """Return a list of (event, detail) tuples for checklist tool calls."""
667
+ if hook_event != "PostToolUse":
668
+ return []
669
+ tool_name = payload.get("tool_name", "")
670
+ tool_input = payload.get("tool_input", {}) or {}
671
+
672
+ if tool_name == "TaskCreate":
673
+ subject = (
674
+ tool_input.get("subject") or
675
+ tool_input.get("description") or
676
+ tool_input.get("title") or
677
+ "task"
678
+ )
679
+ # Only announce the checklist on the very first task creation.
680
+ current_tool_use_id = payload.get("tool_use_id", "")
681
+ transcript_path = payload.get("transcript_path")
682
+ if _has_previous_task_create(transcript_path, current_tool_use_id):
683
+ return []
684
+ return [("checklist.created", subject)]
685
+
686
+ if tool_name not in CHECKLIST_TOOLS:
687
+ return []
688
+
689
+ kind = CHECKLIST_TOOLS[tool_name]
690
+ items = _checklist_items(tool_input, kind)
691
+ if not items:
692
+ return []
693
+
694
+ # Full-list tools (TodoWrite, update_plan) send the whole checklist every
695
+ # time. Per-task update tools (TaskUpdate) send only the changed item, so
696
+ # totals and subject must be resolved from the previous full-list state.
697
+ list_key = {"todos": "todos", "tasks": "tasks", "plan": "plan"}.get(kind)
698
+ is_full_list = list_key is not None and list_key in tool_input
699
+
700
+ previous = _read_transcript_checklists(
701
+ payload.get("transcript_path"), tool_name, items
702
+ )
703
+
704
+ if not is_full_list and previous:
705
+ total = len(previous)
706
+ previous_done = {
707
+ str(i.get("id")) for i in previous
708
+ if i.get("status") == "completed" and i.get("id")
709
+ }
710
+ done_count = len(previous_done)
711
+ events = []
712
+ for item in items:
713
+ if item.get("status") != "completed":
714
+ continue
715
+ item_id = str(item.get("id"))
716
+ if item_id in previous_done:
717
+ continue
718
+ subject = item.get("subject")
719
+ if not subject:
720
+ for p in previous:
721
+ if str(p.get("id")) == item_id and p.get("subject"):
722
+ subject = p["subject"]
723
+ break
724
+ if not subject:
725
+ subject = "task"
726
+ done_count += 1
727
+ events.append(("task.completed", f"{subject} {done_count}/{total} done"))
728
+ return events
729
+
730
+ # For TaskUpdate without a previous full-list state, fold the Claude
731
+ # TaskCreate/TaskUpdate history to resolve subject and N/M.
732
+ if tool_name == "TaskUpdate" and previous is None:
733
+ task_id = str(items[0].get("id")) if items else ""
734
+ task_state = _claude_task_state(
735
+ payload.get("transcript_path"), exclude_task_id=task_id
736
+ )
737
+ if task_state:
738
+ total = len(task_state)
739
+ previous_done = {
740
+ tid for tid, info in task_state.items()
741
+ if info.get("status") == "completed"
742
+ }
743
+ done_count = len(previous_done)
744
+ if task_id in task_state and items[0].get("status") == "completed" and task_id not in previous_done:
745
+ subject = task_state[task_id].get("subject") or "task"
746
+ return [("task.completed", f"{subject} {done_count + 1}/{total} done")]
747
+ return []
748
+
749
+ total = len(items)
750
+ completed = [i for i in items if i.get("status") == "completed"]
751
+
752
+ if previous is None:
753
+ # First checklist call in this session.
754
+ events = []
755
+ events.append(("checklist.created", f"{total} task{'s' if total != 1 else ''}"))
756
+ for item in completed:
757
+ events.append((
758
+ "task.completed",
759
+ f"{item['subject']} {len(completed)}/{total} done",
760
+ ))
761
+ return events
762
+
763
+ previous_done = {
764
+ str(i.get("id")) for i in previous
765
+ if i.get("status") == "completed" and i.get("id")
766
+ }
767
+ newly_completed = [
768
+ i for i in completed
769
+ if str(i.get("id")) not in previous_done
770
+ ]
771
+ if not newly_completed:
772
+ return []
773
+
774
+ done_count = len(completed)
775
+ events = []
776
+ for item in newly_completed:
777
+ events.append((
778
+ "task.completed",
779
+ f"{item['subject']} {done_count}/{total} done",
780
+ ))
781
+ return events
782
+
783
+
784
+ def _make_record(event, detail, tool_name):
785
+ tier = "milestone" if event in MILESTONE_EVENTS else "activity"
786
+ record = {
787
+ "v": 1,
788
+ "ts": datetime.now(timezone.utc).isoformat(),
789
+ "event": event,
790
+ "tier": tier,
791
+ }
792
+ if detail:
793
+ record["detail"] = detail
794
+ record["tool"] = tool_name
795
+ return record
796
+
797
+
382
798
  def build_event(payload, hook_event):
383
799
  tool_name = payload.get("tool_name", "")
384
800
  tool_input = payload.get("tool_input", {}) or {}
385
801
  tool_response = payload.get("tool_response", {})
386
802
 
803
+ # Checklist completions first -- cheap guard above already filtered tool name.
804
+ checklist = _checklist_events(payload, hook_event)
805
+ if checklist:
806
+ return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name
807
+
387
808
  event = None
388
809
  detail = None
389
810
  url = None
@@ -414,21 +835,12 @@ def build_event(payload, hook_event):
414
835
  detail = os.path.basename(fp) if fp else tool_name
415
836
 
416
837
  if not event:
417
- return None, tool_name
838
+ return [], tool_name
418
839
 
419
- tier = "milestone" if event in MILESTONE_EVENTS else "activity"
420
- record = {
421
- "v": 1,
422
- "ts": datetime.now(timezone.utc).isoformat(),
423
- "event": event,
424
- "tier": tier,
425
- }
426
- if detail:
427
- record["detail"] = detail
840
+ record = _make_record(event, detail, tool_name)
428
841
  if url:
429
842
  record["url"] = url
430
- record["tool"] = tool_name
431
- return record, tool_name
843
+ return [record], tool_name
432
844
 
433
845
 
434
846
  def main():
@@ -447,8 +859,8 @@ def main():
447
859
  return
448
860
 
449
861
  hook_event = payload.get("hook_event_name", "")
450
- record, tool_name = build_event(payload, hook_event)
451
- if not record:
862
+ records, tool_name = build_event(payload, hook_event)
863
+ if not records:
452
864
  return
453
865
 
454
866
  safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id) or "unknown"
@@ -463,27 +875,29 @@ def main():
463
875
  hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
464
876
  host = re.sub(r"[^a-z0-9_-]", "-", hostname.split(".")[0].strip().lower()) or "unknown"
465
877
 
466
- record["sessionId"] = session_id
467
- record["mailboxId"] = mailbox_id
468
- record["host"] = host
469
- record["runtime"] = os.environ.get("AGENTS_RUNTIME", "headless")
470
- cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
471
- if cwd:
472
- record["cwd"] = cwd
473
- agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
474
- record["agent"] = agent
878
+ for record in records:
879
+ record["sessionId"] = session_id
880
+ record["mailboxId"] = mailbox_id
881
+ record["host"] = host
882
+ record["runtime"] = os.environ.get("AGENTS_RUNTIME", "headless")
883
+ cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
884
+ if cwd:
885
+ record["cwd"] = cwd
886
+ agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
887
+ record["agent"] = agent
475
888
 
476
889
  try:
477
890
  os.makedirs(activity_dir, exist_ok=True)
478
891
  # Cap growth: once over the size limit, keep only milestones.
479
- if record["tier"] != "milestone":
480
- try:
481
- if os.path.getsize(target) > MAX_LOG_BYTES:
482
- return
483
- except OSError:
484
- pass
892
+ try:
893
+ over_limit = os.path.getsize(target) > MAX_LOG_BYTES
894
+ except OSError:
895
+ over_limit = False
485
896
  with open(target, "a") as f:
486
- f.write(json.dumps(record) + "\\n")
897
+ for record in records:
898
+ if over_limit and record.get("tier") != "milestone":
899
+ continue
900
+ f.write(json.dumps(record) + "\\n")
487
901
  except Exception:
488
902
  pass # fail open
489
903
 
@@ -506,7 +920,7 @@ export const ACTIVITY_HOOK_DEFINITIONS = {
506
920
  'activity-log-result': {
507
921
  agents: ['claude'],
508
922
  events: ['PostToolUse'],
509
- matcher: 'Bash|Write|Edit|MultiEdit',
923
+ matcher: 'Bash|Write|Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate',
510
924
  script: '11-activity-log.py',
511
925
  timeout: 5,
512
926
  },
@@ -17,7 +17,7 @@
17
17
  * nothing to resolve or resume by id.
18
18
  */
19
19
  import { upsertSession } from '../session/db.js';
20
- import { SESSION_AGENTS } from '../session/types.js';
20
+ import { isSessionTrackedAgent } from '../session/types.js';
21
21
  import { deriveShortId } from '../session/short-id.js';
22
22
  /** The execution-id charset the session index will accept as a row id. */
23
23
  const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
@@ -29,7 +29,7 @@ const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
29
29
  * failed index write must never break the dispatch/poll it rides on.
30
30
  */
31
31
  export function registerCloudSession(task, ctx = {}) {
32
- if (!SESSION_AGENTS.includes(task.agent))
32
+ if (!task.agent || !isSessionTrackedAgent(task.agent))
33
33
  return;
34
34
  if (!task.id || !EXECUTION_ID_RE.test(task.id))
35
35
  return;
@@ -81,6 +81,15 @@ export declare function runDaemon(): Promise<void>;
81
81
  export declare function readDaemonClaudeOAuthToken(opts?: {
82
82
  allowPrompt?: boolean;
83
83
  }): string | null;
84
+ /**
85
+ * Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
86
+ * per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
87
+ * never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
88
+ * the bundle can't be read (broker-only headless miss, absent bundle, etc.).
89
+ */
90
+ export declare function readDaemonClaudeBundleEnv(opts?: {
91
+ allowPrompt?: boolean;
92
+ }): Record<string, string>;
84
93
  /**
85
94
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
86
95
  *
@@ -139,7 +148,7 @@ export declare function ensureDaemonStarted(): {
139
148
  * the daemon then passes it to every routine run it spawns. An already-set
140
149
  * value (e.g. inherited from launchd) is left untouched.
141
150
  */
142
- export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oauthToken?: string | null): NodeJS.ProcessEnv;
151
+ export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, bundleEnv?: Record<string, string>): NodeJS.ProcessEnv;
143
152
  /**
144
153
  * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
145
154
  * exact form that works under a direct `__daemon-run`.