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