@phnx-labs/agents-cli 1.22.49 → 1.22.51
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.
- package/CHANGELOG.md +229 -0
- package/README.md +10 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/accounts.js +1 -13
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +77 -2
- package/dist/commands/config.js +20 -0
- package/dist/commands/daemon-test-harness.d.ts +42 -0
- package/dist/commands/daemon-test-harness.js +90 -0
- package/dist/commands/device-config-test-harness.d.ts +11 -0
- package/dist/commands/device-config-test-harness.js +59 -0
- package/dist/commands/exec.js +24 -1
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/menubar.js +6 -4
- package/dist/commands/share.js +9 -4
- package/dist/commands/teams.d.ts +12 -0
- package/dist/commands/teams.js +44 -67
- package/dist/commands/traces.js +36 -4
- package/dist/lib/account-provider-registry.d.ts +9 -0
- package/dist/lib/account-provider-registry.js +20 -0
- package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
- package/dist/lib/accounting/account-pool-collect.js +71 -0
- package/dist/lib/accounting/account-pool.d.ts +46 -0
- package/dist/lib/accounting/account-pool.js +33 -0
- package/dist/lib/accounting/capacity.d.ts +24 -0
- package/dist/lib/accounting/capacity.js +30 -0
- package/dist/lib/accounting/rotate.d.ts +11 -18
- package/dist/lib/accounting/rotate.js +6 -24
- package/dist/lib/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -1
- package/dist/lib/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/installations/store.js +6 -3
- package/dist/lib/installations/versions.js +48 -46
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- package/dist/lib/share/publish.d.ts +8 -3
- package/dist/lib/share/publish.js +6 -3
- package/dist/lib/share/worker-template.js +239 -22
- package/dist/lib/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- package/dist/lib/teams/agents.d.ts +14 -12
- package/dist/lib/teams/agents.js +188 -73
- package/dist/lib/teams/api.d.ts +6 -1
- package/dist/lib/teams/api.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/teams/scheduler.js +26 -8
- package/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +103 -10
- package/package.json +3 -3
|
@@ -34,7 +34,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
|
|
|
34
34
|
import { parseMcpConfigForScan, isProjectMcpTrusted } from '../mcp.js';
|
|
35
35
|
import { createVersionedAlias, removeVersionedAlias, switchConfigSymlink, getConfigSymlinkVersion, ensureClaudeInsideSymlink, assertIsolationBoundary, isIsolationProtected, } from './shims.js';
|
|
36
36
|
import { importInstallScriptBinary } from '../import.js';
|
|
37
|
-
import { createInstallation, getBinaryPath, getCliVersionFromPath, getGlobalDefault, getIsolatedDefault, getLiveVersion, getVersionDir, getVersionHomePath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, isGlobalBinaryAgent, isVersionInstalled, isVersionIsolated, listInstalledVersions, pickCanonicalGlobalBinaryVersion, resolveVersion, } from './store.js';
|
|
37
|
+
import { createInstallation, getBinaryPath, getCliVersionFromPath, getGlobalDefault, getIsolatedDefault, getLiveVersion, getVersionDir, getVersionHomePath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, isGlobalBinaryAgent, isVersionInstalled, isVersionIsolated, listInstalledVersions, pickCanonicalGlobalBinaryVersion, resolveGrokFallbackBinary, resolveVersion, } from './store.js';
|
|
38
38
|
export * from './store.js';
|
|
39
39
|
import { INSTALLATION_RECORD_FILE } from './types.js';
|
|
40
40
|
import { composeWin32CommandLine } from '../platform/index.js';
|
|
@@ -1032,9 +1032,9 @@ export function setIsolatedDefault(agent, version) {
|
|
|
1032
1032
|
/**
|
|
1033
1033
|
* Move any `grok-<installedVersion>-...` binary (and its generic platform
|
|
1034
1034
|
* copy) found directly under `sourceDownloads` into `targetDownloads`.
|
|
1035
|
-
* Returns true if the versioned binary was
|
|
1035
|
+
* Returns true if the versioned binary was transferred.
|
|
1036
1036
|
*/
|
|
1037
|
-
function
|
|
1037
|
+
function transferGrokDownloads(sourceDownloads, targetDownloads, copy) {
|
|
1038
1038
|
if (!fs.existsSync(sourceDownloads))
|
|
1039
1039
|
return false;
|
|
1040
1040
|
if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
|
|
@@ -1042,42 +1042,36 @@ function tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion
|
|
|
1042
1042
|
const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
|
|
1043
1043
|
if (entries.length === 0)
|
|
1044
1044
|
return false;
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if (fs.existsSync(dst))
|
|
1056
|
-
continue;
|
|
1057
|
-
try {
|
|
1058
|
-
fs.mkdirSync(targetDownloads, { recursive: true });
|
|
1059
|
-
fs.renameSync(src, dst);
|
|
1060
|
-
movedPaths.push(dst);
|
|
1061
|
-
}
|
|
1062
|
-
catch {
|
|
1063
|
-
/* ignore per-file failures */
|
|
1064
|
-
}
|
|
1045
|
+
const realBinary = resolveGrokFallbackBinary(sourceDownloads);
|
|
1046
|
+
if (!realBinary)
|
|
1047
|
+
return false;
|
|
1048
|
+
const transferred = path.join(targetDownloads, path.basename(realBinary));
|
|
1049
|
+
try {
|
|
1050
|
+
fs.mkdirSync(targetDownloads, { recursive: true });
|
|
1051
|
+
if (copy)
|
|
1052
|
+
fs.copyFileSync(realBinary, transferred);
|
|
1053
|
+
else
|
|
1054
|
+
fs.renameSync(realBinary, transferred);
|
|
1065
1055
|
}
|
|
1066
|
-
|
|
1056
|
+
catch {
|
|
1067
1057
|
return false;
|
|
1058
|
+
}
|
|
1068
1059
|
// The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
|
|
1069
1060
|
// that is a copy of the versioned binary. Move it too if its size matches.
|
|
1070
|
-
const movedSize = fs.statSync(
|
|
1061
|
+
const movedSize = fs.statSync(transferred).size;
|
|
1071
1062
|
for (const entry of entries) {
|
|
1072
|
-
if (
|
|
1073
|
-
continue;
|
|
1063
|
+
if (entry === path.basename(realBinary))
|
|
1064
|
+
continue;
|
|
1074
1065
|
const src = path.join(sourceDownloads, entry);
|
|
1075
1066
|
const dst = path.join(targetDownloads, entry);
|
|
1076
1067
|
if (fs.existsSync(dst))
|
|
1077
1068
|
continue;
|
|
1078
1069
|
try {
|
|
1079
1070
|
if (fs.statSync(src).size === movedSize) {
|
|
1080
|
-
|
|
1071
|
+
if (copy)
|
|
1072
|
+
fs.copyFileSync(src, dst);
|
|
1073
|
+
else
|
|
1074
|
+
fs.renameSync(src, dst);
|
|
1081
1075
|
}
|
|
1082
1076
|
}
|
|
1083
1077
|
catch {
|
|
@@ -1099,7 +1093,7 @@ function tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion
|
|
|
1099
1093
|
* whatever version was default at the time), sweep every other installed
|
|
1100
1094
|
* grok version home for the missing file before giving up.
|
|
1101
1095
|
*/
|
|
1102
|
-
function relocateGrokBinaryToVersionHome(
|
|
1096
|
+
function relocateGrokBinaryToVersionHome(installationLabel, copyExisting) {
|
|
1103
1097
|
const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
|
|
1104
1098
|
let sourceDownloads;
|
|
1105
1099
|
try {
|
|
@@ -1108,16 +1102,16 @@ function relocateGrokBinaryToVersionHome(installedVersion) {
|
|
|
1108
1102
|
catch {
|
|
1109
1103
|
sourceDownloads = path.join(hostGrokLink, 'downloads');
|
|
1110
1104
|
}
|
|
1111
|
-
const targetDownloads = path.join(getVersionHomePath('grok',
|
|
1112
|
-
if (
|
|
1105
|
+
const targetDownloads = path.join(getVersionHomePath('grok', installationLabel), agentConfigDirName('grok'), 'downloads');
|
|
1106
|
+
if (transferGrokDownloads(sourceDownloads, targetDownloads, copyExisting))
|
|
1113
1107
|
return;
|
|
1114
1108
|
for (const version of listInstalledVersions('grok')) {
|
|
1115
|
-
if (version ===
|
|
1109
|
+
if (version === installationLabel)
|
|
1116
1110
|
continue;
|
|
1117
1111
|
const candidate = path.join(getVersionHomePath('grok', version), agentConfigDirName('grok'), 'downloads');
|
|
1118
1112
|
if (path.resolve(candidate) === path.resolve(sourceDownloads))
|
|
1119
1113
|
continue; // already tried
|
|
1120
|
-
if (
|
|
1114
|
+
if (transferGrokDownloads(candidate, targetDownloads, copyExisting))
|
|
1121
1115
|
return;
|
|
1122
1116
|
}
|
|
1123
1117
|
}
|
|
@@ -1158,6 +1152,7 @@ async function checkGrokAccountCollision(installedVersion) {
|
|
|
1158
1152
|
*/
|
|
1159
1153
|
export async function installVersion(agent, version, onProgress, opts) {
|
|
1160
1154
|
const agentConfig = AGENTS[agent];
|
|
1155
|
+
const requestedLabel = version;
|
|
1161
1156
|
if (isAgentHardDeprecated(agent)) {
|
|
1162
1157
|
return { success: false, installedVersion: version, error: hardDeprecationError(agent) };
|
|
1163
1158
|
}
|
|
@@ -1189,7 +1184,7 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1189
1184
|
}
|
|
1190
1185
|
version = 'latest';
|
|
1191
1186
|
}
|
|
1192
|
-
let
|
|
1187
|
+
let releaseVersion = version;
|
|
1193
1188
|
try {
|
|
1194
1189
|
if (runInstaller) {
|
|
1195
1190
|
const script = agentConfig.installScript.replaceAll('VERSION', version);
|
|
@@ -1218,13 +1213,19 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1218
1213
|
error: `${agentConfig.name} installed but its version could not be determined after several attempts.`,
|
|
1219
1214
|
};
|
|
1220
1215
|
}
|
|
1221
|
-
|
|
1216
|
+
releaseVersion = probed;
|
|
1222
1217
|
// Fold any stale literal `latest` dir from an earlier probe-failed
|
|
1223
1218
|
// install into the real version so it stops shadowing `agents view`.
|
|
1224
|
-
await reconcileStaleLatestDir(agent,
|
|
1219
|
+
await reconcileStaleLatestDir(agent, releaseVersion);
|
|
1225
1220
|
}
|
|
1221
|
+
// Self-updating installers always fetch the current vendor release, but a
|
|
1222
|
+
// concrete requested token is still the stable installation/account slot.
|
|
1223
|
+
// Keeping the two strings separate lets several homes carry the same
|
|
1224
|
+
// release without sharing credentials. `latest` remains the convenient
|
|
1225
|
+
// release-named slot; concrete tokens remain exactly addressable.
|
|
1226
|
+
const installationLabel = requestedLabel === 'latest' ? releaseVersion : requestedLabel;
|
|
1226
1227
|
if (agent === 'grok') {
|
|
1227
|
-
await checkGrokAccountCollision(
|
|
1228
|
+
await checkGrokAccountCollision(installationLabel);
|
|
1228
1229
|
}
|
|
1229
1230
|
onProgress?.(`${agentConfig.name} installed. Setting up agents-cli version home for isolation...`);
|
|
1230
1231
|
}
|
|
@@ -1233,14 +1234,15 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1233
1234
|
return { success: false, installedVersion: version, error: `${agentConfig.name} installer failed: ${err.message}` };
|
|
1234
1235
|
}
|
|
1235
1236
|
ensureAgentsDir();
|
|
1236
|
-
const
|
|
1237
|
+
const installationLabel = requestedLabel === 'latest' ? releaseVersion : requestedLabel;
|
|
1238
|
+
const versionDir = getVersionDir(agent, installationLabel);
|
|
1237
1239
|
fs.mkdirSync(versionDir, { recursive: true });
|
|
1238
1240
|
fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
|
|
1239
1241
|
// Grok's installer drops the binary into ~/.grok/downloads, which currently
|
|
1240
1242
|
// resolves to the PREVIOUS default home. Move it into the target version home
|
|
1241
1243
|
// so version isolation is correct.
|
|
1242
1244
|
if (agent === 'grok') {
|
|
1243
|
-
relocateGrokBinaryToVersionHome(
|
|
1245
|
+
relocateGrokBinaryToVersionHome(installationLabel, !runInstaller);
|
|
1244
1246
|
}
|
|
1245
1247
|
// Symlink the installed binary into the version's node_modules/.bin so
|
|
1246
1248
|
// listInstalledVersions (which checks getBinaryPath) sees this version as
|
|
@@ -1265,26 +1267,26 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1265
1267
|
// that exec-loops forever).
|
|
1266
1268
|
const installedBinary = findInPath(agentConfig.cliCommand);
|
|
1267
1269
|
if (installedBinary) {
|
|
1268
|
-
importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand },
|
|
1270
|
+
importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand }, installationLabel, installedBinary, versionDir);
|
|
1269
1271
|
}
|
|
1270
1272
|
/* If null: binary missing from PATH (install script failed silently) or
|
|
1271
1273
|
only our shim is present. Leave the version dir empty so getBinaryPath
|
|
1272
1274
|
correctly reports it uninstalled. */
|
|
1273
1275
|
}
|
|
1274
|
-
createVersionedAlias(agent,
|
|
1276
|
+
createVersionedAlias(agent, installationLabel);
|
|
1275
1277
|
// Freeze this installation's identity. The dir name is its stable label from
|
|
1276
1278
|
// here on; the release it carries is recorded separately so `agents update`
|
|
1277
1279
|
// can move the release without invalidating any reference to the label.
|
|
1278
|
-
createInstallation(agent,
|
|
1279
|
-
const trackerInstall = await installSessionTrackerHook(agent,
|
|
1280
|
+
createInstallation(agent, installationLabel, releaseVersion);
|
|
1281
|
+
const trackerInstall = await installSessionTrackerHook(agent, installationLabel);
|
|
1280
1282
|
if (!trackerInstall.installed && trackerInstall.error) {
|
|
1281
|
-
console.warn(`agents: SessionStart hook not installed for ${agent}@${
|
|
1283
|
+
console.warn(`agents: SessionStart hook not installed for ${agent}@${installationLabel}: ${trackerInstall.error}`);
|
|
1282
1284
|
}
|
|
1283
1285
|
// The self-updating binary just changed on disk — drop the cached
|
|
1284
1286
|
// `--version` so `agents view` reflects the freshly-installed release.
|
|
1285
1287
|
invalidateLiveVersionCache(agent);
|
|
1286
|
-
emit('version.install', { agent, version:
|
|
1287
|
-
return { success: true, installedVersion };
|
|
1288
|
+
emit('version.install', { agent, version: installationLabel });
|
|
1289
|
+
return { success: true, installedVersion: installationLabel };
|
|
1288
1290
|
}
|
|
1289
1291
|
// Resolve the `latest`/`oldest` aliases to a concrete npm version up front so
|
|
1290
1292
|
// the rest of the install path treats them as an ordinary pinned install.
|
|
@@ -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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
|
|
148
|
-
|
|
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';
|
|
@@ -25,7 +25,7 @@ import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../vers
|
|
|
25
25
|
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
26
26
|
import { compareVersions } from '../agent-spec/primitives.js';
|
|
27
27
|
import { namespacedServiceLabel, serviceManifestHomeEnv, serviceManagerRegistrationAllowed } from '../service-manifest.js';
|
|
28
|
-
import { downloadMenubarHelperApp } from './download-menubar.js';
|
|
28
|
+
import { downloadMenubarHelperApp, menubarHelperCacheDir } from './download-menubar.js';
|
|
29
29
|
import { helperFloor } from '../helper-versions.js';
|
|
30
30
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
31
31
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
@@ -89,13 +89,42 @@ function installedAppPath() {
|
|
|
89
89
|
function installedVersionMarkerPath() {
|
|
90
90
|
return path.join(installDir(), '.menubar-version');
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* What the installed helper IS — deliberately not the CLI's version.
|
|
94
|
+
*
|
|
95
|
+
* `source` matters because the two install paths have different notions of
|
|
96
|
+
* "changed": a release bundle is identified by its helper version, while a local
|
|
97
|
+
* dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
|
|
98
|
+
* so it is identified by the source path + mtime it was copied from.
|
|
99
|
+
*/
|
|
100
|
+
/** Version label for a bundle that has none of its own (a local dev build). */
|
|
101
|
+
export const LOCAL_BUILD_LABEL = 'local';
|
|
102
|
+
/**
|
|
103
|
+
* Read the stamp, tolerating the pre-JSON format.
|
|
104
|
+
*
|
|
105
|
+
* Older installs wrote a bare version string — and wrote the CLI's version into
|
|
106
|
+
* it, which is the bug this replaces. Such a stamp cannot be compared on the
|
|
107
|
+
* helper axis at all, so it reports `legacy` and is treated as stale exactly
|
|
108
|
+
* once, which re-stamps it in the new format. That mirrors the existing
|
|
109
|
+
* null-is-stale rule and is why the migration cannot loop.
|
|
110
|
+
*/
|
|
111
|
+
function readInstalledMenubarStamp() {
|
|
112
|
+
let raw;
|
|
93
113
|
try {
|
|
94
|
-
|
|
114
|
+
raw = fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim();
|
|
95
115
|
}
|
|
96
116
|
catch {
|
|
97
117
|
return null;
|
|
98
118
|
}
|
|
119
|
+
if (!raw)
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (parsed && (parsed.source === 'release' || parsed.source === 'local'))
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
catch { /* fall through to legacy */ }
|
|
127
|
+
return { source: 'legacy', raw };
|
|
99
128
|
}
|
|
100
129
|
/** Executable inside the installed bundle. */
|
|
101
130
|
function installedExecutablePath() {
|
|
@@ -425,7 +454,17 @@ export function restartMenubarHelperAfterSwap(uid, ownProcesses, exec = execFile
|
|
|
425
454
|
function startMenubarServiceFromSource(opts = {}) {
|
|
426
455
|
if (!onDarwin())
|
|
427
456
|
return false;
|
|
428
|
-
|
|
457
|
+
// Resolve the source HERE, once, and hand the SAME value to the installer and
|
|
458
|
+
// the stamp. Passing `opts.sourceAppPath` to both let them disagree: the
|
|
459
|
+
// installer falls back to `sourceAppPath()` internally, so on the self-heal
|
|
460
|
+
// path (which passes nothing) it would install a LOCAL build while the stamp
|
|
461
|
+
// recorded a release version. The next invocation then computed `local`, saw a
|
|
462
|
+
// kind mismatch, and reinstalled — every time, forever. That is the #2109
|
|
463
|
+
// storm this whole change exists to stop, so the two must read one variable.
|
|
464
|
+
const src = opts.sourceAppPath ?? sourceAppPath();
|
|
465
|
+
if (!src)
|
|
466
|
+
return false;
|
|
467
|
+
const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: src });
|
|
429
468
|
if (!exec)
|
|
430
469
|
return false;
|
|
431
470
|
// Never bootstrap a helper macOS will reject at launch: an invalid signature
|
|
@@ -441,7 +480,7 @@ function startMenubarServiceFromSource(opts = {}) {
|
|
|
441
480
|
}
|
|
442
481
|
if (opts.clearOptOut)
|
|
443
482
|
clearMenubarOptOut();
|
|
444
|
-
installAndStartService(exec);
|
|
483
|
+
installAndStartService(exec, stampFor(src));
|
|
445
484
|
return true;
|
|
446
485
|
}
|
|
447
486
|
/**
|
|
@@ -481,33 +520,121 @@ function clearMenubarOptOut() {
|
|
|
481
520
|
* particular is what the upgrade self-heal reads to decide staleness, and a
|
|
482
521
|
* path that skipped it would make every later `agents` invocation reinstall.
|
|
483
522
|
*/
|
|
484
|
-
function installAndStartService(exec) {
|
|
523
|
+
function installAndStartService(exec, stamp) {
|
|
485
524
|
const plist = servicePlistPath();
|
|
486
525
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
487
526
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
488
527
|
restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
|
|
489
528
|
try {
|
|
490
|
-
fs.writeFileSync(installedVersionMarkerPath(),
|
|
529
|
+
fs.writeFileSync(installedVersionMarkerPath(), JSON.stringify(stamp));
|
|
530
|
+
}
|
|
531
|
+
catch { /* best effort */ }
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Render a stamp as a comparable/displayable version string.
|
|
535
|
+
*
|
|
536
|
+
* A local build has no version of its own, so it reports `local`; the ownership
|
|
537
|
+
* contest treats that as "not comparable" and falls through to its owner arm,
|
|
538
|
+
* which is the correct outcome — a dev build must never win a version contest
|
|
539
|
+
* against a release.
|
|
540
|
+
*/
|
|
541
|
+
export function stampVersionLabel(stamp) {
|
|
542
|
+
if (!stamp)
|
|
543
|
+
return null;
|
|
544
|
+
if (stamp.source === 'release')
|
|
545
|
+
return stamp.helperVersion;
|
|
546
|
+
if (stamp.source === 'legacy')
|
|
547
|
+
return null;
|
|
548
|
+
return LOCAL_BUILD_LABEL;
|
|
549
|
+
}
|
|
550
|
+
/** What this install would put on disk right now, as a stamp. */
|
|
551
|
+
function availableStamp() {
|
|
552
|
+
const src = sourceAppPath();
|
|
553
|
+
// No local bundle means the release path: what would be installed is the
|
|
554
|
+
// helper version the floor names.
|
|
555
|
+
return src ? stampFor(src) : { source: 'release', helperVersion: helperFloor('menubar') };
|
|
556
|
+
}
|
|
557
|
+
/** The helper version this install would put on disk right now, for display. */
|
|
558
|
+
function availableHelperLabel() {
|
|
559
|
+
return stampVersionLabel(availableStamp()) ?? LOCAL_BUILD_LABEL;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Identify the bundle about to be installed, for the stamp.
|
|
563
|
+
*
|
|
564
|
+
* A bundle is a RELEASE iff it sits under the helper's own download cache —
|
|
565
|
+
* asked of `menubarHelperCacheDir`, not pattern-matched out of the path. A regex
|
|
566
|
+
* for `/v<x.y.z>/` gets this wrong in both directions: a checkout living under
|
|
567
|
+
* any directory that happens to contain a version-shaped segment reads as a
|
|
568
|
+
* release, and a release cache laid out differently reads as local. Either
|
|
569
|
+
* misclassification flips `source` between invocations, and a kind change is
|
|
570
|
+
* unconditionally stale — which is a reinstall loop.
|
|
571
|
+
*/
|
|
572
|
+
export function stampFor(resolvedSourceAppPath) {
|
|
573
|
+
const version = releaseVersionOfCachedBundle(resolvedSourceAppPath);
|
|
574
|
+
if (version)
|
|
575
|
+
return { source: 'release', helperVersion: version };
|
|
576
|
+
let mtime = 0;
|
|
577
|
+
try {
|
|
578
|
+
mtime = fs.statSync(resolvedSourceAppPath).mtimeMs;
|
|
491
579
|
}
|
|
492
580
|
catch { /* best effort */ }
|
|
581
|
+
return { source: 'local', sourceStamp: `${resolvedSourceAppPath}@${mtime}` };
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The helper version a path denotes, iff it is inside that version's cache dir.
|
|
585
|
+
* Returns null for anything else — including a version-shaped path that is not
|
|
586
|
+
* actually the cache.
|
|
587
|
+
*/
|
|
588
|
+
export function releaseVersionOfCachedBundle(appPath, cacheDirFor = menubarHelperCacheDir) {
|
|
589
|
+
// Try EVERY version-shaped segment, not just the leftmost. A cached bundle
|
|
590
|
+
// under a home or mount path that itself contains an unrelated `vX.Y.Z`
|
|
591
|
+
// (an nvm dir, a versioned volume) would otherwise match that first segment,
|
|
592
|
+
// fail the prefix check, and be misclassified as a local build.
|
|
593
|
+
const resolved = path.resolve(appPath);
|
|
594
|
+
for (const m of appPath.matchAll(/v(\d+\.\d+\.\d+)/g)) {
|
|
595
|
+
const expected = path.resolve(cacheDirFor(m[1]));
|
|
596
|
+
if (resolved === expected || resolved.startsWith(expected + path.sep))
|
|
597
|
+
return m[1];
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
493
600
|
}
|
|
494
601
|
/**
|
|
495
|
-
* Pure staleness decision (no I/O) so the truth table is unit-testable.
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
602
|
+
* Pure staleness decision (no I/O) so the truth table is unit-testable.
|
|
603
|
+
*
|
|
604
|
+
* This compares the HELPER axis, not the CLI's. It used to compare the installed
|
|
605
|
+
* stamp against `getCliVersion()`, which was wrong in both directions once the
|
|
606
|
+
* helpers gained their own version line: every CLI release made an unchanged
|
|
607
|
+
* helper look stale and reinstalled it (the #2109 restart storm), while a
|
|
608
|
+
* genuinely newer helper at the same CLI version never looked stale at all.
|
|
609
|
+
*
|
|
610
|
+
* Stale when: the executable is gone; nothing is stamped; the stamp predates the
|
|
611
|
+
* JSON format (`legacy` — re-stamped once); the install KIND changed
|
|
612
|
+
* (local <-> release), since a dev build and a release bundle are not
|
|
613
|
+
* interchangeable; a release install whose available helper version is newer;
|
|
614
|
+
* or a local install whose source path or mtime moved.
|
|
501
615
|
*/
|
|
502
616
|
export function isMenubarStale(opts) {
|
|
503
617
|
if (!opts.execExists)
|
|
504
618
|
return true;
|
|
505
|
-
|
|
619
|
+
const { installed, available } = opts;
|
|
620
|
+
if (!installed)
|
|
621
|
+
return true;
|
|
622
|
+
if (installed.source === 'legacy')
|
|
623
|
+
return true;
|
|
624
|
+
if (installed.source !== available.source)
|
|
625
|
+
return true;
|
|
626
|
+
if (installed.source === 'release' && available.source === 'release') {
|
|
627
|
+
return compareVersions(available.helperVersion, installed.helperVersion) > 0;
|
|
628
|
+
}
|
|
629
|
+
if (installed.source === 'local' && available.source === 'local') {
|
|
630
|
+
return installed.sourceStamp !== available.sourceStamp;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
506
633
|
}
|
|
507
634
|
function menubarSetupStale() {
|
|
508
635
|
return isMenubarStale({
|
|
509
|
-
|
|
510
|
-
|
|
636
|
+
installed: readInstalledMenubarStamp(),
|
|
637
|
+
available: availableStamp(),
|
|
511
638
|
execExists: fs.existsSync(installedExecutablePath()),
|
|
512
639
|
});
|
|
513
640
|
}
|
|
@@ -662,7 +789,14 @@ export function mayInstallMenubarHelper(opts) {
|
|
|
662
789
|
// running, nothing deadlocks.
|
|
663
790
|
if (!opts.ownerEntryExists)
|
|
664
791
|
return opts.sourceIsDeveloperId;
|
|
665
|
-
|
|
792
|
+
// `local` is a KIND marker, not a version — a dev build has none. Comparing it
|
|
793
|
+
// with compareVersions would order it against real semver arbitrarily and let a
|
|
794
|
+
// dev build win (or lose) a contest it should not enter. When either side is a
|
|
795
|
+
// local build the version arm is skipped entirely and the owner arm decides,
|
|
796
|
+
// which is the correct outcome: ownership, not version, distinguishes them.
|
|
797
|
+
const comparableVersions = opts.installedVersion && opts.currentVersion &&
|
|
798
|
+
opts.installedVersion !== LOCAL_BUILD_LABEL && opts.currentVersion !== LOCAL_BUILD_LABEL;
|
|
799
|
+
if (comparableVersions) {
|
|
666
800
|
const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
|
|
667
801
|
if (versionOrder > 0)
|
|
668
802
|
return opts.sourceIsDeveloperId;
|
|
@@ -730,8 +864,8 @@ function mayHealMenubar(needsDevIdHeal) {
|
|
|
730
864
|
ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
|
|
731
865
|
helperExecMissing: !fs.existsSync(installedExecutablePath()),
|
|
732
866
|
needsDevIdHeal,
|
|
733
|
-
installedVersion:
|
|
734
|
-
currentVersion:
|
|
867
|
+
installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
|
|
868
|
+
currentVersion: availableHelperLabel(),
|
|
735
869
|
msSinceLastHeal: msSinceLastMenubarHeal(),
|
|
736
870
|
cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
|
|
737
871
|
sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
|
|
@@ -931,7 +1065,24 @@ export async function runMenubarSetup() {
|
|
|
931
1065
|
step('bundle', 'failed', 'could not install the helper bundle');
|
|
932
1066
|
return { steps, configured: false, status: getMenubarStatus() };
|
|
933
1067
|
}
|
|
934
|
-
|
|
1068
|
+
// The helper axis here too: this label read "ok" only when the installed
|
|
1069
|
+
// helper's stamp happened to equal the CLI's version, which after this change
|
|
1070
|
+
// is never true on a release install.
|
|
1071
|
+
//
|
|
1072
|
+
// Compare the STAMPS, not their labels. `stampVersionLabel` collapses every
|
|
1073
|
+
// local build to the literal 'local', discarding the mtime — so a real dev
|
|
1074
|
+
// rebuild (which the surrounding logic does correctly reinstall) would print
|
|
1075
|
+
// "ok" and hide that anything changed. That is the same collapse already
|
|
1076
|
+
// excluded in `versionMatches` and `mayInstallMenubarHelper`; this was the
|
|
1077
|
+
// third site and the only one still reading through the lossy label.
|
|
1078
|
+
const bundleStamp = readInstalledMenubarStamp();
|
|
1079
|
+
// Reuse the file's canonical comparator rather than a second, serialization
|
|
1080
|
+
// -based one: `JSON.stringify` was sound only because both stamps come from
|
|
1081
|
+
// `stampFor`, which is an implicit key-order dependency with no reason to
|
|
1082
|
+
// exist when isMenubarStale already answers exactly this question.
|
|
1083
|
+
const bundleUnchanged = bundleStamp !== null &&
|
|
1084
|
+
!isMenubarStale({ installed: bundleStamp, available: availableStamp(), execExists: true });
|
|
1085
|
+
step('bundle', bundleUnchanged ? 'ok' : 'changed', `${installedAppPath()} (${stampVersionLabel(bundleStamp) ?? 'unknown'})`);
|
|
935
1086
|
if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
|
|
936
1087
|
step('signature', 'failed', 'not notarized/valid on this machine — refusing to start it (Gatekeeper rejects an ' +
|
|
937
1088
|
'un-notarized helper as "damaged"). Upgrade to a notarized build of agents-cli.');
|
|
@@ -941,7 +1092,7 @@ export async function runMenubarSetup() {
|
|
|
941
1092
|
// Clear the sticky opt-out: running `setup` is an explicit request for the
|
|
942
1093
|
// menu bar, so a stale `menubar disable` must not silently win.
|
|
943
1094
|
clearMenubarOptOut();
|
|
944
|
-
installAndStartService(exec);
|
|
1095
|
+
installAndStartService(exec, stampFor(src ?? undefined));
|
|
945
1096
|
step('login item', before.serviceInstalled ? 'ok' : 'changed', `${serviceLabel()} — starts at login, restarts if it dies`);
|
|
946
1097
|
// launchd's bootstrap+kickstart is asynchronous; give the status item a beat
|
|
947
1098
|
// to claim the lock before counting instances, or `setup` reports zero on a
|
|
@@ -1043,8 +1194,9 @@ export function getMenubarStatus() {
|
|
|
1043
1194
|
platform: process.platform,
|
|
1044
1195
|
source: sourceAppPath(),
|
|
1045
1196
|
installedApp: fs.existsSync(dest) ? dest : null,
|
|
1046
|
-
installedVersion:
|
|
1047
|
-
currentVersion:
|
|
1197
|
+
installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
|
|
1198
|
+
currentVersion: availableHelperLabel(),
|
|
1199
|
+
cliVersion: getCliVersion(),
|
|
1048
1200
|
stale: onDarwin() && serviceInstalled && menubarSetupStale(),
|
|
1049
1201
|
serviceInstalled,
|
|
1050
1202
|
running: own.length > 0,
|
|
@@ -1105,6 +1257,7 @@ export function buildMenubarDoctorReport() {
|
|
|
1105
1257
|
installPath: null,
|
|
1106
1258
|
installedVersion: null,
|
|
1107
1259
|
currentVersion: getCliVersion(),
|
|
1260
|
+
cliVersion: getCliVersion(),
|
|
1108
1261
|
versionMatches: false,
|
|
1109
1262
|
signingIdentity: 'unknown',
|
|
1110
1263
|
running: false,
|
|
@@ -1137,7 +1290,13 @@ export function buildMenubarDoctorReport() {
|
|
|
1137
1290
|
installPath: appPath,
|
|
1138
1291
|
installedVersion: status.installedVersion,
|
|
1139
1292
|
currentVersion: status.currentVersion,
|
|
1140
|
-
|
|
1293
|
+
cliVersion: status.cliVersion,
|
|
1294
|
+
// Two local builds are not a version "mismatch" — neither carries a version.
|
|
1295
|
+
// Reporting one told the user to run `setup` for a difference that does not
|
|
1296
|
+
// exist, which is what made the old hint fire forever.
|
|
1297
|
+
versionMatches: status.installedVersion === LOCAL_BUILD_LABEL && status.currentVersion === LOCAL_BUILD_LABEL
|
|
1298
|
+
? true
|
|
1299
|
+
: status.installedVersion === status.currentVersion,
|
|
1141
1300
|
signingIdentity,
|
|
1142
1301
|
running: status.running,
|
|
1143
1302
|
staleRunningProcess,
|