@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -1,8 +1,18 @@
1
1
  import type { EventRecord } from './events.js';
2
2
  /** Recognizable milestone events, ordered first in any activity lane. */
3
3
  export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
4
+ /** Bash-driven deliverables detected by command parsing. */
5
+ | 'video.rendered' | 'video.converted' | 'image.upscaled' | 'metadata.edited'
4
6
  /** Deliberate agent-authored progress post (`agents feed post`). */
5
7
  | 'status.posted'
8
+ /**
9
+ * An agent terminal spawned from the Factory VS Code extension. A milestone
10
+ * because it is the BIRTH of a session — it carries the sessionId and the
11
+ * terminalId that every later event on that session joins through, the same
12
+ * way `subagent.spawned` roots a sub-agent. Written out of process by
13
+ * `agents events emit`; the other factory.* kinds are operational-only.
14
+ */
15
+ | 'factory.launch'
6
16
  /**
7
17
  * The same post, but the agent is STUCK (`agents feed post --blocked`).
8
18
  *
@@ -14,7 +24,7 @@ export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'workt
14
24
  */
15
25
  | 'status.blocked';
16
26
  /** Routine activity events, collapsed to counts by readers. */
17
- export type ActivityKind = 'file.edited';
27
+ export type ActivityKind = 'file.edited' | 'bash.executed';
18
28
  export type ActivityEventKind = MilestoneEvent | ActivityKind;
19
29
  export type ActivityTier = 'milestone' | 'activity';
20
30
  /** Well-known attachment kinds; the field is an open string, not an enum. */
@@ -84,6 +94,10 @@ export interface ActivityEvent {
84
94
  terminalId?: string;
85
95
  /** `$TMUX_PANE` at launch when recorded. */
86
96
  tmuxPane?: string;
97
+ /** Bash command taxonomy (set for bash.executed events). */
98
+ category?: string;
99
+ bashTool?: string;
100
+ bashAction?: string;
87
101
  /** Generic artifacts attached to a deliberate progress post. */
88
102
  attachments?: Attachment[];
89
103
  }
@@ -223,19 +237,29 @@ export interface ActivitySessionHint {
223
237
  project?: string | null;
224
238
  }
225
239
  /**
226
- * Resolve a stable project/repo name from a working directory. A worktree cwd
227
- * (`…/<repo>/.agents/worktrees/<slug>[/sub]`) resolves to the repo dir name so
228
- * a worktree session groups with its own repo; any other path resolves to its
229
- * basename. Pure — no filesystem access, so it works for remote events too.
240
+ * Resolve a stable project/repo name from a working directory the same fold
241
+ * the `agents sessions` overview groups by ({@link projectKeyFromCwd}), so a
242
+ * project reads identically in both views.
230
243
  */
231
244
  export declare function projectFromCwd(cwd?: string | null): string | undefined;
232
245
  /**
233
246
  * Join session facts (ticket / project / execution host) onto each event by
234
- * `sessionId`. A hint wins; otherwise pre-baked enriched fields (from a remote
235
- * peer that already enriched its own stream) are preserved, and project/host
236
- * fall back to what the event itself carries (`cwd`, `host`). Pure.
247
+ * `sessionId`. A hint wins; otherwise a CANONICAL def match (`canonicalProject`)
248
+ * upgrades whatever the event carries; otherwise pre-baked enriched fields (from
249
+ * a remote peer that already enriched its own stream) are preserved, and
250
+ * project/host fall back to what the event itself carries (`cwd`, `host`).
251
+ *
252
+ * The def match ranks above the pre-baked stamp because the stamp may be stale
253
+ * (posted before the def existed) or skewed (a peer whose defs haven't synced);
254
+ * the match is pure prefix compare against local defs, so a different-home
255
+ * peer's path simply doesn't match and its stamp is honored as before.
256
+ *
257
+ * `resolveProject` is how a caller reading its OWN machine's logs upgrades the
258
+ * cwd fold to real repository detection ({@link resolveProjectKey}); it defaults
259
+ * to the pure {@link projectFromCwd}, which is all a path from another machine
260
+ * can be resolved with. Pure given pure resolvers.
237
261
  */
238
- export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[]): EnrichedActivityEvent[];
262
+ export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[], resolveProject?: (cwd?: string | null) => string | undefined, canonicalProject?: (cwd?: string | null) => string | undefined): EnrichedActivityEvent[];
239
263
  /**
240
264
  * Validate + host-tag one peer's `activity --json` payload for the fan-out
241
265
  * merge. Mirrors {@link parseLine}: skip anything missing `event`/`sessionId`/
@@ -251,6 +275,22 @@ export declare function parseActivityPayload(stdout: string, machine: string): E
251
275
  * `mergeFeedBlocks`).
252
276
  */
253
277
  export declare function mergeActivityEvents(...groups: EnrichedActivityEvent[][]): EnrichedActivityEvent[];
278
+ /**
279
+ * Apply `--limit` to the events a reader will actually SHOW.
280
+ *
281
+ * The default view collapses routine work (`file.edited`) to a count and shows
282
+ * milestones individually, so a plain `slice(limit)` spends the whole budget on
283
+ * churn: one busy machine editing 40 files hid every other device's PRs behind
284
+ * a single `file edited ×40` line. Milestones therefore carry the cap, and the
285
+ * routine events that ride along are the ones newer than the last milestone
286
+ * kept — so the collapsed counts describe exactly the window on screen.
287
+ *
288
+ * With `all` (routine shown inline) every event is displayed, so the cap is the
289
+ * plain slice again. Input must be newest-first; output preserves that order.
290
+ */
291
+ export declare function capActivityEvents(events: EnrichedActivityEvent[], limit: number, opts?: {
292
+ all?: boolean;
293
+ }): EnrichedActivityEvent[];
254
294
  export type ActivityGroupBy = 'project' | 'device' | 'agent';
255
295
  /** One bucket of the grouped view. */
256
296
  export interface ActivityGroup {
@@ -277,14 +317,39 @@ export declare function groupActivity(events: EnrichedActivityEvent[], by: Activ
277
317
  * no-op. Pure.
278
318
  */
279
319
  export declare function filterActivityEvents(events: EnrichedActivityEvent[], filter: string): EnrichedActivityEvent[];
320
+ /**
321
+ * Narrow events to one resolved project, exact match on the enriched label
322
+ * (unlike {@link filterActivityEvents}'s cross-field substring). This is the
323
+ * `--project` flag: the label is canonical (a defined project's name) once the
324
+ * reader's resolver has run, so a multi-repo project matches as one bucket. Pure.
325
+ */
326
+ export declare function filterActivityByProject(events: EnrichedActivityEvent[], project: string): EnrichedActivityEvent[];
280
327
  /** One rendered enriched line: the base activity line + `· project · ticket` tags. */
281
328
  export declare function formatEnrichedActivityLine(ev: EnrichedActivityEvent, opts?: {
282
329
  showHost?: boolean;
283
330
  showProject?: boolean;
284
331
  indent?: string;
285
332
  }): string;
286
- /** Human header for one grouped bucket: `<label> · N events · M milestones`. */
287
- export declare function formatActivityGroupHeader(group: ActivityGroup): string;
333
+ /** Max device names named in a group header before the rest collapse to `+N`. */
334
+ export declare const GROUP_HEADER_DEVICE_LIMIT = 3;
335
+ /**
336
+ * The distinct machines a group's events ran on, most-active first then
337
+ * alphabetical — so a project header can say WHERE the work happened without
338
+ * sub-grouping the timeline. Prefers the session-joined `executionHost` (where
339
+ * the process really lives) over the raw emitting `host`. Pure.
340
+ */
341
+ export declare function activityGroupDevices(events: EnrichedActivityEvent[]): string[];
342
+ /**
343
+ * The trailing facts for one grouped bucket: `N events · M milestones`, plus
344
+ * the machines the work ran on when `showDevices` is set (redundant when the
345
+ * grouping dimension IS the device, so the caller decides). The device list is
346
+ * capped at {@link GROUP_HEADER_DEVICE_LIMIT} with a `+N` tail, so a project
347
+ * touched by a dozen boxes stays one scannable line. Separate from the label so
348
+ * a renderer can color the two differently without re-splitting a string.
349
+ */
350
+ export declare function formatActivityGroupMeta(group: ActivityGroup, opts?: {
351
+ showDevices?: boolean;
352
+ }): string;
288
353
  /**
289
354
  * The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
290
355
  * PreToolUse/PostToolUse payloads into activity events and appends one JSONL
@@ -295,7 +360,7 @@ export declare function formatActivityGroupHeader(group: ActivityGroup): string;
295
360
  * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
296
361
  * a logging hiccup never blocks a tool call.
297
362
  */
298
- 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, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One\nappend-only file per session; read-only tools never trigger it because the\nmanifest 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\", \"task.completed\", \"checklist.created\", \"status.posted\",\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# Checklist tools across harnesses. The value is the key that holds the item list.\nCHECKLIST_TOOLS = {\n \"TodoWrite\": \"todos\",\n \"todo_write\": \"todos\",\n \"TaskUpdate\": \"tasks\",\n \"update_plan\": \"plan\",\n}\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 _checklist_items(tool_input, kind):\n \"\"\"Extract normalized checklist items from tool input.\"\"\"\n if not isinstance(tool_input, dict):\n return []\n if kind == \"todos\":\n arr = tool_input.get(\"todos\") or []\n elif kind == \"tasks\":\n arr = tool_input.get(\"tasks\") or []\n if not arr and \"taskId\" in tool_input:\n arr = [tool_input]\n elif kind == \"plan\":\n arr = tool_input.get(\"plan\") or []\n else:\n return []\n if not isinstance(arr, list):\n return []\n\n items = []\n for t in arr:\n if not isinstance(t, dict):\n continue\n subject = (\n t.get(\"content\") or\n t.get(\"text\") or\n t.get(\"step\") or\n t.get(\"title\") or\n t.get(\"description\") or\n t.get(\"activeForm\") or\n \"\"\n )\n if not isinstance(subject, str):\n subject = str(subject)\n subject = subject.strip()\n item_id = t.get(\"id\") or t.get(\"taskId\") or subject\n if not item_id:\n continue\n status = str(t.get(\"status\", \"\") or \"\").lower()\n items.append({\"id\": item_id, \"subject\": subject, \"status\": status})\n return items\n\n\ndef _read_transcript_checklists(transcript_path, current_tool, current_items):\n \"\"\"Tail-read the session transcript and return the previous checklist state.\n\n The most recent checklist entry is assumed to be the current tool call if\n it carries the same ids; skip that one and return the next older checklist\n state (or None if there isn't one).\n \"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return None\n try:\n size = os.path.getsize(transcript_path)\n start = max(0, size - 512 * 1024)\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n f.seek(start)\n if start > 0:\n f.readline() # drop a leading partial line\n lines = f.readlines()\n except Exception:\n return None\n\n current_ids = {str(item.get(\"id\")) for item in current_items if item.get(\"id\")}\n skipped_current = False\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n if isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n elif record.get(\"tool_name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or tu.get(\"tool_name\") or \"\"\n if name not in CHECKLIST_TOOLS:\n continue\n kind = CHECKLIST_TOOLS[name]\n args = tu.get(\"input\") or tu.get(\"tool_input\") or tu.get(\"arguments\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n items = _checklist_items(args, kind)\n if not items:\n continue\n ids = {str(item.get(\"id\")) for item in items if item.get(\"id\")}\n # Skip the most recent matching checklist entry once; that is the\n # current call already reflected in the transcript.\n if not skipped_current and name == current_tool and ids == current_ids:\n skipped_current = True\n continue\n return items\n return None\n\n\ndef _has_previous_task_create(transcript_path, current_tool_use_id=\"\"):\n \"\"\"Return True if the transcript contains a TaskCreate before the current one.\"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return False\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return False\n\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n if (tu.get(\"name\") or tu.get(\"tool_name\")) == \"TaskCreate\":\n if tu.get(\"id\") != current_tool_use_id:\n return True\n return False\n\n\ndef _claude_task_state(transcript_path, exclude_task_id=None):\n \"\"\"Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.\n\n TaskCreate provides the subject (and id via toolUseResult); TaskUpdate\n provides the status. If exclude_task_id is given, the last TaskUpdate for\n that id is skipped (it is the current call already reflected in the\n transcript).\n \"\"\"\n state = {}\n if not transcript_path or not os.path.exists(transcript_path):\n return state\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return state\n\n updates = [] # (task_id, status, line_index)\n for idx, line in enumerate(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in tool_uses:\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or \"\"\n args = tu.get(\"input\") or tu.get(\"tool_input\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n if name == \"TaskCreate\":\n subject = (\n args.get(\"subject\") or\n args.get(\"description\") or\n args.get(\"title\") or\n \"\"\n )\n if subject:\n # Link subject to the tool_use id so the result can map it.\n tool_id = tu.get(\"id\")\n if tool_id:\n state.setdefault(\"__pending_subject\", {})[tool_id] = subject\n\n tool_result = record.get(\"toolUseResult\") or {}\n if not isinstance(tool_result, dict):\n continue\n task = tool_result.get(\"task\")\n if isinstance(task, dict):\n task_id = str(task.get(\"id\") or task.get(\"taskId\") or \"\")\n if task_id:\n state.setdefault(task_id, {})\n if task.get(\"subject\"):\n state[task_id][\"subject\"] = task[\"subject\"]\n if task.get(\"status\"):\n state[task_id][\"status\"] = str(task.get(\"status\")).lower()\n # Link any pending subject from the matching tool_use id.\n tool_use_id = record.get(\"tool_use_id\") or \"\"\n pending = state.get(\"__pending_subject\", {})\n if tool_use_id and tool_use_id in pending:\n state[task_id][\"subject\"] = pending[tool_use_id]\n task_id = str(tool_result.get(\"taskId\") or \"\")\n status_change = tool_result.get(\"statusChange\") or {}\n status = status_change.get(\"to\") or tool_result.get(\"status\")\n if task_id and status:\n state.setdefault(task_id, {})\n state[task_id][\"status\"] = str(status).lower()\n updates.append((task_id, str(status).lower(), idx))\n\n if exclude_task_id and updates:\n for i in range(len(updates) - 1, -1, -1):\n if updates[i][0] == exclude_task_id:\n del updates[i]\n break\n # Rebuild statuses from remaining updates.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if \"subject\" not in state[task_id]:\n del state[task_id]\n else:\n state[task_id].pop(\"status\", None)\n for task_id, status, _ in updates:\n if task_id in state:\n state[task_id][\"status\"] = status\n\n # Drop tasks removed from the checklist (deleted/cancelled): they are gone\n # from the user-visible list, so they must not count toward the N/M total.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if state[task_id].get(\"status\") in (\"deleted\", \"cancelled\", \"canceled\", \"removed\"):\n del state[task_id]\n\n # Drop internal bookkeeping.\n state.pop(\"__pending_subject\", None)\n return state\n\n\ndef _checklist_events(payload, hook_event):\n \"\"\"Return a list of (event, detail) tuples for checklist tool calls.\"\"\"\n if hook_event != \"PostToolUse\":\n return []\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n\n if tool_name == \"TaskCreate\":\n subject = (\n tool_input.get(\"subject\") or\n tool_input.get(\"description\") or\n tool_input.get(\"title\") or\n \"task\"\n )\n # Only announce the checklist on the very first task creation.\n current_tool_use_id = payload.get(\"tool_use_id\", \"\")\n transcript_path = payload.get(\"transcript_path\")\n if _has_previous_task_create(transcript_path, current_tool_use_id):\n return []\n return [(\"checklist.created\", subject)]\n\n if tool_name not in CHECKLIST_TOOLS:\n return []\n\n kind = CHECKLIST_TOOLS[tool_name]\n items = _checklist_items(tool_input, kind)\n if not items:\n return []\n\n # Full-list tools (TodoWrite, update_plan) send the whole checklist every\n # time. Per-task update tools (TaskUpdate) send only the changed item, so\n # totals and subject must be resolved from the previous full-list state.\n list_key = {\"todos\": \"todos\", \"tasks\": \"tasks\", \"plan\": \"plan\"}.get(kind)\n is_full_list = list_key is not None and list_key in tool_input\n\n previous = _read_transcript_checklists(\n payload.get(\"transcript_path\"), tool_name, items\n )\n\n if not is_full_list and previous:\n total = len(previous)\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n done_count = len(previous_done)\n events = []\n for item in items:\n if item.get(\"status\") != \"completed\":\n continue\n item_id = str(item.get(\"id\"))\n if item_id in previous_done:\n continue\n subject = item.get(\"subject\")\n if not subject:\n for p in previous:\n if str(p.get(\"id\")) == item_id and p.get(\"subject\"):\n subject = p[\"subject\"]\n break\n if not subject:\n subject = \"task\"\n done_count += 1\n events.append((\"task.completed\", f\"{subject} {done_count}/{total} done\"))\n return events\n\n # For TaskUpdate without a previous full-list state, fold the Claude\n # TaskCreate/TaskUpdate history to resolve subject and N/M.\n if tool_name == \"TaskUpdate\" and previous is None:\n task_id = str(items[0].get(\"id\")) if items else \"\"\n task_state = _claude_task_state(\n payload.get(\"transcript_path\"), exclude_task_id=task_id\n )\n if task_state:\n total = len(task_state)\n previous_done = {\n tid for tid, info in task_state.items()\n if info.get(\"status\") == \"completed\"\n }\n done_count = len(previous_done)\n if task_id in task_state and items[0].get(\"status\") == \"completed\" and task_id not in previous_done:\n subject = task_state[task_id].get(\"subject\") or \"task\"\n return [(\"task.completed\", f\"{subject} {done_count + 1}/{total} done\")]\n return []\n\n total = len(items)\n completed = [i for i in items if i.get(\"status\") == \"completed\"]\n\n if previous is None:\n # First checklist call in this session.\n events = []\n events.append((\"checklist.created\", f\"{total} task{'s' if total != 1 else ''}\"))\n for item in completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {len(completed)}/{total} done\",\n ))\n return events\n\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n newly_completed = [\n i for i in completed\n if str(i.get(\"id\")) not in previous_done\n ]\n if not newly_completed:\n return []\n\n done_count = len(completed)\n events = []\n for item in newly_completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {done_count}/{total} done\",\n ))\n return events\n\n\ndef _make_record(event, detail, tool_name):\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 record[\"tool\"] = tool_name\n return record\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 # Checklist completions first -- cheap guard above already filtered tool name.\n checklist = _checklist_events(payload, hook_event)\n if checklist:\n return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name\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 [], tool_name\n\n record = _make_record(event, detail, tool_name)\n if url:\n record[\"url\"] = url\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 records, tool_name = build_event(payload, hook_event)\n if not records:\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 for record in records:\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 try:\n over_limit = os.path.getsize(target) > MAX_LOG_BYTES\n except OSError:\n over_limit = False\n with open(target, \"a\") as f:\n for record in records:\n if over_limit and record.get(\"tier\") != \"milestone\":\n continue\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";
363
+ export declare const ACTIVITY_LOG_HOOK_SCRIPT: string;
299
364
  /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
300
365
  export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
301
366
  /**