@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -463,6 +463,9 @@ except Exception: pass' 2>/dev/null || true)"
463
463
  ;;
464
464
  esac
465
465
  CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
466
+ # Failure sentinel: touched on bg-refresh failure; mtime drives the backoff window.
467
+ FAIL_FILE="$CACHE_FILE.fail"
468
+ BACKOFF_SEC=60
466
469
 
467
470
  # Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
468
471
  now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
@@ -491,9 +494,38 @@ if [ "$CACHE_STATUS" = miss ]; then
491
494
  # Stale-while-revalidate: serve stale immediately, refresh in detached child.
492
495
  cat "$CACHE_FILE"
493
496
  CACHE_STATUS=stale-prefetch
494
- tmp="$CACHE_FILE.new.$$"
495
- ( printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null && mv -f "$tmp" "$CACHE_FILE" || rm -f "$tmp" ) >/dev/null 2>&1 &
496
- disown 2>/dev/null || true
497
+ # Fix 2: backoff — skip bg refresh if last attempt failed within BACKOFF_SEC.
498
+ _in_backoff=0
499
+ if [ -f "$FAIL_FILE" ]; then
500
+ _fail_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$FAIL_FILE" 2>/dev/null || echo 0)
501
+ _now_s=$(date +%s)
502
+ _fail_age=$((_now_s - \${_fail_mtime:-0}))
503
+ [ "$_fail_age" -lt "$BACKOFF_SEC" ] && _in_backoff=1
504
+ fi
505
+ if [ "$_in_backoff" -eq 0 ]; then
506
+ # Fix 1: lockdir — only one background refresh runs at a time.
507
+ LOCK_DIR="$CACHE_FILE.bg.lck"
508
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
509
+ tmp="$CACHE_FILE.new.$$"
510
+ # Fix 3: background subshell captures and logs its own real exit code.
511
+ (
512
+ trap 'rm -rf "$LOCK_DIR"' EXIT
513
+ _bg_exit=0
514
+ if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null; then
515
+ mv -f "$tmp" "$CACHE_FILE" && rm -f "$FAIL_FILE"
516
+ else
517
+ _bg_exit=$?
518
+ rm -f "$tmp"
519
+ touch "$FAIL_FILE"
520
+ fi
521
+ _bg_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
522
+ _bg_log_file="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
523
+ printf '{"ts":"%s","event":"hook.cache.refresh","hook":"%s","cache":"bg-refresh","exit":%d}\\n' \\
524
+ "$_bg_ts" "$HOOK_NAME" "$_bg_exit" >>"$_bg_log_file" 2>/dev/null || true
525
+ ) >/dev/null 2>&1 &
526
+ disown 2>/dev/null || true
527
+ fi
528
+ fi
497
529
  else
498
530
  # Synchronous fetch + cache.
499
531
  tmp="$CACHE_FILE.new.$$"
@@ -501,6 +533,7 @@ if [ "$CACHE_STATUS" = miss ]; then
501
533
  EXIT=0
502
534
  cat "$tmp"
503
535
  mv -f "$tmp" "$CACHE_FILE"
536
+ rm -f "$FAIL_FILE"
504
537
  else
505
538
  EXIT=$?
506
539
  rm -f "$tmp"
@@ -73,6 +73,14 @@ export declare function hookExists(agentId: AgentId, hookName: string): boolean;
73
73
  * Compares both script file and data file (if present).
74
74
  */
75
75
  export declare function hookContentMatches(agentId: AgentId, hookName: string, sourceEntry: HookEntry): boolean;
76
+ /**
77
+ * Hooks dir for an agent under an arbitrary home (version home or effective
78
+ * home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
79
+ * kimi) must be translated to config-dir-relative first — a raw `path.join`
80
+ * would embed the absolute path as a relative segment and produce a hybrid
81
+ * path that never exists (RUSH-2237).
82
+ */
83
+ export declare function getHooksDirInHome(agentId: AgentId, home: string): string;
76
84
  export declare function listInstalledHooksWithScope(agentId: AgentId, cwd?: string, options?: {
77
85
  home?: string;
78
86
  }): InstalledHook[];
@@ -99,7 +107,7 @@ export interface HookWiringIssue {
99
107
  * group). Real hooks scope by matcher — ask-user-question-guard=AskUserQuestion,
100
108
  * user-message-guard=Bash — so wiring is verified per (event, matcher). */
101
109
  matcher: string;
102
- /** The command settings.json should reference for this hook under `event`. */
110
+ /** The command the harness-native config should reference under `event`. */
103
111
  command: string;
104
112
  }
105
113
  export interface HookWiringReport {
@@ -109,18 +117,22 @@ export interface HookWiringReport {
109
117
  settingsPath?: string;
110
118
  /** Number of hooks the manifest says should be wired for this version. */
111
119
  expected?: number;
112
- /** settings.json does not exist — nothing declared can be wired. */
120
+ /** Native hook config does not exist — nothing declared can be wired. */
113
121
  settingsMissing?: boolean;
114
- /** settings.json exists but is not valid JSON — wiring can't be verified. */
122
+ /** Native hook config cannot be parsed — wiring can't be verified. */
115
123
  settingsUnparseable?: boolean;
116
124
  /** Hooks whose file is present/resolvable but that are NOT referenced in the
117
- * event array settings.json should carry them in. */
125
+ * native event group/entry should carry them in. */
118
126
  unwired: HookWiringIssue[];
127
+ /** Expected hooks that ARE referenced in native config (expected − unwired).
128
+ * Empty whenever wiring cannot be verified (unsupported family, missing or
129
+ * unparseable settings). */
130
+ wired: HookWiringIssue[];
119
131
  }
120
132
  /**
121
- * Verify that every hook the manifest says should be wired for a (claude|droid)
122
- * version is actually REFERENCED in that version's native settings.json not
123
- * merely present as a file on disk.
133
+ * Verify that every hook the manifest says should be wired is actually
134
+ * referenced in that version's harness-native config, not merely present as a
135
+ * file on disk.
124
136
  *
125
137
  * `agents doctor` compares hook FILES against source (see diffHooks in
126
138
  * doctor-diff.ts) but never checks the wiring, so a hook whose script is
package/dist/lib/hooks.js CHANGED
@@ -673,6 +673,20 @@ export function hookContentMatches(agentId, hookName, sourceEntry) {
673
673
  return false;
674
674
  }
675
675
  }
676
+ /**
677
+ * Hooks dir for an agent under an arbitrary home (version home or effective
678
+ * home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
679
+ * kimi) must be translated to config-dir-relative first — a raw `path.join`
680
+ * would embed the absolute path as a relative segment and produce a hybrid
681
+ * path that never exists (RUSH-2237).
682
+ */
683
+ export function getHooksDirInHome(agentId, home) {
684
+ const config = AGENTS[agentId];
685
+ const hooksDir = path.isAbsolute(config.hooksDir)
686
+ ? path.relative(config.configDir, config.hooksDir)
687
+ : config.hooksDir;
688
+ return path.join(home, agentConfigDirName(agentId), hooksDir);
689
+ }
676
690
  export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), options) {
677
691
  const agent = AGENTS[agentId];
678
692
  if (!agent.supportsHooks) {
@@ -702,7 +716,7 @@ export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), option
702
716
  }
703
717
  // User-scoped hooks (version-aware when home is provided)
704
718
  const home = options?.home || getEffectiveHome(agentId);
705
- const userDir = path.join(home, agentConfigDirName(agentId), agent.hooksDir);
719
+ const userDir = getHooksDirInHome(agentId, home);
706
720
  const userHooks = listHookEntriesFromDir(userDir);
707
721
  for (const hook of userHooks) {
708
722
  addHook(hook, 'user', agentId);
@@ -740,8 +754,7 @@ export async function installHooks(source, agents, options = {}) {
740
754
  * Path to the hooks dir of a specific version home (not the active one).
741
755
  */
742
756
  export function getVersionHooksDir(agent, version) {
743
- const home = getVersionHomePath(agent, version);
744
- return path.join(home, agentConfigDirName(agent), AGENTS[agent].hooksDir);
757
+ return getHooksDirInHome(agent, getVersionHomePath(agent, version));
745
758
  }
746
759
  /**
747
760
  * List hook entries in a specific version home.
@@ -749,20 +762,20 @@ export function getVersionHooksDir(agent, version) {
749
762
  export function listHooksInVersionHome(agent, version) {
750
763
  return listHookEntriesFromDir(getVersionHooksDir(agent, version));
751
764
  }
752
- // ─── wiring inspection (settings.json family: claude, droid) ──────────────────
765
+ // ─── wiring inspection ────────────────────────────────────────────────────────
753
766
  /**
754
- * Agents whose hooks register through {@link registerHooksForClaude} a native
755
- * settings.json shaped `hooks[event] = [{ matcher, hooks: [{ command }] }]`, with
756
- * no event renaming. These are the only agents this read-only wiring inspector
757
- * understands; every other harness uses a divergent config format and/or event
758
- * map (Gemini/Antigravity settings.json variants, Codex config.toml, the OpenCode
759
- * plugin, …), so it reports them unsupported rather than risk a false verdict.
767
+ * Native hook-config families understood by this read-only inspector. Claude,
768
+ * Droid, and Muse share settings.json; Grok uses the same grouped event shape
769
+ * in hooks/hooks.json; Kimi stores one hook per [[hooks]] config.toml table.
770
+ * Other harnesses report unsupported rather than risk a false verdict.
760
771
  */
761
- const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid'];
772
+ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
773
+ const HOOKS_JSON_HOOK_FAMILY = ['grok'];
774
+ const TOML_ARRAY_HOOK_FAMILY = ['kimi'];
762
775
  /**
763
- * Verify that every hook the manifest says should be wired for a (claude|droid)
764
- * version is actually REFERENCED in that version's native settings.json not
765
- * merely present as a file on disk.
776
+ * Verify that every hook the manifest says should be wired is actually
777
+ * referenced in that version's harness-native config, not merely present as a
778
+ * file on disk.
766
779
  *
767
780
  * `agents doctor` compares hook FILES against source (see diffHooks in
768
781
  * doctor-diff.ts) but never checks the wiring, so a hook whose script is
@@ -774,11 +787,18 @@ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid'];
774
787
  * resolveHookCommand performs, so it never mutates the version home.
775
788
  */
776
789
  export function checkVersionHookWiring(agent, version) {
777
- if (!AGENTS[agent].supportsHooks || !SETTINGS_JSON_HOOK_FAMILY.includes(agent)) {
778
- return { supported: false, unwired: [] };
790
+ if (!AGENTS[agent].supportsHooks ||
791
+ (!SETTINGS_JSON_HOOK_FAMILY.includes(agent) &&
792
+ !HOOKS_JSON_HOOK_FAMILY.includes(agent) &&
793
+ !TOML_ARRAY_HOOK_FAMILY.includes(agent))) {
794
+ return { supported: false, unwired: [], wired: [] };
779
795
  }
780
796
  const versionHome = getVersionHomePath(agent, version);
781
- const settingsPath = path.join(versionHome, agentConfigDirName(agent), 'settings.json');
797
+ const settingsPath = HOOKS_JSON_HOOK_FAMILY.includes(agent)
798
+ ? path.join(versionHome, '.grok', 'hooks', 'hooks.json')
799
+ : TOML_ARRAY_HOOK_FAMILY.includes(agent)
800
+ ? path.join(versionHome, '.kimi-code', 'config.toml')
801
+ : path.join(versionHome, agentConfigDirName(agent), 'settings.json');
782
802
  const localHooksDir = getVersionHooksDir(agent, version);
783
803
  // Resolve ONLY to a script that was actually synced for THIS agent+version: the
784
804
  // copy in the version home hooks dir, or an absolute subrule-dir path (those are
@@ -815,11 +835,17 @@ export function checkVersionHookWiring(agent, version) {
815
835
  const command = expectedCommand(name, hookDef);
816
836
  if (!command)
817
837
  continue; // script unresolved — a file gap, reported by diffHooks
818
- // Mirror registerHooksForClaude: a hook registers under the matcher group
819
- // `hookDef.matcher || ''` for each of its events.
820
- const matcher = hookDef.matcher || '';
821
- for (const event of hookDef.events)
822
- expected.push({ name, event, matcher, command });
838
+ for (const event of hookDef.events) {
839
+ if (HOOKS_JSON_HOOK_FAMILY.includes(agent)) {
840
+ const matcher = GROK_MATCHER_EVENTS.has(event)
841
+ ? (GROK_MATCHER_ALIASES[hookDef.matcher || ''] ?? hookDef.matcher ?? '')
842
+ : '';
843
+ expected.push({ name, event, matcher, command });
844
+ }
845
+ else {
846
+ expected.push({ name, event, matcher: hookDef.matcher || '', command });
847
+ }
848
+ }
823
849
  }
824
850
  if (!fs.existsSync(settingsPath)) {
825
851
  return {
@@ -828,11 +854,15 @@ export function checkVersionHookWiring(agent, version) {
828
854
  expected: expected.length,
829
855
  settingsMissing: expected.length > 0,
830
856
  unwired: [],
857
+ wired: [],
831
858
  };
832
859
  }
833
860
  let config;
834
861
  try {
835
- config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
862
+ const raw = fs.readFileSync(settingsPath, 'utf-8');
863
+ config = TOML_ARRAY_HOOK_FAMILY.includes(agent)
864
+ ? TOML.parse(raw)
865
+ : JSON.parse(raw);
836
866
  }
837
867
  catch {
838
868
  return {
@@ -841,6 +871,7 @@ export function checkVersionHookWiring(agent, version) {
841
871
  expected: expected.length,
842
872
  settingsUnparseable: true,
843
873
  unwired: [],
874
+ wired: [],
844
875
  };
845
876
  }
846
877
  // Command strings actually referenced, keyed by (event, matcher) — a hook wired
@@ -848,30 +879,49 @@ export function checkVersionHookWiring(agent, version) {
848
879
  // not just by event.
849
880
  const wiredByGroup = new Map();
850
881
  const groupKey = (event, matcher) => `${event}\n${matcher}`;
851
- const hooks = config.hooks && typeof config.hooks === 'object'
852
- ? config.hooks
853
- : {};
854
- for (const [event, groups] of Object.entries(hooks)) {
855
- if (!Array.isArray(groups))
856
- continue;
857
- for (const group of groups) {
858
- if (!group || !Array.isArray(group.hooks))
882
+ if (TOML_ARRAY_HOOK_FAMILY.includes(agent)) {
883
+ const hooks = Array.isArray(config.hooks) ? config.hooks : [];
884
+ for (const hook of hooks) {
885
+ if (typeof hook.event !== 'string' || typeof hook.command !== 'string')
859
886
  continue;
860
- const matcher = typeof group.matcher === 'string' ? group.matcher : '';
861
- const key = groupKey(event, matcher);
887
+ const matcher = typeof hook.matcher === 'string' ? hook.matcher : '';
888
+ const key = groupKey(hook.event, matcher);
862
889
  let cmds = wiredByGroup.get(key);
863
890
  if (!cmds) {
864
891
  cmds = new Set();
865
892
  wiredByGroup.set(key, cmds);
866
893
  }
867
- for (const h of group.hooks) {
868
- if (h && typeof h.command === 'string')
869
- cmds.add(h.command);
894
+ cmds.add(hook.command);
895
+ }
896
+ }
897
+ else {
898
+ const hooks = config.hooks && typeof config.hooks === 'object'
899
+ ? config.hooks
900
+ : {};
901
+ for (const [event, groups] of Object.entries(hooks)) {
902
+ if (!Array.isArray(groups))
903
+ continue;
904
+ for (const group of groups) {
905
+ if (!group || !Array.isArray(group.hooks))
906
+ continue;
907
+ const matcher = typeof group.matcher === 'string' ? group.matcher : '';
908
+ const key = groupKey(event, matcher);
909
+ let cmds = wiredByGroup.get(key);
910
+ if (!cmds) {
911
+ cmds = new Set();
912
+ wiredByGroup.set(key, cmds);
913
+ }
914
+ for (const h of group.hooks) {
915
+ if (h && typeof h.command === 'string')
916
+ cmds.add(h.command);
917
+ }
870
918
  }
871
919
  }
872
920
  }
873
- const unwired = expected.filter((e) => !wiredByGroup.get(groupKey(e.event, e.matcher))?.has(e.command));
874
- return { supported: true, settingsPath, expected: expected.length, unwired };
921
+ const isWired = (entry) => wiredByGroup.get(groupKey(entry.event, entry.matcher))?.has(entry.command) ?? false;
922
+ const unwired = expected.filter((entry) => !isWired(entry));
923
+ const wired = expected.filter(isWired);
924
+ return { supported: true, settingsPath, expected: expected.length, unwired, wired };
875
925
  }
876
926
  /**
877
927
  * Check if a hook installed in a specific version matches central content.
@@ -1402,7 +1452,7 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1402
1452
  // Scripts are copied into the version home during sync — prefer that stable
1403
1453
  // local path so registered commands don't break when source dirs change.
1404
1454
  const localHooksDir = !overrideRoots
1405
- ? path.join(versionHome, agentConfigDirName(agentId), AGENTS[agentId].hooksDir)
1455
+ ? getHooksDirInHome(agentId, versionHome)
1406
1456
  : null;
1407
1457
  const resolveScript = (script) => {
1408
1458
  // Subrule-dir hooks declare an already-absolute script path. Use it
@@ -1453,6 +1503,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1453
1503
  // registrar targeting `.factory/settings.json` (agentConfigDirName('droid')).
1454
1504
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('droid'));
1455
1505
  }
1506
+ if (agentId === 'muse') {
1507
+ // Muse Code: Claude-compatible hooks block in ~/.config/muse/settings.json
1508
+ // (events SessionStart / PreToolUse / …, matcher groups, command hooks).
1509
+ // settings.json MUST carry schema_version: 1 or Muse refuses to start.
1510
+ return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('muse'), { schemaVersion: 1 });
1511
+ }
1456
1512
  if (agentId === 'codex') {
1457
1513
  return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
1458
1514
  }
@@ -1675,7 +1731,7 @@ const ANTIGRAVITY_EVENT_MAP = {
1675
1731
  Stop: 'on_loop_stop',
1676
1732
  OnError: 'on_error',
1677
1733
  };
1678
- function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
1734
+ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude', options) {
1679
1735
  const registered = [];
1680
1736
  const errors = [];
1681
1737
  const configDir = path.join(versionHome, configDirName);
@@ -1692,6 +1748,10 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1692
1748
  return { registered, errors };
1693
1749
  }
1694
1750
  }
1751
+ // Muse (and any future agent that pins a settings schema) requires this key.
1752
+ if (options?.schemaVersion !== undefined && config.schema_version === undefined) {
1753
+ config.schema_version = options.schemaVersion;
1754
+ }
1695
1755
  if (!config.hooks || typeof config.hooks !== 'object') {
1696
1756
  config.hooks = {};
1697
1757
  }
@@ -11,6 +11,10 @@
11
11
  * issued from, so the run appears in that project's session listing like any
12
12
  * local run. The `[host/<name>]` label mirrors the cloud path's
13
13
  * `[cloud/<status>]` convention.
14
+ *
15
+ * `machine` is the dispatch host, not this box. Both the transcript and the
16
+ * version-isolated harness home live there; omitting it makes the SQLite upsert
17
+ * infer this box from the empty file path and sends recovery to the wrong home.
14
18
  */
15
19
  import type { SessionMeta } from '../session/types.js';
16
20
  import { type HostTask } from './tasks.js';
@@ -11,6 +11,10 @@
11
11
  * issued from, so the run appears in that project's session listing like any
12
12
  * local run. The `[host/<name>]` label mirrors the cloud path's
13
13
  * `[cloud/<status>]` convention.
14
+ *
15
+ * `machine` is the dispatch host, not this box. Both the transcript and the
16
+ * version-isolated harness home live there; omitting it makes the SQLite upsert
17
+ * infer this box from the empty file path and sends recovery to the wrong home.
14
18
  */
15
19
  import * as fs from 'fs';
16
20
  import { upsertSession } from '../session/db.js';
@@ -18,6 +22,7 @@ import { isSessionTrackedAgent } from '../session/types.js';
18
22
  import { localLogPath, updateTask } from './tasks.js';
19
23
  import { parseSessionIdMarker } from './session-marker.js';
20
24
  import { deriveShortId } from '../session/short-id.js';
25
+ import { normalizeHost } from '../machine-id.js';
21
26
  /**
22
27
  * Build the SessionMeta for a host-dispatched run. Returns null when the run has
23
28
  * no captured session id (nothing stable to key/resume on) or its agent isn't a
@@ -38,6 +43,7 @@ export function hostSessionMeta(task, ctx) {
38
43
  // Remote transcript — no local file. Empty file_path is the sentinel the DB
39
44
  // stale-filter treats as "always live" (see module doc).
40
45
  filePath: '',
46
+ machine: normalizeHost(task.host),
41
47
  topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
42
48
  // The run's `--name` seeds the label (resolves `agents sessions <name>` and
43
49
  // `agents hosts logs <name>`); an unnamed host run falls back to the
@@ -109,6 +115,7 @@ export function registerInteractiveHostSession(ctx) {
109
115
  timestamp: ctx.createdAt ?? new Date().toISOString(),
110
116
  cwd: ctx.cwd,
111
117
  filePath: '',
118
+ machine: normalizeHost(ctx.host),
112
119
  label: ctx.name || `[host/${ctx.host}]`,
113
120
  }, '');
114
121
  }
@@ -3,8 +3,18 @@ import type { Manifest } from './types.js';
3
3
  export declare const MANIFEST_FILENAME = "agents.yaml";
4
4
  /** Parse a YAML string into a typed Manifest object. */
5
5
  export declare function parseManifest(content: string): Manifest;
6
- /** Serialize a Manifest object to a YAML string with 2-space indentation. */
7
- export declare function serializeManifest(manifest: Manifest): string;
6
+ /**
7
+ * Serialize a Manifest to YAML WITHOUT destroying hand-written comments.
8
+ *
9
+ * Plain `yaml.stringify(manifest)` drops every comment, so `agents mcp add`
10
+ * (and every other writeManifest caller) used to clobber annotations in
11
+ * agents.yaml. Matching `serializeCentral` in state.ts: when existing file
12
+ * text is provided, parse it into a `yaml.Document` (comments + key order
13
+ * preserved) and edit only keys that actually changed. Untouched keys and
14
+ * their comments stay byte-stable. Falls back to plain stringify when there
15
+ * is no existing document yet.
16
+ */
17
+ export declare function serializeManifest(manifest: Manifest, existingContent?: string | null): string;
8
18
  /** Read and parse agents.yaml from a directory. Returns null if the file does not exist. */
9
19
  export declare function readManifest(repoPath: string): Manifest | null;
10
20
  /** Write a Manifest object to agents.yaml in the given directory. */
@@ -16,9 +16,48 @@ const manifestLockDepth = new Map();
16
16
  export function parseManifest(content) {
17
17
  return yaml.parse(content);
18
18
  }
19
- /** Serialize a Manifest object to a YAML string with 2-space indentation. */
20
- export function serializeManifest(manifest) {
21
- return yaml.stringify(manifest, { indent: 2 });
19
+ /**
20
+ * Serialize a Manifest to YAML WITHOUT destroying hand-written comments.
21
+ *
22
+ * Plain `yaml.stringify(manifest)` drops every comment, so `agents mcp add`
23
+ * (and every other writeManifest caller) used to clobber annotations in
24
+ * agents.yaml. Matching `serializeCentral` in state.ts: when existing file
25
+ * text is provided, parse it into a `yaml.Document` (comments + key order
26
+ * preserved) and edit only keys that actually changed. Untouched keys and
27
+ * their comments stay byte-stable. Falls back to plain stringify when there
28
+ * is no existing document yet.
29
+ */
30
+ export function serializeManifest(manifest, existingContent) {
31
+ const entries = Object.entries(manifest).filter(([, v]) => v !== undefined);
32
+ const isEmpty = entries.length === 0;
33
+ if (existingContent == null || existingContent.trim() === '') {
34
+ return isEmpty ? '' : yaml.stringify(manifest, { indent: 2 });
35
+ }
36
+ const doc = yaml.parseDocument(existingContent);
37
+ const current = doc.toJSON() ?? {};
38
+ let changed = false;
39
+ for (const [k, v] of entries) {
40
+ if (JSON.stringify(current[k]) !== JSON.stringify(v)) {
41
+ doc.set(k, v);
42
+ changed = true;
43
+ }
44
+ }
45
+ // Full-document write: callers do read-modify-write, so keys absent from the
46
+ // new manifest are intentional removals (e.g. clearing beta).
47
+ for (const k of Object.keys(current)) {
48
+ const next = manifest[k];
49
+ if (!(k in manifest) || next === undefined) {
50
+ doc.delete(k);
51
+ changed = true;
52
+ }
53
+ }
54
+ // Nothing changed → keep the file byte-identical (comments intact).
55
+ if (!changed)
56
+ return existingContent;
57
+ // Force BLOCK style: an existing flow root (e.g. legacy `{}`) would otherwise
58
+ // make edited nodes render flow. collectionStyle pins the whole doc block
59
+ // while parseDocument still preserves comments + key ordering.
60
+ return isEmpty ? '' : doc.toString({ collectionStyle: 'block' });
22
61
  }
23
62
  /** Read and parse agents.yaml from a directory. Returns null if the file does not exist. */
24
63
  export function readManifest(repoPath) {
@@ -55,8 +94,24 @@ function withManifestLock(filePath, fn) {
55
94
  /** Write a Manifest object to agents.yaml in the given directory. */
56
95
  export function writeManifest(repoPath, manifest) {
57
96
  const manifestPath = safeJoin(repoPath, MANIFEST_FILENAME);
58
- const content = serializeManifest(manifest);
59
- withManifestLock(manifestPath, () => atomicWriteFileSync(manifestPath, content));
97
+ withManifestLock(manifestPath, () => {
98
+ let existing = null;
99
+ try {
100
+ existing = fs.readFileSync(manifestPath, 'utf-8');
101
+ }
102
+ catch {
103
+ /* first write — no file yet (or empty lock target) */
104
+ }
105
+ // ensureLockTarget may have created an empty file for the lock path.
106
+ if (existing !== null && existing.trim() === '')
107
+ existing = null;
108
+ const content = serializeManifest(manifest, existing);
109
+ // Skip the atomic rewrite when nothing changed so comments stay byte-stable
110
+ // and concurrent readers never see a no-op churn.
111
+ if (existing !== null && content === existing)
112
+ return;
113
+ atomicWriteFileSync(manifestPath, content);
114
+ });
60
115
  }
61
116
  /** Create a Manifest with sensible defaults for a fresh agents repo. */
62
117
  export function createDefaultManifest() {
package/dist/lib/mcp.js CHANGED
@@ -595,6 +595,7 @@ function writeMcpConfigSupportsAgent(agentId) {
595
595
  case 'opencode':
596
596
  case 'hermes':
597
597
  case 'pi':
598
+ case 'muse':
598
599
  return true;
599
600
  default:
600
601
  return false;
@@ -797,6 +798,49 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
797
798
  fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
798
799
  break;
799
800
  }
801
+ case 'muse': {
802
+ // Muse Code settings.json: requires schema_version: 1; MCP lives under
803
+ // mcp_servers with an explicit transport (stdio | streamable_http).
804
+ // See https://dev.meta.ai/docs/muse-code/extending#mcp
805
+ let config = {};
806
+ if (fs.existsSync(configPath)) {
807
+ try {
808
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
809
+ }
810
+ catch {
811
+ config = {};
812
+ }
813
+ }
814
+ if (config.schema_version === undefined) {
815
+ config.schema_version = 1;
816
+ }
817
+ const mcpServers = mode === 'merge' && config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
818
+ ? { ...config.mcp_servers }
819
+ : {};
820
+ for (const server of servers) {
821
+ if (server.transport === 'stdio') {
822
+ mcpServers[server.name] = {
823
+ transport: 'stdio',
824
+ command: server.command,
825
+ args: server.args || [],
826
+ ...(server.env && Object.keys(server.env).length > 0 ? { env: server.env } : {}),
827
+ enabled: true,
828
+ };
829
+ }
830
+ else {
831
+ mcpServers[server.name] = {
832
+ transport: 'streamable_http',
833
+ url: server.url,
834
+ ...(server.headers && Object.keys(server.headers).length > 0 ? { headers: server.headers } : {}),
835
+ enabled: true,
836
+ };
837
+ }
838
+ }
839
+ config.mcp_servers = mcpServers;
840
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
841
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
842
+ break;
843
+ }
800
844
  }
801
845
  }
802
846
  /**
@@ -111,6 +111,54 @@ export declare function menubarPlistNeedsRepoint(opts: {
111
111
  * upgrade migration won't re-enable it.
112
112
  */
113
113
  export declare function disableMenubarService(): void;
114
+ /**
115
+ * Which install is allowed to (re)install the shared helper.
116
+ *
117
+ * The helper lives at ONE path in Application Support, but any number of
118
+ * agents-cli copies can be present on a box and every one of them runs the
119
+ * startup self-heal. The version stamp and the plist's baked `AGENTS_ENTRY` each
120
+ * record whichever copy acted last, so without an ownership rule every copy
121
+ * reads the others' marks as drift and recopies the bundle over them. Recopying
122
+ * replaces the executable under the live helper and kills it; launchd
123
+ * `KeepAlive` restarts it; the next copy repeats it. Measured on one box: a new
124
+ * pid every 5-15s, 578 launches in the helper's log, a status item that never
125
+ * stayed visible, and `agents menubar status` still reporting `running: yes`
126
+ * because a pid always existed (#2109).
127
+ *
128
+ * There is deliberately NO content comparison here. Comparing the shipped helper
129
+ * against the installed one cannot distinguish "real upgrade" from "another
130
+ * install's copy": the helper is rebuilt, re-signed and re-notarized on every
131
+ * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
132
+ * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
133
+ * have the same 2876288-byte executable and three different sha256s AND three
134
+ * different CDHashes. Any digest gate therefore reports "changed" for exactly
135
+ * the skew case it was meant to exempt.
136
+ *
137
+ * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
138
+ * only the owner may reinstall. A non-owner takes over only once the recorded
139
+ * owner is gone from disk, which is what makes the rule converge — a dead
140
+ * install cannot hold the helper hostage, and a live one cannot be fought over.
141
+ * A same-install upgrade keeps its entry path, so `npm update` still installs
142
+ * the new helper normally. Pure so the truth table is unit-testable.
143
+ */
144
+ export declare function mayInstallMenubarHelper(opts: {
145
+ /** `AGENTS_ENTRY` baked into the installed plist — the recorded owner. */
146
+ plistEntry: string | null;
147
+ /** `resolveCliEntry()` for the install now running `agents`. */
148
+ activeEntry: string | null;
149
+ /** Whether `plistEntry` still exists on disk. */
150
+ ownerEntryExists: boolean;
151
+ /** The App Support helper executable is absent — a repair, not a contest. */
152
+ helperExecMissing: boolean;
153
+ /** Installed copy is ad-hoc while the shipped source is Developer ID. */
154
+ needsDevIdHeal: boolean;
155
+ /** ms since the last self-heal reinstall, or null if none is recorded. */
156
+ msSinceLastHeal: number | null;
157
+ /** How long a non-owner waits before it may take over. */
158
+ cooldownMs: number;
159
+ /** This install's OWN shipped bundle is Developer-ID signed (not ad-hoc/dev). */
160
+ sourceIsDeveloperId: boolean;
161
+ }): boolean;
114
162
  /**
115
163
  * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
116
164
  * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
@@ -121,8 +169,10 @@ export declare function disableMenubarService(): void;
121
169
  *
122
170
  * Without the staleness re-enable, `npm update` refreshed the CLI but left the
123
171
  * menu bar running the previous release's helper binary on a possibly-stale
124
- * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
125
- * Best-effort never throws into startup.
172
+ * plist. Everything past the ownership gate is unchanged; the gate is what stops
173
+ * coexisting installs reinstalling over each other forever (#2109). No-ops if:
174
+ * not darwin, the user opted out, or no helper bundle ships. Best-effort — never
175
+ * throws into startup.
126
176
  */
127
177
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
128
178
  /** One step of `agents menubar setup`, and how it came out. */