@phnx-labs/agents-cli 1.22.42 → 1.22.44

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 (256) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +15 -13
  3. package/dist/bootstrap.js +33 -13
  4. package/dist/cli/command-registry.d.ts +0 -8
  5. package/dist/cli/command-registry.js +1 -25
  6. package/dist/commands/alias.d.ts +7 -4
  7. package/dist/commands/alias.js +33 -8
  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/auth.js +14 -6
  14. package/dist/commands/beta.d.ts +7 -1
  15. package/dist/commands/beta.js +16 -9
  16. package/dist/commands/commands.js +1 -1
  17. package/dist/commands/computer.js +20 -6
  18. package/dist/commands/daemon.js +1 -1
  19. package/dist/commands/doctor.d.ts +4 -2
  20. package/dist/commands/doctor.js +21 -9
  21. package/dist/commands/events.d.ts +2 -2
  22. package/dist/commands/events.js +6 -5
  23. package/dist/commands/exec.d.ts +1 -1
  24. package/dist/commands/exec.js +11 -6
  25. package/dist/commands/factory.d.ts +1 -1
  26. package/dist/commands/feed.js +1 -30
  27. package/dist/commands/fleet-capture.d.ts +1 -1
  28. package/dist/commands/fleet-capture.js +4 -5
  29. package/dist/commands/harness.js +4 -52
  30. package/dist/commands/hooks.js +1 -1
  31. package/dist/commands/insights.d.ts +1 -1
  32. package/dist/commands/insights.js +3 -2
  33. package/dist/commands/menubar.js +58 -1
  34. package/dist/commands/monitors.js +1 -1
  35. package/dist/commands/org.d.ts +5 -0
  36. package/dist/commands/org.js +83 -29
  37. package/dist/commands/output.js +2 -2
  38. package/dist/commands/profiles.js +2 -2
  39. package/dist/commands/prune.js +5 -4
  40. package/dist/commands/routines.js +43 -25
  41. package/dist/commands/routines.test-fixture.d.ts +86 -0
  42. package/dist/commands/routines.test-fixture.js +345 -0
  43. package/dist/commands/sessions-picker.js +124 -122
  44. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  45. package/dist/commands/sessions.test-fixture.js +224 -0
  46. package/dist/commands/setup.js +9 -1
  47. package/dist/commands/share.d.ts +9 -10
  48. package/dist/commands/share.js +68 -50
  49. package/dist/commands/skills.js +1 -1
  50. package/dist/commands/snapshot.d.ts +1 -1
  51. package/dist/commands/snapshot.js +4 -4
  52. package/dist/commands/ssh.js +15 -62
  53. package/dist/commands/status.d.ts +4 -2
  54. package/dist/commands/status.js +10 -7
  55. package/dist/commands/subagents.js +1 -1
  56. package/dist/commands/sync.d.ts +1 -0
  57. package/dist/commands/sync.js +29 -2
  58. package/dist/commands/view.js +11 -2
  59. package/dist/commands/webhook.js +1 -1
  60. package/dist/lib/accounting/rotate.d.ts +5 -3
  61. package/dist/lib/accounting/rotate.js +36 -7
  62. package/dist/lib/accounting/usage.d.ts +18 -4
  63. package/dist/lib/accounting/usage.js +138 -27
  64. package/dist/lib/agent-spec/agents.d.ts +7 -7
  65. package/dist/lib/agent-spec/agents.js +15 -15
  66. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  67. package/dist/lib/analytics/mix-commands.js +14 -40
  68. package/dist/lib/audit/log.d.ts +2 -2
  69. package/dist/lib/audit/log.js +2 -2
  70. package/dist/lib/beta.js +1 -1
  71. package/dist/lib/browser/domain-skills.d.ts +26 -6
  72. package/dist/lib/browser/domain-skills.js +81 -43
  73. package/dist/lib/cloud/rush.d.ts +0 -15
  74. package/dist/lib/cloud/rush.js +0 -35
  75. package/dist/lib/crabbox/runtimes.d.ts +6 -2
  76. package/dist/lib/crabbox/runtimes.js +21 -5
  77. package/dist/lib/daemon/daemon.js +97 -58
  78. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  79. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  80. package/dist/lib/daemon/runner.d.ts +18 -1
  81. package/dist/lib/daemon/runner.js +105 -11
  82. package/dist/lib/daemon-ticks.d.ts +38 -1
  83. package/dist/lib/daemon-ticks.js +52 -5
  84. package/dist/lib/device-config.js +1 -2
  85. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  86. package/dist/lib/devices/discovery-policy.js +3 -3
  87. package/dist/lib/devices/doctor-findings.js +1 -1
  88. package/dist/lib/devices/fleet.d.ts +4 -8
  89. package/dist/lib/devices/fleet.js +3 -15
  90. package/dist/lib/devices/health-report.js +4 -4
  91. package/dist/lib/devices/pool.d.ts +0 -6
  92. package/dist/lib/devices/pool.js +0 -6
  93. package/dist/lib/devices/registry.d.ts +0 -15
  94. package/dist/lib/devices/registry.js +0 -9
  95. package/dist/lib/devices/stats-cache.d.ts +12 -0
  96. package/dist/lib/devices/stats-cache.js +24 -5
  97. package/dist/lib/drift-sync.d.ts +1 -1
  98. package/dist/lib/drift-sync.js +2 -2
  99. package/dist/lib/drift.d.ts +1 -1
  100. package/dist/lib/exec.js +13 -3
  101. package/dist/lib/fleet/capture.d.ts +1 -1
  102. package/dist/lib/fleet/manifest.js +1 -1
  103. package/dist/lib/fleet/remote-login.d.ts +2 -2
  104. package/dist/lib/fleet/remote-login.js +3 -4
  105. package/dist/lib/format.d.ts +9 -0
  106. package/dist/lib/format.js +16 -0
  107. package/dist/lib/github/pr-mergeable.d.ts +49 -0
  108. package/dist/lib/github/pr-mergeable.js +157 -0
  109. package/dist/lib/github/pr-verdict.d.ts +67 -0
  110. package/dist/lib/github/pr-verdict.js +72 -0
  111. package/dist/lib/harness/adapter.d.ts +6 -5
  112. package/dist/lib/hosts/dispatch.d.ts +2 -0
  113. package/dist/lib/hosts/dispatch.js +8 -1
  114. package/dist/lib/hosts/passthrough.d.ts +10 -0
  115. package/dist/lib/hosts/passthrough.js +28 -7
  116. package/dist/lib/hosts/providers/devices.js +1 -13
  117. package/dist/lib/hosts/ready.d.ts +12 -3
  118. package/dist/lib/hosts/ready.js +27 -12
  119. package/dist/lib/hosts/registry.d.ts +4 -5
  120. package/dist/lib/hosts/registry.js +3 -7
  121. package/dist/lib/hosts/session-index.js +1 -1
  122. package/dist/lib/installations/shims.js +1 -1
  123. package/dist/lib/installations/versions.d.ts +40 -0
  124. package/dist/lib/installations/versions.js +78 -4
  125. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  126. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  127. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  128. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  129. package/dist/lib/menubar/install-menubar.js +218 -12
  130. package/dist/lib/monitors/config.d.ts +2 -2
  131. package/dist/lib/monitors/config.js +3 -2
  132. package/dist/lib/origin-machine.d.ts +18 -0
  133. package/dist/lib/origin-machine.js +34 -0
  134. package/dist/lib/perf/db.js +1 -1
  135. package/dist/lib/perf/spool.js +1 -1
  136. package/dist/lib/prix-account.d.ts +2 -1
  137. package/dist/lib/prix-account.js +2 -1
  138. package/dist/lib/profiles.d.ts +7 -0
  139. package/dist/lib/profiles.js +26 -1
  140. package/dist/lib/remote-agents-json.js +1 -7
  141. package/dist/lib/routines-placement.d.ts +1 -1
  142. package/dist/lib/routines-placement.js +1 -1
  143. package/dist/lib/sandbox.d.ts +24 -0
  144. package/dist/lib/sandbox.js +101 -0
  145. package/dist/lib/scheduling/routines.d.ts +18 -9
  146. package/dist/lib/scheduling/routines.js +16 -8
  147. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  149. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  151. package/dist/lib/secrets/agent.js +9 -1
  152. package/dist/lib/secrets/index.js +1 -1
  153. package/dist/lib/secrets/list-filter.js +1 -1
  154. package/dist/lib/service-manifest.d.ts +20 -0
  155. package/dist/lib/service-manifest.js +34 -0
  156. package/dist/lib/session/active.d.ts +4 -16
  157. package/dist/lib/session/active.js +2 -6
  158. package/dist/lib/session/cloud.js +1 -1
  159. package/dist/lib/session/db.d.ts +13 -98
  160. package/dist/lib/session/db.js +5 -12
  161. package/dist/lib/session/discover.d.ts +5 -81
  162. package/dist/lib/session/discover.js +5 -5
  163. package/dist/lib/session/fork.js +1 -1
  164. package/dist/lib/session/live-metadata.js +1 -1
  165. package/dist/lib/session/origin-machine.d.ts +1 -18
  166. package/dist/lib/session/origin-machine.js +4 -34
  167. package/dist/lib/session/relative-time.d.ts +1 -44
  168. package/dist/lib/session/relative-time.js +4 -120
  169. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  170. package/dist/lib/session/remote/remote-bundle.js +61 -0
  171. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  172. package/dist/lib/session/remote/remote-list.js +607 -0
  173. package/dist/lib/session/remote/remote.d.ts +132 -0
  174. package/dist/lib/session/remote/remote.js +314 -0
  175. package/dist/lib/session/remote/watch.d.ts +101 -0
  176. package/dist/lib/session/remote/watch.js +241 -0
  177. package/dist/lib/session/remote-bundle.d.ts +1 -12
  178. package/dist/lib/session/remote-bundle.js +3 -61
  179. package/dist/lib/session/remote-list.d.ts +1 -148
  180. package/dist/lib/session/remote-list.js +3 -607
  181. package/dist/lib/session/remote.d.ts +1 -132
  182. package/dist/lib/session/remote.js +3 -314
  183. package/dist/lib/session/short-id.d.ts +1 -17
  184. package/dist/lib/session/short-id.js +4 -20
  185. package/dist/lib/session/watch.d.ts +1 -101
  186. package/dist/lib/session/watch.js +3 -242
  187. package/dist/lib/session/width.d.ts +1 -29
  188. package/dist/lib/session/width.js +4 -101
  189. package/dist/lib/share/config.d.ts +12 -2
  190. package/dist/lib/share/config.js +47 -11
  191. package/dist/lib/share/delete.js +1 -1
  192. package/dist/lib/share/publish.js +1 -1
  193. package/dist/lib/share/worker-template.js +2 -2
  194. package/dist/lib/smart-launch.d.ts +4 -4
  195. package/dist/lib/smart-launch.js +8 -18
  196. package/dist/lib/snapshot.d.ts +2 -2
  197. package/dist/lib/snapshot.js +1 -1
  198. package/dist/lib/staleness/index.d.ts +3 -1
  199. package/dist/lib/staleness/index.js +21 -1
  200. package/dist/lib/staleness/types.d.ts +11 -0
  201. package/dist/lib/staleness/writers/commands.js +13 -4
  202. package/dist/lib/staleness/writers/hooks.js +3 -1
  203. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  204. package/dist/lib/staleness/writers/mcp.js +16 -1
  205. package/dist/lib/staleness/writers/rules.js +1 -1
  206. package/dist/lib/staleness/writers/skills.js +3 -1
  207. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  208. package/dist/lib/staleness/writers/subagents.js +18 -1
  209. package/dist/lib/staleness/writers/types.d.ts +10 -0
  210. package/dist/lib/startup/command-registry.d.ts +10 -1
  211. package/dist/lib/startup/command-registry.js +26 -7
  212. package/dist/lib/startup/root-command.d.ts +18 -1
  213. package/dist/lib/startup/root-command.js +18 -1
  214. package/dist/lib/state.js +7 -0
  215. package/dist/lib/teams/placement-probe.js +28 -13
  216. package/dist/lib/teams/scheduler.d.ts +3 -1
  217. package/dist/lib/teams/scheduler.js +1 -1
  218. package/dist/lib/text/relative-time.d.ts +44 -0
  219. package/dist/lib/text/relative-time.js +120 -0
  220. package/dist/lib/text/short-id.d.ts +17 -0
  221. package/dist/lib/text/short-id.js +20 -0
  222. package/dist/lib/text/width.d.ts +29 -0
  223. package/dist/lib/text/width.js +101 -0
  224. package/dist/lib/triggers/handlers.d.ts +7 -0
  225. package/dist/lib/triggers/handlers.js +8 -0
  226. package/dist/lib/triggers/webhook.d.ts +1 -0
  227. package/dist/lib/triggers/webhook.js +2 -1
  228. package/dist/lib/types.d.ts +1 -1
  229. package/dist/lib/view-types.d.ts +7 -0
  230. package/package.json +2 -3
  231. package/dist/bin/agents +0 -0
  232. package/dist/commands/serve.d.ts +0 -10
  233. package/dist/commands/serve.js +0 -68
  234. package/dist/commands/tickets.d.ts +0 -2
  235. package/dist/commands/tickets.js +0 -43
  236. package/dist/commands/trends.d.ts +0 -10
  237. package/dist/commands/trends.js +0 -12
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  240. package/dist/lib/observe-aliases.d.ts +0 -30
  241. package/dist/lib/observe-aliases.js +0 -38
  242. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  243. package/dist/lib/serve/control.d.ts +0 -95
  244. package/dist/lib/serve/control.js +0 -260
  245. package/dist/lib/serve/data.d.ts +0 -81
  246. package/dist/lib/serve/data.js +0 -91
  247. package/dist/lib/serve/page.d.ts +0 -7
  248. package/dist/lib/serve/page.js +0 -140
  249. package/dist/lib/serve/server.d.ts +0 -80
  250. package/dist/lib/serve/server.js +0 -145
  251. package/dist/lib/serve/stream.d.ts +0 -43
  252. package/dist/lib/serve/stream.js +0 -116
  253. package/dist/lib/serve/token.d.ts +0 -35
  254. package/dist/lib/serve/token.js +0 -85
  255. package/dist/lib/tickets/list.d.ts +0 -53
  256. package/dist/lib/tickets/list.js +0 -153
@@ -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';
@@ -1,29 +1 @@
1
- /**
2
- * Terminal display-width helpers.
3
- *
4
- * `String.length` is the wrong ruler for a terminal: it over-counts ANSI colour
5
- * escapes (chalk output) and under-counts wide glyphs (CJK, emoji) which occupy
6
- * two cells. The result is the drifting, wrapping session-table line users see
7
- * under tmux and over `--device` SSH. Every renderer that sizes a session-table
8
- * cell measures and truncates through this module so alignment is computed once,
9
- * correctly, from the same source of truth.
10
- */
11
- /** Strip SGR colour + OSC-8 hyperlink escapes so width is measured on visible characters only. */
12
- export declare function stripAnsi(s: string): string;
13
- /** Visible display width of a string, ANSI-aware and wide-char-aware. */
14
- export declare function stringWidth(s: string): number;
15
- /**
16
- * Truncate to a target display width, appending '…' when shortened. Operates on
17
- * the visible (ANSI-stripped) string; callers colour the result afterwards so
18
- * the ellipsis is never inserted mid-escape.
19
- */
20
- export declare function truncateToWidth(s: string, max: number): string;
21
- /** Right-pad with spaces to a target display width. Never truncates. */
22
- export declare function padToWidth(s: string, width: number): string;
23
- /**
24
- * Effective terminal width. Reads `$COLUMNS` first so it survives tmux and
25
- * `--device` SSH (where `process.stdout.columns` is unset or wrong), falls back to
26
- * the TTY's reported width, then to `fallback`. Clamped to a sane band so a
27
- * bogus value can't produce a 0-wide or absurdly long table.
28
- */
29
- export declare function terminalWidth(fallback?: number): number;
1
+ export * from '../text/width.js';
@@ -1,101 +1,4 @@
1
- /**
2
- * Terminal display-width helpers.
3
- *
4
- * `String.length` is the wrong ruler for a terminal: it over-counts ANSI colour
5
- * escapes (chalk output) and under-counts wide glyphs (CJK, emoji) which occupy
6
- * two cells. The result is the drifting, wrapping session-table line users see
7
- * under tmux and over `--device` SSH. Every renderer that sizes a session-table
8
- * cell measures and truncates through this module so alignment is computed once,
9
- * correctly, from the same source of truth.
10
- */
11
- /** SGR colour sequences emitted by chalk (e.g. `\x1b[32m`). */
12
- const SGR_REGEX = /\x1b\[[0-9;]*m/g;
13
- /**
14
- * OSC-8 hyperlink sequences (`\x1b]8;;<url>\x1b\\` … `\x1b]8;;\x1b\\`, or with a
15
- * BEL `\x07` terminator) emitted by `osc8()` in render.ts for clickable
16
- * ticket/PR/path badges. These carry the URL, which is NOT visible — leaving it
17
- * in over-counts width and, worse, lets a truncation land *inside* the escape and
18
- * corrupt it. Stripping both the opener and closer leaves only the visible label
19
- * between them, so width is measured on what the user sees and `truncateToWidth`
20
- * can never cut a hyperlink in half.
21
- */
22
- const OSC8_REGEX = /\x1b\]8;[^;]*;.*?(?:\x1b\\|\x07)/g;
23
- /** Strip SGR colour + OSC-8 hyperlink escapes so width is measured on visible characters only. */
24
- export function stripAnsi(s) {
25
- return s.replace(OSC8_REGEX, '').replace(SGR_REGEX, '');
26
- }
27
- /**
28
- * Display cells for one code point: 0 for zero-width combining/ZWJ/variation
29
- * selectors, 2 for East-Asian-wide and emoji ranges, 1 otherwise. Compact and
30
- * dependency-free — covers the glyphs that actually show up in prompts/titles.
31
- */
32
- function charWidth(cp) {
33
- if (cp === 0)
34
- return 0;
35
- // Zero-width: combining marks, zero-width joiner, variation selectors.
36
- if ((cp >= 0x0300 && cp <= 0x036f) ||
37
- cp === 0x200b || cp === 0x200d ||
38
- (cp >= 0xfe00 && cp <= 0xfe0f))
39
- return 0;
40
- // Wide (2 cells): CJK, Hangul, fullwidth forms, emoji & pictographs.
41
- if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
42
- (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
43
- (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
44
- (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
45
- (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
46
- (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
47
- (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
48
- (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & symbols
49
- (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext-B and beyond
50
- )
51
- return 2;
52
- return 1;
53
- }
54
- /** Visible display width of a string, ANSI-aware and wide-char-aware. */
55
- export function stringWidth(s) {
56
- const plain = stripAnsi(s);
57
- let w = 0;
58
- for (const ch of plain)
59
- w += charWidth(ch.codePointAt(0));
60
- return w;
61
- }
62
- /**
63
- * Truncate to a target display width, appending '…' when shortened. Operates on
64
- * the visible (ANSI-stripped) string; callers colour the result afterwards so
65
- * the ellipsis is never inserted mid-escape.
66
- */
67
- export function truncateToWidth(s, max) {
68
- if (max <= 0)
69
- return '';
70
- const plain = stripAnsi(s);
71
- if (stringWidth(plain) <= max)
72
- return plain;
73
- let w = 0;
74
- let out = '';
75
- for (const ch of plain) {
76
- const cw = charWidth(ch.codePointAt(0));
77
- if (w + cw > max - 1)
78
- break; // reserve one cell for the ellipsis
79
- out += ch;
80
- w += cw;
81
- }
82
- return out + '…';
83
- }
84
- /** Right-pad with spaces to a target display width. Never truncates. */
85
- export function padToWidth(s, width) {
86
- const pad = width - stringWidth(s);
87
- return pad > 0 ? s + ' '.repeat(pad) : s;
88
- }
89
- /**
90
- * Effective terminal width. Reads `$COLUMNS` first so it survives tmux and
91
- * `--device` SSH (where `process.stdout.columns` is unset or wrong), falls back to
92
- * the TTY's reported width, then to `fallback`. Clamped to a sane band so a
93
- * bogus value can't produce a 0-wide or absurdly long table.
94
- */
95
- export function terminalWidth(fallback = 100) {
96
- const env = Number.parseInt(process.env.COLUMNS ?? '', 10);
97
- const raw = Number.isFinite(env) && env > 0
98
- ? env
99
- : (process.stdout.columns || fallback);
100
- return Math.max(60, Math.min(200, raw));
101
- }
1
+ // Moved to ../text/width.ts (move 1 of the core-module refactor — see
2
+ // .agents/artifacts/2026-08-20/plan-*.md). Kept as a re-export shim so
3
+ // existing importers don't break.
4
+ export * from '../text/width.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
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * That is N+2 process starts per poll tick. This module gathers the same
10
10
  * shapes in one invocation so poll count drops to 1 without redefining
11
- * `agents status` (which stays the UnifiedSyncStatus sync contract).
11
+ * `agents sync status` (which stays the UnifiedSyncStatus sync contract).
12
12
  *
13
13
  * Stores are not merged — inventory still comes from view, active rows from
14
14
  * sessions, blocks from feed. Only the reader is consolidated.
@@ -63,7 +63,7 @@ export interface FleetSnapshot {
63
63
  agents: FleetAgentCounts;
64
64
  /** Present when --with-feed. */
65
65
  feed?: SnapshotFeedSummary;
66
- /** Present when --with-sync (UnifiedSyncStatus; does not replace `agents status`). */
66
+ /** Present when --with-sync (UnifiedSyncStatus; does not replace `agents sync status`). */
67
67
  sync?: UnifiedSyncStatus;
68
68
  }
69
69
  export interface ComputeSnapshotOptions {
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * That is N+2 process starts per poll tick. This module gathers the same
10
10
  * shapes in one invocation so poll count drops to 1 without redefining
11
- * `agents status` (which stays the UnifiedSyncStatus sync contract).
11
+ * `agents sync status` (which stays the UnifiedSyncStatus sync contract).
12
12
  *
13
13
  * Stores are not merged — inventory still comes from view, active rows from
14
14
  * sessions, blocks from feed. Only the reader is consolidated.
@@ -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