@phnx-labs/agents-cli 1.22.43 → 1.22.45

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 (164) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +14 -12
  3. package/dist/bootstrap.js +3 -6
  4. package/dist/cli/command-registry.d.ts +0 -7
  5. package/dist/cli/command-registry.js +1 -21
  6. package/dist/commands/accounts.d.ts +1 -9
  7. package/dist/commands/accounts.js +12 -90
  8. package/dist/commands/apply.d.ts +7 -10
  9. package/dist/commands/apply.js +15 -35
  10. package/dist/commands/artifacts.js +4 -5
  11. package/dist/commands/audit.d.ts +6 -6
  12. package/dist/commands/audit.js +12 -12
  13. package/dist/commands/beta.d.ts +7 -1
  14. package/dist/commands/beta.js +16 -9
  15. package/dist/commands/commands.js +1 -1
  16. package/dist/commands/doctor.js +1 -4
  17. package/dist/commands/events.d.ts +2 -2
  18. package/dist/commands/events.js +6 -5
  19. package/dist/commands/exec.d.ts +1 -1
  20. package/dist/commands/exec.js +8 -4
  21. package/dist/commands/factory.d.ts +1 -1
  22. package/dist/commands/fleet-capture.d.ts +1 -1
  23. package/dist/commands/fleet-capture.js +4 -5
  24. package/dist/commands/harness.js +1 -49
  25. package/dist/commands/hooks.js +1 -1
  26. package/dist/commands/insights.d.ts +1 -1
  27. package/dist/commands/insights.js +85 -156
  28. package/dist/commands/menubar.js +58 -1
  29. package/dist/commands/output.js +2 -2
  30. package/dist/commands/prune.js +5 -4
  31. package/dist/commands/routines.test-fixture.d.ts +86 -0
  32. package/dist/commands/routines.test-fixture.js +345 -0
  33. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  34. package/dist/commands/sessions.test-fixture.js +224 -0
  35. package/dist/commands/setup.js +5 -1
  36. package/dist/commands/share.d.ts +9 -10
  37. package/dist/commands/share.js +68 -50
  38. package/dist/commands/skills.js +1 -1
  39. package/dist/commands/ssh.js +15 -62
  40. package/dist/commands/subagents.js +1 -1
  41. package/dist/commands/view.js +5 -2
  42. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  43. package/dist/lib/analytics/mix-commands.js +14 -40
  44. package/dist/lib/audit/log.d.ts +2 -2
  45. package/dist/lib/audit/log.js +2 -2
  46. package/dist/lib/beta.js +1 -1
  47. package/dist/lib/browser/domain-skills.d.ts +26 -6
  48. package/dist/lib/browser/domain-skills.js +81 -43
  49. package/dist/lib/daemon/daemon.js +11 -0
  50. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  51. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  52. package/dist/lib/daemon-ticks.d.ts +38 -1
  53. package/dist/lib/daemon-ticks.js +52 -5
  54. package/dist/lib/device-config.js +1 -2
  55. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  56. package/dist/lib/devices/discovery-policy.js +3 -3
  57. package/dist/lib/devices/fleet.d.ts +4 -8
  58. package/dist/lib/devices/fleet.js +3 -15
  59. package/dist/lib/devices/health-report.js +3 -3
  60. package/dist/lib/devices/pool.d.ts +0 -6
  61. package/dist/lib/devices/pool.js +0 -6
  62. package/dist/lib/devices/registry.d.ts +0 -15
  63. package/dist/lib/devices/registry.js +0 -9
  64. package/dist/lib/devices/stats-cache.d.ts +12 -0
  65. package/dist/lib/devices/stats-cache.js +24 -5
  66. package/dist/lib/fleet/capture.d.ts +1 -1
  67. package/dist/lib/fleet/manifest.js +1 -1
  68. package/dist/lib/fleet/remote-login.d.ts +2 -2
  69. package/dist/lib/fleet/remote-login.js +3 -4
  70. package/dist/lib/hosts/dispatch.d.ts +2 -0
  71. package/dist/lib/hosts/dispatch.js +8 -1
  72. package/dist/lib/hosts/passthrough.js +0 -1
  73. package/dist/lib/hosts/providers/devices.js +1 -13
  74. package/dist/lib/hosts/ready.d.ts +12 -3
  75. package/dist/lib/hosts/ready.js +27 -12
  76. package/dist/lib/hosts/registry.d.ts +4 -5
  77. package/dist/lib/hosts/registry.js +3 -7
  78. package/dist/lib/installations/versions.js +21 -2
  79. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  80. package/dist/lib/menubar/install-menubar.js +199 -4
  81. package/dist/lib/remote-agents-json.js +1 -7
  82. package/dist/lib/routines-placement.d.ts +1 -1
  83. package/dist/lib/routines-placement.js +1 -1
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  86. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  87. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  88. package/dist/lib/session/active.d.ts +4 -16
  89. package/dist/lib/session/active.js +2 -6
  90. package/dist/lib/session/db.d.ts +13 -98
  91. package/dist/lib/session/db.js +4 -11
  92. package/dist/lib/session/discover.d.ts +5 -81
  93. package/dist/lib/session/discover.js +5 -5
  94. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  95. package/dist/lib/session/remote/remote-bundle.js +61 -0
  96. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  97. package/dist/lib/session/remote/remote-list.js +607 -0
  98. package/dist/lib/session/remote/remote.d.ts +132 -0
  99. package/dist/lib/session/remote/remote.js +314 -0
  100. package/dist/lib/session/remote/watch.d.ts +101 -0
  101. package/dist/lib/session/remote/watch.js +241 -0
  102. package/dist/lib/session/remote-bundle.d.ts +1 -12
  103. package/dist/lib/session/remote-bundle.js +3 -61
  104. package/dist/lib/session/remote-list.d.ts +1 -148
  105. package/dist/lib/session/remote-list.js +3 -607
  106. package/dist/lib/session/remote.d.ts +1 -132
  107. package/dist/lib/session/remote.js +3 -314
  108. package/dist/lib/session/watch.d.ts +1 -101
  109. package/dist/lib/session/watch.js +3 -242
  110. package/dist/lib/share/config.d.ts +12 -2
  111. package/dist/lib/share/config.js +47 -11
  112. package/dist/lib/share/delete.js +1 -1
  113. package/dist/lib/share/publish.js +1 -1
  114. package/dist/lib/share/worker-template.js +2 -2
  115. package/dist/lib/smart-launch.d.ts +4 -4
  116. package/dist/lib/smart-launch.js +8 -18
  117. package/dist/lib/staleness/index.d.ts +3 -1
  118. package/dist/lib/staleness/index.js +21 -1
  119. package/dist/lib/staleness/types.d.ts +11 -0
  120. package/dist/lib/staleness/writers/commands.js +13 -4
  121. package/dist/lib/staleness/writers/hooks.js +3 -1
  122. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  123. package/dist/lib/staleness/writers/mcp.js +16 -1
  124. package/dist/lib/staleness/writers/rules.js +1 -1
  125. package/dist/lib/staleness/writers/skills.js +3 -1
  126. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  127. package/dist/lib/staleness/writers/subagents.js +18 -1
  128. package/dist/lib/staleness/writers/types.d.ts +10 -0
  129. package/dist/lib/startup/command-registry.d.ts +8 -1
  130. package/dist/lib/startup/command-registry.js +21 -6
  131. package/dist/lib/startup/root-command.d.ts +18 -1
  132. package/dist/lib/startup/root-command.js +18 -1
  133. package/dist/lib/state.js +7 -0
  134. package/dist/lib/teams/placement-probe.js +27 -12
  135. package/dist/lib/teams/scheduler.d.ts +3 -1
  136. package/dist/lib/types.d.ts +1 -1
  137. package/dist/lib/view-types.d.ts +3 -0
  138. package/package.json +2 -3
  139. package/dist/bin/agents +0 -0
  140. package/dist/commands/auth.d.ts +0 -9
  141. package/dist/commands/auth.js +0 -108
  142. package/dist/commands/org.d.ts +0 -6
  143. package/dist/commands/org.js +0 -175
  144. package/dist/commands/serve.d.ts +0 -10
  145. package/dist/commands/serve.js +0 -68
  146. package/dist/commands/trends.d.ts +0 -10
  147. package/dist/commands/trends.js +0 -12
  148. package/dist/lib/entitlement.d.ts +0 -31
  149. package/dist/lib/entitlement.js +0 -137
  150. package/dist/lib/prix-account.d.ts +0 -158
  151. package/dist/lib/prix-account.js +0 -214
  152. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  153. package/dist/lib/serve/control.d.ts +0 -95
  154. package/dist/lib/serve/control.js +0 -260
  155. package/dist/lib/serve/data.d.ts +0 -81
  156. package/dist/lib/serve/data.js +0 -91
  157. package/dist/lib/serve/page.d.ts +0 -7
  158. package/dist/lib/serve/page.js +0 -140
  159. package/dist/lib/serve/server.d.ts +0 -80
  160. package/dist/lib/serve/server.js +0 -145
  161. package/dist/lib/serve/stream.d.ts +0 -43
  162. package/dist/lib/serve/stream.js +0 -116
  163. package/dist/lib/serve/token.d.ts +0 -35
  164. package/dist/lib/serve/token.js +0 -85
@@ -1,242 +1,3 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { spawn } from 'node:child_process';
3
- import { createInterface } from 'node:readline';
4
- import * as fs from 'node:fs';
5
- import * as path from 'node:path';
6
- import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
7
- import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
8
- import { machineId, normalizeHost } from '../machine-id.js';
9
- import { SSH_OPTS, controlOpts, shellQuote } from '../ssh-exec.js';
10
- import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
11
- import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, } from './session-cache.js';
12
- export const SESSION_WATCH_VERSION = 1;
13
- export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
14
- /** Stable, opaque identity for one row within one device scope. */
15
- export function sessionWatchRowKey(scope, row) {
16
- const identity = activeSessionJournalIdentity(row);
17
- return createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
18
- }
19
- export function toSessionWatchRow(scope, row) {
20
- const rowKey = sessionWatchRowKey(scope, row);
21
- const resumable = Boolean(row.sessionId);
22
- const viewingIn = row.viewingIn
23
- ? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
24
- : null;
25
- return {
26
- ...row,
27
- rowKey,
28
- sourceDevice: scope,
29
- resumable,
30
- unwatched: !viewingIn,
31
- viewingIn,
32
- recovery: resumable
33
- ? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', scope], ...(row.cwd ? { cwd: row.cwd } : {}) }
34
- : null,
35
- };
36
- }
37
- function sameRow(a, b) {
38
- return JSON.stringify(a) === JSON.stringify(b);
39
- }
40
- /** Stream-local sequencer and convergent row diff. */
41
- export class SessionWatchState {
42
- streamId;
43
- sequence = 0;
44
- rows = new Map();
45
- constructor(streamId = randomUUID()) { this.streamId = streamId; }
46
- base(type) {
47
- return { version: SESSION_WATCH_VERSION, type, streamId: this.streamId, sequence: ++this.sequence, capturedAt: Date.now() };
48
- }
49
- reset(scope, sourceRows) {
50
- const rows = sourceRows.map((row) => toSessionWatchRow(scope, row));
51
- this.rows.set(scope, new Map(rows.map((row) => [row.rowKey, row])));
52
- return { ...this.base('reset'), scope, rows };
53
- }
54
- update(scope, sourceRows) {
55
- const previous = this.rows.get(scope) ?? new Map();
56
- const nextRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
57
- const next = new Map(nextRows.map((row) => [row.rowKey, row]));
58
- const events = [];
59
- for (const [rowKey, row] of next) {
60
- if (!previous.has(rowKey) || !sameRow(previous.get(rowKey), row)) {
61
- events.push({ ...this.base('upsert'), scope, rowKey, row });
62
- }
63
- }
64
- for (const rowKey of previous.keys()) {
65
- if (!next.has(rowKey))
66
- events.push({ ...this.base('remove'), scope, rowKey });
67
- }
68
- this.rows.set(scope, next);
69
- return events;
70
- }
71
- patch(scope, upserts, removes) {
72
- const current = new Map(this.rows.get(scope) ?? []);
73
- const events = [];
74
- for (const source of upserts) {
75
- const row = toSessionWatchRow(scope, source);
76
- if (!current.has(row.rowKey) || !sameRow(current.get(row.rowKey), row)) {
77
- current.set(row.rowKey, row);
78
- events.push({ ...this.base('upsert'), scope, rowKey: row.rowKey, row });
79
- }
80
- }
81
- for (const identity of removes) {
82
- const rowKey = createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
83
- if (current.delete(rowKey))
84
- events.push({ ...this.base('remove'), scope, rowKey });
85
- }
86
- this.rows.set(scope, current);
87
- return events;
88
- }
89
- scope(scope, status, reason) {
90
- // Deliberately retain rows while unavailable. A reconnecting scope replaces
91
- // them with a reset; transient fleet loss must not look like session death.
92
- return { ...this.base('scope'), scope, status, ...(reason ? { reason } : {}) };
93
- }
94
- heartbeat(scope, capturedAt = Date.now()) {
95
- return { ...this.base('heartbeat'), scope, capturedAt };
96
- }
97
- }
98
- /**
99
- * Keep one local subscription alive. Startup reads one canonical reset snapshot;
100
- * steady state tails the canonical writer journal and never invokes a gather.
101
- */
102
- export async function watchLocalSessions(options) {
103
- const state = new SessionWatchState();
104
- const readCache = options.readCache ?? readActiveSessionsCache;
105
- const journal = options.journalPath ?? activeSessionsJournalPath();
106
- const heartbeatMs = options.heartbeatMs ?? SESSION_WATCH_HEARTBEAT_MS;
107
- let offset = 0;
108
- try {
109
- offset = fs.statSync(journal).size;
110
- }
111
- catch { /* first publication */ }
112
- const initial = readCache('local');
113
- options.emit(state.reset(options.scope, initial?.sessions ?? []));
114
- options.emit(state.scope(options.scope, initial ? 'available' : 'unavailable', initial ? undefined : 'awaiting publisher'));
115
- if (options.signal.aborted)
116
- return;
117
- await new Promise((resolve) => {
118
- let partial = '';
119
- let reading = false;
120
- const readAppended = () => {
121
- if (reading || options.signal.aborted)
122
- return;
123
- reading = true;
124
- try {
125
- const size = fs.statSync(journal).size;
126
- if (size < offset) {
127
- offset = 0;
128
- partial = '';
129
- }
130
- if (size === offset)
131
- return;
132
- const fd = fs.openSync(journal, 'r');
133
- try {
134
- const buffer = Buffer.alloc(size - offset);
135
- fs.readSync(fd, buffer, 0, buffer.length, offset);
136
- offset = size;
137
- const lines = (partial + buffer.toString('utf8')).split('\n');
138
- partial = lines.pop() ?? '';
139
- for (const line of lines) {
140
- if (!line)
141
- continue;
142
- try {
143
- const record = JSON.parse(line);
144
- if (record.version !== 1 || record.scope !== 'local' || !Array.isArray(record.upserts) || !Array.isArray(record.removes))
145
- continue;
146
- for (const event of state.patch(options.scope, record.upserts, record.removes))
147
- options.emit(event);
148
- options.emit(state.scope(options.scope, 'available'));
149
- }
150
- catch { /* partial/corrupt journal lines are not state */ }
151
- }
152
- }
153
- finally {
154
- fs.closeSync(fd);
155
- }
156
- }
157
- catch { /* journal may not exist until the first publisher write */ }
158
- finally {
159
- reading = false;
160
- }
161
- };
162
- fs.mkdirSync(path.dirname(journal), { recursive: true });
163
- const journalListener = () => readAppended();
164
- fs.watchFile(journal, { interval: options.journalPollMs ?? 250 }, journalListener);
165
- const heartbeatTimer = setInterval(() => options.emit(state.heartbeat(options.scope)), heartbeatMs);
166
- const stop = () => { fs.unwatchFile(journal, journalListener); clearInterval(heartbeatTimer); resolve(); };
167
- options.signal.addEventListener('abort', stop, { once: true });
168
- // Close the offset/read/watch handoff: a publisher may append after the
169
- // startup offset is captured but before watchFile is registered.
170
- readAppended();
171
- });
172
- }
173
- function remoteWatchCommand(os) {
174
- const args = ['sessions', 'watch', '--json', '--local'];
175
- return remoteShellFor(os) === 'powershell'
176
- ? buildWindowsAgentsCommand({ args })
177
- : `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
178
- }
179
- /**
180
- * Subscribe to every dialable compute device with one persistent SSH process.
181
- * A peer disconnect emits `scope: unavailable` but no removes; reconnecting
182
- * peer resets only its own scope. There is no recurring fleet list command.
183
- */
184
- export async function watchFleetSessions(options) {
185
- const local = watchLocalSessions({ scope: machineId(), signal: options.signal, emit: options.emit });
186
- let devices;
187
- try {
188
- devices = await loadDevices();
189
- }
190
- catch {
191
- await local;
192
- return;
193
- }
194
- const self = machineId();
195
- const peers = Object.values(devices).filter((device) => isDialableDevice(device)
196
- && !isControlDevice(device)
197
- && normalizeHost(device.name) !== self
198
- && ['windows', 'linux', 'macos'].includes(device.platform));
199
- const reconnectMs = options.reconnectMs ?? 2_000;
200
- const peerTasks = peers.map(async (device) => {
201
- const scope = normalizeHost(device.name);
202
- const state = new SessionWatchState();
203
- while (!options.signal.aborted) {
204
- let target;
205
- try {
206
- target = sshTargetFor(device);
207
- }
208
- catch (error) {
209
- options.emit(state.scope(scope, 'unavailable', error instanceof Error ? error.message : String(error)));
210
- break;
211
- }
212
- const child = spawn('ssh', [
213
- ...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteWatchCommand(device.platform),
214
- ], { stdio: ['ignore', 'pipe', 'ignore'] });
215
- const stop = () => child.kill('SIGTERM');
216
- options.signal.addEventListener('abort', stop, { once: true });
217
- const reader = createInterface({ input: child.stdout });
218
- reader.on('line', (line) => {
219
- try {
220
- const event = JSON.parse(line);
221
- if (event.version === SESSION_WATCH_VERSION && typeof event.sequence === 'number')
222
- options.emit(event);
223
- }
224
- catch { /* incomplete/non-protocol peer output is not state */ }
225
- });
226
- const code = await new Promise((resolve) => {
227
- child.once('error', () => resolve(null));
228
- child.once('close', resolve);
229
- });
230
- reader.close();
231
- options.signal.removeEventListener('abort', stop);
232
- if (options.signal.aborted)
233
- break;
234
- options.emit(state.scope(scope, 'unavailable', code === null ? 'ssh failed' : `ssh exited ${code}`));
235
- await new Promise((resolve) => {
236
- const timer = setTimeout(resolve, reconnectMs);
237
- options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
238
- });
239
- }
240
- });
241
- await Promise.all([local, ...peerTasks]);
242
- }
1
+ // Moved to ./remote/watch.ts (move 4 of the core-module refactor).
2
+ // Kept as a re-export shim so existing importers don't break.
3
+ export * from './remote/watch.js';
@@ -22,9 +22,19 @@ export declare const DEFAULT_BUCKET_NAME = "agents-share";
22
22
  export declare const DEFAULT_SHARE_DOMAIN = "share.agents-cli.sh";
23
23
  /** The write token may be injected ephemerally into fleet/cloud agents. */
24
24
  export declare function readWriteTokenEnv(env?: NodeJS.ProcessEnv): string | null;
25
- /** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
25
+ /**
26
+ * Read the persisted endpoint config, or null if there is no `share.baseUrl`.
27
+ *
28
+ * Publish talks only to `baseUrl` with `WRITE_TOKEN`. `accountId` / worker /
29
+ * bucket are provisioning metadata for `setup`/`update`. Requiring all four
30
+ * (and treating `accountId: ""` as missing) made `status` and publish both
31
+ * report "not set up" on an endpoint that still worked — RUSH-2837.
32
+ */
26
33
  export declare function readShareConfig(): ShareConfig | null;
27
- /** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
34
+ /** Persist the endpoint config to `agents.yaml` (syncs across the fleet).
35
+ * Empty strings never overwrite a previously stored value and are not written
36
+ * back as `accountId: ""` (that form made {@link readShareConfig} return null
37
+ * under the old all-fields-required check, and it is still useless). */
28
38
  export declare function writeShareConfig(cfg: ShareConfig): void;
29
39
  /** A fresh 32-byte hex write token. */
30
40
  export declare function generateWriteToken(): string;
@@ -23,24 +23,60 @@ export function readWriteTokenEnv(env = process.env) {
23
23
  const token = env[SHARE_TOKEN_ENV_KEY]?.trim();
24
24
  return token ? token : null;
25
25
  }
26
- /** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
26
+ /** Trim; empty / whitespace-only strings are absent. */
27
+ function nonempty(v) {
28
+ const t = v?.trim();
29
+ return t ? t : undefined;
30
+ }
31
+ /**
32
+ * Read the persisted endpoint config, or null if there is no `share.baseUrl`.
33
+ *
34
+ * Publish talks only to `baseUrl` with `WRITE_TOKEN`. `accountId` / worker /
35
+ * bucket are provisioning metadata for `setup`/`update`. Requiring all four
36
+ * (and treating `accountId: ""` as missing) made `status` and publish both
37
+ * report "not set up" on an endpoint that still worked — RUSH-2837.
38
+ */
27
39
  export function readShareConfig() {
28
40
  const s = readMeta().share;
29
- if (!s?.baseUrl || !s.accountId || !s.workerName || !s.bucketName)
41
+ const baseUrl = nonempty(s?.baseUrl)?.replace(/\/+$/, '');
42
+ if (!baseUrl)
30
43
  return null;
31
44
  return {
32
- baseUrl: s.baseUrl.replace(/\/+$/, ''),
33
- accountId: s.accountId,
34
- workerName: s.workerName,
35
- bucketName: s.bucketName,
36
- domain: s.domain,
37
- analyticsToken: s.analyticsToken,
38
- templateHash: s.templateHash,
45
+ baseUrl,
46
+ accountId: nonempty(s?.accountId) ?? '',
47
+ workerName: nonempty(s?.workerName) ?? DEFAULT_WORKER_NAME,
48
+ bucketName: nonempty(s?.bucketName) ?? DEFAULT_BUCKET_NAME,
49
+ domain: nonempty(s?.domain),
50
+ analyticsToken: nonempty(s?.analyticsToken),
51
+ templateHash: nonempty(s?.templateHash),
39
52
  };
40
53
  }
41
- /** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
54
+ /** Persist the endpoint config to `agents.yaml` (syncs across the fleet).
55
+ * Empty strings never overwrite a previously stored value and are not written
56
+ * back as `accountId: ""` (that form made {@link readShareConfig} return null
57
+ * under the old all-fields-required check, and it is still useless). */
42
58
  export function writeShareConfig(cfg) {
43
- updateMeta((meta) => ({ ...meta, share: { ...meta.share, ...cfg } }));
59
+ updateMeta((meta) => {
60
+ const prev = meta.share ?? {};
61
+ const next = { ...prev };
62
+ const assign = (key, incoming) => {
63
+ const v = nonempty(incoming);
64
+ if (v)
65
+ next[key] = v;
66
+ };
67
+ assign('baseUrl', cfg.baseUrl);
68
+ assign('accountId', cfg.accountId);
69
+ assign('workerName', cfg.workerName);
70
+ assign('bucketName', cfg.bucketName);
71
+ assign('domain', cfg.domain);
72
+ assign('analyticsToken', cfg.analyticsToken);
73
+ assign('templateHash', cfg.templateHash);
74
+ for (const key of Object.keys(next)) {
75
+ if (typeof next[key] === 'string' && !nonempty(next[key]))
76
+ delete next[key];
77
+ }
78
+ return { ...meta, share: next };
79
+ });
44
80
  }
45
81
  /** A fresh 32-byte hex write token. */
46
82
  export function generateWriteToken() {
@@ -1,4 +1,4 @@
1
- // The delete path for `agents artifacts share delete` / `agents unshare` — an authed DELETE
1
+ // The delete path for `agents artifacts share delete` / `agents artifacts unshare` — an authed DELETE
2
2
  // to the Worker, which already implements it (worker-template.ts). Mirrors
3
3
  // publish.ts: pure target-resolution logic is exported for tests, the network
4
4
  // calls (a status check + a delete) sit behind an injectable DI seam.
@@ -459,7 +459,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
459
459
  // strings unless --force. Runs on the raw file body AND on every piece of
460
460
  // free-text metadata that lands in public customMetadata — --label (explicit
461
461
  // or derived) and every --meta value. Metadata is visible in the gallery,
462
- // `share list --json`, and `share revisions` just like the page itself, so a
462
+ // `share list --list-json`, and `share revisions` just like the page itself, so a
463
463
  // credential smuggled in there is exactly as exposed as one in the body; it
464
464
  // must not have a free pass around this gate. Runs before analytics/cover
465
465
  // mutation so a beacon injection never triggers a false positive on the body
@@ -21,8 +21,8 @@
21
21
  // - GET /<slug> — backward-compat flat slug (legacy shares before
22
22
  // per-user namespaces).
23
23
  //
24
- // Emitted as a string (mirrors src/lib/serve/page.ts `renderPage()`), so it compiles
25
- // into `dist/**` and ships with no package.json#files change. `provision.ts` uploads
24
+ // Emitted as a string, so it compiles into `dist/**` and ships with no
25
+ // package.json#files change. `provision.ts` uploads
26
26
  // this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
27
27
  /**
28
28
  * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
@@ -34,9 +34,7 @@ export declare function sampleWeighted(candidates: WeightedCandidate[], rng?: ()
34
34
  * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
35
35
  * marking workers moves every `--device auto` at once instead of one surface.
36
36
  *
37
- * Paired cockpits (`role: control` in the device registry) are dropped here,
38
- * where the registry is already being read — they are control surfaces, not
39
- * compute. It CAN return an empty list — a fleet where every marked worker is
37
+ * It CAN return an empty list a fleet where every marked worker is
40
38
  * offline, or where this box is the only candidate and is marked `personal`. That is a
41
39
  * real answer, and both callers fail loud on it rather than falling back to the
42
40
  * local machine (which would be the exact box the operator marked personal to
@@ -94,6 +92,8 @@ export declare function formatNoHealthyDeviceError(pool: string[], signals: Map<
94
92
  export declare function resolveDeviceAuto(agent?: string, opts?: {
95
93
  eligibleHosts?: string[];
96
94
  localMachine?: string;
95
+ /** Route only to devices with a row the interactive account picker can launch. */
96
+ accountPicker?: boolean;
97
97
  probe?: (pool: string[], agent?: AgentType) => Promise<Map<string, DevicePlacementSignal>>;
98
98
  }): Promise<DeviceAutoPlan>;
99
99
  /**
@@ -136,7 +136,7 @@ export type DeviceAutoApplyResult = {
136
136
  * `auto`, so callers fail loud instead of silently launching locally.
137
137
  */
138
138
  export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
139
- resolve?: () => DeviceAutoPlan | Promise<DeviceAutoPlan>;
139
+ resolve?: (accountPicker: boolean) => DeviceAutoPlan | Promise<DeviceAutoPlan>;
140
140
  agent?: string;
141
141
  accountPickerRequested?: boolean;
142
142
  }): Promise<DeviceAutoApplyResult>;
@@ -7,12 +7,11 @@
7
7
  */
8
8
  import { queryAffinityRollup } from './session/db.js';
9
9
  import { localMachineId } from './session/origin-machine.js';
10
- import { isControlDevice, loadDevicesSync } from './devices/registry.js';
10
+ import { loadDevicesSync } from './devices/registry.js';
11
11
  import { describeAutoPool, filterAutoPool, isAutoPoolMember } from './devices/pool.js';
12
12
  import { normalizeHost } from './machine-id.js';
13
13
  import { probePoolSignals } from './teams/placement-probe.js';
14
14
  import { pickBestDevice } from './teams/scheduler.js';
15
- import { getAccountInfo } from './agents.js';
16
15
  /** Peakiness of usage weights; >1 amplifies the most-used option. */
17
16
  export const DEFAULT_AFFINITY_ALPHA = 1.3;
18
17
  /**
@@ -55,9 +54,7 @@ export function sampleWeighted(candidates, rng = Math.random) {
55
54
  * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
56
55
  * marking workers moves every `--device auto` at once instead of one surface.
57
56
  *
58
- * Paired cockpits (`role: control` in the device registry) are dropped here,
59
- * where the registry is already being read — they are control surfaces, not
60
- * compute. It CAN return an empty list — a fleet where every marked worker is
57
+ * It CAN return an empty list a fleet where every marked worker is
61
58
  * offline, or where this box is the only candidate and is marked `personal`. That is a
62
59
  * real answer, and both callers fail loud on it rather than falling back to the
63
60
  * local machine (which would be the exact box the operator marked personal to
@@ -74,10 +71,6 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
74
71
  // No tailscale snapshot → treat as candidate (registry-only box).
75
72
  if (online === false)
76
73
  continue;
77
- // A paired cockpit (iPhone/iPad) is a control surface, not compute — it
78
- // is never dialed for a session, so it is never a placement candidate.
79
- if (isControlDevice(d))
80
- continue;
81
74
  names.add(normalizeHost(name));
82
75
  }
83
76
  }
@@ -147,18 +140,15 @@ export async function resolveDeviceAuto(agent, opts = {}) {
147
140
  signals.set(key, { ...current, installed: accountEligible, signedIn: accountEligible });
148
141
  }
149
142
  }
150
- if (agent && !opts.probe && signals.has(local)) {
151
- const info = await getAccountInfo(agent).catch(() => null);
152
- const eligible = !!info?.signedIn && info.usageStatus !== 'rate_limited' && info.usageStatus !== 'out_of_credits';
153
- signals.set(local, { ...signals.get(local), signedIn: eligible });
154
- }
155
143
  const eligiblePool = pool.filter((key) => {
156
144
  const signal = signals.get(key);
157
145
  if (signal?.reachable !== true || signal.headroom === 'loaded')
158
146
  return false;
159
147
  if (!agent)
160
148
  return opts.probe ? true : signal.installed === true && signal.signedIn === true;
161
- return signal.installed === true && signal.signedIn === true;
149
+ return signal.installed === true && (opts.accountPicker === true
150
+ ? signal.pickerEligible === true
151
+ : signal.signedIn === true);
162
152
  });
163
153
  if (eligiblePool.length === 0) {
164
154
  throw new Error(formatNoHealthyDeviceError(pool, signals, agent));
@@ -260,15 +250,15 @@ export async function applyDeviceAutoToOptions(options, deps = {}) {
260
250
  if (!hasAuto) {
261
251
  return { attempted: false, deprecationSmart };
262
252
  }
263
- const resolve = deps.resolve ?? (() => resolveDeviceAuto(deps.agent));
264
- const plan = await resolve();
253
+ const accountPickerRequested = deps.accountPickerRequested ?? false;
254
+ const resolve = deps.resolve ?? ((accountPicker) => resolveDeviceAuto(deps.agent, { accountPicker }));
255
+ const plan = await resolve(accountPickerRequested);
265
256
  const concrete = plan.host; // null = local
266
257
  for (const k of HOST_SLOTS) {
267
258
  if (isDeviceAuto(options[k])) {
268
259
  options[k] = concrete ?? undefined;
269
260
  }
270
261
  }
271
- const accountPickerRequested = deps.accountPickerRequested ?? false;
272
262
  if (!accountPickerRequested && !options.strategy && !options.balanced) {
273
263
  options.balanced = true;
274
264
  }
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * - `buildManifest(agent, version, cwd)` — snapshot current state.
6
6
  * - `isStale(manifest, agent, version, cwd)` — true when any tracked
7
- * resource has drifted from its stored fingerprint.
7
+ * resource has drifted from its stored fingerprint, or when an artifact
8
+ * the last full sync wrote (`writtenTargets`) has been deleted from the
9
+ * version home (#2398).
8
10
  *
9
11
  * `loadManifest` / `saveManifest` round-trip the on-disk JSON. The format
10
12
  * version stays at 1; new optional fields (workflows, plugins) on old files
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * - `buildManifest(agent, version, cwd)` — snapshot current state.
6
6
  * - `isStale(manifest, agent, version, cwd)` — true when any tracked
7
- * resource has drifted from its stored fingerprint.
7
+ * resource has drifted from its stored fingerprint, or when an artifact
8
+ * the last full sync wrote (`writtenTargets`) has been deleted from the
9
+ * version home (#2398).
8
10
  *
9
11
  * `loadManifest` / `saveManifest` round-trip the on-disk JSON. The format
10
12
  * version stays at 1; new optional fields (workflows, plugins) on old files
@@ -91,6 +93,12 @@ export function buildManifest(agent, version, cwd, previous) {
91
93
  subagents: {},
92
94
  workflows: {},
93
95
  plugins: {},
96
+ // Empty baseline ("nothing recorded to verify"). The full-sync
97
+ // orchestrator (versions.ts) replaces this with the writer-reported
98
+ // artifact paths right before saving; a manifest built outside that path
99
+ // simply verifies nothing. Only an ABSENT field (a pre-upgrade manifest
100
+ // on disk) forces the one-time migration re-sync in isStale.
101
+ writtenTargets: [],
94
102
  };
95
103
  for (const { checker, field } of STANDARD_CHECKERS) {
96
104
  const target = manifest[field];
@@ -143,5 +151,17 @@ export function isStale(manifest, agent, version, cwd) {
143
151
  return true;
144
152
  if (isRulesStale(manifest.rules, agent, version, cwd))
145
153
  return true;
154
+ // Home-side deletion check (#2398): every artifact the last full sync wrote
155
+ // must still exist, or the version home has rotted and needs a re-sync —
156
+ // regardless of source fingerprints. One existsSync per path, no content
157
+ // reads, so the fast-guard budget holds (RUSH-2320). A manifest that
158
+ // predates the field reads as stale once so the next full sync records the
159
+ // baseline (same precedent as the optional workflows/plugins maps).
160
+ if (manifest.writtenTargets === undefined)
161
+ return true;
162
+ for (const target of manifest.writtenTargets) {
163
+ if (!fs.existsSync(target))
164
+ return true;
165
+ }
146
166
  return false;
147
167
  }
@@ -54,4 +54,15 @@ export interface SyncManifest {
54
54
  subagents: Record<string, DirEntry>;
55
55
  workflows?: Record<string, DirEntry>;
56
56
  plugins?: Record<string, PluginEntry>;
57
+ /**
58
+ * Absolute paths of the artifacts the last full sync materialized in the
59
+ * version home (writer-reported — see `WriteResult.paths`). `isStale`
60
+ * treats a missing path as stale so a deleted resource is restored by a
61
+ * plain `agents sync`, not only `--force` (#2398). An absent field means a
62
+ * pre-upgrade manifest: it reads as stale once, and the resulting full sync
63
+ * establishes the baseline — same migration precedent as the optional
64
+ * `workflows`/`plugins` maps above. An empty array is a valid baseline
65
+ * ("nothing recorded to verify"), never a migration trigger.
66
+ */
67
+ writtenTargets?: string[];
57
68
  }
@@ -25,7 +25,7 @@ import { supports } from '../../capabilities.js';
25
25
  import { safeJoin } from '../../paths.js';
26
26
  import { markdownToToml } from '../../convert.js';
27
27
  import { commandAppliesTo, parseCommandMetadata } from '../../commands.js';
28
- import { installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from '../../command-skills.js';
28
+ import { commandSkillName, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from '../../command-skills.js';
29
29
  import { installGooseCommandToVersion, listGooseCommandsInVersion, removeGooseCommandFromVersion, } from '../../goose-commands.js';
30
30
  import { resolveCommandSource, trustedSkillRoots } from './sources.js';
31
31
  import { lazyAgentMap } from './lazy-map.js';
@@ -50,6 +50,7 @@ function buildCommandsWriter(agent) {
50
50
  fs.mkdirSync(commandsTarget, { recursive: true });
51
51
  }
52
52
  const synced = [];
53
+ const paths = [];
53
54
  for (const cmd of selection) {
54
55
  const srcFile = resolveCommandSource(cmd);
55
56
  if (!srcFile)
@@ -63,9 +64,13 @@ function buildCommandsWriter(agent) {
63
64
  // which is a deliberate no-op, not a failure — the native file is still written.
64
65
  if (!installed.success)
65
66
  continue;
67
+ if (!installed.skipped) {
68
+ paths.push(safeJoin(path.join(agentDir, 'skills'), commandSkillName(cmd)));
69
+ }
66
70
  }
67
71
  if (supportsCommands && agent === 'goose') {
68
72
  // Goose: recipe YAML + config.yaml slash_commands entry, not a file copy.
73
+ // No artifact path recorded — the recipe layout lives in goose-commands.ts.
69
74
  const installed = installGooseCommandToVersion(versionHome, cmd, srcFile);
70
75
  if (!installed.success)
71
76
  continue;
@@ -73,14 +78,18 @@ function buildCommandsWriter(agent) {
73
78
  else if (supportsCommands && agentConfig.format === 'toml') {
74
79
  const content = fs.readFileSync(srcFile, 'utf-8');
75
80
  const tomlContent = markdownToToml(cmd, content);
76
- fs.writeFileSync(safeJoin(commandsTarget, `${cmd}.toml`), tomlContent);
81
+ const dest = safeJoin(commandsTarget, `${cmd}.toml`);
82
+ fs.writeFileSync(dest, tomlContent);
83
+ paths.push(dest);
77
84
  }
78
85
  else if (supportsCommands) {
79
- fs.copyFileSync(srcFile, safeJoin(commandsTarget, `${cmd}.md`));
86
+ const dest = safeJoin(commandsTarget, `${cmd}.md`);
87
+ fs.copyFileSync(srcFile, dest);
88
+ paths.push(dest);
80
89
  }
81
90
  synced.push(cmd);
82
91
  }
83
- return { synced };
92
+ return { synced, paths };
84
93
  },
85
94
  remove({ versionHome, name }) {
86
95
  const agentConfig = AGENTS[agent];
@@ -75,6 +75,7 @@ function buildHooksWriter(agent) {
75
75
  const hooksTarget = path.join(agentDir, 'hooks');
76
76
  fs.mkdirSync(hooksTarget, { recursive: true });
77
77
  const synced = [];
78
+ const paths = [];
78
79
  for (const hook of selection) {
79
80
  const srcFile = resolveHookSource(hook);
80
81
  if (!srcFile)
@@ -82,6 +83,7 @@ function buildHooksWriter(agent) {
82
83
  const destFile = safeJoin(hooksTarget, hook);
83
84
  if (copyHookSource(srcFile, destFile)) {
84
85
  synced.push(hook);
86
+ paths.push(destFile);
85
87
  }
86
88
  }
87
89
  // Native hook registration in settings.json/hooks.json. Grok is included
@@ -93,7 +95,7 @@ function buildHooksWriter(agent) {
93
95
  if (agent === 'claude' || agent === 'codex' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes' || agent === 'muse') {
94
96
  registerHooksToSettings(agent, versionHome);
95
97
  }
96
- return { synced };
98
+ return { synced, paths };
97
99
  },
98
100
  remove({ versionHome, name }) {
99
101
  // Delete the copied hook artifact from `{agentDir}/hooks/<name>`. Native
@@ -1,10 +1,3 @@
1
- /**
2
- * MCP writer — thin dispatcher into `installMcpServers` from `lib/mcp.ts`.
3
- *
4
- * The per-agent format handling (Claude CLI, Codex TOML, Cursor JSON, etc.)
5
- * lives in lib/mcp.ts; we keep it there to avoid the import cycle with
6
- * `versions.ts`. The writer simply hands the selection to that function.
7
- */
8
1
  import type { AgentId } from '../../types.js';
9
2
  import type { ResourceWriter } from './types.js';
10
3
  export declare const mcpWriters: Partial<Record<AgentId, ResourceWriter<string[]>>>;