@phnx-labs/agents-cli 1.20.72 → 1.20.73

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 (219) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/feed.d.ts +13 -0
  16. package/dist/commands/feed.js +130 -29
  17. package/dist/commands/hq.d.ts +2 -0
  18. package/dist/commands/hq.js +58 -0
  19. package/dist/commands/lease.d.ts +19 -0
  20. package/dist/commands/lease.js +138 -11
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.js +123 -0
  23. package/dist/commands/logs.js +1 -1
  24. package/dist/commands/mine.d.ts +27 -0
  25. package/dist/commands/mine.js +207 -0
  26. package/dist/commands/monitors.js +79 -49
  27. package/dist/commands/plugins.js +1 -0
  28. package/dist/commands/profiles.js +158 -0
  29. package/dist/commands/repo.js +2 -60
  30. package/dist/commands/resources.d.ts +5 -0
  31. package/dist/commands/resources.js +95 -0
  32. package/dist/commands/routines.d.ts +2 -0
  33. package/dist/commands/routines.js +139 -37
  34. package/dist/commands/secrets.js +234 -47
  35. package/dist/commands/send.d.ts +15 -0
  36. package/dist/commands/send.js +62 -0
  37. package/dist/commands/sessions-tail.d.ts +1 -0
  38. package/dist/commands/sessions-tail.js +20 -9
  39. package/dist/commands/sessions.js +18 -3
  40. package/dist/commands/setup-mine.d.ts +18 -0
  41. package/dist/commands/setup-mine.js +106 -0
  42. package/dist/commands/setup-share.js +2 -2
  43. package/dist/commands/setup.js +3 -1
  44. package/dist/commands/share.d.ts +8 -1
  45. package/dist/commands/share.js +122 -37
  46. package/dist/commands/ssh.d.ts +9 -0
  47. package/dist/commands/ssh.js +196 -26
  48. package/dist/commands/sync.js +1 -1
  49. package/dist/commands/teams.js +11 -2
  50. package/dist/index.js +79 -10
  51. package/dist/lib/activity.d.ts +124 -0
  52. package/dist/lib/activity.js +542 -0
  53. package/dist/lib/agents.js +16 -5
  54. package/dist/lib/artifact-actions.d.ts +1 -1
  55. package/dist/lib/artifact-actions.js +1 -1
  56. package/dist/lib/ask-classifier.d.ts +2 -1
  57. package/dist/lib/ask-classifier.js +3 -3
  58. package/dist/lib/auth-health.d.ts +32 -4
  59. package/dist/lib/auth-health.js +40 -1
  60. package/dist/lib/auto-pull.js +8 -1
  61. package/dist/lib/brand.d.ts +40 -0
  62. package/dist/lib/brand.js +122 -0
  63. package/dist/lib/browser/drivers/ssh.js +4 -7
  64. package/dist/lib/browser/service.js +6 -8
  65. package/dist/lib/channels/providers/index.d.ts +2 -0
  66. package/dist/lib/channels/providers/index.js +20 -0
  67. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  68. package/dist/lib/channels/providers/mailbox.js +26 -0
  69. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  70. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  71. package/dist/lib/channels/providers/rush.d.ts +6 -0
  72. package/dist/lib/channels/providers/rush.js +55 -0
  73. package/dist/lib/channels/registry.d.ts +42 -0
  74. package/dist/lib/channels/registry.js +20 -0
  75. package/dist/lib/channels/resolve.d.ts +11 -0
  76. package/dist/lib/channels/resolve.js +12 -0
  77. package/dist/lib/cli-resources.d.ts +18 -0
  78. package/dist/lib/cli-resources.js +53 -4
  79. package/dist/lib/cloud/codex.js +6 -3
  80. package/dist/lib/cloud/factory.d.ts +1 -0
  81. package/dist/lib/cloud/factory.js +10 -4
  82. package/dist/lib/cloud/rush.d.ts +3 -1
  83. package/dist/lib/cloud/rush.js +6 -1
  84. package/dist/lib/cloud/types.d.ts +2 -0
  85. package/dist/lib/codex-home.d.ts +35 -0
  86. package/dist/lib/codex-home.js +136 -0
  87. package/dist/lib/crabbox/cli.d.ts +44 -0
  88. package/dist/lib/crabbox/cli.js +120 -20
  89. package/dist/lib/crabbox/lease.d.ts +52 -1
  90. package/dist/lib/crabbox/lease.js +117 -16
  91. package/dist/lib/crabbox/progress.d.ts +31 -0
  92. package/dist/lib/crabbox/progress.js +76 -0
  93. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  94. package/dist/lib/crabbox/runtimes.js +33 -8
  95. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  96. package/dist/lib/crabbox/setup-copy.js +127 -0
  97. package/dist/lib/daemon.d.ts +5 -5
  98. package/dist/lib/daemon.js +9 -9
  99. package/dist/lib/drive-sync.js +6 -3
  100. package/dist/lib/event-stream.d.ts +36 -0
  101. package/dist/lib/event-stream.js +68 -0
  102. package/dist/lib/events.d.ts +1 -1
  103. package/dist/lib/exec.d.ts +11 -0
  104. package/dist/lib/exec.js +23 -2
  105. package/dist/lib/feed-policy.d.ts +1 -1
  106. package/dist/lib/feed-policy.js +14 -9
  107. package/dist/lib/feed-ranking.d.ts +32 -0
  108. package/dist/lib/feed-ranking.js +224 -0
  109. package/dist/lib/feed.d.ts +35 -2
  110. package/dist/lib/feed.js +66 -3
  111. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  112. package/dist/lib/fleet/auth-sync.js +92 -0
  113. package/dist/lib/fleet/remote-login.d.ts +187 -0
  114. package/dist/lib/fleet/remote-login.js +556 -0
  115. package/dist/lib/format.d.ts +30 -3
  116. package/dist/lib/format.js +34 -5
  117. package/dist/lib/fs-atomic.d.ts +7 -2
  118. package/dist/lib/fs-atomic.js +8 -12
  119. package/dist/lib/git.d.ts +16 -0
  120. package/dist/lib/git.js +79 -2
  121. package/dist/lib/heal.js +11 -3
  122. package/dist/lib/hosts/progress.d.ts +28 -10
  123. package/dist/lib/hosts/progress.js +79 -22
  124. package/dist/lib/hosts/remote-cmd.js +4 -0
  125. package/dist/lib/hq/floor.d.ts +87 -0
  126. package/dist/lib/hq/floor.js +226 -0
  127. package/dist/lib/menubar/install-menubar.js +14 -20
  128. package/dist/lib/notify.d.ts +1 -0
  129. package/dist/lib/notify.js +3 -3
  130. package/dist/lib/open-url.d.ts +2 -0
  131. package/dist/lib/open-url.js +19 -0
  132. package/dist/lib/openclaw-keychain.d.ts +56 -0
  133. package/dist/lib/openclaw-keychain.js +236 -0
  134. package/dist/lib/overdue.js +10 -0
  135. package/dist/lib/permissions.d.ts +44 -1
  136. package/dist/lib/permissions.js +284 -7
  137. package/dist/lib/project-launch.d.ts +6 -12
  138. package/dist/lib/project-launch.js +13 -228
  139. package/dist/lib/project-resources.d.ts +7 -0
  140. package/dist/lib/project-resources.js +291 -0
  141. package/dist/lib/pty-client.d.ts +27 -0
  142. package/dist/lib/pty-client.js +136 -10
  143. package/dist/lib/refresh.js +14 -10
  144. package/dist/lib/resource-profiles.d.ts +26 -0
  145. package/dist/lib/resource-profiles.js +157 -0
  146. package/dist/lib/resources/permissions.js +7 -1
  147. package/dist/lib/resources/types.d.ts +1 -1
  148. package/dist/lib/resources.d.ts +1 -1
  149. package/dist/lib/resources.js +32 -3
  150. package/dist/lib/routines.d.ts +11 -0
  151. package/dist/lib/routines.js +56 -15
  152. package/dist/lib/runner.d.ts +1 -0
  153. package/dist/lib/runner.js +66 -5
  154. package/dist/lib/secrets/bundles.d.ts +16 -3
  155. package/dist/lib/secrets/bundles.js +206 -37
  156. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  157. package/dist/lib/secrets/icloud-import.js +9 -6
  158. package/dist/lib/secrets/mcp.js +1 -1
  159. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  160. package/dist/lib/secrets/vault-age-helper.js +34 -0
  161. package/dist/lib/secrets/vault.d.ts +49 -0
  162. package/dist/lib/secrets/vault.js +397 -0
  163. package/dist/lib/self-heal/checks/path.js +3 -1
  164. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  165. package/dist/lib/self-heal/checks/shims.js +19 -11
  166. package/dist/lib/session/cloud.d.ts +2 -2
  167. package/dist/lib/session/cloud.js +2 -2
  168. package/dist/lib/session/db.d.ts +4 -0
  169. package/dist/lib/session/db.js +44 -7
  170. package/dist/lib/session/discover.d.ts +2 -0
  171. package/dist/lib/session/discover.js +114 -3
  172. package/dist/lib/session/stream-render.d.ts +3 -0
  173. package/dist/lib/session/stream-render.js +130 -0
  174. package/dist/lib/session/types.d.ts +6 -0
  175. package/dist/lib/share/analytics.d.ts +13 -0
  176. package/dist/lib/share/analytics.js +45 -0
  177. package/dist/lib/share/config.d.ts +19 -5
  178. package/dist/lib/share/config.js +44 -8
  179. package/dist/lib/share/provision.d.ts +58 -6
  180. package/dist/lib/share/provision.js +97 -22
  181. package/dist/lib/share/publish.d.ts +36 -13
  182. package/dist/lib/share/publish.js +55 -8
  183. package/dist/lib/share/worker-template.js +83 -17
  184. package/dist/lib/shims.d.ts +9 -0
  185. package/dist/lib/shims.js +91 -13
  186. package/dist/lib/ssh-exec.d.ts +14 -0
  187. package/dist/lib/ssh-exec.js +57 -0
  188. package/dist/lib/staleness/detectors/hooks.js +25 -1
  189. package/dist/lib/staleness/detectors/permissions.js +66 -0
  190. package/dist/lib/staleness/detectors/workflows.js +20 -0
  191. package/dist/lib/staleness/writers/hooks.js +58 -4
  192. package/dist/lib/staleness/writers/permissions.js +2 -2
  193. package/dist/lib/staleness/writers/skills.js +1 -1
  194. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  195. package/dist/lib/staleness/writers/sources.js +68 -1
  196. package/dist/lib/star-nudge.d.ts +45 -0
  197. package/dist/lib/star-nudge.js +91 -0
  198. package/dist/lib/startup/command-registry.d.ts +6 -1
  199. package/dist/lib/startup/command-registry.js +17 -3
  200. package/dist/lib/state.d.ts +2 -0
  201. package/dist/lib/state.js +3 -0
  202. package/dist/lib/subagents-registry.d.ts +2 -0
  203. package/dist/lib/subagents-registry.js +3 -0
  204. package/dist/lib/teams/parsers.js +214 -6
  205. package/dist/lib/teams/supervisor.d.ts +7 -0
  206. package/dist/lib/teams/supervisor.js +2 -1
  207. package/dist/lib/template.d.ts +1 -1
  208. package/dist/lib/template.js +1 -1
  209. package/dist/lib/triggers/webhook.js +36 -3
  210. package/dist/lib/types.d.ts +73 -0
  211. package/dist/lib/version.d.ts +40 -0
  212. package/dist/lib/version.js +94 -16
  213. package/dist/lib/versions.d.ts +11 -0
  214. package/dist/lib/versions.js +208 -76
  215. package/dist/lib/workflows.d.ts +2 -0
  216. package/dist/lib/workflows.js +88 -0
  217. package/package.json +2 -1
  218. package/dist/commands/daemon.d.ts +0 -10
  219. package/dist/commands/daemon.js +0 -121
@@ -0,0 +1,124 @@
1
+ import type { EventRecord } from './events.js';
2
+ /** Recognizable milestone events, ordered first in any activity lane. */
3
+ export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created';
4
+ /** Routine activity events, collapsed to counts by readers. */
5
+ export type ActivityKind = 'file.edited';
6
+ export type ActivityEventKind = MilestoneEvent | ActivityKind;
7
+ export type ActivityTier = 'milestone' | 'activity';
8
+ /** The set of milestone events, for tier classification and ordering. */
9
+ export declare const MILESTONE_EVENTS: readonly MilestoneEvent[];
10
+ export declare function tierForEvent(event: string): ActivityTier;
11
+ export interface ActivityEvent {
12
+ /** Schema version, for forward-compatible readers. */
13
+ v: number;
14
+ /** ISO-8601 timestamp of the event. */
15
+ ts: string;
16
+ /** Event kind (see {@link ActivityEventKind}). */
17
+ event: ActivityEventKind | string;
18
+ /** Coarse importance tier -- stamped by the writer, recomputed if absent. */
19
+ tier: ActivityTier;
20
+ /** Owning session id. */
21
+ sessionId: string;
22
+ /** Mailbox id for routing/joins (falls back to sessionId). */
23
+ mailboxId: string;
24
+ /** Short, normalized hostname the event fired on. */
25
+ host: string;
26
+ /** Runtime label (headless, tmux, cloud, ...). */
27
+ runtime: string;
28
+ /** Working directory at event time -- the join key to a project/git repo. */
29
+ cwd?: string;
30
+ /** Agent that produced the event (claude, codex, ...). */
31
+ agent?: string;
32
+ /** Tool that triggered the event (Bash, Task, ExitPlanMode, ...). */
33
+ tool?: string;
34
+ /** One-line human summary (plan title, PR command, sub-agent role). */
35
+ detail?: string;
36
+ /** Extracted URL when the event has one (e.g. the opened PR). */
37
+ url?: string;
38
+ }
39
+ /**
40
+ * Append one event to a session's activity log. Primarily the Python hook
41
+ * writes these at runtime; this TS writer exists for tests and any in-process
42
+ * emitter. Uses O_APPEND so concurrent appends never interleave a line.
43
+ */
44
+ export declare function appendActivityEvent(event: Omit<ActivityEvent, 'v' | 'tier'> & {
45
+ v?: number;
46
+ tier?: ActivityTier;
47
+ }, root?: string): void;
48
+ /** Read all events for one session (bounded tail). */
49
+ export declare function readSessionActivity(sessionId: string, root?: string, maxBytes?: number): ActivityEvent[];
50
+ /** List session ids that have an activity log. */
51
+ export declare function listActivitySessions(root?: string): string[];
52
+ export interface RecentActivityOptions {
53
+ /** Only include events at or after this epoch-ms. */
54
+ sinceMs?: number;
55
+ /** Cap the number of returned events (most recent first). */
56
+ limit?: number;
57
+ /** Override the activity dir (tests). */
58
+ root?: string;
59
+ /** Per-session tail budget in bytes. */
60
+ maxBytesPerSession?: number;
61
+ }
62
+ /**
63
+ * Merge recent events across every session's log, newest first. Reads only the
64
+ * tail of each file, so cost scales with active sessions, not transcript size.
65
+ */
66
+ export declare function readRecentActivity(opts?: RecentActivityOptions): ActivityEvent[];
67
+ export interface CollapsedActivity {
68
+ /** Milestone events, individually preserved, newest first. */
69
+ milestones: ActivityEvent[];
70
+ /** Routine events rolled up to counts, e.g. { 'file.edited': 12 }. */
71
+ counts: Record<string, number>;
72
+ /** Number of sub-agents spawned across the collapsed set. */
73
+ subagentCount: number;
74
+ }
75
+ /**
76
+ * Split a chronological event list into individual milestones plus a
77
+ * count map for the routine (tier-2) events, so a reader shows recognizable
78
+ * deliverables in full and collapses the noise.
79
+ */
80
+ export declare function collapseActivity(events: ActivityEvent[]): CollapsedActivity;
81
+ /**
82
+ * Normalize one activity event into the shared {@link EventRecord} shape so the
83
+ * agent-semantic stream reads through the same reader as operational events.
84
+ * `module` is stamped `activity` so `--module` filters partition the two cleanly.
85
+ */
86
+ export declare function activityEventToRecord(ev: ActivityEvent): EventRecord;
87
+ /** Read recent activity across sessions as unified {@link EventRecord}s. */
88
+ export declare function readActivityAsEventRecords(opts?: RecentActivityOptions): EventRecord[];
89
+ /** Glyph + color + human label per event, so the lane reads at a glance. */
90
+ export declare const EVENT_STYLE: Record<string, {
91
+ glyph: string;
92
+ color: (s: string) => string;
93
+ label: string;
94
+ }>;
95
+ export declare function styleForEvent(event: string): {
96
+ glyph: string;
97
+ color: (s: string) => string;
98
+ label: string;
99
+ };
100
+ /** One rendered activity line: ` <rel> [host] <glyph label> detail url`. */
101
+ export declare function formatActivityLine(ev: ActivityEvent, opts?: {
102
+ showHost?: boolean;
103
+ }): string;
104
+ /**
105
+ * The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
106
+ * PreToolUse/PostToolUse payloads into activity events and appends one JSONL
107
+ * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
108
+ *
109
+ * Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
110
+ * Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
111
+ * error is swallowed so a logging hiccup never blocks a tool call.
112
+ */
113
+ export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
114
+ /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
115
+ export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
116
+ /**
117
+ * Install the activity-log hook script into the user hooks dir and add its
118
+ * manifest entries to the user agents.yaml. Mirrors {@link ensureFeedPublishHook}
119
+ * in feed.ts -- idempotent, and never writes the read-only system repo.
120
+ */
121
+ export declare function ensureActivityLogHook(userAgentsDir?: string): {
122
+ installed: boolean;
123
+ error?: string;
124
+ };
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Activity log -- an append-only, per-session stream of agent-semantic events
3
+ * (plan created, PR opened, worktree created, sub-agent spawned, file edited).
4
+ *
5
+ * This is the counterpart to the feed block store: the block store is
6
+ * last-writer-wins STATE ("this agent is waiting on you"), while the activity
7
+ * log is an append-only EVENT stream ("this agent did X at T"). Together they
8
+ * let `agents feed` show both open decisions and a running activity lane
9
+ * without ever re-parsing session transcripts -- events are emitted at hook
10
+ * time by `11-activity-log.py` and read back by tailing the log.
11
+ *
12
+ * Layout: <activityDir>/<sessionId>.jsonl (one append-only file per session)
13
+ * Each line is one {@link ActivityEvent}. Files are keyed by session so a
14
+ * plain O_APPEND write is race-free (a session has a single writer process).
15
+ *
16
+ * Event tiers:
17
+ * - `milestone` -- recognizable deliverables, always surfaced individually.
18
+ * - `activity` -- routine work (file edits), collapsed to counts by readers.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as yaml from 'yaml';
23
+ import chalk from 'chalk';
24
+ import { relTime, truncate } from './format.js';
25
+ import { getActivityDir, getUserAgentsDir } from './state.js';
26
+ /** The set of milestone events, for tier classification and ordering. */
27
+ export const MILESTONE_EVENTS = [
28
+ 'plan.created',
29
+ 'pr.opened',
30
+ 'pr.merged',
31
+ 'worktree.created',
32
+ 'worktree.removed',
33
+ 'commit.created',
34
+ 'pushed',
35
+ 'subagent.spawned',
36
+ 'artifact.created',
37
+ ];
38
+ const MILESTONE_SET = new Set(MILESTONE_EVENTS);
39
+ export function tierForEvent(event) {
40
+ return MILESTONE_SET.has(event) ? 'milestone' : 'activity';
41
+ }
42
+ function activityPath(root, sessionId) {
43
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
44
+ if (!safe)
45
+ throw new Error(`Invalid activity session id: ${sessionId}`);
46
+ return path.join(root, `${safe}.jsonl`);
47
+ }
48
+ /**
49
+ * Append one event to a session's activity log. Primarily the Python hook
50
+ * writes these at runtime; this TS writer exists for tests and any in-process
51
+ * emitter. Uses O_APPEND so concurrent appends never interleave a line.
52
+ */
53
+ export function appendActivityEvent(event, root) {
54
+ const dir = root ?? getActivityDir();
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ const record = {
57
+ v: event.v ?? 1,
58
+ tier: event.tier ?? tierForEvent(event.event),
59
+ ...event,
60
+ };
61
+ fs.appendFileSync(activityPath(dir, event.sessionId), `${JSON.stringify(record)}\n`, { mode: 0o644 });
62
+ }
63
+ function parseLine(line) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed)
66
+ return undefined;
67
+ try {
68
+ const parsed = JSON.parse(trimmed);
69
+ if (!parsed.event || !parsed.sessionId || !parsed.ts)
70
+ return undefined;
71
+ return {
72
+ v: parsed.v ?? 1,
73
+ ts: parsed.ts,
74
+ event: parsed.event,
75
+ tier: parsed.tier ?? tierForEvent(parsed.event),
76
+ sessionId: parsed.sessionId,
77
+ mailboxId: parsed.mailboxId ?? parsed.sessionId,
78
+ host: parsed.host ?? 'unknown',
79
+ runtime: parsed.runtime ?? 'headless',
80
+ cwd: parsed.cwd,
81
+ agent: parsed.agent,
82
+ tool: parsed.tool,
83
+ detail: parsed.detail,
84
+ url: parsed.url,
85
+ };
86
+ }
87
+ catch {
88
+ return undefined; // skip corrupt / partial lines (fail-open reader)
89
+ }
90
+ }
91
+ /** Read the tail of a file as UTF-8, bounded to the last `maxBytes`. */
92
+ function readTail(file, maxBytes) {
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(file, 'r');
96
+ const size = fs.fstatSync(fd).size;
97
+ const start = size > maxBytes ? size - maxBytes : 0;
98
+ const len = size - start;
99
+ if (len <= 0)
100
+ return '';
101
+ const buf = Buffer.allocUnsafe(len);
102
+ fs.readSync(fd, buf, 0, len, start);
103
+ let text = buf.toString('utf-8');
104
+ // Drop a leading partial line when we started mid-file.
105
+ if (start > 0) {
106
+ const nl = text.indexOf('\n');
107
+ text = nl >= 0 ? text.slice(nl + 1) : '';
108
+ }
109
+ return text;
110
+ }
111
+ catch {
112
+ return '';
113
+ }
114
+ finally {
115
+ if (fd !== undefined)
116
+ fs.closeSync(fd);
117
+ }
118
+ }
119
+ /** Read all events for one session (bounded tail). */
120
+ export function readSessionActivity(sessionId, root, maxBytes = 256 * 1024) {
121
+ const dir = root ?? getActivityDir();
122
+ const text = readTail(activityPath(dir, sessionId), maxBytes);
123
+ const out = [];
124
+ for (const line of text.split('\n')) {
125
+ const ev = parseLine(line);
126
+ if (ev)
127
+ out.push(ev);
128
+ }
129
+ return out;
130
+ }
131
+ /** List session ids that have an activity log. */
132
+ export function listActivitySessions(root) {
133
+ const dir = root ?? getActivityDir();
134
+ try {
135
+ return fs.readdirSync(dir).filter(n => n.endsWith('.jsonl')).map(n => n.slice(0, -'.jsonl'.length));
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ /**
142
+ * Merge recent events across every session's log, newest first. Reads only the
143
+ * tail of each file, so cost scales with active sessions, not transcript size.
144
+ */
145
+ export function readRecentActivity(opts = {}) {
146
+ const dir = opts.root ?? getActivityDir();
147
+ const sinceMs = opts.sinceMs ?? 0;
148
+ const all = [];
149
+ for (const sessionId of listActivitySessions(dir)) {
150
+ for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
151
+ const t = Date.parse(ev.ts);
152
+ if (Number.isFinite(t) && t >= sinceMs)
153
+ all.push(ev);
154
+ }
155
+ }
156
+ all.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
157
+ return typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all;
158
+ }
159
+ /**
160
+ * Split a chronological event list into individual milestones plus a
161
+ * count map for the routine (tier-2) events, so a reader shows recognizable
162
+ * deliverables in full and collapses the noise.
163
+ */
164
+ export function collapseActivity(events) {
165
+ const milestones = [];
166
+ const counts = {};
167
+ let subagentCount = 0;
168
+ for (const ev of events) {
169
+ if (tierForEvent(ev.event) === 'milestone') {
170
+ milestones.push(ev);
171
+ if (ev.event === 'subagent.spawned')
172
+ subagentCount += 1;
173
+ }
174
+ else {
175
+ counts[ev.event] = (counts[ev.event] ?? 0) + 1;
176
+ }
177
+ }
178
+ return { milestones, counts, subagentCount };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Bridge into the unified event stream (lib/event-stream.ts)
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Normalize one activity event into the shared {@link EventRecord} shape so the
185
+ * agent-semantic stream reads through the same reader as operational events.
186
+ * `module` is stamped `activity` so `--module` filters partition the two cleanly.
187
+ */
188
+ export function activityEventToRecord(ev) {
189
+ return {
190
+ ts: ev.ts,
191
+ tz: '',
192
+ tzName: '',
193
+ hostname: ev.host,
194
+ platform: process.platform,
195
+ arch: process.arch,
196
+ pid: 0,
197
+ ppid: 0,
198
+ event: ev.event,
199
+ level: 'info',
200
+ caller: 'hook',
201
+ session: ev.sessionId,
202
+ osUser: ev.agent ?? 'agent',
203
+ transport: 'local',
204
+ // payload
205
+ agent: ev.agent,
206
+ sessionId: ev.sessionId,
207
+ cwd: ev.cwd,
208
+ module: 'activity',
209
+ tool: ev.tool,
210
+ detail: ev.detail,
211
+ url: ev.url,
212
+ tier: ev.tier,
213
+ };
214
+ }
215
+ /** Read recent activity across sessions as unified {@link EventRecord}s. */
216
+ export function readActivityAsEventRecords(opts = {}) {
217
+ return readRecentActivity(opts).map(activityEventToRecord);
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Rendering (shared by `agents activity` and `agents feed`)
221
+ // ---------------------------------------------------------------------------
222
+ /** Glyph + color + human label per event, so the lane reads at a glance. */
223
+ export const EVENT_STYLE = {
224
+ 'plan.created': { glyph: '◆', color: chalk.cyan, label: 'plan created' },
225
+ 'pr.opened': { glyph: '⇡', color: chalk.green, label: 'PR opened' },
226
+ 'pr.merged': { glyph: '✔', color: chalk.green, label: 'PR merged' },
227
+ 'worktree.created': { glyph: '⌥', color: chalk.blue, label: 'worktree created' },
228
+ 'worktree.removed': { glyph: '⌦', color: chalk.gray, label: 'worktree removed' },
229
+ 'commit.created': { glyph: '●', color: chalk.yellow, label: 'commit' },
230
+ 'pushed': { glyph: '↥', color: chalk.yellow, label: 'pushed' },
231
+ 'subagent.spawned': { glyph: '⑂', color: chalk.magenta, label: 'sub-agent spawned' },
232
+ 'artifact.created': { glyph: '▤', color: chalk.cyan, label: 'artifact' },
233
+ 'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
234
+ };
235
+ export function styleForEvent(event) {
236
+ return EVENT_STYLE[event] ?? { glyph: '•', color: chalk.white, label: event };
237
+ }
238
+ /** One rendered activity line: ` <rel> [host] <glyph label> detail url`. */
239
+ export function formatActivityLine(ev, opts = {}) {
240
+ const s = styleForEvent(ev.event);
241
+ const host = opts.showHost && ev.host && ev.host !== 'unknown' ? chalk.gray(`[${ev.host}] `) : '';
242
+ const label = s.color(`${s.glyph} ${s.label}`);
243
+ const detail = ev.detail ? ` ${truncate(ev.detail, 60)}` : '';
244
+ const url = ev.url ? chalk.gray(` ${ev.url}`) : '';
245
+ const when = chalk.gray(relTime(ev.ts).padStart(7));
246
+ return ` ${when} ${host}${label}${detail}${url}`;
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Hook installation
250
+ // ---------------------------------------------------------------------------
251
+ /**
252
+ * The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
253
+ * PreToolUse/PostToolUse payloads into activity events and appends one JSONL
254
+ * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
255
+ *
256
+ * Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
257
+ * Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
258
+ * error is swallowed so a logging hiccup never blocks a tool call.
259
+ */
260
+ export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
261
+ """Append agent-activity events for \`agents feed\` / \`agents activity\`.
262
+
263
+ Bound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,
264
+ MultiEdit). One append-only file per session; read-only tools never trigger it
265
+ because the manifest matcher excludes them.
266
+
267
+ Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
268
+ sub-agent -- skip so only the top-level agent logs its own activity.
269
+
270
+ Fail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.
271
+ """
272
+ import os
273
+ import re
274
+ import sys
275
+ import json
276
+ import shlex
277
+ import socket
278
+ from datetime import datetime, timezone
279
+
280
+ MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
281
+ MILESTONE_EVENTS = {
282
+ "plan.created", "pr.opened", "pr.merged", "worktree.created",
283
+ "worktree.removed", "commit.created", "pushed", "subagent.spawned",
284
+ "artifact.created",
285
+ }
286
+
287
+ # Deliverable file types + locations -- a Write here is a recognizable artifact
288
+ # (an HTML plan, a PDF report, a rendered image), not a routine code edit.
289
+ ARTIFACT_EXTS = {
290
+ ".html", ".htm", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".svg",
291
+ ".webp", ".mp4", ".mov", ".webm", ".csv", ".xlsx", ".pptx", ".docx",
292
+ }
293
+ ARTIFACT_DIR_HINTS = ("/tmp/", "/downloads/", "/.agents/artifacts/")
294
+
295
+
296
+ def is_artifact(file_path):
297
+ low = (file_path or "").lower()
298
+ if os.path.splitext(low)[1] in ARTIFACT_EXTS:
299
+ return True
300
+ return any(hint in low for hint in ARTIFACT_DIR_HINTS)
301
+
302
+
303
+ def first_line(text, limit=140):
304
+ for raw in (text or "").splitlines():
305
+ s = raw.strip().lstrip("#").strip()
306
+ if s:
307
+ return s[:limit]
308
+ return ""
309
+
310
+
311
+ def _subcommand(tokens, tool):
312
+ """First non-flag token after \`tool\`, i.e. its subcommand (skips -C <path>,
313
+ -c <cfg>, and other leading flags). None if \`tool\` isn't the invoked command."""
314
+ try:
315
+ i = tokens.index(tool) + 1
316
+ except ValueError:
317
+ return None, []
318
+ while i < len(tokens):
319
+ t = tokens[i]
320
+ if t in ("-C", "-c", "--git-dir", "--work-tree"):
321
+ i += 2 # flag that consumes the next token
322
+ continue
323
+ if t.startswith("-"):
324
+ i += 1
325
+ continue
326
+ return t, tokens[i + 1:]
327
+ return None, []
328
+
329
+
330
+ def classify_bash(command):
331
+ """Return the milestone event for a git/gh command, else None. Tokenizes so a
332
+ path like \`git diff -- src/commit.ts\` is not mistaken for a commit."""
333
+ try:
334
+ tokens = shlex.split(command or "")
335
+ except Exception:
336
+ tokens = (command or "").split()
337
+
338
+ verb, rest = _subcommand(tokens, "git")
339
+ if verb == "worktree":
340
+ sub = rest[0] if rest else ""
341
+ if sub == "add":
342
+ return "worktree.created"
343
+ if sub == "remove":
344
+ return "worktree.removed"
345
+ elif verb == "commit":
346
+ return "commit.created"
347
+ elif verb == "push":
348
+ return "pushed"
349
+
350
+ verb, rest = _subcommand(tokens, "gh")
351
+ if verb == "pr" and rest:
352
+ if rest[0] == "create":
353
+ return "pr.opened"
354
+ if rest[0] == "merge":
355
+ return "pr.merged"
356
+ return None
357
+
358
+
359
+ def extract_url(tool_response):
360
+ """Pull the first https URL out of a Bash tool response (stdout)."""
361
+ text = ""
362
+ if isinstance(tool_response, dict):
363
+ text = str(tool_response.get("stdout") or tool_response.get("output") or "")
364
+ elif isinstance(tool_response, str):
365
+ text = tool_response
366
+ m = re.search(r"https?://\\S+", text)
367
+ return m.group(0).rstrip(").,") if m else None
368
+
369
+
370
+ def build_event(payload, hook_event):
371
+ tool_name = payload.get("tool_name", "")
372
+ tool_input = payload.get("tool_input", {}) or {}
373
+ tool_response = payload.get("tool_response", {})
374
+
375
+ event = None
376
+ detail = None
377
+ url = None
378
+
379
+ if hook_event == "PreToolUse":
380
+ if tool_name == "ExitPlanMode":
381
+ event = "plan.created"
382
+ detail = first_line(tool_input.get("plan", "")) or "plan presented"
383
+ elif tool_name == "Task":
384
+ event = "subagent.spawned"
385
+ role = tool_input.get("subagent_type") or "agent"
386
+ desc = tool_input.get("description") or tool_input.get("prompt") or ""
387
+ detail = (role + ": " + first_line(desc)).strip(": ").strip()
388
+ elif hook_event == "PostToolUse":
389
+ if tool_name == "Bash":
390
+ event = classify_bash(tool_input.get("command", ""))
391
+ if event:
392
+ detail = first_line(tool_input.get("command", ""))
393
+ url = extract_url(tool_response)
394
+ elif tool_name in ("Write", "Edit", "MultiEdit"):
395
+ fp = tool_input.get("file_path") or tool_input.get("path") or ""
396
+ # A freshly-written deliverable is a milestone; edits and code
397
+ # writes stay routine and collapse to a count.
398
+ if tool_name == "Write" and is_artifact(fp):
399
+ event = "artifact.created"
400
+ else:
401
+ event = "file.edited"
402
+ detail = os.path.basename(fp) if fp else tool_name
403
+
404
+ if not event:
405
+ return None, tool_name
406
+
407
+ tier = "milestone" if event in MILESTONE_EVENTS else "activity"
408
+ record = {
409
+ "v": 1,
410
+ "ts": datetime.now(timezone.utc).isoformat(),
411
+ "event": event,
412
+ "tier": tier,
413
+ }
414
+ if detail:
415
+ record["detail"] = detail
416
+ if url:
417
+ record["url"] = url
418
+ record["tool"] = tool_name
419
+ return record, tool_name
420
+
421
+
422
+ def main():
423
+ raw = sys.stdin.read()
424
+ try:
425
+ payload = json.loads(raw) if raw.strip() else {}
426
+ except Exception:
427
+ return
428
+
429
+ # Sub-agent gate -- only the top-level agent logs.
430
+ if payload.get("agent_type"):
431
+ return
432
+
433
+ session_id = payload.get("session_id", "")
434
+ if not session_id:
435
+ return
436
+
437
+ hook_event = payload.get("hook_event_name", "")
438
+ record, tool_name = build_event(payload, hook_event)
439
+ if not record:
440
+ return
441
+
442
+ safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id) or "unknown"
443
+ home = os.environ.get("HOME") or os.path.expanduser("~")
444
+ activity_dir = os.path.join(home, ".agents", ".history", "activity")
445
+ target = os.path.join(activity_dir, safe_session + ".jsonl")
446
+
447
+ # Identity (mirrors 10-feed-publish.py).
448
+ mailbox_id = os.path.basename(
449
+ os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
450
+ ) or session_id
451
+ hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
452
+ host = re.sub(r"[^a-z0-9_-]", "-", hostname.split(".")[0].strip().lower()) or "unknown"
453
+
454
+ record["sessionId"] = session_id
455
+ record["mailboxId"] = mailbox_id
456
+ record["host"] = host
457
+ record["runtime"] = os.environ.get("AGENTS_RUNTIME", "headless")
458
+ cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
459
+ if cwd:
460
+ record["cwd"] = cwd
461
+ agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
462
+ record["agent"] = agent
463
+
464
+ try:
465
+ os.makedirs(activity_dir, exist_ok=True)
466
+ # Cap growth: once over the size limit, keep only milestones.
467
+ if record["tier"] != "milestone":
468
+ try:
469
+ if os.path.getsize(target) > MAX_LOG_BYTES:
470
+ return
471
+ except OSError:
472
+ pass
473
+ with open(target, "a") as f:
474
+ f.write(json.dumps(record) + "\\n")
475
+ except Exception:
476
+ pass # fail open
477
+
478
+
479
+ if __name__ == "__main__":
480
+ try:
481
+ main()
482
+ except Exception:
483
+ pass # fail open
484
+ `;
485
+ /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
486
+ export const ACTIVITY_HOOK_DEFINITIONS = {
487
+ 'activity-log-intent': {
488
+ agents: ['claude'],
489
+ events: ['PreToolUse'],
490
+ matcher: 'ExitPlanMode|Task',
491
+ script: '11-activity-log.py',
492
+ timeout: 5,
493
+ },
494
+ 'activity-log-result': {
495
+ agents: ['claude'],
496
+ events: ['PostToolUse'],
497
+ matcher: 'Bash|Write|Edit|MultiEdit',
498
+ script: '11-activity-log.py',
499
+ timeout: 5,
500
+ },
501
+ };
502
+ /**
503
+ * Install the activity-log hook script into the user hooks dir and add its
504
+ * manifest entries to the user agents.yaml. Mirrors {@link ensureFeedPublishHook}
505
+ * in feed.ts -- idempotent, and never writes the read-only system repo.
506
+ */
507
+ export function ensureActivityLogHook(userAgentsDir = getUserAgentsDir()) {
508
+ try {
509
+ const hooksDir = path.join(userAgentsDir, 'hooks');
510
+ const scriptPath = path.join(hooksDir, '11-activity-log.py');
511
+ fs.mkdirSync(hooksDir, { recursive: true });
512
+ let installed = false;
513
+ if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== ACTIVITY_LOG_HOOK_SCRIPT) {
514
+ const tmpScript = `${scriptPath}.${process.pid}.tmp`;
515
+ fs.writeFileSync(tmpScript, ACTIVITY_LOG_HOOK_SCRIPT, { mode: 0o755 });
516
+ fs.renameSync(tmpScript, scriptPath);
517
+ installed = true;
518
+ }
519
+ const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
520
+ const yamlDoc = fs.existsSync(agentsYamlPath)
521
+ ? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
522
+ : new yaml.Document({});
523
+ if (yamlDoc.errors.length > 0) {
524
+ throw new Error(`Cannot install activity hook: ${agentsYamlPath} is invalid YAML`);
525
+ }
526
+ for (const [name, definition] of Object.entries(ACTIVITY_HOOK_DEFINITIONS)) {
527
+ if (!yamlDoc.getIn(['hooks', name])) {
528
+ yamlDoc.setIn(['hooks', name], definition);
529
+ installed = true;
530
+ }
531
+ }
532
+ if (installed) {
533
+ const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
534
+ fs.writeFileSync(tmpYaml, String(yamlDoc));
535
+ fs.renameSync(tmpYaml, agentsYamlPath);
536
+ }
537
+ return { installed };
538
+ }
539
+ catch (err) {
540
+ return { installed: false, error: err.message };
541
+ }
542
+ }