@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. 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
  },
@@ -265,27 +265,6 @@ export declare function __resetAntigravityKeychainCacheForTest(): void;
265
265
  * Sync, no Keychain, no network — safe on the `agents run` hot path.
266
266
  */
267
267
  export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
268
- /**
269
- * Whether a Claude version home holds its OWN on-disk credential — a non-blank
270
- * `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
271
- * or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
272
- *
273
- * RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
274
- * (RUSH-1759) so a token-less default account still authenticates. But Claude —
275
- * and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
276
- * env var over a pinned account's config dir, so when balanced rotation pins a
277
- * specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
278
- * is inert and every fire 401s on the one stale token. A routine spawn drops the
279
- * ambient token when the pinned account has its own credential (this returns
280
- * true) so the account authenticates itself; when it does not (the RUSH-1759
281
- * default), the injected token is kept.
282
- *
283
- * Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
284
- * login Keychain is the canonical store and probing it raises an auth sheet, and
285
- * there the daemon injects no token to shadow (broker-only, usually absent), so
286
- * returning false changes nothing. Sync, no Keychain, no network.
287
- */
288
- export declare function claudeHomeHasOwnCredential(base: string, platform?: NodeJS.Platform): boolean;
289
268
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
290
269
  /**
291
270
  * Determine when the agent was last used by checking session file mtimes,
@@ -1404,43 +1404,6 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1404
1404
  return false;
1405
1405
  }
1406
1406
  }
1407
- /**
1408
- * Whether a Claude version home holds its OWN on-disk credential — a non-blank
1409
- * `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
1410
- * or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
1411
- *
1412
- * RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
1413
- * (RUSH-1759) so a token-less default account still authenticates. But Claude —
1414
- * and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
1415
- * env var over a pinned account's config dir, so when balanced rotation pins a
1416
- * specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
1417
- * is inert and every fire 401s on the one stale token. A routine spawn drops the
1418
- * ambient token when the pinned account has its own credential (this returns
1419
- * true) so the account authenticates itself; when it does not (the RUSH-1759
1420
- * default), the injected token is kept.
1421
- *
1422
- * Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
1423
- * login Keychain is the canonical store and probing it raises an auth sheet, and
1424
- * there the daemon injects no token to shadow (broker-only, usually absent), so
1425
- * returning false changes nothing. Sync, no Keychain, no network.
1426
- */
1427
- export function claudeHomeHasOwnCredential(base, platform = process.platform) {
1428
- if (platform === 'darwin')
1429
- return false;
1430
- if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
1431
- return true;
1432
- try {
1433
- const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1434
- const oauth = JSON.parse(raw).claudeAiOauth;
1435
- if (!oauth)
1436
- return false;
1437
- const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1438
- return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
1439
- }
1440
- catch {
1441
- return false;
1442
- }
1443
- }
1444
1407
  export async function getAccountInfo(agentId, home) {
1445
1408
  const base = home || os.homedir();
1446
1409
  const empty = {
@@ -2,12 +2,13 @@
2
2
  * Background sync for tracked git repos:
3
3
  * - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
4
4
  * - User repo (~/.agents/) and enabled extras may have local commits, so we only
5
- * `git fetch` and write a status marker. Next CLI invocation surfaces a one-line
6
- * notice if upstream is ahead. Pulling is left to the user via `agents repo pull`.
5
+ * `git fetch` and write a status marker. `agents doctor` surfaces these markers
6
+ * as a "Repo updates" section instead of printing to stderr on every command.
7
+ * Pulling is left to the user via `agents repo pull`.
7
8
  *
8
9
  * Public API:
9
- * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
10
- * printPendingUpdateNotices() — synchronous; reads markers and prints + consumes them.
10
+ * spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
11
+ * readRepoBehindMarkers() — synchronous; reads markers without consuming them.
11
12
  */
12
13
  /** Per-repo lock file path. mtime acts as a recency check. */
13
14
  export declare function lockFilePath(alias: string): string;
@@ -24,8 +25,15 @@ export interface FetchStatusMarker {
24
25
  /** Spawn the detached worker. No-op when AGENTS_NO_AUTOPULL=1 is set. */
25
26
  export declare function spawnDetachedSync(): void;
26
27
  /**
27
- * Read any pending status markers and print one-line notices for repos that
28
- * are behind upstream. Markers are deleted after printing so notices don't
29
- * repeat on every invocation. Synchronous, cheap (small JSON files).
28
+ * Read all current status markers and return those where the local repo is
29
+ * behind upstream. Markers are NOT deleted they persist until the next
30
+ * background fetch overwrites them with fresh data.
31
+ *
32
+ * Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
33
+ * repo-behind warnings in one place instead of printing to stderr on every
34
+ * command.
35
+ *
36
+ * @param fetchDir - Override the fetch state dir (for tests). Defaults to the
37
+ * production getFetchCacheDir() path.
30
38
  */
31
- export declare function printPendingUpdateNotices(): void;
39
+ export declare function readRepoBehindMarkers(fetchDir?: string): FetchStatusMarker[];