@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,59 @@
1
+ import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus } from '../session/watch.js';
2
+ import { type AttentionItem } from './attention.js';
3
+ import { type ActivityEvent } from './activity.js';
4
+ export declare const FEED_WATCH_VERSION: 1;
5
+ type Base = {
6
+ v: 1;
7
+ type: string;
8
+ streamId: string;
9
+ sequence: number;
10
+ scope: string;
11
+ };
12
+ export type FeedWatchEnvelope = Base & {
13
+ type: 'reset';
14
+ capturedAt: number;
15
+ agents: SessionWatchRow[];
16
+ attention: AttentionItem[];
17
+ } | Base & {
18
+ type: 'agent.upsert';
19
+ rowKey: string;
20
+ agent: SessionWatchRow;
21
+ } | Base & {
22
+ type: 'attention.upsert';
23
+ rowKey: string;
24
+ attention: AttentionItem;
25
+ } | Base & {
26
+ type: 'attention.remove';
27
+ rowKey: string;
28
+ } | Base & {
29
+ type: 'activity.append';
30
+ event: ActivityEvent;
31
+ } | Base & {
32
+ type: 'scope';
33
+ capturedAt: number;
34
+ status: SessionWatchScopeStatus;
35
+ reason?: string;
36
+ } | Base & {
37
+ type: 'heartbeat';
38
+ capturedAt: number;
39
+ };
40
+ type FeedWatchPayload = FeedWatchEnvelope extends infer Envelope ? Envelope extends FeedWatchEnvelope ? Omit<Envelope, 'v' | 'streamId' | 'sequence'> : never : never;
41
+ export declare class FeedWatchState {
42
+ readonly streamId: string;
43
+ private sequence;
44
+ constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
45
+ emit(event: FeedWatchPayload): FeedWatchEnvelope;
46
+ }
47
+ export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
48
+ export declare function watchLocalFeed(options: {
49
+ scope: string;
50
+ signal: AbortSignal;
51
+ emit: (event: FeedWatchEnvelope) => void;
52
+ activityPollMs?: number;
53
+ }): Promise<void>;
54
+ export declare function watchFleetFeed(options: {
55
+ signal: AbortSignal;
56
+ emit: (event: FeedWatchEnvelope) => void;
57
+ reconnectMs?: number;
58
+ }): Promise<void>;
59
+ export {};
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import { loadDevices, isDialableDevice } from '../devices/registry.js';
5
+ import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
6
+ import { machineId, normalizeHost } from '../machine-id.js';
7
+ import { SSH_OPTS, controlOpts, shellQuote } from '../ssh-exec.js';
8
+ import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
9
+ import { watchLocalSessions } from '../session/watch.js';
10
+ import { readBlock, readResolution, blockIdForSession } from './feed.js';
11
+ import { reconcileAttention } from './attention.js';
12
+ import { readRecentActivity } from './activity.js';
13
+ import { readPullRequestStatus } from './pr-status.js';
14
+ export const FEED_WATCH_VERSION = 1;
15
+ export class FeedWatchState {
16
+ streamId;
17
+ sequence = 0;
18
+ constructor(streamId = randomUUID()) { this.streamId = streamId; }
19
+ emit(event) {
20
+ return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
21
+ }
22
+ }
23
+ async function attentionFor(agent) {
24
+ if (!agent.sessionId)
25
+ return undefined;
26
+ const blockId = blockIdForSession(agent.sessionId);
27
+ // ActiveSession.host names the terminal app; the feed contract's host is the
28
+ // device scope. Normalize only the reconciler input so lifecycle/PR keys are
29
+ // routable across the fleet while the projected agent row stays compatible.
30
+ const session = { ...agent, host: agent.sourceDevice, viewingIn: undefined };
31
+ return reconcileAttention({
32
+ block: readBlock(blockId), session,
33
+ resolution: readResolution(blockId),
34
+ pullRequest: await readPullRequestStatus(session), nowMs: Date.now(),
35
+ });
36
+ }
37
+ export async function projectSessionEnvelope(event, state) {
38
+ if (event.type === 'reset') {
39
+ const attention = (await Promise.all(event.rows.map(attentionFor))).filter((item) => item !== undefined);
40
+ return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: event.rows, attention })];
41
+ }
42
+ if (event.type === 'upsert') {
43
+ const attention = await attentionFor(event.row);
44
+ return [
45
+ state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent: event.row }),
46
+ attention
47
+ ? state.emit({ type: 'attention.upsert', scope: event.scope, rowKey: event.rowKey, attention })
48
+ : state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
49
+ ];
50
+ }
51
+ if (event.type === 'remove')
52
+ return [state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey })];
53
+ if (event.type === 'scope')
54
+ return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
55
+ return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
56
+ }
57
+ export async function watchLocalFeed(options) {
58
+ const state = new FeedWatchState();
59
+ let activityCursor = Date.now();
60
+ const agents = new Map();
61
+ const attention = new Map();
62
+ let pending = Promise.resolve();
63
+ const reconcileRows = async () => {
64
+ for (const [rowKey, agent] of agents) {
65
+ const item = await attentionFor(agent);
66
+ const next = item ? JSON.stringify(item) : '';
67
+ if (attention.get(rowKey) === next)
68
+ continue;
69
+ attention.set(rowKey, next);
70
+ options.emit(item
71
+ ? state.emit({ type: 'attention.upsert', scope: options.scope, rowKey, attention: item })
72
+ : state.emit({ type: 'attention.remove', scope: options.scope, rowKey }));
73
+ }
74
+ };
75
+ const activityTimer = setInterval(() => {
76
+ pending = pending.then(async () => {
77
+ const events = readRecentActivity({ sinceMs: activityCursor + 1 }).reverse();
78
+ for (const event of events) {
79
+ activityCursor = Math.max(activityCursor, Date.parse(event.ts));
80
+ options.emit(state.emit({ type: 'activity.append', scope: options.scope, event }));
81
+ }
82
+ await reconcileRows();
83
+ });
84
+ }, options.activityPollMs ?? 500);
85
+ const stopActivity = () => clearInterval(activityTimer);
86
+ options.signal.addEventListener('abort', stopActivity, { once: true });
87
+ try {
88
+ await watchLocalSessions({ scope: options.scope, signal: options.signal, emit: (event) => {
89
+ if (event.type === 'reset') {
90
+ agents.clear();
91
+ for (const row of event.rows)
92
+ agents.set(row.rowKey, row);
93
+ }
94
+ else if (event.type === 'upsert')
95
+ agents.set(event.rowKey, event.row);
96
+ else if (event.type === 'remove') {
97
+ agents.delete(event.rowKey);
98
+ attention.delete(event.rowKey);
99
+ }
100
+ pending = pending.then(() => projectSessionEnvelope(event, state)).then((events) => {
101
+ for (const projected of events) {
102
+ if (projected.type === 'reset') {
103
+ attention.clear();
104
+ for (const row of projected.agents)
105
+ attention.set(row.rowKey, '');
106
+ for (const item of projected.attention) {
107
+ const row = projected.agents.find((agent) => agent.sessionId === item.sessionId);
108
+ if (row)
109
+ attention.set(row.rowKey, JSON.stringify(item));
110
+ }
111
+ }
112
+ else if (projected.type === 'attention.upsert')
113
+ attention.set(projected.rowKey, JSON.stringify(projected.attention));
114
+ else if (projected.type === 'attention.remove')
115
+ attention.set(projected.rowKey, '');
116
+ options.emit(projected);
117
+ }
118
+ });
119
+ } });
120
+ await pending;
121
+ }
122
+ finally {
123
+ stopActivity();
124
+ }
125
+ }
126
+ function remoteFeedWatchCommand(os) {
127
+ const args = ['feed', 'watch', '--json', '--local'];
128
+ return remoteShellFor(os) === 'powershell'
129
+ ? buildWindowsAgentsCommand({ args })
130
+ : `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
131
+ }
132
+ export async function watchFleetFeed(options) {
133
+ const coordinator = new FeedWatchState();
134
+ const forward = (event) => {
135
+ const { v: _v, streamId: peerStreamId, sequence: peerSequence, ...payload } = event;
136
+ options.emit(coordinator.emit({ ...payload, peerStreamId, peerSequence }));
137
+ };
138
+ const local = watchLocalFeed({ scope: machineId(), signal: options.signal, emit: forward });
139
+ let devices;
140
+ try {
141
+ devices = await loadDevices();
142
+ }
143
+ catch {
144
+ await local;
145
+ return;
146
+ }
147
+ const self = machineId();
148
+ const peers = Object.values(devices).filter((device) => isDialableDevice(device) && normalizeHost(device.name) !== self && ['windows', 'linux', 'macos'].includes(device.platform));
149
+ const tasks = peers.map(async (device) => {
150
+ const scope = normalizeHost(device.name);
151
+ while (!options.signal.aborted) {
152
+ let target;
153
+ try {
154
+ target = sshTargetFor(device);
155
+ }
156
+ catch (error) {
157
+ options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: String(error) }));
158
+ break;
159
+ }
160
+ const child = spawn('ssh', [...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteFeedWatchCommand(device.platform)], { stdio: ['ignore', 'pipe', 'ignore'] });
161
+ const stop = () => child.kill('SIGTERM');
162
+ options.signal.addEventListener('abort', stop, { once: true });
163
+ const reader = createInterface({ input: child.stdout });
164
+ reader.on('line', (line) => { try {
165
+ const event = JSON.parse(line);
166
+ if (event.v === 1)
167
+ forward(event);
168
+ }
169
+ catch { /* protocol only */ } });
170
+ const code = await new Promise((resolve) => { child.once('error', () => resolve(null)); child.once('close', resolve); });
171
+ reader.close();
172
+ options.signal.removeEventListener('abort', stop);
173
+ if (options.signal.aborted)
174
+ break;
175
+ options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: code == null ? 'ssh failed' : `ssh exited ${code}` }));
176
+ await new Promise((resolve) => { const timer = setTimeout(resolve, options.reconnectMs ?? 2_000); options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
177
+ }
178
+ });
179
+ await Promise.all([local, ...tasks]);
180
+ }
@@ -58,6 +58,12 @@ export function captureFleet(prev, inputs) {
58
58
  if (prev?.discovery && Object.keys(prev.discovery).length > 0) {
59
59
  manifest.discovery = { ...prev.discovery };
60
60
  }
61
+ // Dismissals are operator state, not live state — a capture must never wipe
62
+ // them (fleet.ignored syncs; losing it re-suggests every dismissed node
63
+ // fleet-wide). Carry forward verbatim, same contract as `discovery`.
64
+ if (prev?.ignored && prev.ignored.length > 0) {
65
+ manifest.ignored = prev.ignored.map((e) => ({ ...e }));
66
+ }
61
67
  const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
62
68
  if (bundles && bundles.length > 0)
63
69
  manifest.secrets = { bundles: [...bundles].sort() };
@@ -33,7 +33,7 @@ import { planFleetTargets } from '../devices/fleet.js';
33
33
  import { assertValidSshTarget, shellQuote, sshExecAsync } from '../ssh-exec.js';
34
34
  import { machineId } from '../session/sync/config.js';
35
35
  import { ptyRequest } from '../pty-client.js';
36
- import { openUrl } from '../open-url.js';
36
+ import { showUrl } from '../open-url.js';
37
37
  import { readAuthHealthCache, } from '../auth-health.js';
38
38
  import { FLEET_LOGIN_FLOWS, KEYCHAIN_BOUND_ON_MAC, } from './auth-sync.js';
39
39
  /**
@@ -500,8 +500,13 @@ export async function runFleetLogin(opts = {}) {
500
500
  if (!opts.json) {
501
501
  console.log(`Dashboard: ${dashUrl}`);
502
502
  }
503
- if (opts.open !== false)
504
- openUrl(dashUrl);
503
+ if (opts.open !== false) {
504
+ const shown = await showUrl(dashUrl);
505
+ if (shown.via === 'none') {
506
+ console.error('Could not open a browser — open this yourself:');
507
+ console.error(` ${dashUrl}`);
508
+ }
509
+ }
505
510
  const driver = opts.driver ?? defaultPtyDriver();
506
511
  const remotable = pending.filter((p) => p.remotable);
507
512
  const driveOne = async (p) => {
@@ -80,6 +80,23 @@ export interface FleetManifest {
80
80
  * machine's local device registry and is never committed.
81
81
  */
82
82
  discovery?: Record<string, 'approved' | 'ignored'>;
83
+ /**
84
+ * Tailnet node names the user dismissed from auto-discovery, with who
85
+ * dismissed each and when. Lives here rather than in a per-device doc because
86
+ * a dismissed node is deliberately NOT a device — it never enters the
87
+ * registry, so it has no per-device folder. Syncs fleet-wide with the rest of
88
+ * `agents.yaml`, so a dismissal on one box stops the suggestion on every box.
89
+ */
90
+ ignored?: IgnoredDeviceEntry[];
91
+ }
92
+ /** One dismissal record in {@link FleetManifest.ignored}. */
93
+ export interface IgnoredDeviceEntry {
94
+ /** Tailscale node name the user dismissed. */
95
+ name: string;
96
+ /** ISO-8601 timestamp of the dismissal. */
97
+ ignoredAt: string;
98
+ /** machineId() of the box the dismissal was made on. */
99
+ ignoredOn: string;
83
100
  }
84
101
  /**
85
102
  * A device's desired state after merging defaults with its override and
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import type { AgentId, Mode } from '../types.js';
30
30
  import type { JobConfig } from '../scheduling/routines.js';
31
+ import type { ConfiguredDeviceRole } from '../device-config.js';
31
32
  /**
32
33
  * Context for the exec-time config-env pin (mapping A, exec.ts side). The caller
33
34
  * resolves the interactive/version facts once; the adapter only expresses the
@@ -42,6 +43,16 @@ export interface ExecConfigEnvCtx {
42
43
  versionHome: string | null;
43
44
  /** resolveInteractive(options) — computed once by the caller. */
44
45
  interactive: boolean;
46
+ /**
47
+ * The role marked on THIS machine (worker | personal | undefined), resolved
48
+ * once by the caller from selfConfiguredDeviceRole(). A `personal` device is
49
+ * the user's own interactive box: it holds a real per-version login and the
50
+ * credential decision MUST defer to it for EVERY run — interactive OR headless
51
+ * — never the worker-only setup-token (RUSH-2395). Injected as a plain value
52
+ * (not imported) to keep the adapter import-leaf. Absent/undefined is treated
53
+ * as non-personal (worker-equivalent).
54
+ */
55
+ deviceRole?: ConfiguredDeviceRole;
45
56
  /**
46
57
  * claude-account-token's resolveClaudeSetupToken, injected. The adapters MUST
47
58
  * stay import-leaf: claude-account-token pulls in the secrets stack, which
@@ -26,35 +26,49 @@ export const claudeAdapter = {
26
26
  }
27
27
  // The `auth` bundle's setup-token exists so a run with NO human present
28
28
  // authenticates without the Touch-ID-gated login item — usage probes,
29
- // routines, dispatched runs (claude-account-token.ts). An interactive run
30
- // has a human at the TTY, and their own per-version login is the credential
31
- // they established and expect; overriding it made `/status` report
32
- // `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal machine and took every
33
- // hand-driven session off that login. macOS cannot cheaply confirm a home's
34
- // login first (probing the Keychain raises an authorization sheet per
35
- // installed version on the `agents run` hot path — agents.ts
36
- // `isClaudeCredentialFileBlank`), so interactive simply defers to Claude
37
- // Code, which prompts a present human to log in if the login is missing.
38
- if (ctx.interactive) {
39
- // Drop an INHERITED copy of OUR OWN setup-token: an interactive launch from
40
- // inside a headless agent's shell inherits that agent's injected value via
41
- // sanitizeProcessEnv(process.env) and would keep authenticating as it.
42
- // Matched by VALUE, so a token the user exported deliberately is a different
43
- // string and is left alone (#2383). This is NARROWER than the non-interactive
44
- // path below, which overwrites-or-deletes unconditionally and never inspects
45
- // the inherited value a DIFFERENT account's inherited setup-token passing
46
- // through this equality check is the adjacent hole RUSH-2360 leaves as
47
- // follow-up (it does not silently run on a *shared, rotating* token, which is
48
- // what caused the RUSH-1822 logout storm).
29
+ // routines, dispatched runs (claude-account-token.ts). It is a WORKER
30
+ // credential. Two kinds of run defer to the per-version login instead:
31
+ //
32
+ // 1. An interactive run: a human is at the TTY, and their per-version login
33
+ // is the credential they established and expect. Overriding it made
34
+ // `/status` report `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal
35
+ // machine and took every hand-driven session off that login.
36
+ // 2. ANY run on a `personal` device the user's own interactive box (zion),
37
+ // marked `config.role: personal`. That box holds a real per-version login
38
+ // and is the single origin of it, so every run there — interactive TUI OR
39
+ // a headless one-shot like `agents run claude "fix the bug"` MUST use
40
+ // that login, not the setup-token. Gating on run mode ALONE
41
+ // (resolveInteractive = "this opens a TUI", NOT "a human is present")
42
+ // sent a headless run on the laptop onto the setup-token and hijacked the
43
+ // login. Keying the credential on DEVICE ROLE is the fix RUSH-2395.
44
+ //
45
+ // macOS cannot cheaply confirm a home's login first (probing the Keychain
46
+ // raises an authorization sheet per installed version on the `agents run` hot
47
+ // path agents.ts `isClaudeCredentialFileBlank`), so this path defers to
48
+ // Claude Code, which reads its own ACL-trusted login item without a prompt and
49
+ // asks a present human to log in only if the login is missing.
50
+ const personalDevice = ctx.deviceRole === 'personal';
51
+ if (ctx.interactive || personalDevice) {
52
+ // Drop an INHERITED copy of OUR OWN setup-token: a launch from inside a
53
+ // headless agent's shell inherits that agent's injected value via
54
+ // sanitizeProcessEnv(process.env) and would keep authenticating as it,
55
+ // overriding the login this branch is protecting. Matched by VALUE, so a
56
+ // token the user exported deliberately is a different string and is left
57
+ // alone (#2383). This is NARROWER than the worker path below, which
58
+ // overwrites-or-deletes unconditionally and never inspects the inherited
59
+ // value — a DIFFERENT account's inherited setup-token passing through this
60
+ // equality check is the adjacent hole RUSH-2360 leaves as follow-up (it does
61
+ // not silently run on a *shared, rotating* token, which is what caused the
62
+ // RUSH-1822 logout storm).
49
63
  if (setupToken && result.CLAUDE_CODE_OAUTH_TOKEN === setupToken) {
50
64
  delete result.CLAUDE_CODE_OAUTH_TOKEN;
51
65
  }
52
66
  }
53
67
  else {
54
- // Non-interactive (routines, dispatched, provisioned box): mirror the routines
55
- // path (`runner.ts:1017-1021`) UNCONDITIONALLY. Inject the per-account
56
- // setup-token when one resolves — it replaces any ambient shared value
57
- // inherited from the launcher. When NONE resolves, STRIP the ambient
68
+ // Headless run on a NON-personal device (worker, dispatched, provisioned
69
+ // box): mirror the routines path (`runner.ts`) UNCONDITIONALLY. Inject the
70
+ // per-account setup-token when one resolves — it replaces any ambient shared
71
+ // value inherited from the launcher. When NONE resolves, STRIP the ambient
58
72
  // CLAUDE_CODE_OAUTH_TOKEN so a run on a provisioned box can never silently
59
73
  // authenticate as the shared, rotating token an earlier version of this path
60
74
  // let through — the RUSH-1822 fleet-wide-logout hazard, tracked by RUSH-2360.
@@ -63,21 +63,19 @@ fi
63
63
  exec "$BINARY"${launchArgs} "$@"`;
64
64
  },
65
65
  execModeArgs(ctx) {
66
- const policyMode = ctx.resolvedMode === 'plan' || ctx.resolvedMode === 'skip' ? ctx.resolvedMode : 'edit';
67
66
  const writableRoots = [
68
67
  ...codexEditWritableRoots(ctx.cwd),
69
68
  ...ctx.addDirs,
70
69
  ];
71
- return codexPolicyArgs(policyMode, writableRoots);
70
+ return codexPolicyArgs(ctx.resolvedMode, writableRoots);
72
71
  },
73
72
  routineModeArgs(cmd, ctx) {
74
- const policyMode = ctx.mode === 'plan' || ctx.mode === 'skip' ? ctx.mode : 'edit';
75
73
  const routineRoots = (ctx.config.allow?.dirs ?? []).map((dir) => {
76
74
  if (dir.startsWith('-')) {
77
75
  throw new Error(`allow.dirs entries must not start with '-': ${JSON.stringify(dir)}`);
78
76
  }
79
77
  return dir.replace(/^~/, os.homedir());
80
78
  });
81
- cmd.push(...codexPolicyArgs(policyMode, [...codexEditWritableRoots(), ...routineRoots]));
79
+ cmd.push(...codexPolicyArgs(ctx.mode, [...codexEditWritableRoots(), ...routineRoots]));
82
80
  },
83
81
  };
@@ -275,6 +275,14 @@ def should_fire():
275
275
  if not tn or tn not in allowed:
276
276
  return False
277
277
 
278
+ v = m.get("permission_mode")
279
+ if v is not None:
280
+ allowed = arr(v)
281
+ if allowed:
282
+ pm = inp.get("permission_mode") or inp.get("permissionMode")
283
+ if pm and pm not in allowed:
284
+ return False
285
+
278
286
  v = m.get("tool_args_match")
279
287
  if v is not None:
280
288
  ta = inp.get("tool_args")
@@ -20,6 +20,9 @@ export interface HookInput {
20
20
  tool_name?: string;
21
21
  tool_args?: unknown;
22
22
  cwd?: string;
23
+ permission_mode?: string;
24
+ /** Grok-style camelCase spelling of permission_mode. */
25
+ permissionMode?: string;
23
26
  }
24
27
  export declare function isSafeHookRegex(source: string): boolean;
25
28
  /**
@@ -131,6 +131,31 @@ export function shouldFire(matches, input) {
131
131
  return false;
132
132
  }
133
133
  }
134
+ if (matches.permission_mode !== undefined) {
135
+ const allowed = arrayOf(matches.permission_mode);
136
+ if (allowed.length > 0) {
137
+ // Fail-open on absence: only some harnesses (Claude Code) report the
138
+ // live mode. An input with no mode field passes; an explicit value not
139
+ // in the allowed list skips.
140
+ const mode = input.permission_mode || input.permissionMode;
141
+ if (mode && !allowed.includes(mode))
142
+ return false;
143
+ }
144
+ }
145
+ if (matches.permission_mode_not !== undefined) {
146
+ const denied = arrayOf(matches.permission_mode_not);
147
+ if (denied.length > 0) {
148
+ // The negative form exists because the positive one cannot express
149
+ // "everywhere except plan" without enumerating every other mode — and an
150
+ // enumeration silently stops firing the moment a harness adds or renames
151
+ // one, which for a guard means it quietly stops guarding. Naming the mode
152
+ // to skip keeps every unknown mode firing, so the failure direction is
153
+ // "ran unnecessarily", never "did not run".
154
+ const mode = input.permission_mode || input.permissionMode;
155
+ if (mode && denied.includes(mode))
156
+ return false;
157
+ }
158
+ }
134
159
  if (matches.tool_args_match !== undefined) {
135
160
  const serialized = typeof input.tool_args === 'string'
136
161
  ? input.tool_args
@@ -28,6 +28,7 @@ import { resolveRemoteOsSync } from './remote-os.js';
28
28
  // 6–7× cheaper graph (RUSH-2374 proposal 2).
29
29
  import { machineId } from '../machine-id.js';
30
30
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
31
+ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, } from '../devices/interactive-host.js';
31
32
  import { flagValue, hasHostRoutingFlag } from './routing-flag.js';
32
33
  import { loadDevices } from '../devices/registry.js';
33
34
  import { isSelfHost } from '../devices/self-host.js';
@@ -527,6 +528,21 @@ export async function maybeRunOnHost(command, allArgs, opts) {
527
528
  process.stderr.write(chalk.gray(`[agents] device=auto → ${plan.host}\n`));
528
529
  hostName = plan.host;
529
530
  }
531
+ // `interactive` is the second affinity sentinel: the box the human sits at
532
+ // (`interactive.host`). Resolved here, ahead of the isSelfHost check below, for
533
+ // the same reason `auto` is — a pin naming THIS machine must run locally rather
534
+ // than self-SSH. It never falls back to the local box when unset: rendering to
535
+ // a screen nobody is watching is the exact failure the sentinel prevents.
536
+ if (isDeviceInteractive(hostName)) {
537
+ const pinned = resolveInteractiveDevice();
538
+ if (!pinned) {
539
+ process.stderr.write(`${interactiveUnsetError()}\n`);
540
+ process.exitCode = 1;
541
+ return true;
542
+ }
543
+ process.stderr.write(chalk.gray(`[agents] device=interactive → ${pinned}\n`));
544
+ hostName = pinned;
545
+ }
530
546
  // Running against your own machine is just a local run — skip the SSH round-trip.
531
547
  // Match EVERY identity the box answers to (short id, loopback, tailscale
532
548
  // dnsName), not just machineId() — a `--device <self-dnsName>` used to slip past a
@@ -27,6 +27,7 @@ import { resolveRemoteOsSync } from './remote-os.js';
27
27
  import { loadDevices } from '../devices/registry.js';
28
28
  import { resolveDeviceProfile } from '../devices/resolve-profile.js';
29
29
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
30
+ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, } from '../devices/interactive-host.js';
30
31
  import { localMachineId } from '../session/origin-machine.js';
31
32
  // Re-export so existing importers (tests, commands) keep their path; the class
32
33
  // itself lives in types.ts so providers can throw it without a circular import.
@@ -161,6 +162,12 @@ export async function matchHost(name, opts = {}) {
161
162
  // returning nothing — callers that already special-case "target is this
162
163
  // machine" (teams add/create, the passthrough self-host check) then treat it as
163
164
  // local exactly as they would if the user had typed the local name.
165
+ if (isDeviceInteractive(name)) {
166
+ const pinned = resolveInteractiveDevice();
167
+ if (!pinned)
168
+ throw new Error(interactiveUnsetError());
169
+ name = pinned;
170
+ }
164
171
  if (isDeviceAuto(name)) {
165
172
  const plan = (opts.resolveAuto ?? (() => resolveDeviceAffinity({})))();
166
173
  const picked = plan.host ?? normalizeHost(localMachineId());
@@ -0,0 +1,53 @@
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
+ /**
18
+ * Where the account backend lives. Config, never a literal at a call site.
19
+ *
20
+ * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
21
+ * rather than a vanity hostname because no custom domain is attached yet — and
22
+ * a default naming an unregistered domain is worse than an ugly one: every
23
+ * `agents auth login` would fail DNS with nothing to point at.
24
+ */
25
+ export declare const PHOENIX_ID_BASE: string;
26
+ /** Our own session file. agents-cli never reads another product's credentials. */
27
+ export declare function sessionFilePath(): string;
28
+ export interface PhoenixSession {
29
+ access_token: string;
30
+ email?: string;
31
+ userId?: string;
32
+ /** Unix ms; absent means the server did not scope the token's lifetime. */
33
+ expires_at?: number;
34
+ }
35
+ export declare function readSession(): PhoenixSession | null;
36
+ export declare function writeSession(session: PhoenixSession): void;
37
+ export declare function clearSession(): void;
38
+ /** An error carrying the server's status and message, so callers can branch on it. */
39
+ export declare class PhoenixApiError extends Error {
40
+ readonly status: number;
41
+ constructor(message: string, status: number);
42
+ }
43
+ interface RequestOptions {
44
+ body?: unknown;
45
+ /** Send the stored session token. Default true; the device-flow start does not. */
46
+ auth?: boolean;
47
+ /** Use this token instead of the stored one (mid-login, before the write). */
48
+ token?: string;
49
+ timeoutMs?: number;
50
+ }
51
+ /** The single HTTP funnel. Every request to the account backend goes through here. */
52
+ export declare function phoenixRequest<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', route: string, opts?: RequestOptions): Promise<T>;
53
+ export {};