@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The ONE place agents-cli talks to its account backend (Phoenix ID).
3
+ *
4
+ * Why a seam at all: the removed Prix-coupled layer (RUSH-2581) had no single
5
+ * entry point — the backend URL was hardcoded in five files and the session
6
+ * token was re-read from `~/.rush/user.yaml` by seven separate functions, so
7
+ * re-pointing identity meant editing a dozen call sites and rewriting error
8
+ * strings scattered through the tree. This module is the correction: one base
9
+ * URL, one token reader, one HTTP funnel, one error type. Commands import from
10
+ * here and nothing else.
11
+ *
12
+ * The shape mirrors the seams this repo already proved elsewhere —
13
+ * `SyncBackend` (`lib/secrets/sync-backend.ts`) and `CloudProvider`
14
+ * (`lib/cloud/types.ts`) — so a second identity backend, if one is ever
15
+ * needed, is a swap here rather than a sweep across commands.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { getRuntimeStateDir } from '../state.js';
20
+ /**
21
+ * Where the account backend lives. Config, never a literal at a call site.
22
+ *
23
+ * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
24
+ * rather than a vanity hostname because no custom domain is attached yet — and
25
+ * a default naming an unregistered domain is worse than an ugly one: every
26
+ * `agents auth login` would fail DNS with nothing to point at.
27
+ */
28
+ export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? 'https://phoenix-id.muqsitnawaz.workers.dev';
29
+ /** Our own session file. agents-cli never reads another product's credentials. */
30
+ export function sessionFilePath() {
31
+ return path.join(getRuntimeStateDir(), 'phoenix-session.json');
32
+ }
33
+ export function readSession() {
34
+ try {
35
+ const raw = fs.readFileSync(sessionFilePath(), 'utf-8');
36
+ const parsed = JSON.parse(raw);
37
+ return parsed.access_token ? parsed : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function writeSession(session) {
44
+ const file = sessionFilePath();
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ fs.writeFileSync(file, JSON.stringify(session, null, 2), { mode: 0o600 });
47
+ }
48
+ export function clearSession() {
49
+ try {
50
+ fs.rmSync(sessionFilePath(), { force: true });
51
+ }
52
+ catch {
53
+ // Already gone: logging out twice is not an error.
54
+ }
55
+ }
56
+ /** An error carrying the server's status and message, so callers can branch on it. */
57
+ export class PhoenixApiError extends Error {
58
+ status;
59
+ constructor(message, status) {
60
+ super(message);
61
+ this.status = status;
62
+ this.name = 'PhoenixApiError';
63
+ }
64
+ }
65
+ /** The single HTTP funnel. Every request to the account backend goes through here. */
66
+ export async function phoenixRequest(method, route, opts = {}) {
67
+ const headers = { 'Content-Type': 'application/json' };
68
+ if (opts.auth !== false) {
69
+ const token = opts.token ?? readSession()?.access_token;
70
+ if (!token)
71
+ throw new PhoenixApiError("Not signed in. Run 'agents auth login'.", 401);
72
+ headers.Authorization = `Bearer ${token}`;
73
+ }
74
+ let response;
75
+ try {
76
+ response = await fetch(`${PHOENIX_ID_BASE}${route}`, {
77
+ method,
78
+ headers,
79
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
80
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
81
+ });
82
+ }
83
+ catch (err) {
84
+ const detail = err instanceof Error ? err.message : String(err);
85
+ throw new PhoenixApiError(`Could not reach the account service (${detail}).`, 0);
86
+ }
87
+ if (response.status === 204)
88
+ return undefined;
89
+ const text = await response.text();
90
+ let payload = null;
91
+ if (text) {
92
+ try {
93
+ payload = JSON.parse(text);
94
+ }
95
+ catch {
96
+ payload = null;
97
+ }
98
+ }
99
+ if (!response.ok) {
100
+ const message = payload && typeof payload === 'object' && 'error' in payload
101
+ ? String(payload.error)
102
+ : `${response.status} ${response.statusText}`;
103
+ throw new PhoenixApiError(message, response.status);
104
+ }
105
+ return payload;
106
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Phoenix ID — the typed surface commands use. Every route the account backend
3
+ * exposes is a function here; no command builds a URL or reads a token itself.
4
+ */
5
+ import { type PhoenixSession } from './client.js';
6
+ export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, type PhoenixSession, } from './client.js';
7
+ export interface DeviceAuthorization {
8
+ device_code: string;
9
+ user_code: string;
10
+ verification_uri: string;
11
+ verification_uri_complete: string;
12
+ expires_in: number;
13
+ interval: number;
14
+ }
15
+ export interface WhoAmI {
16
+ userId: string;
17
+ email: string;
18
+ valid: true;
19
+ }
20
+ /**
21
+ * RFC 8628 poll outcomes. `pending` and `slow_down` are normal states of a
22
+ * login in progress, not failures — the server signals them through the error
23
+ * body, and this is where that wire detail stops.
24
+ */
25
+ export type DevicePoll = {
26
+ status: 'authorized';
27
+ access_token: string;
28
+ user: {
29
+ email: string;
30
+ id: string;
31
+ };
32
+ } | {
33
+ status: 'pending';
34
+ } | {
35
+ status: 'slow_down';
36
+ } | {
37
+ status: 'expired';
38
+ } | {
39
+ status: 'denied';
40
+ };
41
+ export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>;
42
+ export declare function pollDeviceToken(deviceCode: string): Promise<DevicePoll>;
43
+ export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
44
+ export interface SpaceSummary {
45
+ id: string;
46
+ slug: string;
47
+ name: string;
48
+ organization_id: string | null;
49
+ owner_user_id: string;
50
+ invite_code?: string;
51
+ user_role: 'owner' | 'admin' | 'member';
52
+ created_at: string;
53
+ }
54
+ export interface SpaceMember {
55
+ user_id: string;
56
+ email: string;
57
+ name?: string;
58
+ avatar_url?: string;
59
+ role: 'owner' | 'admin' | 'member';
60
+ joined_at: string;
61
+ }
62
+ export interface SpaceInvite {
63
+ id: string;
64
+ space_id: string;
65
+ email: string;
66
+ role: 'admin' | 'member';
67
+ invite_code: string;
68
+ created_at: string;
69
+ }
70
+ export type CreateInviteResult = {
71
+ invited: true;
72
+ email: string;
73
+ role: string;
74
+ member_added: true;
75
+ } | {
76
+ invited: true;
77
+ email: string;
78
+ role: string;
79
+ invite_code: string;
80
+ member_added: false;
81
+ };
82
+ export declare const listSpaces: () => Promise<SpaceSummary[]>;
83
+ export declare const createSpace: (input: {
84
+ name: string;
85
+ slug: string;
86
+ }) => Promise<SpaceSummary>;
87
+ export declare const getSpace: (id: string) => Promise<SpaceSummary>;
88
+ export declare const listSpaceMembers: (id: string) => Promise<SpaceMember[]>;
89
+ export declare const createSpaceInvite: (id: string, input: {
90
+ email: string;
91
+ role: "admin" | "member";
92
+ }) => Promise<CreateInviteResult>;
93
+ export declare const listSpaceInvites: (id: string) => Promise<SpaceInvite[]>;
94
+ export declare const revokeSpaceInvite: (id: string, inviteId: string) => Promise<{
95
+ revoked: true;
96
+ }>;
97
+ export declare const updateSpaceMemberRole: (id: string, userId: string, role: "admin" | "member") => Promise<{
98
+ user_id: string;
99
+ role: string;
100
+ updated: true;
101
+ }>;
102
+ export declare const removeSpaceMember: (id: string, userId: string) => Promise<void>;
103
+ export declare const deleteSpace: (id: string) => Promise<void>;
104
+ export interface Subscription {
105
+ tierName?: string;
106
+ [key: string]: unknown;
107
+ }
108
+ export declare const fetchSubscription: (agent?: string) => Promise<Subscription>;
109
+ /** `Design Team` → `design-team`; the slug a space gets when the user gives only a name. */
110
+ export declare function slugify(name: string): string;
111
+ /** Resolve a space by id, slug, or name from a list the caller already fetched. */
112
+ export declare function resolveSpaceFromList(spaces: SpaceSummary[], ref?: string): SpaceSummary | null;
113
+ /** Resolve a member by email or user id from a list the caller already fetched. */
114
+ export declare function resolveMemberFromList(members: SpaceMember[], ref: string): SpaceMember | null;
115
+ export type { PhoenixSession as Session };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Phoenix ID — the typed surface commands use. Every route the account backend
3
+ * exposes is a function here; no command builds a URL or reads a token itself.
4
+ */
5
+ import { phoenixRequest, PhoenixApiError } from './client.js';
6
+ export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, } from './client.js';
7
+ export function startDeviceAuthorization() {
8
+ return phoenixRequest('POST', '/api/v1/auth/device/authorization', {
9
+ auth: false,
10
+ body: {},
11
+ });
12
+ }
13
+ export async function pollDeviceToken(deviceCode) {
14
+ try {
15
+ return await phoenixRequest('POST', '/api/v1/auth/device/token', {
16
+ auth: false,
17
+ body: {
18
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
19
+ device_code: deviceCode,
20
+ },
21
+ });
22
+ }
23
+ catch (err) {
24
+ if (!(err instanceof PhoenixApiError))
25
+ throw err;
26
+ // The server encodes poll state in the error body (RFC 8628 §3.5).
27
+ if (err.message.includes('authorization_pending'))
28
+ return { status: 'pending' };
29
+ if (err.message.includes('slow_down'))
30
+ return { status: 'slow_down' };
31
+ if (err.message.includes('expired_token'))
32
+ return { status: 'expired' };
33
+ if (err.message.includes('access_denied'))
34
+ return { status: 'denied' };
35
+ throw err;
36
+ }
37
+ }
38
+ export function fetchWhoAmI(token) {
39
+ return phoenixRequest('GET', '/api/v1/auth/me', { token });
40
+ }
41
+ export const listSpaces = () => phoenixRequest('GET', '/api/v1/spaces');
42
+ export const createSpace = (input) => phoenixRequest('POST', '/api/v1/spaces', { body: input });
43
+ export const getSpace = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}`);
44
+ export const listSpaceMembers = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}/members`);
45
+ export const createSpaceInvite = (id, input) => phoenixRequest('POST', `/api/v1/spaces/${encodeURIComponent(id)}/invites`, {
46
+ body: input,
47
+ });
48
+ export const listSpaceInvites = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}/invites`);
49
+ export const revokeSpaceInvite = (id, inviteId) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}/invites/${encodeURIComponent(inviteId)}`);
50
+ export const updateSpaceMemberRole = (id, userId, role) => phoenixRequest('PATCH', `/api/v1/spaces/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`, {
51
+ body: { role },
52
+ });
53
+ export const removeSpaceMember = (id, userId) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`);
54
+ export const deleteSpace = (id) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}`);
55
+ export const fetchSubscription = (agent = 'agents-cli') => phoenixRequest('GET', `/api/v1/billing/subscription?agent=${encodeURIComponent(agent)}`);
56
+ // ─── Helpers shared by the commands ──────────────────────────────────────────
57
+ /** `Design Team` → `design-team`; the slug a space gets when the user gives only a name. */
58
+ export function slugify(name) {
59
+ return name
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9]+/g, '-')
63
+ .replace(/^-+|-+$/g, '')
64
+ .slice(0, 63);
65
+ }
66
+ /** Resolve a space by id, slug, or name from a list the caller already fetched. */
67
+ export function resolveSpaceFromList(spaces, ref) {
68
+ if (!ref)
69
+ return spaces.length === 1 ? spaces[0] : null;
70
+ const needle = ref.trim().toLowerCase();
71
+ return (spaces.find((s) => s.id === ref) ??
72
+ spaces.find((s) => s.slug.toLowerCase() === needle) ??
73
+ spaces.find((s) => s.name.toLowerCase() === needle) ??
74
+ null);
75
+ }
76
+ /** Resolve a member by email or user id from a list the caller already fetched. */
77
+ export function resolveMemberFromList(members, ref) {
78
+ const needle = ref.trim().toLowerCase();
79
+ return (members.find((m) => m.user_id === ref) ??
80
+ members.find((m) => m.email.toLowerCase() === needle) ??
81
+ null);
82
+ }
@@ -370,6 +370,19 @@ export interface PidStaleness {
370
370
  * PREVIOUS binary — the exact condition `restartMenubarHelperAfterSwap` exists
371
371
  * to fix. Millisecond epoch timestamps in, so the truth table is unit-testable
372
372
  * without a live process or filesystem.
373
+ *
374
+ * The two timestamps do not have the same resolution. `pidStartTimeMs` parses
375
+ * `ps -o lstart`, which prints whole SECONDS, while the bundle mtime carries
376
+ * sub-second precision — and the restart this check exists to detect happens
377
+ * within a second of the swap that triggered it. A raw `<` therefore called a
378
+ * healthy just-restarted helper stale on essentially every upgrade: measured on
379
+ * zion at 1.22.46, pid start 1787441353000 vs bundle mtime 1787441353700, 700ms
380
+ * apart inside one second, reported as "running the OLD binary" and surfaced as
381
+ * `accessibilityHintNeeded` — telling the user to re-grant Accessibility after
382
+ * an upgrade that had already restarted the helper correctly. So the bundle
383
+ * mtime is truncated to the same whole second `ps` reports before comparing: a
384
+ * pid that started in the swap's own second is fresh, and a genuinely stale pid
385
+ * (a full second or more older) is still caught.
373
386
  */
374
387
  export declare function isMenubarProcessStaleAgainstBundle(pidStartedAtMs: number, bundleMtimeMs: number): boolean;
375
388
  /**
@@ -602,8 +602,19 @@ export function mayInstallMenubarHelper(opts) {
602
602
  // No owner recorded yet (fresh or pre-`AGENTS_ENTRY` plist) — adopt it.
603
603
  if (!opts.plistEntry)
604
604
  return true;
605
+ // The recorded owner's entry path is gone from disk, so this install may adopt
606
+ // the helper — but a non-Developer-ID (ad-hoc/dev) source may NOT seize a
607
+ // healthy install this way. Recopying an ad-hoc bundle over the Developer-ID
608
+ // one poisons the shared Accessibility grant (an ad-hoc signature fails the
609
+ // grant's stored code requirement, so macOS revokes it and re-prompts on the
610
+ // next paste) and Gatekeeper then rejects the result as "damaged" (RUSH-2134).
611
+ // This does not strand a genuinely broken helper: escape (1) above
612
+ // (helperExecMissing / needsDevIdHeal) already lets ANY source repair a
613
+ // missing-or-ad-hoc install, so refusing here only declines to re-point the
614
+ // plist of a helper that is already present and working — the menu bar keeps
615
+ // running, nothing deadlocks.
605
616
  if (!opts.ownerEntryExists)
606
- return true; // the recorded owner is gone
617
+ return opts.sourceIsDeveloperId;
607
618
  if (opts.installedVersion && opts.currentVersion) {
608
619
  const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
609
620
  if (versionOrder > 0)
@@ -990,9 +1001,22 @@ export function getMenubarStatus() {
990
1001
  * PREVIOUS binary — the exact condition `restartMenubarHelperAfterSwap` exists
991
1002
  * to fix. Millisecond epoch timestamps in, so the truth table is unit-testable
992
1003
  * without a live process or filesystem.
1004
+ *
1005
+ * The two timestamps do not have the same resolution. `pidStartTimeMs` parses
1006
+ * `ps -o lstart`, which prints whole SECONDS, while the bundle mtime carries
1007
+ * sub-second precision — and the restart this check exists to detect happens
1008
+ * within a second of the swap that triggered it. A raw `<` therefore called a
1009
+ * healthy just-restarted helper stale on essentially every upgrade: measured on
1010
+ * zion at 1.22.46, pid start 1787441353000 vs bundle mtime 1787441353700, 700ms
1011
+ * apart inside one second, reported as "running the OLD binary" and surfaced as
1012
+ * `accessibilityHintNeeded` — telling the user to re-grant Accessibility after
1013
+ * an upgrade that had already restarted the helper correctly. So the bundle
1014
+ * mtime is truncated to the same whole second `ps` reports before comparing: a
1015
+ * pid that started in the swap's own second is fresh, and a genuinely stale pid
1016
+ * (a full second or more older) is still caught.
993
1017
  */
994
1018
  export function isMenubarProcessStaleAgainstBundle(pidStartedAtMs, bundleMtimeMs) {
995
- return pidStartedAtMs < bundleMtimeMs;
1019
+ return pidStartedAtMs < Math.floor(bundleMtimeMs / 1000) * 1000;
996
1020
  }
997
1021
  /** Wall-clock start time of a live pid via `ps`, or null if it can't be read. */
998
1022
  function pidStartTimeMs(pid) {
@@ -1,2 +1,60 @@
1
- /** Best-effort: open a URL in the user's default browser. Never throws. */
2
- export declare function openUrl(url: string): void;
1
+ /** Where a "show the human this" call actually landed. */
2
+ export type ShowOutcome = {
3
+ via: 'profile';
4
+ profile: string;
5
+ tabId?: string;
6
+ } | {
7
+ via: 'os';
8
+ command: string;
9
+ } | {
10
+ via: 'none';
11
+ reason: string;
12
+ };
13
+ export interface ShowOptions {
14
+ /**
15
+ * Force the OS default handler, ignoring `browser.viewer`. This is the
16
+ * The programmatic escape hatch, for a caller that must use the user's own
17
+ * browser regardless of configuration. There is deliberately no CLI flag for
18
+ * it: `agents config set browser.viewer os` is the user-facing control.
19
+ */
20
+ osBrowser?: boolean;
21
+ /** Explicit profile override, ahead of `browser.viewer`. */
22
+ profile?: string;
23
+ /** Injected opener so the OS branch is testable without spawning anything. */
24
+ spawnOpen?: (cmd: string, args: string[]) => boolean;
25
+ }
26
+ /**
27
+ * Launch a detached opener and report whether it actually started.
28
+ *
29
+ * Detection without blocking, which is the whole trick here. A bare detached
30
+ * `spawn` cannot tell success from "xdg-open is not installed" — it does not
31
+ * throw for a missing binary, it emits `error` asynchronously — so the failure
32
+ * branches of every caller were dead. But `spawnSync` is not the answer either:
33
+ * it waits for the child's whole lifetime, and `devices lease` opens a console
34
+ * and then immediately prompts for a pasted key, so a blocking open would stall
35
+ * that prompt behind the browser.
36
+ *
37
+ * Racing `spawn` against `error` gives both: Node emits `spawn` as soon as the
38
+ * child is successfully created (measured: 1ms, and it does NOT wait for exit),
39
+ * and `error` for ENOENT. We unref on success so the opener outlives us.
40
+ */
41
+ export declare function trySpawn(cmd: string, args: string[]): Promise<boolean>;
42
+ /**
43
+ * Decide the viewer for this call. Exported for its own test — this is the one
44
+ * place the policy lives.
45
+ *
46
+ * Every fall back to the OS handler prints one stderr line naming why. A silent
47
+ * downgrade here is what made the original bug invisible for so long: the user
48
+ * had configured a profile and had no way to see that it was being ignored.
49
+ */
50
+ export declare function resolveViewer(opts?: ShowOptions): Promise<'os' | {
51
+ profile: string;
52
+ }>;
53
+ /** Show a URL to the human at this machine. Never throws. */
54
+ export declare function showUrl(url: string, opts?: ShowOptions): Promise<ShowOutcome>;
55
+ /**
56
+ * Show a local file. Browser-renderable kinds go through {@link showUrl}; every
57
+ * other kind goes to the OS default APP, which for a screenshot or a recording
58
+ * is the right viewer.
59
+ */
60
+ export declare function showFile(filePath: string, opts?: ShowOptions): Promise<ShowOutcome>;
@@ -1,19 +1,174 @@
1
1
  /**
2
- * Open a URL in the user's default browser, cross-platform. Best-effort and
3
- * detached — never throws and never blocks the caller. Shared by `agents devices lease`
4
- * (Hetzner console) and `agents fleet login` (the local login dashboard).
2
+ * The one place that decides WHERE a URL or file is shown to the human.
3
+ *
4
+ * Two browsers exist on a machine like this: the OS default handler, and the
5
+ * profile `agents browser` drives. They are not interchangeable. The configured
6
+ * profile is where the fleet's logins accumulate — `agents browser profiles
7
+ * logins` lists them — so a page opened there is a page the user is already
8
+ * signed in for, and a login acquired there is inherited by every later agent.
9
+ * The OS handler has none of that.
10
+ *
11
+ * Before this seam existed, `agents browser navigate` honoured the configured
12
+ * profile and nothing else did: `fleet login`, `devices lease`, `feedback`, and
13
+ * the browser-session artifact opener each shelled straight to `open`/`xdg-open`,
14
+ * so every one of them landed in whatever the OS handler happened to be. This
15
+ * module replaces all of those call sites; do not add a sixth raw `open`.
16
+ *
17
+ * Never throws. A viewer that cannot be reached degrades to the OS handler with
18
+ * one stderr line naming the reason, and a total failure returns `via: 'none'`
19
+ * so the caller can print the URL rather than silently doing nothing.
5
20
  */
6
21
  import { spawn } from 'child_process';
7
- /** Best-effort: open a URL in the user's default browser. Never throws. */
8
- export function openUrl(url) {
9
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
10
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
22
+ import * as path from 'path';
23
+ import { pathToFileURL } from 'url';
24
+ /**
25
+ * Extensions a CDP tab renders at least as well as the OS default app.
26
+ *
27
+ * Deliberately narrow. `sessions-list.ts` EXT_KIND covers .png/.jpg/.webp/.pdf/
28
+ * .webm, and for those Preview and QuickTime are the better viewer — routing a
29
+ * screenshot into a browser tab is a downgrade, not a fix.
30
+ */
31
+ const BROWSER_RENDERABLE = new Set(['.html', '.htm', '.svg', '.xhtml']);
32
+ async function osOpen(target, spawnOpen) {
33
+ const candidates = process.platform === 'darwin'
34
+ ? [['open', [target]]]
35
+ : process.platform === 'win32'
36
+ ? // `start` treats a lone quoted first argument as the window TITLE, so the
37
+ // empty title placeholder is required before the target. The three copies
38
+ // of this that predated the seam disagreed on it ('' vs '""').
39
+ [['cmd', ['/c', 'start', '', target]]]
40
+ : [
41
+ ['xdg-open', [target]],
42
+ ['gnome-open', [target]],
43
+ ];
44
+ for (const [cmd, args] of candidates) {
45
+ if (spawnOpen) {
46
+ if (spawnOpen(cmd, args))
47
+ return { via: 'os', command: cmd };
48
+ continue;
49
+ }
50
+ if (await trySpawn(cmd, args))
51
+ return { via: 'os', command: cmd };
52
+ }
53
+ return { via: 'none', reason: 'no working OS opener on this platform' };
54
+ }
55
+ /**
56
+ * Launch a detached opener and report whether it actually started.
57
+ *
58
+ * Detection without blocking, which is the whole trick here. A bare detached
59
+ * `spawn` cannot tell success from "xdg-open is not installed" — it does not
60
+ * throw for a missing binary, it emits `error` asynchronously — so the failure
61
+ * branches of every caller were dead. But `spawnSync` is not the answer either:
62
+ * it waits for the child's whole lifetime, and `devices lease` opens a console
63
+ * and then immediately prompts for a pasted key, so a blocking open would stall
64
+ * that prompt behind the browser.
65
+ *
66
+ * Racing `spawn` against `error` gives both: Node emits `spawn` as soon as the
67
+ * child is successfully created (measured: 1ms, and it does NOT wait for exit),
68
+ * and `error` for ENOENT. We unref on success so the opener outlives us.
69
+ */
70
+ export function trySpawn(cmd, args) {
71
+ return new Promise((resolve) => {
72
+ let settled = false;
73
+ const done = (ok) => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ resolve(ok);
78
+ };
79
+ try {
80
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
81
+ child.on('error', () => done(false));
82
+ child.on('spawn', () => {
83
+ child.unref();
84
+ done(true);
85
+ });
86
+ }
87
+ catch {
88
+ done(false);
89
+ }
90
+ });
91
+ }
92
+ /**
93
+ * Decide the viewer for this call. Exported for its own test — this is the one
94
+ * place the policy lives.
95
+ *
96
+ * Every fall back to the OS handler prints one stderr line naming why. A silent
97
+ * downgrade here is what made the original bug invisible for so long: the user
98
+ * had configured a profile and had no way to see that it was being ignored.
99
+ */
100
+ export async function resolveViewer(opts = {}) {
101
+ if (opts.osBrowser)
102
+ return 'os';
103
+ const { getConfigValue } = await import('./device-config.js');
104
+ const configured = opts.profile ?? (getConfigValue('browser.viewer').value || undefined);
105
+ // Unset means "follow the profile agents drive" — the whole point is that a
106
+ // machine with a configured browser stops leaking pages to the OS handler.
107
+ const { getConfiguredDefaultProfileName, resolveProfileRef, getProfile, isProfileLaunchableHere } = await import('./browser/profiles.js');
108
+ const name = configured ?? getConfiguredDefaultProfileName();
109
+ if (!name)
110
+ return 'os';
111
+ if (name === 'os')
112
+ return 'os';
113
+ let resolved;
11
114
  try {
12
- const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
13
- p.on('error', () => { });
14
- p.unref();
115
+ resolved = await resolveProfileRef(name);
116
+ }
117
+ catch (err) {
118
+ console.error(`[viewer] ${name}: ${err instanceof Error ? err.message : String(err)} — using the OS browser.`);
119
+ return 'os';
120
+ }
121
+ if (!resolved) {
122
+ console.error(`[viewer] "${name}" does not resolve to a profile — using the OS browser.`);
123
+ return 'os';
15
124
  }
16
- catch {
17
- /* best-effort */
125
+ const profile = await getProfile(resolved);
126
+ if (!profile) {
127
+ console.error(`[viewer] profile "${resolved}" is not configured — using the OS browser.`);
128
+ return 'os';
129
+ }
130
+ if (profile.browser === 'arc') {
131
+ // Arc exposes no CDP page targets and crashes on tab creation, so it can be
132
+ // a configured profile but never a drivable viewer.
133
+ console.error(`[viewer] "${resolved}" is Arc, which cannot be driven — using the OS browser.`);
134
+ return 'os';
135
+ }
136
+ if (!isProfileLaunchableHere(profile)) {
137
+ console.error(`[viewer] "${resolved}" cannot launch on this machine — using the OS browser.`);
138
+ return 'os';
139
+ }
140
+ return { profile: resolved };
141
+ }
142
+ /** Show a URL to the human at this machine. Never throws. */
143
+ export async function showUrl(url, opts = {}) {
144
+ const viewer = await resolveViewer(opts);
145
+ if (viewer === 'os')
146
+ return osOpen(url, opts.spawnOpen);
147
+ try {
148
+ const { sendIPCRequest } = await import('./browser/ipc.js');
149
+ // Deliberately does NOT auto-start the browser daemon. Showing a page is a
150
+ // side errand — `devices lease` opens a console and immediately prompts for
151
+ // a pasted key — so blocking that on a daemon cold start is a surprising
152
+ // multi-second stall. Daemon already up: use the viewer. Not up: the OS
153
+ // handler is the fast, correct answer.
154
+ const response = await sendIPCRequest({ action: 'show', url, profile: viewer.profile }, { autoStartDaemon: false });
155
+ if (response.ok)
156
+ return { via: 'profile', profile: viewer.profile, tabId: response.tabId };
157
+ console.error(`[viewer] ${viewer.profile}: ${response.error} — using the OS browser.`);
158
+ }
159
+ catch (err) {
160
+ console.error(`[viewer] ${viewer.profile}: ${err instanceof Error ? err.message : String(err)} — using the OS browser.`);
161
+ }
162
+ return osOpen(url, opts.spawnOpen);
163
+ }
164
+ /**
165
+ * Show a local file. Browser-renderable kinds go through {@link showUrl}; every
166
+ * other kind goes to the OS default APP, which for a screenshot or a recording
167
+ * is the right viewer.
168
+ */
169
+ export async function showFile(filePath, opts = {}) {
170
+ if (!BROWSER_RENDERABLE.has(path.extname(filePath).toLowerCase())) {
171
+ return osOpen(filePath, opts.spawnOpen);
18
172
  }
173
+ return showUrl(pathToFileURL(filePath).href, opts);
19
174
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Async probe capturing stdout. Rejects on spawn error, non-zero exit, or
3
+ * timeout — matching the `execFileAsync` contract the version probe had — and
4
+ * reaps the probe's whole process group on every settle path.
5
+ */
6
+ export declare function probeCapture(cmd: string, args: string[], timeoutMs: number): Promise<{
7
+ stdout: string;
8
+ }>;