@phnx-labs/agents-cli 1.20.90 → 1.20.92

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 (112) 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/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -1
@@ -24,6 +24,7 @@ import chalk from 'chalk';
24
24
  import { relTime, truncate } from './format.js';
25
25
  import { getActivityDir, getUserAgentsDir } from './state.js';
26
26
  import { normalizeHost } from './machine-id.js';
27
+ import { projectKeyFromCwd } from './project-key.js';
27
28
  /** The set of milestone events, for tier classification and ordering. */
28
29
  export const MILESTONE_EVENTS = [
29
30
  'plan.created',
@@ -37,7 +38,12 @@ export const MILESTONE_EVENTS = [
37
38
  'artifact.created',
38
39
  'task.completed',
39
40
  'checklist.created',
41
+ 'video.rendered',
42
+ 'video.converted',
43
+ 'image.upscaled',
44
+ 'metadata.edited',
40
45
  'status.posted',
46
+ 'status.blocked',
41
47
  ];
42
48
  const MILESTONE_SET = new Set(MILESTONE_EVENTS);
43
49
  export function tierForEvent(event) {
@@ -130,6 +136,9 @@ function parseLine(line) {
130
136
  launchId: parsed.launchId,
131
137
  terminalId: parsed.terminalId,
132
138
  tmuxPane: parsed.tmuxPane,
139
+ category: typeof parsed.category === 'string' ? parsed.category : undefined,
140
+ bashTool: typeof parsed.bashTool === 'string' ? parsed.bashTool : undefined,
141
+ bashAction: typeof parsed.bashAction === 'string' ? parsed.bashAction : undefined,
133
142
  attachments: sanitizeAttachments(parsed.attachments),
134
143
  };
135
144
  }
@@ -293,6 +302,7 @@ export const EVENT_STYLE = {
293
302
  'checklist.created': { glyph: '☐', color: chalk.cyan, label: 'checklist created' },
294
303
  'status.posted': { glyph: '▸', color: chalk.white, label: 'status' },
295
304
  'file.edited': { glyph: '·', color: chalk.gray, label: 'file edited' },
305
+ 'bash.executed': { glyph: '$', color: chalk.gray, label: 'command run' },
296
306
  };
297
307
  export function styleForEvent(event) {
298
308
  return EVENT_STYLE[event] ?? { glyph: '•', color: chalk.white, label: event };
@@ -385,41 +395,38 @@ export function formatProgressUpdate(ev, opts = {}) {
385
395
  return lines.join('\n');
386
396
  }
387
397
  /**
388
- * Resolve a stable project/repo name from a working directory. A worktree cwd
389
- * (`…/<repo>/.agents/worktrees/<slug>[/sub]`) resolves to the repo dir name so
390
- * a worktree session groups with its own repo; any other path resolves to its
391
- * basename. Pure — no filesystem access, so it works for remote events too.
398
+ * Resolve a stable project/repo name from a working directory the same fold
399
+ * the `agents sessions` overview groups by ({@link projectKeyFromCwd}), so a
400
+ * project reads identically in both views.
392
401
  */
393
402
  export function projectFromCwd(cwd) {
394
- if (!cwd)
395
- return undefined;
396
- const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '');
397
- if (!norm)
398
- return undefined;
399
- const wtIdx = norm.indexOf('/.agents/worktrees/');
400
- if (wtIdx > 0) {
401
- const repoPath = norm.slice(0, wtIdx);
402
- const base = repoPath.slice(repoPath.lastIndexOf('/') + 1);
403
- if (base)
404
- return base;
405
- }
406
- const base = norm.slice(norm.lastIndexOf('/') + 1);
407
- return base || undefined;
403
+ return projectKeyFromCwd(cwd);
408
404
  }
409
405
  /**
410
406
  * Join session facts (ticket / project / execution host) onto each event by
411
- * `sessionId`. A hint wins; otherwise pre-baked enriched fields (from a remote
412
- * peer that already enriched its own stream) are preserved, and project/host
413
- * fall back to what the event itself carries (`cwd`, `host`). Pure.
407
+ * `sessionId`. A hint wins; otherwise a CANONICAL def match (`canonicalProject`)
408
+ * upgrades whatever the event carries; otherwise pre-baked enriched fields (from
409
+ * a remote peer that already enriched its own stream) are preserved, and
410
+ * project/host fall back to what the event itself carries (`cwd`, `host`).
411
+ *
412
+ * The def match ranks above the pre-baked stamp because the stamp may be stale
413
+ * (posted before the def existed) or skewed (a peer whose defs haven't synced);
414
+ * the match is pure prefix compare against local defs, so a different-home
415
+ * peer's path simply doesn't match and its stamp is honored as before.
416
+ *
417
+ * `resolveProject` is how a caller reading its OWN machine's logs upgrades the
418
+ * cwd fold to real repository detection ({@link resolveProjectKey}); it defaults
419
+ * to the pure {@link projectFromCwd}, which is all a path from another machine
420
+ * can be resolved with. Pure given pure resolvers.
414
421
  */
415
- export function enrichActivityEvents(events, hints) {
422
+ export function enrichActivityEvents(events, hints, resolveProject = projectFromCwd, canonicalProject) {
416
423
  const bySession = new Map();
417
424
  for (const h of hints)
418
425
  if (h.sessionId)
419
426
  bySession.set(h.sessionId, h);
420
427
  return events.map((ev) => {
421
428
  const hint = ev.sessionId ? bySession.get(ev.sessionId) : undefined;
422
- const project = hint?.project ?? ev.project ?? projectFromCwd(ev.cwd);
429
+ const project = hint?.project ?? canonicalProject?.(ev.cwd) ?? ev.project ?? resolveProject(ev.cwd);
423
430
  const ticket = hint?.ticket ?? ev.ticket ?? undefined;
424
431
  const executionHost = hint?.executionHost ?? ev.executionHost
425
432
  ?? (ev.host && ev.host !== 'unknown' ? ev.host : undefined);
@@ -475,6 +482,41 @@ export function mergeActivityEvents(...groups) {
475
482
  }
476
483
  return [...byKey.values()].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
477
484
  }
485
+ /**
486
+ * Apply `--limit` to the events a reader will actually SHOW.
487
+ *
488
+ * The default view collapses routine work (`file.edited`) to a count and shows
489
+ * milestones individually, so a plain `slice(limit)` spends the whole budget on
490
+ * churn: one busy machine editing 40 files hid every other device's PRs behind
491
+ * a single `file edited ×40` line. Milestones therefore carry the cap, and the
492
+ * routine events that ride along are the ones newer than the last milestone
493
+ * kept — so the collapsed counts describe exactly the window on screen.
494
+ *
495
+ * With `all` (routine shown inline) every event is displayed, so the cap is the
496
+ * plain slice again. Input must be newest-first; output preserves that order.
497
+ */
498
+ export function capActivityEvents(events, limit, opts = {}) {
499
+ if (!Number.isFinite(limit) || limit <= 0)
500
+ return [];
501
+ if (opts.all)
502
+ return events.slice(0, limit);
503
+ const out = [];
504
+ let milestones = 0;
505
+ let lastMilestoneIdx = -1;
506
+ for (const ev of events) {
507
+ if (tierForEvent(ev.event) === 'milestone') {
508
+ if (milestones >= limit) {
509
+ // The cap is reached: the window ends at the oldest milestone kept, so
510
+ // trailing routine events (older than it) are outside it.
511
+ return out.slice(0, lastMilestoneIdx + 1);
512
+ }
513
+ milestones += 1;
514
+ lastMilestoneIdx = out.length;
515
+ }
516
+ out.push(ev);
517
+ }
518
+ return out;
519
+ }
478
520
  /** The (key, label) an event sorts under for a given grouping dimension. */
479
521
  export function activityGroupKey(ev, by) {
480
522
  if (by === 'project') {
@@ -527,6 +569,18 @@ export function filterActivityEvents(events, filter) {
527
569
  return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
528
570
  });
529
571
  }
572
+ /**
573
+ * Narrow events to one resolved project, exact match on the enriched label
574
+ * (unlike {@link filterActivityEvents}'s cross-field substring). This is the
575
+ * `--project` flag: the label is canonical (a defined project's name) once the
576
+ * reader's resolver has run, so a multi-repo project matches as one bucket. Pure.
577
+ */
578
+ export function filterActivityByProject(events, project) {
579
+ const name = project.trim();
580
+ if (!name)
581
+ return events;
582
+ return events.filter((ev) => ev.project === name);
583
+ }
530
584
  /** One rendered enriched line: the base activity line + `· project · ticket` tags. */
531
585
  export function formatEnrichedActivityLine(ev, opts = {}) {
532
586
  const base = formatActivityLine(ev, { showHost: opts.showHost });
@@ -538,15 +592,50 @@ export function formatEnrichedActivityLine(ev, opts = {}) {
538
592
  const suffix = tags.length > 0 ? chalk.gray(` · ${tags.join(' · ')}`) : '';
539
593
  return `${opts.indent ?? ''}${base}${suffix}`;
540
594
  }
541
- /** Human header for one grouped bucket: `<label> · N events · M milestones`. */
542
- export function formatActivityGroupHeader(group) {
595
+ /** Max device names named in a group header before the rest collapse to `+N`. */
596
+ export const GROUP_HEADER_DEVICE_LIMIT = 3;
597
+ /**
598
+ * The distinct machines a group's events ran on, most-active first then
599
+ * alphabetical — so a project header can say WHERE the work happened without
600
+ * sub-grouping the timeline. Prefers the session-joined `executionHost` (where
601
+ * the process really lives) over the raw emitting `host`. Pure.
602
+ */
603
+ export function activityGroupDevices(events) {
604
+ const counts = new Map();
605
+ for (const ev of events) {
606
+ const host = ev.executionHost ?? (ev.host && ev.host !== 'unknown' ? ev.host : undefined);
607
+ if (!host)
608
+ continue;
609
+ counts.set(host, (counts.get(host) ?? 0) + 1);
610
+ }
611
+ return [...counts.entries()]
612
+ .sort((a, b) => (b[1] !== a[1] ? b[1] - a[1] : a[0].localeCompare(b[0])))
613
+ .map(([host]) => host);
614
+ }
615
+ /**
616
+ * The trailing facts for one grouped bucket: `N events · M milestones`, plus
617
+ * the machines the work ran on when `showDevices` is set (redundant when the
618
+ * grouping dimension IS the device, so the caller decides). The device list is
619
+ * capped at {@link GROUP_HEADER_DEVICE_LIMIT} with a `+N` tail, so a project
620
+ * touched by a dozen boxes stays one scannable line. Separate from the label so
621
+ * a renderer can color the two differently without re-splitting a string.
622
+ */
623
+ export function formatActivityGroupMeta(group, opts = {}) {
543
624
  const { milestones } = collapseActivity(group.events);
544
625
  const n = group.events.length;
545
626
  const m = milestones.length;
546
627
  const parts = [`${n} event${n === 1 ? '' : 's'}`];
547
628
  if (m > 0)
548
629
  parts.push(`${m} milestone${m === 1 ? '' : 's'}`);
549
- return `${group.label} · ${parts.join(' · ')}`;
630
+ if (opts.showDevices) {
631
+ const devices = activityGroupDevices(group.events);
632
+ if (devices.length > 0) {
633
+ const named = devices.slice(0, GROUP_HEADER_DEVICE_LIMIT);
634
+ const rest = devices.length - named.length;
635
+ parts.push(rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', '));
636
+ }
637
+ }
638
+ return parts.join(' · ');
550
639
  }
551
640
  // ---------------------------------------------------------------------------
552
641
  // Hook installation
@@ -561,15 +650,15 @@ export function formatActivityGroupHeader(group) {
561
650
  * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
562
651
  * a logging hiccup never blocks a tool call.
563
652
  */
564
- export const ACTIVITY_LOG_HOOK_SCRIPT = `#!/usr/bin/env python3
565
- """Append agent-activity events for \`agents feed\` / \`agents activity\`.
653
+ export const ACTIVITY_LOG_HOOK_SCRIPT = String.raw `#!/usr/bin/env python3
654
+ """Append agent-activity events for 'agents feed' / 'agents activity'.
566
655
 
567
656
  Bound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,
568
657
  MultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One
569
658
  append-only file per session; read-only tools never trigger it because the
570
659
  manifest matcher excludes them.
571
660
 
572
- Sub-agent gate: when the payload carries \`agent_type\`, this is a Task/Agent
661
+ Sub-agent gate: when the payload carries 'agent_type', this is a Task/Agent
573
662
  sub-agent -- skip so only the top-level agent logs its own activity.
574
663
 
575
664
  Fail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.
@@ -586,7 +675,9 @@ MAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log
586
675
  MILESTONE_EVENTS = {
587
676
  "plan.created", "pr.opened", "pr.merged", "worktree.created",
588
677
  "worktree.removed", "commit.created", "pushed", "subagent.spawned",
589
- "artifact.created", "task.completed", "checklist.created", "status.posted",
678
+ "artifact.created", "task.completed", "checklist.created",
679
+ "video.rendered", "video.converted", "image.upscaled", "metadata.edited",
680
+ "status.posted",
590
681
  }
591
682
 
592
683
  # Deliverable file types + locations -- a Write here is a recognizable artifact
@@ -621,54 +712,293 @@ def first_line(text, limit=140):
621
712
  return ""
622
713
 
623
714
 
624
- def _subcommand(tokens, tool):
625
- """First non-flag token after \`tool\`, i.e. its subcommand (skips -C <path>,
626
- -c <cfg>, and other leading flags). None if \`tool\` isn't the invoked command."""
627
- try:
628
- i = tokens.index(tool) + 1
629
- except ValueError:
630
- return None, []
715
+ # Canonical command taxonomy for Bash tool calls. Mirrors the TypeScript
716
+ # registry in lib/session/bash-command.ts; keep them in sync.
717
+ BASH_TOOL_REGISTRY = {
718
+ "git": {"category": "vcs", "action": "working in git"},
719
+ "gh": {"category": "vcs", "action": "using GitHub CLI"},
720
+ "bun": {"category": "build-test", "action": "running bun"},
721
+ "npm": {"category": "build-test", "action": "running npm"},
722
+ "pnpm": {"category": "build-test", "action": "running pnpm"},
723
+ "yarn": {"category": "build-test", "action": "running yarn"},
724
+ "vitest": {"category": "build-test", "action": "running vitest"},
725
+ "jest": {"category": "build-test", "action": "running jest"},
726
+ "mocha": {"category": "build-test", "action": "running mocha"},
727
+ "pytest": {"category": "build-test", "action": "running pytest"},
728
+ "cargo": {"category": "build-test", "action": "running cargo"},
729
+ "go": {"category": "build-test", "action": "running go"},
730
+ "tsc": {"category": "build-test", "action": "running tsc"},
731
+ "tsx": {"category": "build-test", "action": "running tsx"},
732
+ "node": {"category": "build-test", "action": "running node"},
733
+ "python": {"category": "build-test", "action": "running python"},
734
+ "python3": {"category": "build-test", "action": "running python"},
735
+ "make": {"category": "build-test", "action": "running make"},
736
+ "brew": {"category": "install", "action": "installing with brew"},
737
+ "pip": {"category": "install", "action": "installing with pip"},
738
+ "pip3": {"category": "install", "action": "installing with pip"},
739
+ "apt": {"category": "install", "action": "installing with apt"},
740
+ "apk": {"category": "install", "action": "installing with apk"},
741
+ "ssh": {"category": "remote", "action": "using ssh"},
742
+ "scp": {"category": "remote", "action": "using scp"},
743
+ "rsync": {"category": "remote", "action": "using rsync"},
744
+ "curl": {"category": "http", "action": "fetching with curl"},
745
+ "wget": {"category": "http", "action": "fetching with wget"},
746
+ "ffmpeg": {"category": "media", "action": "using ffmpeg"},
747
+ "ffprobe": {"category": "media", "action": "probing media"},
748
+ "magick": {"category": "media", "action": "using ImageMagick"},
749
+ "convert": {"category": "media", "action": "converting images"},
750
+ "composite": {"category": "media", "action": "compositing images"},
751
+ "montage": {"category": "media", "action": "montaging images"},
752
+ "identify": {"category": "media", "action": "identifying images"},
753
+ "realesrgan": {"category": "upscaling", "action": "upscaling with realesrgan"},
754
+ "realesrgan-ncnn-vulkan": {"category": "upscaling", "action": "upscaling with realesrgan"},
755
+ "waifu2x": {"category": "upscaling", "action": "upscaling with waifu2x"},
756
+ "waifu2x-caffe": {"category": "upscaling", "action": "upscaling with waifu2x"},
757
+ "waifu2x-converter-cpp": {"category": "upscaling", "action": "upscaling with waifu2x"},
758
+ "swin2sr": {"category": "upscaling", "action": "upscaling with swin2sr"},
759
+ "resdet": {"category": "upscaling", "action": "detecting upscale"},
760
+ "id3v2": {"category": "metadata", "action": "editing id3 tags"},
761
+ "exiftool": {"category": "metadata", "action": "editing exif metadata"},
762
+ "metaflac": {"category": "metadata", "action": "editing flac metadata"},
763
+ "vorbiscomment": {"category": "metadata", "action": "editing vorbis comments"},
764
+ # Shell
765
+ "rm": {"category": "shell", "action": "removing files"},
766
+ "mv": {"category": "shell", "action": "moving files"},
767
+ "cp": {"category": "shell", "action": "copying files"},
768
+ "mkdir": {"category": "shell", "action": "making directories"},
769
+ "touch": {"category": "shell", "action": "touching files"},
770
+ "echo": {"category": "shell", "action": "echoing"},
771
+ "printf": {"category": "shell", "action": "printing"},
772
+ "chmod": {"category": "shell", "action": "changing permissions"},
773
+ "ln": {"category": "shell", "action": "linking files"},
774
+ "awk": {"category": "shell", "action": "running awk"},
775
+ "sed": {"category": "shell", "action": "running sed"},
776
+ "tee": {"category": "shell", "action": "teeing output"},
777
+ "xargs": {"category": "shell", "action": "running xargs"},
778
+ # Probes
779
+ "ls": {"category": "probe", "action": "listing files"},
780
+ "cat": {"category": "probe", "action": "reading files"},
781
+ "head": {"category": "probe", "action": "reading files"},
782
+ "tail": {"category": "probe", "action": "reading files"},
783
+ "wc": {"category": "probe", "action": "counting"},
784
+ "stat": {"category": "probe", "action": "statting files"},
785
+ "file": {"category": "probe", "action": "inspecting files"},
786
+ "which": {"category": "probe", "action": "locating binaries"},
787
+ "tree": {"category": "probe", "action": "listing files"},
788
+ "pwd": {"category": "probe", "action": "printing pwd"},
789
+ # Search
790
+ "grep": {"category": "search", "action": "searching"},
791
+ "rg": {"category": "search", "action": "searching with ripgrep"},
792
+ "ag": {"category": "search", "action": "searching with the silver searcher"},
793
+ "fd": {"category": "search", "action": "searching files"},
794
+ "find": {"category": "search", "action": "finding files"},
795
+ # Wait
796
+ "sleep": {"category": "wait", "action": "sleeping"},
797
+ "wait": {"category": "wait", "action": "waiting"},
798
+ }
799
+
800
+ # Two-level tools mapped to the flags that consume the following token as their
801
+ # value, per tool; the subcommand scan skips both. Missing a value flag mis-reads
802
+ # the value as the subcommand; over-listing only drops the subcommand (safe). A
803
+ # tool with no such leading flags maps to an empty set. Mirrors VALUE_FLAGS in
804
+ # lib/session/bash-command.ts; TWO_LEVEL_TOOLS is derived from its keys.
805
+ VALUE_FLAGS = {
806
+ "git": {"-C", "-c", "--git-dir", "--work-tree"},
807
+ "gh": {"-R", "--repo"},
808
+ "bun": {"--cwd"},
809
+ "npm": {"--prefix", "-w", "--workspace"},
810
+ "pnpm": {"--filter", "-C", "--dir"},
811
+ "yarn": {"--cwd"},
812
+ "cargo": {"--manifest-path"},
813
+ "docker": {"-H", "--host", "-c", "--context", "--config", "-l", "--log-level"},
814
+ "kubectl": {"-n", "--namespace", "--kubeconfig", "--context", "--cluster", "--user", "-s", "--server", "--as", "--token", "--cache-dir", "--request-timeout"},
815
+ "rush": set(),
816
+ "openclaw": set(),
817
+ }
818
+
819
+ TWO_LEVEL_TOOLS = set(VALUE_FLAGS.keys())
820
+
821
+
822
+ def _unwrap_command(cmd):
823
+ """Strip wrapper prefixes so the real executable is classified."""
824
+ s = (cmd or "").strip()
825
+ ssh = re.match(r'^ssh\s+\S+\s+["\']?(.+?)["\']?\s*(?:\|.*)?$', s)
826
+ if ssh:
827
+ return _unwrap_command(ssh.group(1))
828
+ # VAR=value prefix (value may be a single- or double-quoted string with spaces)
829
+ env = re.match(r'^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s+)+(.+)$', s)
830
+ if env:
831
+ return _unwrap_command(env.group(2))
832
+ # sudo / time prefix (value-taking flags such as -u user consume their argument)
833
+ prefix = re.match(r'^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$', s)
834
+ if prefix:
835
+ return _unwrap_command(prefix.group(1))
836
+ cd = re.match(r'^cd\s+\S+\s*&&\s*(.+)$', s)
837
+ if cd:
838
+ return _unwrap_command(cd.group(1))
839
+ npx = re.match(r'^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$', s)
840
+ if npx:
841
+ return _unwrap_command(npx.group(1))
842
+ return s
843
+
844
+
845
+ def _split_on_operators(cmd):
846
+ """Split a command on && || | ; while respecting quotes and escapes."""
847
+ parts = []
848
+ current = ""
849
+ quote = None
850
+ escaped = False
851
+ i = 0
852
+ while i < len(cmd):
853
+ ch = cmd[i]
854
+ if escaped:
855
+ current += ch
856
+ escaped = False
857
+ i += 1
858
+ continue
859
+ if ch == "\\":
860
+ current += ch
861
+ escaped = True
862
+ i += 1
863
+ continue
864
+ if quote:
865
+ current += ch
866
+ if ch == quote:
867
+ quote = None
868
+ i += 1
869
+ continue
870
+ if ch in ('"', "'"):
871
+ current += ch
872
+ quote = ch
873
+ i += 1
874
+ continue
875
+ if cmd[i:i+2] in ("&&", "||"):
876
+ if current.strip():
877
+ parts.append(current.strip())
878
+ current = ""
879
+ i += 2
880
+ continue
881
+ if ch in ("|", ";"):
882
+ if current.strip():
883
+ parts.append(current.strip())
884
+ current = ""
885
+ i += 1
886
+ continue
887
+ current += ch
888
+ i += 1
889
+ if current.strip():
890
+ parts.append(current.strip())
891
+ return parts
892
+
893
+
894
+ def _tokenize_bash(command):
895
+ """Return a list of token lists, one per simple command."""
896
+ unwrapped = _unwrap_command(command)
897
+ segments = _split_on_operators(unwrapped)
898
+ out = []
899
+ for seg in segments:
900
+ try:
901
+ tokens = shlex.split(seg)
902
+ except Exception:
903
+ tokens = seg.split()
904
+ if tokens:
905
+ out.append(tokens)
906
+ return out
907
+
908
+
909
+ def _scan_subcommand(tokens, tool):
910
+ """First non-flag token after the executable, skipping flags and the argument
911
+ of a value-taking flag for that tool. Mirrors scanSubcommand in bash-command.ts."""
912
+ value_flags = VALUE_FLAGS.get(tool, set())
913
+ i = 1
631
914
  while i < len(tokens):
632
915
  t = tokens[i]
633
- if t in ("-C", "-c", "--git-dir", "--work-tree"):
634
- i += 2 # flag that consumes the next token
635
- continue
636
916
  if t.startswith("-"):
637
- i += 1
917
+ i += 2 if t in value_flags else 1
638
918
  continue
639
- return t, tokens[i + 1:]
640
- return None, []
919
+ return t.lower()
920
+ return ""
641
921
 
642
922
 
643
- def classify_bash(command):
644
- """Return the milestone event for a git/gh command, else None. Tokenizes so a
645
- path like \`git diff -- src/commit.ts\` is not mistaken for a commit."""
646
- try:
647
- tokens = shlex.split(command or "")
648
- except Exception:
649
- tokens = (command or "").split()
650
-
651
- verb, rest = _subcommand(tokens, "git")
652
- if verb == "worktree":
653
- sub = rest[0] if rest else ""
654
- if sub == "add":
655
- return "worktree.created"
656
- if sub == "remove":
657
- return "worktree.removed"
658
- elif verb == "commit":
659
- return "commit.created"
660
- elif verb == "push":
661
- return "pushed"
662
-
663
- verb, rest = _subcommand(tokens, "gh")
664
- if verb == "pr" and rest:
665
- if rest[0] == "create":
666
- return "pr.opened"
667
- if rest[0] == "merge":
668
- return "pr.merged"
923
+ def classify_bash_command(command):
924
+ """Return {tool, category, subcommand, action, summary} for the first simple command."""
925
+ simple_commands = _tokenize_bash(command)
926
+ if not simple_commands:
927
+ return {"tool": "other", "category": "other", "subcommand": "", "action": "running command", "summary": ""}
928
+ tokens = simple_commands[0]
929
+ if not tokens:
930
+ return {"tool": "other", "category": "other", "subcommand": "", "action": "running command", "summary": ""}
931
+
932
+ first = tokens[0]
933
+ base = re.sub(r'^[./]+', '', first).lower()
934
+ if base.endswith(".exe"):
935
+ base = base[:-4]
936
+ info = BASH_TOOL_REGISTRY.get(base)
937
+ if not info:
938
+ # Known two-level tool absent from the registry (docker/kubectl/rush/
939
+ # openclaw) still surfaces its subcommand; category stays 'other'.
940
+ sub = _scan_subcommand(tokens, base) if base in TWO_LEVEL_TOOLS else ""
941
+ summary = "{} {}".format(base, sub) if sub else first
942
+ return {"tool": first, "category": "other", "subcommand": sub, "action": "running command", "summary": summary}
943
+
944
+ subcommand = _scan_subcommand(tokens, base) if base in TWO_LEVEL_TOOLS else ""
945
+
946
+ summary = "{} {}".format(base, subcommand) if subcommand else base
947
+ return {
948
+ "tool": base,
949
+ "category": info["category"],
950
+ "subcommand": subcommand,
951
+ "action": info["action"],
952
+ "summary": summary,
953
+ }
954
+
955
+
956
+ def detect_bash_milestone(command):
957
+ """Return (event, detail) for high-signal Bash commands, else None."""
958
+ info = classify_bash_command(command)
959
+ lower = (command or "").lower()
960
+
961
+ if info["category"] == "upscaling":
962
+ return "image.upscaled", info["action"]
963
+
964
+ if info["tool"] == "ffmpeg":
965
+ has_output = re.search(r'\s+\S+\.\w{2,5}\s*$', command or "")
966
+ if has_output or "-c:v" in lower or "-codec" in lower or "libx264" in lower:
967
+ return "video.rendered", "ffmpeg render"
968
+ return "video.converted", "ffmpeg"
969
+
970
+ if info["category"] == "metadata":
971
+ return "metadata.edited", info["action"]
972
+
973
+ if info["tool"] == "git":
974
+ if info["subcommand"] == "commit":
975
+ return "commit.created", "git commit"
976
+ if info["subcommand"] == "push":
977
+ return "pushed", "git push"
978
+ if info["subcommand"] == "worktree":
979
+ if "worktree add" in lower:
980
+ return "worktree.created", "git worktree add"
981
+ if "worktree remove" in lower:
982
+ return "worktree.removed", "git worktree remove"
983
+
984
+ if info["tool"] == "gh" and info["subcommand"] == "pr":
985
+ if "pr create" in lower:
986
+ return "pr.opened", "gh pr create"
987
+ if "pr merge" in lower:
988
+ return "pr.merged", "gh pr merge"
989
+
669
990
  return None
670
991
 
671
992
 
993
+ def first_line_of_command(command):
994
+ """First non-empty line of a command, trimmed."""
995
+ for raw in (command or "").splitlines():
996
+ s = raw.strip()
997
+ if s:
998
+ return s[:140]
999
+ return ""
1000
+
1001
+
672
1002
  def extract_url(tool_response):
673
1003
  """Pull the first https URL out of a Bash tool response (stdout)."""
674
1004
  text = ""
@@ -676,7 +1006,7 @@ def extract_url(tool_response):
676
1006
  text = str(tool_response.get("stdout") or tool_response.get("output") or "")
677
1007
  elif isinstance(tool_response, str):
678
1008
  text = tool_response
679
- m = re.search(r"https?://\\S+", text)
1009
+ m = re.search(r"https?://\S+", text)
680
1010
  return m.group(0).rstrip(").,") if m else None
681
1011
 
682
1012
 
@@ -1107,10 +1437,24 @@ def build_event(payload, hook_event):
1107
1437
  detail = (role + ": " + first_line(desc)).strip(": ").strip()
1108
1438
  elif hook_event == "PostToolUse":
1109
1439
  if tool_name == "Bash":
1110
- event = classify_bash(tool_input.get("command", ""))
1111
- if event:
1112
- detail = first_line(tool_input.get("command", ""))
1440
+ cmd = tool_input.get("command", "")
1441
+ info = classify_bash_command(cmd)
1442
+ records = []
1443
+ # Always emit a structured bash.executed activity event.
1444
+ bash_record = _make_record("bash.executed", info.get("summary") or first_line_of_command(cmd), tool_name)
1445
+ bash_record["category"] = info.get("category")
1446
+ bash_record["bashTool"] = info.get("tool")
1447
+ bash_record["bashAction"] = info.get("action")
1448
+ records.append(bash_record)
1449
+ milestone = detect_bash_milestone(cmd)
1450
+ if milestone:
1451
+ event, detail = milestone
1452
+ milestone_record = _make_record(event, detail, tool_name)
1113
1453
  url = extract_url(tool_response)
1454
+ if url:
1455
+ milestone_record["url"] = url
1456
+ records.append(milestone_record)
1457
+ return records, tool_name
1114
1458
  elif tool_name in ("Write", "Edit", "MultiEdit"):
1115
1459
  fp = tool_input.get("file_path") or tool_input.get("path") or ""
1116
1460
  # A freshly-written deliverable is a milestone; edits and code
@@ -1184,7 +1528,7 @@ def main():
1184
1528
  for record in records:
1185
1529
  if over_limit and record.get("tier") != "milestone":
1186
1530
  continue
1187
- f.write(json.dumps(record) + "\\n")
1531
+ f.write(json.dumps(record) + "\n")
1188
1532
  except Exception:
1189
1533
  pass # fail open
1190
1534
 
@@ -8,7 +8,7 @@
8
8
  * checks.
9
9
  */
10
10
  import type { BetaFeatureName } from './types.js';
11
- export declare const ALL_BETA_FEATURES: readonly ["drive", "factory", "session-sync"];
11
+ export declare const ALL_BETA_FEATURES: readonly ["drive", "factory", "session-sync", "projects"];
12
12
  export declare function getEnabledBetaFeatures(): BetaFeatureName[];
13
13
  export declare function isBetaEnabled(feature: BetaFeatureName): boolean;
14
14
  export declare function getBetaConfigLocation(): {
package/dist/lib/beta.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import * as path from 'path';
11
11
  import { getAgentsDir, getOptionalUserAgentsDir, readMeta, writeMeta } from './state.js';
12
12
  import { readManifest, writeManifest } from './manifest.js';
13
- export const ALL_BETA_FEATURES = ['drive', 'factory', 'session-sync'];
13
+ export const ALL_BETA_FEATURES = ['drive', 'factory', 'session-sync', 'projects'];
14
14
  function isBetaFeatureName(value) {
15
15
  return typeof value === 'string' && ALL_BETA_FEATURES.includes(value);
16
16
  }