@phnx-labs/agents-cli 1.22.31 → 1.22.33
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 +72 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions-browser.js +2 -2
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +66 -22
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/session/team-filter.d.ts +11 -0
- package/dist/lib/session/team-filter.js +10 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- package/package.json +2 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { formatAgentError, resolveAgentName } from '../lib/agents.js';
|
|
3
|
+
import { setHelpSections } from '../lib/help.js';
|
|
4
|
+
import { describeInstallation, listInstallations, resolveInstallation, selectUpdateStrategy, supportsPinnedUpdate, updateInstallation, } from '../lib/installations/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Split `<agent>[@<selector>]`. The selector names an INSTALLATION — its frozen
|
|
7
|
+
* label, or the release it currently carries — never a release to install; that
|
|
8
|
+
* is `--to`. Keeping them separate is what lets `agents update claude@2.0.65
|
|
9
|
+
* --to 2.0.71` read unambiguously.
|
|
10
|
+
*/
|
|
11
|
+
function parseTarget(raw) {
|
|
12
|
+
const at = raw.indexOf('@');
|
|
13
|
+
const name = at === -1 ? raw : raw.slice(0, at);
|
|
14
|
+
const selector = at === -1 ? undefined : raw.slice(at + 1).trim();
|
|
15
|
+
if (at !== -1 && !selector) {
|
|
16
|
+
throw new Error(`Missing installation in '${raw}'. Use <agent>@<installed-version>, or just <agent>.`);
|
|
17
|
+
}
|
|
18
|
+
const agent = resolveAgentName(name);
|
|
19
|
+
if (!agent)
|
|
20
|
+
throw new Error(formatAgentError(name));
|
|
21
|
+
return { agent, selector };
|
|
22
|
+
}
|
|
23
|
+
function serialize(installation) {
|
|
24
|
+
return {
|
|
25
|
+
id: installation.id,
|
|
26
|
+
agent: installation.agent,
|
|
27
|
+
label: installation.label,
|
|
28
|
+
releaseVersion: installation.releaseVersion,
|
|
29
|
+
createdAt: installation.createdAt,
|
|
30
|
+
updatedAt: installation.updatedAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function printOutcome(outcome, json) {
|
|
34
|
+
if (json) {
|
|
35
|
+
console.log(JSON.stringify({
|
|
36
|
+
installation: serialize(outcome.installation),
|
|
37
|
+
strategy: outcome.strategy,
|
|
38
|
+
fromRelease: outcome.fromRelease,
|
|
39
|
+
toRelease: outcome.toRelease,
|
|
40
|
+
unchanged: outcome.unchanged,
|
|
41
|
+
alsoUpdated: outcome.alsoUpdated.map(serialize),
|
|
42
|
+
}, null, 2));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const name = `${outcome.installation.agent}@${outcome.installation.label}`;
|
|
46
|
+
if (outcome.unchanged) {
|
|
47
|
+
console.log(chalk.gray(`${name} is already on release ${outcome.toRelease}.`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(chalk.green(`Updated ${name}: release ${outcome.fromRelease} -> ${outcome.toRelease}`));
|
|
51
|
+
console.log(chalk.gray(`Its name is unchanged, so every default, project pin, and routine that names ${name} still resolves to it.`));
|
|
52
|
+
for (const other of outcome.alsoUpdated) {
|
|
53
|
+
console.log(chalk.gray(` ${other.agent}@${other.label} shares the same binary and now also reports release ${other.releaseVersion}.`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function printInstallations(agent, json) {
|
|
57
|
+
const installations = listInstallations(agent);
|
|
58
|
+
if (json) {
|
|
59
|
+
console.log(JSON.stringify(installations.map(serialize), null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!installations.length) {
|
|
63
|
+
console.log(chalk.gray(`No managed ${agent} installations. Install one with: agents add ${agent}@latest`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.log(chalk.bold(`${agent} installations\n`));
|
|
67
|
+
for (const installation of installations) {
|
|
68
|
+
console.log(` ${chalk.cyan(installation.label)} release ${installation.releaseVersion} ${chalk.gray(installation.id)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Surface a failure as one red line, not a stack trace. Everything this command
|
|
73
|
+
* can fail on — an unknown agent, an ambiguous selector, an unpinnable harness,
|
|
74
|
+
* a release that would not launch — is a message the user acts on, and a
|
|
75
|
+
* commander async action rejection otherwise reaches the user as a raw Node dump.
|
|
76
|
+
*/
|
|
77
|
+
function fail(err) {
|
|
78
|
+
console.error(chalk.red(err.message));
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
}
|
|
81
|
+
export function registerUpdateCommand(program) {
|
|
82
|
+
const update = program
|
|
83
|
+
.command('update [target]')
|
|
84
|
+
.description('Move a frozen agent installation to a new release, keeping its name and every reference to it')
|
|
85
|
+
.option('--to <release>', 'Release to move to: latest (default), oldest, or an exact version')
|
|
86
|
+
.option('--account <label>', 'Disambiguate by signed-in account when several installations match')
|
|
87
|
+
.option('--json', 'Machine-readable result')
|
|
88
|
+
.action(async (target, options) => {
|
|
89
|
+
try {
|
|
90
|
+
if (!target) {
|
|
91
|
+
throw new Error('Which agent? Use: agents update <agent>[@<installed-version>]');
|
|
92
|
+
}
|
|
93
|
+
const { agent, selector } = parseTarget(target);
|
|
94
|
+
if (options.to && options.to !== 'latest' && !supportsPinnedUpdate(agent)) {
|
|
95
|
+
// Fail loud at the boundary rather than installing the current release
|
|
96
|
+
// and reporting it as the pin that was asked for.
|
|
97
|
+
throw new Error(`${agent} is a single self-updating binary with no pinnable releases — drop --to, or pass --to latest.`);
|
|
98
|
+
}
|
|
99
|
+
const installation = await resolveInstallation(agent, selector, { account: options.account });
|
|
100
|
+
const strategy = selectUpdateStrategy(agent);
|
|
101
|
+
if (!options.json && !strategy.transactional) {
|
|
102
|
+
console.log(chalk.yellow(`${agent} installs one vendor-managed binary, so this update cannot be staged or rolled back; `
|
|
103
|
+
+ `a failure leaves whatever its installer wrote.`));
|
|
104
|
+
}
|
|
105
|
+
if (!options.json) {
|
|
106
|
+
console.log(chalk.gray(`Updating ${agent}@${describeInstallation(installation)} via the ${strategy.id} strategy...`));
|
|
107
|
+
}
|
|
108
|
+
const outcome = await updateInstallation(installation, {
|
|
109
|
+
to: options.to,
|
|
110
|
+
onProgress: options.json ? undefined : (message) => console.log(chalk.gray(` ${message}`)),
|
|
111
|
+
});
|
|
112
|
+
printOutcome(outcome, !!options.json);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
fail(err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
update
|
|
119
|
+
.command('list <agent>')
|
|
120
|
+
.description('Show every frozen installation of an agent and the release each carries')
|
|
121
|
+
.option('--json', 'Machine-readable listing')
|
|
122
|
+
// `--json` is declared on both `update` and `update list`, and commander
|
|
123
|
+
// binds a flag the parent also declares to the PARENT's option store — so
|
|
124
|
+
// reading this subcommand's own opts alone silently drops it. Merge them.
|
|
125
|
+
.action((rawAgent, _options, command) => {
|
|
126
|
+
try {
|
|
127
|
+
const agent = resolveAgentName(rawAgent);
|
|
128
|
+
if (!agent)
|
|
129
|
+
throw new Error(formatAgentError(rawAgent));
|
|
130
|
+
printInstallations(agent, !!command.optsWithGlobals().json);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
fail(err);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
setHelpSections(update, {
|
|
137
|
+
examples: `agents update list claude
|
|
138
|
+
agents update claude@2.0.65
|
|
139
|
+
agents update claude@2.0.65 --to 2.1.220
|
|
140
|
+
agents update claude --account work
|
|
141
|
+
agents update claude@2.0.65 --json`,
|
|
142
|
+
notes: 'An installation keeps its name for life; only the release inside it moves. That is why a default, a project pin, '
|
|
143
|
+
+ 'a routine version, or a profile that names claude@2.0.65 keeps working after you update it. '
|
|
144
|
+
+ 'The selector matches either the installation name or the release it currently carries — when two installations '
|
|
145
|
+
+ 'share a release, name one or pass --account <label> (see: agents accounts). '
|
|
146
|
+
+ 'The new release is fetched and launched before it replaces the working one, so a bad release leaves your agent running.',
|
|
147
|
+
});
|
|
148
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
|
|
|
94
94
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
95
95
|
// first byte of output), the registry maps a command name to a thunk that
|
|
96
96
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
97
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
97
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadUpdate, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
98
98
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
99
99
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
100
100
|
import { getCliLaunch } from './lib/cli-entry.js';
|
|
@@ -292,6 +292,7 @@ Quick start:
|
|
|
292
292
|
Agent versions:
|
|
293
293
|
add <agent>[@version] Install an agent CLI (e.g. agents add grok or agents add codex)
|
|
294
294
|
import <agent> Adopt an existing global install (npm/homebrew) into agents-cli
|
|
295
|
+
update <agent>[@version] Move an installed agent to a new release, keeping its name (agents-cli itself is 'agents upgrade')
|
|
295
296
|
prune <agent>[@version] Uninstall a version
|
|
296
297
|
remove <agent>[@version] Alias for prune
|
|
297
298
|
use <agent>@<version> Set the default version
|
|
@@ -942,6 +943,7 @@ async function registerAllEagerCommands() {
|
|
|
942
943
|
await reg(loadWorkflows);
|
|
943
944
|
await reg(loadWorktree);
|
|
944
945
|
await reg(loadVersions);
|
|
946
|
+
await reg(loadUpdate);
|
|
945
947
|
await reg(loadImport);
|
|
946
948
|
await reg(loadExport);
|
|
947
949
|
await reg(loadPackages);
|
package/dist/lib/catchup.js
CHANGED
|
@@ -139,7 +139,10 @@ export async function runCatchup(opts = {}) {
|
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
try {
|
|
142
|
-
|
|
142
|
+
// No `scheduledFor` here on purpose: the missed slot is already claimed by
|
|
143
|
+
// `claimMissedFire` above (its atomic mkdir IS the catch-up single-fire), so
|
|
144
|
+
// the late run gets a fresh id rather than colliding with the missed record.
|
|
145
|
+
const meta = await executeJobDetached(config, undefined, { kind: 'catchup' });
|
|
143
146
|
outcomes.push({
|
|
144
147
|
name: entry.name,
|
|
145
148
|
expectedAt: entry.expectedAt,
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -273,6 +273,23 @@ export interface DaemonStopResult {
|
|
|
273
273
|
surviving: string[];
|
|
274
274
|
detachedChildren: number[];
|
|
275
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Live `__daemon-run` processes still registered in THIS state dir's instance
|
|
278
|
+
* registry, excluding `exclude`. State-dir-scoped by construction: the registry
|
|
279
|
+
* lives inside this daemon dir, so a daemon serving a DIFFERENT state dir (a test
|
|
280
|
+
* fixture with its own HOME, a separate install/home) registers elsewhere and is
|
|
281
|
+
* invisible here — it is never a stop/takeover target. POSIX-only (the registry
|
|
282
|
+
* and its `ps` liveness probe are); `[]` on Windows.
|
|
283
|
+
*
|
|
284
|
+
* Exported for `agents daemon status`/`doctor`/`services` (RUSH-2368): those
|
|
285
|
+
* commands previously flagged every `__daemon-run` on the box (a raw `ps` scan)
|
|
286
|
+
* as a "duplicate" of this daemon, which misreported test fixtures under their
|
|
287
|
+
* own HOME — and therefore their own state dir and registry — as strays to
|
|
288
|
+
* kill. This registry read is the same scope the reaper (`reapStrayDaemons`)
|
|
289
|
+
* and the stop postcondition (`stopDaemon`) already use, so the display and the
|
|
290
|
+
* reaper agree on what a duplicate is.
|
|
291
|
+
*/
|
|
292
|
+
export declare function findSurvivingStateDirDaemons(exclude: Set<number>): number[];
|
|
276
293
|
/**
|
|
277
294
|
* Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
|
|
278
295
|
* from launchd/systemd if applicable.
|
package/dist/lib/daemon.js
CHANGED
|
@@ -30,6 +30,7 @@ import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from './d
|
|
|
30
30
|
import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
|
|
31
31
|
import { recordSubsystemOk, recordSubsystemError, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC } from './daemon-health.js';
|
|
32
32
|
const PID_FILE = 'daemon.pid';
|
|
33
|
+
const LIFETIME_FILE = 'daemon.lifetime';
|
|
33
34
|
const LOCK_FILE = 'daemon.lock';
|
|
34
35
|
const LOG_FILE = 'logs.jsonl';
|
|
35
36
|
const HEARTBEAT_FILE = 'heartbeat.json';
|
|
@@ -543,6 +544,12 @@ export async function runDaemon() {
|
|
|
543
544
|
// rather than a failure to restart-flap on.
|
|
544
545
|
process.exit(0);
|
|
545
546
|
}
|
|
547
|
+
// Unlike the pid and heartbeat files, this marker is written exactly once
|
|
548
|
+
// for this daemon lifetime. Status probes deliberately repair those other
|
|
549
|
+
// files, so they cannot prove that the original state tree still exists.
|
|
550
|
+
const lifetimePath = path.join(getDaemonDirRoot(), LIFETIME_FILE);
|
|
551
|
+
const lifetimeToken = `${process.pid}:${Date.now()}`;
|
|
552
|
+
fs.writeFileSync(lifetimePath, lifetimeToken, 'utf-8');
|
|
546
553
|
log('INFO', `Daemon started (PID: ${process.pid})`);
|
|
547
554
|
anchorDaemonCwd();
|
|
548
555
|
warnEphemeralDaemonRoot();
|
|
@@ -610,7 +617,7 @@ export async function runDaemon() {
|
|
|
610
617
|
log('WARN', err.message);
|
|
611
618
|
}
|
|
612
619
|
}
|
|
613
|
-
const triggerJob = async (config) => {
|
|
620
|
+
const triggerJob = async (config, ctx) => {
|
|
614
621
|
const jobLabel = config.command
|
|
615
622
|
? 'command'
|
|
616
623
|
: config.workflow
|
|
@@ -644,7 +651,7 @@ export async function runDaemon() {
|
|
|
644
651
|
})
|
|
645
652
|
.catch(() => { });
|
|
646
653
|
},
|
|
647
|
-
});
|
|
654
|
+
}, { kind: 'schedule', scheduledFor: ctx?.scheduledFor });
|
|
648
655
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
649
656
|
}
|
|
650
657
|
catch (err) {
|
|
@@ -847,9 +854,16 @@ export async function runDaemon() {
|
|
|
847
854
|
const runHealCheck = async () => {
|
|
848
855
|
if (healing)
|
|
849
856
|
return;
|
|
857
|
+
// The daemon's state directory is its liveness boundary. Once that tree is
|
|
858
|
+
// removed, background maintenance must not recreate it while the
|
|
859
|
+
// self-terminate guard is shutting the process down.
|
|
860
|
+
if (!fs.existsSync(getDaemonDirRoot()))
|
|
861
|
+
return;
|
|
850
862
|
healing = true;
|
|
851
863
|
try {
|
|
852
864
|
const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('./self-heal/registry.js');
|
|
865
|
+
if (!fs.existsSync(getDaemonDirRoot()))
|
|
866
|
+
return;
|
|
853
867
|
// Background heal is conservative (mode: 'safe'): fixes low-risk drift (shims,
|
|
854
868
|
// symlink adoption, PATH, missing resources) and only reports risky ones. The
|
|
855
869
|
// 30s kickoff means shims/PATH settle shortly after the daemon starts. No
|
|
@@ -925,6 +939,42 @@ export async function runDaemon() {
|
|
|
925
939
|
}
|
|
926
940
|
};
|
|
927
941
|
const keychainReapInterval = setInterval(() => { void runKeychainReap(); }, 5 * 60_000);
|
|
942
|
+
// RUSH-2367: self-terminate if this daemon's own state dir has been removed
|
|
943
|
+
// out from under it — the shape of a leaked test-fixture daemon whose /tmp
|
|
944
|
+
// HOME was deleted by its test's own cleanup while the process itself
|
|
945
|
+
// somehow survived (lost the SIGTERM/SIGKILL race, or outlived a killed
|
|
946
|
+
// test runner before its `finally` ever ran). Nothing else can reach a
|
|
947
|
+
// daemon in that state: no `agents daemon` command targets it, since a
|
|
948
|
+
// different HOME resolves a different getDaemonDir() and therefore a
|
|
949
|
+
// different instance registry — without this it runs forever. Reads
|
|
950
|
+
// Reads the lifetime marker directly, never the local getDaemonDir() wrapper,
|
|
951
|
+
// which recreates the directory as a side effect and would defeat the check.
|
|
952
|
+
// Heartbeat/status paths may recreate the directory and pid file after a
|
|
953
|
+
// deletion; they never recreate this per-lifetime token.
|
|
954
|
+
let checkingStateDir = false;
|
|
955
|
+
const runStateDirSelfCheck = () => {
|
|
956
|
+
if (checkingStateDir)
|
|
957
|
+
return;
|
|
958
|
+
checkingStateDir = true;
|
|
959
|
+
try {
|
|
960
|
+
let markerMatches = false;
|
|
961
|
+
try {
|
|
962
|
+
markerMatches = fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken;
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
// A missing state tree or marker is the condition this guard detects.
|
|
966
|
+
}
|
|
967
|
+
if (!markerMatches) {
|
|
968
|
+
log('WARN', `Daemon state dir ${getDaemonDirRoot()} no longer exists; exiting (self-terminate guard)`);
|
|
969
|
+
void handleShutdown();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
finally {
|
|
973
|
+
checkingStateDir = false;
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const stateDirCheckMs = Number(process.env.AGENTS_DAEMON_STATE_DIR_CHECK_MS) || 60_000;
|
|
977
|
+
const stateDirCheckInterval = setInterval(runStateDirSelfCheck, stateDirCheckMs);
|
|
928
978
|
const handleReload = () => {
|
|
929
979
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
930
980
|
// Refresh user-layer copies of opted-in project routines BEFORE the
|
|
@@ -975,6 +1025,14 @@ export async function runDaemon() {
|
|
|
975
1025
|
clearTimeout(healKickoff);
|
|
976
1026
|
clearInterval(brokerSelfHealInterval);
|
|
977
1027
|
clearInterval(keychainReapInterval);
|
|
1028
|
+
clearInterval(stateDirCheckInterval);
|
|
1029
|
+
try {
|
|
1030
|
+
if (fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken)
|
|
1031
|
+
fs.unlinkSync(lifetimePath);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
// Already removed with the state tree, or replaced by a newer owner.
|
|
1035
|
+
}
|
|
978
1036
|
hostedBroker?.close();
|
|
979
1037
|
removeDaemonPid();
|
|
980
1038
|
removeHeartbeat();
|
|
@@ -1356,8 +1414,16 @@ function waitForPid(timeoutMs) {
|
|
|
1356
1414
|
* fixture with its own HOME, a separate install/home) registers elsewhere and is
|
|
1357
1415
|
* invisible here — it is never a stop/takeover target. POSIX-only (the registry
|
|
1358
1416
|
* and its `ps` liveness probe are); `[]` on Windows.
|
|
1417
|
+
*
|
|
1418
|
+
* Exported for `agents daemon status`/`doctor`/`services` (RUSH-2368): those
|
|
1419
|
+
* commands previously flagged every `__daemon-run` on the box (a raw `ps` scan)
|
|
1420
|
+
* as a "duplicate" of this daemon, which misreported test fixtures under their
|
|
1421
|
+
* own HOME — and therefore their own state dir and registry — as strays to
|
|
1422
|
+
* kill. This registry read is the same scope the reaper (`reapStrayDaemons`)
|
|
1423
|
+
* and the stop postcondition (`stopDaemon`) already use, so the display and the
|
|
1424
|
+
* reaper agree on what a duplicate is.
|
|
1359
1425
|
*/
|
|
1360
|
-
function findSurvivingStateDirDaemons(exclude) {
|
|
1426
|
+
export function findSurvivingStateDirDaemons(exclude) {
|
|
1361
1427
|
if (process.platform === 'win32')
|
|
1362
1428
|
return [];
|
|
1363
1429
|
const dir = getDaemonInstancesDir();
|
|
@@ -6,13 +6,13 @@ import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
|
|
|
6
6
|
import type { SyncStatusRow, OrphanRow } from '../drift.js';
|
|
7
7
|
import type { FetchStatusMarker } from '../auto-pull.js';
|
|
8
8
|
import type { VersionResourceReport } from '../doctor-diff.js';
|
|
9
|
-
import type { FleetDivergence, FleetVersionSignIn } from './fleet-divergence.js';
|
|
9
|
+
import type { FleetDivergence, FleetHookRuntimeState, FleetVersionSignIn } from './fleet-divergence.js';
|
|
10
10
|
export type FindingSeverity = 'critical' | 'warning';
|
|
11
11
|
/** A machine-stable class for a finding — drives {@link remediationFor} and lets
|
|
12
12
|
* the JSON consumer group by kind. */
|
|
13
13
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
14
14
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
15
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
|
|
15
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
|
|
16
16
|
/**
|
|
17
17
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
18
18
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -161,6 +161,11 @@ export declare function collapseAcrossVersions(findings: DoctorFinding[], isolat
|
|
|
161
161
|
* logout provable. Pure.
|
|
162
162
|
*/
|
|
163
163
|
export declare function signInToFindings(device: string, signIn: Record<string, FleetVersionSignIn[]>): DoctorFinding[];
|
|
164
|
+
/**
|
|
165
|
+
* Rebuild remote generated-wrapper findings from the closed enum inventory
|
|
166
|
+
* state. Remote paths and detector messages never cross the fleet boundary.
|
|
167
|
+
*/
|
|
168
|
+
export declare function hookRuntimeToFindings(device: string, hookRuntime: Record<string, Record<string, FleetHookRuntimeState>> | undefined): DoctorFinding[];
|
|
164
169
|
/**
|
|
165
170
|
* Map cross-device divergence (from {@link compareFleetInventories}) into
|
|
166
171
|
* warnings: an agent version present elsewhere but absent on a device is a
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* with. Keep this list exhaustive; a kind missing from it is a doc that lies.
|
|
19
19
|
* CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
|
|
20
20
|
* unwired-hook (a hook on disk that settings.json never fires) ·
|
|
21
|
-
*
|
|
21
|
+
* hook-runtime-broken (a wired hook's generated shim wrapper is
|
|
22
|
+
* missing or unusable) · cli-missing · ssh-key-enrollment ·
|
|
23
|
+
* owner-sink-unreachable (the feed/notify owner lane
|
|
22
24
|
* cannot reach the owner from this box).
|
|
23
25
|
* WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
|
|
24
26
|
* never-synced · stale · repo-behind · repo-drift · version-skew ·
|
|
25
|
-
* fleet-resource-gap · orphan · duplicate-hook ·
|
|
27
|
+
* fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
|
|
26
28
|
* duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
|
|
27
29
|
* rc-secret-export · env-secret-export · exec-policy · stale-cli.
|
|
28
30
|
* (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
|
|
@@ -81,6 +83,8 @@ export const ALL_FINDING_KINDS = [
|
|
|
81
83
|
'missing-hook', // a declared hook absent from a version home
|
|
82
84
|
'missing-plugin', // a declared plugin absent from a version home
|
|
83
85
|
'unwired-hook', // hook present on disk but not wired into settings.json
|
|
86
|
+
'hook-runtime-broken', // a wired hook's generated shim wrapper is missing/unusable
|
|
87
|
+
'hook-runtime-visibility-unavailable', // remote CLI cannot report generated wrapper health
|
|
84
88
|
'cli-missing', // a managed agent whose binary won't resolve
|
|
85
89
|
'missing-resource', // a missing command/skill/rule/mcp/permission/subagent
|
|
86
90
|
'content-drift', // a resource diverged from source
|
|
@@ -119,6 +123,7 @@ export const FINDING_SEVERITY = {
|
|
|
119
123
|
'missing-hook': 'critical',
|
|
120
124
|
'missing-plugin': 'critical',
|
|
121
125
|
'unwired-hook': 'critical',
|
|
126
|
+
'hook-runtime-broken': 'critical',
|
|
122
127
|
'cli-missing': 'critical',
|
|
123
128
|
// A factory that cannot escalate a blocked agent to the owner is not healthy,
|
|
124
129
|
// and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
|
|
@@ -127,6 +132,7 @@ export const FINDING_SEVERITY = {
|
|
|
127
132
|
// the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
|
|
128
133
|
// here: both are stale-sync states that one `agents sync` resolves.
|
|
129
134
|
'logout-unprovable': 'warning',
|
|
135
|
+
'hook-runtime-visibility-unavailable': 'warning',
|
|
130
136
|
'missing-resource': 'warning',
|
|
131
137
|
'content-drift': 'warning',
|
|
132
138
|
'never-synced': 'warning',
|
|
@@ -200,10 +206,13 @@ export function remediationFor(finding) {
|
|
|
200
206
|
case 'missing-hook':
|
|
201
207
|
case 'missing-plugin':
|
|
202
208
|
case 'unwired-hook':
|
|
209
|
+
case 'hook-runtime-broken':
|
|
203
210
|
case 'missing-resource':
|
|
204
211
|
case 'content-drift':
|
|
205
212
|
case 'stale':
|
|
206
213
|
return idLabel ? `agents doctor ${idLabel} --fix` : 'agents doctor --fix';
|
|
214
|
+
case 'hook-runtime-visibility-unavailable':
|
|
215
|
+
return 'upgrade agents-cli on this device';
|
|
207
216
|
case 'never-synced':
|
|
208
217
|
// A bare `agents sync <agent>` targets only the default/sole installed
|
|
209
218
|
// version (`commands/sync.ts:8`), so a row collapsed across versions must
|
|
@@ -354,6 +363,15 @@ export function buildLocalFindings(input) {
|
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
365
|
}
|
|
366
|
+
// Generated shim wrapper missing/unusable for a wired hook — independent of
|
|
367
|
+
// whether the native settings format itself is understood, so this fires
|
|
368
|
+
// even for harnesses `w.supported` is false for (RUSH-2382).
|
|
369
|
+
for (const issue of w?.runtimeBroken ?? []) {
|
|
370
|
+
out.push(finding({
|
|
371
|
+
severity: FINDING_SEVERITY['hook-runtime-broken'], kind: 'hook-runtime-broken', device, agent, version,
|
|
372
|
+
message: `hook '${issue.name}' wired but its generated shim is ${issue.reason}`,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
357
375
|
// A never-synced version has EVERY declared resource "missing" — that's one
|
|
358
376
|
// root cause (never synced), not one emergency per hook. Collapse it to a
|
|
359
377
|
// single critical rather than flooding the top section with 100+ lines. The
|
|
@@ -766,6 +784,39 @@ export function signInToFindings(device, signIn) {
|
|
|
766
784
|
}
|
|
767
785
|
return out;
|
|
768
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Rebuild remote generated-wrapper findings from the closed enum inventory
|
|
789
|
+
* state. Remote paths and detector messages never cross the fleet boundary.
|
|
790
|
+
*/
|
|
791
|
+
export function hookRuntimeToFindings(device, hookRuntime) {
|
|
792
|
+
if (!hookRuntime) {
|
|
793
|
+
return [finding({
|
|
794
|
+
severity: FINDING_SEVERITY['hook-runtime-visibility-unavailable'],
|
|
795
|
+
kind: 'hook-runtime-visibility-unavailable',
|
|
796
|
+
device,
|
|
797
|
+
message: "older agents-cli — can't report generated hook-wrapper health",
|
|
798
|
+
})];
|
|
799
|
+
}
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
802
|
+
const versions = hookRuntime[agent];
|
|
803
|
+
if (!versions)
|
|
804
|
+
continue;
|
|
805
|
+
for (const [version, state] of Object.entries(versions)) {
|
|
806
|
+
if (state !== 'broken')
|
|
807
|
+
continue;
|
|
808
|
+
out.push(finding({
|
|
809
|
+
severity: FINDING_SEVERITY['hook-runtime-broken'],
|
|
810
|
+
kind: 'hook-runtime-broken',
|
|
811
|
+
device,
|
|
812
|
+
agent,
|
|
813
|
+
version,
|
|
814
|
+
message: 'generated hook wrapper is unusable',
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
769
820
|
/**
|
|
770
821
|
* Map cross-device divergence (from {@link compareFleetInventories}) into
|
|
771
822
|
* warnings: an agent version present elsewhere but absent on a device is a
|
|
@@ -14,6 +14,13 @@ export declare function readDoctorOverviewCache(deps?: DoctorOverviewCacheDeps):
|
|
|
14
14
|
} | null;
|
|
15
15
|
/** Persist a fresh overview payload (best-effort; tmp+rename so reads are atomic). */
|
|
16
16
|
export declare function writeDoctorOverviewCache(payload: unknown, deps?: DoctorOverviewCacheDeps): void;
|
|
17
|
+
/**
|
|
18
|
+
* Drop the cached overview after a doctor repair attempt changes (or fails to
|
|
19
|
+
* change) live health. Best-effort and deliberately narrow: it never touches
|
|
20
|
+
* the singleflight lock, so an in-progress overview compute remains owned by
|
|
21
|
+
* its holder and no repair can create a retry loop.
|
|
22
|
+
*/
|
|
23
|
+
export declare function invalidateDoctorOverviewCache(deps?: DoctorOverviewCacheDeps): void;
|
|
17
24
|
/**
|
|
18
25
|
* Result of {@link enterDoctorOverviewGate}.
|
|
19
26
|
* - `cached` non-null → the caller MUST print this string and return; no compute.
|
|
@@ -82,6 +82,21 @@ export function writeDoctorOverviewCache(payload, deps = {}) {
|
|
|
82
82
|
// best-effort; a failed write just means the next read falls back to live
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Drop the cached overview after a doctor repair attempt changes (or fails to
|
|
87
|
+
* change) live health. Best-effort and deliberately narrow: it never touches
|
|
88
|
+
* the singleflight lock, so an in-progress overview compute remains owned by
|
|
89
|
+
* its holder and no repair can create a retry loop.
|
|
90
|
+
*/
|
|
91
|
+
export function invalidateDoctorOverviewCache(deps = {}) {
|
|
92
|
+
const dir = deps.dir ?? getCacheDir();
|
|
93
|
+
try {
|
|
94
|
+
fs.unlinkSync(cachePath(dir));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Missing/unlinkable cache is already equivalent to invalidated.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
85
100
|
/**
|
|
86
101
|
* Enter the doctor-overview singleflight gate. Returns a cached string to print,
|
|
87
102
|
* or a lock token telling the caller to compute (and then write + release).
|
|
@@ -57,6 +57,13 @@ export interface FleetVersionSignIn {
|
|
|
57
57
|
* AND globally) — the caller gates a critical on this. */
|
|
58
58
|
provable: boolean;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* A deliberately closed summary of one version's generated hook-wrapper
|
|
62
|
+
* runtime. Fleet payloads carry only this state — never the remote wrapper
|
|
63
|
+
* path, embedded source path, or detector text.
|
|
64
|
+
*/
|
|
65
|
+
export declare const FLEET_HOOK_RUNTIME_STATES: readonly ["healthy", "broken", "not-applicable"];
|
|
66
|
+
export type FleetHookRuntimeState = typeof FLEET_HOOK_RUNTIME_STATES[number];
|
|
60
67
|
/**
|
|
61
68
|
* The self-reported harness inventory a single device emits in `doctor --json`.
|
|
62
69
|
* Comparable device-to-device with no further probing.
|
|
@@ -77,6 +84,10 @@ export interface FleetInventory {
|
|
|
77
84
|
* predates this field omits it, and the caller degrades to a warning
|
|
78
85
|
* ("older agents-cli — can't report per-version sign-in"). */
|
|
79
86
|
signIn?: Record<string, FleetVersionSignIn[]>;
|
|
87
|
+
/** Generated hook-wrapper health per installed agent/version. Optional for
|
|
88
|
+
* wire compatibility with older remotes; a present value is fully validated
|
|
89
|
+
* before it is used by fleet doctor. */
|
|
90
|
+
hookRuntime?: Record<string, Record<string, FleetHookRuntimeState>>;
|
|
80
91
|
}
|
|
81
92
|
/** A device's inventory paired with its name (and reachability). A device that
|
|
82
93
|
* was unreachable / failed to report carries `inventory: null` and is skipped
|
|
@@ -37,6 +37,12 @@ export const FLEET_RESOURCE_KINDS = [
|
|
|
37
37
|
'promptcuts',
|
|
38
38
|
'workflows',
|
|
39
39
|
];
|
|
40
|
+
/**
|
|
41
|
+
* A deliberately closed summary of one version's generated hook-wrapper
|
|
42
|
+
* runtime. Fleet payloads carry only this state — never the remote wrapper
|
|
43
|
+
* path, embedded source path, or detector text.
|
|
44
|
+
*/
|
|
45
|
+
export const FLEET_HOOK_RUNTIME_STATES = ['healthy', 'broken', 'not-applicable'];
|
|
40
46
|
function sortedUnique(list) {
|
|
41
47
|
return Array.from(new Set(list)).sort();
|
|
42
48
|
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* FleetInventory} that both the local baseline and every remote box serialize
|
|
9
9
|
* into their doctor payload; the comparator then diffs those payloads.
|
|
10
10
|
*/
|
|
11
|
-
import { getAvailableResources, getVersionHomePath, listInstalledVersions } from '../versions.js';
|
|
11
|
+
import { getAvailableResources, getVersionHomePath, isVersionIsolated, listInstalledVersions } from '../versions.js';
|
|
12
|
+
import { supports } from '../capabilities.js';
|
|
13
|
+
import { checkVersionHookWiring } from '../hooks.js';
|
|
12
14
|
import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
|
|
13
15
|
import { readRepoState } from '../git.js';
|
|
14
16
|
import { ALL_AGENT_IDS, accountDisplayLabel, credentialPresence, getAccountInfo, supportsAccountInspection, } from '../agents.js';
|
|
@@ -88,10 +90,21 @@ export async function collectLocalFleetInventory(cwd = process.cwd()) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
const agentVersions = {};
|
|
93
|
+
const hookRuntime = {};
|
|
91
94
|
for (const agent of ALL_AGENT_IDS) {
|
|
92
95
|
const versions = listInstalledVersions(agent);
|
|
93
|
-
if (versions.length > 0)
|
|
96
|
+
if (versions.length > 0) {
|
|
94
97
|
agentVersions[agent] = [...versions].sort();
|
|
98
|
+
hookRuntime[agent] = Object.fromEntries(versions.map((version) => {
|
|
99
|
+
const eligible = supports(agent, 'hooks', version).ok && !isVersionIsolated(agent, version);
|
|
100
|
+
if (!eligible)
|
|
101
|
+
return [version, 'not-applicable'];
|
|
102
|
+
const state = checkVersionHookWiring(agent, version).runtimeBroken.length > 0
|
|
103
|
+
? 'broken'
|
|
104
|
+
: 'healthy';
|
|
105
|
+
return [version, state];
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
return {
|
|
97
110
|
resources,
|
|
@@ -101,5 +114,6 @@ export async function collectLocalFleetInventory(cwd = process.cwd()) {
|
|
|
101
114
|
system: toRepoState(readRepoState(getSystemAgentsDir())),
|
|
102
115
|
},
|
|
103
116
|
signIn: await collectLocalFleetSignIn(),
|
|
117
|
+
hookRuntime,
|
|
104
118
|
};
|
|
105
119
|
}
|
package/dist/lib/drift.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface SyncStatusRow {
|
|
|
22
22
|
* (claude/droid). A non-zero value makes the version out-of-sync even when the
|
|
23
23
|
* manifest reads fresh — the yosemite-s1 blind spot the CI gate must catch. */
|
|
24
24
|
unwiredHooks?: number;
|
|
25
|
+
/** Generated hooks whose shared runtime wrapper is missing or unusable. */
|
|
26
|
+
brokenHookRuntime?: number;
|
|
25
27
|
}
|
|
26
28
|
export interface OrphanRow {
|
|
27
29
|
agent: AgentId;
|
|
@@ -52,11 +54,14 @@ export interface DriftSummary {
|
|
|
52
54
|
orphanVersionCount: number;
|
|
53
55
|
/** Versions with hooks present on disk but not wired into settings.json. */
|
|
54
56
|
unwiredHookVersions: number;
|
|
57
|
+
/** Versions with at least one broken generated hook-runtime wrapper. */
|
|
58
|
+
brokenHookRuntimeVersions: number;
|
|
55
59
|
/** Source layers behind their upstream (reconciled against stale truth). */
|
|
56
60
|
sourceBehind: SourceLayerBehind[];
|
|
57
61
|
/**
|
|
58
62
|
* True when the install is out of sync: any installed version is stale,
|
|
59
|
-
* never-synced,
|
|
63
|
+
* never-synced, carries unwired hooks, or has a broken generated hook runtime,
|
|
64
|
+
* OR a source layer is behind origin.
|
|
60
65
|
* `agents doctor` surfaces it as "run `agents status`"; `agents doctor --check`
|
|
61
66
|
* maps it to a non-zero exit. Orphans are a `prune` concern, not sync drift, so they do
|
|
62
67
|
* NOT set this flag (mirrors the sync-status engine: an orphan alone never
|