@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
package/dist/lib/feed.js CHANGED
@@ -348,6 +348,10 @@ CLEAR_EVENTS = {
348
348
  "Stop",
349
349
  "SessionEnd",
350
350
  }
351
+ # Codex emits a PermissionRequest event (not Claude's Notification) when it
352
+ # blocks on an approval prompt. Claude never fires PermissionRequest, so the
353
+ # same script handles both: PermissionRequest maps to an approval-class block
354
+ # with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.
351
355
 
352
356
 
353
357
  def read_json(path):
@@ -398,6 +402,19 @@ def main():
398
402
  hook_event = payload.get("hook_event_name", "PreToolUse")
399
403
 
400
404
  if hook_event in CLEAR_EVENTS:
405
+ # A matcher-less PostToolUse clear (registered for Codex so an approved
406
+ # tool clears its approval card) must NOT wipe an open AskUserQuestion
407
+ # while an unrelated tool runs mid-question -- those are cleared only by
408
+ # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a
409
+ # 'question' block; approval/notification blocks clear once the tool runs.
410
+ if hook_event == "PostToolUse":
411
+ try:
412
+ with open(target) as existing_file:
413
+ existing = json.load(existing_file)
414
+ if existing.get("kind") == "question" and payload.get("tool_name") != "AskUserQuestion":
415
+ return
416
+ except Exception:
417
+ pass
401
418
  try:
402
419
  os.unlink(target)
403
420
  except FileNotFoundError:
@@ -442,6 +459,7 @@ def main():
442
459
  return
443
460
 
444
461
  notification_type = None
462
+ codex_approval = False
445
463
  if hook_event == "Notification":
446
464
  notification_type = payload.get("notification_type", "")
447
465
  if notification_type not in WAITING_NOTIFICATION_TYPES:
@@ -466,6 +484,34 @@ def main():
466
484
  "multiSelect": False,
467
485
  }]
468
486
  kind = "notification"
487
+ elif hook_event == "PermissionRequest":
488
+ # Codex approval prompt. The payload mirrors PreToolUse (tool_name,
489
+ # tool_input) but carries no questions -- Codex is asking to run a tool,
490
+ # not asking the operator a multiple-choice question. Publish it as a
491
+ # notification-kind approval block naming the tool so the feed and the
492
+ # phone notifier can surface it, and so the Factory extension can bridge
493
+ # it to a VS Code notification.
494
+ tool_name = payload.get("tool_name") or "a tool"
495
+ tool_input = payload.get("tool_input", {})
496
+ command = ""
497
+ if isinstance(tool_input, dict):
498
+ command = (
499
+ tool_input.get("command")
500
+ or tool_input.get("cmd")
501
+ or tool_input.get("path")
502
+ or ""
503
+ )
504
+ if isinstance(command, list):
505
+ command = " ".join(str(c) for c in command)
506
+ detail = f": {command}" if command else ""
507
+ normalized_questions = [{
508
+ "text": f"Codex needs approval to run {tool_name}{detail}",
509
+ "header": "Approval needed",
510
+ "multiSelect": False,
511
+ }]
512
+ kind = "notification"
513
+ notification_type = "permission_prompt"
514
+ codex_approval = True
469
515
  else:
470
516
  tool_input = payload.get("tool_input", {})
471
517
  questions = tool_input.get("questions", [])
@@ -535,9 +581,21 @@ def main():
535
581
  if notification_type:
536
582
  block["notificationType"] = notification_type
537
583
 
584
+ # A Codex PermissionRequest is a real approval gate: mark it approval-class
585
+ # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent
586
+ # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default
587
+ # 'medium') and pages the phone. A plain 'deny' is the safe default.
588
+ if codex_approval:
589
+ block["blockClass"] = "approval"
590
+ block["costOfDelay"] = "high"
591
+ block["safeDefault"] = "deny"
592
+
538
593
  # Optional multi-operator control metadata passed by the agent in the
539
- # AskUserQuestion tool_input. Defaults keep the existing behavior.
540
- controls = payload.get("tool_input", {}) if hook_event != "Notification" else {}
594
+ # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex
595
+ # PermissionRequest carries tool ARGS in tool_input (command/path), not
596
+ # operator controls, so it is excluded here -- its class/cost is stamped
597
+ # above from codex_approval.
598
+ controls = payload.get("tool_input", {}) if hook_event not in ("Notification", "PermissionRequest") else {}
541
599
  block_class = controls.get("blockClass") if isinstance(controls, dict) else None
542
600
  if block_class in ("approval", "decision"):
543
601
  block["blockClass"] = block_class
@@ -604,6 +662,15 @@ export const FEED_NOTIFICATION_HOOK_MANIFEST = {
604
662
  script: '10-feed-publish.py',
605
663
  timeout: 5,
606
664
  };
665
+ // Codex fires PermissionRequest (not Claude's Notification) when it blocks on an
666
+ // approval prompt. The same script handles it, publishing a high-cost approval
667
+ // block so the feed dispatch pages the phone. PermissionRequest has no matcher.
668
+ export const FEED_PERMISSION_HOOK_MANIFEST = {
669
+ name: 'feed-publish-permission',
670
+ events: ['PermissionRequest'],
671
+ script: '10-feed-publish.py',
672
+ timeout: 5,
673
+ };
607
674
  export const FEED_ANSWERED_HOOK_MANIFEST = {
608
675
  name: 'feed-clear-answered',
609
676
  events: ['PostToolUse'],
@@ -644,28 +711,51 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
644
711
  }
645
712
  const desiredHooks = {
646
713
  'feed-publish': {
647
- agents: ['claude'],
714
+ agents: ['claude', 'codex'],
648
715
  events: ['PreToolUse'],
649
716
  matcher: 'AskUserQuestion',
650
717
  script: '10-feed-publish.py',
651
718
  timeout: 5,
652
719
  },
653
720
  'feed-publish-notification': {
654
- agents: ['claude'],
721
+ agents: ['claude', 'codex'],
655
722
  events: ['Notification'],
656
723
  matcher: 'permission_prompt|idle_prompt|elicitation_dialog',
657
724
  script: '10-feed-publish.py',
658
725
  timeout: 5,
659
726
  },
727
+ // Codex-specific approval gate: Codex emits PermissionRequest (Claude does
728
+ // not), so this hook is where a blocked Codex agent surfaces to the feed.
729
+ 'feed-publish-permission': {
730
+ agents: ['claude', 'codex'],
731
+ events: ['PermissionRequest'],
732
+ script: '10-feed-publish.py',
733
+ timeout: 5,
734
+ },
660
735
  'feed-clear-answered': {
661
- agents: ['claude'],
736
+ agents: ['claude', 'codex'],
662
737
  events: ['PostToolUse'],
663
738
  matcher: 'AskUserQuestion',
664
739
  script: '10-feed-publish.py',
665
740
  timeout: 5,
666
741
  },
742
+ // Matcher-less PostToolUse clear: after Codex runs an approved tool, the
743
+ // approval card is stale, so clear it. Codex-only on purpose -- Claude
744
+ // never fires PermissionRequest, so it has no approval card to clear here,
745
+ // and a matcher-less PostToolUse for Claude would (1) re-run the script on
746
+ // every tool completion and (2) wipe Claude's notification-kind blocks
747
+ // (permission_prompt/idle_prompt/elicitation_dialog) the moment any later
748
+ // tool runs, instead of letting them persist to Stop/SessionEnd like they
749
+ // did before RUSH-2039. Registering it for codex alone keeps Claude's
750
+ // card lifetime exactly as it was.
751
+ 'feed-clear-permission': {
752
+ agents: ['codex'],
753
+ events: ['PostToolUse'],
754
+ script: '10-feed-publish.py',
755
+ timeout: 5,
756
+ },
667
757
  'feed-clear-lifecycle': {
668
- agents: ['claude'],
758
+ agents: ['claude', 'codex'],
669
759
  events: ['Stop', 'UserPromptSubmit', 'SessionEnd'],
670
760
  script: '10-feed-publish.py',
671
761
  timeout: 5,
package/dist/lib/git.d.ts CHANGED
@@ -86,6 +86,19 @@ export declare function clonePackage(source: string): Promise<{
86
86
  }>;
87
87
  /** Get the short commit hash (8 chars) of the latest commit in a repo. */
88
88
  export declare function getRepoCommit(repoPath: string): Promise<string>;
89
+ /** Compact, self-contained state of a git repo — branch, short HEAD, and a
90
+ * dirty flag — for cross-device comparison (RUSH-2027). Synchronous and
91
+ * best-effort: a non-repo or unreadable path yields `null` fields, never a
92
+ * throw, so a device's `doctor --json` payload always serializes. */
93
+ export interface RepoStateSnapshot {
94
+ branch: string | null;
95
+ head: string | null;
96
+ dirty: boolean;
97
+ }
98
+ /** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
99
+ * result is stable across git versions and never mutates the tree. Returns null
100
+ * when the path is not a git worktree. */
101
+ export declare function readRepoState(repoPath: string): RepoStateSnapshot | null;
89
102
  /**
90
103
  * Get the current GitHub username using gh CLI.
91
104
  * Returns null if gh is not installed or user is not authenticated.
package/dist/lib/git.js CHANGED
@@ -334,6 +334,34 @@ export async function getRepoCommit(repoPath) {
334
334
  return 'unknown';
335
335
  }
336
336
  }
337
+ /** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
338
+ * result is stable across git versions and never mutates the tree. Returns null
339
+ * when the path is not a git worktree. */
340
+ export function readRepoState(repoPath) {
341
+ const runGit = (args) => {
342
+ try {
343
+ return execFileSync('git', ['-C', repoPath, ...args], {
344
+ stdio: ['ignore', 'pipe', 'ignore'],
345
+ })
346
+ .toString()
347
+ .trim();
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ };
353
+ // Gate on a real worktree first; `rev-parse --is-inside-work-tree` prints
354
+ // `true` only inside one, and returns non-zero (→ null) otherwise.
355
+ if (runGit(['rev-parse', '--is-inside-work-tree']) !== 'true')
356
+ return null;
357
+ const branchRaw = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
358
+ const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null; // detached → null
359
+ const headRaw = runGit(['rev-parse', 'HEAD']);
360
+ const head = headRaw ? headRaw.slice(0, 8) : null;
361
+ const porcelain = runGit(['status', '--porcelain']);
362
+ const dirty = porcelain != null && porcelain.length > 0;
363
+ return { branch, head, dirty };
364
+ }
337
365
  /**
338
366
  * Get the current GitHub username using gh CLI.
339
367
  * Returns null if gh is not installed or user is not authenticated.
@@ -813,6 +841,39 @@ export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
813
841
  export async function pullRepo(dir) {
814
842
  try {
815
843
  const git = simpleGit(dir);
844
+ // A rebase left in progress by an earlier run must be reported as itself.
845
+ // Without this the dirty-tree guard below claims "Blocked by local changes",
846
+ // which is both wrong and actively harmful advice mid-rebase on a detached
847
+ // HEAD. Checked BEFORE commitOwnDeviceMeta so we never commit into one.
848
+ // Ask git where the state dirs live rather than assuming `<dir>/.git/` is a
849
+ // directory. In a worktree `.git` is a FILE containing `gitdir: <path>`, so
850
+ // path.join(dir, '.git', 'rebase-merge') can never exist and the check would
851
+ // silently never fire. `rev-parse --git-path` resolves both layouts.
852
+ const gitPath = async (name) => {
853
+ try {
854
+ const raw = (await git.raw(['rev-parse', '--git-path', name])).trim();
855
+ return path.isAbsolute(raw) ? raw : path.join(dir, raw);
856
+ }
857
+ catch {
858
+ return null;
859
+ }
860
+ };
861
+ const [rebaseMerge, rebaseApply] = await Promise.all([
862
+ gitPath('rebase-merge'),
863
+ gitPath('rebase-apply'),
864
+ ]);
865
+ const rebaseInProgress = (rebaseMerge !== null && fs.existsSync(rebaseMerge)) ||
866
+ (rebaseApply !== null && fs.existsSync(rebaseApply));
867
+ if (rebaseInProgress) {
868
+ return {
869
+ success: false,
870
+ commit: '',
871
+ error: `A previous rebase is still in progress — finish or abort it, then pull again.\n\n` +
872
+ ` cd ${displayHomePath(dir)} && git status\n` +
873
+ ` git rebase --continue # after resolving\n` +
874
+ ` git rebase --abort # to discard the attempt`,
875
+ };
876
+ }
816
877
  // Commit this machine's own device-meta first so a per-machine pin change
817
878
  // never wedges the pull. Genuine edits elsewhere still block below.
818
879
  await commitOwnDeviceMeta(dir);
@@ -825,12 +886,15 @@ export async function pullRepo(dir) {
825
886
  };
826
887
  }
827
888
  const branch = status.current || 'main';
828
- await git.fetch('origin');
829
889
  // Resolve the upstream ref to fast-forward against. Prefer the local
830
890
  // branch's tracking config; otherwise ask origin for its default branch.
831
891
  let tracking = status.tracking;
832
892
  if (!tracking) {
893
+ // No tracking config: fetch origin so its HEAD is known, then ask which
894
+ // branch it points at. This path only ever concerns origin — a branch with
895
+ // no upstream has no other remote to consult.
833
896
  try {
897
+ await git.fetch('origin');
834
898
  await git.raw(['remote', 'set-head', 'origin', '--auto']);
835
899
  const sym = await git.raw(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
836
900
  tracking = sym.trim();
@@ -839,6 +903,22 @@ export async function pullRepo(dir) {
839
903
  tracking = `origin/${branch}`;
840
904
  }
841
905
  }
906
+ // Split the remote-tracking ref (<remote>/<branch>) into its parts and pull
907
+ // THOSE. Hardcoding 'origin' while comparing against `tracking` is how a
908
+ // branch tracking e.g. upstream/main silently 'succeeded': revparse saw a
909
+ // difference, the pull fetched origin/main (already current), and pullRepo
910
+ // returned success having moved nothing — the same "reported ok, pulled
911
+ // nothing" failure this change exists to remove. Branch names may contain
912
+ // slashes, so split on the FIRST separator only.
913
+ const sep = tracking.indexOf('/');
914
+ const remoteName = sep > 0 ? tracking.slice(0, sep) : 'origin';
915
+ const remoteBranch = sep > 0 ? tracking.slice(sep + 1) : branch;
916
+ // Bare fetch: updates every remote, so the revparse below sees a fresh ref
917
+ // whichever one the branch tracks. Deliberately argument-less — simple-git's
918
+ // fetchTask only forwards a remote when BOTH remote and branch are passed,
919
+ // so `fetch(remoteName)` would silently drop the argument and do exactly
920
+ // this anyway. Saying so beats an inert argument that reads as targeted.
921
+ await git.fetch();
842
922
  const localRef = await git.revparse(['HEAD']);
843
923
  const remoteRef = await git.revparse([tracking]).catch(() => null);
844
924
  if (!remoteRef) {
@@ -853,13 +933,31 @@ export async function pullRepo(dir) {
853
933
  };
854
934
  }
855
935
  try {
856
- await git.merge(['--ff-only', tracking]);
936
+ // Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
937
+ // not, so a single local commit — including the one commitOwnDeviceMeta
938
+ // makes just above — permanently wedged the pull with nothing actually in
939
+ // conflict. Every device carries its own devices/<host>/ path, so those
940
+ // replay cleanly. Matches syncRepoGit (below) and this function's own doc.
941
+ //
942
+ // Pull the RESOLVED tracking ref, not `branch`: when the local branch has
943
+ // no tracking config the block above falls back to origin's default head,
944
+ // which may be named differently (local `main` vs origin `master`). Using
945
+ // `branch` there asks origin for a ref it does not have.
946
+ assertValidBranchName(remoteBranch);
947
+ await git.pull(remoteName, remoteBranch, { '--rebase': 'true' });
857
948
  }
858
- catch {
949
+ catch (err) {
950
+ // Abort so the tree is restored, matching the atomicity --ff-only gave us.
951
+ // Without this a conflict leaves the repo detached, mid-rebase, with
952
+ // conflict markers written into live config (this repo is ~/.agents —
953
+ // agents.yaml and AGENTS.md are in it), and every later pull misreports
954
+ // the cause. `agents sync` reaches this path unattended across the fleet,
955
+ // so a wedged checkout would be worse than the bug this fixes.
956
+ await git.raw(['rebase', '--abort']).catch(() => { });
859
957
  return {
860
958
  success: false,
861
959
  commit: '',
862
- error: `Blocked by local commits. Push or reset them before pulling.\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}`,
960
+ error: `Rebase onto ${tracking} hit a conflict the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
863
961
  };
864
962
  }
865
963
  installGithooksSymlinks(dir);
@@ -87,10 +87,13 @@ export declare function healChangedAnything(r: HealResult): boolean;
87
87
  /** One-line summary of a heal pass for daemon logs. */
88
88
  export declare function summarizeHeal(r: HealResult): string;
89
89
  /**
90
- * Fire a native desktop notification when a background heal did something
91
- * noteworthy. Best-effort missing `osascript`/`notify-send` or no display is
92
- * swallowed. Silent when the pass auto-fixed everything and nothing needs the
93
- * operator (no point pinging them for routine self-healing).
90
+ * Fire a branded desktop notification when a background heal did something
91
+ * noteworthy. Routed through the MenubarHelper companion (notify-desktop.ts) so
92
+ * it carries the agents-cli mark; clicking opens the runs folder
93
+ * (~/.agents/.history/runs, via the `routines:list` action the companion
94
+ * understands). Best-effort — a missing
95
+ * notifier or no display is swallowed. Silent when the pass auto-fixed everything
96
+ * and nothing needs the operator (no point pinging them for routine self-healing).
94
97
  */
95
98
  export declare function notifyHeal(r: HealResult): void;
96
99
  /**
package/dist/lib/heal.js CHANGED
@@ -30,7 +30,7 @@ import { repairPluginManifestFile } from './plugin-marketplace.js';
30
30
  import * as fs from 'fs';
31
31
  import * as path from 'path';
32
32
  import * as os from 'os';
33
- import { spawn } from 'child_process';
33
+ import { notifyDesktop } from './menubar/notify-desktop.js';
34
34
  // ─── diff → selection mapping ────────────────────────────────────────────────
35
35
  // Which ResourceSelection key each healable diff kind writes through. `rules`
36
36
  // re-syncs via the whole-memory channel (not name-scoped); `promptcuts` is not
@@ -68,10 +68,13 @@ export function summarizeHeal(r) {
68
68
  return parts.length > 0 ? parts.join(', ') : 'nothing to heal';
69
69
  }
70
70
  /**
71
- * Fire a native desktop notification when a background heal did something
72
- * noteworthy. Best-effort missing `osascript`/`notify-send` or no display is
73
- * swallowed. Silent when the pass auto-fixed everything and nothing needs the
74
- * operator (no point pinging them for routine self-healing).
71
+ * Fire a branded desktop notification when a background heal did something
72
+ * noteworthy. Routed through the MenubarHelper companion (notify-desktop.ts) so
73
+ * it carries the agents-cli mark; clicking opens the runs folder
74
+ * (~/.agents/.history/runs, via the `routines:list` action the companion
75
+ * understands). Best-effort — a missing
76
+ * notifier or no display is swallowed. Silent when the pass auto-fixed everything
77
+ * and nothing needs the operator (no point pinging them for routine self-healing).
75
78
  */
76
79
  export function notifyHeal(r) {
77
80
  const needsAttention = r.skippedPlugins.length;
@@ -79,27 +82,12 @@ export function notifyHeal(r) {
79
82
  if (needsAttention === 0 && healed === 0)
80
83
  return;
81
84
  const title = needsAttention > 0
82
- ? `agents: ${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
85
+ ? `${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
83
86
  : 'agents: auto-healed config gaps';
84
87
  const body = needsAttention > 0
85
88
  ? `${summarizeHeal(r)}. Run: agents doctor --fix`
86
89
  : summarizeHeal(r);
87
- const platform = os.platform();
88
- try {
89
- if (platform === 'darwin') {
90
- const safeTitle = title.replace(/"/g, '\\"');
91
- const safeBody = body.replace(/"/g, '\\"');
92
- const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
93
- child.unref();
94
- }
95
- else if (platform === 'linux') {
96
- const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
97
- child.unref();
98
- }
99
- }
100
- catch {
101
- // Notification is best-effort; nothing to do.
102
- }
90
+ notifyDesktop({ title, body, action: 'routines:list' });
103
91
  }
104
92
  // ─── central plugin layer (version-independent, runs once per heal) ──────────
105
93
  /**
@@ -20,6 +20,15 @@ import { type HostCredentials } from './credentials.js';
20
20
  * remainder. Any other path (absolute or relative) is quoted verbatim.
21
21
  */
22
22
  export declare function remoteCdPrefix(remoteCwd?: string): string;
23
+ /**
24
+ * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
25
+ * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
26
+ * hop. Without it the remote re-resolves the actor from the ORIGINATING box's
27
+ * `SSH_CONNECTION` (the wrong IP) and mis-credits the run to the shared machine or
28
+ * `UNRESOLVED@<host>` (RUSH-2028). A caller-supplied value wins on any key
29
+ * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
30
+ */
31
+ export declare function withActorEnv(env?: Record<string, string>): Record<string, string>;
23
32
  /**
24
33
  * Launch a detached login-shell command in its own Unix session/process group.
25
34
  *
@@ -12,8 +12,9 @@ import { randomUUID } from 'crypto';
12
12
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
13
13
  import { sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
- import { remoteShellFor } from './remote-cmd.js';
15
+ import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
16
  import { resolveRemoteOsSync } from './remote-os.js';
17
+ import { resolveActor, actorEnv } from '../actor.js';
17
18
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
18
19
  import { followHostTask } from './progress.js';
19
20
  import { wrapHostCommandWithCredentials } from './credentials.js';
@@ -56,6 +57,17 @@ export function remoteCdPrefix(remoteCwd) {
56
57
  return `cd "$HOME"/${shellQuote(rest)} && `;
57
58
  return `cd ${shellQuote(remoteCwd)} && `;
58
59
  }
60
+ /**
61
+ * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
62
+ * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
63
+ * hop. Without it the remote re-resolves the actor from the ORIGINATING box's
64
+ * `SSH_CONNECTION` (the wrong IP) and mis-credits the run to the shared machine or
65
+ * `UNRESOLVED@<host>` (RUSH-2028). A caller-supplied value wins on any key
66
+ * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
67
+ */
68
+ export function withActorEnv(env) {
69
+ return { ...actorEnv(resolveActor()), ...(env ?? {}) };
70
+ }
59
71
  /**
60
72
  * Launch a detached login-shell command in its own Unix session/process group.
61
73
  *
@@ -174,10 +186,14 @@ async function launchDetached(host, target, opts) {
174
186
  const id = randomUUID().slice(0, 8);
175
187
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
176
188
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
177
- // Inner command run under a login shell so PATH resolves `agents`.
189
+ // Inner command run under a login shell so PATH resolves `agents`. Export the
190
+ // resolved actor provenance first so the detached remote run inherits it
191
+ // instead of re-resolving from this box's SSH_CONNECTION (RUSH-2028).
178
192
  const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
179
193
  const cwd = remoteCdPrefix(opts.remoteCwd);
180
- let inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
194
+ const actorExports = posixEnvExports(withActorEnv());
195
+ const prelude = actorExports ? `${actorExports}; ` : '';
196
+ let inner = `${prelude}${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
181
197
  if (opts.copyCreds) {
182
198
  inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
183
199
  }
@@ -370,7 +386,11 @@ export async function runInteractiveOnHost(host, opts) {
370
386
  process.stderr.write(`[hosts] warning: ${w}\n`);
371
387
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
372
388
  const cwd = remoteCdPrefix(opts.remoteCwd);
373
- let remoteCmd = `${cwd}${invocation}`;
389
+ // Forward actor provenance so the interactive remote run inherits it rather
390
+ // than re-resolving from this box's SSH_CONNECTION (RUSH-2028).
391
+ const actorExports = posixEnvExports(withActorEnv());
392
+ const prelude = actorExports ? `${actorExports}; ` : '';
393
+ let remoteCmd = `${prelude}${cwd}${invocation}`;
374
394
  if (opts.copyCreds) {
375
395
  remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
376
396
  }
@@ -20,7 +20,7 @@ import chalk from 'chalk';
20
20
  import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
21
  import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
- import { dispatchAgentsCommand } from './dispatch.js';
23
+ import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
@@ -267,9 +267,14 @@ export async function maybeRunOnHost(command, allArgs) {
267
267
  // profile, and single-quoted env values would not expand $HOME/$PATH.
268
268
  const isDoctorCommand = command === 'doctor' || (command === 'teams' && forwarded[1] === 'doctor');
269
269
  const remoteOs = resolveRemoteOsSync(host.name);
270
- const env = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
270
+ const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
271
271
  ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
272
272
  : undefined;
273
+ // Forward actor provenance (AGENTS_ACTOR*/GIT_*) across the SSH hop, merged
274
+ // UNDER the doctor PATH so that PATH still wins — without this the remote
275
+ // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
276
+ // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
277
+ const env = withActorEnv(doctorPath);
273
278
  const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
274
279
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
275
280
  if (code === 255) {
@@ -68,6 +68,17 @@ export declare const RUN_OPTION_REJECT_MESSAGES: Record<string, string>;
68
68
  * POSIX form, so linux/macos are byte-for-byte unchanged.
69
69
  */
70
70
  export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string, os?: string, env?: Record<string, string>): string;
71
+ /**
72
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
73
+ * map is missing or empty. Values are rendered as shell LITERALS by default
74
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
75
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
76
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
77
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
78
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
79
+ * exports env identically.
80
+ */
81
+ export declare function posixEnvExports(env?: Record<string, string>): string;
71
82
  /** The two remote shell dialects we build commands for. */
72
83
  export type RemoteShell = 'posix' | 'powershell';
73
84
  /**
@@ -116,6 +116,8 @@ export const RUN_OPTION_FORWARDING = {
116
116
  tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
117
117
  copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
118
118
  authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
119
+ // Deprecated alias for --device auto; resolved on the launching box before SSH.
120
+ smart: 'local-only',
119
121
  };
120
122
  /** Actionable messages for value-aware rejections, keyed by attribute name. */
121
123
  export const RUN_OPTION_REJECT_MESSAGES = {
@@ -144,18 +146,41 @@ export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd, os, env) {
144
146
  }
145
147
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
146
148
  const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
147
- if (!env || Object.keys(env).length === 0) {
148
- return `bash -lc ${shellQuote(withCwd)}`;
149
- }
150
149
  // Prepend env exports so the remote command sees the shims dir even when the
151
150
  // login shell hasn't sourced the interactive rc files that usually add it.
152
- // Values are double-quoted (not single-quoted) so remote variables like
153
- // $HOME and $PATH are expanded by the login shell.
154
- const exports = Object.entries(env)
155
- .map(([k, v]) => `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`)
156
- .join('; ');
151
+ const exports = posixEnvExports(env);
152
+ if (!exports) {
153
+ return `bash -lc ${shellQuote(withCwd)}`;
154
+ }
157
155
  return `bash -lc ${shellQuote(`${exports}; ${withCwd}`)}`;
158
156
  }
157
+ /**
158
+ * Keys whose values are trusted-static and legitimately need remote shell
159
+ * expansion — `PATH` references the remote `$HOME`/`$PATH`. Every other key is
160
+ * rendered as a shell literal, so an attacker-influenceable value (notably actor
161
+ * provenance, whose name/email can come from a tailnet peer's whois or an
162
+ * unvalidated `AGENTS_ACTOR_*` env var) can never inject shell into a dispatch.
163
+ */
164
+ const EXPAND_KEYS = new Set(['PATH']);
165
+ /**
166
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
167
+ * map is missing or empty. Values are rendered as shell LITERALS by default
168
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
169
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
170
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
171
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
172
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
173
+ * exports env identically.
174
+ */
175
+ export function posixEnvExports(env) {
176
+ if (!env || Object.keys(env).length === 0)
177
+ return '';
178
+ return Object.entries(env)
179
+ .map(([k, v]) => EXPAND_KEYS.has(k)
180
+ ? `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`
181
+ : `export ${shellQuote(k)}=${shellQuote(v)}`)
182
+ .join('; ');
183
+ }
159
184
  /**
160
185
  * Pick the remote shell dialect from a recorded OS/platform string. A Windows
161
186
  * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Resolve a remote-created session id back to an interactive `--host` launcher.
3
+ *
4
+ * A headless `--host` run captures the remote-coined id from the followed log
5
+ * (the `--emit-session-id` marker — see session-marker.ts). An INTERACTIVE run
6
+ * has no followed log: the local TTY is wired straight through `sshStream`
7
+ * (stdio:'inherit'), so nothing can tap the stream for a marker without breaking
8
+ * the agent's raw-mode TUI. Instead we correlate by AGENT_LAUNCH_ID.
9
+ *
10
+ * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
+ * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
+ * SessionStart hook records the agent's REAL session id under that exact key in
13
+ * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
+ * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
+ * the same launch-id join `agents sessions --active` uses locally
16
+ * (session/hook-sessions.ts), just across the SSH hop.
17
+ *
18
+ * This gives the interactive path a real per-agent id to register in the local
19
+ * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
+ * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
+ * id up front and never reaches here).
22
+ */
23
+ /**
24
+ * Pick the real session id for `launchId` from the remote hook records.
25
+ *
26
+ * `recordsJson` is the newline-delimited JSON the remote read emits — one record
27
+ * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
28
+ * `launch_id` matches and return its `session_id`; when several match (pid reuse,
29
+ * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
30
+ * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
31
+ * unit-testable from fixtures with no SSH. Returns undefined when no record
32
+ * carries the launch id (the hook hasn't landed yet, or a hookless harness).
33
+ */
34
+ export declare function pickRemoteSessionId(recordsJson: string, launchId: string): string | undefined;
35
+ /**
36
+ * Read the remote hook-session dir over ssh and resolve the real session id the
37
+ * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
38
+ * failure, a missing dir, or an absent record yields undefined — the caller then
39
+ * keeps the un-mapped run rather than a fabricated id.
40
+ *
41
+ * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
42
+ * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
43
+ * globless dir keeps the command quiet when the dir is empty or absent.
44
+ */
45
+ export declare function resolveRemoteSessionId(target: string, launchId: string, timeoutMs?: number): string | undefined;