@phnx-labs/agents-cli 1.22.50 → 1.22.52

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 (85) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/commands/attach.js +7 -0
  4. package/dist/commands/browser.d.ts +10 -0
  5. package/dist/commands/browser.js +191 -54
  6. package/dist/commands/config.js +20 -0
  7. package/dist/commands/daemon.d.ts +2 -0
  8. package/dist/commands/daemon.js +8 -4
  9. package/dist/commands/detach.js +1 -1
  10. package/dist/commands/feedback.js +1 -1
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +14 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +63 -5
  15. package/dist/commands/menubar.js +6 -4
  16. package/dist/commands/monitors.js +1 -1
  17. package/dist/commands/repo.js +31 -3
  18. package/dist/commands/sessions-resume.d.ts +1 -0
  19. package/dist/commands/sessions-resume.js +13 -2
  20. package/dist/commands/sessions-stop.js +1 -1
  21. package/dist/commands/sessions.d.ts +23 -13
  22. package/dist/commands/sessions.js +40 -20
  23. package/dist/commands/setup-browser.d.ts +5 -2
  24. package/dist/commands/setup-browser.js +14 -29
  25. package/dist/commands/setup-preferences.d.ts +22 -3
  26. package/dist/commands/setup-preferences.js +25 -8
  27. package/dist/commands/share.js +12 -8
  28. package/dist/commands/status.js +5 -0
  29. package/dist/commands/sync.js +58 -2
  30. package/dist/commands/tmux.d.ts +8 -1
  31. package/dist/commands/tmux.js +167 -17
  32. package/dist/commands/traces.js +32 -4
  33. package/dist/lib/browser/ipc.d.ts +44 -0
  34. package/dist/lib/browser/ipc.js +120 -8
  35. package/dist/lib/browser/profiles.d.ts +39 -17
  36. package/dist/lib/browser/profiles.js +51 -52
  37. package/dist/lib/browser/runtime-state.d.ts +4 -2
  38. package/dist/lib/browser/runtime-state.js +4 -2
  39. package/dist/lib/browser/service.js +4 -3
  40. package/dist/lib/channels/owner-forward.d.ts +88 -0
  41. package/dist/lib/channels/owner-forward.js +116 -0
  42. package/dist/lib/channels/owner-sink.js +7 -0
  43. package/dist/lib/claude-statusline.d.ts +9 -0
  44. package/dist/lib/claude-statusline.js +45 -4
  45. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  46. package/dist/lib/computer/ssh-tunnel.js +13 -8
  47. package/dist/lib/config-keys.d.ts +4 -3
  48. package/dist/lib/config-keys.js +9 -2
  49. package/dist/lib/device-config.js +23 -0
  50. package/dist/lib/exec.d.ts +8 -0
  51. package/dist/lib/exec.js +7 -0
  52. package/dist/lib/factory/snapshot.d.ts +1 -1
  53. package/dist/lib/factory/snapshot.js +1 -1
  54. package/dist/lib/feed-broadcast.js +15 -1
  55. package/dist/lib/git.d.ts +93 -0
  56. package/dist/lib/git.js +232 -0
  57. package/dist/lib/helper-download.d.ts +12 -2
  58. package/dist/lib/helper-download.js +12 -2
  59. package/dist/lib/installations/migrate.js +4 -4
  60. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  61. package/dist/lib/menubar/install-menubar.js +183 -24
  62. package/dist/lib/monitors/remote.d.ts +18 -1
  63. package/dist/lib/monitors/remote.js +15 -2
  64. package/dist/lib/notify.d.ts +7 -0
  65. package/dist/lib/notify.js +15 -1
  66. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  67. package/dist/lib/session/local-tmux-attach.js +164 -0
  68. package/dist/lib/session/remote-active.d.ts +8 -0
  69. package/dist/lib/session/remote-active.js +1 -0
  70. package/dist/lib/share/publish.d.ts +8 -11
  71. package/dist/lib/share/publish.js +16 -20
  72. package/dist/lib/share/worker-template.js +99 -12
  73. package/dist/lib/star-nudge.d.ts +2 -2
  74. package/dist/lib/star-nudge.js +2 -2
  75. package/dist/lib/state.d.ts +1 -1
  76. package/dist/lib/state.js +2 -2
  77. package/dist/lib/sync-status.d.ts +17 -0
  78. package/dist/lib/sync-status.js +21 -2
  79. package/dist/lib/tmux/index.d.ts +1 -1
  80. package/dist/lib/tmux/index.js +1 -1
  81. package/dist/lib/tmux/session.d.ts +10 -0
  82. package/dist/lib/tmux/session.js +29 -0
  83. package/dist/lib/traces/sync.d.ts +30 -0
  84. package/dist/lib/traces/sync.js +91 -10
  85. package/package.json +3 -3
package/dist/lib/git.js CHANGED
@@ -829,6 +829,238 @@ export async function adoptRepo(source, targetDir) {
829
829
  return { success: false, commit: '', backedUp: [], error: err.message };
830
830
  }
831
831
  }
832
+ /**
833
+ * Device-local record of the user config repo's remote URL, kept OUTSIDE the git
834
+ * tree so it survives a lost `.git` (`.history/` is gitignored runtime state).
835
+ * This is what lets `agents repo sync user` adopt-in-place a box that was healthy
836
+ * once and later lost its checkout, without the operator re-typing the URL.
837
+ */
838
+ function userRepoRemoteRecordPath(dir) {
839
+ return path.join(dir, '.history', 'user-repo-remote.json');
840
+ }
841
+ /** Read an origin remote URL from a git dir, or null when there is none. */
842
+ export function readOriginUrl(dir) {
843
+ if (!isGitRepo(dir))
844
+ return null;
845
+ try {
846
+ const url = execFileSync('git', ['-C', dir, 'config', '--get', 'remote.origin.url'], {
847
+ encoding: 'utf-8',
848
+ }).trim();
849
+ return url || null;
850
+ }
851
+ catch {
852
+ return null;
853
+ }
854
+ }
855
+ /**
856
+ * Persist the user repo's remote URL to device-local runtime state so a future
857
+ * adopt-in-place can recover it after a `.git` loss. Best-effort — a write
858
+ * failure never blocks a sync.
859
+ */
860
+ export function recordUserRepoRemote(dir, url) {
861
+ try {
862
+ const file = userRepoRemoteRecordPath(dir);
863
+ fs.mkdirSync(path.dirname(file), { recursive: true });
864
+ fs.writeFileSync(file, JSON.stringify({ url }, null, 2) + '\n', { mode: 0o600 });
865
+ }
866
+ catch {
867
+ /* runtime cache write is best-effort */
868
+ }
869
+ }
870
+ /**
871
+ * Resolve the user config repo's remote URL WITHOUT hardcoding it, for the
872
+ * adopt-in-place self-heal. In priority order:
873
+ * 1. an existing `origin` remote on the dir (a partial repo that kept its
874
+ * `.git` but drifted) — the same source `agents repo sync` already reads;
875
+ * 2. the `AGENTS_USER_REPO_URL` env override (a fresh/never-cloned box);
876
+ * 3. the device-local record written by a prior healthy sync (a box that lost
877
+ * its `.git` but kept `.history/` runtime state).
878
+ * Returns null when none is known — the caller then guides the operator to
879
+ * `agents repo pull user <git-url>` instead of crashing.
880
+ */
881
+ export function resolveUserRepoRemoteUrl(dir) {
882
+ const fromOrigin = readOriginUrl(dir);
883
+ if (fromOrigin)
884
+ return fromOrigin;
885
+ const fromEnv = process.env.AGENTS_USER_REPO_URL?.trim();
886
+ if (fromEnv)
887
+ return fromEnv;
888
+ try {
889
+ const raw = fs.readFileSync(userRepoRemoteRecordPath(dir), 'utf-8');
890
+ const url = JSON.parse(raw).url?.trim();
891
+ if (url)
892
+ return url;
893
+ }
894
+ catch {
895
+ /* no record yet */
896
+ }
897
+ return null;
898
+ }
899
+ /**
900
+ * Decide whether a local top-level `agents.yaml` is a stale install stub that
901
+ * should be restored from the committed copy, vs. a legitimately customized file
902
+ * that must be preserved.
903
+ *
904
+ * The stub a partial install leaves behind (createDefaultMeta + a few config
905
+ * writes) is strictly SHORTER than the committed config AND missing whole
906
+ * top-level blocks the committed one carries (`config:` / `hooks:` — the fleet
907
+ * browser hub and hook registrations). Device-specific settings live in
908
+ * `devices/<host>/agents.yaml`, never here, so restoring the top-level file is
909
+ * safe. A file that already carries those blocks (or is longer) is treated as a
910
+ * real local edit and left alone — it surfaces as a modified path instead.
911
+ */
912
+ export function isStaleAgentsYamlStub(local, committed) {
913
+ if (local.trim() === committed.trim())
914
+ return false;
915
+ const shorter = local.split('\n').length < committed.split('\n').length;
916
+ const missingBlock = (/^config:/m.test(committed) && !/^config:/m.test(local)) ||
917
+ (/^hooks:/m.test(committed) && !/^hooks:/m.test(local));
918
+ return shorter && missingBlock;
919
+ }
920
+ /**
921
+ * Adopt an EXISTING, non-git (or origin-less) `~/.agents` directory in place —
922
+ * git-back it against its remote WITHOUT re-cloning and WITHOUT destroying the
923
+ * runtime state it carries (`.cache` / `.history` / `scratch` / `.system`, all
924
+ * gitignored). The self-heal for a partial install (PHNX-3301): the current code
925
+ * hard-fails with "Not a git repo", and the only manual fix is a destructive
926
+ * re-clone that wipes that runtime state.
927
+ *
928
+ * Plumbing-only, so it never trips the fleet git-guard (no `reset` / `checkout
929
+ * <branch>` / `stash` / `git config`):
930
+ * 1. `git init` + point HEAD at `main`.
931
+ * 2. `git remote add origin <url>`.
932
+ * 3. `git fetch origin main`.
933
+ * 4. `git update-ref refs/heads/main origin/main`; set upstream to origin/main.
934
+ * 5. `git read-tree origin/main` — index = origin/main, working tree untouched.
935
+ * 6. Materialize only the tracked files MISSING from the working tree
936
+ * (`checkout-index` on that set) — existing local files are never overwritten.
937
+ * 7. Reconcile the top-level `agents.yaml`: restore it from origin/main only
938
+ * when the local copy is a stale stub ({@link isStaleAgentsYamlStub}).
939
+ *
940
+ * Idempotent: a second run finds the remote/refs already present and simply
941
+ * re-materializes nothing. Any tracked path with real local edits is returned in
942
+ * `localEdits` (surfaced, never clobbered).
943
+ */
944
+ export async function adoptRepoInPlace(dir, remoteUrl) {
945
+ const empty = {
946
+ success: false,
947
+ commit: '',
948
+ materialized: 0,
949
+ reconciledAgentsYaml: false,
950
+ localEdits: [],
951
+ };
952
+ const trimmed = remoteUrl.trim();
953
+ try {
954
+ // The URL is always a resolved git remote (origin / env / record), never a
955
+ // `gh:` shorthand — so skip parseSource (which THROWS on ssh:// and rewrites
956
+ // git@github -> https, breaking SSH-key-only auth). assertSafeGitTransport
957
+ // still blocks the dangerous transports (ext::, file://, option injection)
958
+ // while permitting https / ssh / scp-style / a local bare repo.
959
+ assertSafeGitTransport(trimmed);
960
+ if (!fs.existsSync(dir)) {
961
+ return { ...empty, error: `Target directory does not exist: ${dir}` };
962
+ }
963
+ // Non-interactive git — fail fast on a missing credential instead of hanging
964
+ // on a prompt (same rationale as adoptRepo).
965
+ process.env.GIT_TERMINAL_PROMPT = '0';
966
+ const git = simpleGit(dir);
967
+ // 1. init + HEAD -> main (idempotent: init on an existing repo is a no-op).
968
+ // Set HEAD via symbolic-ref rather than `init -b main` so it works on git
969
+ // < 2.28, and lands on `main` even if the repo already initialized as
970
+ // `master`.
971
+ if (!isGitRepo(dir))
972
+ await git.init();
973
+ await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']);
974
+ // 2. origin — add only when absent, so a re-run keeps the existing remote.
975
+ const remotes = await git.getRemotes(true);
976
+ if (!remotes.some((r) => r.name === 'origin')) {
977
+ await git.raw(['remote', 'add', 'origin', trimmed]);
978
+ }
979
+ // 3-4. fetch, plant the local main on origin/main, set upstream.
980
+ await git.raw(['fetch', 'origin', 'main']);
981
+ await git.raw(['update-ref', 'refs/heads/main', 'origin/main']);
982
+ await git.raw(['branch', '--set-upstream-to=origin/main', 'main']);
983
+ // 5. index = origin/main, working tree untouched.
984
+ await git.raw(['read-tree', 'origin/main']);
985
+ // 6. Materialize only the tracked files MISSING on disk. Passing the explicit
986
+ // missing set (never `checkout-index -a`) guarantees no existing local
987
+ // file — a stub agents.yaml, a modified rule — is overwritten. Chunked to
988
+ // stay under the argv limit on a cold box where most files are missing.
989
+ const tracked = (await git.raw(['ls-files', '-z'])).split('\0').filter(Boolean);
990
+ const missing = tracked.filter((rel) => !fs.existsSync(path.join(dir, rel)));
991
+ for (let i = 0; i < missing.length; i += 500) {
992
+ await git.raw(['checkout-index', '-f', '--', ...missing.slice(i, i + 500)]);
993
+ }
994
+ // 7. Reconcile a stale-stub top-level agents.yaml from origin/main. `restore`
995
+ // is plumbing the git-guard allows; it rewrites only this one path.
996
+ let reconciledAgentsYaml = false;
997
+ let agentsYamlBackup;
998
+ if (tracked.includes('agents.yaml')) {
999
+ const abs = path.join(dir, 'agents.yaml');
1000
+ const local = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf-8') : '';
1001
+ const committed = await git.raw(['show', 'origin/main:agents.yaml']);
1002
+ if (isStaleAgentsYamlStub(local, committed)) {
1003
+ // The stub heuristic can't perfectly distinguish a partial-install stub
1004
+ // from a user who deliberately removed a whole block, so save the local
1005
+ // copy to gitignored runtime state BEFORE restoring — a false positive is
1006
+ // then recoverable and surfaced, never silent data loss.
1007
+ if (local) {
1008
+ agentsYamlBackup = path.join(dir, '.history', 'agents.yaml.pre-adopt.bak');
1009
+ fs.mkdirSync(path.dirname(agentsYamlBackup), { recursive: true });
1010
+ fs.writeFileSync(agentsYamlBackup, local);
1011
+ }
1012
+ await git.raw(['restore', '--source=origin/main', '--', 'agents.yaml']);
1013
+ reconciledAgentsYaml = true;
1014
+ }
1015
+ }
1016
+ // Surface — never silently keep — any tracked path whose local copy still
1017
+ // differs from origin/main after the reconcile (real un-gitignored edits).
1018
+ const dirty = (await git.raw(['status', '--porcelain', '--untracked-files=no']))
1019
+ .split('\n')
1020
+ .map((l) => l.slice(3).trim())
1021
+ .filter(Boolean);
1022
+ installGithooksSymlinks(dir);
1023
+ recordUserRepoRemote(dir, trimmed);
1024
+ const commit = (await git.raw(['rev-parse', '--short', 'HEAD'])).trim();
1025
+ return {
1026
+ success: true,
1027
+ commit,
1028
+ materialized: missing.length,
1029
+ reconciledAgentsYaml,
1030
+ ...(agentsYamlBackup ? { agentsYamlBackup } : {}),
1031
+ localEdits: dirty,
1032
+ };
1033
+ }
1034
+ catch (err) {
1035
+ return { ...empty, error: err.message };
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Self-heal entry point for the USER config repo: when `dir` is not a git repo
1040
+ * (or is a repo with no `origin`), resolve its remote URL and adopt it in place;
1041
+ * otherwise return null (nothing to adopt — the normal sync path runs). Returns a
1042
+ * failed result carrying `needsUrl` when the URL cannot be resolved, so the
1043
+ * caller can print the `agents repo pull user <git-url>` remediation instead of
1044
+ * the old "Not a git repo" crash.
1045
+ */
1046
+ export async function adoptUserRepoIfNeeded(dir, opts = {}) {
1047
+ const hasOrigin = isGitRepo(dir) && readOriginUrl(dir) !== null;
1048
+ if (hasOrigin)
1049
+ return null;
1050
+ const url = opts.explicitUrl?.trim() || resolveUserRepoRemoteUrl(dir);
1051
+ if (!url) {
1052
+ return {
1053
+ success: false,
1054
+ commit: '',
1055
+ materialized: 0,
1056
+ reconciledAgentsYaml: false,
1057
+ localEdits: [],
1058
+ needsUrl: true,
1059
+ error: `${displayHomePath(dir)} is not a git repo and no remote URL is known.`,
1060
+ };
1061
+ }
1062
+ return adoptRepoInPlace(dir, url);
1063
+ }
832
1064
  /**
833
1065
  * Check if the repo's origin points to the system repo.
834
1066
  */
@@ -16,8 +16,18 @@
16
16
  * lands for both helpers at once.
17
17
  */
18
18
  import { type HelperName } from './helper-versions.js';
19
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
20
- export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
19
+ /**
20
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
21
+ *
22
+ * This is the SLUG, which is not the npm package name: the package is still
23
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
24
+ * slug kept working only because GitHub redirects a renamed repo, which is a
25
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
26
+ * repo away from resolving somewhere else. What actually protects the download
27
+ * is the sha256 + codesign + designated-requirement + Team ID verification
28
+ * below; this just stops relying on the redirect.
29
+ */
30
+ export declare const HELPER_RELEASE_REPO = "phnx-labs/agi-cli";
21
31
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
22
32
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
23
33
  export declare const EXPECTED_TEAM_ID = "2HTP252L87";
@@ -23,8 +23,18 @@ import { pipeline } from 'node:stream/promises';
23
23
  import { getCacheDir } from './state.js';
24
24
  import { parseSha256Asset, sha256File } from './sha256-asset.js';
25
25
  import { helperTag } from './helper-versions.js';
26
- /** GitHub repo whose `v<version>` releases carry the helper assets. */
27
- export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
26
+ /**
27
+ * GitHub repo whose `<helper>/v<version>` releases carry the helper assets.
28
+ *
29
+ * This is the SLUG, which is not the npm package name: the package is still
30
+ * `@phnx-labs/agents-cli`, but the repository was renamed to `agi-cli`. The old
31
+ * slug kept working only because GitHub redirects a renamed repo, which is a
32
+ * poor thing to hang signed-binary delivery on — a redirect is one re-created
33
+ * repo away from resolving somewhere else. What actually protects the download
34
+ * is the sha256 + codesign + designated-requirement + Team ID verification
35
+ * below; this just stops relying on the redirect.
36
+ */
37
+ export const HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
28
38
  /** Apple Developer ID Team every helper must be signed by ("Developer ID
29
39
  * Application: Muqit Nawaz"). Defense in depth on top of `spctl` notarization. */
30
40
  export const EXPECTED_TEAM_ID = '2HTP252L87';
@@ -298,7 +298,7 @@ function foldUserHooksYamlIntoAgentsYaml() {
298
298
  meta.hooks = merged;
299
299
  const header = `# agents-cli metadata
300
300
  # Auto-generated - do not edit manually
301
- # https://github.com/phnx-labs/agents-cli
301
+ # https://github.com/phnx-labs/agi-cli
302
302
 
303
303
  `;
304
304
  try {
@@ -414,7 +414,7 @@ function foldBrowserProfilesIntoAgentsYaml() {
414
414
  meta.browser = merged;
415
415
  const header = `# agents-cli metadata
416
416
  # Auto-generated - do not edit manually
417
- # https://github.com/phnx-labs/agents-cli
417
+ # https://github.com/phnx-labs/agi-cli
418
418
 
419
419
  `;
420
420
  try {
@@ -1641,7 +1641,7 @@ function migrateVersionResourcesToPatterns() {
1641
1641
  }
1642
1642
  }
1643
1643
  if (changed) {
1644
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1644
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agi-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1645
1645
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1646
1646
  console.error('Migrated agents.yaml versions: entries to pattern format');
1647
1647
  }
@@ -1668,7 +1668,7 @@ function migrateSplitDeviceLocalMeta() {
1668
1668
  const agents = meta.agents;
1669
1669
  const versions = meta.versions;
1670
1670
  const hasLocal = (!!agents && Object.keys(agents).length > 0) || (!!versions && Object.keys(versions).length > 0);
1671
- const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1671
+ const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agi-cli\n\n';
1672
1672
  // Only rewrite central when it actually carries machine-local fields — a
1673
1673
  // machine whose agents.yaml is already portable-only is left untouched.
1674
1674
  if (hasLocal) {
@@ -35,6 +35,26 @@ export declare const MENUBAR_HELPER_EXECUTABLE_NAME = "AGI Menu";
35
35
  * hermetic test fork's own teardown boots out the operator's live helper.
36
36
  */
37
37
  export declare function serviceLabel(): string;
38
+ /**
39
+ * What the installed helper IS — deliberately not the CLI's version.
40
+ *
41
+ * `source` matters because the two install paths have different notions of
42
+ * "changed": a release bundle is identified by its helper version, while a local
43
+ * dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
44
+ * so it is identified by the source path + mtime it was copied from.
45
+ */
46
+ /** Version label for a bundle that has none of its own (a local dev build). */
47
+ export declare const LOCAL_BUILD_LABEL = "local";
48
+ export type MenubarStamp = {
49
+ source: 'release';
50
+ helperVersion: string;
51
+ } | {
52
+ source: 'local';
53
+ sourceStamp: string;
54
+ } | {
55
+ source: 'legacy';
56
+ raw: string;
57
+ };
38
58
  /**
39
59
  * Absolute path to the installed menu-bar helper executable if it exists on
40
60
  * disk, else null. The desktop notifier (notify-desktop.ts) routes daemon
@@ -136,16 +156,50 @@ export declare function enableMenubarService(opts?: {
136
156
  clearOptOut?: boolean;
137
157
  }): Promise<boolean>;
138
158
  /**
139
- * Pure staleness decision (no I/O) so the truth table is unit-testable. The
140
- * installed service is stale when the helper binary is gone, or when it was
141
- * installed by a different CLI version than the one now running — a version
142
- * change is the signal that the plist's baked interpreter/entry/bundle paths
143
- * and the helper binary itself may have drifted. A null installedVersion
144
- * (pre-stamp install) counts as stale so old installs get re-stamped once.
159
+ * Render a stamp as a comparable/displayable version string.
160
+ *
161
+ * A local build has no version of its own, so it reports `local`; the ownership
162
+ * contest treats that as "not comparable" and falls through to its owner arm,
163
+ * which is the correct outcome — a dev build must never win a version contest
164
+ * against a release.
165
+ */
166
+ export declare function stampVersionLabel(stamp: MenubarStamp | null): string | null;
167
+ /**
168
+ * Identify the bundle about to be installed, for the stamp.
169
+ *
170
+ * A bundle is a RELEASE iff it sits under the helper's own download cache —
171
+ * asked of `menubarHelperCacheDir`, not pattern-matched out of the path. A regex
172
+ * for `/v<x.y.z>/` gets this wrong in both directions: a checkout living under
173
+ * any directory that happens to contain a version-shaped segment reads as a
174
+ * release, and a release cache laid out differently reads as local. Either
175
+ * misclassification flips `source` between invocations, and a kind change is
176
+ * unconditionally stale — which is a reinstall loop.
177
+ */
178
+ export declare function stampFor(resolvedSourceAppPath: string): MenubarStamp;
179
+ /**
180
+ * The helper version a path denotes, iff it is inside that version's cache dir.
181
+ * Returns null for anything else — including a version-shaped path that is not
182
+ * actually the cache.
183
+ */
184
+ export declare function releaseVersionOfCachedBundle(appPath: string, cacheDirFor?: (v: string) => string): string | null;
185
+ /**
186
+ * Pure staleness decision (no I/O) so the truth table is unit-testable.
187
+ *
188
+ * This compares the HELPER axis, not the CLI's. It used to compare the installed
189
+ * stamp against `getCliVersion()`, which was wrong in both directions once the
190
+ * helpers gained their own version line: every CLI release made an unchanged
191
+ * helper look stale and reinstalled it (the #2109 restart storm), while a
192
+ * genuinely newer helper at the same CLI version never looked stale at all.
193
+ *
194
+ * Stale when: the executable is gone; nothing is stamped; the stamp predates the
195
+ * JSON format (`legacy` — re-stamped once); the install KIND changed
196
+ * (local <-> release), since a dev build and a release bundle are not
197
+ * interchangeable; a release install whose available helper version is newer;
198
+ * or a local install whose source path or mtime moved.
145
199
  */
146
200
  export declare function isMenubarStale(opts: {
147
- installedVersion: string | null;
148
- currentVersion: string;
201
+ installed: MenubarStamp | null;
202
+ available: MenubarStamp;
149
203
  execExists: boolean;
150
204
  }): boolean;
151
205
  /**
@@ -354,8 +408,12 @@ export interface MenubarStatus {
354
408
  platform: string;
355
409
  source: string | null;
356
410
  installedApp: string | null;
411
+ /** The installed HELPER's version — not the CLI's. `local` for a dev build. */
357
412
  installedVersion: string | null;
413
+ /** The helper version this install would put on disk right now. */
358
414
  currentVersion: string;
415
+ /** The CLI's own version, reported separately so the two are never conflated. */
416
+ cliVersion: string;
359
417
  stale: boolean;
360
418
  serviceInstalled: boolean;
361
419
  running: boolean;
@@ -370,8 +428,13 @@ export declare function getMenubarStatus(): MenubarStatus;
370
428
  export interface MenubarDoctorReport {
371
429
  platform: string;
372
430
  installPath: string | null;
431
+ /** The installed HELPER's version — not the CLI's. `local` for a dev build. */
373
432
  installedVersion: string | null;
433
+ /** The helper version this install would put on disk right now. */
374
434
  currentVersion: string;
435
+ /** The CLI's own version, reported separately so the two are never conflated. */
436
+ cliVersion: string;
437
+ /** installed helper vs available helper. Never compares the CLI's version. */
375
438
  versionMatches: boolean;
376
439
  /** `unknown` on non-darwin or when nothing is installed to inspect. */
377
440
  signingIdentity: 'developer-id' | 'ad-hoc' | 'unknown';