@phnx-labs/agents-cli 1.22.64 → 1.22.65
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 +33 -0
- package/dist/commands/accounts.d.ts +7 -6
- package/dist/commands/accounts.js +7 -6
- package/dist/commands/feed.js +2 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +66 -4
- package/dist/lib/accounting/account-pool-collect.js +7 -2
- package/dist/lib/accounting/account-pool.d.ts +12 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +35 -8
- package/dist/lib/accounting/rotate.js +37 -10
- package/dist/lib/accounting/usage.d.ts +12 -0
- package/dist/lib/accounting/usage.js +119 -15
- package/dist/lib/agent-spec/agents.js +21 -6
- package/dist/lib/daemon-ticks.js +6 -2
- package/dist/lib/feed-broadcast.d.ts +2 -0
- package/dist/lib/feed-broadcast.js +22 -4
- package/dist/lib/harness/adapters/claude.js +33 -27
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +66 -8
- package/dist/lib/usage-refresh.d.ts +100 -1
- package/dist/lib/usage-refresh.js +195 -4
- package/package.json +1 -1
|
@@ -437,8 +437,22 @@ export async function getUsageInfoForIdentity(input, opts) {
|
|
|
437
437
|
// stale snapshot here is safe, and an absent one reports
|
|
438
438
|
// {@link USAGE_NOT_COLLECTED_MARKER}.
|
|
439
439
|
if (readOnly) {
|
|
440
|
-
|
|
440
|
+
// A row carries a CONFIRMED reading when it has a fresh window, a
|
|
441
|
+
// subscription plan (meterless-healthy, e.g. Grok's tier), or a live refusal
|
|
442
|
+
// (out_of_credits / session_limit). Those report `usageError: null`.
|
|
443
|
+
if (cached && (cached.windows.length > 0 || cached.plan || cached.unavailable)) {
|
|
441
444
|
return { snapshot: cached, error: null };
|
|
445
|
+
}
|
|
446
|
+
// A row whose ONLY content is last-known stale readings — the all-expired
|
|
447
|
+
// Claude case, its windows moved to `staleWindows` (view-only) so the
|
|
448
|
+
// TERMINAL view still renders the number with its age — must NOT read as a
|
|
449
|
+
// healthy account. `staleWindows` is deliberately excluded from `--json`
|
|
450
|
+
// (which projects only `windows`), so keep `usageError` non-null: a consumer
|
|
451
|
+
// polling `agents view --json` must still see the staleness signal, the exact
|
|
452
|
+
// RUSH-2858 weeks-stale case this marker exists for. The snapshot is still
|
|
453
|
+
// returned, so the human view is unaffected.
|
|
454
|
+
if (cached)
|
|
455
|
+
return { snapshot: cached, error: USAGE_NOT_COLLECTED_MARKER };
|
|
442
456
|
return { snapshot: null, error: USAGE_NOT_COLLECTED_MARKER };
|
|
443
457
|
}
|
|
444
458
|
// Explicit refresh: block on the shared device collector.
|
|
@@ -590,14 +604,25 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
590
604
|
// multi-meter agent cannot force the whole table to wrap.
|
|
591
605
|
const selected = pickCompactUsageWindows(snapshot.windows, opts?.maxWindows);
|
|
592
606
|
const hidden = Math.max(0, snapshot.windows.filter((w) => w.key !== 'sonnet_week').length - selected.length);
|
|
607
|
+
// Last-known windows the freshness gate dropped (see UsageSnapshot.staleWindows).
|
|
608
|
+
// Rendered with an age suffix so a stale reading stays visible instead of a
|
|
609
|
+
// bare "unavailable"; never in `snapshot.windows`, so routing never sees them.
|
|
610
|
+
const now = new Date();
|
|
611
|
+
const staleWindows = snapshot.staleWindows ?? [];
|
|
612
|
+
const staleByKey = new Map(staleWindows.map((w) => [w.key, w]));
|
|
593
613
|
const expected = opts?.expectedWindows;
|
|
594
614
|
const windowsToRender = expected
|
|
595
|
-
? expected.map(({ key, shortLabel }) => ({ window: selected.find((item) => item.key === key), shortLabel }))
|
|
596
|
-
: selected.map((window) => ({ window, shortLabel: window.shortLabel }));
|
|
597
|
-
const windowParts = windowsToRender.map(({ window, shortLabel }, index) => {
|
|
615
|
+
? expected.map(({ key, shortLabel }) => ({ key, window: selected.find((item) => item.key === key), shortLabel }))
|
|
616
|
+
: selected.map((window) => ({ key: window.key, window, shortLabel: window.shortLabel }));
|
|
617
|
+
const windowParts = windowsToRender.map(({ key, window, shortLabel }, index) => {
|
|
598
618
|
if (!window) {
|
|
599
|
-
|
|
600
|
-
|
|
619
|
+
// A window we expected but have no fresh reading for: render the
|
|
620
|
+
// last-known value with its age if we still have it, else "unavailable".
|
|
621
|
+
const stale = staleByKey.get(key);
|
|
622
|
+
const rendered = stale
|
|
623
|
+
? renderStaleUsageWindow(stale, snapshot.capturedAt, shortLabel, now)
|
|
624
|
+
: chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
|
|
625
|
+
return index < windowsToRender.length - 1 ? padToWidth(rendered, 20) : rendered;
|
|
601
626
|
}
|
|
602
627
|
const bar = renderCompactUsageBar(window.usedPercent);
|
|
603
628
|
const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
|
|
@@ -611,6 +636,16 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
611
636
|
if (windowParts.length > 0) {
|
|
612
637
|
parts.push(windowParts.join(' '));
|
|
613
638
|
}
|
|
639
|
+
else if (staleWindows.length > 0) {
|
|
640
|
+
// No fresh bars, but we have last-known readings (e.g. Grok's weekly bar
|
|
641
|
+
// from an ended billing period): show them with an age suffix rather than
|
|
642
|
+
// the "run once to refresh" hint, which hid a number we actually had.
|
|
643
|
+
const cap = opts?.maxWindows ?? staleWindows.length;
|
|
644
|
+
const rendered = staleWindows
|
|
645
|
+
.slice(0, cap)
|
|
646
|
+
.map((w) => renderStaleUsageWindow(w, snapshot.capturedAt, w.shortLabel, now));
|
|
647
|
+
parts.push(rendered.join(' '));
|
|
648
|
+
}
|
|
614
649
|
else if (snapshot.refreshHint) {
|
|
615
650
|
parts.push(chalk.dim(snapshot.refreshHint));
|
|
616
651
|
}
|
|
@@ -1879,9 +1914,12 @@ function serializeClaudeUsageSnapshot(snapshot) {
|
|
|
1879
1914
|
* have burned since. Zeroing-but-keeping it (the previous behavior) rendered a
|
|
1880
1915
|
* weeks-frozen cache as "S: 0% (now)" with `deriveUsageStatusFromSnapshot` →
|
|
1881
1916
|
* 'available', so a genuinely rate-limited account read as an idle dispatch
|
|
1882
|
-
* candidate (RUSH-2858). Dropping
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1917
|
+
* candidate (RUSH-2858). Dropping keeps them out of `windows` (routing stays
|
|
1918
|
+
* blind), but they are preserved on `staleWindows` so the view can render the
|
|
1919
|
+
* last-known number with its age instead of a bare "unavailable" — a row that
|
|
1920
|
+
* carries only stale windows therefore survives (it is worth showing), and only
|
|
1921
|
+
* a row with NOTHING to show — no fresh window, no stale window, no plan, no
|
|
1922
|
+
* refusal — deserializes to null so `readClaudeUsageCache` deletes it.
|
|
1885
1923
|
*
|
|
1886
1924
|
* A row that carries a plan survives even with no fresh windows: the plan is a
|
|
1887
1925
|
* truthful reading in its own right, and losing it is what made the cached view
|
|
@@ -1889,16 +1927,21 @@ function serializeClaudeUsageSnapshot(snapshot) {
|
|
|
1889
1927
|
*/
|
|
1890
1928
|
function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
1891
1929
|
const capturedAt = parseDateValue(snapshot.capturedAt);
|
|
1892
|
-
const
|
|
1893
|
-
.map((window) => ({
|
|
1930
|
+
const deserialized = snapshot.windows.map((window) => ({
|
|
1894
1931
|
key: window.key,
|
|
1895
1932
|
label: window.label,
|
|
1896
1933
|
shortLabel: window.shortLabel,
|
|
1897
1934
|
usedPercent: window.usedPercent,
|
|
1898
1935
|
resetsAt: parseDateValue(window.resetsAt),
|
|
1899
1936
|
windowMinutes: window.windowMinutes,
|
|
1900
|
-
}))
|
|
1901
|
-
|
|
1937
|
+
}));
|
|
1938
|
+
const windows = deserialized.filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
|
|
1939
|
+
// The dropped windows are still the LAST reading we saw for those meters —
|
|
1940
|
+
// routing must not trust them (they stay out of `windows`), but the view
|
|
1941
|
+
// renders them with an age suffix rather than a bare "unavailable" (see
|
|
1942
|
+
// UsageSnapshot.staleWindows). Skip any meter that already has a fresh row.
|
|
1943
|
+
const freshKeys = new Set(windows.map((window) => window.key));
|
|
1944
|
+
const staleWindows = deserialized.filter((window) => !freshKeys.has(window.key) && !isCachedUsageWindowFresh(window, capturedAt, now));
|
|
1902
1945
|
const unavailable = deserializeUnavailable(snapshot.unavailable, now);
|
|
1903
1946
|
// A windowless row is not automatically worthless. Grok's collector reports
|
|
1904
1947
|
// the subscription tier and no meters at all, so treating "no fresh windows"
|
|
@@ -1910,7 +1953,11 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
|
1910
1953
|
// and `deriveUsageStatusFromSnapshot` still returns null for zero windows, so
|
|
1911
1954
|
// it can never read as a 0% bar or an "available" badge (the RUSH-2858
|
|
1912
1955
|
// property that made expired windows drop in the first place).
|
|
1913
|
-
if (windows.length === 0 &&
|
|
1956
|
+
if (windows.length === 0 &&
|
|
1957
|
+
staleWindows.length === 0 &&
|
|
1958
|
+
!unavailable &&
|
|
1959
|
+
!snapshot.plan &&
|
|
1960
|
+
!snapshot.refreshHint) {
|
|
1914
1961
|
return null;
|
|
1915
1962
|
}
|
|
1916
1963
|
return {
|
|
@@ -1918,6 +1965,7 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
|
1918
1965
|
sourceLabel: CACHED_CLAUDE_USAGE_SOURCE_LABEL,
|
|
1919
1966
|
capturedAt,
|
|
1920
1967
|
windows,
|
|
1968
|
+
staleWindows: staleWindows.length > 0 ? staleWindows : undefined,
|
|
1921
1969
|
plan: snapshot.plan ?? null,
|
|
1922
1970
|
refreshHint: snapshot.refreshHint ?? null,
|
|
1923
1971
|
unavailable,
|
|
@@ -2246,6 +2294,56 @@ function formatResetHint(date) {
|
|
|
2246
2294
|
const days = Math.round(hours / 24);
|
|
2247
2295
|
return `${days}d`;
|
|
2248
2296
|
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Compact elapsed-time label for a stale reading's age: "30m", "6h", "2d".
|
|
2299
|
+
* Coarse single-unit like {@link formatResetHint}, floored at "1m" so a
|
|
2300
|
+
* just-expired window never reads "0m".
|
|
2301
|
+
*/
|
|
2302
|
+
function formatAgeShort(diffMs) {
|
|
2303
|
+
const mins = Math.max(1, Math.round(diffMs / 60000));
|
|
2304
|
+
if (mins < 60)
|
|
2305
|
+
return `${mins}m`;
|
|
2306
|
+
const hours = Math.round(mins / 60);
|
|
2307
|
+
if (hours < 24)
|
|
2308
|
+
return `${hours}h`;
|
|
2309
|
+
const days = Math.round(hours / 24);
|
|
2310
|
+
return `${days}d`;
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Staleness suffix for a last-known window the freshness gate dropped. A window
|
|
2314
|
+
* whose reset/period boundary passed while the sample itself is still inside its
|
|
2315
|
+
* `windowMinutes` rolled OVER — the number describes a period that is done, so
|
|
2316
|
+
* name it ("period ended 1h ago", e.g. Grok's weekly billing period). A window
|
|
2317
|
+
* that aged past its own `windowMinutes` (Claude's 5h session read never
|
|
2318
|
+
* refreshed in time) is a stale sample of a still-rolling window, so report the
|
|
2319
|
+
* capture age ("6h old"). Falls back to the reset age, then a bare "stale".
|
|
2320
|
+
*/
|
|
2321
|
+
function formatStaleWindowSuffix(window, capturedAt, now) {
|
|
2322
|
+
const resetPassed = !!window.resetsAt && window.resetsAt.getTime() <= now.getTime();
|
|
2323
|
+
const captureExpired = !!capturedAt &&
|
|
2324
|
+
window.windowMinutes !== null &&
|
|
2325
|
+
capturedAt.getTime() + window.windowMinutes * 60 * 1000 <= now.getTime();
|
|
2326
|
+
if (resetPassed && !captureExpired) {
|
|
2327
|
+
return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
|
|
2328
|
+
}
|
|
2329
|
+
if (capturedAt)
|
|
2330
|
+
return `${formatAgeShort(now.getTime() - capturedAt.getTime())} old`;
|
|
2331
|
+
if (resetPassed)
|
|
2332
|
+
return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
|
|
2333
|
+
return 'stale';
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Render a dropped-but-last-known window as "S: ▍░░░░ 30% · 6h old": the gauge
|
|
2337
|
+
* and percentage exactly as a live bar, then a dim staleness suffix so the
|
|
2338
|
+
* number is always visible and unmistakably not current. VIEW-ONLY — these
|
|
2339
|
+
* windows are never in `snapshot.windows`, so routing never sees them.
|
|
2340
|
+
*/
|
|
2341
|
+
function renderStaleUsageWindow(window, capturedAt, shortLabel, now) {
|
|
2342
|
+
const bar = renderCompactUsageBar(window.usedPercent);
|
|
2343
|
+
const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
|
|
2344
|
+
const suffix = formatStaleWindowSuffix(window, capturedAt, now);
|
|
2345
|
+
return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct} ${chalk.dim(`· ${suffix}`)}`;
|
|
2346
|
+
}
|
|
2249
2347
|
/** Format a reset timestamp as a human-readable relative or absolute time. */
|
|
2250
2348
|
function formatResetAt(date) {
|
|
2251
2349
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -2308,8 +2406,13 @@ async function getGrokUsageInfo(options) {
|
|
|
2308
2406
|
// (see readLatestGrokBilling).
|
|
2309
2407
|
const now = new Date();
|
|
2310
2408
|
const windows = match.windows.filter((window) => isCachedUsageWindowFresh(window, match.capturedAt, now));
|
|
2409
|
+
// A window from an ended billing period is the LAST reading we saw — routing
|
|
2410
|
+
// must not trust it (kept out of `windows`), but the view renders it with a
|
|
2411
|
+
// "period ended Xh ago" suffix instead of the bare refresh hint. Only when
|
|
2412
|
+
// there is nothing at all to show does the refresh hint stand alone.
|
|
2413
|
+
const staleWindows = match.windows.filter((window) => !isCachedUsageWindowFresh(window, match.capturedAt, now));
|
|
2311
2414
|
const version = options?.cliVersion;
|
|
2312
|
-
const refreshHint = windows.length === 0
|
|
2415
|
+
const refreshHint = windows.length === 0 && staleWindows.length === 0
|
|
2313
2416
|
? `run grok${version ? `@${version}` : ''} once to refresh usage`
|
|
2314
2417
|
: null;
|
|
2315
2418
|
return {
|
|
@@ -2318,6 +2421,7 @@ async function getGrokUsageInfo(options) {
|
|
|
2318
2421
|
sourceLabel: 'last seen in Grok logs',
|
|
2319
2422
|
capturedAt: match.capturedAt,
|
|
2320
2423
|
windows,
|
|
2424
|
+
staleWindows: staleWindows.length > 0 ? staleWindows : undefined,
|
|
2321
2425
|
plan: match.subscriptionTier,
|
|
2322
2426
|
refreshHint,
|
|
2323
2427
|
},
|
|
@@ -1513,15 +1513,30 @@ function credentialFileExistsUnder(agentId, home) {
|
|
|
1513
1513
|
const alternatives = CREDENTIAL_FILE_SEGMENTS[agentId];
|
|
1514
1514
|
if (!alternatives)
|
|
1515
1515
|
return false;
|
|
1516
|
-
|
|
1516
|
+
const hasSegment = alternatives.some((segments) => {
|
|
1517
1517
|
const p = path.join(home, ...segments);
|
|
1518
1518
|
try {
|
|
1519
|
-
|
|
1520
|
-
return true;
|
|
1519
|
+
return fs.existsSync(p);
|
|
1521
1520
|
}
|
|
1522
|
-
catch {
|
|
1523
|
-
|
|
1524
|
-
|
|
1521
|
+
catch {
|
|
1522
|
+
return false;
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
if (!hasSegment)
|
|
1526
|
+
return false;
|
|
1527
|
+
// Claude's `.claude.json` is account METADATA Claude Code writes on any
|
|
1528
|
+
// launch, not the credential itself — a version can carry stale identity
|
|
1529
|
+
// there (e.g. install-time carry-forward) with no usable credential behind
|
|
1530
|
+
// it: a failed OAuth refresh blanks `.credentials.json`, and a Linux worker
|
|
1531
|
+
// can be missing BOTH `.credentials.json` and the `.oauth_token` setup-token
|
|
1532
|
+
// file entirely (PHNX-3502). `credentialPresence` promises to mirror what a
|
|
1533
|
+
// real launch would find, so it must fail whenever `isClaudeCredentialFileBlank`
|
|
1534
|
+
// — the SAME floor `getAccountInfo` applies — would. On macOS that floor is a
|
|
1535
|
+
// no-op (Keychain-only, always "not blank"), so this reduces to the identity
|
|
1536
|
+
// check above, unchanged.
|
|
1537
|
+
if (agentId === 'claude')
|
|
1538
|
+
return !isClaudeCredentialFileBlank(home);
|
|
1539
|
+
return true;
|
|
1525
1540
|
}
|
|
1526
1541
|
/**
|
|
1527
1542
|
* File-presence probe for an agent's credential, split by location: whether it
|
package/dist/lib/daemon-ticks.js
CHANGED
|
@@ -114,17 +114,21 @@ export async function runFleetCacheWarmTick() {
|
|
|
114
114
|
*/
|
|
115
115
|
export async function runUsageRefreshTick() {
|
|
116
116
|
const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
|
|
117
|
-
const { writeClaudeUsageCache } = await import('./accounting/usage.js');
|
|
117
|
+
const { writeClaudeUsageCache, readClaudeUsageCache } = await import('./accounting/usage.js');
|
|
118
118
|
const { usageRateLimitedUntil } = await import('./usage-backoff.js');
|
|
119
119
|
const r = await runUsageRefresh({
|
|
120
120
|
listAccounts: buildLocalUsageAccounts,
|
|
121
121
|
writeUsageCache: writeClaudeUsageCache,
|
|
122
122
|
backoffUntil: (agentId, usageKey) => usageRateLimitedUntil(agentId, Date.now(), usageKey),
|
|
123
|
+
// The free statusline ingest of a live `agents run` writes this same cache,
|
|
124
|
+
// so a recent capture means the account is already fresh at zero API cost —
|
|
125
|
+
// the refresher re-derives headroom from it and skips the API fetch.
|
|
126
|
+
readCachedSnapshot: (usageKey) => readClaudeUsageCache(usageKey),
|
|
123
127
|
});
|
|
124
128
|
const { listProfiles } = await import('./profiles.js');
|
|
125
129
|
const { refreshDueByokUsage } = await import('./byok-usage.js');
|
|
126
130
|
const byok = await refreshDueByokUsage(listProfiles());
|
|
127
|
-
console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due`);
|
|
131
|
+
console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped, ${r.skippedBudget} over-budget, ${r.skippedFresh} statusline-fresh; BYOK ${byok.refreshed} refreshed, ${byok.skipped} not-due`);
|
|
128
132
|
}
|
|
129
133
|
/**
|
|
130
134
|
* Active-sessions warm (RUSH-2062 / RUSH-2484): publish THIS host's live session
|
|
@@ -42,6 +42,8 @@ export interface FeedBroadcastContext {
|
|
|
42
42
|
level: FeedPostLevel;
|
|
43
43
|
/** Tracker id for the work, e.g. `RUSH-2081`. */
|
|
44
44
|
ticket?: string;
|
|
45
|
+
/** Canonical clickable tracker URL for `ticket`, when the tracker can resolve it. */
|
|
46
|
+
ticketUrl?: string;
|
|
45
47
|
/** Repo/project the post came from. */
|
|
46
48
|
project?: string;
|
|
47
49
|
agent?: string;
|
|
@@ -38,6 +38,7 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
|
|
|
38
38
|
import { lookupTransport } from './channels/resolve.js';
|
|
39
39
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
40
40
|
import { sendToOwner } from './notify.js';
|
|
41
|
+
import { linearIssueUrl } from './session/linear.js';
|
|
41
42
|
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
42
43
|
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
43
44
|
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
@@ -78,6 +79,7 @@ export function blockBroadcastContext(block, extras = {}) {
|
|
|
78
79
|
text,
|
|
79
80
|
level: 'important',
|
|
80
81
|
ticket: block.ticket,
|
|
82
|
+
ticketUrl: linearIssueUrl(block.ticket),
|
|
81
83
|
project: extras.project,
|
|
82
84
|
agent: extras.agent,
|
|
83
85
|
host: block.host,
|
|
@@ -118,7 +120,7 @@ export function blockDeliveryFailure(blocked, outcomes) {
|
|
|
118
120
|
}
|
|
119
121
|
return undefined;
|
|
120
122
|
}
|
|
121
|
-
const PLACEHOLDER = /\{([a-
|
|
123
|
+
const PLACEHOLDER = /\{([a-z_]+)\}/g;
|
|
122
124
|
/**
|
|
123
125
|
* Short host label for a phone line — strip user@ and domain so
|
|
124
126
|
* `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
|
|
@@ -241,7 +243,9 @@ export function composeBroadcastMessage(ctx) {
|
|
|
241
243
|
const head = title || body;
|
|
242
244
|
const mid = title && body && title !== body ? body : undefined;
|
|
243
245
|
const footer = composeBroadcastFooter(ctx);
|
|
244
|
-
const
|
|
246
|
+
const links = [ctx.ticketUrl, ...(ctx.links ?? [])]
|
|
247
|
+
.filter((l) => !!l && /^https?:\/\//i.test(l))
|
|
248
|
+
.filter((l, i, all) => all.indexOf(l) === i);
|
|
245
249
|
// The action block: the one thing the operator can act on from a phone. Show the
|
|
246
250
|
// choices, then what happens if they do not answer. Deliberately NOT a CLI command
|
|
247
251
|
// (`agents focus <id>` is unusable from a phone) -- the safe default is the real
|
|
@@ -256,7 +260,7 @@ export function composeBroadcastMessage(ctx) {
|
|
|
256
260
|
: undefined;
|
|
257
261
|
const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
|
|
258
262
|
// Link trail after the "Sent from" footer so the human sentence stays at the top.
|
|
259
|
-
const trail = [footer,
|
|
263
|
+
const trail = [footer, ...links].filter(Boolean);
|
|
260
264
|
const parts = [];
|
|
261
265
|
if (head)
|
|
262
266
|
parts.push(head);
|
|
@@ -285,6 +289,7 @@ function templateVars(ctx) {
|
|
|
285
289
|
title: ctx.title,
|
|
286
290
|
text: ctx.text,
|
|
287
291
|
ticket: ctx.ticket,
|
|
292
|
+
ticket_url: ctx.ticketUrl,
|
|
288
293
|
project: ctx.project,
|
|
289
294
|
agent: ctx.agent,
|
|
290
295
|
host: ctx.host,
|
|
@@ -339,7 +344,20 @@ export function renderSinkMessage(template, ctx) {
|
|
|
339
344
|
});
|
|
340
345
|
if (missing)
|
|
341
346
|
return undefined;
|
|
342
|
-
const
|
|
347
|
+
const seenUrls = new Set();
|
|
348
|
+
const text = rendered
|
|
349
|
+
.trim()
|
|
350
|
+
.split('\n')
|
|
351
|
+
.filter((line) => {
|
|
352
|
+
const value = line.trim();
|
|
353
|
+
if (!/^https?:\/\/\S+$/i.test(value))
|
|
354
|
+
return true;
|
|
355
|
+
if (seenUrls.has(value))
|
|
356
|
+
return false;
|
|
357
|
+
seenUrls.add(value);
|
|
358
|
+
return true;
|
|
359
|
+
})
|
|
360
|
+
.join('\n');
|
|
343
361
|
return text || undefined;
|
|
344
362
|
}
|
|
345
363
|
/**
|
|
@@ -28,28 +28,33 @@ export const claudeAdapter = {
|
|
|
28
28
|
// The `auth` bundle's setup-token exists so a run with NO human present
|
|
29
29
|
// authenticates without the Touch-ID-gated login item — usage probes,
|
|
30
30
|
// routines, dispatched runs (claude-account-token.ts). It is a WORKER
|
|
31
|
-
// credential.
|
|
31
|
+
// credential. Exactly one kind of run defers to the per-version login
|
|
32
|
+
// instead: ANY run on a `personal`/`desktop` (headed) device — the user's
|
|
33
|
+
// own interactive box (zion), marked `config.role: personal`. That box
|
|
34
|
+
// holds a real per-version login and is the single origin of it, so every
|
|
35
|
+
// run there — interactive TUI OR a headless one-shot like `agents run
|
|
36
|
+
// claude "fix the bug"` — MUST use that login, not the setup-token. Keying
|
|
37
|
+
// the credential on DEVICE ROLE, not run mode, is RUSH-2395's fix: gating on
|
|
38
|
+
// `ctx.interactive` alone sent a headless run on the laptop onto the
|
|
39
|
+
// setup-token and hijacked the login.
|
|
32
40
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// (resolveInteractive = "this opens a TUI", NOT "a human is present")
|
|
43
|
-
// sent a headless run on the laptop onto the setup-token and hijacked the
|
|
44
|
-
// login. Keying the credential on DEVICE ROLE is the fix — RUSH-2395.
|
|
41
|
+
// A WORKER device carries no such login regardless of interactive/headless:
|
|
42
|
+
// an interactive run there is a remotely dispatched TUI (`agents run claude
|
|
43
|
+
// --interactive --device <worker>`), not a human sitting at that box's own
|
|
44
|
+
// Keychain-trusted session — the same worker credential headless runs use is
|
|
45
|
+
// the only credential that exists to authenticate it. Treating `interactive`
|
|
46
|
+
// as "defer to native login" regardless of device role left a keychain-less
|
|
47
|
+
// worker with NO injected token and a per-version `.credentials.json` that
|
|
48
|
+
// was never written, so the run landed on Claude Code's login screen instead
|
|
49
|
+
// of authenticating (PHNX-3502).
|
|
45
50
|
//
|
|
46
51
|
// macOS cannot cheaply confirm a home's login first (probing the Keychain
|
|
47
52
|
// raises an authorization sheet per installed version on the `agents run` hot
|
|
48
|
-
// path — agents.ts `isClaudeCredentialFileBlank`), so
|
|
49
|
-
// Claude Code, which reads its own ACL-trusted login item without a
|
|
50
|
-
// asks a present human to log in only if the login is missing.
|
|
53
|
+
// path — agents.ts `isClaudeCredentialFileBlank`), so the headed-device path
|
|
54
|
+
// defers to Claude Code, which reads its own ACL-trusted login item without a
|
|
55
|
+
// prompt and asks a present human to log in only if the login is missing.
|
|
51
56
|
const headedDevice = isHeadedDeviceRole(ctx.deviceRole);
|
|
52
|
-
if (
|
|
57
|
+
if (headedDevice) {
|
|
53
58
|
// Drop an INHERITED copy of OUR OWN setup-token: a launch from inside a
|
|
54
59
|
// headless agent's shell inherits that agent's injected value via
|
|
55
60
|
// sanitizeProcessEnv(process.env) and would keep authenticating as it,
|
|
@@ -66,16 +71,17 @@ export const claudeAdapter = {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
else {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// per-account setup-token when one resolves —
|
|
72
|
-
// value inherited from the launcher. When
|
|
73
|
-
// CLAUDE_CODE_OAUTH_TOKEN so a run on a
|
|
74
|
-
// authenticate as the shared, rotating
|
|
75
|
-
// let through — the RUSH-1822
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
74
|
+
// Any run on a NON-personal device (worker, dispatched, provisioned box) —
|
|
75
|
+
// interactive OR headless: mirror the routines path (`runner.ts`)
|
|
76
|
+
// UNCONDITIONALLY. Inject the per-account setup-token when one resolves —
|
|
77
|
+
// it replaces any ambient shared value inherited from the launcher. When
|
|
78
|
+
// NONE resolves, STRIP the ambient CLAUDE_CODE_OAUTH_TOKEN so a run on a
|
|
79
|
+
// provisioned box can never silently authenticate as the shared, rotating
|
|
80
|
+
// token an earlier version of this path let through — the RUSH-1822
|
|
81
|
+
// fleet-wide-logout hazard, tracked by RUSH-2360. A missing login then
|
|
82
|
+
// fails loud (401) against this home's own credential instead of quietly
|
|
83
|
+
// borrowing another's. options.env still wins below for an explicit
|
|
84
|
+
// caller override.
|
|
79
85
|
if (setupToken) {
|
|
80
86
|
result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
|
|
81
87
|
}
|
|
@@ -25,6 +25,23 @@ export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
|
|
|
25
25
|
interface RemoteSpec {
|
|
26
26
|
/** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
|
|
27
27
|
nonInteractive?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Pure read-only RENDER command: draws a screen and exits, never reads stdin.
|
|
30
|
+
* Forwarded over a PLAIN PIPE rather than `ssh -tt` (a forced PTY), because the
|
|
31
|
+
* PTY teardown + local-terminal restore on a CLEAN exit wipes the just-drawn
|
|
32
|
+
* output — so the render "flashes and vanishes" the moment it finishes
|
|
33
|
+
* (PHNX-3583). Over a pipe the output persists exactly as the working non-TTY
|
|
34
|
+
* path already proves; color and terminal geometry are forced into the remote
|
|
35
|
+
* env so it still comes back colored and correctly wrapped
|
|
36
|
+
* ({@link renderForwardDecision}).
|
|
37
|
+
*/
|
|
38
|
+
render?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* For a {@link render} command with a NARROW interactive sub-path, return `true`
|
|
41
|
+
* when THIS argv hits it — the PTY is kept for that one invocation. Absent means
|
|
42
|
+
* the command never prompts, so it is always safe to forward over a pipe.
|
|
43
|
+
*/
|
|
44
|
+
interactiveWhen?: (forwarded: string[]) => boolean;
|
|
28
45
|
}
|
|
29
46
|
/**
|
|
30
47
|
* First-class groups that run transparently on a remote via SSH when
|
|
@@ -58,6 +75,33 @@ export declare const OWN_HOST_COMMANDS: Set<string>;
|
|
|
58
75
|
* (RUSH-2864).
|
|
59
76
|
*/
|
|
60
77
|
export declare function buildPassthroughForwardedArgs(command: string, allArgs: string[], interactive: boolean): string[];
|
|
78
|
+
/**
|
|
79
|
+
* Decide whether a `--device` passthrough should forward over a PLAIN PIPE
|
|
80
|
+
* instead of a PTY, and what color/geometry env to inject when it does.
|
|
81
|
+
*
|
|
82
|
+
* A pure read-only render ({@link RemoteSpec.render}) drawn under a forced
|
|
83
|
+
* `ssh -tt` PTY vanishes on clean exit: the PTY teardown + local-terminal
|
|
84
|
+
* restore (`restoreLocalTerminal` in ssh-exec.ts) wipes the output the command
|
|
85
|
+
* just drew (PHNX-3583). The non-TTY (piped) path never had this problem, so a
|
|
86
|
+
* render command takes it too — but only when a human is actually at a real
|
|
87
|
+
* local terminal (`isTTY` and not `--no-tty`); a genuinely piped local run is
|
|
88
|
+
* already on the pipe path and wants neither color nor forced geometry.
|
|
89
|
+
*
|
|
90
|
+
* A render command's narrow interactive sub-path ({@link RemoteSpec.interactiveWhen},
|
|
91
|
+
* e.g. `view --prune`'s confirm) keeps the PTY. When forwarding over a pipe the
|
|
92
|
+
* remote sees `isTTY=false`, so chalk goes colorless and `terminalWidth()` has no
|
|
93
|
+
* `$COLUMNS` to read; `FORCE_COLOR`/`COLUMNS`/`LINES` restore both. `FORCE_COLOR`
|
|
94
|
+
* is withheld under `--json` so it can never taint machine-readable output.
|
|
95
|
+
*/
|
|
96
|
+
export declare function renderForwardDecision(command: string, allArgs: string[], io: {
|
|
97
|
+
isTTY: boolean;
|
|
98
|
+
noTty: boolean;
|
|
99
|
+
columns?: number;
|
|
100
|
+
rows?: number;
|
|
101
|
+
}): {
|
|
102
|
+
noPty: boolean;
|
|
103
|
+
env?: Record<string, string>;
|
|
104
|
+
};
|
|
61
105
|
/** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
|
|
62
106
|
export interface FleetPassthroughOptions {
|
|
63
107
|
/** Override the device registry loader (tests). */
|
|
@@ -54,14 +54,23 @@ export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
|
|
|
54
54
|
* `add`/`use`/`list`, and none) and were removed.
|
|
55
55
|
*/
|
|
56
56
|
export const REMOTE_PASSTHROUGH = {
|
|
57
|
-
// inspect
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// inspect — pure read-only renders: forward over a pipe, never a forced PTY
|
|
58
|
+
// (PHNX-3583), so the drawn output persists instead of vanishing on exit.
|
|
59
|
+
view: {
|
|
60
|
+
render: true,
|
|
61
|
+
// Only `--prune` (without --yes/--dry-run) asks a confirm(); that one
|
|
62
|
+
// invocation needs the PTY. Every other `view` is a pure render.
|
|
63
|
+
interactiveWhen: (f) => f.includes('--prune') &&
|
|
64
|
+
!f.includes('--dry-run') &&
|
|
65
|
+
!f.includes('--yes') &&
|
|
66
|
+
!f.includes('-y'),
|
|
67
|
+
},
|
|
68
|
+
inspect: { render: true },
|
|
69
|
+
doctor: { render: true },
|
|
61
70
|
check: {},
|
|
62
71
|
list: {},
|
|
63
72
|
usage: {},
|
|
64
|
-
insights: {},
|
|
73
|
+
insights: { render: true },
|
|
65
74
|
// config / resources
|
|
66
75
|
config: {},
|
|
67
76
|
sync: { nonInteractive: ['--yes'] },
|
|
@@ -162,6 +171,41 @@ export function buildPassthroughForwardedArgs(command, allArgs, interactive) {
|
|
|
162
171
|
}
|
|
163
172
|
return forwarded;
|
|
164
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Decide whether a `--device` passthrough should forward over a PLAIN PIPE
|
|
176
|
+
* instead of a PTY, and what color/geometry env to inject when it does.
|
|
177
|
+
*
|
|
178
|
+
* A pure read-only render ({@link RemoteSpec.render}) drawn under a forced
|
|
179
|
+
* `ssh -tt` PTY vanishes on clean exit: the PTY teardown + local-terminal
|
|
180
|
+
* restore (`restoreLocalTerminal` in ssh-exec.ts) wipes the output the command
|
|
181
|
+
* just drew (PHNX-3583). The non-TTY (piped) path never had this problem, so a
|
|
182
|
+
* render command takes it too — but only when a human is actually at a real
|
|
183
|
+
* local terminal (`isTTY` and not `--no-tty`); a genuinely piped local run is
|
|
184
|
+
* already on the pipe path and wants neither color nor forced geometry.
|
|
185
|
+
*
|
|
186
|
+
* A render command's narrow interactive sub-path ({@link RemoteSpec.interactiveWhen},
|
|
187
|
+
* e.g. `view --prune`'s confirm) keeps the PTY. When forwarding over a pipe the
|
|
188
|
+
* remote sees `isTTY=false`, so chalk goes colorless and `terminalWidth()` has no
|
|
189
|
+
* `$COLUMNS` to read; `FORCE_COLOR`/`COLUMNS`/`LINES` restore both. `FORCE_COLOR`
|
|
190
|
+
* is withheld under `--json` so it can never taint machine-readable output.
|
|
191
|
+
*/
|
|
192
|
+
export function renderForwardDecision(command, allArgs, io) {
|
|
193
|
+
const spec = REMOTE_PASSTHROUGH[command];
|
|
194
|
+
const localTty = io.isTTY && !io.noTty;
|
|
195
|
+
if (!spec?.render || !localTty)
|
|
196
|
+
return { noPty: false };
|
|
197
|
+
const forwarded = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
198
|
+
if (spec.interactiveWhen?.(forwarded))
|
|
199
|
+
return { noPty: false };
|
|
200
|
+
const env = {};
|
|
201
|
+
if (!allArgs.includes('--json'))
|
|
202
|
+
env.FORCE_COLOR = '1';
|
|
203
|
+
if (io.columns && io.columns > 0)
|
|
204
|
+
env.COLUMNS = String(io.columns);
|
|
205
|
+
if (io.rows && io.rows > 0)
|
|
206
|
+
env.LINES = String(io.rows);
|
|
207
|
+
return { noPty: true, env: Object.keys(env).length ? env : undefined };
|
|
208
|
+
}
|
|
165
209
|
/** Synthesize a `Host` for a raw `user@host` / bare-alias target (not enrolled). */
|
|
166
210
|
function syntheticHost(target) {
|
|
167
211
|
const at = target.indexOf('@');
|
|
@@ -554,10 +598,21 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
554
598
|
return true;
|
|
555
599
|
}
|
|
556
600
|
const target = sshTargetFor(host);
|
|
601
|
+
// A read-only render (view/inspect/insights/doctor) must forward over a plain
|
|
602
|
+
// pipe even from a real terminal — a forced `ssh -tt` PTY wipes its output on
|
|
603
|
+
// clean exit (PHNX-3583). renderForwardDecision returns that choice plus the
|
|
604
|
+
// color/geometry env that keeps a piped render colored and correctly wrapped.
|
|
605
|
+
const { noPty: renderNoPty, env: renderForwardEnv } = renderForwardDecision(command, allArgs, {
|
|
606
|
+
isTTY: !!process.stdout.isTTY,
|
|
607
|
+
noTty: allArgs.includes('--no-tty'),
|
|
608
|
+
columns: process.stdout.columns,
|
|
609
|
+
rows: process.stdout.rows,
|
|
610
|
+
});
|
|
557
611
|
// Interactive only when our own stdout is a terminal and the caller didn't opt
|
|
558
612
|
// out — otherwise force the command's non-interactive path so no half-drawn
|
|
559
|
-
// picker is piped into a file or another program.
|
|
560
|
-
|
|
613
|
+
// picker is piped into a file or another program. A no-PTY render is
|
|
614
|
+
// non-interactive by construction.
|
|
615
|
+
const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty') && !renderNoPty;
|
|
561
616
|
const forwarded = buildPassthroughForwardedArgs(command, allArgs, interactive);
|
|
562
617
|
// The one long-running case: keep the remote team supervisor alive past a
|
|
563
618
|
// disconnect by dispatching it detached (nohup), still streaming live.
|
|
@@ -583,10 +638,13 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
583
638
|
const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
|
|
584
639
|
? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
|
|
585
640
|
: undefined;
|
|
641
|
+
// Merge the doctor PATH bootstrap with the render color/geometry env (doctor is
|
|
642
|
+
// itself a render command, so both can apply). undefined when neither is needed.
|
|
643
|
+
const extraEnv = doctorPath || renderForwardEnv ? { ...doctorPath, ...renderForwardEnv } : undefined;
|
|
586
644
|
process.exitCode = streamAgentsOnHost(host, forwarded, {
|
|
587
645
|
remoteCwd,
|
|
588
646
|
interactive,
|
|
589
|
-
extraEnv
|
|
647
|
+
extraEnv,
|
|
590
648
|
remoteOs,
|
|
591
649
|
target,
|
|
592
650
|
});
|