@phnx-labs/agents-cli 1.22.74 → 1.22.76
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 +152 -0
- package/README.md +21 -9
- package/dist/bootstrap.js +7 -7
- package/dist/cli/command-registry.js +5 -0
- package/dist/commands/artifacts-setup.js +1 -1
- package/dist/commands/artifacts.js +1 -1
- package/dist/commands/auth.js +7 -1
- package/dist/commands/browser.js +104 -10
- package/dist/commands/commands.js +7 -6
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +26 -7
- package/dist/commands/config.js +27 -4
- package/dist/commands/cost.js +6 -4
- package/dist/commands/doctor.d.ts +6 -5
- package/dist/commands/doctor.js +32 -274
- package/dist/commands/exec.d.ts +2 -0
- package/dist/commands/exec.js +9 -2
- package/dist/commands/harness.d.ts +1 -0
- package/dist/commands/harness.js +11 -3
- package/dist/commands/hooks.js +7 -6
- package/dist/commands/mcp.js +7 -6
- package/dist/commands/memory.js +7 -7
- package/dist/commands/monitors.js +3 -2
- package/dist/commands/open.d.ts +25 -12
- package/dist/commands/open.js +24 -10
- package/dist/commands/permissions.js +7 -6
- package/dist/commands/plugins.js +21 -17
- package/dist/commands/route.js +33 -16
- package/dist/commands/rules.js +7 -12
- package/dist/commands/sessions-share.js +1 -1
- package/dist/commands/setup-watchdog.js +2 -2
- package/dist/commands/setup.js +22 -1
- package/dist/commands/share.js +26 -10
- package/dist/commands/skills.js +7 -6
- package/dist/commands/subagents.js +7 -6
- package/dist/commands/sync.js +81 -10
- package/dist/commands/view.js +4 -1
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +10 -10
- package/dist/commands/webhook.d.ts +4 -0
- package/dist/commands/webhook.js +22 -4
- package/dist/commands/workflows.js +7 -6
- package/dist/lib/account-registry.js +27 -6
- package/dist/lib/accounting/rotate.d.ts +3 -1
- package/dist/lib/accounting/rotate.js +8 -4
- package/dist/lib/auth-health.d.ts +2 -0
- package/dist/lib/auth-health.js +2 -0
- package/dist/lib/browser/chrome.d.ts +21 -0
- package/dist/lib/browser/chrome.js +60 -3
- package/dist/lib/browser/drivers/local.d.ts +21 -0
- package/dist/lib/browser/drivers/local.js +102 -9
- package/dist/lib/browser/profiles.d.ts +29 -1
- package/dist/lib/browser/profiles.js +50 -1
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/computer/computer-rpc.d.ts +6 -1
- package/dist/lib/computer/computer-rpc.js +23 -3
- package/dist/lib/computer/des.d.ts +1 -0
- package/dist/lib/computer/des.js +114 -0
- package/dist/lib/computer/rfb-client.d.ts +53 -0
- package/dist/lib/computer/rfb-client.js +562 -0
- package/dist/lib/config-keys.d.ts +7 -2
- package/dist/lib/config-keys.js +17 -2
- package/dist/lib/daemon/auth-sync-service.js +3 -0
- package/dist/lib/daemon/daemon.js +17 -10
- package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
- package/dist/lib/daemon/session-summarizer-service.js +39 -0
- package/dist/lib/daemon/usage-sync-service.js +3 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/daemon-ticks.d.ts +2 -2
- package/dist/lib/daemon-ticks.js +2 -1
- package/dist/lib/daemon-webhooks.js +15 -2
- package/dist/lib/deeplink/register.js +10 -9
- package/dist/lib/deeplink/url.d.ts +4 -4
- package/dist/lib/deeplink/url.js +4 -4
- package/dist/lib/device-config.js +25 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -4
- package/dist/lib/devices/doctor-findings.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/stats-cache.d.ts +4 -0
- package/dist/lib/devices/stats-cache.js +19 -0
- package/dist/lib/drift-sync.d.ts +3 -1
- package/dist/lib/drift-sync.js +16 -5
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +16 -1
- package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
- package/dist/lib/fleet-shared-repo-sync.js +101 -4
- package/dist/lib/fleet-shared-state.d.ts +8 -0
- package/dist/lib/heal.d.ts +4 -3
- package/dist/lib/heal.js +5 -4
- package/dist/lib/hosts/ready.d.ts +1 -1
- package/dist/lib/hosts/ready.js +16 -4
- package/dist/lib/hosts/reconnect.js +4 -2
- package/dist/lib/identity/client.d.ts +6 -0
- package/dist/lib/identity/index.d.ts +16 -0
- package/dist/lib/identity/index.js +25 -1
- package/dist/lib/profiles.d.ts +2 -0
- package/dist/lib/profiles.js +28 -9
- package/dist/lib/reconcile-and-repair.d.ts +109 -0
- package/dist/lib/reconcile-and-repair.js +267 -0
- package/dist/lib/routers.d.ts +12 -1
- package/dist/lib/routers.js +30 -1
- package/dist/lib/scheduling/routines.js +8 -2
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/db.d.ts +47 -7
- package/dist/lib/session/db.js +114 -12
- package/dist/lib/session/mirror.js +58 -0
- package/dist/lib/session/remote/remote-list.d.ts +2 -0
- package/dist/lib/session/remote/remote-list.js +4 -0
- package/dist/lib/session/remote/watch.js +22 -2
- package/dist/lib/session/session-cache.d.ts +19 -0
- package/dist/lib/session/session-cache.js +46 -0
- package/dist/lib/session/types.d.ts +34 -0
- package/dist/lib/share/backend.d.ts +6 -4
- package/dist/lib/share/backend.js +10 -8
- package/dist/lib/share/config.d.ts +4 -3
- package/dist/lib/share/config.js +10 -1
- package/dist/lib/share/delete.d.ts +1 -1
- package/dist/lib/share/delete.js +1 -1
- package/dist/lib/share/html.d.ts +1 -1
- package/dist/lib/share/html.js +1 -1
- package/dist/lib/share/provision.d.ts +1 -1
- package/dist/lib/share/provision.js +2 -2
- package/dist/lib/share/publish.d.ts +23 -7
- package/dist/lib/share/publish.js +58 -12
- package/dist/lib/share/worker-template.js +221 -60
- package/dist/lib/startup/command-registry.js +2 -2
- package/dist/lib/state.d.ts +15 -0
- package/dist/lib/state.js +29 -7
- package/dist/lib/summarizer/config.d.ts +46 -0
- package/dist/lib/summarizer/config.js +83 -0
- package/dist/lib/summarizer/pass.d.ts +45 -0
- package/dist/lib/summarizer/pass.js +112 -0
- package/dist/lib/summarizer/summarize.d.ts +68 -0
- package/dist/lib/summarizer/summarize.js +120 -0
- package/dist/lib/teams/agents.d.ts +4 -3
- package/dist/lib/teams/agents.js +12 -4
- package/dist/lib/teams/scheduler.d.ts +4 -2
- package/dist/lib/teams/scheduler.js +6 -6
- package/dist/lib/tmux/session.d.ts +2 -0
- package/dist/lib/tmux/session.js +7 -1
- package/dist/lib/types.d.ts +20 -0
- package/dist/lib/verbs.d.ts +23 -0
- package/dist/lib/verbs.js +24 -0
- package/dist/lib/view-types.d.ts +4 -0
- package/dist/lib/watchdog/rotate.d.ts +1 -1
- package/dist/lib/watchdog/rotate.js +1 -1
- package/package.json +1 -1
|
@@ -21,6 +21,18 @@ export interface FleetSharedRepoSyncResult {
|
|
|
21
21
|
timedOut: boolean;
|
|
22
22
|
skipped: string | null;
|
|
23
23
|
error: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Untracked working-tree files that collided with origin and were dropped
|
|
26
|
+
* (byte-identical to origin) before rebasing. See clearCollidingUntracked.
|
|
27
|
+
*/
|
|
28
|
+
untrackedCleared?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Untracked working-tree files that collided with origin, differed from it,
|
|
31
|
+
* and were moved to untrackedBackupDir before rebasing.
|
|
32
|
+
*/
|
|
33
|
+
untrackedBackedUp?: string[];
|
|
34
|
+
/** Directory the differing untracked collisions were moved to, if any. */
|
|
35
|
+
untrackedBackupDir?: string;
|
|
24
36
|
}
|
|
25
37
|
/** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
|
|
26
38
|
export declare function runBoundedProcess(command: string, args: string[], options: {
|
|
@@ -161,6 +161,72 @@ function retainedAutostash(stashList, objectId) {
|
|
|
161
161
|
ref: null,
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* `git rebase` checks out its base (origin/<branch>) and aborts when an
|
|
166
|
+
* untracked working-tree file would be overwritten by a file that origin
|
|
167
|
+
* tracks. `--autostash` only sets aside *tracked* changes, so these untracked
|
|
168
|
+
* collisions wedge the rebase on every sync and the device silently falls
|
|
169
|
+
* hundreds of commits behind — the fleet-wide drift root cause (PHNX-3923).
|
|
170
|
+
*
|
|
171
|
+
* In this shared-state repo an untracked file that also exists on origin is a
|
|
172
|
+
* stale local snapshot: the canonical copy is on origin and every device
|
|
173
|
+
* republishes its own state (this device's own owned file is already committed
|
|
174
|
+
* before we get here, so it is tracked and never appears below). Drop the ones
|
|
175
|
+
* byte-identical to origin (lossless) and move any that differ into a backup
|
|
176
|
+
* dir beside the repo root — outside the repo's tracked tree, so the move
|
|
177
|
+
* cannot create a fresh collision — so nothing is silently destroyed. Then let
|
|
178
|
+
* the rebase check out origin's version. A file we cannot clear is left in
|
|
179
|
+
* place; the rebase may still abort, no worse than today and never losing data.
|
|
180
|
+
*/
|
|
181
|
+
async function clearCollidingUntracked(git, root, branch) {
|
|
182
|
+
const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
183
|
+
if (others.code !== 0)
|
|
184
|
+
return { error: others };
|
|
185
|
+
const relPaths = others.stdout.split('\0').filter(Boolean);
|
|
186
|
+
let cleared = 0;
|
|
187
|
+
const backedUp = [];
|
|
188
|
+
let backupDir = null;
|
|
189
|
+
for (const rel of relPaths) {
|
|
190
|
+
// rev-parse doubles as the "does origin track this path" test (non-zero =
|
|
191
|
+
// absent) — only paths origin tracks are overwritten by the rebase checkout.
|
|
192
|
+
const originHash = await git(['rev-parse', `origin/${branch}:${rel}`]);
|
|
193
|
+
if (originHash.code !== 0 || !originHash.stdout.trim())
|
|
194
|
+
continue;
|
|
195
|
+
const abs = path.join(root, rel);
|
|
196
|
+
// Byte-exact identity via git blob SHAs. Comparing UTF-8-decoded strings
|
|
197
|
+
// would collapse distinct invalid bytes to U+FFFD and could delete
|
|
198
|
+
// non-identical content. `--no-filters` hashes the raw on-disk bytes (no
|
|
199
|
+
// clean/autocrlf normalization), so `identical` is true only when the local
|
|
200
|
+
// bytes exactly equal origin's stored blob — never a filter-normalized
|
|
201
|
+
// near-match. A hash-object failure (unreadable / vanished under a
|
|
202
|
+
// concurrent writer) means "leave it in place".
|
|
203
|
+
const localHash = await git(['hash-object', '--no-filters', '--', abs]);
|
|
204
|
+
if (localHash.code !== 0 || !localHash.stdout.trim())
|
|
205
|
+
continue;
|
|
206
|
+
const identical = originHash.stdout.trim() === localHash.stdout.trim();
|
|
207
|
+
try {
|
|
208
|
+
if (identical) {
|
|
209
|
+
fs.rmSync(abs, { force: true });
|
|
210
|
+
cleared++;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
if (!backupDir) {
|
|
214
|
+
// A sibling of the repo root: outside the tracked tree (so the move
|
|
215
|
+
// cannot create a fresh collision) yet colocated for easy recovery.
|
|
216
|
+
backupDir = path.join(`${root}-fleet-sync-backups`, String(Date.now()));
|
|
217
|
+
}
|
|
218
|
+
const dest = path.join(backupDir, rel);
|
|
219
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
220
|
+
fs.renameSync(abs, dest);
|
|
221
|
+
backedUp.push(rel);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Could not clear this collision; leave it in place.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { cleared, backedUp, backupDir };
|
|
229
|
+
}
|
|
164
230
|
async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
165
231
|
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
166
232
|
return {
|
|
@@ -201,29 +267,57 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
|
201
267
|
}
|
|
202
268
|
const ownedFile = fleetSharedStatePath(device, root);
|
|
203
269
|
const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
|
|
270
|
+
// The central `agents.yaml` is fleet-shared state too — `agents accounts label`
|
|
271
|
+
// writes version-scoped native account rows there (lib/state.ts writeMetaUnlocked),
|
|
272
|
+
// as a plain file write with no commit. Publishing only the per-device file and
|
|
273
|
+
// then `rebase --autostash`-ing over a dirty central file silently destroyed
|
|
274
|
+
// those labels: every box lost its account labels on its next daemon publish
|
|
275
|
+
// (PHNX-3887). Commit it alongside the device doc so the rebase carries the
|
|
276
|
+
// rows instead of stashing them, which is also what `accounts label --help`
|
|
277
|
+
// already promises ("labels live on the central account rows in agents.yaml,
|
|
278
|
+
// which repo push/pull already syncs fleet-wide").
|
|
279
|
+
const centralFile = path.join(root, 'agents.yaml');
|
|
280
|
+
const publishPaths = [relativeOwnedFile];
|
|
281
|
+
if (fs.existsSync(centralFile))
|
|
282
|
+
publishPaths.push('agents.yaml');
|
|
283
|
+
const existingPaths = publishPaths.filter(rel => fs.existsSync(path.join(root, rel)));
|
|
204
284
|
let committed = false;
|
|
205
|
-
if (
|
|
206
|
-
const status = await git(['status', '--porcelain=v1', '--',
|
|
285
|
+
if (existingPaths.length > 0) {
|
|
286
|
+
const status = await git(['status', '--porcelain=v1', '--', ...existingPaths]);
|
|
207
287
|
if (status.code !== 0)
|
|
208
288
|
return failure('git status', status);
|
|
209
289
|
if (status.stdout.trim()) {
|
|
210
|
-
const add = await git(['add', '--',
|
|
290
|
+
const add = await git(['add', '--', ...existingPaths]);
|
|
211
291
|
if (add.code !== 0)
|
|
212
292
|
return failure('git add', add);
|
|
213
293
|
const commit = await git([
|
|
214
294
|
'-c', 'commit.gpgsign=false',
|
|
215
295
|
'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
|
|
216
|
-
'--',
|
|
296
|
+
'--', ...existingPaths,
|
|
217
297
|
]);
|
|
218
298
|
if (commit.code !== 0)
|
|
219
299
|
return failure('git commit', commit);
|
|
220
300
|
committed = true;
|
|
221
301
|
}
|
|
222
302
|
}
|
|
303
|
+
let untrackedCleared = 0;
|
|
304
|
+
const untrackedBackedUp = [];
|
|
305
|
+
let untrackedBackupDir;
|
|
223
306
|
for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
|
|
224
307
|
const fetch = await git(['fetch', 'origin']);
|
|
225
308
|
if (fetch.code !== 0)
|
|
226
309
|
return { ...failure('git fetch', fetch), committed };
|
|
310
|
+
// Untracked files that origin tracks would abort the rebase's checkout
|
|
311
|
+
// (autostash only covers tracked changes). Clear them first — this is the
|
|
312
|
+
// fleet-drift root cause (PHNX-3923).
|
|
313
|
+
const reconcile = await clearCollidingUntracked(git, root, branch);
|
|
314
|
+
if ('error' in reconcile) {
|
|
315
|
+
return { ...failure('git ls-files --others', reconcile.error), committed };
|
|
316
|
+
}
|
|
317
|
+
untrackedCleared += reconcile.cleared;
|
|
318
|
+
untrackedBackedUp.push(...reconcile.backedUp);
|
|
319
|
+
if (reconcile.backupDir)
|
|
320
|
+
untrackedBackupDir = reconcile.backupDir;
|
|
227
321
|
// Keep enough of the same wall-clock bound available to remove a botched
|
|
228
322
|
// autostash pop from the operator's live checkout before returning.
|
|
229
323
|
const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
|
|
@@ -283,6 +377,9 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
|
283
377
|
timedOut: false,
|
|
284
378
|
skipped: null,
|
|
285
379
|
error: null,
|
|
380
|
+
untrackedCleared,
|
|
381
|
+
untrackedBackedUp,
|
|
382
|
+
untrackedBackupDir,
|
|
286
383
|
};
|
|
287
384
|
}
|
|
288
385
|
if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
|
|
@@ -26,6 +26,14 @@ export interface SessionMirrorRow {
|
|
|
26
26
|
timestamp: string;
|
|
27
27
|
ticketId?: string;
|
|
28
28
|
prUrl?: string;
|
|
29
|
+
/** Daemon-computed goal (PHNX-3939) — carried so a peer renders it with no transcript. */
|
|
30
|
+
goal?: string;
|
|
31
|
+
/** Daemon-computed progress checkpoints, newest last (PHNX-3939). */
|
|
32
|
+
checkpoints?: import('./session/types.js').SessionCheckpoint[];
|
|
33
|
+
/** Daemon-computed detailed checklist (PHNX-3939). */
|
|
34
|
+
summaryChecklist?: import('./session/types.js').SessionChecklistItem[];
|
|
35
|
+
/** Lifecycle of the daemon-computed summary (PHNX-3939). */
|
|
36
|
+
summaryState?: import('./session/types.js').SummaryState;
|
|
29
37
|
capturedAt: number;
|
|
30
38
|
}
|
|
31
39
|
export interface FleetSharedDeviceState {
|
package/dist/lib/heal.d.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* what is actually present/valid in each installed agent home.
|
|
4
4
|
*
|
|
5
5
|
* Powers two callers:
|
|
6
|
-
* - `agents
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* - `agents sync` — the one fixer, via the post-reconcile repair pass
|
|
7
|
+
* (`lib/reconcile-and-repair.ts`, run at the tail of every sync). Mode
|
|
8
|
+
* 'full': fills missing, overwrites drifted content, and refreshes stale
|
|
9
|
+
* plugins even when the baseline is unknown (the user asked to sync).
|
|
9
10
|
* - the routines daemon's periodic safety check — Mode 'safe': fixes only the
|
|
10
11
|
* unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
|
|
11
12
|
* provably-unmodified stale plugins). Drift and risky refreshes are reported,
|
package/dist/lib/heal.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* what is actually present/valid in each installed agent home.
|
|
4
4
|
*
|
|
5
5
|
* Powers two callers:
|
|
6
|
-
* - `agents
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* - `agents sync` — the one fixer, via the post-reconcile repair pass
|
|
7
|
+
* (`lib/reconcile-and-repair.ts`, run at the tail of every sync). Mode
|
|
8
|
+
* 'full': fills missing, overwrites drifted content, and refreshes stale
|
|
9
|
+
* plugins even when the baseline is unknown (the user asked to sync).
|
|
9
10
|
* - the routines daemon's periodic safety check — Mode 'safe': fixes only the
|
|
10
11
|
* unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
|
|
11
12
|
* provably-unmodified stale plugins). Drift and risky refreshes are reported,
|
|
@@ -86,7 +87,7 @@ export function notifyHeal(r) {
|
|
|
86
87
|
? `${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
|
|
87
88
|
: 'agents: auto-healed config gaps';
|
|
88
89
|
const body = needsAttention > 0
|
|
89
|
-
? `${summarizeHeal(r)}. Run: agents
|
|
90
|
+
? `${summarizeHeal(r)}. Run: agents sync`
|
|
90
91
|
: summarizeHeal(r);
|
|
91
92
|
notifyDesktop({ title, body, action: 'routines:list' });
|
|
92
93
|
}
|
|
@@ -109,7 +109,7 @@ export interface ViewAgentAccountEligibility {
|
|
|
109
109
|
* older remote CLI omits `launchable`, so it falls back to `signedIn` — the
|
|
110
110
|
* pre-fix behavior, so a rolling fleet does not regress.
|
|
111
111
|
*/
|
|
112
|
-
export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
|
|
112
|
+
export declare function viewAgentAccountEligibility(view: string, agent: string, now?: number): ViewAgentAccountEligibility;
|
|
113
113
|
export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
|
|
114
114
|
export interface EnsureReadyOptions {
|
|
115
115
|
agent: string;
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -12,7 +12,8 @@ import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
|
12
12
|
import { hostIdentityArgs, sshTargetFor } from './types.js';
|
|
13
13
|
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
|
|
14
14
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
15
|
-
import { isDeadVerdict } from '../auth-health.js';
|
|
15
|
+
import { AUTH_PROBE_MAX_AGE_MS, isDeadVerdict } from '../auth-health.js';
|
|
16
|
+
import { USAGE_STALE_REFUSAL_MAX_AGE_MS } from '../accounting/rotate.js';
|
|
16
17
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
17
18
|
export function localCliVersion() {
|
|
18
19
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -222,7 +223,7 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
|
|
|
222
223
|
* older remote CLI omits `launchable`, so it falls back to `signedIn` — the
|
|
223
224
|
* pre-fix behavior, so a rolling fleet does not regress.
|
|
224
225
|
*/
|
|
225
|
-
export function viewAgentAccountEligibility(view, agent) {
|
|
226
|
+
export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
|
|
226
227
|
try {
|
|
227
228
|
const rows = JSON.parse(view);
|
|
228
229
|
const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
|
|
@@ -234,11 +235,22 @@ export function viewAgentAccountEligibility(view, agent) {
|
|
|
234
235
|
// Prefer the strict per-version launch signal; fall back to the display
|
|
235
236
|
// `signedIn` for an older remote CLI that does not emit `launchable`.
|
|
236
237
|
const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
|
|
237
|
-
const
|
|
238
|
+
const usageCapturedAt = version.usageCapturedAt ? Date.parse(version.usageCapturedAt) : Number.NaN;
|
|
239
|
+
const usageFresh = Number.isFinite(usageCapturedAt)
|
|
240
|
+
? now - usageCapturedAt <= USAGE_STALE_REFUSAL_MAX_AGE_MS
|
|
241
|
+
: version.usageCapturedAt === undefined;
|
|
242
|
+
const authFresh = typeof version.authCheckedAt === 'number'
|
|
243
|
+
? now - version.authCheckedAt <= AUTH_PROBE_MAX_AGE_MS
|
|
244
|
+
: version.authCheckedAt === undefined;
|
|
245
|
+
const throttled = usageFresh
|
|
246
|
+
&& (version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits');
|
|
238
247
|
const authBlocked = version.authVerdict !== null
|
|
239
248
|
&& version.authVerdict !== undefined
|
|
249
|
+
&& authFresh
|
|
240
250
|
&& isDeadVerdict(version.authVerdict);
|
|
241
|
-
const
|
|
251
|
+
const freshnessKnown = version.usageCapturedAt !== undefined || version.authCheckedAt !== undefined;
|
|
252
|
+
const fresh = !freshnessKnown || (usageFresh && authFresh);
|
|
253
|
+
const ready = launchable && fresh && !authBlocked && !throttled;
|
|
242
254
|
return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
|
|
243
255
|
});
|
|
244
256
|
if (verdicts.length === 0)
|
|
@@ -81,7 +81,9 @@ export function formatDuration(ms) {
|
|
|
81
81
|
export function reconnectNotice(target, host, attempt, waitMs, remainingMs) {
|
|
82
82
|
const secs = Math.round(waitMs / 1000);
|
|
83
83
|
const when = secs <= 1 ? 'now' : `in ${secs}s`;
|
|
84
|
-
|
|
84
|
+
// A tmux-wrapped run is still live; a bare run was SIGHUPed and focus will
|
|
85
|
+
// resume it from disk. This wording is truthful for either peer configuration.
|
|
86
|
+
return `\nConnection to ${host} dropped — reconnecting to ${targetLabel(target)}.`
|
|
85
87
|
+ `\n Reconnecting ${when} · ${formatDuration(remainingMs)} left · attempt ${attempt} · Ctrl-C to stop\n`;
|
|
86
88
|
}
|
|
87
89
|
/** Notice shown once the retry budget is spent on an unreachable host. */
|
|
@@ -99,7 +101,7 @@ export function remoteExitNotice(target, host) {
|
|
|
99
101
|
}
|
|
100
102
|
/** Notice shown when the user stops the wait with Ctrl-C. */
|
|
101
103
|
export function interruptedNotice(target, host) {
|
|
102
|
-
return `\nStopped reconnecting
|
|
104
|
+
return `\nStopped reconnecting to ${targetLabel(target)} on ${host}. Recover it when the link is stable:\n${recoveryHint(target, host)}`;
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
105
107
|
* Wrap `cmd` in `bash -lc` with an exit-code remap: 255 becomes
|
|
@@ -30,6 +30,12 @@ export interface PhoenixSession {
|
|
|
30
30
|
access_token: string;
|
|
31
31
|
email?: string;
|
|
32
32
|
userId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Hosted OAuth profile image (https URL), when Phoenix ID exposes one. Wins
|
|
35
|
+
* over the email-Gravatar fallback in share attribution. Optional forever —
|
|
36
|
+
* a missing value just means the Gravatar/initials fallback.
|
|
37
|
+
*/
|
|
38
|
+
avatarUrl?: string;
|
|
33
39
|
/** Unix ms; absent means the server did not scope the token's lifetime. */
|
|
34
40
|
expires_at?: number;
|
|
35
41
|
}
|
|
@@ -16,6 +16,8 @@ export interface WhoAmI {
|
|
|
16
16
|
userId: string;
|
|
17
17
|
email: string;
|
|
18
18
|
valid: true;
|
|
19
|
+
/** Hosted OAuth profile image, when Phoenix ID stores one for this user. */
|
|
20
|
+
avatar_url?: string;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* RFC 8628 poll outcomes. `pending` and `slow_down` are normal states of a
|
|
@@ -28,6 +30,10 @@ export type DevicePoll = {
|
|
|
28
30
|
user: {
|
|
29
31
|
email: string;
|
|
30
32
|
id: string;
|
|
33
|
+
/** Hosted OAuth profile image, when the provider exposed one to Phoenix ID. */
|
|
34
|
+
avatar_url?: string;
|
|
35
|
+
/** Google-style alias some providers use for the same field. */
|
|
36
|
+
picture?: string;
|
|
31
37
|
};
|
|
32
38
|
} | {
|
|
33
39
|
status: 'pending';
|
|
@@ -41,6 +47,16 @@ export type DevicePoll = {
|
|
|
41
47
|
export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>;
|
|
42
48
|
export declare function pollDeviceToken(deviceCode: string): Promise<DevicePoll>;
|
|
43
49
|
export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
|
|
50
|
+
/**
|
|
51
|
+
* Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
|
|
52
|
+
* before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
|
|
53
|
+
* a hosted `avatar_url` into the persisted session — future publishes then
|
|
54
|
+
* stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
|
|
55
|
+
* signed out, when an avatar is already stored, or when the server exposes
|
|
56
|
+
* none; network/server failures are swallowed (the share attribution falls
|
|
57
|
+
* back to Gravatar/initials either way).
|
|
58
|
+
*/
|
|
59
|
+
export declare function refreshSessionAvatar(): Promise<void>;
|
|
44
60
|
export interface SpaceSummary {
|
|
45
61
|
id: string;
|
|
46
62
|
slug: string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Phoenix ID — the typed surface commands use. Every route the account backend
|
|
3
3
|
* exposes is a function here; no command builds a URL or reads a token itself.
|
|
4
4
|
*/
|
|
5
|
-
import { phoenixRequest, PhoenixApiError } from './client.js';
|
|
5
|
+
import { phoenixRequest, PhoenixApiError, readSession, writeSession } from './client.js';
|
|
6
6
|
export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, } from './client.js';
|
|
7
7
|
export function startDeviceAuthorization() {
|
|
8
8
|
return phoenixRequest('POST', '/api/v1/auth/device/authorization', {
|
|
@@ -38,6 +38,30 @@ export async function pollDeviceToken(deviceCode) {
|
|
|
38
38
|
export function fetchWhoAmI(token) {
|
|
39
39
|
return phoenixRequest('GET', '/api/v1/auth/me', { token });
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
|
|
43
|
+
* before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
|
|
44
|
+
* a hosted `avatar_url` into the persisted session — future publishes then
|
|
45
|
+
* stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
|
|
46
|
+
* signed out, when an avatar is already stored, or when the server exposes
|
|
47
|
+
* none; network/server failures are swallowed (the share attribution falls
|
|
48
|
+
* back to Gravatar/initials either way).
|
|
49
|
+
*/
|
|
50
|
+
export async function refreshSessionAvatar() {
|
|
51
|
+
const session = readSession();
|
|
52
|
+
if (!session || session.avatarUrl)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
const me = await fetchWhoAmI();
|
|
56
|
+
const hosted = me.avatar_url?.trim();
|
|
57
|
+
if (hosted && /^https:\/\//i.test(hosted)) {
|
|
58
|
+
writeSession({ ...session, avatarUrl: hosted });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Offline or server without the field — the Gravatar fallback covers it.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
41
65
|
export const listSpaces = () => phoenixRequest('GET', '/api/v1/spaces');
|
|
42
66
|
export const createSpace = (input) => phoenixRequest('POST', '/api/v1/spaces', { body: input });
|
|
43
67
|
export const getSpace = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}`);
|
package/dist/lib/profiles.d.ts
CHANGED
|
@@ -177,6 +177,8 @@ export interface HostModelOptions {
|
|
|
177
177
|
export declare function profileFromHostModel(name: string, host: AgentId, model: string, opts?: HostModelOptions): Profile;
|
|
178
178
|
/** Overrides applied on top of the source when forking a harness. */
|
|
179
179
|
export interface ForkProfileOptions {
|
|
180
|
+
/** Translate the fork onto a different native harness host. */
|
|
181
|
+
host?: AgentId;
|
|
180
182
|
/** Swap the pinned model. Written onto the source's model env key when it has
|
|
181
183
|
* one, else onto the host's canonical model var. */
|
|
182
184
|
model?: string;
|
package/dist/lib/profiles.js
CHANGED
|
@@ -404,31 +404,50 @@ export function profileFromHostModel(name, host, model, opts = {}) {
|
|
|
404
404
|
*/
|
|
405
405
|
export function forkProfile(source, name, opts = {}) {
|
|
406
406
|
validateProfileName(name);
|
|
407
|
-
const
|
|
407
|
+
const sourceHost = source.host.agent;
|
|
408
|
+
const host = opts.host ?? sourceHost;
|
|
408
409
|
const env = { ...source.env };
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
const sourceModelKey = profileModelEnvKey(source) ?? modelEnvKeyForHost(sourceHost);
|
|
411
|
+
const targetModelKey = modelEnvKeyForHost(host);
|
|
412
|
+
const model = opts.model ?? env[sourceModelKey];
|
|
413
|
+
if (host !== sourceHost && sourceModelKey !== targetModelKey)
|
|
414
|
+
delete env[sourceModelKey];
|
|
415
|
+
if (model) {
|
|
416
|
+
env[targetModelKey] = model;
|
|
411
417
|
}
|
|
412
|
-
|
|
413
|
-
|
|
418
|
+
const sourceBaseKey = baseUrlEnvKeyForHost(sourceHost);
|
|
419
|
+
const targetBaseKey = baseUrlEnvKeyForHost(host);
|
|
420
|
+
const baseUrl = opts.baseUrl ?? (sourceBaseKey ? env[sourceBaseKey] : undefined);
|
|
421
|
+
if (host !== sourceHost && sourceBaseKey && sourceBaseKey !== targetBaseKey)
|
|
422
|
+
delete env[sourceBaseKey];
|
|
423
|
+
if (baseUrl) {
|
|
424
|
+
const key = targetBaseKey;
|
|
414
425
|
if (!key) {
|
|
415
426
|
throw new Error(`Host '${host}' has no known base-URL env var; drop --base-url or fork onto a claude/codex host.`);
|
|
416
427
|
}
|
|
417
|
-
env[key] =
|
|
428
|
+
env[key] = baseUrl;
|
|
418
429
|
}
|
|
419
430
|
const forked = {
|
|
420
431
|
...source,
|
|
421
432
|
name,
|
|
422
|
-
host: { agent: host, ...(opts.version ? { version: opts.version } : source.host.version ? { version: source.host.version } : {}) },
|
|
433
|
+
host: { agent: host, ...(opts.version ? { version: opts.version } : host === sourceHost && source.host.version ? { version: source.host.version } : {}) },
|
|
423
434
|
env,
|
|
424
435
|
// The source's description names the source's model, so inheriting it
|
|
425
436
|
// across a model swap would describe the fork wrongly.
|
|
426
|
-
description: opts.description ?? (opts.model
|
|
437
|
+
description: opts.description ?? (opts.model || host !== sourceHost
|
|
438
|
+
? `Forked from ${source.name}: ${model ?? host}`
|
|
439
|
+
: source.description),
|
|
427
440
|
forkedFrom: source.name,
|
|
428
441
|
};
|
|
442
|
+
if (forked.auth && host !== sourceHost) {
|
|
443
|
+
const envVar = authEnvKeyForHost(host);
|
|
444
|
+
if (!envVar)
|
|
445
|
+
throw new Error(`Host '${host}' has no known auth env var; the source auth binding cannot be translated.`);
|
|
446
|
+
forked.auth = { ...forked.auth, envVar };
|
|
447
|
+
}
|
|
429
448
|
// A fork that repoints the model or endpoint is no longer that preset — keep
|
|
430
449
|
// the preset link only while the fork still matches what the preset defines.
|
|
431
|
-
if (opts.model || opts.baseUrl)
|
|
450
|
+
if (opts.model || opts.baseUrl || host !== sourceHost)
|
|
432
451
|
delete forked.preset;
|
|
433
452
|
if (opts.provider) {
|
|
434
453
|
const envVar = opts.authEnvVar ?? source.auth?.envVar ?? authEnvKeyForHost(host);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-reconcile repair — the shared "make it whole" pass `agents sync` runs
|
|
3
|
+
* after its prune+write, so sync is a true superset of the old `doctor --fix`.
|
|
4
|
+
*
|
|
5
|
+
* `syncResourcesToVersion` reconciles resource FILES (and, with a caller
|
|
6
|
+
* selection, prunes orphans). It does NOT: fill live-home gaps the staleness
|
|
7
|
+
* manifest can't see, generate/repair the managed hook runtime shims, or re-wire
|
|
8
|
+
* a present-but-unwired hook into settings.json. `doctor --fix` used to do all of
|
|
9
|
+
* that; now sync does, by calling this one routine at the tail of every reconcile.
|
|
10
|
+
* (The destructive stale-CLI purge `doctor --fix` also ran is NOT automatic here
|
|
11
|
+
* — it runs only via the explicit `agents sync --prune-clis`, see `pruneClis`.)
|
|
12
|
+
*
|
|
13
|
+
* Ordering matters: sync's own prune+write happens FIRST (unchanged), THEN this
|
|
14
|
+
* repair pass. Prune stays sync's job; heal only FILLS and FIXES (never deletes),
|
|
15
|
+
* so the two never fight. Pure of stdout — returns a structured report;
|
|
16
|
+
* `renderRepairAfterSync` prints it only when the caller is neither `--json` nor
|
|
17
|
+
* `--quiet`.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentId } from './types.js';
|
|
20
|
+
import { type HealResult } from './heal.js';
|
|
21
|
+
import { type HookRuntimeRepairReport } from './hooks/install.js';
|
|
22
|
+
import { type RemediateStaleInstallsResult, type FindAgentsCliInstallsOptions } from './self-update.js';
|
|
23
|
+
export interface HookRewireResult {
|
|
24
|
+
agent: AgentId;
|
|
25
|
+
version: string;
|
|
26
|
+
/** Hooks newly wired into settings.json by this pass. */
|
|
27
|
+
rewired: number;
|
|
28
|
+
/** Hooks still unwired after re-registering (source/home mismatch). */
|
|
29
|
+
remaining: number;
|
|
30
|
+
/** Stable failure class; never expose a generated temporary shim pathname. */
|
|
31
|
+
failure?: 'register-failed';
|
|
32
|
+
}
|
|
33
|
+
export interface RepairAfterSyncReport {
|
|
34
|
+
heal: HealResult;
|
|
35
|
+
hookRewire: HookRewireResult[];
|
|
36
|
+
hookRuntimeRepair: HookRuntimeRepairReport;
|
|
37
|
+
/** Only populated on the umbrella (no-agent) path; null otherwise. */
|
|
38
|
+
staleInstallPurge: RemediateStaleInstallsResult | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Test/override hook that scopes the destructive stale-CLI purge to injected
|
|
42
|
+
* paths so it NEVER touches real machine installs. `findAgentsCliInstalls`
|
|
43
|
+
* hard-codes `/usr/local/lib/node_modules` et al. when `globalNodeModulesDirs` is
|
|
44
|
+
* absent, so a purge is unsafe to run unscoped in a test — inject sandbox paths.
|
|
45
|
+
*/
|
|
46
|
+
export interface PurgeInjection {
|
|
47
|
+
runningRoot?: string;
|
|
48
|
+
runningVersion?: string;
|
|
49
|
+
pathEnv?: string;
|
|
50
|
+
findOpts?: FindAgentsCliInstallsOptions;
|
|
51
|
+
dryRun?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface RepairAfterSyncOptions {
|
|
54
|
+
/** Scope the repair to one agent; omit for the umbrella sweep across all
|
|
55
|
+
* installed agents. (The purge is NOT tied to this — see `pruneClis`.) */
|
|
56
|
+
agent?: AgentId;
|
|
57
|
+
/** The versions of `agent` the sync just reconciled. Passing them scopes
|
|
58
|
+
* heal + rewire + runtime-repair to exactly those homes; omitting lets heal
|
|
59
|
+
* apply its own non-isolated sweep. */
|
|
60
|
+
versions?: string[];
|
|
61
|
+
/** Resolution cwd for heal's diff. Defaults to heal's neutral home dir. */
|
|
62
|
+
cwd?: string;
|
|
63
|
+
/** Run the DESTRUCTIVE machine-wide stale-CLI purge (`fs.rmSync`s other
|
|
64
|
+
* agents-cli installs). Default false — the purge is NEVER automatic; it runs
|
|
65
|
+
* only on the umbrella (no-agent) path AND only when the caller passed
|
|
66
|
+
* `agents sync --prune-clis`. */
|
|
67
|
+
pruneClis?: boolean;
|
|
68
|
+
/** Scope the purge scan+delete to injected paths (tests only). */
|
|
69
|
+
purgeInjection?: PurgeInjection;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run the post-reconcile repair pass over the agents/versions a sync just
|
|
73
|
+
* touched: heal live-home gaps (mode 'full', matching the old `doctor --fix`),
|
|
74
|
+
* re-wire hooks the diff-driven heal leaves behind, run one bounded managed-hook
|
|
75
|
+
* runtime repair, and — ONLY when `pruneClis` is set on the umbrella (no-agent)
|
|
76
|
+
* path — purge stale/legacy agents-cli copies. The purge is never automatic.
|
|
77
|
+
* Returns a full account; never writes to stdout.
|
|
78
|
+
*/
|
|
79
|
+
export declare function repairAfterSync(opts: RepairAfterSyncOptions): Promise<RepairAfterSyncReport>;
|
|
80
|
+
/** True when the repair pass changed (or attempted to change) anything. */
|
|
81
|
+
export declare function repairChangedAnything(report: RepairAfterSyncReport): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* True when the RECONCILE repair left a per-version problem a human must fix — an
|
|
84
|
+
* unresolvable managed hook runtime shim, or a hook that could not be re-wired.
|
|
85
|
+
* These gate a sync's `ok`/exit code (the yosemite-s1 class the old
|
|
86
|
+
* `doctor --fix` surfaced).
|
|
87
|
+
*
|
|
88
|
+
* The machine-wide stale-CLI purge is deliberately EXCLUDED: a purge that could
|
|
89
|
+
* not delete a system-wide install (typically `EACCES` on `/usr/local/...`, which
|
|
90
|
+
* needs sudo) is a hygiene issue unrelated to whether the reconcile succeeded, so
|
|
91
|
+
* it must not flip a fleet peer's `agents sync --json` to `ok: false`. Purge
|
|
92
|
+
* failures stay fully visible — in the JSON `repair.staleInstallPurge.failed` and
|
|
93
|
+
* the rendered `hold`/`manual` lines.
|
|
94
|
+
*/
|
|
95
|
+
export declare function repairHadFailures(report: RepairAfterSyncReport): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Serialize the repair pass for a `--json` sync payload — the machine surface the
|
|
98
|
+
* deleted `doctor --fix --json` used to carry (heal detail + hook rewire + hook
|
|
99
|
+
* runtime repair + the umbrella stale-CLI purge). Attached under the `repair` key
|
|
100
|
+
* so fleet fan-out sees what the reconcile fixed and whether anything still needs
|
|
101
|
+
* a human (mirror `repairHadFailures`).
|
|
102
|
+
*/
|
|
103
|
+
export declare function repairAfterSyncJson(report: RepairAfterSyncReport): Record<string, unknown>;
|
|
104
|
+
/**
|
|
105
|
+
* Print the repair pass's human-readable detail. Sync calls this only on the
|
|
106
|
+
* interactive / non-json path; the header is emitted only when something actually
|
|
107
|
+
* changed, so a clean sync stays quiet.
|
|
108
|
+
*/
|
|
109
|
+
export declare function renderRepairAfterSync(report: RepairAfterSyncReport, log?: (s: string) => void): void;
|