@phnx-labs/agents-cli 1.22.23 → 1.22.25

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 (143) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +19 -8
  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/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -29,6 +29,7 @@ import { loadDevices } from '../devices/registry.js';
29
29
  import { isSelfHost } from '../devices/self-host.js';
30
30
  import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
31
31
  import { platformGroupLabel } from '../devices/health-report.js';
32
+ import { isKnownTopLevelCommand } from '../startup/command-registry.js';
32
33
  /**
33
34
  * First-class groups that run transparently on a remote via SSH when
34
35
  * `--host`/`--device` is present. Keep both canonical names and aliases
@@ -436,6 +437,15 @@ export async function maybeRunOnHost(command, allArgs, opts) {
436
437
  if (!isAll && command !== 'routines')
437
438
  return false;
438
439
  }
440
+ // A command that does not exist is an unknown-command error, not a routing
441
+ // error. The router runs BEFORE commander parses, so without this gate a typo
442
+ // (`agents session resume --host box`) was answered with "does not support
443
+ // --host/--device" — a true statement about a command the user never typed,
444
+ // and the exact opposite of the truth for the `sessions` they meant, which
445
+ // does support it. Fall through so commander reports `unknown command` (and
446
+ // its did-you-mean). RUSH-2022.
447
+ if (!isKnownTopLevelCommand(command))
448
+ return false;
439
449
  const spec = REMOTE_PASSTHROUGH[command];
440
450
  if (!spec) {
441
451
  // Flag was accepted (no raw commander "unknown option") but this group has
@@ -532,20 +542,37 @@ export async function maybeRunOnHost(command, allArgs, opts) {
532
542
  const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
533
543
  ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
534
544
  : undefined;
535
- // Forward actor provenance (AGENTS_ACTOR*/GIT_*) across the SSH hop, merged
536
- // UNDER the doctor PATH so that PATH still wins — without this the remote
537
- // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
538
- // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
539
- // AGENTS_FLEET_REMOTE marks this as a fleet-dispatched `--host` run so the far
540
- // side can gate consent-sensitive actions — the browser consent gate
541
- // (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
542
- const env = withActorEnv({ ...doctorPath, AGENTS_FLEET_REMOTE: '1' });
543
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
544
- const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
545
+ process.exitCode = streamAgentsOnHost(host, forwarded, {
546
+ remoteCwd,
547
+ interactive,
548
+ extraEnv: doctorPath,
549
+ remoteOs,
550
+ target,
551
+ });
552
+ return true;
553
+ }
554
+ /**
555
+ * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
556
+ * return the exit code. The single place the SSH hop is built, so every remote
557
+ * `agents` invocation carries identical env semantics.
558
+ *
559
+ * Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
560
+ * `extraEnv` so a caller-supplied PATH still wins — without this the remote
561
+ * re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
562
+ * (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
563
+ * AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
564
+ * gate consent-sensitive actions — the browser consent gate
565
+ * (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
566
+ */
567
+ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
568
+ const target = opts.target ?? sshTargetFor(host);
569
+ const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
570
+ const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
571
+ const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
572
+ const code = sshStream(target, remoteCmd, { tty: !!opts.interactive, multiplex: true });
545
573
  if (code === 255) {
546
574
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
547
575
  chalk.gray(' Check: agents hosts check ' + host.name));
548
576
  }
549
- process.exitCode = code;
550
- return true;
577
+ return code;
551
578
  }
@@ -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
@@ -596,6 +596,7 @@ function writeMcpConfigSupportsAgent(agentId) {
596
596
  case 'hermes':
597
597
  case 'pi':
598
598
  case 'muse':
599
+ case 'warp':
599
600
  return true;
600
601
  default:
601
602
  return false;
@@ -621,7 +622,10 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
621
622
  // omp reads the same Claude `{ "mcpServers": {...} }` schema from .mcp.json
622
623
  // (stdio: command/args/env; http/sse: url/headers — transport inferred from
623
624
  // command/url presence).
624
- case 'pi': {
625
+ case 'pi':
626
+ // Oz reads the same Claude `{ "mcpServers": {...} }` schema from
627
+ // `.warp/.mcp.json` (stdio + http with headers).
628
+ case 'warp': {
625
629
  let config = {};
626
630
  if (fs.existsSync(configPath)) {
627
631
  try {
@@ -7,7 +7,11 @@
7
7
  <key>CFBundleIdentifier</key>
8
8
  <string>com.phnx-labs.agents-menubar</string>
9
9
  <key>CFBundleName</key>
10
- <string>Agents Menu Bar</string>
10
+ <string>AGI Menu</string>
11
+ <key>CFBundleDisplayName</key>
12
+ <string>AGI Menu</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>AppIcon</string>
11
15
  <key>CFBundlePackageType</key>
12
16
  <string>APPL</string>
13
17
  <key>CFBundleShortVersionString</key>
@@ -3,9 +3,22 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ jOjZVimcFRHoP2VPzgn8uM8mjkA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
- <dict/>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
19
+ </data>
20
+ </dict>
21
+ </dict>
9
22
  <key>rules</key>
10
23
  <dict>
11
24
  <key>^Resources/</key>
@@ -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. */
@@ -468,6 +468,115 @@ export function disableMenubarService() {
468
468
  }
469
469
  catch { /* best effort */ }
470
470
  }
471
+ /**
472
+ * Which install is allowed to (re)install the shared helper.
473
+ *
474
+ * The helper lives at ONE path in Application Support, but any number of
475
+ * agents-cli copies can be present on a box and every one of them runs the
476
+ * startup self-heal. The version stamp and the plist's baked `AGENTS_ENTRY` each
477
+ * record whichever copy acted last, so without an ownership rule every copy
478
+ * reads the others' marks as drift and recopies the bundle over them. Recopying
479
+ * replaces the executable under the live helper and kills it; launchd
480
+ * `KeepAlive` restarts it; the next copy repeats it. Measured on one box: a new
481
+ * pid every 5-15s, 578 launches in the helper's log, a status item that never
482
+ * stayed visible, and `agents menubar status` still reporting `running: yes`
483
+ * because a pid always existed (#2109).
484
+ *
485
+ * There is deliberately NO content comparison here. Comparing the shipped helper
486
+ * against the installed one cannot distinguish "real upgrade" from "another
487
+ * install's copy": the helper is rebuilt, re-signed and re-notarized on every
488
+ * release (`menubar/scripts/build.sh` via `release.sh`), so consecutive releases
489
+ * ship byte-different bundles from identical Swift source — 1.22.20/21/22 all
490
+ * have the same 2876288-byte executable and three different sha256s AND three
491
+ * different CDHashes. Any digest gate therefore reports "changed" for exactly
492
+ * the skew case it was meant to exempt.
493
+ *
494
+ * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
495
+ * only the owner may reinstall. A non-owner takes over only once the recorded
496
+ * owner is gone from disk, which is what makes the rule converge — a dead
497
+ * install cannot hold the helper hostage, and a live one cannot be fought over.
498
+ * A same-install upgrade keeps its entry path, so `npm update` still installs
499
+ * the new helper normally. Pure so the truth table is unit-testable.
500
+ */
501
+ export function mayInstallMenubarHelper(opts) {
502
+ // Repairs are never gated: a missing binary or a broken signing identity leaves
503
+ // the menu bar dead or re-prompting for Accessibility, and no other install can
504
+ // be "fighting" for a bundle that isn't there. Blocking these behind ownership
505
+ // is what turned the first version of this gate into a silent stuck state.
506
+ if (opts.helperExecMissing || opts.needsDevIdHeal)
507
+ return true;
508
+ // Can't resolve which install we are (a dev/tsx run) — never churn the plist.
509
+ if (!opts.activeEntry)
510
+ return false;
511
+ // No owner recorded yet (fresh or pre-`AGENTS_ENTRY` plist) — adopt it.
512
+ if (!opts.plistEntry)
513
+ return true;
514
+ if (opts.plistEntry === opts.activeEntry)
515
+ return true; // we are the owner
516
+ if (!opts.ownerEntryExists)
517
+ return true; // the recorded owner is gone
518
+ // A foreign install while the owner still exists. Refusing outright bounds the
519
+ // loop but strands the user when the recorded owner is a stale copy that simply
520
+ // still sits on disk (an old nvm node dir) while their daily driver upgrades:
521
+ // that install would never heal again. So it may take over, but only once per
522
+ // cooldown — which turns an every-invocation storm into at most one restart per
523
+ // cooldown while keeping every install able to make progress.
524
+ //
525
+ // Except an ad-hoc/dev-signed copy, which never seizes a healthy helper on a
526
+ // timer. `scripts/install.sh` deliberately puts a dev build beside the npm
527
+ // global, and its bundle cannot be notarized; letting it win the timed takeover
528
+ // would recopy an ad-hoc bundle over a good Developer-ID one, and Gatekeeper
529
+ // then rejects the result as "damaged" and AppKit crashes at launch (RUSH-2134)
530
+ // — trading a cosmetic loop for a broken menu bar. It can still take over when
531
+ // the owner is genuinely gone (above), which is the case that must not deadlock.
532
+ if (!opts.sourceIsDeveloperId)
533
+ return false;
534
+ return opts.msSinceLastHeal === null || opts.msSinceLastHeal >= opts.cooldownMs;
535
+ }
536
+ /**
537
+ * How long a non-owner install waits before it may take the helper over. Long
538
+ * enough that a multi-install box restarts the helper at most once an hour
539
+ * instead of every few seconds; short enough that a user who switched installs
540
+ * gets their upgrade without hunting for `agents menubar setup`.
541
+ */
542
+ const MENUBAR_TAKEOVER_COOLDOWN_MS = 60 * 60 * 1000;
543
+ /** Timestamp of the last self-heal reinstall, next to the version stamp. */
544
+ function lastHealMarkerPath() {
545
+ return path.join(installDir(), '.menubar-last-heal');
546
+ }
547
+ function msSinceLastMenubarHeal() {
548
+ try {
549
+ const t = Number(fs.readFileSync(lastHealMarkerPath(), 'utf-8').trim());
550
+ if (!Number.isFinite(t))
551
+ return null;
552
+ return Math.max(0, Date.now() - t);
553
+ }
554
+ catch {
555
+ return null;
556
+ }
557
+ }
558
+ function stampMenubarHeal() {
559
+ try {
560
+ fs.mkdirSync(installDir(), { recursive: true });
561
+ fs.writeFileSync(lastHealMarkerPath(), String(Date.now()));
562
+ }
563
+ catch { /* best effort */ }
564
+ }
565
+ /** Whether this install may (re)install the helper (see `mayInstallMenubarHelper`). */
566
+ function mayHealMenubar(needsDevIdHeal) {
567
+ const plistEntry = readPlistEnvValue('AGENTS_ENTRY');
568
+ const src = sourceAppPath();
569
+ return mayInstallMenubarHelper({
570
+ plistEntry,
571
+ activeEntry: resolveCliEntry(),
572
+ ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
573
+ helperExecMissing: !fs.existsSync(installedExecutablePath()),
574
+ needsDevIdHeal,
575
+ msSinceLastHeal: msSinceLastMenubarHeal(),
576
+ cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
577
+ sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
578
+ });
579
+ }
471
580
  /**
472
581
  * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
473
582
  * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
@@ -478,8 +587,10 @@ export function disableMenubarService() {
478
587
  *
479
588
  * Without the staleness re-enable, `npm update` refreshed the CLI but left the
480
589
  * menu bar running the previous release's helper binary on a possibly-stale
481
- * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
482
- * Best-effort never throws into startup.
590
+ * plist. Everything past the ownership gate is unchanged; the gate is what stops
591
+ * coexisting installs reinstalling over each other forever (#2109). No-ops if:
592
+ * not darwin, the user opted out, or no helper bundle ships. Best-effort — never
593
+ * throws into startup.
483
594
  */
484
595
  export function installMenubarLaunchAgentOnUpgrade() {
485
596
  try {
@@ -495,12 +606,23 @@ export function installMenubarLaunchAgentOnUpgrade() {
495
606
  }
496
607
  // Re-enable (recopy helper + rewrite plist) when the version drifted OR the
497
608
  // plist's baked interpreter/entry no longer point at the install now running
498
- // `agents` — the dual-install skew a version bump alone can't catch — OR the
609
+ // `agents` — e.g. the owner moved between node interpreters — OR the
499
610
  // installed copy is still ad-hoc while the shipped source is Developer ID
500
611
  // (older heal path; Accessibility re-prompts until the identity is restored).
501
- if (menubarSetupStale() || menubarSetupNeedsRepoint() || installedNeedsDevIdHeal()) {
502
- enableMenubarService({ clearOptOut: false });
503
- }
612
+ const needsDevIdHeal = installedNeedsDevIdHeal();
613
+ if (!(menubarSetupStale() || menubarSetupNeedsRepoint() || needsDevIdHeal))
614
+ return;
615
+ // ...but a copy that does not own the helper only gets to act on that drift
616
+ // once per cooldown. Without the gate every coexisting install recopies the
617
+ // bundle on every invocation, killing the live helper on a loop (#2109).
618
+ if (!mayHealMenubar(needsDevIdHeal))
619
+ return;
620
+ // Stamp only a heal that actually happened. `enableMenubarService` returns
621
+ // false without installing when the bundle fails the Gatekeeper check, and
622
+ // stamping first would spend the shared cooldown on a no-op — locking every
623
+ // non-owner out for another hour while nothing had been fixed.
624
+ if (enableMenubarService({ clearOptOut: false }))
625
+ stampMenubarHeal();
504
626
  }
505
627
  catch {
506
628
  /* never block startup on the menu bar */
@@ -0,0 +1,15 @@
1
+ import type { WatchdogTickResult } from '../watchdog/runner.js';
2
+ export interface MenubarSnapshot {
3
+ version: 1;
4
+ capturedAt: string;
5
+ routines: Record<string, unknown>[];
6
+ recentSessions: Record<string, unknown>[];
7
+ activeSessions: Record<string, unknown>[];
8
+ watchdog: {
9
+ enabled: boolean;
10
+ lastTick: Pick<WatchdogTickResult, 'didNudge' | 'counts'> | null;
11
+ };
12
+ }
13
+ export declare function readLastWatchdogTick(stateDir?: string): WatchdogTickResult | null;
14
+ /** One-process read model for AGI Menu's repeating three-minute refresh. */
15
+ export declare function computeMenubarSnapshot(): Promise<MenubarSnapshot>;
@@ -0,0 +1,40 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { buildRoutineListJson } from '../../commands/routines.js';
4
+ import { backfillActiveRowsFromIndex, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
5
+ import { getConfigValue } from '../device-config.js';
6
+ import { querySessions } from '../session/db.js';
7
+ import { readActiveSessionsCache } from '../session/session-cache.js';
8
+ import { getRuntimeStateDir } from '../state.js';
9
+ export function readLastWatchdogTick(stateDir = path.join(getRuntimeStateDir(), 'watchdog')) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(path.join(stateDir, 'last-tick.json'), 'utf-8'));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** One-process read model for AGI Menu's repeating three-minute refresh. */
18
+ export async function computeMenubarSnapshot() {
19
+ const [routines, recent] = await Promise.all([
20
+ Promise.resolve(buildRoutineListJson()),
21
+ Promise.resolve(querySessions({ limit: 40, skipExistenceCheck: true })),
22
+ ]);
23
+ const active = readActiveSessionsCache('local');
24
+ const activeSessions = active?.sessions ?? [];
25
+ backfillActiveRowsFromIndex(activeSessions);
26
+ return {
27
+ version: 1,
28
+ capturedAt: new Date().toISOString(),
29
+ routines,
30
+ recentSessions: JSON.parse(serializeSessionsJson(recent)),
31
+ activeSessions: serializeActiveSessionsForJson(activeSessions),
32
+ watchdog: {
33
+ enabled: getConfigValue('watchdog.enabled').value === true,
34
+ lastTick: (() => {
35
+ const tick = readLastWatchdogTick();
36
+ return tick ? { didNudge: tick.didNudge, counts: tick.counts } : null;
37
+ })(),
38
+ },
39
+ };
40
+ }
@@ -68,8 +68,20 @@ export function discoverPluginsInDir(pluginsDir, spec = { kind: 'user' }) {
68
68
  continue;
69
69
  const pluginRoot = path.join(pluginsDir, entry.name);
70
70
  const manifest = loadPluginManifest(pluginRoot);
71
- if (!manifest)
71
+ if (!manifest) {
72
+ // A directory that looks like a plugin root but has no valid manifest is
73
+ // silently invisible to every downstream command (list/info/sync, and the
74
+ // materialize-into-version-homes copy) with no other diagnostic anywhere
75
+ // in the chain — it can sit here indefinitely, on the correct git commit,
76
+ // and never surface as broken (RUSH-2270: the `work` plugin shipped
77
+ // without .claude-plugin/plugin.json and nothing noticed for a full merge
78
+ // cycle). Warn once here, at the one place that decides discoverability,
79
+ // so the gap is visible the moment `agents sync` runs.
80
+ const manifestPath = path.join(entry.name, PLUGIN_MANIFEST_DIR, PLUGIN_MANIFEST_FILE);
81
+ process.stderr.write(`agents-cli: '${entry.name}' in ${pluginsDir} has no valid ${manifestPath} ` +
82
+ `(missing, malformed JSON, or missing name/version) — skipped, not discovered as a plugin.\n`);
72
83
  continue;
84
+ }
73
85
  plugins.push(buildDiscoveredPlugin(pluginRoot, manifest, spec));
74
86
  }
75
87
  return plugins;
@@ -17,6 +17,11 @@ export interface RefreshOptions {
17
17
  skipPrompts?: boolean;
18
18
  /** Skip CLI version install/upgrade from agents.yaml. */
19
19
  skipClis?: boolean;
20
+ /**
21
+ * Suppress human progress lines on stdout. Required for machine consumers
22
+ * (`agents sync --json` / fleet fan-out) so stdout stays a single JSON object.
23
+ */
24
+ quiet?: boolean;
20
25
  }
21
26
  /**
22
27
  * Re-materialize local state from declared configuration: install CLI versions,