@phnx-labs/agents-cli 1.22.75 → 1.22.77
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 +121 -0
- package/README.md +20 -8
- 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/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/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/registry.js +10 -3
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/config-keys.d.ts +7 -2
- package/dist/lib/config-keys.js +17 -2
- package/dist/lib/daemon/daemon.js +8 -0
- 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-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/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-state.d.ts +8 -0
- package/dist/lib/git.js +6 -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/installations/migrate.d.ts +10 -0
- package/dist/lib/installations/migrate.js +18 -0
- package/dist/lib/profiles.d.ts +2 -0
- package/dist/lib/profiles.js +28 -9
- package/dist/lib/project-resources.d.ts +23 -0
- package/dist/lib/project-resources.js +94 -0
- 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/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 +58 -1
- package/dist/lib/state.js +138 -16
- 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
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)}`);
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
export { foldLegacySystemRepo } from '../migrate-fold.js';
|
|
3
3
|
/** Migrate a legacy single-repo agents.yaml into ~/.agents/agents.yaml. Leaves the tracked system-mirror copy alone to avoid dirtying the npm-shipped defaults. */
|
|
4
4
|
export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Stop tracking the user repo's CHANGELOG.md. It duplicates the npm-shipped
|
|
7
|
+
* `.system/CHANGELOG.md` (read the canonical copy from there), and as a tracked
|
|
8
|
+
* file that upstream never carries it can only ever show as `M`/`??`
|
|
9
|
+
* — dirtying the tree and, when a peer's publish commit arrives, tripping the
|
|
10
|
+
* dirty-tree pull guard. De-track it into `.git/info/exclude` (per-clone,
|
|
11
|
+
* uncommitted; never `.gitignore`, which would re-introduce the same failure).
|
|
12
|
+
* Idempotent: a no-op once the path is untracked and excluded.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detrackUserChangelog(userDir?: string): void;
|
|
5
15
|
/** Fold the legacy global browser/sessions/<task>/ tree into the per-profile browser/<profile>/sessions/<task>/ layout. Unclaimed tasks move under `_legacy`. */
|
|
6
16
|
export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): void;
|
|
7
17
|
/** Repair node_modules/.bin/<cli> symlinks that resolve back into our own shims dir (infinite exec-loop fix). */
|
|
@@ -16,6 +16,7 @@ import { setConfigValue } from '../device-config.js';
|
|
|
16
16
|
import { enabledRoutineNames, replaceEnabledRoutines } from '../routine-activation.js';
|
|
17
17
|
import { evaluateActivationReadiness } from '../routine-readiness.js';
|
|
18
18
|
import { migrateDeviceConfigStores } from '../devices/config-migration.js';
|
|
19
|
+
import { detrackViaGitExclude } from '../project-resources.js';
|
|
19
20
|
// Two constants only, never the read/write API — migrations still operate on raw
|
|
20
21
|
// YAML so they never take the meta lock or prime the meta cache mid-migration.
|
|
21
22
|
import { LEGACY_DEFAULT_BROWSER_PROFILE_NAME } from '../browser/profiles.js';
|
|
@@ -70,6 +71,22 @@ function deleteSystemPromptsJson() {
|
|
|
70
71
|
}
|
|
71
72
|
catch { /* best-effort */ }
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop tracking the user repo's CHANGELOG.md. It duplicates the npm-shipped
|
|
76
|
+
* `.system/CHANGELOG.md` (read the canonical copy from there), and as a tracked
|
|
77
|
+
* file that upstream never carries it can only ever show as `M`/`??`
|
|
78
|
+
* — dirtying the tree and, when a peer's publish commit arrives, tripping the
|
|
79
|
+
* dirty-tree pull guard. De-track it into `.git/info/exclude` (per-clone,
|
|
80
|
+
* uncommitted; never `.gitignore`, which would re-introduce the same failure).
|
|
81
|
+
* Idempotent: a no-op once the path is untracked and excluded.
|
|
82
|
+
*/
|
|
83
|
+
export function detrackUserChangelog(userDir = USER_DIR) {
|
|
84
|
+
if (!fs.existsSync(path.join(userDir, '.git')))
|
|
85
|
+
return; // plain dir, not a clone
|
|
86
|
+
const untracked = detrackViaGitExclude(userDir, 'CHANGELOG.md');
|
|
87
|
+
if (untracked)
|
|
88
|
+
console.error('Stopped tracking ~/.agents/CHANGELOG.md (duplicates .system/CHANGELOG.md)');
|
|
89
|
+
}
|
|
73
90
|
/** Delete the legacy ~/.agents-system/config.json (dead teams agent registry). */
|
|
74
91
|
function migrateSystemConfigJson() {
|
|
75
92
|
const src = path.join(SYSTEM_DIR, 'config.json');
|
|
@@ -2242,6 +2259,7 @@ export async function runMigration() {
|
|
|
2242
2259
|
migrateHumans();
|
|
2243
2260
|
deleteSystemPromptsJson();
|
|
2244
2261
|
migrateSystemConfigJson();
|
|
2262
|
+
detrackUserChangelog();
|
|
2245
2263
|
migratePromptcutsIntoHooks();
|
|
2246
2264
|
migrateSystemVersionsToUser();
|
|
2247
2265
|
mergeOverlappingVersionHomes();
|
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);
|
|
@@ -21,6 +21,29 @@ export declare function syncProjectResourcesToAgent(agent: AgentId, version: str
|
|
|
21
21
|
* commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
|
|
22
22
|
*/
|
|
23
23
|
export declare function managedGitignoreEntries(agentRoot: string, referenceRoot: string, managed: string[]): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Stop git tracking `relPath` in the DotAgents clone at `repoDir` and keep it
|
|
26
|
+
* locally ignored — the PHNX-3718 pattern, applied to a shared config file the
|
|
27
|
+
* user repo should no longer carry (e.g. the duplicated CHANGELOG.md; the
|
|
28
|
+
* canonical copy ships in `.system/`).
|
|
29
|
+
*
|
|
30
|
+
* The ignore entry goes in `.git/info/exclude`, NOT `.gitignore`: a tracked
|
|
31
|
+
* `.gitignore` block would itself show as `M .gitignore` on every clone and
|
|
32
|
+
* reintroduce the exact dirty-tree-blocks-pull failure this whole effort exists
|
|
33
|
+
* to kill. info/exclude is per-clone and never committed, so the working tree
|
|
34
|
+
* stays clean at rest.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent and convergent: when the path is still tracked it is removed from
|
|
37
|
+
* the index (the working file is kept) AND that removal is committed — a bare
|
|
38
|
+
* `git rm --cached` would leave a staged deletion, which is itself a dirty tree
|
|
39
|
+
* that re-arms the very pull trip this exists to prevent. The single removal
|
|
40
|
+
* commit also propagates the de-track fleet-wide: peers pull it and converge,
|
|
41
|
+
* their next run finding nothing to untrack. The exclude block is rewritten in
|
|
42
|
+
* place, unioning `relPath` with any entries already there, so a second run is a
|
|
43
|
+
* no-op. Fails open outside a git repo. Returns whether the path was untracked
|
|
44
|
+
* by this call (a signal for the caller's one-time log).
|
|
45
|
+
*/
|
|
46
|
+
export declare function detrackViaGitExclude(repoDir: string, relPath: string): boolean;
|
|
24
47
|
/**
|
|
25
48
|
* One human line for the files a project sync left alone because you already
|
|
26
49
|
* wrote them. This is the normal steady state — every sync of a project whose
|
|
@@ -284,6 +284,100 @@ function stripLegacyManagedGitignoreBlock(projectRoot, agent) {
|
|
|
284
284
|
fs.writeFileSync(tmp, stripped);
|
|
285
285
|
fs.renameSync(tmp, gitignorePath);
|
|
286
286
|
}
|
|
287
|
+
const DETRACK_BEGIN = '# BEGIN agents-cli detracked (managed)';
|
|
288
|
+
const DETRACK_END = '# END agents-cli detracked (managed)';
|
|
289
|
+
/**
|
|
290
|
+
* Stop git tracking `relPath` in the DotAgents clone at `repoDir` and keep it
|
|
291
|
+
* locally ignored — the PHNX-3718 pattern, applied to a shared config file the
|
|
292
|
+
* user repo should no longer carry (e.g. the duplicated CHANGELOG.md; the
|
|
293
|
+
* canonical copy ships in `.system/`).
|
|
294
|
+
*
|
|
295
|
+
* The ignore entry goes in `.git/info/exclude`, NOT `.gitignore`: a tracked
|
|
296
|
+
* `.gitignore` block would itself show as `M .gitignore` on every clone and
|
|
297
|
+
* reintroduce the exact dirty-tree-blocks-pull failure this whole effort exists
|
|
298
|
+
* to kill. info/exclude is per-clone and never committed, so the working tree
|
|
299
|
+
* stays clean at rest.
|
|
300
|
+
*
|
|
301
|
+
* Idempotent and convergent: when the path is still tracked it is removed from
|
|
302
|
+
* the index (the working file is kept) AND that removal is committed — a bare
|
|
303
|
+
* `git rm --cached` would leave a staged deletion, which is itself a dirty tree
|
|
304
|
+
* that re-arms the very pull trip this exists to prevent. The single removal
|
|
305
|
+
* commit also propagates the de-track fleet-wide: peers pull it and converge,
|
|
306
|
+
* their next run finding nothing to untrack. The exclude block is rewritten in
|
|
307
|
+
* place, unioning `relPath` with any entries already there, so a second run is a
|
|
308
|
+
* no-op. Fails open outside a git repo. Returns whether the path was untracked
|
|
309
|
+
* by this call (a signal for the caller's one-time log).
|
|
310
|
+
*/
|
|
311
|
+
export function detrackViaGitExclude(repoDir, relPath) {
|
|
312
|
+
const target = resolveGitExcludeTarget(repoDir);
|
|
313
|
+
if (!target)
|
|
314
|
+
return false; // not a git repo — nothing to de-track or ignore.
|
|
315
|
+
let untrackedNow = false;
|
|
316
|
+
const abs = path.join(repoDir, relPath);
|
|
317
|
+
if (isTrackedByGit(repoDir, abs)) {
|
|
318
|
+
try {
|
|
319
|
+
// Unstage anything else first so the removal commit records ONLY this
|
|
320
|
+
// path's deletion — a mixed reset (index only, worktree untouched). At
|
|
321
|
+
// migration time the index is already clean; this just makes the scope
|
|
322
|
+
// guaranteed rather than assumed.
|
|
323
|
+
execFileSync('git', ['-C', repoDir, 'reset', '-q'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
324
|
+
// --cached keeps the working file; the commit records the removal so the
|
|
325
|
+
// tree is clean at rest and the de-track converges across the fleet. A
|
|
326
|
+
// pathspec commit would re-read the still-present worktree file and undo
|
|
327
|
+
// the removal, so the commit takes the staged index (just this deletion).
|
|
328
|
+
execFileSync('git', ['-C', repoDir, 'rm', '--cached', '--quiet', '--', relPath], {
|
|
329
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
330
|
+
});
|
|
331
|
+
execFileSync('git', ['-C', repoDir, '-c', 'commit.gpgsign=false', 'commit', '--no-verify',
|
|
332
|
+
'-m', `chore(config): stop tracking ${relPath}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
333
|
+
untrackedNow = true;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// rm/commit can fail (e.g. mid-rebase, index.lock); the ignore entry below
|
|
337
|
+
// still lands and the next migration run retries the untrack. Fail open —
|
|
338
|
+
// but roll back a staged-but-uncommitted removal so we never LEAVE a dirty
|
|
339
|
+
// staged deletion behind (which would re-arm the pull trip).
|
|
340
|
+
try {
|
|
341
|
+
execFileSync('git', ['-C', repoDir, 'reset', '-q', '--', relPath], {
|
|
342
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch { /* nothing staged to reset */ }
|
|
346
|
+
untrackedNow = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Anchor to the worktree root so the pattern matches only the top-level file,
|
|
350
|
+
// exactly like git anchors a leading-slash info/exclude entry.
|
|
351
|
+
const entry = '/' + relPath.split(path.sep).join('/');
|
|
352
|
+
let original = '';
|
|
353
|
+
try {
|
|
354
|
+
original = fs.readFileSync(target.excludePath, 'utf-8');
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
original = '';
|
|
358
|
+
}
|
|
359
|
+
const existing = extractManagedEntries(original, DETRACK_BEGIN, DETRACK_END);
|
|
360
|
+
const entries = existing.includes(entry) ? existing : [...existing, entry].sort();
|
|
361
|
+
const next = applyManagedBlock(original, DETRACK_BEGIN, DETRACK_END, entries);
|
|
362
|
+
if (next !== null && next !== original) {
|
|
363
|
+
fs.mkdirSync(path.dirname(target.excludePath), { recursive: true });
|
|
364
|
+
const tmp = target.excludePath + '.tmp';
|
|
365
|
+
fs.writeFileSync(tmp, next);
|
|
366
|
+
fs.renameSync(tmp, target.excludePath);
|
|
367
|
+
}
|
|
368
|
+
return untrackedNow;
|
|
369
|
+
}
|
|
370
|
+
/** The entries currently inside a `begin`/`end` managed block, or `[]`. */
|
|
371
|
+
function extractManagedEntries(content, begin, end) {
|
|
372
|
+
const lines = content.split('\n');
|
|
373
|
+
const bi = lines.indexOf(begin);
|
|
374
|
+
if (bi === -1)
|
|
375
|
+
return [];
|
|
376
|
+
const ei = lines.indexOf(end, bi + 1);
|
|
377
|
+
if (ei === -1)
|
|
378
|
+
return [];
|
|
379
|
+
return lines.slice(bi + 1, ei).map((l) => l.trim()).filter(Boolean);
|
|
380
|
+
}
|
|
287
381
|
function removeManagedPath(agentRoot, rel) {
|
|
288
382
|
if (path.isAbsolute(rel) || rel.includes('..'))
|
|
289
383
|
return;
|
|
@@ -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;
|