@phnx-labs/agents-cli 1.22.64 → 1.22.66

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.
@@ -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
- if (cached)
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
- const missing = chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
600
- return index < windowsToRender.length - 1 ? padToWidth(missing, 20) : missing;
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 mirrors the Grok collector, and an all-expired
1883
- * snapshot deserializes to null so `readClaudeUsageCache` deletes the entry and
1884
- * callers surface "usage unavailable" plus the recorded throttle reason.
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 windows = snapshot.windows
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
- .filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
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 && !unavailable && !snapshot.plan && !snapshot.refreshHint) {
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
- for (const segments of alternatives) {
1516
+ const hasSegment = alternatives.some((segments) => {
1517
1517
  const p = path.join(home, ...segments);
1518
1518
  try {
1519
- if (fs.existsSync(p))
1520
- return true;
1519
+ return fs.existsSync(p);
1521
1520
  }
1522
- catch { /* unreadable */ }
1523
- }
1524
- return false;
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
@@ -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
@@ -1,3 +1,4 @@
1
+ import type { DeviceProfile } from './registry.js';
1
2
  /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
2
3
  export declare function managedKnownHostsPath(): string;
3
4
  /** Ensure the parent directory of the managed store exists (mode 0700). */
@@ -12,6 +13,20 @@ export declare function readManagedKnownHosts(file?: string): string;
12
13
  export declare function isHostPinnedIn(content: string, host: string): boolean;
13
14
  /** True if `host` is pinned in the managed store on disk. */
14
15
  export declare function isHostPinned(host: string, file?: string): boolean;
16
+ /**
17
+ * True if a DEVICE's host key is pinned — checked against the SAME host string
18
+ * the ssh connection dials (`hostNameFor(device)`: the Tailscale dnsName/IP that
19
+ * `sshTargetFor` resolves), falling back to the bare device name.
20
+ *
21
+ * A device discovered over the tailnet is pinned under its FQDN
22
+ * (`yosemite-m6.tail….ts.net`), so a caller that checks the bare `device.name`
23
+ * misses the pin and wrongly treats a reachable, pinned peer as unpinned —
24
+ * silently excluding it from usage/auth fan-out while ssh to it would have
25
+ * succeeded (PHNX-3505). Accepting the bare name too keeps a device pinned only
26
+ * under its short name working. `isPinned` is injectable for tests; it defaults
27
+ * to the managed on-disk store.
28
+ */
29
+ export declare function isDevicePinned(device: DeviceProfile, isPinned?: (host: string) => boolean): boolean;
15
30
  /**
16
31
  * The host-key-checking ssh options for a connection.
17
32
  *
@@ -22,6 +22,7 @@ import { spawnSync } from 'child_process';
22
22
  import { getCacheDir } from '../state.js';
23
23
  import { assertValidSshTarget } from '../ssh-exec.js';
24
24
  import { parseKnownHosts } from '../hosts/ssh-config.js';
25
+ import { hostNameFor } from './ssh-config.js';
25
26
  /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
26
27
  export function managedKnownHostsPath() {
27
28
  return path.join(getCacheDir(), 'devices', 'known_hosts');
@@ -54,6 +55,23 @@ export function isHostPinnedIn(content, host) {
54
55
  export function isHostPinned(host, file = managedKnownHostsPath()) {
55
56
  return isHostPinnedIn(readManagedKnownHosts(file), host);
56
57
  }
58
+ /**
59
+ * True if a DEVICE's host key is pinned — checked against the SAME host string
60
+ * the ssh connection dials (`hostNameFor(device)`: the Tailscale dnsName/IP that
61
+ * `sshTargetFor` resolves), falling back to the bare device name.
62
+ *
63
+ * A device discovered over the tailnet is pinned under its FQDN
64
+ * (`yosemite-m6.tail….ts.net`), so a caller that checks the bare `device.name`
65
+ * misses the pin and wrongly treats a reachable, pinned peer as unpinned —
66
+ * silently excluding it from usage/auth fan-out while ssh to it would have
67
+ * succeeded (PHNX-3505). Accepting the bare name too keeps a device pinned only
68
+ * under its short name working. `isPinned` is injectable for tests; it defaults
69
+ * to the managed on-disk store.
70
+ */
71
+ export function isDevicePinned(device, isPinned = (host) => isHostPinned(host)) {
72
+ const host = device.address ? hostNameFor(device) : undefined;
73
+ return (host != null && isPinned(host)) || isPinned(device.name);
74
+ }
57
75
  /**
58
76
  * The host-key-checking ssh options for a connection.
59
77
  *
@@ -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-z]+)\}/g;
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 link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
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, link].filter(Boolean);
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 text = rendered.trim();
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. Two kinds of run defer to the per-version login instead:
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
- // 1. An interactive run: a human is at the TTY, and their per-version login
34
- // is the credential they established and expect. Overriding it made
35
- // `/status` report `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal
36
- // machine and took every hand-driven session off that login.
37
- // 2. ANY run on a `personal` device — the user's own interactive box (zion),
38
- // marked `config.role: personal`. That box holds a real per-version login
39
- // and is the single origin of it, so every run there — interactive TUI OR
40
- // a headless one-shot like `agents run claude "fix the bug"` — MUST use
41
- // that login, not the setup-token. Gating on run mode ALONE
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 this path defers to
49
- // Claude Code, which reads its own ACL-trusted login item without a prompt and
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 (ctx.interactive || headedDevice) {
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
- // Headless run on a NON-personal device (worker, dispatched, provisioned
70
- // box): mirror the routines path (`runner.ts`) UNCONDITIONALLY. Inject the
71
- // per-account setup-token when one resolves — it replaces any ambient shared
72
- // value inherited from the launcher. When NONE resolves, STRIP the ambient
73
- // CLAUDE_CODE_OAUTH_TOKEN so a run on a provisioned box can never silently
74
- // authenticate as the shared, rotating token an earlier version of this path
75
- // let through — the RUSH-1822 fleet-wide-logout hazard, tracked by RUSH-2360.
76
- // A missing login then fails loud (401) against this home's own credential
77
- // instead of quietly borrowing another's. options.env still wins below for an
78
- // explicit caller override.
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). */