@phnx-labs/agents-cli 1.22.59 → 1.22.61
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 +71 -0
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +35 -1
- package/dist/commands/harness-hooks.d.ts +55 -0
- package/dist/commands/harness-hooks.js +104 -0
- package/dist/commands/harness-wizard.d.ts +33 -14
- package/dist/commands/harness-wizard.js +53 -23
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +86 -5
- package/dist/commands/perf.js +10 -0
- package/dist/commands/reminders.d.ts +9 -0
- package/dist/commands/reminders.js +49 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +13 -0
- package/dist/commands/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions.js +13 -11
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +3 -3
- package/dist/commands/teams.js +86 -24
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +240 -16
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- package/dist/lib/browser/drivers/local.d.ts +11 -0
- package/dist/lib/browser/drivers/local.js +26 -0
- package/dist/lib/browser/profiles.js +8 -6
- package/dist/lib/browser/service.d.ts +12 -8
- package/dist/lib/browser/service.js +38 -10
- package/dist/lib/claude-statusline.d.ts +14 -1
- package/dist/lib/claude-statusline.js +27 -2
- package/dist/lib/daemon/runner.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +22 -4
- package/dist/lib/doctor-diff.d.ts +21 -5
- package/dist/lib/doctor-diff.js +242 -76
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +28 -15
- package/dist/lib/github/gh-overload.d.ts +58 -0
- package/dist/lib/github/gh-overload.js +246 -0
- package/dist/lib/github/rest.d.ts +64 -0
- package/dist/lib/github/rest.js +111 -0
- package/dist/lib/harness-connection-test.d.ts +57 -0
- package/dist/lib/harness-connection-test.js +80 -0
- package/dist/lib/heal.js +8 -3
- package/dist/lib/installations/shims.d.ts +22 -0
- package/dist/lib/installations/shims.js +104 -0
- package/dist/lib/linear-project-counts.js +8 -0
- package/dist/lib/linear-rate-limit.d.ts +26 -0
- package/dist/lib/linear-rate-limit.js +163 -0
- package/dist/lib/mcp.d.ts +9 -0
- package/dist/lib/mcp.js +37 -1
- package/dist/lib/open-url.js +5 -3
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- package/dist/lib/permissions.d.ts +28 -0
- package/dist/lib/permissions.js +156 -1
- package/dist/lib/refresh.js +9 -1
- package/dist/lib/reminders.d.ts +29 -0
- package/dist/lib/reminders.js +88 -0
- package/dist/lib/resource-content-diff.d.ts +33 -0
- package/dist/lib/resource-content-diff.js +103 -0
- package/dist/lib/rules/compile.d.ts +7 -0
- package/dist/lib/rules/compile.js +7 -1
- package/dist/lib/session/active.d.ts +41 -4
- package/dist/lib/session/active.js +58 -7
- package/dist/lib/session/host-link.d.ts +22 -0
- package/dist/lib/session/host-link.js +40 -4
- package/dist/lib/session/live-metadata.js +3 -3
- package/dist/lib/session/trajectory.d.ts +42 -0
- package/dist/lib/session/trajectory.js +46 -27
- package/dist/lib/ssh-exec.d.ts +30 -0
- package/dist/lib/ssh-exec.js +37 -5
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/subagents-registry.d.ts +18 -0
- package/dist/lib/subagents-registry.js +79 -0
- package/dist/lib/teams/agents.d.ts +12 -0
- package/dist/lib/teams/agents.js +51 -0
- package/dist/lib/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- package/dist/lib/traces/schema2-build.d.ts +85 -0
- package/dist/lib/traces/schema2-build.js +637 -0
- package/dist/lib/traces/schema2-danger.d.ts +36 -0
- package/dist/lib/traces/schema2-danger.js +185 -0
- package/dist/lib/traces/schema2.d.ts +149 -0
- package/dist/lib/traces/schema2.js +20 -0
- package/dist/lib/traces/sync.d.ts +93 -0
- package/dist/lib/traces/sync.js +75 -22
- package/dist/lib/traces/worker-template.js +5 -0
- package/dist/lib/uninstall.js +10 -1
- package/dist/lib/workflows.d.ts +11 -0
- package/dist/lib/workflows.js +67 -8
- package/package.json +1 -1
|
@@ -131,6 +131,26 @@ export function isUsageVerified(candidate, nowMs = Date.now()) {
|
|
|
131
131
|
return false;
|
|
132
132
|
return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether this candidate carries a STALE-but-present usage number: a snapshot
|
|
136
|
+
* with windows whose capture time is older than {@link USAGE_DECISION_MAX_AGE_MS}.
|
|
137
|
+
*
|
|
138
|
+
* This is the misleading case the initial route must refuse — the number reads
|
|
139
|
+
* "48% used" with the same confidence whether captured a minute or three days
|
|
140
|
+
* ago, and a box whose refresh is failing stays wrong indefinitely. It is
|
|
141
|
+
* deliberately NARROWER than "not verified": a BLIND candidate with no snapshot
|
|
142
|
+
* (or a plan-only meterless one with no windows) carries no number to be misled
|
|
143
|
+
* by — a worker box whose usage endpoint 403s (RUSH-2392), or a meterless Grok
|
|
144
|
+
* login — so it is not "stale", and an entirely-blind pool still draws a pick
|
|
145
|
+
* (PHNX-3392) rather than fail loud with NO_VERIFIED_USAGE.
|
|
146
|
+
*/
|
|
147
|
+
export function hasStaleUsage(candidate, nowMs = Date.now()) {
|
|
148
|
+
const snapshot = candidate.usageSnapshot;
|
|
149
|
+
const capturedAt = snapshot?.capturedAt;
|
|
150
|
+
if (!capturedAt || !snapshot?.windows.length)
|
|
151
|
+
return false;
|
|
152
|
+
return nowMs - capturedAt.getTime() > USAGE_DECISION_MAX_AGE_MS;
|
|
153
|
+
}
|
|
134
154
|
function hasUsageAvailable(candidate) {
|
|
135
155
|
const snapshot = candidate.usageSnapshot;
|
|
136
156
|
if (snapshot) {
|
|
@@ -313,8 +333,8 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
|
|
|
313
333
|
if (!deduped.has(c))
|
|
314
334
|
excluded.push(c);
|
|
315
335
|
}
|
|
316
|
-
const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
|
|
317
|
-
return { picked, healthy: sorted, excluded, usageUnverified };
|
|
336
|
+
const { picked, usageUnverified, noVerifiedUsage } = preferVerified(sorted, nowMs, (from) => weightedRandomByCapacity(from, nowMs), 'representative');
|
|
337
|
+
return { picked, healthy: sorted, excluded, usageUnverified, noVerifiedUsage };
|
|
318
338
|
}
|
|
319
339
|
/**
|
|
320
340
|
* Choose from the VERIFIED candidates when they are a representative share of
|
|
@@ -361,9 +381,17 @@ function preferVerified(pool, nowMs, choose, narrowing = 'any-verified') {
|
|
|
361
381
|
const narrow = verified.length > 0 &&
|
|
362
382
|
(narrowing === 'any-verified' || verified.length >= Math.ceil(pool.length / 2));
|
|
363
383
|
const picked = choose(narrow ? verified : pool);
|
|
384
|
+
// Entirely-stale = zero verified AND at least one stale-but-present number. A
|
|
385
|
+
// BLIND pool (no snapshots at all) does NOT trip this — it carries no
|
|
386
|
+
// misleading figure, so it still draws a pick (see {@link hasStaleUsage}). The
|
|
387
|
+
// chosen `picked` is still returned so `healthy` (which includes it) stays
|
|
388
|
+
// whole for bounded post-rejection failover; the INITIAL selection acts on
|
|
389
|
+
// this flag instead of launching that stale pick.
|
|
390
|
+
const noVerifiedUsage = verified.length === 0 && pool.some((c) => hasStaleUsage(c, nowMs));
|
|
364
391
|
return {
|
|
365
392
|
picked,
|
|
366
393
|
usageUnverified: !isUsageVerified(picked, nowMs),
|
|
394
|
+
noVerifiedUsage,
|
|
367
395
|
};
|
|
368
396
|
}
|
|
369
397
|
// capacityWeight + PROJECTION_HORIZON_MIN moved to ./capacity.js (a pure,
|
|
@@ -376,8 +404,16 @@ export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
|
376
404
|
* routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
|
|
377
405
|
* near-exhausted-but-still-eligible candidate can still be picked occasionally.
|
|
378
406
|
*/
|
|
379
|
-
function weightedRandomByCapacity(sorted) {
|
|
380
|
-
const weights = sorted.map((c) => capacityWeight(
|
|
407
|
+
function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
|
|
408
|
+
const weights = sorted.map((c) => capacityWeight(
|
|
409
|
+
// An unverified (stale or absent) snapshot carries no trustworthy number,
|
|
410
|
+
// so it weights as UNVERIFIED_WEIGHT (the floor) instead of by its frozen
|
|
411
|
+
// usedPercent. Otherwise a day-old "45% used" competes as if it were live
|
|
412
|
+
// headroom and can win the draw over a verified-healthy account — which is
|
|
413
|
+
// exactly how balanced launched into an account already at its weekly cap
|
|
414
|
+
// on a worker whose usage refresh had stalled (PHNX-3479). A verified
|
|
415
|
+
// snapshot keeps its real remaining-headroom weight.
|
|
416
|
+
isUsageVerified(c, nowMs) ? getRoutingUsedPercent(c.usageSnapshot) : null, c.usageMinutesToLimit));
|
|
381
417
|
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
382
418
|
if (total <= 0)
|
|
383
419
|
return sorted[0];
|
|
@@ -416,13 +452,19 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
|
|
|
416
452
|
// unconfirmed "48% used" outranks an accurate "90% used" — the same inversion
|
|
417
453
|
// that put a launch on an exhausted account under `balanced`. It routes on the
|
|
418
454
|
// same cache, so it gets the same rule: confirmed headroom first.
|
|
419
|
-
const { picked: bestVerified, usageUnverified } = preferVerified(sorted, nowMs, (from) => from[0]);
|
|
455
|
+
const { picked: bestVerified, usageUnverified, noVerifiedUsage } = preferVerified(sorted, nowMs, (from) => from[0]);
|
|
420
456
|
// An explicit version preference is an instruction, not a ranking signal, so it
|
|
421
457
|
// still wins — but only while that version is actually eligible.
|
|
422
458
|
const preferred = preferredVersion
|
|
423
459
|
? sorted.find((candidate) => candidate.version === preferredVersion)
|
|
424
460
|
: undefined;
|
|
425
|
-
|
|
461
|
+
// `noVerifiedUsage` rides along even when a `preferred` default resolves: an
|
|
462
|
+
// all-stale pool can only make `preferred` a stale pick too (a verified
|
|
463
|
+
// preferred would make verified.length > 0), and auto-selecting the default
|
|
464
|
+
// pin on a stale number is the very thing PHNX-2526 refuses. The initial
|
|
465
|
+
// route (resolveRunVersion) acts on the flag; the failover chain keeps
|
|
466
|
+
// `healthy` regardless.
|
|
467
|
+
return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified, noVerifiedUsage };
|
|
426
468
|
}
|
|
427
469
|
/**
|
|
428
470
|
* Classify every harness's candidates into healthy (with a representative
|
|
@@ -485,7 +527,7 @@ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
|
|
|
485
527
|
const excluded = summaries.filter((s) => s.best === null);
|
|
486
528
|
if (healthy.length === 0)
|
|
487
529
|
return null;
|
|
488
|
-
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
|
|
530
|
+
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best), nowMs);
|
|
489
531
|
const picked = healthy.find((s) => s.best === pickedBest);
|
|
490
532
|
return { picked, healthy, excluded };
|
|
491
533
|
}
|
|
@@ -539,6 +581,37 @@ export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = D
|
|
|
539
581
|
const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
|
|
540
582
|
return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
|
|
541
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* How old this candidate's usage snapshot is, in whole minutes, or null when it
|
|
586
|
+
* carries no dated snapshot (a blind account). Used only to explain WHY a route
|
|
587
|
+
* was refused as unverified — never to route on.
|
|
588
|
+
*/
|
|
589
|
+
function snapshotAgeMinutes(candidate, nowMs) {
|
|
590
|
+
const capturedAt = candidate.usageSnapshot?.capturedAt;
|
|
591
|
+
if (!capturedAt || !candidate.usageSnapshot?.windows.length)
|
|
592
|
+
return null;
|
|
593
|
+
return Math.max(0, Math.round((nowMs - capturedAt.getTime()) / 60_000));
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The all-stale-usage error (PHNX-2526) an UNATTENDED `balanced`/`available`
|
|
597
|
+
* run fails loud with when no account's usage is fresh enough to route on. EXACT
|
|
598
|
+
* contract — it MUST contain the literal `NO_VERIFIED_USAGE` so a machine caller
|
|
599
|
+
* (and the Factory watchdog) can tail-detect it distinctly from the
|
|
600
|
+
* `no healthy` throttle error, which is a different condition (throttled vs
|
|
601
|
+
* merely stale). Names each candidate with how stale its snapshot is, so the
|
|
602
|
+
* operator can see the failing-refresh box rather than guess.
|
|
603
|
+
*/
|
|
604
|
+
export function formatNoVerifiedUsageError(agent, strategy, candidates, nowMs = Date.now()) {
|
|
605
|
+
const detail = candidates.length === 0
|
|
606
|
+
? 'no signed-in accounts'
|
|
607
|
+
: candidates.map((c) => {
|
|
608
|
+
const age = snapshotAgeMinutes(c, nowMs);
|
|
609
|
+
const staleness = age === null ? 'no usage snapshot' : `usage ${age}m old`;
|
|
610
|
+
return `${c.version} (${staleness})`;
|
|
611
|
+
}).join(', ');
|
|
612
|
+
const maxAgeMin = Math.round(USAGE_DECISION_MAX_AGE_MS / 60_000);
|
|
613
|
+
return `agents: NO_VERIFIED_USAGE — no signed-in ${agent} account has usage newer than ${maxAgeMin}m under strategy '${strategy}', so routing refuses to guess on a stale number: ${detail}. Refresh usage (agents view ${agent}) or pin the default with --strategy pinned.`;
|
|
614
|
+
}
|
|
542
615
|
/**
|
|
543
616
|
* The zero-healthy-harness error for `agents run auto` — names each harness's
|
|
544
617
|
* exclusion reason plus the earliest reset across all snapshots.
|
|
@@ -716,6 +789,141 @@ function readRotationStamp(agent) {
|
|
|
716
789
|
catch { /* missing or corrupt — treat as no stamp */ }
|
|
717
790
|
return null;
|
|
718
791
|
}
|
|
792
|
+
/** Cap on candidates serialized into a rotation decision event — a pathological
|
|
793
|
+
* backstop on the log line; a real fleet is ~19 accounts, well under it. The
|
|
794
|
+
* candidates are emitted as a keyed OBJECT (not an array) so the event sink's
|
|
795
|
+
* generic `sanitizeNested` does not truncate them to its 10-element array cap
|
|
796
|
+
* (feed/events.ts) — an object is recursed uncapped, so the whole pool up to
|
|
797
|
+
* this bound survives. */
|
|
798
|
+
const ROTATION_EVENT_CANDIDATE_CAP = 32;
|
|
799
|
+
/**
|
|
800
|
+
* Compact, queryable descriptor of ONE candidate exactly as the router saw it,
|
|
801
|
+
* for the `rotation.resolved`/`rotation.unresolved` event. These are the fields
|
|
802
|
+
* that disambiguate WHY a route landed on a bad account, so a post-mortem reads
|
|
803
|
+
* them from `agents events` instead of guessing:
|
|
804
|
+
*
|
|
805
|
+
* - `usageKey` is the per-org quota key — the ONLY identity that joins the same
|
|
806
|
+
* account across devices (version numbers are device-local and meaningless
|
|
807
|
+
* across the fleet).
|
|
808
|
+
* - `tier` is the freshness class the weighting actually used: `verified`
|
|
809
|
+
* (fresh windowed → routed at real headroom), `stale` (windowed but past the
|
|
810
|
+
* {@link USAGE_DECISION_MAX_AGE_MS} decision window → floored weight), `blind`
|
|
811
|
+
* (no snapshot at all — a worker whose usage endpoint 403s, or a never-synced
|
|
812
|
+
* network:false harness → still drawn at floor weight).
|
|
813
|
+
* - `source`/`ageMs`/`capturedAt` expose staleness and provenance: a `last_seen`
|
|
814
|
+
* snapshot showing `tier: verified` with a large `ageMs` is the cross-host
|
|
815
|
+
* clock-skew failure (capturedAt is stamped by the reading host's clock, this
|
|
816
|
+
* `ageMs` by the routing host's — they disagree under skew).
|
|
817
|
+
*/
|
|
818
|
+
function describeRotationCandidate(c, nowMs) {
|
|
819
|
+
const snap = c.usageSnapshot;
|
|
820
|
+
const capturedAtMs = snap?.capturedAt ? snap.capturedAt.getTime() : null;
|
|
821
|
+
const tier = isUsageVerified(c, nowMs) ? 'verified' : hasStaleUsage(c, nowMs) ? 'stale' : 'blind';
|
|
822
|
+
const readiness = readinessFromCandidate(c);
|
|
823
|
+
return {
|
|
824
|
+
usageKey: c.usageKey,
|
|
825
|
+
// A RUSH-3182 provider/setup-token account carries a null usageKey and
|
|
826
|
+
// shares its `version` with the native login and every sibling provider
|
|
827
|
+
// account, so neither is a unique identity here. `accountKey` is the pool's
|
|
828
|
+
// own dedup boundary (foldRegistryCandidates), and `providerAccount` names
|
|
829
|
+
// the injected account — together they keep same-version rows distinct.
|
|
830
|
+
accountKey: c.accountKey,
|
|
831
|
+
providerAccount: c.providerAccount ?? null,
|
|
832
|
+
email: c.email,
|
|
833
|
+
version: c.version,
|
|
834
|
+
signedIn: c.signedIn,
|
|
835
|
+
// NOT `authVerdict`: the event sink's generic sanitizer redacts any payload
|
|
836
|
+
// key matching /auth/i to "[REDACTED]" (feed/events.ts SENSITIVE_PAYLOAD_KEY),
|
|
837
|
+
// which would silently blank this field on every row. `credentialVerdict`
|
|
838
|
+
// carries the same value ('revoked'/null/…) past the redaction.
|
|
839
|
+
credentialVerdict: c.authVerdict,
|
|
840
|
+
usageStatus: c.usageStatus,
|
|
841
|
+
tier,
|
|
842
|
+
source: snap?.source ?? null,
|
|
843
|
+
sourceLabel: snap?.sourceLabel ?? null,
|
|
844
|
+
capturedAt: snap?.capturedAt ? snap.capturedAt.toISOString() : null,
|
|
845
|
+
ageMs: capturedAtMs === null ? null : nowMs - capturedAtMs,
|
|
846
|
+
windows: (snap?.windows ?? []).map((w) => ({ key: w.key, usedPercent: Math.round(w.usedPercent) })),
|
|
847
|
+
unavailable: snap?.unavailable?.reason ?? null,
|
|
848
|
+
eligible: readiness.ready,
|
|
849
|
+
excludedReason: readiness.ready ? null : readiness.reason,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Build the enriched `rotation.resolved`/`rotation.unresolved` event payload:
|
|
854
|
+
* the full candidate pool as the router saw it, the pick and WHY, and a
|
|
855
|
+
* freshness tally. Replaces the old `{ version, healthy: <n>, excluded: <n> }`
|
|
856
|
+
* shape, which recorded only counts and a device-local version and so could not
|
|
857
|
+
* tell a blind-pool draw from a skewed-verified pick from a refused-stale route
|
|
858
|
+
* — the exact ambiguity that keeps a bad pick undebuggable from the log. All
|
|
859
|
+
* candidates share ONE `nowMs` so their `tier`/`ageMs` are mutually consistent.
|
|
860
|
+
*/
|
|
861
|
+
export function buildRotationDecisionEvent(rotation, agent, strategy) {
|
|
862
|
+
const nowMs = Date.now();
|
|
863
|
+
const tally = { verified: 0, stale: 0, blind: 0 };
|
|
864
|
+
for (const c of rotation.healthy) {
|
|
865
|
+
const t = isUsageVerified(c, nowMs) ? 'verified' : hasStaleUsage(c, nowMs) ? 'stale' : 'blind';
|
|
866
|
+
tally[t] += 1;
|
|
867
|
+
}
|
|
868
|
+
const pickedTier = isUsageVerified(rotation.picked, nowMs)
|
|
869
|
+
? 'verified'
|
|
870
|
+
: hasStaleUsage(rotation.picked, nowMs)
|
|
871
|
+
? 'stale'
|
|
872
|
+
: 'blind';
|
|
873
|
+
// WHY the pick: a verified-weighted draw is the healthy path; an
|
|
874
|
+
// `unverified-*-draw` names the fallback the router was forced into (a blind
|
|
875
|
+
// worker pool, or a stale-but-plausible number); `refused-no-verified` is the
|
|
876
|
+
// fail-closed exit where no version launches. Read straight from the result's
|
|
877
|
+
// own `usageUnverified`/`noVerifiedUsage`, so the event can never disagree
|
|
878
|
+
// with what rotation actually did.
|
|
879
|
+
const pickReason = rotation.noVerifiedUsage
|
|
880
|
+
? 'refused-no-verified'
|
|
881
|
+
: rotation.usageUnverified
|
|
882
|
+
? `unverified-${pickedTier}-draw`
|
|
883
|
+
: 'verified-weighted';
|
|
884
|
+
const pool = [...rotation.healthy, ...rotation.excluded];
|
|
885
|
+
return {
|
|
886
|
+
module: 'rotate',
|
|
887
|
+
agent,
|
|
888
|
+
strategy,
|
|
889
|
+
version: rotation.picked.version,
|
|
890
|
+
picked: {
|
|
891
|
+
usageKey: rotation.picked.usageKey,
|
|
892
|
+
email: rotation.picked.email,
|
|
893
|
+
version: rotation.picked.version,
|
|
894
|
+
tier: pickedTier,
|
|
895
|
+
},
|
|
896
|
+
pickReason,
|
|
897
|
+
healthy: rotation.healthy.length,
|
|
898
|
+
excluded: rotation.excluded.length,
|
|
899
|
+
freshness: tally,
|
|
900
|
+
// An OBJECT (not an array), so the sink cannot truncate it to 10 entries
|
|
901
|
+
// (see ROTATION_EVENT_CANDIDATE_CAP). Keyed by POOL INDEX, not by version or
|
|
902
|
+
// usageKey: a RUSH-3182 provider-account pool has multiple candidates
|
|
903
|
+
// sharing one `version` AND a null `usageKey` (foldRegistryCandidates), so
|
|
904
|
+
// either would collide and silently drop rows via Object.fromEntries. The
|
|
905
|
+
// index is structural only; each row's identity is its `accountKey` /
|
|
906
|
+
// `usageKey` / `providerAccount`. `candidatesTotal` reveals cap overflow.
|
|
907
|
+
candidates: Object.fromEntries(pool.slice(0, ROTATION_EVENT_CANDIDATE_CAP).map((c, i) => [String(i), describeRotationCandidate(c, nowMs)])),
|
|
908
|
+
candidatesTotal: pool.length,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Build AND emit a rotation decision event without ever letting an observability
|
|
913
|
+
* bug crash a live route. `emit` swallows its own IO errors, but the payload
|
|
914
|
+
* ARGUMENT is evaluated before `emit` is called, so a future null-deref inside
|
|
915
|
+
* `describeRotationCandidate` would otherwise propagate into `resolveRunVersion`
|
|
916
|
+
* and abort the launch. This wraps both build and emit, so the worst case is a
|
|
917
|
+
* lost log line, never a failed run.
|
|
918
|
+
*/
|
|
919
|
+
function emitRotationDecision(event, rotation, agent, strategy, extra = {}) {
|
|
920
|
+
try {
|
|
921
|
+
emit(event, { ...buildRotationDecisionEvent(rotation, agent, strategy), ...extra });
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
/* observability must never break a route */
|
|
925
|
+
}
|
|
926
|
+
}
|
|
719
927
|
/**
|
|
720
928
|
* Resolve the version `agents run` should use when the caller did not pin
|
|
721
929
|
* one with `@version`. The caller supplies the effective strategy.
|
|
@@ -732,6 +940,21 @@ function readRotationStamp(agent) {
|
|
|
732
940
|
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
|
|
733
941
|
const fallback = resolveVersion(agent, cwd);
|
|
734
942
|
const candidates = await collect(agent);
|
|
943
|
+
// Entirely stale usage (PHNX-2526): every eligible account carries a
|
|
944
|
+
// stale-but-present number and none is verified. Refuse to auto-pick on a
|
|
945
|
+
// number that looks plausible but is wrong. `rotation` is returned so its
|
|
946
|
+
// `healthy` set survives for BOUNDED post-rejection failover, but `version`
|
|
947
|
+
// is null so the caller diverts — interactive to the account picker,
|
|
948
|
+
// unattended to a loud NO_VERIFIED_USAGE exit. Shared across BOTH rotating
|
|
949
|
+
// paths (the pinned auth-blocked-pin fallback AND balanced/available), since
|
|
950
|
+
// both reuse `pickAvailableCandidate`/`pickBalancedCandidate` and neither may
|
|
951
|
+
// launch a stale pick.
|
|
952
|
+
const refuseStaleUsage = (rotation) => {
|
|
953
|
+
emitRotationDecision('rotation.unresolved', rotation, agent, strategy, {
|
|
954
|
+
reason: 'no_verified_usage',
|
|
955
|
+
});
|
|
956
|
+
return { version: null, rotation, noVerifiedUsage: true };
|
|
957
|
+
};
|
|
735
958
|
if (strategy === 'pinned') {
|
|
736
959
|
const pinnedCandidate = fallback
|
|
737
960
|
? candidates.find((c) => c.version === fallback)
|
|
@@ -740,15 +963,14 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
|
|
|
740
963
|
// guaranteed miss. Prefer a signed-in sibling on this device.
|
|
741
964
|
if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
|
|
742
965
|
const rotation = pickAvailableCandidate(candidates, fallback);
|
|
966
|
+
// The auth-blocked pin rotates to a sibling — an initial selection, so it
|
|
967
|
+
// gets the same verified-only gate as balanced/available. Without this, a
|
|
968
|
+
// revoked pin with only stale siblings launched one blind (the yosemite-s1
|
|
969
|
+
// trap through the pinned path — PR #3295 review).
|
|
970
|
+
if (rotation && rotation.noVerifiedUsage)
|
|
971
|
+
return refuseStaleUsage(rotation);
|
|
743
972
|
if (rotation) {
|
|
744
|
-
|
|
745
|
-
module: 'rotate',
|
|
746
|
-
agent,
|
|
747
|
-
version: rotation.picked.version,
|
|
748
|
-
strategy,
|
|
749
|
-
healthy: rotation.healthy.length,
|
|
750
|
-
excluded: rotation.excluded.length,
|
|
751
|
-
});
|
|
973
|
+
emitRotationDecision('rotation.resolved', rotation, agent, strategy);
|
|
752
974
|
return { version: rotation.picked.version, rotation };
|
|
753
975
|
}
|
|
754
976
|
return {
|
|
@@ -762,6 +984,8 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
|
|
|
762
984
|
const rotation = strategy === 'available'
|
|
763
985
|
? pickAvailableCandidate(candidates, fallback)
|
|
764
986
|
: pickBalancedCandidate(candidates);
|
|
987
|
+
if (rotation && rotation.noVerifiedUsage)
|
|
988
|
+
return refuseStaleUsage(rotation);
|
|
765
989
|
if (rotation) {
|
|
766
990
|
// `available` is sticky to the pinned default when healthy. Use the 60s
|
|
767
991
|
// anti-collision stamp to nudge parallel callers off the same version.
|
|
@@ -776,7 +1000,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
|
|
|
776
1000
|
}
|
|
777
1001
|
recordRotationPick(agent, rotation.picked.version);
|
|
778
1002
|
}
|
|
779
|
-
|
|
1003
|
+
emitRotationDecision('rotation.resolved', rotation, agent, strategy);
|
|
780
1004
|
return { version: rotation.picked.version, rotation };
|
|
781
1005
|
}
|
|
782
1006
|
return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { type DeviceProfile } from '../devices/registry.js';
|
|
2
2
|
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
3
|
-
import { type CachedUsageSnapshot
|
|
3
|
+
import { type CachedUsageSnapshot } from './usage.js';
|
|
4
4
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
5
5
|
export declare const USAGE_PUSH_DEADLINE_MS = 20000;
|
|
6
6
|
export declare const USAGE_PULL_DEADLINE_MS = 20000;
|
|
7
|
+
/**
|
|
8
|
+
* A worker's newest local usage row older than this triggers a pull from the
|
|
9
|
+
* primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
|
|
10
|
+
* RUSH-2392), so its only source of a fresh row is the daemon push every
|
|
11
|
+
* `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
|
|
12
|
+
* cycle is tolerated before the worker actively pulls. Bounds worker staleness
|
|
13
|
+
* to ~30m instead of the days-old caches this replaces.
|
|
14
|
+
*/
|
|
15
|
+
export declare const USAGE_SYNC_MAX_AGE_MS: number;
|
|
7
16
|
/** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
|
|
8
17
|
export interface UsageSyncPayload {
|
|
9
18
|
v: 1;
|
|
@@ -75,8 +84,9 @@ export interface UsagePullDeps {
|
|
|
75
84
|
listRoles?: () => Record<string, ConfiguredDeviceRole>;
|
|
76
85
|
isPinned?: (name: string) => boolean;
|
|
77
86
|
exportRows?: () => Record<string, CachedUsageSnapshot>;
|
|
78
|
-
readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
|
|
79
87
|
ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
|
|
88
|
+
/** Injectable clock for the staleness gate; defaults to `Date.now`. */
|
|
89
|
+
now?: () => number;
|
|
80
90
|
/** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
|
|
81
91
|
pull?: (device: DeviceProfile) => {
|
|
82
92
|
ok: boolean;
|
|
@@ -32,10 +32,26 @@ import { sshTargetFor } from '../devices/connect.js';
|
|
|
32
32
|
import { isHostPinned } from '../devices/known-hosts.js';
|
|
33
33
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
34
34
|
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
|
|
35
|
-
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows,
|
|
35
|
+
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
|
|
36
36
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
37
37
|
export const USAGE_PUSH_DEADLINE_MS = 20_000;
|
|
38
38
|
export const USAGE_PULL_DEADLINE_MS = 20_000;
|
|
39
|
+
/**
|
|
40
|
+
* A worker's newest local usage row older than this triggers a pull from the
|
|
41
|
+
* primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
|
|
42
|
+
* RUSH-2392), so its only source of a fresh row is the daemon push every
|
|
43
|
+
* `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
|
|
44
|
+
* cycle is tolerated before the worker actively pulls. Bounds worker staleness
|
|
45
|
+
* to ~30m instead of the days-old caches this replaces.
|
|
46
|
+
*/
|
|
47
|
+
export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
|
|
48
|
+
/** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
|
|
49
|
+
function capturedAtMs(capturedAt) {
|
|
50
|
+
if (!capturedAt)
|
|
51
|
+
return null;
|
|
52
|
+
const ms = Date.parse(capturedAt);
|
|
53
|
+
return Number.isFinite(ms) ? ms : null;
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* Decide, per peer, whether to push the local usage snapshot. Pure.
|
|
41
57
|
*
|
|
@@ -109,12 +125,24 @@ export function pullUsageFromPrimary(deps = {}) {
|
|
|
109
125
|
return result;
|
|
110
126
|
}
|
|
111
127
|
const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
|
|
112
|
-
const readRow = deps.readRow ?? readClaudeUsageCache;
|
|
113
128
|
const localEntries = Object.entries(localRows);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
// Fresh means RECENT, not "the cache equals itself". The prior check read the
|
|
130
|
+
// SAME on-disk cache twice (exportRows() and readRow()) and compared window
|
|
131
|
+
// COUNT, so `fresh.windows.length === row.windows.length` was always true and
|
|
132
|
+
// any non-empty worker cache skipped the pull forever — which is how a worker
|
|
133
|
+
// served a days-old snapshot indefinitely and balanced launched into an
|
|
134
|
+
// account already at its weekly cap. A worker cannot self-read usage, so any
|
|
135
|
+
// row older than the sync cadence is stale and must trigger a pull; the merge
|
|
136
|
+
// is newest-wins + idempotent, so pulling when the primary is no fresher is a
|
|
137
|
+
// harmless no-op.
|
|
138
|
+
const now = (deps.now ?? Date.now)();
|
|
139
|
+
const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
|
|
140
|
+
const allFresh = localEntries.length > 0 &&
|
|
141
|
+
localEntries.every(([, row]) => {
|
|
142
|
+
const ms = capturedAtMs(row.capturedAt);
|
|
143
|
+
return ms !== null && ms >= staleCutoff;
|
|
144
|
+
});
|
|
145
|
+
if (allFresh) {
|
|
118
146
|
result.skipped = 'local usage cache is fresh';
|
|
119
147
|
return result;
|
|
120
148
|
}
|
|
@@ -5,6 +5,17 @@ export interface LocalConnection {
|
|
|
5
5
|
port: number;
|
|
6
6
|
pid: number;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Arc is single-instance: relaunching the Arc binary with a fresh
|
|
10
|
+
* `--user-data-dir` does not create a second debuggable process — macOS routes
|
|
11
|
+
* the launch to the running Arc, which was started with no debug port, so the
|
|
12
|
+
* spawn produces a stray window and no CDP endpoint (issue #2779). agents
|
|
13
|
+
* browser therefore ATTACHES to the user's running Arc and never launches its
|
|
14
|
+
* own. When the running Arc exposes no CDP endpoint on the profile's port, we
|
|
15
|
+
* fail loud with the one relaunch that fixes it rather than silently spawning a
|
|
16
|
+
* duplicate. Exported so the contract is unit-testable without a real Arc.
|
|
17
|
+
*/
|
|
18
|
+
export declare function arcAttachRequiredError(profileName: string, port: number): Error;
|
|
8
19
|
export declare function connectLocal(endpoint: string, profile: BrowserProfile,
|
|
9
20
|
/**
|
|
10
21
|
* Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
|
|
@@ -31,6 +31,24 @@ const TUNNEL_PROCESS_NAMES = new Set(['ssh', 'autossh', 'mosh-client', 'socat'])
|
|
|
31
31
|
function isTunnelProcess(command) {
|
|
32
32
|
return TUNNEL_PROCESS_NAMES.has(command.toLowerCase());
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Arc is single-instance: relaunching the Arc binary with a fresh
|
|
36
|
+
* `--user-data-dir` does not create a second debuggable process — macOS routes
|
|
37
|
+
* the launch to the running Arc, which was started with no debug port, so the
|
|
38
|
+
* spawn produces a stray window and no CDP endpoint (issue #2779). agents
|
|
39
|
+
* browser therefore ATTACHES to the user's running Arc and never launches its
|
|
40
|
+
* own. When the running Arc exposes no CDP endpoint on the profile's port, we
|
|
41
|
+
* fail loud with the one relaunch that fixes it rather than silently spawning a
|
|
42
|
+
* duplicate. Exported so the contract is unit-testable without a real Arc.
|
|
43
|
+
*/
|
|
44
|
+
export function arcAttachRequiredError(profileName, port) {
|
|
45
|
+
return new Error(`Arc is not exposing a CDP endpoint on cdp://127.0.0.1:${port} for profile ` +
|
|
46
|
+
`"${profileName}". Arc is single-instance — agents browser attaches to your ` +
|
|
47
|
+
`RUNNING Arc and never launches a second one, so it will not start an isolated ` +
|
|
48
|
+
`Arc for you. Quit Arc, then relaunch it with remote debugging on this port:\n` +
|
|
49
|
+
` open -a Arc --args --remote-debugging-port=${port}\n` +
|
|
50
|
+
`and retry. The port must match the profile's endpoint (\`agents browser profiles list\`).`);
|
|
51
|
+
}
|
|
34
52
|
export async function connectLocal(endpoint, profile,
|
|
35
53
|
/**
|
|
36
54
|
* Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
|
|
@@ -71,6 +89,14 @@ key) {
|
|
|
71
89
|
if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
|
|
72
90
|
throw err;
|
|
73
91
|
}
|
|
92
|
+
// Arc reached the catch, so the attach above failed: the running Arc is not
|
|
93
|
+
// serving CDP on this port. Never fall through to launchBrowser — that would
|
|
94
|
+
// spawn the duplicate/stray-window this ticket set out to end (#2779,
|
|
95
|
+
// PHNX-2399). Fail loud with the relaunch that makes the user's Arc
|
|
96
|
+
// attachable.
|
|
97
|
+
if (profile.browser === 'arc') {
|
|
98
|
+
throw arcAttachRequiredError(profile.name, port);
|
|
99
|
+
}
|
|
74
100
|
// Distinguish "nothing listening on this port" (fine to launch fresh) from
|
|
75
101
|
// "something is listening but it's not a debuggable browser" (bail loudly —
|
|
76
102
|
// silently launching on a different port leads to confusing `pid 0` and
|
|
@@ -513,13 +513,15 @@ export async function editProfile(name, patch) {
|
|
|
513
513
|
const current = configToProfile(name, local.config, devices);
|
|
514
514
|
const merged = { ...current, ...patch, name };
|
|
515
515
|
const changed = Object.keys(patch).filter((k) => JSON.stringify(current[k]) !== JSON.stringify(merged[k]));
|
|
516
|
-
// A target filter only means anything for
|
|
516
|
+
// A target filter only means anything for a profile that reuses an existing
|
|
517
|
+
// page target rather than creating one — an Electron app or Arc (which crashes
|
|
518
|
+
// on tab creation, so it drives an open tab / Space, PHNX-2399). The gate must
|
|
517
519
|
// read the MERGED record: `--target-filter x` on a profile that is already
|
|
518
|
-
// electron is valid, and `--no-electron` on
|
|
519
|
-
// not. Checking the patch alone would accept both.
|
|
520
|
-
if (merged.targetFilter && !merged.electron) {
|
|
521
|
-
throw new Error(`--target-filter only applies to an Electron profile. ` +
|
|
522
|
-
`Pass --electron, or clear the filter with --target-filter ''.`);
|
|
520
|
+
// electron/arc is valid, and `--no-electron` on a Chromium profile that still
|
|
521
|
+
// carries a filter is not. Checking the patch alone would accept both.
|
|
522
|
+
if (merged.targetFilter && !merged.electron && merged.browser !== 'arc') {
|
|
523
|
+
throw new Error(`--target-filter only applies to an Electron or Arc profile. ` +
|
|
524
|
+
`Pass --electron, use --browser arc, or clear the filter with --target-filter ''.`);
|
|
523
525
|
}
|
|
524
526
|
assertLocalPortFree(merged, { ignore: name });
|
|
525
527
|
// Same rule as create: the binary lives on the remote for an SSH profile, so a
|
|
@@ -53,9 +53,10 @@ interface ProfileConnection {
|
|
|
53
53
|
electron?: boolean;
|
|
54
54
|
/**
|
|
55
55
|
* The profile's declared browser family. Load-bearing for Arc: Arc answers
|
|
56
|
-
* `Browser.getVersion`
|
|
57
|
-
* `Target.createTarget` (verified live, PR #2778), so any
|
|
58
|
-
* must refuse
|
|
56
|
+
* `Browser.getVersion` and DOES expose CDP page targets it honors `Page.navigate`
|
|
57
|
+
* on, but CRASHES on `Target.createTarget` (verified live, PR #2778), so any
|
|
58
|
+
* tab-CREATING path must refuse and drive an EXISTING tab instead rather than
|
|
59
|
+
* crash the user's Arc. See `createPageTarget` / `pickReusableTargetWithoutCreate`.
|
|
59
60
|
*/
|
|
60
61
|
browserType?: BrowserType;
|
|
61
62
|
/** Raw `url:<v>` / `title:<v>` filter copied from the profile config. */
|
|
@@ -88,11 +89,14 @@ interface ProfileConnection {
|
|
|
88
89
|
/** Join error lines so callers get a next command, not a dead-end message. */
|
|
89
90
|
export declare function actionable(...lines: string[]): string;
|
|
90
91
|
/**
|
|
91
|
-
* Arc
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
|
|
93
|
+
* user already has open (measured live against Arc: 33 targets, PR #2786), so
|
|
94
|
+
* agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
|
|
95
|
+
* survive is `Target.createTarget` — opening a brand-new tab crashes the user's
|
|
96
|
+
* Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
|
|
97
|
+
* fails clearly here (PHNX-2399): every tab-creating path throws this actionable
|
|
98
|
+
* error instead of crashing Arc, and steers the caller to reuse an existing
|
|
99
|
+
* tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
|
|
96
100
|
*/
|
|
97
101
|
export declare function arcNotDrivableError(profileName?: string): Error;
|
|
98
102
|
/** Derive a human label from an explicit title or a navigated URL. */
|
|
@@ -247,15 +247,18 @@ export function actionable(...lines) {
|
|
|
247
247
|
return lines.filter((l) => l != null && l !== '').join('\n');
|
|
248
248
|
}
|
|
249
249
|
/**
|
|
250
|
-
* Arc
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
250
|
+
* Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
|
|
251
|
+
* user already has open (measured live against Arc: 33 targets, PR #2786), so
|
|
252
|
+
* agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
|
|
253
|
+
* survive is `Target.createTarget` — opening a brand-new tab crashes the user's
|
|
254
|
+
* Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
|
|
255
|
+
* fails clearly here (PHNX-2399): every tab-creating path throws this actionable
|
|
256
|
+
* error instead of crashing Arc, and steers the caller to reuse an existing
|
|
257
|
+
* tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
|
|
255
258
|
*/
|
|
256
259
|
export function arcNotDrivableError(profileName) {
|
|
257
260
|
const scope = profileName ? ` (profile "${profileName}")` : '';
|
|
258
|
-
return new Error(actionable(`Browser "arc"${scope}
|
|
261
|
+
return new Error(actionable(`Browser "arc"${scope} cannot open a NEW tab: Arc crashes when a tab is created`, 'over CDP (Target.createTarget). agents browser drives Arc by attaching to your', 'running window and reusing an EXISTING tab — bind the profile to the tab/Space you', 'want with --target-filter (url:<substring> or title:<substring>), or navigate to a', 'URL that is already open. To open brand-new tabs, use a Chromium-family browser:', ' agents browser profiles create <name> --browser comet'));
|
|
259
262
|
}
|
|
260
263
|
/** Derive a human label from an explicit title or a navigated URL. */
|
|
261
264
|
export function deriveTaskLabel(opts) {
|
|
@@ -481,7 +484,7 @@ export class BrowserService {
|
|
|
481
484
|
// If URL provided, reclaim a tab an abandoned task is holding on it, else
|
|
482
485
|
// create one directly (no about:blank).
|
|
483
486
|
let tabId;
|
|
484
|
-
if (opts.url && !conn.electron) {
|
|
487
|
+
if (opts.url && !conn.electron && conn.browserType !== 'arc') {
|
|
485
488
|
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
486
489
|
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
487
490
|
const shortId = generateShortId();
|
|
@@ -491,7 +494,13 @@ export class BrowserService {
|
|
|
491
494
|
await this.saveTaskState(effectiveKey, conn.tasks);
|
|
492
495
|
tabId = shortId;
|
|
493
496
|
}
|
|
494
|
-
else if (opts.url && conn.electron) {
|
|
497
|
+
else if (opts.url && (conn.electron || conn.browserType === 'arc')) {
|
|
498
|
+
// Electron and Arc share the reuse-in-place path: neither may open a fresh
|
|
499
|
+
// tab here (Electron drives its one window; Arc crashes on Target.createTarget),
|
|
500
|
+
// so the implicit first navigate attaches to an existing tab — honoring the
|
|
501
|
+
// profile's --target-filter — instead of throwing. This is what makes the
|
|
502
|
+
// documented `navigate --profile arc --url …` first-use workflow attach rather
|
|
503
|
+
// than refuse on a task-less profile (PHNX-2399 review).
|
|
495
504
|
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
496
505
|
tabId = result.tabId;
|
|
497
506
|
}
|
|
@@ -583,13 +592,32 @@ export class BrowserService {
|
|
|
583
592
|
for (const id of Object.values(t.tabs))
|
|
584
593
|
owned.add(id);
|
|
585
594
|
}
|
|
586
|
-
|
|
595
|
+
let free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
|
|
596
|
+
// A bound --target-filter (url:/title:<substring>) is how the profile names the
|
|
597
|
+
// tab/Space it drives — e.g. an Arc Space pinned with `--target-filter url:notion.so`.
|
|
598
|
+
// Scope the reusable set to tabs matching it; an explicit filter that matches nothing
|
|
599
|
+
// returns undefined so the caller refuses rather than borrowing an unrelated tab (the
|
|
600
|
+
// same contract pickWindowTarget holds for the tab-creating path). Without this the
|
|
601
|
+
// filter was accepted and stored but never consulted when driving Arc (PHNX-2399 review).
|
|
602
|
+
const parsed = parseTargetFilter(conn.targetFilter);
|
|
603
|
+
if (parsed) {
|
|
604
|
+
const needle = parsed.value.toLowerCase();
|
|
605
|
+
free = free.filter((t) => ((parsed.kind === 'url' ? t.url : t.title) ?? '').toLowerCase().includes(needle));
|
|
606
|
+
if (free.length === 0)
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
587
609
|
const wanted = canonicalTabUrl(url);
|
|
588
610
|
const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
|
|
589
611
|
if (sameUrl)
|
|
590
612
|
return sameUrl.targetId;
|
|
591
613
|
const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
|
|
592
|
-
|
|
614
|
+
const blank = free.find((t) => BLANK.has(t.url));
|
|
615
|
+
if (blank)
|
|
616
|
+
return blank.targetId;
|
|
617
|
+
// With a filter set, every remaining tab already IS the bound Space, so reuse the
|
|
618
|
+
// first even when it is neither the exact URL nor blank — that is the target the
|
|
619
|
+
// operator pinned. With no filter, only an exact-URL or blank tab is safe to borrow.
|
|
620
|
+
return parsed ? free[0]?.targetId : undefined;
|
|
593
621
|
}
|
|
594
622
|
async adoptTabShowing(conn, url) {
|
|
595
623
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|