@phnx-labs/agents-cli 1.22.100 → 1.22.102

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.
@@ -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 'rate_limited';
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"). Pure, so it's unit-tested directly. */
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
- // The `acct:` / `ver:` tokens make the two branches disjoint, so an account
477
- // label can never collide with a version fallback key no matter its content.
478
- const key = inst.account && isMergeable(inst)
479
- ? `${inst.agent} acct:${inst.account}`
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, account) — but only for the network-probing agents
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 run in parallel: they target distinct
520
- // accounts, so no same-account concurrency is left to trip the throttle.
521
- const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
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
- return [];
528
- return group.members.map((inst) => ({
529
- agent: inst.agent,
530
- version: inst.version,
531
- account: health.account,
532
- accountId: inst.accountId ?? health.accountId,
533
- // A distinct object per row so a later mutation of one can't bleed across.
534
- health: { ...health },
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
  }
@@ -24,7 +24,7 @@ import { pullRepo } from './git.js';
24
24
  import { repairAfterSync, renderRepairAfterSync } from './reconcile-and-repair.js';
25
25
  import { promptAgentVersionSelection } from './installations/versions.js';
26
26
  import { isInteractiveTerminal, isPromptCancelled } from './format.js';
27
- import { computeSyncStatus, } from './sync-status.js';
27
+ import { computeSyncStatus, formatDriftRows } from './sync-status.js';
28
28
  const agentName = (id) => AGENTS[id]?.name ?? id;
29
29
  /** "claude@2.1.170 2 drifted · 1 missing" for one version. */
30
30
  function versionLine(v) {
@@ -42,8 +42,11 @@ function renderSummary(status, needing) {
42
42
  if (status.system.behind > 0) {
43
43
  console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
44
44
  }
45
- for (const v of needing)
45
+ for (const v of needing) {
46
46
  console.log(versionLine(v));
47
+ for (const line of formatDriftRows(v))
48
+ console.log(chalk.gray(` ${line}`));
49
+ }
47
50
  if (status.totals.orphan > 0) {
48
51
  console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
49
52
  }
package/dist/lib/exec.js CHANGED
@@ -39,6 +39,7 @@ import { probeUnprivilegedUserns } from './linux-userns.js';
39
39
  import { resolveClaudeSetupToken } from './claude-account-token.js';
40
40
  import { applyAddDirs } from './add-dir.js';
41
41
  import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
42
+ import { applySystemResourcesAtRun } from './system-run-sync.js';
42
43
  import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
43
44
  import { resolveConfigVersion } from './harness/exec-config-version.js';
44
45
  import { getAccountInfo } from './agents.js';
@@ -2406,6 +2407,7 @@ export async function runWithFallback(options) {
2406
2407
  const rulesVersion = version ?? resolveVersion(agent);
2407
2408
  if (rulesVersion) {
2408
2409
  applyActiveRulesPresetAtRun(agent, rulesVersion, getVersionHomePath(agent, rulesVersion));
2410
+ applySystemResourcesAtRun(agent, rulesVersion, getVersionHomePath(agent, rulesVersion));
2409
2411
  }
2410
2412
  // Record the entry we're about to attempt so the caller (audit log) sees the
2411
2413
  // agent+version that actually ran, even after a rate-limit handoff.
@@ -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 { stdout } = await execFileAsync('gh', ['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS], {
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
+ }
@@ -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: {
@@ -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
- async function attentionFor(agent) {
25
- // Durable Previous rows share the operator stream for Sessions history, but
26
- // they are not live work and must never synthesize Needs-you attention.
27
- if (!agent.sessionId || agent.previous || agent.context === 'recent')
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: await readPullRequestStatus(session), nowMs: Date.now(),
51
+ pullRequest, nowMs: Date.now(),
43
52
  });
44
53
  }
45
- export async function projectSessionEnvelope(event, state) {
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 attention = (await Promise.all(event.rows.map(attentionFor))).filter((item) => item !== undefined);
48
- return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: event.rows, attention })];
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 attentionFor(event.row);
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: event.row }),
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, agent] of agents) {
105
- const item = await attentionFor(agent);
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
- for (const row of projected.agents)
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.1.4' },
39
+ menubar: { tagPrefix: 'menubar', floor: '1.2.3' },
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,8 @@ 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;
71
73
  /** True when the bundle carries a signature the kernel will accept at launch. */
72
74
  export declare function codesignVerifies(appPath: string): boolean;
73
75
  /**
@@ -203,6 +205,33 @@ export declare function isMenubarStale(opts: {
203
205
  available: MenubarStamp;
204
206
  execExists: boolean;
205
207
  }): boolean;
208
+ /**
209
+ * Pure decision (no I/O): should the detached background worker download the
210
+ * floor release into the cache so the next startup self-heal has a source?
211
+ *
212
+ * The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
213
+ * network-free, and the npm tarball ships no bundle, so without this step an
214
+ * `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
215
+ * until someone runs `agents menubar setup` by hand. The worker fetches only
216
+ * when the fetch would change something: nothing else can act as a source, the
217
+ * user has not opted out, and either no service exists yet (the auto-enable the
218
+ * bootstrap promises) or the installed helper is older than the floor.
219
+ */
220
+ export declare function menubarHelperPrefetchNeeded(opts: {
221
+ darwin: boolean;
222
+ disabledByUser: boolean;
223
+ /** `sourceAppPath()` found a bundle (shipped or already cached). */
224
+ hasSource: boolean;
225
+ serviceInstalled: boolean;
226
+ stale: boolean;
227
+ }): boolean;
228
+ /**
229
+ * Background half of the release-path self-heal: download + verify the floor
230
+ * release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
231
+ * the cached path, or null when nothing was fetched. Run from the detached
232
+ * auto-pull worker; the foreground CLI never waits on it.
233
+ */
234
+ export declare function prefetchMenubarHelper(): Promise<string | null>;
206
235
  /**
207
236
  * Pure decision (no I/O): did THIS heal actually replace bundle content a live
208
237
  * process could be running stale, as opposed to a plist-only repoint?
@@ -171,6 +171,13 @@ export function menubarServiceInstalled() {
171
171
  * binary: `import.meta.url` is a virtual `/$bunfs/` path, so the sibling
172
172
  * candidates above can't see the on-disk bundle; recover it via the
173
173
  * `agents` launcher symlink.
174
+ * 4. the verified download cache for the FLOOR release — the npm tarball ships
175
+ * no bundle (PHNX-4036), so on an `npm i -g` machine this is the only
176
+ * source there is. It is filled by `agents menubar setup` or by the
177
+ * detached background worker (`prefetchMenubarHelper`), never here: this
178
+ * resolver stays network-free so the startup self-heal stays cheap. Last
179
+ * so a bundle that ships with the build always wins; keyed by the floor
180
+ * version so an older cached release is never picked up.
174
181
  */
175
182
  function sourceAppPath() {
176
183
  const candidates = [];
@@ -196,8 +203,15 @@ function sourceAppPath() {
196
203
  if (fs.existsSync(p))
197
204
  return p;
198
205
  }
206
+ const cached = cachedFloorBundlePath();
207
+ if (fs.existsSync(cached))
208
+ return cached;
199
209
  return null;
200
210
  }
211
+ /** Where a downloaded copy of the floor release sits once fetched and verified. */
212
+ export function cachedFloorBundlePath() {
213
+ return path.join(menubarHelperCacheDir(helperFloor('menubar')), APP_BUNDLE_NAME);
214
+ }
201
215
  /** Resolve the compiled CLI entry (dist/index.js) so the helper can exec node directly. */
202
216
  function resolveCliEntry() {
203
217
  try {
@@ -640,6 +654,41 @@ function menubarSetupStale() {
640
654
  execExists: fs.existsSync(installedExecutablePath()),
641
655
  });
642
656
  }
657
+ /**
658
+ * Pure decision (no I/O): should the detached background worker download the
659
+ * floor release into the cache so the next startup self-heal has a source?
660
+ *
661
+ * The self-heal (`installMenubarLaunchAgentOnUpgrade`) is deliberately
662
+ * network-free, and the npm tarball ships no bundle, so without this step an
663
+ * `npm i -g` Mac keeps whatever helper it has — a crashing 1.1.2, a dead 0.1.0 —
664
+ * until someone runs `agents menubar setup` by hand. The worker fetches only
665
+ * when the fetch would change something: nothing else can act as a source, the
666
+ * user has not opted out, and either no service exists yet (the auto-enable the
667
+ * bootstrap promises) or the installed helper is older than the floor.
668
+ */
669
+ export function menubarHelperPrefetchNeeded(opts) {
670
+ if (!opts.darwin || opts.disabledByUser || opts.hasSource)
671
+ return false;
672
+ return !opts.serviceInstalled || opts.stale;
673
+ }
674
+ /**
675
+ * Background half of the release-path self-heal: download + verify the floor
676
+ * release into the cache when `menubarHelperPrefetchNeeded` says so. Returns
677
+ * the cached path, or null when nothing was fetched. Run from the detached
678
+ * auto-pull worker; the foreground CLI never waits on it.
679
+ */
680
+ export async function prefetchMenubarHelper() {
681
+ const needed = menubarHelperPrefetchNeeded({
682
+ darwin: onDarwin(),
683
+ disabledByUser: menubarDisabledByUser(),
684
+ hasSource: Boolean(sourceAppPath()),
685
+ serviceInstalled: menubarServiceInstalled(),
686
+ stale: menubarSetupStale(),
687
+ });
688
+ if (!needed)
689
+ return null;
690
+ return downloadMenubarHelperApp(helperFloor('menubar'));
691
+ }
643
692
  /**
644
693
  * Pure decision (no I/O): did THIS heal actually replace bundle content a live
645
694
  * process could be running stale, as opposed to a plist-only repoint?
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;