@phnx-labs/agents-cli 1.22.58 → 1.22.59
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 +238 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +32 -1
- package/dist/commands/monitors.js +187 -23
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +39 -5
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +43 -2
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/installations/versions.d.ts +17 -0
- package/dist/lib/installations/versions.js +53 -2
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/self-update.d.ts +42 -0
- package/dist/lib/self-update.js +88 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +32 -6
- package/dist/lib/session/db.js +128 -12
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/sync.d.ts +113 -6
- package/dist/lib/traces/sync.js +193 -19
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
package/dist/commands/view.js
CHANGED
|
@@ -4,7 +4,7 @@ import { termLink } from '../lib/format.js';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
-
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
7
|
+
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, credentialPresence, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
8
8
|
import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
|
|
9
9
|
import { machineId } from '../lib/machine-id.js';
|
|
10
10
|
import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
|
|
@@ -24,7 +24,7 @@ import { listNativeAccounts } from '../lib/account-registry.js';
|
|
|
24
24
|
import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
|
|
25
25
|
import { getCentralRulesFileName } from '../lib/rules/rules.js';
|
|
26
26
|
import { composeRulesFromState } from '../lib/rules/compose.js';
|
|
27
|
-
import { getConfiguredRunStrategy } from '../lib/accounting/rotate.js';
|
|
27
|
+
import { getConfiguredRunStrategy, isLaunchableSignedIn } from '../lib/accounting/rotate.js';
|
|
28
28
|
import { resolveRunDefaults } from '../lib/run-defaults.js';
|
|
29
29
|
import { resolveConfiguredModel } from '../lib/models.js';
|
|
30
30
|
import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
|
|
@@ -1293,6 +1293,11 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
|
|
|
1293
1293
|
isolated: isVersionIsolated(agentId, version),
|
|
1294
1294
|
isIsolatedDefault: getIsolatedDefault(agentId) === version,
|
|
1295
1295
|
signedIn: info.signedIn,
|
|
1296
|
+
// The strict per-version launch truth (vs the display `signedIn` above,
|
|
1297
|
+
// which inherits the active/global HOME login). The same primitive
|
|
1298
|
+
// `collectRunCandidates` uses locally, so remote `--device auto` placement
|
|
1299
|
+
// is gated on identical launchability (PHNX-3466).
|
|
1300
|
+
launchable: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
|
|
1296
1301
|
authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
|
|
1297
1302
|
email: info.email,
|
|
1298
1303
|
accountId: info.accountId,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import { simpleGit } from 'simple-git';
|
|
13
|
-
import {
|
|
13
|
+
import { tryAutoPullSystemRepo, isGitRepo } from './git.js';
|
|
14
14
|
import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos, getFetchCacheDir, } from './state.js';
|
|
15
15
|
import { lockFilePath, statusFilePath, markDetachedSyncComplete, SYNC_LOCK_TTL_MS, } from './auto-pull.js';
|
|
16
16
|
/**
|
|
@@ -99,7 +99,12 @@ async function processTarget(target) {
|
|
|
99
99
|
await notifyRepo(target);
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
102
|
-
|
|
102
|
+
// Verify origin is the EXPECTED system remote before fast-forwarding —
|
|
103
|
+
// the system repo ships hooks that run as shell, so a pull from a
|
|
104
|
+
// repointed origin is RCE. An unexpected origin is refused, not pulled
|
|
105
|
+
// (PHNX-2957). The detached worker has no terminal to warn on; the
|
|
106
|
+
// foreground `agents use` path surfaces the refusal to the operator.
|
|
107
|
+
await tryAutoPullSystemRepo(target.dir);
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
else {
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* Requires the Rush GitHub App installed on the target repo.
|
|
6
6
|
*/
|
|
7
7
|
import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
10
|
+
* the token has not passed its expires_at timestamp (Unix seconds). A missing
|
|
11
|
+
* expires_at is treated as non-expired so tokens written without an expiry
|
|
12
|
+
* still work. Pass yamlPath to override the default path in tests.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isRushSessionValid(yamlPath?: string): boolean;
|
|
8
15
|
/** One version's entry in the account manifest sent on every dispatch. */
|
|
9
16
|
export interface AccountManifestEntry {
|
|
10
17
|
version: string;
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -16,6 +16,29 @@ import { getAccountInfo } from '../agents.js';
|
|
|
16
16
|
import { selectBalancedVersion } from '../accounting/rotate.js';
|
|
17
17
|
const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
18
18
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
19
|
+
/**
|
|
20
|
+
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
21
|
+
* the token has not passed its expires_at timestamp (Unix seconds). A missing
|
|
22
|
+
* expires_at is treated as non-expired so tokens written without an expiry
|
|
23
|
+
* still work. Pass yamlPath to override the default path in tests.
|
|
24
|
+
*/
|
|
25
|
+
export function isRushSessionValid(yamlPath = USER_YAML) {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(yamlPath))
|
|
28
|
+
return false;
|
|
29
|
+
const raw = fs.readFileSync(yamlPath, 'utf-8');
|
|
30
|
+
const data = yaml.parse(raw);
|
|
31
|
+
if (!data?.session?.access_token)
|
|
32
|
+
return false;
|
|
33
|
+
const expiresAt = data.session.expires_at;
|
|
34
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000)
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
/** Read the Rush session access token from ~/.rush/user.yaml. */
|
|
20
43
|
function readToken() {
|
|
21
44
|
if (!fs.existsSync(USER_YAML)) {
|
|
@@ -27,6 +50,11 @@ function readToken() {
|
|
|
27
50
|
if (!token) {
|
|
28
51
|
throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
|
|
29
52
|
}
|
|
53
|
+
const expiresAt = data.session?.expires_at;
|
|
54
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
|
|
55
|
+
const expiredAt = new Date(expiresAt * 1000).toISOString();
|
|
56
|
+
throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
|
|
57
|
+
}
|
|
30
58
|
return token;
|
|
31
59
|
}
|
|
32
60
|
/** Read the user's email from the Rush session config, if available. */
|
|
@@ -203,7 +231,7 @@ export class RushCloudProvider {
|
|
|
203
231
|
name = 'Rush Cloud';
|
|
204
232
|
capabilities() {
|
|
205
233
|
return {
|
|
206
|
-
available:
|
|
234
|
+
available: isRushSessionValid(),
|
|
207
235
|
dispatch: true,
|
|
208
236
|
status: true,
|
|
209
237
|
list: true,
|
|
@@ -215,6 +215,28 @@ export declare function anchorDaemonCwd(): string | null;
|
|
|
215
215
|
* root is stable (or could not be resolved).
|
|
216
216
|
*/
|
|
217
217
|
export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
|
|
218
|
+
/**
|
|
219
|
+
* Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
|
|
220
|
+
* `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
|
|
221
|
+
* override fails to reach the child — an `env: {...process.env}` spawn that
|
|
222
|
+
* forgot to set it, a login shell that reset HOME — the daemon resolves its
|
|
223
|
+
* state dir under the operator's REAL home and its scheduler/watchdog then tick
|
|
224
|
+
* against shared production state. That is the exact leak the ticket reports:
|
|
225
|
+
* real test daemons found alive on a fleet box, each a second live scheduler
|
|
226
|
+
* racing the legitimate one, in violation of the execution-singularity spec.
|
|
227
|
+
*
|
|
228
|
+
* A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
|
|
229
|
+
* it provisioned. When that marker is present, this daemon's resolved state dir
|
|
230
|
+
* MUST sit under it; otherwise the daemon refuses to boot — failing loud before
|
|
231
|
+
* it claims an instance, writes a pid, or fires a single tick (the throw is
|
|
232
|
+
* caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
|
|
233
|
+
* rather than running in the wrong directory against the real host. In
|
|
234
|
+
* production the marker is never set, so this is a no-op there.
|
|
235
|
+
*
|
|
236
|
+
* `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
|
|
237
|
+
* without spawning a process; the defaults read the live daemon dir and env.
|
|
238
|
+
*/
|
|
239
|
+
export declare function assertTestDaemonHome(daemonDir?: string, testHome?: string | undefined): void;
|
|
218
240
|
export declare function runDaemon(): Promise<void>;
|
|
219
241
|
/**
|
|
220
242
|
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
@@ -705,6 +705,40 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
705
705
|
return null;
|
|
706
706
|
}
|
|
707
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
|
|
710
|
+
* `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
|
|
711
|
+
* override fails to reach the child — an `env: {...process.env}` spawn that
|
|
712
|
+
* forgot to set it, a login shell that reset HOME — the daemon resolves its
|
|
713
|
+
* state dir under the operator's REAL home and its scheduler/watchdog then tick
|
|
714
|
+
* against shared production state. That is the exact leak the ticket reports:
|
|
715
|
+
* real test daemons found alive on a fleet box, each a second live scheduler
|
|
716
|
+
* racing the legitimate one, in violation of the execution-singularity spec.
|
|
717
|
+
*
|
|
718
|
+
* A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
|
|
719
|
+
* it provisioned. When that marker is present, this daemon's resolved state dir
|
|
720
|
+
* MUST sit under it; otherwise the daemon refuses to boot — failing loud before
|
|
721
|
+
* it claims an instance, writes a pid, or fires a single tick (the throw is
|
|
722
|
+
* caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
|
|
723
|
+
* rather than running in the wrong directory against the real host. In
|
|
724
|
+
* production the marker is never set, so this is a no-op there.
|
|
725
|
+
*
|
|
726
|
+
* `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
|
|
727
|
+
* without spawning a process; the defaults read the live daemon dir and env.
|
|
728
|
+
*/
|
|
729
|
+
export function assertTestDaemonHome(daemonDir = getDaemonDir(), testHome = process.env.AGENTS_DAEMON_TEST_HOME) {
|
|
730
|
+
if (!testHome)
|
|
731
|
+
return;
|
|
732
|
+
const root = path.resolve(testHome);
|
|
733
|
+
const dir = path.resolve(daemonDir);
|
|
734
|
+
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
|
735
|
+
const msg = `Daemon test-home tripwire (PHNX-2545): AGENTS_DAEMON_TEST_HOME is ${root}, but this ` +
|
|
736
|
+
`daemon's state dir resolved to ${dir} — the isolated HOME override did not reach this ` +
|
|
737
|
+
`__daemon-run child, so it would schedule against the real host. Refusing to start.`;
|
|
738
|
+
log('ERROR', msg);
|
|
739
|
+
throw new Error(msg);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
708
742
|
// ---------------------------------------------------------------------------
|
|
709
743
|
// Module-level periodic maintenance helpers (RUSH-2422)
|
|
710
744
|
//
|
|
@@ -720,6 +754,11 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
720
754
|
// below. runBrokerSelfHeal was moved into SecretsBrokerService (RUSH-3193 P2).
|
|
721
755
|
// ---------------------------------------------------------------------------
|
|
722
756
|
export async function runDaemon() {
|
|
757
|
+
// PHNX-2545 test-home tripwire — FIRST, before this daemon claims an instance,
|
|
758
|
+
// writes a pid, or fires any tick. A test-spawned daemon that lost its isolated
|
|
759
|
+
// HOME override must refuse to run against the operator's real state rather than
|
|
760
|
+
// schedule against the real host. No-op in production (the marker is never set).
|
|
761
|
+
assertTestDaemonHome();
|
|
723
762
|
// Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
|
|
724
763
|
// (manual, or a service-manager restart racing a live predecessor) EVICTS the
|
|
725
764
|
// incumbent and becomes the survivor. claimDaemonInstance returns false only
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* and deadline instead.
|
|
12
12
|
*/
|
|
13
13
|
import { BasePeriodicService } from './service.js';
|
|
14
|
-
import { runSessionIndexWarmTick } from '../daemon-ticks.js';
|
|
14
|
+
import { runDeferredToolIndex, runSessionIndexWarmTick } from '../daemon-ticks.js';
|
|
15
15
|
/** Matches the historical inline interval (daemon.ts SESSION_INDEX_WARM_TICK_MS). */
|
|
16
16
|
const SESSION_INDEX_WARM_TICK_MS = 20_000;
|
|
17
17
|
/** Hard cap per tick — well above a healthy incremental scan, short enough that a hang never freezes the service for long. */
|
|
@@ -36,5 +36,13 @@ export class SessionIndexService extends BasePeriodicService {
|
|
|
36
36
|
ctx.log('INFO', 'session-index warm: skipped, another process holds the scan claim');
|
|
37
37
|
else if (indexed > 0)
|
|
38
38
|
ctx.log('INFO', `session-index warm: indexed ${indexed} transcript(s)`);
|
|
39
|
+
// Deferred tool-index pass (PHNX-3411): fill tool_scan_ledger rows for
|
|
40
|
+
// harnesses whose scanner produces no events. Runs only when the warm tick
|
|
41
|
+
// claimed the scan lock (i.e. we are the active indexer this tick).
|
|
42
|
+
if (claimed) {
|
|
43
|
+
const { indexed: toolIndexed } = await runDeferredToolIndex();
|
|
44
|
+
if (toolIndexed > 0)
|
|
45
|
+
ctx.log('INFO', `session-index deferred: tool-indexed ${toolIndexed} transcript(s)`);
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
}
|
|
@@ -121,3 +121,18 @@ export declare function runSessionIndexWarmTick(): Promise<{
|
|
|
121
121
|
indexed: number;
|
|
122
122
|
claimed: boolean;
|
|
123
123
|
}>;
|
|
124
|
+
/**
|
|
125
|
+
* Deferred tool-index pass for large-transcript harnesses (PHNX-3411).
|
|
126
|
+
*
|
|
127
|
+
* Kimi (wire.jsonl) and Grok (chat_history.jsonl) scanners produce only
|
|
128
|
+
* metadata — no events. Calling parseSession for those on the warm tick wedges
|
|
129
|
+
* the Node event loop when the transcript is large and active (observed: several
|
|
130
|
+
* seconds per tick on zion, causing browser IPC ECONNREFUSED). This pass fills
|
|
131
|
+
* the gap: it queries recently-active kimi/grok sessions and calls
|
|
132
|
+
* ensureToolIndex, which uses tool_scan_ledger stamps to skip already-current
|
|
133
|
+
* sessions and applies byte/file budget caps so no large transcript monopolises
|
|
134
|
+
* the tick.
|
|
135
|
+
*/
|
|
136
|
+
export declare function runDeferredToolIndex(): Promise<{
|
|
137
|
+
indexed: number;
|
|
138
|
+
}>;
|
package/dist/lib/daemon-ticks.js
CHANGED
|
@@ -185,3 +185,29 @@ export async function runSessionIndexWarmTick() {
|
|
|
185
185
|
return { indexed: 0, claimed: false };
|
|
186
186
|
return { indexed: scanned, claimed: true };
|
|
187
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Deferred tool-index pass for large-transcript harnesses (PHNX-3411).
|
|
190
|
+
*
|
|
191
|
+
* Kimi (wire.jsonl) and Grok (chat_history.jsonl) scanners produce only
|
|
192
|
+
* metadata — no events. Calling parseSession for those on the warm tick wedges
|
|
193
|
+
* the Node event loop when the transcript is large and active (observed: several
|
|
194
|
+
* seconds per tick on zion, causing browser IPC ECONNREFUSED). This pass fills
|
|
195
|
+
* the gap: it queries recently-active kimi/grok sessions and calls
|
|
196
|
+
* ensureToolIndex, which uses tool_scan_ledger stamps to skip already-current
|
|
197
|
+
* sessions and applies byte/file budget caps so no large transcript monopolises
|
|
198
|
+
* the tick.
|
|
199
|
+
*/
|
|
200
|
+
export async function runDeferredToolIndex() {
|
|
201
|
+
const { querySessionsForDeferredToolIndex } = await import('./session/db.js');
|
|
202
|
+
const { ensureToolIndex } = await import('./session/tool-index.js');
|
|
203
|
+
// Feed the 200 most-recently-active kimi/grok sessions; ensureToolIndex skips
|
|
204
|
+
// any whose tool_scan_ledger stamp is current.
|
|
205
|
+
const sessions = querySessionsForDeferredToolIndex(200);
|
|
206
|
+
if (sessions.length === 0)
|
|
207
|
+
return { indexed: 0 };
|
|
208
|
+
const coverage = await ensureToolIndex(sessions, {
|
|
209
|
+
maxFiles: 20,
|
|
210
|
+
maxBytes: 20 * 1024 * 1024, // 20 MB — bounds one tick even on large transcripts
|
|
211
|
+
});
|
|
212
|
+
return { indexed: coverage.indexedFiles };
|
|
213
|
+
}
|
|
@@ -194,7 +194,11 @@ export declare function devicesPinningBrowserProfile(profile: string): Array<{
|
|
|
194
194
|
export declare function listConfiguredDeviceRoles(roster?: string[]): Record<string, ConfiguredDeviceRole>;
|
|
195
195
|
/** The configured automatic-placement pool mode. Unset means `workers`. */
|
|
196
196
|
export declare function autoPoolMode(): AutoPoolMode;
|
|
197
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* A device's auto-launch flags, read by the automatic-placement pool
|
|
199
|
+
* (`filterAutoPool` drops `enabled: false`; `pickBestDevice` boosts
|
|
200
|
+
* `preferred: true` — see `lib/devices/pool.ts`) and by the menu-bar snapshot.
|
|
201
|
+
*/
|
|
198
202
|
export interface AutoLaunchPreference {
|
|
199
203
|
enabled?: boolean;
|
|
200
204
|
preferred?: boolean;
|
|
@@ -294,7 +294,7 @@ export const CONFIG_KEYS = [
|
|
|
294
294
|
visibility: 'shared',
|
|
295
295
|
type: 'bool',
|
|
296
296
|
defaultValue: true,
|
|
297
|
-
description: 'Whether AGI EXT
|
|
297
|
+
description: 'Whether `--device auto` (run, teams, AGI EXT) may pick this device (default on). Off drops it from every automatic-placement path.',
|
|
298
298
|
},
|
|
299
299
|
{
|
|
300
300
|
name: 'auto-launch.preferred',
|
|
@@ -303,7 +303,7 @@ export const CONFIG_KEYS = [
|
|
|
303
303
|
visibility: 'shared',
|
|
304
304
|
type: 'bool',
|
|
305
305
|
defaultValue: false,
|
|
306
|
-
description: 'Boost this device in
|
|
306
|
+
description: 'Boost this device in `--device auto` ranking (default off) — picked ahead of load-equal peers when eligible.',
|
|
307
307
|
},
|
|
308
308
|
];
|
|
309
309
|
/** Look up a key spec by CLI dotted name, or throw listing the known keys. */
|
|
@@ -145,8 +145,12 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
|
|
|
145
145
|
* numbers, mirroring how garbage POSIX output degrades. */
|
|
146
146
|
export function parseWinProbeOutput(host, stdout, fetchedAt) {
|
|
147
147
|
const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)(?: diskFreeKb=([0-9.]+) diskTotalKb=([0-9.]+))?/);
|
|
148
|
+
// Unparseable output still means the probe RAN — the box answered, we just
|
|
149
|
+
// could not read it. Stamp specsFetchedAt anyway so a hardware-fact carry
|
|
150
|
+
// forward (retainHardwareFacts, RUSH-3096) has a real observation moment
|
|
151
|
+
// instead of undefined.
|
|
148
152
|
if (!m)
|
|
149
|
-
return { host, reachable: true, fetchedAt };
|
|
153
|
+
return { host, reachable: true, fetchedAt, specsFetchedAt: fetchedAt };
|
|
150
154
|
const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
|
|
151
155
|
const freeKb = parseInt(m[2], 10);
|
|
152
156
|
const totalKb = parseInt(m[3], 10);
|
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* | no device marked | every online device (unchanged behavior) |
|
|
17
17
|
* | some marked `worker` | ONLY those workers |
|
|
18
18
|
* | marked `personal` / `desktop` | never, under either state |
|
|
19
|
+
* | `auto-launch.enabled` off | never, whatever its role or the mode |
|
|
19
20
|
*
|
|
20
|
-
* `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
|
|
21
|
+
* `auto.pool all` turns the WORKER allowlist off; `personal` and `desktop` stay
|
|
21
22
|
* excluded, because a box the user sits at (personal) or a headed always-on
|
|
22
23
|
* release/credential box (desktop) is marked precisely so agents stay off it.
|
|
24
|
+
*
|
|
25
|
+
* A device the operator turned off with `agents devices disable <name>`
|
|
26
|
+
* (`auto-launch.enabled` = false) is dropped here too — one operator switch,
|
|
27
|
+
* one place, so `disable` removes a box from EVERY automatic-placement path
|
|
28
|
+
* (run/teams/ssh auto), not just one surface. Its sibling
|
|
29
|
+
* `auto-launch.preferred` does not narrow the pool; it BOOSTS a member in the
|
|
30
|
+
* ranker ({@link autoLaunchPreferredSet}, applied by `pickBestDevice`).
|
|
23
31
|
*/
|
|
24
|
-
import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
|
|
32
|
+
import { type AutoLaunchPreference, type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
|
|
25
33
|
export interface AutoPoolOptions {
|
|
26
34
|
/** Pool mode; defaults to the configured `auto.pool`. */
|
|
27
35
|
mode?: AutoPoolMode;
|
|
@@ -34,6 +42,14 @@ export interface AutoPoolOptions {
|
|
|
34
42
|
* {@link listConfiguredDeviceRoles}.
|
|
35
43
|
*/
|
|
36
44
|
roster?: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Auto-launch flags by device name; defaults to the fleet-shared block for
|
|
47
|
+
* the roster (or the pool). A device whose `enabled` is `false` is dropped
|
|
48
|
+
* from the pool. Inject `{}` in a pure unit test to keep the rule off disk,
|
|
49
|
+
* exactly as `roles: {}` does for the role rule. See
|
|
50
|
+
* {@link loadAutoLaunchPreferences}.
|
|
51
|
+
*/
|
|
52
|
+
autoLaunch?: Record<string, AutoLaunchPreference>;
|
|
37
53
|
}
|
|
38
54
|
/**
|
|
39
55
|
* Narrow a candidate host list to the devices automatic placement may pick.
|
|
@@ -44,6 +60,13 @@ export interface AutoPoolOptions {
|
|
|
44
60
|
* quietly widening back to the full fleet.
|
|
45
61
|
*/
|
|
46
62
|
export declare function filterAutoPool(pool: string[], opts?: AutoPoolOptions): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Normalized hosts the operator boosted with `auto-launch.preferred` = true —
|
|
65
|
+
* the set `pickBestDevice` ranks ahead of its peers. Unlike the disable drop,
|
|
66
|
+
* a preference never removes a device: an eligible non-preferred box is still
|
|
67
|
+
* picked when no preferred one is available.
|
|
68
|
+
*/
|
|
69
|
+
export declare function autoLaunchPreferredSet(pool: string[], opts?: AutoPoolOptions): Set<string>;
|
|
47
70
|
/** True when this host is one automatic placement may pick. */
|
|
48
71
|
export declare function isAutoPoolMember(host: string, opts?: AutoPoolOptions): boolean;
|
|
49
72
|
/** Device names explicitly marked `worker`, in registry order. */
|
package/dist/lib/devices/pool.js
CHANGED
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* | no device marked | every online device (unchanged behavior) |
|
|
17
17
|
* | some marked `worker` | ONLY those workers |
|
|
18
18
|
* | marked `personal` / `desktop` | never, under either state |
|
|
19
|
+
* | `auto-launch.enabled` off | never, whatever its role or the mode |
|
|
19
20
|
*
|
|
20
|
-
* `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
|
|
21
|
+
* `auto.pool all` turns the WORKER allowlist off; `personal` and `desktop` stay
|
|
21
22
|
* excluded, because a box the user sits at (personal) or a headed always-on
|
|
22
23
|
* release/credential box (desktop) is marked precisely so agents stay off it.
|
|
24
|
+
*
|
|
25
|
+
* A device the operator turned off with `agents devices disable <name>`
|
|
26
|
+
* (`auto-launch.enabled` = false) is dropped here too — one operator switch,
|
|
27
|
+
* one place, so `disable` removes a box from EVERY automatic-placement path
|
|
28
|
+
* (run/teams/ssh auto), not just one surface. Its sibling
|
|
29
|
+
* `auto-launch.preferred` does not narrow the pool; it BOOSTS a member in the
|
|
30
|
+
* ranker ({@link autoLaunchPreferredSet}, applied by `pickBestDevice`).
|
|
23
31
|
*/
|
|
24
|
-
import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
|
|
32
|
+
import { autoPoolMode, listConfiguredDeviceRoles, loadAutoLaunchPreferences, } from '../device-config.js';
|
|
25
33
|
import { normalizeHost } from '../machine-id.js';
|
|
26
34
|
/**
|
|
27
35
|
* Roles that automatic placement never picks, whatever the pool mode.
|
|
@@ -44,7 +52,10 @@ export function filterAutoPool(pool, opts = {}) {
|
|
|
44
52
|
const roles = opts.roles ?? listConfiguredDeviceRoles(opts.roster ?? pool);
|
|
45
53
|
const byHost = new Map(Object.entries(roles).map(([name, role]) => [normalizeHost(name), role]));
|
|
46
54
|
const roleOf = (host) => byHost.get(normalizeHost(host));
|
|
55
|
+
const disabled = disabledAutoLaunchSet(pool, opts);
|
|
47
56
|
const eligible = pool.filter((host) => {
|
|
57
|
+
if (disabled.has(normalizeHost(host)))
|
|
58
|
+
return false;
|
|
48
59
|
const role = roleOf(host);
|
|
49
60
|
return role === undefined || !NEVER_AUTO.has(role);
|
|
50
61
|
});
|
|
@@ -56,6 +67,25 @@ export function filterAutoPool(pool, opts = {}) {
|
|
|
56
67
|
return eligible;
|
|
57
68
|
return eligible.filter((host) => roleOf(host) === 'worker');
|
|
58
69
|
}
|
|
70
|
+
/** Normalized hosts the operator turned off with `auto-launch.enabled` = false. */
|
|
71
|
+
function disabledAutoLaunchSet(pool, opts) {
|
|
72
|
+
const prefs = opts.autoLaunch ?? loadAutoLaunchPreferences(opts.roster ?? pool);
|
|
73
|
+
return new Set(Object.entries(prefs)
|
|
74
|
+
.filter(([, pref]) => pref.enabled === false)
|
|
75
|
+
.map(([name]) => normalizeHost(name)));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Normalized hosts the operator boosted with `auto-launch.preferred` = true —
|
|
79
|
+
* the set `pickBestDevice` ranks ahead of its peers. Unlike the disable drop,
|
|
80
|
+
* a preference never removes a device: an eligible non-preferred box is still
|
|
81
|
+
* picked when no preferred one is available.
|
|
82
|
+
*/
|
|
83
|
+
export function autoLaunchPreferredSet(pool, opts = {}) {
|
|
84
|
+
const prefs = opts.autoLaunch ?? loadAutoLaunchPreferences(opts.roster ?? pool);
|
|
85
|
+
return new Set(Object.entries(prefs)
|
|
86
|
+
.filter(([, pref]) => pref.preferred === true)
|
|
87
|
+
.map(([name]) => normalizeHost(name)));
|
|
88
|
+
}
|
|
59
89
|
/** True when this host is one automatic placement may pick. */
|
|
60
90
|
export function isAutoPoolMember(host, opts = {}) {
|
|
61
91
|
return filterAutoPool([host], opts).length > 0;
|
|
@@ -8,12 +8,8 @@ import type { DeviceProfile } from './registry.js';
|
|
|
8
8
|
* rewrites the cache, unreachable boxes included).
|
|
9
9
|
*/
|
|
10
10
|
export declare const STATS_STALE_MS: number;
|
|
11
|
-
/** Static hardware totals are valid for seven days. */
|
|
12
|
-
export declare const SPECS_STALE_MS: number;
|
|
13
11
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
14
12
|
export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
|
|
15
|
-
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
16
|
-
export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
|
|
17
13
|
/**
|
|
18
14
|
* Carry a device's last successfully-probed hardware facts onto a row whose
|
|
19
15
|
* probe just came back unreachable (RUSH-3096).
|
|
@@ -54,8 +50,6 @@ export interface FleetStatsResult {
|
|
|
54
50
|
export interface LoadFleetStatsOptions {
|
|
55
51
|
/** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
|
|
56
52
|
forceRefresh?: boolean;
|
|
57
|
-
/** Read only static hardware facts, whose cache lifetime is seven days. */
|
|
58
|
-
specsOnly?: boolean;
|
|
59
53
|
/** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
|
|
60
54
|
selfName?: string;
|
|
61
55
|
/** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
|
|
@@ -37,17 +37,10 @@ const CACHE_FILE = '.fleet-stats.json';
|
|
|
37
37
|
* rewrites the cache, unreachable boxes included).
|
|
38
38
|
*/
|
|
39
39
|
export const STATS_STALE_MS = 3 * 60_000;
|
|
40
|
-
/** Static hardware totals are valid for seven days. */
|
|
41
|
-
export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
|
|
42
40
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
43
41
|
export function isFreshDeviceStats(stats, now = Date.now()) {
|
|
44
42
|
return now - stats.fetchedAt <= STATS_STALE_MS;
|
|
45
43
|
}
|
|
46
|
-
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
47
|
-
export function isFreshDeviceSpecs(stats, now = Date.now()) {
|
|
48
|
-
const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
|
|
49
|
-
return now - fetchedAt <= SPECS_STALE_MS;
|
|
50
|
-
}
|
|
51
44
|
/**
|
|
52
45
|
* Carry a device's last successfully-probed hardware facts onto a row whose
|
|
53
46
|
* probe just came back unreachable (RUSH-3096).
|
|
@@ -141,13 +134,13 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
141
134
|
const toProbe = [];
|
|
142
135
|
let servedFromCache = false;
|
|
143
136
|
for (const d of devices) {
|
|
144
|
-
if (d.name === self
|
|
137
|
+
if (d.name === self) {
|
|
145
138
|
// This machine is always probed locally — cheap, no ssh, always live.
|
|
146
139
|
toProbe.push(d);
|
|
147
140
|
continue;
|
|
148
141
|
}
|
|
149
142
|
const cached = opts.forceRefresh ? undefined : cache[d.name];
|
|
150
|
-
const cacheFresh = cached &&
|
|
143
|
+
const cacheFresh = cached && isFreshDeviceStats(cached, now);
|
|
151
144
|
if (cached && cacheFresh) {
|
|
152
145
|
stats.set(d.name, cached);
|
|
153
146
|
servedFromCache = true;
|
|
@@ -82,6 +82,20 @@ export interface VersionResourceReport {
|
|
|
82
82
|
* probe), not the pure diff — undefined when uncomputed. */
|
|
83
83
|
sourceBehind?: SourceLayerBehind[];
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
|
|
87
|
+
* FILE — the shape git produces on a client without symlink support (Windows
|
|
88
|
+
* without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
|
|
89
|
+
* link target (a relative path, no trailing newline) instead of the pointed-to
|
|
90
|
+
* content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
|
|
91
|
+
* skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
|
|
92
|
+
* GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
|
|
93
|
+
* an independent resource that no sync can ever reconcile (PHNX-3187). The
|
|
94
|
+
* signal is unambiguous: a whole file with no newline whose entire content
|
|
95
|
+
* resolves to an existing sibling path. Real markdown rule files always contain
|
|
96
|
+
* newlines, so this never false-positives on genuine content.
|
|
97
|
+
*/
|
|
98
|
+
export declare function isCheckedOutSymlink(filePath: string): boolean;
|
|
85
99
|
/**
|
|
86
100
|
* Describe how a version's marketplace MIRROR of a plugin diverges from its
|
|
87
101
|
* central source — the detail presence-only checks miss. Surfaces a stale mirror
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -63,6 +63,42 @@ function readSafe(file) {
|
|
|
63
63
|
function fileExists(p) {
|
|
64
64
|
return !!p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink();
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
|
|
68
|
+
* FILE — the shape git produces on a client without symlink support (Windows
|
|
69
|
+
* without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
|
|
70
|
+
* link target (a relative path, no trailing newline) instead of the pointed-to
|
|
71
|
+
* content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
|
|
72
|
+
* skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
|
|
73
|
+
* GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
|
|
74
|
+
* an independent resource that no sync can ever reconcile (PHNX-3187). The
|
|
75
|
+
* signal is unambiguous: a whole file with no newline whose entire content
|
|
76
|
+
* resolves to an existing sibling path. Real markdown rule files always contain
|
|
77
|
+
* newlines, so this never false-positives on genuine content.
|
|
78
|
+
*/
|
|
79
|
+
export function isCheckedOutSymlink(filePath) {
|
|
80
|
+
let content;
|
|
81
|
+
try {
|
|
82
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// A git symlink blob is the bare target with no newline; any newline means
|
|
88
|
+
// this is real file content, not a link.
|
|
89
|
+
if (content.length === 0 || content.length > 255 || /[\r\n]/.test(content))
|
|
90
|
+
return false;
|
|
91
|
+
const target = content.trim();
|
|
92
|
+
if (!target)
|
|
93
|
+
return false;
|
|
94
|
+
const resolved = path.resolve(path.dirname(filePath), target);
|
|
95
|
+
try {
|
|
96
|
+
return fs.statSync(resolved).isFile();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
66
102
|
function findFirst(candidates) {
|
|
67
103
|
for (const c of candidates) {
|
|
68
104
|
if (fileExists(c.path) || (fs.existsSync(c.path) && fs.lstatSync(c.path).isDirectory())) {
|
|
@@ -419,8 +455,13 @@ function listRulesNames(cwd, excludeProject = false) {
|
|
|
419
455
|
for (const file of entries) {
|
|
420
456
|
if (!file.endsWith('.md') || file === RULES_DOC_FILENAME)
|
|
421
457
|
continue;
|
|
422
|
-
const
|
|
423
|
-
|
|
458
|
+
const filePath = path.join(base.path, file);
|
|
459
|
+
const stat = fs.lstatSync(filePath);
|
|
460
|
+
// Skip the CLAUDE.md / GEMINI.md alias symlinks — both when they are real
|
|
461
|
+
// symlinks (posix) and when git checked them out as plain text files
|
|
462
|
+
// (Windows), so they are never mistaken for independent rule sources the
|
|
463
|
+
// writer can't produce (PHNX-3187).
|
|
464
|
+
if (stat.isSymbolicLink() || isCheckedOutSymlink(filePath))
|
|
424
465
|
continue;
|
|
425
466
|
const name = file.replace(/\.md$/, '');
|
|
426
467
|
if (out.has(name))
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -161,6 +161,21 @@ export declare function canonicalGitRemote(url: string): string;
|
|
|
161
161
|
* checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
|
|
162
162
|
*/
|
|
163
163
|
export declare function isSystemRepoRemote(remote: string | null | undefined): boolean;
|
|
164
|
+
/**
|
|
165
|
+
* True when `remote` is the origin the system repo is EXPECTED to track on this
|
|
166
|
+
* machine, honouring an operator's `AGENTS_SYSTEM_REPO` override.
|
|
167
|
+
*
|
|
168
|
+
* The system repo ships hooks that register as shell `command` strings run on
|
|
169
|
+
* every tool event, and its checkout auto-fast-forwards from origin — so a
|
|
170
|
+
* fast-forward from an origin the operator never chose is remote code execution
|
|
171
|
+
* on the next command that loads a system resource (PHNX-2957). This is the
|
|
172
|
+
* pinning predicate every auto-pull of the system repo gates on: pull only when
|
|
173
|
+
* origin is the canonical {@link isSystemRepoRemote} repo, or the exact
|
|
174
|
+
* `AGENTS_SYSTEM_REPO` the operator pointed at instead. Anything else — a
|
|
175
|
+
* repointed origin, a fork, an unset-then-swapped remote — is refused, not
|
|
176
|
+
* pulled. Pure string check; no git spawn.
|
|
177
|
+
*/
|
|
178
|
+
export declare function isExpectedSystemRepoRemote(remote: string | null | undefined): boolean;
|
|
164
179
|
/** True when two git remote URLs point at the same repo across transport forms. */
|
|
165
180
|
export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
166
181
|
/**
|
|
@@ -500,6 +515,29 @@ export declare function tryAutoPull(dir: string): Promise<{
|
|
|
500
515
|
pulled: boolean;
|
|
501
516
|
error?: string;
|
|
502
517
|
}>;
|
|
518
|
+
/** Result of {@link tryAutoPullSystemRepo}. `refused` is set only when the pull
|
|
519
|
+
* was blocked because origin is not the expected system remote. */
|
|
520
|
+
export interface SystemRepoPullResult {
|
|
521
|
+
pulled: boolean;
|
|
522
|
+
error?: string;
|
|
523
|
+
/** True when origin is present but is NOT the expected system remote; no
|
|
524
|
+
* fast-forward was attempted. `actualRemote` names what was found. */
|
|
525
|
+
refused?: boolean;
|
|
526
|
+
/** The origin fetch URL that was examined (present when a remote exists). */
|
|
527
|
+
actualRemote?: string;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Auto-pull the system repo ONLY after verifying its origin is the expected
|
|
531
|
+
* system remote (PHNX-2957). The system repo ships hooks that run as shell on
|
|
532
|
+
* tool events, so fast-forwarding it from an unexpected/repointed origin is
|
|
533
|
+
* remote code execution. An origin that fails {@link isExpectedSystemRepoRemote}
|
|
534
|
+
* is REFUSED loud (`refused: true`), never pulled — the canonical system repo
|
|
535
|
+
* (or an operator's `AGENTS_SYSTEM_REPO`) still fast-forwards exactly as before.
|
|
536
|
+
*
|
|
537
|
+
* A system dir with no origin at all is a plain no-op (`pulled: false`), not a
|
|
538
|
+
* refusal — there is nothing to pull from and nothing to distrust.
|
|
539
|
+
*/
|
|
540
|
+
export declare function tryAutoPullSystemRepo(dir: string): Promise<SystemRepoPullResult>;
|
|
503
541
|
/**
|
|
504
542
|
* How many commits `dir`'s checked-out branch is behind its upstream, read from
|
|
505
543
|
* the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
|