@phnx-labs/agents-cli 1.22.107 → 1.22.110

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/daemon.js +45 -40
  7. package/dist/commands/doctor.js +7 -0
  8. package/dist/commands/exec.js +4 -2
  9. package/dist/commands/fork.js +6 -4
  10. package/dist/commands/logs.js +2 -1
  11. package/dist/commands/monitors.js +15 -2
  12. package/dist/commands/routines.js +46 -6
  13. package/dist/commands/sessions-backfill.d.ts +30 -0
  14. package/dist/commands/sessions-backfill.js +72 -0
  15. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  16. package/dist/commands/sessions-backup-setup.js +65 -0
  17. package/dist/commands/sessions-inject.d.ts +3 -3
  18. package/dist/commands/sessions-inject.js +5 -11
  19. package/dist/commands/sessions-picker.js +10 -7
  20. package/dist/commands/sessions-resume.d.ts +17 -2
  21. package/dist/commands/sessions-resume.js +88 -12
  22. package/dist/commands/sessions.js +46 -24
  23. package/dist/commands/share.js +15 -2
  24. package/dist/commands/sync.js +30 -1
  25. package/dist/commands/utils.d.ts +9 -0
  26. package/dist/commands/utils.js +18 -0
  27. package/dist/commands/watchdog.js +4 -1
  28. package/dist/lib/accounting/rotate.d.ts +9 -0
  29. package/dist/lib/accounting/rotate.js +17 -1
  30. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  31. package/dist/lib/accounting/usage-sync.js +70 -6
  32. package/dist/lib/accounting/usage.d.ts +32 -0
  33. package/dist/lib/accounting/usage.js +37 -2
  34. package/dist/lib/accounts/slots.js +7 -0
  35. package/dist/lib/agent-spec/agents.d.ts +60 -8
  36. package/dist/lib/agent-spec/agents.js +118 -45
  37. package/dist/lib/auth-health.js +19 -0
  38. package/dist/lib/claude-statusline.js +5 -0
  39. package/dist/lib/computer/sessions-list.js +2 -1
  40. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  41. package/dist/lib/daemon/auth-sync-service.js +2 -2
  42. package/dist/lib/daemon/daemon.d.ts +34 -1
  43. package/dist/lib/daemon/daemon.js +70 -9
  44. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  45. package/dist/lib/daemon/leaked-daemons.js +180 -0
  46. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  47. package/dist/lib/daemon/session-title-service.js +76 -0
  48. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  49. package/dist/lib/daemon/usage-sync-service.js +10 -2
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +5 -0
  52. package/dist/lib/daemon-ticks.js +6 -0
  53. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  54. package/dist/lib/devices/doctor-findings.js +32 -1
  55. package/dist/lib/fleet-shared-state.d.ts +2 -0
  56. package/dist/lib/hooks/install.js +78 -64
  57. package/dist/lib/mailbox-target.js +2 -1
  58. package/dist/lib/models.js +76 -5
  59. package/dist/lib/session/active.d.ts +93 -16
  60. package/dist/lib/session/active.js +80 -14
  61. package/dist/lib/session/cloud.js +3 -1
  62. package/dist/lib/session/db.d.ts +44 -1
  63. package/dist/lib/session/db.js +122 -17
  64. package/dist/lib/session/fork.d.ts +10 -2
  65. package/dist/lib/session/fork.js +11 -2
  66. package/dist/lib/session/live-metadata.js +6 -0
  67. package/dist/lib/session/mirror.d.ts +3 -2
  68. package/dist/lib/session/mirror.js +5 -2
  69. package/dist/lib/session/parse.d.ts +1 -0
  70. package/dist/lib/session/parse.js +136 -2
  71. package/dist/lib/session/recovery.d.ts +9 -1
  72. package/dist/lib/session/recovery.js +14 -4
  73. package/dist/lib/session/remote/watch.js +18 -8
  74. package/dist/lib/session/title.d.ts +256 -0
  75. package/dist/lib/session/title.js +312 -0
  76. package/dist/lib/session/tool-calls.d.ts +1 -1
  77. package/dist/lib/session/tool-calls.js +40 -5
  78. package/dist/lib/session/tool-index.d.ts +5 -0
  79. package/dist/lib/session/tool-index.js +1 -0
  80. package/dist/lib/session/types.d.ts +11 -0
  81. package/dist/lib/share/backend.d.ts +23 -0
  82. package/dist/lib/share/backend.js +24 -0
  83. package/dist/lib/share/provision.d.ts +12 -0
  84. package/dist/lib/share/provision.js +30 -0
  85. package/dist/lib/share/worker-template.js +273 -0
  86. package/dist/lib/startup/root-command.d.ts +2 -0
  87. package/dist/lib/startup/root-command.js +22 -0
  88. package/dist/lib/terminal/engine.js +13 -1
  89. package/dist/lib/traces/sync.js +1 -0
  90. package/dist/lib/usage-refresh.d.ts +58 -7
  91. package/dist/lib/usage-refresh.js +145 -23
  92. package/dist/lib/watchdog/runner.d.ts +3 -0
  93. package/dist/lib/watchdog/runner.js +1 -0
  94. package/dist/session-tracker/dist/install-hook.js +4 -4
  95. package/package.json +1 -1
@@ -431,6 +431,7 @@ export async function getUsageInfoForIdentity(input, opts) {
431
431
  organizationId: input.info.organizationId,
432
432
  fileOnly: opts?.fileOnly,
433
433
  allowInteractiveLogin: opts?.allowInteractiveLogin,
434
+ nativeFileLogin: opts?.nativeFileLogin,
434
435
  signal: opts?.signal,
435
436
  });
436
437
  }
@@ -469,6 +470,7 @@ export async function getUsageInfoForIdentity(input, opts) {
469
470
  // Explicit refresh: block on the shared device collector.
470
471
  return fetchLiveUsageDeduped(input, usageKey, cached, opts?.fileOnly === true, {
471
472
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
473
+ nativeFileLogin: opts?.nativeFileLogin === true,
472
474
  signal: opts?.signal,
473
475
  });
474
476
  }
@@ -501,6 +503,7 @@ async function fetchLiveUsageDeduped(input, usageKey, cached, fileOnly, opts) {
501
503
  usageScope: usageKey,
502
504
  fileOnly,
503
505
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
506
+ nativeFileLogin: opts?.nativeFileLogin === true,
504
507
  signal: opts?.signal,
505
508
  });
506
509
  if (usage.snapshot) {
@@ -935,9 +938,10 @@ async function getClaudeUsageInfo(options) {
935
938
  // requires (the setup-token is user:inference → 403, RUSH-2392). It is unset
936
939
  // for every background caller, so the RUSH-1822 guarantee is untouched there.
937
940
  const oauth = await loadClaudeOauth(options?.home, {
938
- accessTokenCache: true,
939
- fileOnly: options?.fileOnly === true,
941
+ accessTokenCache: options?.nativeFileLogin !== true,
942
+ fileOnly: options?.fileOnly === true || options?.nativeFileLogin === true,
940
943
  allowInteractiveLogin: options?.allowInteractiveLogin === true,
944
+ nativeFileLogin: options?.nativeFileLogin === true,
941
945
  });
942
946
  if (!oauth?.accessToken) {
943
947
  // NOT the shared no-credential message: "sign in" is not a remedy here.
@@ -1612,7 +1616,33 @@ function deleteCachedClaudeOauth(service) {
1612
1616
  * `.credentials.json` only. Used by the daemon usage refresher so a background
1613
1617
  * tick can never pop Touch ID.
1614
1618
  */
1619
+ /** True when `<home>/.claude/.credentials.json` is a native rotating OAuth blob. */
1620
+ export function claudeHomeHasNativeOauthFile(home) {
1621
+ return readClaudeNativeCredentialsFile(home) !== null;
1622
+ }
1623
+ function readClaudeNativeCredentialsFile(home) {
1624
+ const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
1625
+ try {
1626
+ if (!fs.existsSync(credsPath))
1627
+ return null;
1628
+ const parsed = parseClaudeOauthPayload(fs.readFileSync(credsPath, 'utf-8'));
1629
+ // A native rotating blob has a refresh token. A setup-token-shaped file
1630
+ // (access only) is not a native login and must not be polled as one.
1631
+ if (!parsed?.accessToken?.trim() || !parsed.refreshToken?.trim())
1632
+ return null;
1633
+ return parsed;
1634
+ }
1635
+ catch {
1636
+ return null;
1637
+ }
1638
+ }
1615
1639
  export async function loadClaudeOauth(home, opts) {
1640
+ // Headed usage poller: the native rotating blob in `.credentials.json` is
1641
+ // the only credential that carries `user:profile` without opening the ACL
1642
+ // keychain (Touch ID) or firing a setup-token that 403s on /oauth/usage.
1643
+ if (opts?.nativeFileLogin === true) {
1644
+ return readClaudeNativeCredentialsFile(home);
1645
+ }
1616
1646
  // Read-only usage/probe callers (accessTokenCache) authenticate ONLY with a
1617
1647
  // file-based setup-token from the `auth` bundle — never Claude Code's
1618
1648
  // interactive login. The usage endpoint accepts any sk-ant-oat01 bearer, and
@@ -1963,6 +1993,8 @@ function serializeClaudeUsageSnapshot(snapshot) {
1963
1993
  resetsAt: window.resetsAt?.toISOString() || null,
1964
1994
  windowMinutes: window.windowMinutes,
1965
1995
  })),
1996
+ freshnessSource: snapshot.freshness?.source,
1997
+ pollerDevice: snapshot.freshness?.poller,
1966
1998
  };
1967
1999
  }
1968
2000
  /**
@@ -2028,6 +2060,9 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
2028
2060
  plan: snapshot.plan ?? null,
2029
2061
  refreshHint: snapshot.refreshHint ?? null,
2030
2062
  unavailable,
2063
+ freshness: snapshot.freshnessSource
2064
+ ? { source: snapshot.freshnessSource, poller: snapshot.pollerDevice }
2065
+ : undefined,
2031
2066
  };
2032
2067
  }
2033
2068
  /**
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { agentConfigDirName } from '../agents.js';
17
17
  import { harnessAuth, harnessWorkerIsPerDevice } from '../harness-auth-capabilities.js';
18
+ import { installSessionTrackerHookSync } from '../hooks/install.js';
18
19
  import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from '../installations/store.js';
19
20
  import { carryForwardSettings } from '../settings-manifest.js';
20
21
  import { getHistoryDir, readMeta, updateMeta } from '../state.js';
@@ -71,6 +72,12 @@ function projectResources(harness, version, destHome, fromHome) {
71
72
  continue;
72
73
  writer.write({ version, versionHome: destHome, selection: names, cwd });
73
74
  }
75
+ if (supports(harness, 'hooks', version).ok) {
76
+ const tracker = installSessionTrackerHookSync(harness, version, destHome);
77
+ if (!tracker.installed && tracker.error) {
78
+ console.warn(`agents: SessionStart hook not installed for ${harness} account home ${destHome}: ${tracker.error}`);
79
+ }
80
+ }
74
81
  }
75
82
  /**
76
83
  * Remove slot artifacts whose source name is gone. Only the name-keyed kinds
@@ -355,25 +355,77 @@ export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
355
355
  } | null;
356
356
  /** @internal test hook — clear the per-process keyring probe cache. */
357
357
  export declare function __resetAntigravityKeychainCacheForTest(): void;
358
+ /** The XDG base dirs OpenCode reads, and the env var that overrides each. */
359
+ declare const OPENCODE_XDG_DIRS: {
360
+ readonly data: {
361
+ readonly env: "XDG_DATA_HOME";
362
+ readonly fallback: readonly [".local", "share"];
363
+ };
364
+ readonly state: {
365
+ readonly env: "XDG_STATE_HOME";
366
+ readonly fallback: readonly [".local", "state"];
367
+ };
368
+ };
369
+ /**
370
+ * Resolve one of OpenCode's (sst/opencode) XDG-rooted files.
371
+ *
372
+ * OpenCode keeps provider credentials under `$XDG_DATA_HOME/opencode/` and TUI
373
+ * state under `$XDG_STATE_HOME/opencode/`, defaulting to `~/.local/share` and
374
+ * `~/.local/state` on EVERY platform — its `xdg-basedir` dependency does not
375
+ * special-case macOS, so there is no `~/Library/Application Support` variant.
376
+ * Both roots are account-global (not per-version), matching how
377
+ * `session/discover.ts` already resolves `~/.local/share/opencode/opencode.db`.
378
+ *
379
+ * Resolution order, first existing wins:
380
+ * 1. `<base>/<fallback>/opencode/<file>` — the passed per-version home. This is
381
+ * primarily a test hook (suites write a hermetic file under a temp home)
382
+ * but also covers any relocated install.
383
+ * 2. `$XDG_<KIND>_HOME/opencode/<file>` — an explicit XDG override, exactly
384
+ * what OpenCode itself honours.
385
+ * 3. `<realHome>/<fallback>/opencode/<file>` — the active default, under
386
+ * `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
387
+ * the one account-global state (same fallback shape as
388
+ * resolveAccountCredentialPath).
389
+ * Returns the first existing path, or null. Never throws.
390
+ */
391
+ export declare function resolveOpenCodeXdgPath(base: string, kind: keyof typeof OPENCODE_XDG_DIRS, file: string): string | null;
392
+ /** OpenCode's signed-in identity, as far as `auth.json` can describe it. */
393
+ export interface OpenCodeIdentity {
394
+ /** Sorted, "+"-joined provider ids holding a valid credential. */
395
+ providers: string;
396
+ /** Account email, when some OAuth credential's token carries the claim. */
397
+ email: string | null;
398
+ /** Plan tier from the same token (e.g. `Pro`), when present. */
399
+ plan: string | null;
400
+ }
358
401
  /**
359
- * OpenCode's account identity: the sorted, "+"-joined list of provider ids
360
- * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
361
- * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
362
- * so this join is the closest thing to "which account is this" available — the
363
- * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
402
+ * OpenCode's account identity, read from `auth.json`.
403
+ *
404
+ * The provider join (`"meta+openai+opencode-go"`) is the stable key: OpenCode is
405
+ * a multi-provider harness, so "which providers are configured" is what actually
406
+ * identifies an install, and `session/discover.ts` indexes sessions by it.
407
+ * Providers that sign in over OAuth additionally hand OpenCode a token with real
408
+ * identity claims, so the email and plan are surfaced alongside it instead of
409
+ * leaving `agents view` showing a bare `id:` key for a login that knows exactly
410
+ * whose it is. Providers are walked in sorted order so a multi-OAuth install
411
+ * resolves to the same email on every machine.
364
412
  *
365
413
  * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
366
414
  * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
367
415
  * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
368
416
  * migrations) all three are permanently empty — no migration ever populates
369
417
  * them, and no session has ever written a row. Reading from them instead of
370
- * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
371
- * uses this function rather than duplicating a sqlite lookup against those
372
- * dead tables.
418
+ * `auth.json` always yields `undefined`, credential or not.
373
419
  *
374
420
  * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
375
421
  * missing, unreadable, or carries no valid credential.
376
422
  */
423
+ export declare function resolveOpenCodeIdentity(base: string): OpenCodeIdentity | undefined;
424
+ /**
425
+ * The provider join alone — the value `session/discover.ts` indexes sessions by,
426
+ * kept as its own entry point so callers that only need the key do not have to
427
+ * know about the OAuth claim walk.
428
+ */
377
429
  export declare function resolveOpenCodeAccountId(base: string): string | undefined;
378
430
  /**
379
431
  * Whether a Claude version home's credential file is present but carries no
@@ -1692,34 +1692,41 @@ async function antigravityKeychainSignedIn() {
1692
1692
  }
1693
1693
  return cachedAgyKeychainSignedIn;
1694
1694
  }
1695
+ /** The XDG base dirs OpenCode reads, and the env var that overrides each. */
1696
+ const OPENCODE_XDG_DIRS = {
1697
+ data: { env: 'XDG_DATA_HOME', fallback: ['.local', 'share'] },
1698
+ state: { env: 'XDG_STATE_HOME', fallback: ['.local', 'state'] },
1699
+ };
1695
1700
  /**
1696
- * OpenCode (sst/opencode) stores provider credentials in a single JSON file at
1697
- * `$XDG_DATA_HOME/opencode/auth.json`, defaulting to
1698
- * `~/.local/share/opencode/auth.json` on EVERY platform — its `xdg-basedir`
1699
- * dependency does not special-case macOS, so there is no
1700
- * `~/Library/Application Support` variant. The path is account-global (not
1701
- * per-version), matching how `session/discover.ts` already resolves
1702
- * `~/.local/share/opencode/opencode.db`.
1701
+ * Resolve one of OpenCode's (sst/opencode) XDG-rooted files.
1702
+ *
1703
+ * OpenCode keeps provider credentials under `$XDG_DATA_HOME/opencode/` and TUI
1704
+ * state under `$XDG_STATE_HOME/opencode/`, defaulting to `~/.local/share` and
1705
+ * `~/.local/state` on EVERY platform — its `xdg-basedir` dependency does not
1706
+ * special-case macOS, so there is no `~/Library/Application Support` variant.
1707
+ * Both roots are account-global (not per-version), matching how
1708
+ * `session/discover.ts` already resolves `~/.local/share/opencode/opencode.db`.
1703
1709
  *
1704
1710
  * Resolution order, first existing wins:
1705
- * 1. `<base>/.local/share/opencode/auth.json` — the passed per-version home.
1706
- * This is primarily a test hook (suites write a hermetic auth file under a
1707
- * temp home) but also covers any relocated install.
1708
- * 2. `$XDG_DATA_HOME/opencode/auth.json` — an explicit XDG override, exactly
1711
+ * 1. `<base>/<fallback>/opencode/<file>` — the passed per-version home. This is
1712
+ * primarily a test hook (suites write a hermetic file under a temp home)
1713
+ * but also covers any relocated install.
1714
+ * 2. `$XDG_<KIND>_HOME/opencode/<file>` — an explicit XDG override, exactly
1709
1715
  * what OpenCode itself honours.
1710
- * 3. `<realHome>/.local/share/opencode/auth.json` — the active default, under
1716
+ * 3. `<realHome>/<fallback>/opencode/<file>` — the active default, under
1711
1717
  * `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
1712
- * the one account-global login (same fallback shape as
1718
+ * the one account-global state (same fallback shape as
1713
1719
  * resolveAccountCredentialPath).
1714
1720
  * Returns the first existing path, or null. Never throws.
1715
1721
  */
1716
- function resolveOpenCodeAuthPath(base) {
1717
- const candidates = [path.join(base, '.local', 'share', 'opencode', 'auth.json')];
1718
- const xdgData = process.env.XDG_DATA_HOME;
1719
- if (xdgData)
1720
- candidates.push(path.join(xdgData, 'opencode', 'auth.json'));
1722
+ export function resolveOpenCodeXdgPath(base, kind, file) {
1723
+ const { env, fallback } = OPENCODE_XDG_DIRS[kind];
1724
+ const candidates = [path.join(base, ...fallback, 'opencode', file)];
1725
+ const override = process.env[env];
1726
+ if (override)
1727
+ candidates.push(path.join(override, 'opencode', file));
1721
1728
  const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1722
- candidates.push(path.join(realHome, '.local', 'share', 'opencode', 'auth.json'));
1729
+ candidates.push(path.join(realHome, ...fallback, 'opencode', file));
1723
1730
  for (const candidate of candidates) {
1724
1731
  try {
1725
1732
  if (fs.existsSync(candidate))
@@ -1729,6 +1736,9 @@ function resolveOpenCodeAuthPath(base) {
1729
1736
  }
1730
1737
  return null;
1731
1738
  }
1739
+ function resolveOpenCodeAuthPath(base) {
1740
+ return resolveOpenCodeXdgPath(base, 'data', 'auth.json');
1741
+ }
1732
1742
  /**
1733
1743
  * Validate one OpenCode auth.json entry against its discriminated union
1734
1744
  * (`type: 'oauth' | 'api' | 'wellknown'`) and confirm the credential actually
@@ -1751,25 +1761,58 @@ function isValidOpenCodeCredential(value) {
1751
1761
  }
1752
1762
  }
1753
1763
  /**
1754
- * OpenCode's account identity: the sorted, "+"-joined list of provider ids
1755
- * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
1756
- * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
1757
- * so this join is the closest thing to "which account is this" available — the
1758
- * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
1764
+ * The human identity an OpenCode `type: 'oauth'` credential carries in its
1765
+ * access token, when that token is a JWT. Provider-agnostic by shape: the
1766
+ * OpenAI-issued token OpenCode stores under its `openai` provider carries the
1767
+ * same namespaced claims Codex's own `auth.json` does (`…/profile.email`,
1768
+ * `…/auth.chatgpt_plan_type`), so the extraction is shared with `case 'codex'`
1769
+ * rather than reinvented. A plain `email` claim is accepted too, for providers
1770
+ * that issue an ordinary OIDC token.
1771
+ *
1772
+ * Only claims are read — the token itself is never returned or logged. A
1773
+ * non-JWT credential (Anthropic's `sk-ant-oat…` opaque token, any `type: 'api'`
1774
+ * key) simply yields nothing.
1775
+ */
1776
+ function openCodeOauthIdentity(cred) {
1777
+ const access = cred?.access;
1778
+ if (typeof access !== 'string' || access.length === 0)
1779
+ return { email: null, plan: null };
1780
+ const claims = decodeJwtPayload(access);
1781
+ if (!claims)
1782
+ return { email: null, plan: null };
1783
+ const profile = claims['https://api.openai.com/profile'] || {};
1784
+ const auth = claims['https://api.openai.com/auth'] || {};
1785
+ const rawEmail = profile.email ?? claims.email;
1786
+ const email = typeof rawEmail === 'string' && rawEmail.includes('@') ? rawEmail : null;
1787
+ const rawPlan = auth.chatgpt_plan_type;
1788
+ const plan = typeof rawPlan === 'string' && rawPlan
1789
+ ? rawPlan.charAt(0).toUpperCase() + rawPlan.slice(1)
1790
+ : null;
1791
+ return { email, plan };
1792
+ }
1793
+ /**
1794
+ * OpenCode's account identity, read from `auth.json`.
1795
+ *
1796
+ * The provider join (`"meta+openai+opencode-go"`) is the stable key: OpenCode is
1797
+ * a multi-provider harness, so "which providers are configured" is what actually
1798
+ * identifies an install, and `session/discover.ts` indexes sessions by it.
1799
+ * Providers that sign in over OAuth additionally hand OpenCode a token with real
1800
+ * identity claims, so the email and plan are surfaced alongside it instead of
1801
+ * leaving `agents view` showing a bare `id:` key for a login that knows exactly
1802
+ * whose it is. Providers are walked in sorted order so a multi-OAuth install
1803
+ * resolves to the same email on every machine.
1759
1804
  *
1760
1805
  * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
1761
1806
  * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
1762
1807
  * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
1763
1808
  * migrations) all three are permanently empty — no migration ever populates
1764
1809
  * them, and no session has ever written a row. Reading from them instead of
1765
- * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
1766
- * uses this function rather than duplicating a sqlite lookup against those
1767
- * dead tables.
1810
+ * `auth.json` always yields `undefined`, credential or not.
1768
1811
  *
1769
1812
  * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
1770
1813
  * missing, unreadable, or carries no valid credential.
1771
1814
  */
1772
- export function resolveOpenCodeAccountId(base) {
1815
+ export function resolveOpenCodeIdentity(base) {
1773
1816
  const authPath = resolveOpenCodeAuthPath(base);
1774
1817
  if (!authPath)
1775
1818
  return undefined;
@@ -1777,16 +1820,34 @@ export function resolveOpenCodeAccountId(base) {
1777
1820
  const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1778
1821
  if (!data || typeof data !== 'object')
1779
1822
  return undefined;
1780
- const providers = Object.entries(data)
1823
+ const valid = Object.entries(data)
1781
1824
  .filter(([, cred]) => isValidOpenCodeCredential(cred))
1782
- .map(([id]) => id)
1783
- .sort();
1784
- return providers.length ? providers.join('+') : undefined;
1825
+ .sort(([a], [b]) => a.localeCompare(b));
1826
+ if (valid.length === 0)
1827
+ return undefined;
1828
+ let email = null;
1829
+ let plan = null;
1830
+ for (const [, cred] of valid) {
1831
+ const claimed = openCodeOauthIdentity(cred);
1832
+ email ??= claimed.email;
1833
+ plan ??= claimed.plan;
1834
+ if (email && plan)
1835
+ break;
1836
+ }
1837
+ return { providers: valid.map(([id]) => id).join('+'), email, plan };
1785
1838
  }
1786
1839
  catch {
1787
1840
  return undefined;
1788
1841
  }
1789
1842
  }
1843
+ /**
1844
+ * The provider join alone — the value `session/discover.ts` indexes sessions by,
1845
+ * kept as its own entry point so callers that only need the key do not have to
1846
+ * know about the OAuth claim walk.
1847
+ */
1848
+ export function resolveOpenCodeAccountId(base) {
1849
+ return resolveOpenCodeIdentity(base)?.providers;
1850
+ }
1790
1851
  /**
1791
1852
  * Whether a Muse Code `~/.config/muse/auth.json` document holds any usable
1792
1853
  * access token. Live shape from `muse login` (device OAuth, Muse Code 0.1.0):
@@ -1972,6 +2033,11 @@ export async function getAccountInfo(agentId, home) {
1972
2033
  claude: path.join(base, '.claude.json'),
1973
2034
  codex: path.join(base, '.codex', 'auth.json'),
1974
2035
  gemini: path.join(base, '.gemini', 'google_accounts.json'),
2036
+ // OpenCode keeps every session in ONE sqlite file rather than a directory of
2037
+ // per-session transcripts, so the session-file walk resolveLastActive runs
2038
+ // for other agents finds nothing. Its mtime fallback is the right read here:
2039
+ // opencode.db is written on every turn, so it tracks real activity.
2040
+ opencode: resolveOpenCodeXdgPath(base, 'data', 'opencode.db') ?? undefined,
1975
2041
  };
1976
2042
  const lastActive = resolveLastActive(agentId, base, configFiles[agentId]);
1977
2043
  try {
@@ -2238,20 +2304,27 @@ export async function getAccountInfo(agentId, home) {
2238
2304
  }
2239
2305
  case 'opencode': {
2240
2306
  // OpenCode's auth.json is a record keyed by provider id ->
2241
- // { type: 'oauth'|'api'|'wellknown', ...secret fields }. There is no
2242
- // email/identity claim to surface, so — like antigravity/kimi — we
2243
- // report signed-in state plus the NON-SECRET provider metadata (which
2244
- // provider ids hold a valid credential) and never read the tokens/keys
2245
- // themselves. The user's complaint was the row read "not signed in"
2246
- // despite a live login; a valid provider entry now shows e.g.
2247
- // "id:muse-spark" so they can see exactly which provider is configured.
2248
- // resolveOpenCodeAccountId is the single source of truth for this join —
2249
- // session/discover.ts reuses it for the indexed `account` field.
2250
- const accountId = resolveOpenCodeAccountId(base);
2251
- if (!accountId)
2307
+ // { type: 'oauth'|'api'|'wellknown', ...secret fields }. The provider
2308
+ // join is the identity key (see resolveOpenCodeIdentity), and an OAuth
2309
+ // provider's token additionally carries the real account email and plan
2310
+ // — so the row shows who is signed in rather than only a bare `id:` key.
2311
+ // Secrets are never read out; only JWT claims are.
2312
+ const identity = resolveOpenCodeIdentity(base);
2313
+ if (!identity)
2252
2314
  return { ...empty, lastActive };
2253
- const accountKey = buildIdentityKey(agentId, [['providers', accountId]]);
2254
- return { ...empty, signedIn: true, accountId, accountKey, lastActive };
2315
+ // Keyed on providers, not email: OpenCode bills through whichever
2316
+ // provider credentials are configured, so two installs sharing one
2317
+ // email but different provider sets are genuinely different accounts.
2318
+ const accountKey = buildIdentityKey(agentId, [['providers', identity.providers]]);
2319
+ return {
2320
+ ...empty,
2321
+ signedIn: true,
2322
+ accountId: identity.providers,
2323
+ accountKey,
2324
+ email: identity.email,
2325
+ plan: identity.plan,
2326
+ lastActive,
2327
+ };
2255
2328
  }
2256
2329
  case 'muse': {
2257
2330
  // Muse Code authenticates with META_API_KEY (env, highest priority) or
@@ -25,6 +25,8 @@ import { getCacheDir, readMeta } from './state.js';
25
25
  import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
26
26
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
27
27
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
28
+ import { selfConfiguredDeviceRole } from './device-config.js';
29
+ import { mayIssueUsageEndpointProbe, trySpendUsageApiCall } from './usage-refresh.js';
28
30
  /** Maximum age of an auth verdict used for automatic routing decisions. */
29
31
  export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
30
32
  /** Agents with a live network probe wired up today. The rest are best-effort. */
@@ -455,6 +457,23 @@ export async function probeAuthHealth(agent, home, opts) {
455
457
  if (derived)
456
458
  return derived;
457
459
  }
460
+ if (!mayIssueUsageEndpointProbe({
461
+ role: selfConfiguredDeviceRole(),
462
+ forceLive: opts?.forceLive,
463
+ })) {
464
+ return {
465
+ verdict: 'unverified',
466
+ checkedAt,
467
+ detail: 'setup-token box does not probe the usage endpoint',
468
+ };
469
+ }
470
+ if (opts?.forceLive !== true && usageScope && !trySpendUsageApiCall(usageScope, agent, checkedAt)) {
471
+ return {
472
+ verdict: 'unverified',
473
+ checkedAt,
474
+ detail: 'usage-endpoint budget already spent this hour',
475
+ };
476
+ }
458
477
  let probe;
459
478
  if (agent === 'claude')
460
479
  probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope, opts?.signal);
@@ -8,6 +8,7 @@ import { atomicWriteFileSync } from './fs-atomic.js';
8
8
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
9
9
  import { loadReminders, pickReminderForSession } from './reminders.js';
10
10
  import { readMeta } from './state.js';
11
+ import { machineId } from './machine-id.js';
11
12
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
12
13
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
13
14
  // The private subcommand this feature runs. It is only ever invoked internally,
@@ -77,7 +78,11 @@ export function ingestClaudeStatusLineUsage(payload, identity) {
77
78
  sourceLabel: 'Claude response rate limits',
78
79
  capturedAt: new Date(),
79
80
  windows,
81
+ freshness: { source: 'statusline', poller: machineId() },
80
82
  });
83
+ void import('./accounting/usage-sync.js')
84
+ .then((mod) => mod.pushUsageSnapshotNow())
85
+ .catch(() => { });
81
86
  return true;
82
87
  }
83
88
  /**
@@ -61,6 +61,7 @@
61
61
  import { query, truncate } from '../feed/events.js';
62
62
  import { formatRelativeTime } from '../session/relative-time.js';
63
63
  import { getSessionById, listComputerSessionRecords, pruneToolSessions } from '../session/db.js';
64
+ import { sessionHeadline } from '../session/title.js';
64
65
  import { buildLaunchSessionIndex, resolveLaunchSession, } from '../browser/sessions-list.js';
65
66
  /** Max chars of a `run --task` description persisted to the ledger — see the
66
67
  * module docblock's retention/privacy note. */
@@ -315,7 +316,7 @@ export function renderComputerSessionRows(rows) {
315
316
  const where = r.remoteHost ? `${r.machine} -> ${r.remoteHost}` : r.machine;
316
317
  const label = r.task ? truncate(r.task, 60) : (r.bundle ?? `pid ${r.pid}`);
317
318
  const link = r.linkStatus === 'linked' && r.linkedSession
318
- ? `${r.linkedSession.agent} — ${r.linkedSession.label || r.linkedSession.topic || r.linkedSession.shortId}`
319
+ ? `${r.linkedSession.agent} — ${sessionHeadline(r.linkedSession) || r.linkedSession.shortId}`
319
320
  : r.linkStatus === 'unresolved'
320
321
  ? 'unresolved (session not indexed here)'
321
322
  : 'unlinked';
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { BasePeriodicService, type DaemonContext } from './service.js';
27
27
  import type { DaemonServiceId } from '../daemon-services.js';
28
+ export declare const AUTH_SYNC_TICK_MS: number;
29
+ export declare const AUTH_SYNC_KICKOFF_MS = 60000;
28
30
  export declare class AuthSyncService extends BasePeriodicService {
29
31
  readonly id: DaemonServiceId;
30
32
  readonly intervalMs: number;
@@ -25,9 +25,9 @@
25
25
  */
26
26
  import { BasePeriodicService } from './service.js';
27
27
  import { USAGE_SYNC_TICK_MS } from './usage-sync-service.js';
28
- const AUTH_SYNC_TICK_MS = 15 * 60_000;
28
+ export const AUTH_SYNC_TICK_MS = 15 * 60_000;
29
29
  const AUTH_SYNC_DEADLINE_MS = 2 * 60_000;
30
- const AUTH_SYNC_KICKOFF_MS = 60_000;
30
+ export const AUTH_SYNC_KICKOFF_MS = 60_000;
31
31
  export class AuthSyncService extends BasePeriodicService {
32
32
  id = 'auth-sync';
33
33
  intervalMs = AUTH_SYNC_TICK_MS;
@@ -11,6 +11,17 @@ import { getAgentsBinPath } from '../cli-entry.js';
11
11
  import type { ServiceHealth } from './service.js';
12
12
  /** Health for every service currently registered on the live supervisor, or `null` if the daemon isn't running in this process. */
13
13
  export declare function getServiceSupervisorHealth(): Record<string, ServiceHealth> | null;
14
+ /**
15
+ * The service-manager identifiers of the REAL install's daemon — never
16
+ * namespaced. A caller under a redirected HOME (a test/e2e harness) uses these
17
+ * to recognize the box's production daemon as owned, where
18
+ * `daemonSystemdUnitName()`/`daemonServiceLabel()` would name only its own
19
+ * sandbox job (W4, PHNX-3736).
20
+ */
21
+ export declare function productionDaemonServiceNames(): {
22
+ systemdUnit: string;
23
+ launchdLabel: string;
24
+ };
14
25
  /**
15
26
  * RUSH-2639 (residual): launchd/systemd route `unload`/`load`/`list` by the
16
27
  * service identifier ALONE, never by the plist/unit file's path. Baking the
@@ -96,7 +107,7 @@ export declare function singleShot(fn: () => Promise<void>): () => Promise<void>
96
107
  */
97
108
  export declare function getDaemonLogPath(): string;
98
109
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
99
- export declare function readDaemonPid(): number | null;
110
+ export declare function readDaemonPid(daemonDir?: string): number | null;
100
111
  /** Write the daemon PID to the pid file. */
101
112
  export declare function writeDaemonPid(pid: number): void;
102
113
  /** Remove the daemon PID file. */
@@ -286,6 +297,28 @@ export declare function generateLaunchdPlist(agentsBin?: string): string;
286
297
  */
287
298
  export declare function generateSystemdUnit(agentsBin?: string): string;
288
299
  export { getAgentsBinPath };
300
+ /**
301
+ * Ask the service manager for the daemon's live PID. Used as a fallback when
302
+ * the daemon hasn't yet written its pid file but launchd/systemd already report
303
+ * it running — so a start never has to surface a null PID for a daemon that is
304
+ * in fact up. Returns null when the service isn't running or the query fails.
305
+ *
306
+ * `names` defaults to THIS process's (possibly sandbox-namespaced) job; a
307
+ * caller under a redirected HOME passes `productionDaemonServiceNames()` to
308
+ * ask after the real install's unit instead. Read-only either way.
309
+ */
310
+ export declare function readServiceManagerPid(platform?: NodeJS.Platform, names?: {
311
+ systemdUnit: string;
312
+ launchdLabel: string;
313
+ }): number | null;
314
+ /**
315
+ * Thrown when a daemon LAUNCH is attempted under a redirected HOME without the
316
+ * explicit test opt-in (W4, PHNX-3736). `bootstrap.ts` prints the message
317
+ * without a stack — this is user-actionable, not an engineering bug.
318
+ */
319
+ export declare class RedirectedHomeDaemonError extends Error {
320
+ name: string;
321
+ }
289
322
  /** Start the daemon via launchd, systemd, or as a detached process. */
290
323
  export declare function startDaemon(agentsBin?: string): {
291
324
  pid: number | null;