@phnx-labs/agents-cli 1.20.74 → 1.20.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -34,6 +34,9 @@ export const MILESTONE_EVENTS = [
34
34
  'pushed',
35
35
  'subagent.spawned',
36
36
  'artifact.created',
37
+ 'task.completed',
38
+ 'checklist.created',
39
+ 'status.posted',
37
40
  ];
38
41
  const MILESTONE_SET = new Set(MILESTONE_EVENTS);
39
42
  export function tierForEvent(event) {
@@ -82,6 +85,10 @@ function parseLine(line) {
82
85
  tool: parsed.tool,
83
86
  detail: parsed.detail,
84
87
  url: parsed.url,
88
+ pid: typeof parsed.pid === 'number' ? parsed.pid : undefined,
89
+ launchId: parsed.launchId,
90
+ terminalId: parsed.terminalId,
91
+ tmuxPane: parsed.tmuxPane,
85
92
  };
86
93
  }
87
94
  catch {
@@ -193,11 +200,11 @@ export function activityEventToRecord(ev) {
193
200
  hostname: ev.host,
194
201
  platform: process.platform,
195
202
  arch: process.arch,
196
- pid: 0,
203
+ pid: ev.pid ?? 0,
197
204
  ppid: 0,
198
205
  event: ev.event,
199
206
  level: 'info',
200
- caller: 'hook',
207
+ caller: ev.tool === 'feed.post' ? 'agent' : 'hook',
201
208
  session: ev.sessionId,
202
209
  osUser: ev.agent ?? 'agent',
203
210
  transport: 'local',
@@ -210,6 +217,9 @@ export function activityEventToRecord(ev) {
210
217
  detail: ev.detail,
211
218
  url: ev.url,
212
219
  tier: ev.tier,
220
+ ...(ev.launchId ? { launchId: ev.launchId } : {}),
221
+ ...(ev.terminalId ? { terminalId: ev.terminalId } : {}),
222
+ ...(ev.tmuxPane ? { tmuxPane: ev.tmuxPane } : {}),
213
223
  };
214
224
  }
215
225
  /** Read recent activity across sessions as unified {@link EventRecord}s. */
@@ -230,6 +240,9 @@ export const EVENT_STYLE = {
230
240
  'pushed': { glyph: '↥', color: chalk.yellow, label: 'pushed' },
231
241
  'subagent.spawned': { glyph: '⑂', color: chalk.magenta, label: 'sub-agent spawned' },
232
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' },
245
+ 'status.posted': { glyph: '▸', color: chalk.white, label: 'status' },
233
246
  'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
234
247
  };
235
248
  export function styleForEvent(event) {
@@ -240,10 +253,13 @@ export function formatActivityLine(ev, opts = {}) {
240
253
  const s = styleForEvent(ev.event);
241
254
  const host = opts.showHost && ev.host && ev.host !== 'unknown' ? chalk.gray(`[${ev.host}] `) : '';
242
255
  const label = s.color(`${s.glyph} ${s.label}`);
243
- const detail = ev.detail ? ` ${truncate(ev.detail, 60)}` : '';
256
+ // Status posts are the message; allow a longer snippet than tool-derived detail.
257
+ const detailLimit = ev.event === 'status.posted' ? 100 : 60;
258
+ const detail = ev.detail ? ` ${truncate(ev.detail, detailLimit)}` : '';
244
259
  const url = ev.url ? chalk.gray(` ${ev.url}`) : '';
260
+ const agent = ev.event === 'status.posted' && ev.agent ? chalk.gray(` · ${ev.agent}`) : '';
245
261
  const when = chalk.gray(relTime(ev.ts).padStart(7));
246
- return ` ${when} ${host}${label}${detail}${url}`;
262
+ return ` ${when} ${host}${label}${detail}${agent}${url}`;
247
263
  }
248
264
  // ---------------------------------------------------------------------------
249
265
  // Hook installation
@@ -254,15 +270,17 @@ export function formatActivityLine(ev, opts = {}) {
254
270
  * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
255
271
  *
256
272
  * 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.
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.
259
276
  */
260
277
  export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
261
278
  """Append agent-activity events for \`agents feed\` / \`agents activity\`.
262
279
 
263
280
  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.
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.
266
284
 
267
285
  Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
268
286
  sub-agent -- skip so only the top-level agent logs its own activity.
@@ -281,7 +299,7 @@ MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
281
299
  MILESTONE_EVENTS = {
282
300
  "plan.created", "pr.opened", "pr.merged", "worktree.created",
283
301
  "worktree.removed", "commit.created", "pushed", "subagent.spawned",
284
- "artifact.created",
302
+ "artifact.created", "task.completed", "checklist.created", "status.posted",
285
303
  }
286
304
 
287
305
  # Deliverable file types + locations -- a Write here is a recognizable artifact
@@ -292,6 +310,14 @@ ARTIFACT_EXTS = {
292
310
  }
293
311
  ARTIFACT_DIR_HINTS = ("/tmp/", "/downloads/", "/.agents/artifacts/")
294
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
+
295
321
 
296
322
  def is_artifact(file_path):
297
323
  low = (file_path or "").lower()
@@ -367,11 +393,418 @@ def extract_url(tool_response):
367
393
  return m.group(0).rstrip(").,") if m else None
368
394
 
369
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
+
370
798
  def build_event(payload, hook_event):
371
799
  tool_name = payload.get("tool_name", "")
372
800
  tool_input = payload.get("tool_input", {}) or {}
373
801
  tool_response = payload.get("tool_response", {})
374
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
+
375
808
  event = None
376
809
  detail = None
377
810
  url = None
@@ -402,21 +835,12 @@ def build_event(payload, hook_event):
402
835
  detail = os.path.basename(fp) if fp else tool_name
403
836
 
404
837
  if not event:
405
- return None, tool_name
838
+ return [], tool_name
406
839
 
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
840
+ record = _make_record(event, detail, tool_name)
416
841
  if url:
417
842
  record["url"] = url
418
- record["tool"] = tool_name
419
- return record, tool_name
843
+ return [record], tool_name
420
844
 
421
845
 
422
846
  def main():
@@ -435,8 +859,8 @@ def main():
435
859
  return
436
860
 
437
861
  hook_event = payload.get("hook_event_name", "")
438
- record, tool_name = build_event(payload, hook_event)
439
- if not record:
862
+ records, tool_name = build_event(payload, hook_event)
863
+ if not records:
440
864
  return
441
865
 
442
866
  safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id) or "unknown"
@@ -451,27 +875,29 @@ def main():
451
875
  hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
452
876
  host = re.sub(r"[^a-z0-9_-]", "-", hostname.split(".")[0].strip().lower()) or "unknown"
453
877
 
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
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
463
888
 
464
889
  try:
465
890
  os.makedirs(activity_dir, exist_ok=True)
466
891
  # 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
892
+ try:
893
+ over_limit = os.path.getsize(target) > MAX_LOG_BYTES
894
+ except OSError:
895
+ over_limit = False
473
896
  with open(target, "a") as f:
474
- 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")
475
901
  except Exception:
476
902
  pass # fail open
477
903
 
@@ -494,7 +920,7 @@ export const ACTIVITY_HOOK_DEFINITIONS = {
494
920
  'activity-log-result': {
495
921
  agents: ['claude'],
496
922
  events: ['PostToolUse'],
497
- matcher: 'Bash|Write|Edit|MultiEdit',
923
+ matcher: 'Bash|Write|Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate',
498
924
  script: '11-activity-log.py',
499
925
  timeout: 5,
500
926
  },
@@ -0,0 +1,50 @@
1
+ import type { ActorConfig } from './types.js';
2
+ export type ActorKind = 'human' | 'agent';
3
+ export interface ResolvedActor {
4
+ /**
5
+ * Stable id for the responsible entity: the tailnet login (usually an email)
6
+ * for a resolved human, or `UNRESOLVED@<host>` when it can't be determined.
7
+ */
8
+ id: string;
9
+ kind: ActorKind;
10
+ /** Human-readable name, for git author + display. */
11
+ name?: string;
12
+ /** Email, for git author + as a durable key. */
13
+ email?: string;
14
+ /** GitHub handle, when the actors map records one. */
15
+ github?: string;
16
+ }
17
+ /** Result of `tailscale whois --json <ip>` we care about. */
18
+ export interface WhoisIdentity {
19
+ login?: string;
20
+ displayName?: string;
21
+ }
22
+ /**
23
+ * Map a resolved tailnet identity (+ the actors map) to a ResolvedActor. Pure --
24
+ * the impure whois/config reads happen in computeActor -- so the enrich/override
25
+ * path is fully testable. No login (local, or an unnameable peer) yields the
26
+ * honest `UNRESOLVED@<host>`; a login without a config entry still credits git
27
+ * from the tailnet DisplayName + login email.
28
+ */
29
+ export declare function actorFromIdentity(who: WhoisIdentity | undefined, host: string, actors: Record<string, ActorConfig>): ResolvedActor;
30
+ /**
31
+ * Compute the actor for a given environment. Pure with respect to `env` (the
32
+ * only impurity is the `tailscale whois` / config read on the fresh-SSH path),
33
+ * so tests can drive every branch by passing an env explicitly.
34
+ */
35
+ export declare function computeActor(env?: NodeJS.ProcessEnv): ResolvedActor;
36
+ /**
37
+ * Resolve the actor for the current process, cached for the process lifetime
38
+ * (the SSH `whois` shell-out runs at most once).
39
+ */
40
+ export declare function resolveActor(): ResolvedActor;
41
+ /** Clear the per-process cache. For tests, and for env changes within a run. */
42
+ export declare function resetActorCache(): void;
43
+ /**
44
+ * The env an actor propagates to child processes. Always carries the actor id +
45
+ * kind (so children inherit and don't re-resolve). For a resolved human with a
46
+ * real name and email, it also carries `GIT_AUTHOR_*` / `GIT_COMMITTER_*` so the
47
+ * agent's own commits are credited to the person, not the shared account. An
48
+ * unresolved actor sets no git identity -- git keeps its ambient config.
49
+ */
50
+ export declare function actorEnv(actor: ResolvedActor): Record<string, string>;