@phnx-labs/agents-cli 1.22.101 → 1.22.103
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 +14 -0
- package/dist/bootstrap.js +15 -0
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/menubar.js +4 -1
- package/dist/commands/ssh.js +12 -4
- package/dist/commands/view.d.ts +0 -2
- package/dist/commands/view.js +9 -28
- package/dist/lib/account-catalog.d.ts +24 -0
- package/dist/lib/account-catalog.js +45 -15
- package/dist/lib/accounting/usage.d.ts +7 -0
- package/dist/lib/accounting/usage.js +25 -0
- package/dist/lib/auth-health.d.ts +17 -4
- package/dist/lib/auth-health.js +60 -22
- package/dist/lib/auto-pull-worker.js +38 -1
- package/dist/lib/devices/harness-inventory.d.ts +4 -0
- package/dist/lib/devices/harness-inventory.js +3 -1
- package/dist/lib/feed/pr-status.d.ts +18 -0
- package/dist/lib/feed/pr-status.js +43 -5
- package/dist/lib/feed/watch.d.ts +4 -1
- package/dist/lib/feed/watch.js +57 -25
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +85 -0
- package/dist/lib/menubar/install-menubar.js +171 -6
- package/dist/lib/menubar/resolve-version.d.ts +82 -0
- package/dist/lib/menubar/resolve-version.js +133 -0
- package/dist/lib/profiles.js +13 -2
- package/dist/lib/secrets-client.d.ts +20 -0
- package/dist/lib/secrets-client.js +42 -19
- package/dist/lib/self-heal/checks/menubar-helper.d.ts +2 -0
- package/dist/lib/self-heal/checks/menubar-helper.js +21 -0
- package/dist/lib/self-heal/registry.js +3 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.d.ts +1 -1
- package/dist/lib/session/active.js +7 -0
- package/dist/lib/session/state.d.ts +7 -0
- package/package.json +1 -1
package/dist/lib/auth-health.js
CHANGED
|
@@ -32,14 +32,18 @@ export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
// Pure classifiers / render (unit-tested; no network, no fs)
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
|
-
/** Map an HTTP status from a live probe to a verdict. */
|
|
35
|
+
/** Map an HTTP status from a live probe to a verdict. Probe 429 never yields `rate_limited` (PHNX-4051). */
|
|
36
36
|
export function classifyHttpStatus(status) {
|
|
37
37
|
if (status >= 200 && status < 300)
|
|
38
38
|
return 'live';
|
|
39
39
|
if (status === 401 || status === 403)
|
|
40
40
|
return 'revoked';
|
|
41
|
+
// 429 is probe throttling, not an account throttle — the account's real
|
|
42
|
+
// throttle state comes from the usage snapshot (deriveUsageStatusFromSnapshot).
|
|
43
|
+
// Treating it as `rate_limited` let a burst-throttled endpoint mark every
|
|
44
|
+
// account LIMITED. See mergeAuthHealthEntries for the keep-or-unverified path.
|
|
41
45
|
if (status === 429)
|
|
42
|
-
return '
|
|
46
|
+
return 'error';
|
|
43
47
|
return 'error';
|
|
44
48
|
}
|
|
45
49
|
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
@@ -62,6 +66,8 @@ export function probeDetail(probe) {
|
|
|
62
66
|
if (probe.reason === 'usage_scope') {
|
|
63
67
|
return probe.error ?? USAGE_HEADLESS_SCOPE_MARKER;
|
|
64
68
|
}
|
|
69
|
+
if (probe.status === 429)
|
|
70
|
+
return 'probe throttled (HTTP 429)';
|
|
65
71
|
if (probe.status != null && (probe.status < 200 || probe.status >= 300))
|
|
66
72
|
return `HTTP ${probe.status}`;
|
|
67
73
|
if (probe.error)
|
|
@@ -345,10 +351,22 @@ export function readFleetAuthRows(host) {
|
|
|
345
351
|
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
346
352
|
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
347
353
|
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
348
|
-
* one").
|
|
354
|
+
* one"). A probe-throttled 429 (detail `probe throttled (HTTP 429)`) keeps the
|
|
355
|
+
* previous real verdict when one exists within {@link AUTH_PROBE_MAX_AGE_MS},
|
|
356
|
+
* otherwise becomes `unverified` (PHNX-4051). Pure, so it's unit-tested directly. */
|
|
349
357
|
export function mergeAuthHealthEntries(current, incoming) {
|
|
350
358
|
const merged = { ...current };
|
|
351
359
|
for (const [key, health] of Object.entries(incoming)) {
|
|
360
|
+
const isProbeThrottled = health.detail === 'probe throttled (HTTP 429)';
|
|
361
|
+
if (isProbeThrottled) {
|
|
362
|
+
const prev = merged[key];
|
|
363
|
+
const fresh = !!prev && prev.verdict !== 'error' && prev.verdict !== 'unconfigured'
|
|
364
|
+
&& (health.checkedAt - prev.checkedAt < AUTH_PROBE_MAX_AGE_MS);
|
|
365
|
+
if (fresh)
|
|
366
|
+
continue; // keep previous real verdict
|
|
367
|
+
merged[key] = { ...health, verdict: 'unverified' };
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
352
370
|
if (health.verdict === 'error' && merged[key])
|
|
353
371
|
continue; // keep last known
|
|
354
372
|
merged[key] = health;
|
|
@@ -470,13 +488,22 @@ export async function probeAuthHealth(agent, home, opts) {
|
|
|
470
488
|
* per-install probe (also the `unconfigured` case dropped downstream). Pure: no
|
|
471
489
|
* fs, no network, so the dedup decision is unit-tested directly.
|
|
472
490
|
*/
|
|
491
|
+
/** Small fixed delay between live probes so one box no longer fires 16 requests in 4s (PHNX-4051). */
|
|
492
|
+
export const AUTH_PROBE_SPACING_MS = 150;
|
|
473
493
|
export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
|
|
474
494
|
const groups = new Map();
|
|
475
495
|
for (const inst of installs) {
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
496
|
+
// Prefer stable accountId (identity) — a version home and its slot share the
|
|
497
|
+
// same id, so they collapse to ONE probe per identity (PHNX-4051). Fallback
|
|
498
|
+
// to the display label when no id is known. The `id:`/`acct:`/`ver:` tokens
|
|
499
|
+
// keep the three branches disjoint.
|
|
500
|
+
const mergeKey = inst.accountId
|
|
501
|
+
? `id:${inst.accountId}`
|
|
502
|
+
: inst.account
|
|
503
|
+
? `acct:${inst.account}`
|
|
504
|
+
: null;
|
|
505
|
+
const key = mergeKey && isMergeable(inst)
|
|
506
|
+
? `${inst.agent} ${mergeKey}`
|
|
480
507
|
: `${inst.agent} ver:${inst.version}`;
|
|
481
508
|
const existing = groups.get(key);
|
|
482
509
|
if (existing)
|
|
@@ -514,26 +541,37 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
514
541
|
// A slot already knows its account; a version home is joined by identity.
|
|
515
542
|
inst.accountId ??= findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
|
|
516
543
|
}
|
|
517
|
-
// Probe once per (agent,
|
|
544
|
+
// Probe once per (agent, identity) — but only for the network-probing agents
|
|
518
545
|
// that can actually 429; best-effort agents stay per-install (see
|
|
519
|
-
// groupFleetAuthInstalls). Groups
|
|
520
|
-
//
|
|
521
|
-
|
|
546
|
+
// groupFleetAuthInstalls). Groups are probed SEQUENTIALLY with a small fixed
|
|
547
|
+
// delay between them (PHNX-4051) so one box no longer fires 16 requests in
|
|
548
|
+
// 4s; they target distinct identities, so no same-account concurrency remains.
|
|
549
|
+
const groups = groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent));
|
|
550
|
+
const perGroup = [];
|
|
551
|
+
for (let idx = 0; idx < groups.length; idx++) {
|
|
552
|
+
const group = groups[idx];
|
|
522
553
|
const rep = group.probe;
|
|
523
554
|
const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
|
|
524
555
|
health.account = authAccountLabel(rep.info);
|
|
525
556
|
health.accountId = rep.accountId;
|
|
526
|
-
if (health.verdict === 'unconfigured')
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
557
|
+
if (health.verdict === 'unconfigured') {
|
|
558
|
+
perGroup.push([]);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
perGroup.push(group.members.map((inst) => ({
|
|
562
|
+
agent: inst.agent,
|
|
563
|
+
version: inst.version,
|
|
564
|
+
account: health.account,
|
|
565
|
+
accountId: inst.accountId ?? health.accountId,
|
|
566
|
+
// A distinct object per row so a later mutation of one can't bleed across.
|
|
567
|
+
health: { ...health },
|
|
568
|
+
})));
|
|
569
|
+
}
|
|
570
|
+
// Space probes; not after the last group and not if the tick is being aborted.
|
|
571
|
+
if (idx < groups.length - 1 && !opts?.signal?.aborted) {
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, AUTH_PROBE_SPACING_MS));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
537
575
|
return perGroup.flat();
|
|
538
576
|
}
|
|
539
577
|
/**
|
|
@@ -51,6 +51,13 @@ function tryAcquireLock(alias) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
function touchLock(alias) {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
try {
|
|
57
|
+
fs.utimesSync(lockFilePath(alias), now, now);
|
|
58
|
+
}
|
|
59
|
+
catch { /* lock gone; nothing to extend */ }
|
|
60
|
+
}
|
|
54
61
|
function releaseLock(alias) {
|
|
55
62
|
try {
|
|
56
63
|
fs.unlinkSync(lockFilePath(alias));
|
|
@@ -118,6 +125,36 @@ async function processTarget(target) {
|
|
|
118
125
|
releaseLock(target.alias);
|
|
119
126
|
}
|
|
120
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* macOS only: fetch the floor AGI Menu release into the verified cache when the
|
|
130
|
+
* installed helper is behind it and no bundle ships with this install, so the
|
|
131
|
+
* next foreground invocation's network-free self-heal has a source to install
|
|
132
|
+
* from (`sourceAppPath` candidate 4). Same lock discipline as the repo targets.
|
|
133
|
+
*/
|
|
134
|
+
async function prefetchMenubarHelperTarget() {
|
|
135
|
+
if (process.platform !== 'darwin')
|
|
136
|
+
return;
|
|
137
|
+
const alias = 'menubar-helper';
|
|
138
|
+
if (!tryAcquireLock(alias))
|
|
139
|
+
return;
|
|
140
|
+
// The zip fetch may run up to 15 min (helper-download.ts) while the lock
|
|
141
|
+
// reads as stale after SYNC_LOCK_TTL_MS, and the download lands through one
|
|
142
|
+
// shared partial file — so keep the lock's mtime fresh for as long as this
|
|
143
|
+
// worker is actually fetching, or a second worker races onto the same bytes.
|
|
144
|
+
const heartbeat = setInterval(() => touchLock(alias), SYNC_LOCK_TTL_MS / 2);
|
|
145
|
+
heartbeat.unref();
|
|
146
|
+
try {
|
|
147
|
+
const { prefetchMenubarHelper } = await import('./menubar/install-menubar.js');
|
|
148
|
+
await prefetchMenubarHelper();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* network / verification failures are non-fatal; the next cycle retries */
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearInterval(heartbeat);
|
|
155
|
+
releaseLock(alias);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
121
158
|
async function main() {
|
|
122
159
|
const targets = [];
|
|
123
160
|
const systemDir = getSystemAgentsDir();
|
|
@@ -133,7 +170,7 @@ async function main() {
|
|
|
133
170
|
targets.push({ alias: extra.alias, dir: extra.dir, mode: 'notify' });
|
|
134
171
|
}
|
|
135
172
|
}
|
|
136
|
-
await Promise.all(targets.map(processTarget));
|
|
173
|
+
await Promise.all([...targets.map(processTarget), prefetchMenubarHelperTarget()]);
|
|
137
174
|
// Stamp the cycle so the next foreground CLI invocation can skip the ~7ms
|
|
138
175
|
// detached spawn for SYNC_LOCK_TTL_MS (RUSH-2324). Written even when every
|
|
139
176
|
// target was lock-skipped or the target list was empty — "nothing to do"
|
|
@@ -36,6 +36,10 @@ export interface HarnessRow {
|
|
|
36
36
|
reason?: string;
|
|
37
37
|
verdict: AccountVerdict;
|
|
38
38
|
fix: string | null;
|
|
39
|
+
/** The live usage snapshot backing `quota`, or null when none was collected. */
|
|
40
|
+
snapshot: UsageSnapshot | null;
|
|
41
|
+
/** Raw error string from the usage fetch, if any (headless scope, expired, etc.). */
|
|
42
|
+
usageError: string | null;
|
|
39
43
|
}
|
|
40
44
|
/** One host's rows, or the reason it produced none. */
|
|
41
45
|
export interface HostHarnessResult {
|
|
@@ -123,7 +123,7 @@ export function applyUsageHonesty(verdict, usage) {
|
|
|
123
123
|
}
|
|
124
124
|
return { verdict, usage: stripped };
|
|
125
125
|
}
|
|
126
|
-
if (usage.status === 'rate_limited' && (verdict === 'live' || verdict === 'unverified')) {
|
|
126
|
+
if ((usage.status === 'rate_limited' || usage.status === 'out_of_credits') && (verdict === 'live' || verdict === 'unverified')) {
|
|
127
127
|
return { verdict: 'rate_limited', usage };
|
|
128
128
|
}
|
|
129
129
|
return { verdict, usage };
|
|
@@ -204,6 +204,8 @@ export async function collectLocalHarnessInventory(opts) {
|
|
|
204
204
|
reason,
|
|
205
205
|
verdict,
|
|
206
206
|
fix: fixFor({ agent, verdict, name: saved?.name, version, provisioning }),
|
|
207
|
+
snapshot,
|
|
208
|
+
usageError: usage?.error ?? null,
|
|
207
209
|
};
|
|
208
210
|
});
|
|
209
211
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import type { DetectedPr } from '../session/state.js';
|
|
3
|
+
import type { GhExec } from '../github/pr-mergeable.js';
|
|
2
4
|
import type { PullRequestAttentionSignal } from './attention.js';
|
|
3
5
|
export declare const PR_STATUS_TTL_MS = 45000;
|
|
4
6
|
export declare const PR_STATUS_FIELDS = "number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup";
|
|
@@ -9,6 +11,22 @@ export interface PullRequestStatus extends PullRequestAttentionSignal {
|
|
|
9
11
|
export declare function readPullRequestStatus(session: ActiveSession, options?: {
|
|
10
12
|
nowMs?: number;
|
|
11
13
|
ttlMs?: number;
|
|
14
|
+
gh?: GhExec;
|
|
12
15
|
}): Promise<PullRequestStatus | undefined>;
|
|
13
16
|
export declare function projectPullRequestBoard(sessions: ActiveSession[]): Promise<PullRequestStatus[]>;
|
|
14
17
|
export declare function resetPullRequestStatusCache(): void;
|
|
18
|
+
/**
|
|
19
|
+
* The check rollup folded to one verdict; undefined when the PR has no checks.
|
|
20
|
+
* `statusCheckRollup` mixes Checks-API runs (`conclusion` + `status`) with
|
|
21
|
+
* legacy Status-API contexts (`state` only), so both shapes are read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checksVerdict(rollup?: unknown[]): DetectedPr['checks'];
|
|
24
|
+
/**
|
|
25
|
+
* The row with its `pr` carrying the fetched status, so a consumer that only
|
|
26
|
+
* sees the agent row (the menu bar, the extension) can color the chip without
|
|
27
|
+
* its own `gh` call. A row without a PR, or a status that never resolved, is
|
|
28
|
+
* returned as is.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withPullRequestStatus<T extends {
|
|
31
|
+
pr?: DetectedPr;
|
|
32
|
+
}>(row: T, status?: PullRequestStatus): T;
|
|
@@ -24,12 +24,10 @@ export async function readPullRequestStatus(session, options = {}) {
|
|
|
24
24
|
const hit = cache.get(key);
|
|
25
25
|
if (hit && hit.expiresAt > now)
|
|
26
26
|
return hit.value;
|
|
27
|
+
const cwd = session.cwd;
|
|
28
|
+
const gh = options.gh ?? (async (args) => (await execFileAsync('gh', args, { cwd, timeout: 15_000, maxBuffer: 1024 * 1024 })).stdout);
|
|
27
29
|
try {
|
|
28
|
-
const
|
|
29
|
-
cwd: session.cwd,
|
|
30
|
-
timeout: 15_000,
|
|
31
|
-
maxBuffer: 1024 * 1024,
|
|
32
|
-
});
|
|
30
|
+
const stdout = await gh(['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS]);
|
|
33
31
|
const raw = JSON.parse(stdout);
|
|
34
32
|
const value = { ...raw, url: session.pr?.url, needsHuman: needsHuman(raw) };
|
|
35
33
|
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS), value });
|
|
@@ -45,3 +43,43 @@ export async function projectPullRequestBoard(sessions) {
|
|
|
45
43
|
return rows.filter((row) => row !== undefined);
|
|
46
44
|
}
|
|
47
45
|
export function resetPullRequestStatusCache() { cache.clear(); }
|
|
46
|
+
/**
|
|
47
|
+
* The check rollup folded to one verdict; undefined when the PR has no checks.
|
|
48
|
+
* `statusCheckRollup` mixes Checks-API runs (`conclusion` + `status`) with
|
|
49
|
+
* legacy Status-API contexts (`state` only), so both shapes are read.
|
|
50
|
+
*/
|
|
51
|
+
export function checksVerdict(rollup) {
|
|
52
|
+
if (!rollup || rollup.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
let pending = false;
|
|
55
|
+
for (const check of rollup) {
|
|
56
|
+
const row = check;
|
|
57
|
+
const verdict = (row.conclusion || row.state || '').toUpperCase();
|
|
58
|
+
if (['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(verdict))
|
|
59
|
+
return 'failing';
|
|
60
|
+
if (verdict === '' ? (row.status ?? '').toUpperCase() !== 'COMPLETED' : ['PENDING', 'EXPECTED', 'STALE'].includes(verdict))
|
|
61
|
+
pending = true;
|
|
62
|
+
}
|
|
63
|
+
return pending ? 'pending' : 'passing';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The row with its `pr` carrying the fetched status, so a consumer that only
|
|
67
|
+
* sees the agent row (the menu bar, the extension) can color the chip without
|
|
68
|
+
* its own `gh` call. A row without a PR, or a status that never resolved, is
|
|
69
|
+
* returned as is.
|
|
70
|
+
*/
|
|
71
|
+
export function withPullRequestStatus(row, status) {
|
|
72
|
+
if (!row.pr || !status)
|
|
73
|
+
return row;
|
|
74
|
+
const pr = {
|
|
75
|
+
...row.pr,
|
|
76
|
+
...(status.state !== undefined ? { state: status.state } : {}),
|
|
77
|
+
...(status.isDraft !== undefined ? { isDraft: status.isDraft } : {}),
|
|
78
|
+
...(status.reviewDecision !== undefined ? { reviewDecision: status.reviewDecision } : {}),
|
|
79
|
+
...(status.mergeable !== undefined ? { mergeable: status.mergeable } : {}),
|
|
80
|
+
};
|
|
81
|
+
const checks = checksVerdict(status.statusCheckRollup);
|
|
82
|
+
if (checks)
|
|
83
|
+
pr.checks = checks;
|
|
84
|
+
return { ...row, pr };
|
|
85
|
+
}
|
package/dist/lib/feed/watch.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus, type WatchLocalOptions } from '../session/watch.js';
|
|
2
2
|
import { type AttentionItem } from './attention.js';
|
|
3
3
|
import { type ActivityEvent } from './activity.js';
|
|
4
|
+
import type { GhExec } from '../github/pr-mergeable.js';
|
|
4
5
|
export declare const FEED_WATCH_VERSION: 1;
|
|
5
6
|
type Base = {
|
|
6
7
|
v: 1;
|
|
@@ -47,7 +48,7 @@ export declare class FeedWatchState {
|
|
|
47
48
|
constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
|
|
48
49
|
emit(event: FeedWatchPayload): FeedWatchEnvelope;
|
|
49
50
|
}
|
|
50
|
-
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
|
|
51
|
+
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState, gh?: GhExec): Promise<FeedWatchEnvelope[]>;
|
|
51
52
|
export interface WatchLocalFeedOptions {
|
|
52
53
|
scope: string;
|
|
53
54
|
signal: AbortSignal;
|
|
@@ -58,6 +59,8 @@ export interface WatchLocalFeedOptions {
|
|
|
58
59
|
reconcileMs?: number;
|
|
59
60
|
/** Session-watch inputs, forwarded verbatim to {@link watchLocalSessions}. */
|
|
60
61
|
sessions?: Pick<WatchLocalOptions, 'readCache' | 'readPrevious' | 'journalPath' | 'journalPollMs' | 'heartbeatMs'>;
|
|
62
|
+
/** The `gh` runner behind PR status; a test passes a recorded table. */
|
|
63
|
+
gh?: GhExec;
|
|
61
64
|
}
|
|
62
65
|
export declare function watchLocalFeed(options: WatchLocalFeedOptions): Promise<void>;
|
|
63
66
|
export declare function watchFleetFeed(options: {
|
package/dist/lib/feed/watch.js
CHANGED
|
@@ -11,7 +11,7 @@ import { watchLocalSessions } from '../session/watch.js';
|
|
|
11
11
|
import { readBlock, readResolution, blockIdForSession } from './feed.js';
|
|
12
12
|
import { reconcileAttention } from './attention.js';
|
|
13
13
|
import { ActivityStream } from './activity-stream.js';
|
|
14
|
-
import { PR_STATUS_TTL_MS, readPullRequestStatus } from './pr-status.js';
|
|
14
|
+
import { PR_STATUS_TTL_MS, readPullRequestStatus, withPullRequestStatus } from './pr-status.js';
|
|
15
15
|
export const FEED_WATCH_VERSION = 1;
|
|
16
16
|
export class FeedWatchState {
|
|
17
17
|
streamId;
|
|
@@ -21,36 +21,51 @@ export class FeedWatchState {
|
|
|
21
21
|
return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Live rows only: durable Previous rows share the operator stream for Sessions
|
|
26
|
+
* history, but they are not live work and must never synthesize Needs-you
|
|
27
|
+
* attention or a PR lookup.
|
|
28
|
+
*/
|
|
29
|
+
function isLive(agent) {
|
|
30
|
+
return Boolean(agent.sessionId) && !agent.previous && agent.context !== 'recent';
|
|
31
|
+
}
|
|
32
|
+
// ActiveSession.host names the terminal app; the feed contract's host is the
|
|
33
|
+
// device scope. Normalize only the reconciler input so lifecycle/PR keys are
|
|
34
|
+
// routable across the fleet while the projected agent row stays compatible.
|
|
35
|
+
function reconcilerSession(agent) {
|
|
36
|
+
return { ...agent, context: agent.context, host: agent.sourceDevice, viewingIn: undefined };
|
|
37
|
+
}
|
|
38
|
+
/** One `gh pr view` (cached 45 s) feeds both the attention verdict and the row's `pr` status. */
|
|
39
|
+
async function pullRequestFor(agent, gh) {
|
|
40
|
+
if (!isLive(agent) || !agent.pr)
|
|
41
|
+
return undefined;
|
|
42
|
+
return readPullRequestStatus(reconcilerSession(agent), { gh });
|
|
43
|
+
}
|
|
44
|
+
function attentionFor(agent, pullRequest) {
|
|
45
|
+
if (!isLive(agent) || !agent.sessionId)
|
|
28
46
|
return undefined;
|
|
29
47
|
const blockId = blockIdForSession(agent.sessionId);
|
|
30
|
-
// ActiveSession.host names the terminal app; the feed contract's host is the
|
|
31
|
-
// device scope. Normalize only the reconciler input so lifecycle/PR keys are
|
|
32
|
-
// routable across the fleet while the projected agent row stays compatible.
|
|
33
|
-
const session = {
|
|
34
|
-
...agent,
|
|
35
|
-
context: agent.context,
|
|
36
|
-
host: agent.sourceDevice,
|
|
37
|
-
viewingIn: undefined,
|
|
38
|
-
};
|
|
39
48
|
return reconcileAttention({
|
|
40
|
-
block: readBlock(blockId), session,
|
|
49
|
+
block: readBlock(blockId), session: reconcilerSession(agent),
|
|
41
50
|
resolution: readResolution(blockId),
|
|
42
|
-
pullRequest
|
|
51
|
+
pullRequest, nowMs: Date.now(),
|
|
43
52
|
});
|
|
44
53
|
}
|
|
45
|
-
|
|
54
|
+
/** The row as the feed projects it: the session row with its PR status attached. */
|
|
55
|
+
async function projectAgent(agent, gh) {
|
|
56
|
+
const pullRequest = await pullRequestFor(agent, gh);
|
|
57
|
+
return { agent: withPullRequestStatus(agent, pullRequest), attention: attentionFor(agent, pullRequest) };
|
|
58
|
+
}
|
|
59
|
+
export async function projectSessionEnvelope(event, state, gh) {
|
|
46
60
|
if (event.type === 'reset') {
|
|
47
|
-
const
|
|
48
|
-
|
|
61
|
+
const projected = await Promise.all(event.rows.map((row) => projectAgent(row, gh)));
|
|
62
|
+
const attention = projected.map((p) => p.attention).filter((item) => item !== undefined);
|
|
63
|
+
return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: projected.map((p) => p.agent), attention })];
|
|
49
64
|
}
|
|
50
65
|
if (event.type === 'upsert') {
|
|
51
|
-
const attention = await
|
|
66
|
+
const { agent, attention } = await projectAgent(event.row, gh);
|
|
52
67
|
return [
|
|
53
|
-
state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent
|
|
68
|
+
state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent }),
|
|
54
69
|
attention
|
|
55
70
|
? state.emit({ type: 'attention.upsert', scope: event.scope, rowKey: event.rowKey, attention })
|
|
56
71
|
: state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
|
|
@@ -99,10 +114,18 @@ export async function watchLocalFeed(options) {
|
|
|
99
114
|
let activityCursor = Date.now();
|
|
100
115
|
const agents = new Map();
|
|
101
116
|
const attention = new Map();
|
|
117
|
+
// The PR status last projected onto each row, so a merge or a check verdict
|
|
118
|
+
// that lands between session events still reaches the row's consumers.
|
|
119
|
+
const prStatus = new Map();
|
|
102
120
|
let pending = Promise.resolve();
|
|
103
121
|
const reconcileRows = async () => {
|
|
104
|
-
for (const [rowKey,
|
|
105
|
-
const item = await
|
|
122
|
+
for (const [rowKey, raw] of agents) {
|
|
123
|
+
const { agent, attention: item } = await projectAgent(raw, options.gh);
|
|
124
|
+
const nextPr = agent.pr ? JSON.stringify(agent.pr) : '';
|
|
125
|
+
if (agent.pr && prStatus.get(rowKey) !== nextPr) {
|
|
126
|
+
prStatus.set(rowKey, nextPr);
|
|
127
|
+
options.emit(state.emit({ type: 'agent.upsert', scope: options.scope, rowKey, agent }));
|
|
128
|
+
}
|
|
106
129
|
const next = item ? JSON.stringify(item) : '';
|
|
107
130
|
if (attention.get(rowKey) === next)
|
|
108
131
|
continue;
|
|
@@ -150,19 +173,28 @@ export async function watchLocalFeed(options) {
|
|
|
150
173
|
else if (event.type === 'remove') {
|
|
151
174
|
agents.delete(event.rowKey);
|
|
152
175
|
attention.delete(event.rowKey);
|
|
176
|
+
prStatus.delete(event.rowKey);
|
|
153
177
|
}
|
|
154
|
-
pending = pending.then(() => projectSessionEnvelope(event, state)).then((events) => {
|
|
178
|
+
pending = pending.then(() => projectSessionEnvelope(event, state, options.gh)).then((events) => {
|
|
155
179
|
for (const projected of events) {
|
|
156
180
|
if (projected.type === 'reset') {
|
|
157
181
|
attention.clear();
|
|
158
|
-
|
|
182
|
+
prStatus.clear();
|
|
183
|
+
for (const row of projected.agents) {
|
|
159
184
|
attention.set(row.rowKey, '');
|
|
185
|
+
if (row.pr)
|
|
186
|
+
prStatus.set(row.rowKey, JSON.stringify(row.pr));
|
|
187
|
+
}
|
|
160
188
|
for (const item of projected.attention) {
|
|
161
189
|
const row = projected.agents.find((agent) => agent.sessionId === item.sessionId);
|
|
162
190
|
if (row)
|
|
163
191
|
attention.set(row.rowKey, JSON.stringify(item));
|
|
164
192
|
}
|
|
165
193
|
}
|
|
194
|
+
else if (projected.type === 'agent.upsert') {
|
|
195
|
+
if (projected.agent.pr)
|
|
196
|
+
prStatus.set(projected.rowKey, JSON.stringify(projected.agent.pr));
|
|
197
|
+
}
|
|
166
198
|
else if (projected.type === 'attention.upsert')
|
|
167
199
|
attention.set(projected.rowKey, JSON.stringify(projected.attention));
|
|
168
200
|
else if (projected.type === 'attention.remove')
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* helper release now, off this table entirely.
|
|
37
37
|
*/
|
|
38
38
|
export const HELPER_RELEASES = {
|
|
39
|
-
menubar: { tagPrefix: 'menubar', floor: '1.
|
|
39
|
+
menubar: { tagPrefix: 'menubar', floor: '1.3.0' },
|
|
40
40
|
'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
|
|
41
41
|
// The Windows helper is a bare .exe, not an .app bundle, so it does not share
|
|
42
42
|
// helper-download.ts's zip/codesign/notarize machinery -- but it has the same
|
|
@@ -68,6 +68,22 @@ export declare function resolveInstalledMenubarExecutable(): string | null;
|
|
|
68
68
|
export declare function menubarDisabledByUser(): boolean;
|
|
69
69
|
/** True if the launchd plist for the menu-bar service is installed. */
|
|
70
70
|
export declare function menubarServiceInstalled(): boolean;
|
|
71
|
+
/** Where a downloaded copy of the floor release sits once fetched and verified. */
|
|
72
|
+
export declare function cachedFloorBundlePath(): string;
|
|
73
|
+
/**
|
|
74
|
+
* Where the downloaded copy of the RESOLVED release sits (`cachedMenubarVersion`,
|
|
75
|
+
* so the floor's cache dir until this machine has resolved something newer).
|
|
76
|
+
* The startup self-heal installs from here, network-free.
|
|
77
|
+
*/
|
|
78
|
+
export declare function cachedReleaseBundlePath(): string;
|
|
79
|
+
/**
|
|
80
|
+
* The release version an explicit install should fetch: the newest published
|
|
81
|
+
* build, but never below what this Mac already runs — a release deleted after
|
|
82
|
+
* it was installed must not roll the helper back through `setup`/`enable`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function menubarVersionToInstall(opts?: {
|
|
85
|
+
force?: boolean;
|
|
86
|
+
}): Promise<string>;
|
|
71
87
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
72
88
|
export declare function codesignVerifies(appPath: string): boolean;
|
|
73
89
|
/**
|
|
@@ -203,6 +219,33 @@ export declare function isMenubarStale(opts: {
|
|
|
203
219
|
available: MenubarStamp;
|
|
204
220
|
execExists: boolean;
|
|
205
221
|
}): boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Pure decision (no I/O): should the detached background worker download the
|
|
224
|
+
* floor release into the cache so the next startup self-heal has a source?
|
|
225
|
+
*
|
|
226
|
+
* The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
|
|
227
|
+
* network-free, and the npm tarball ships no bundle, so without this step an
|
|
228
|
+
* `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
|
|
229
|
+
* until someone runs `agents menubar setup` by hand. The worker fetches only
|
|
230
|
+
* when the fetch would change something: nothing else can act as a source, the
|
|
231
|
+
* user has not opted out, and either no service exists yet (the auto-enable the
|
|
232
|
+
* bootstrap promises) or the installed helper is older than the floor.
|
|
233
|
+
*/
|
|
234
|
+
export declare function menubarHelperPrefetchNeeded(opts: {
|
|
235
|
+
darwin: boolean;
|
|
236
|
+
disabledByUser: boolean;
|
|
237
|
+
/** `sourceAppPath()` found a bundle (shipped or already cached). */
|
|
238
|
+
hasSource: boolean;
|
|
239
|
+
serviceInstalled: boolean;
|
|
240
|
+
stale: boolean;
|
|
241
|
+
}): boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Background half of the release-path self-heal: download + verify the floor
|
|
244
|
+
* release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
|
|
245
|
+
* the cached path, or null when nothing was fetched. Run from the detached
|
|
246
|
+
* auto-pull worker; the foreground CLI never waits on it.
|
|
247
|
+
*/
|
|
248
|
+
export declare function prefetchMenubarHelper(): Promise<string | null>;
|
|
206
249
|
/**
|
|
207
250
|
* Pure decision (no I/O): did THIS heal actually replace bundle content a live
|
|
208
251
|
* process could be running stale, as opposed to a plist-only repoint?
|
|
@@ -482,3 +525,45 @@ export declare function isMenubarProcessStaleAgainstBundle(pidStartedAtMs: numbe
|
|
|
482
525
|
* remains the command that fixes what this reports.
|
|
483
526
|
*/
|
|
484
527
|
export declare function buildMenubarDoctorReport(): MenubarDoctorReport;
|
|
528
|
+
/** Outcome of one auto-update pass (`updateMenubarHelperIfNewer`). */
|
|
529
|
+
export interface MenubarUpdateResult {
|
|
530
|
+
outcome: 'updated' | 'current' | 'skipped' | 'failed';
|
|
531
|
+
installed: string | null;
|
|
532
|
+
available: string;
|
|
533
|
+
detail: string;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Bring an installed release helper up to the newest published build, without
|
|
537
|
+
* a human running `agents menubar setup`.
|
|
538
|
+
*
|
|
539
|
+
* Runs from the daemon's self-heal tick and right after `agents upgrade`. It
|
|
540
|
+
* only ever moves a RELEASE install forward: a machine whose helper came from a
|
|
541
|
+
* local build (a dev checkout) keeps it, a machine that never enabled the menu
|
|
542
|
+
* bar or opted out is left alone, and the ownership contest that bounds
|
|
543
|
+
* multi-install churn (`mayInstallMenubarHelper`) still applies. The bundle is
|
|
544
|
+
* downloaded and verified by the same path every install uses (sha256,
|
|
545
|
+
* codesign, Team, designated-requirement pin, notarization), swapped
|
|
546
|
+
* atomically under the install lock at the same path and identity — which is
|
|
547
|
+
* what keeps the Accessibility grant — and the running helper is restarted
|
|
548
|
+
* from the new binary (`restartMenubarHelperAfterSwap`; launchd's KeepAlive
|
|
549
|
+
* relaunches it within seconds). `dryRun` reports what would happen. Never
|
|
550
|
+
* throws.
|
|
551
|
+
*/
|
|
552
|
+
/**
|
|
553
|
+
* Pure (no I/O): whether an auto-update pass should proceed, and why not.
|
|
554
|
+
* `shipped` is a bundle that ships WITH this install (never a downloaded
|
|
555
|
+
* release cache) — CLI upgrades own that one. Returns null to proceed.
|
|
556
|
+
*/
|
|
557
|
+
export declare function menubarUpdateSkipReason(opts: {
|
|
558
|
+
darwin: boolean;
|
|
559
|
+
disabledByUser: boolean;
|
|
560
|
+
serviceInstalled: boolean;
|
|
561
|
+
shipped: boolean;
|
|
562
|
+
installed: MenubarStamp | null;
|
|
563
|
+
}): string | null;
|
|
564
|
+
/** Pure (no I/O): what the pass does given the installed and available versions. */
|
|
565
|
+
export declare function menubarUpdateOutcome(installed: string, available: string): 'current' | 'updated';
|
|
566
|
+
export declare function updateMenubarHelperIfNewer(opts?: {
|
|
567
|
+
dryRun?: boolean;
|
|
568
|
+
force?: boolean;
|
|
569
|
+
}): Promise<MenubarUpdateResult>;
|