@phnx-labs/agents-cli 1.22.114 → 1.22.116

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 (243) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +210 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +214 -148
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/feed.js +65 -14
  19. package/dist/commands/focus.js +1 -1
  20. package/dist/commands/go.d.ts +0 -17
  21. package/dist/commands/go.js +2 -19
  22. package/dist/commands/logs.js +1 -1
  23. package/dist/commands/mcp.js +8 -83
  24. package/dist/commands/memory.js +4 -47
  25. package/dist/commands/message.js +4 -4
  26. package/dist/commands/plugins.js +1 -93
  27. package/dist/commands/repo.js +0 -44
  28. package/dist/commands/resume.js +4 -1
  29. package/dist/commands/secrets-passthrough.js +2 -2
  30. package/dist/commands/send.d.ts +4 -4
  31. package/dist/commands/send.js +6 -51
  32. package/dist/commands/sessions-backup-setup.js +1 -1
  33. package/dist/commands/sessions-picker.d.ts +11 -0
  34. package/dist/commands/sessions-picker.js +88 -7
  35. package/dist/commands/sessions-resume.js +0 -1
  36. package/dist/commands/sessions-share.d.ts +5 -7
  37. package/dist/commands/sessions-share.js +98 -49
  38. package/dist/commands/sessions.d.ts +21 -2
  39. package/dist/commands/sessions.js +158 -11
  40. package/dist/commands/setup-browser.js +18 -2
  41. package/dist/commands/setup-computer.js +20 -6
  42. package/dist/commands/setup-secrets.d.ts +2 -2
  43. package/dist/commands/setup-secrets.js +24 -4
  44. package/dist/commands/setup-term.d.ts +24 -0
  45. package/dist/commands/setup-term.js +70 -0
  46. package/dist/commands/setup-terminal.d.ts +3 -0
  47. package/dist/commands/setup-terminal.js +22 -0
  48. package/dist/commands/setup.d.ts +1 -1
  49. package/dist/commands/setup.js +29 -11
  50. package/dist/commands/skills.js +0 -8
  51. package/dist/commands/ssh.d.ts +6 -0
  52. package/dist/commands/ssh.js +93 -302
  53. package/dist/commands/sync.js +14 -5
  54. package/dist/commands/teams.js +1 -1
  55. package/dist/commands/traces.js +1 -1
  56. package/dist/lib/accounting/rotate.d.ts +63 -1
  57. package/dist/lib/accounting/rotate.js +56 -0
  58. package/dist/lib/accounts/add.d.ts +0 -5
  59. package/dist/lib/accounts/add.js +3 -9
  60. package/dist/lib/accounts/slots.js +32 -2
  61. package/dist/lib/answer-router.d.ts +11 -2
  62. package/dist/lib/answer-router.js +26 -2
  63. package/dist/lib/artifacts-client.d.ts +20 -0
  64. package/dist/lib/artifacts-client.js +46 -0
  65. package/dist/lib/auth-mint.d.ts +5 -4
  66. package/dist/lib/auth-mint.js +6 -5
  67. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  68. package/dist/lib/browser/runtime-state.d.ts +55 -0
  69. package/dist/lib/browser/runtime-state.js +99 -18
  70. package/dist/lib/browser/service.d.ts +10 -0
  71. package/dist/lib/browser/service.js +229 -37
  72. package/dist/lib/browser/types.d.ts +18 -0
  73. package/dist/lib/cli-resources.js +3 -1
  74. package/dist/lib/cloud/dispatch.js +1 -1
  75. package/dist/lib/cloudflare/creds.d.ts +10 -0
  76. package/dist/lib/cloudflare/creds.js +46 -0
  77. package/dist/lib/cloudflare/provision.d.ts +35 -0
  78. package/dist/lib/cloudflare/provision.js +144 -0
  79. package/dist/lib/computer/sessions-list.d.ts +55 -0
  80. package/dist/lib/computer/sessions-list.js +168 -1
  81. package/dist/lib/config-keys.d.ts +1 -1
  82. package/dist/lib/config-keys.js +5 -0
  83. package/dist/lib/daemon/daemon.js +8 -1
  84. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  85. package/dist/lib/daemon/feed-stream-service.js +40 -0
  86. package/dist/lib/daemon-services.d.ts +1 -1
  87. package/dist/lib/daemon-services.js +5 -0
  88. package/dist/lib/device-config.js +61 -0
  89. package/dist/lib/devices/connect.d.ts +49 -5
  90. package/dist/lib/devices/connect.js +169 -21
  91. package/dist/lib/devices/doctor-findings.js +2 -6
  92. package/dist/lib/feed/answer.d.ts +153 -4
  93. package/dist/lib/feed/answer.js +716 -105
  94. package/dist/lib/feed/envelope.d.ts +79 -0
  95. package/dist/lib/feed/envelope.js +23 -0
  96. package/dist/lib/feed/events.d.ts +6 -0
  97. package/dist/lib/feed/events.js +8 -0
  98. package/dist/lib/feed/feed.d.ts +61 -1
  99. package/dist/lib/feed/feed.js +226 -14
  100. package/dist/lib/feed/hub-server.d.ts +141 -0
  101. package/dist/lib/feed/hub-server.js +586 -0
  102. package/dist/lib/feed/hub.d.ts +95 -0
  103. package/dist/lib/feed/hub.js +255 -0
  104. package/dist/lib/feed/pr-status.d.ts +8 -0
  105. package/dist/lib/feed/pr-status.js +9 -1
  106. package/dist/lib/feed/tool-activity.d.ts +108 -0
  107. package/dist/lib/feed/tool-activity.js +313 -0
  108. package/dist/lib/feed/tools.d.ts +198 -0
  109. package/dist/lib/feed/tools.js +265 -0
  110. package/dist/lib/feed/watch.d.ts +50 -50
  111. package/dist/lib/feed/watch.js +147 -16
  112. package/dist/lib/feed-outcome.d.ts +1 -1
  113. package/dist/lib/feed-outcome.js +9 -2
  114. package/dist/lib/feed-policy.js +9 -3
  115. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  116. package/dist/lib/fleet/auth-sync.js +2 -89
  117. package/dist/lib/format.d.ts +1 -1
  118. package/dist/lib/format.js +1 -1
  119. package/dist/lib/git.d.ts +0 -16
  120. package/dist/lib/git.js +0 -58
  121. package/dist/lib/harness-auth-capabilities.js +7 -2
  122. package/dist/lib/helper-versions.js +1 -1
  123. package/dist/lib/hosts/dispatch.d.ts +20 -1
  124. package/dist/lib/hosts/dispatch.js +52 -30
  125. package/dist/lib/hosts/remote-cmd.d.ts +72 -1
  126. package/dist/lib/hosts/remote-cmd.js +151 -10
  127. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  128. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  129. package/dist/lib/mailbox.d.ts +12 -0
  130. package/dist/lib/mailbox.js +16 -2
  131. package/dist/lib/mcp.js +17 -11
  132. package/dist/lib/menubar/snapshot.d.ts +51 -0
  133. package/dist/lib/menubar/snapshot.js +42 -3
  134. package/dist/lib/open-url.js +2 -2
  135. package/dist/lib/probe.d.ts +4 -1
  136. package/dist/lib/probe.js +5 -2
  137. package/dist/lib/projects.d.ts +23 -0
  138. package/dist/lib/projects.js +78 -0
  139. package/dist/lib/pwsh.d.ts +33 -0
  140. package/dist/lib/pwsh.js +56 -0
  141. package/dist/lib/redact.d.ts +8 -0
  142. package/dist/lib/redact.js +11 -0
  143. package/dist/lib/refresh.d.ts +6 -2
  144. package/dist/lib/refresh.js +92 -72
  145. package/dist/lib/secrets-cli.d.ts +11 -0
  146. package/dist/lib/secrets-cli.js +30 -0
  147. package/dist/lib/secrets-client.js +3 -2
  148. package/dist/lib/session/active.d.ts +1 -0
  149. package/dist/lib/session/active.js +8 -0
  150. package/dist/lib/session/db.d.ts +67 -3
  151. package/dist/lib/session/db.js +381 -126
  152. package/dist/lib/session/detached.d.ts +7 -0
  153. package/dist/lib/session/detached.js +29 -0
  154. package/dist/lib/session/prompt.d.ts +23 -7
  155. package/dist/lib/session/prompt.js +46 -8
  156. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  157. package/dist/lib/session/remote/peer-stream.js +33 -6
  158. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  159. package/dist/lib/session/remote/remote-list.js +22 -6
  160. package/dist/lib/session/remote/watch.d.ts +12 -0
  161. package/dist/lib/session/remote/watch.js +9 -0
  162. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  163. package/dist/lib/session/remote-preview-cache.js +373 -0
  164. package/dist/lib/session/sync/backend.d.ts +3 -3
  165. package/dist/lib/session/sync/backend.js +3 -3
  166. package/dist/lib/session/sync/provision.d.ts +1 -1
  167. package/dist/lib/session/sync/provision.js +2 -2
  168. package/dist/lib/session/tail.d.ts +50 -0
  169. package/dist/lib/session/tail.js +219 -0
  170. package/dist/lib/sessions-client.js +0 -3
  171. package/dist/lib/setup-tool-install.d.ts +3 -0
  172. package/dist/lib/setup-tool-install.js +27 -0
  173. package/dist/lib/setup-tool-status.d.ts +22 -0
  174. package/dist/lib/setup-tool-status.js +220 -0
  175. package/dist/lib/share-runtime.d.ts +11 -0
  176. package/dist/lib/share-runtime.js +63 -0
  177. package/dist/lib/signin-badge.d.ts +19 -4
  178. package/dist/lib/signin-badge.js +29 -11
  179. package/dist/lib/smart-launch.d.ts +1 -5
  180. package/dist/lib/smart-launch.js +3 -11
  181. package/dist/lib/ssh-exec.d.ts +44 -0
  182. package/dist/lib/ssh-exec.js +119 -0
  183. package/dist/lib/startup/command-registry.d.ts +6 -4
  184. package/dist/lib/startup/command-registry.js +10 -7
  185. package/dist/lib/state.js +2 -2
  186. package/dist/lib/storage/selection.d.ts +2 -2
  187. package/dist/lib/storage/selection.js +2 -2
  188. package/dist/lib/sync-umbrella.d.ts +5 -0
  189. package/dist/lib/sync-umbrella.js +18 -10
  190. package/dist/lib/term-driver.d.ts +24 -0
  191. package/dist/lib/term-driver.js +36 -0
  192. package/dist/lib/terminal/index.d.ts +1 -1
  193. package/dist/lib/terminal/index.js +1 -1
  194. package/dist/lib/terminal/inject.d.ts +38 -0
  195. package/dist/lib/terminal/inject.js +55 -9
  196. package/dist/lib/terminal/transport.d.ts +15 -5
  197. package/dist/lib/terminal/transport.js +61 -11
  198. package/dist/lib/traces/backend.d.ts +1 -2
  199. package/dist/lib/traces/backend.js +1 -2
  200. package/dist/lib/traces/provision.d.ts +1 -1
  201. package/dist/lib/traces/provision.js +2 -2
  202. package/dist/lib/types.d.ts +8 -6
  203. package/package.json +2 -3
  204. package/dist/commands/artifacts-setup.d.ts +0 -53
  205. package/dist/commands/artifacts-setup.js +0 -161
  206. package/dist/commands/artifacts.d.ts +0 -18
  207. package/dist/commands/artifacts.js +0 -58
  208. package/dist/commands/attach.d.ts +0 -12
  209. package/dist/commands/attach.js +0 -86
  210. package/dist/commands/auth-mint.d.ts +0 -12
  211. package/dist/commands/auth-mint.js +0 -108
  212. package/dist/commands/reconnect.d.ts +0 -46
  213. package/dist/commands/reconnect.js +0 -115
  214. package/dist/commands/share.d.ts +0 -293
  215. package/dist/commands/share.js +0 -1424
  216. package/dist/lib/fleet/remote-login.d.ts +0 -170
  217. package/dist/lib/fleet/remote-login.js +0 -568
  218. package/dist/lib/share/analytics.d.ts +0 -13
  219. package/dist/lib/share/analytics.js +0 -45
  220. package/dist/lib/share/backend.d.ts +0 -120
  221. package/dist/lib/share/backend.js +0 -176
  222. package/dist/lib/share/capture.d.ts +0 -31
  223. package/dist/lib/share/capture.js +0 -174
  224. package/dist/lib/share/config.d.ts +0 -72
  225. package/dist/lib/share/config.js +0 -211
  226. package/dist/lib/share/delete.d.ts +0 -123
  227. package/dist/lib/share/delete.js +0 -173
  228. package/dist/lib/share/html.d.ts +0 -20
  229. package/dist/lib/share/html.js +0 -88
  230. package/dist/lib/share/http-error.d.ts +0 -53
  231. package/dist/lib/share/http-error.js +0 -65
  232. package/dist/lib/share/og.d.ts +0 -26
  233. package/dist/lib/share/og.js +0 -84
  234. package/dist/lib/share/provision.d.ts +0 -127
  235. package/dist/lib/share/provision.js +0 -285
  236. package/dist/lib/share/publish.d.ts +0 -379
  237. package/dist/lib/share/publish.js +0 -818
  238. package/dist/lib/share/worker-template.d.ts +0 -27
  239. package/dist/lib/share/worker-template.js +0 -2424
  240. package/dist/lib/storage/index.d.ts +0 -14
  241. package/dist/lib/storage/index.js +0 -14
  242. package/dist/lib/storage/visibility.d.ts +0 -82
  243. package/dist/lib/storage/visibility.js +0 -99
@@ -0,0 +1,586 @@
1
+ /**
2
+ * The shared feed collector, exposed to other processes.
3
+ *
4
+ * {@link FeedHub} already collapses N readers in ONE process to one fleet
5
+ * fan-out. The readers that matter are in DIFFERENT processes — the extension's
6
+ * leader child, the menu-bar helper, an operator's `agents feed watch --json` —
7
+ * so the hub has to be reachable across the process boundary or each of them
8
+ * opens its own ssh-per-peer fan-out anyway.
9
+ *
10
+ * This is that boundary, and it is deliberately the thinnest possible one: a
11
+ * UNIX socket (named pipe on Windows) that writes the SAME NDJSON envelopes
12
+ * `agents feed watch --json` has always written, one per line. A client is a
13
+ * line splitter over the socket; there is no request/response protocol, no framing
14
+ * of its own, and no second schema to keep in sync.
15
+ *
16
+ * The daemon owns the server (`FeedStreamService`), which is what makes it one
17
+ * scheduler and one executor: the hub dials peers, the clients only render. A
18
+ * client MUST NOT fall back to running its own fan-out when the socket is
19
+ * absent — that is the double-connection bug this module exists to remove — so
20
+ * {@link streamFeedFromHub} fails loud and the caller starts the daemon.
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as net from 'node:net';
24
+ import * as path from 'node:path';
25
+ import { getHelpersDir } from '../state.js';
26
+ import { ipcEndpoint } from '../platform/ipc.js';
27
+ const IS_WINDOWS = process.platform === 'win32';
28
+ const SOCKET_NAME = 'feed-stream.sock';
29
+ /**
30
+ * Live bytes a reader may leave queued, sustained past {@link HUB_BACKLOG_GRACE_MS},
31
+ * before it is dropped.
32
+ *
33
+ * `socket.write()` never blocks: when a reader stops draining — a stopped
34
+ * process, a suspended laptop, a debugger paused on a breakpoint — node buffers
35
+ * the backlog in the DAEMON's heap, without limit. A busy fleet stream is a few
36
+ * KB per second, so a reader wedged for an hour is tens of megabytes the daemon
37
+ * can never reclaim, and the daemon is the process every other surface depends
38
+ * on. A reader that cannot keep up is dropped loudly instead: it can reconnect
39
+ * and be caught up from held state, which is cheaper than the backlog.
40
+ *
41
+ * The budget is a SUSTAINED condition on queued live events, never a verdict on
42
+ * one envelope or one burst. A cold collector delivers every peer's reset as a
43
+ * live event, thirteen of them inside one tick, and a healthy reader drains
44
+ * that in milliseconds; judging the budget the instant an envelope was written
45
+ * is how a 5 MB fleet reset was cut off after 8 KiB and delivered as one
46
+ * unterminated line (the Menu activation failure this module's writer fixes).
47
+ * The catch-up snapshot is never counted: it is bounded by the held state and is
48
+ * exactly what a fresh reader is waiting for.
49
+ */
50
+ export const HUB_CLIENT_BACKLOG_LIMIT = 4 * 1024 * 1024;
51
+ /**
52
+ * How long a reader may stay past {@link HUB_CLIENT_BACKLOG_LIMIT} before it is
53
+ * dropped. A healthy reader on a unix socket clears the whole budget in well
54
+ * under this; one still over it after this long is not keeping up. The live
55
+ * bytes queued for a reader are therefore bounded by the budget plus what the
56
+ * stream produces in this window; the snapshot and the frame in flight sit
57
+ * outside that figure.
58
+ */
59
+ export const HUB_BACKLOG_GRACE_MS = 2_000;
60
+ /**
61
+ * How long a reader may leave one chunk unaccepted before it is dropped.
62
+ *
63
+ * A write that returned `false` is a kernel buffer full of bytes the reader has
64
+ * not read. A healthy reader — even one on a busy laptop — clears it in
65
+ * milliseconds; one that has not in this long is not reading at all, and the
66
+ * live-bytes budget alone would let a paused reader hold a large snapshot's
67
+ * remainder in the daemon's heap forever.
68
+ */
69
+ export const HUB_DRAIN_STALL_MS = 30_000;
70
+ /**
71
+ * Bytes handed to the socket per write. Small enough that a reader's own
72
+ * backpressure (`write()` returning `false`, then `'drain'`) paces a multi-MB
73
+ * snapshot instead of dumping it into the daemon's heap in one copy.
74
+ */
75
+ export const HUB_WRITE_CHUNK_BYTES = 64 * 1024;
76
+ /**
77
+ * How long a reader gets to send its scope line before it is REJECTED.
78
+ *
79
+ * The handshake is required, not defaulted. Silently treating a missing or
80
+ * unparseable scope as `fleet` meant a reader that sent nothing — or sent
81
+ * garbage, or sent its line after the grace elapsed — was quietly subscribed to
82
+ * the whole-fleet collector: it started ssh children to every peer on behalf of a
83
+ * client that never asked for them, and delivered peer data to a client that may
84
+ * have wanted only this box. A boundary that guesses is worse than one that
85
+ * refuses, so an unusable handshake is reported and the connection ends.
86
+ */
87
+ export const HUB_HANDSHAKE_GRACE_MS = 2_000;
88
+ /** Bytes of handshake accepted before the reader is rejected outright. */
89
+ const HUB_HANDSHAKE_MAX_BYTES = 1024;
90
+ /** The scopes a reader may ask for. */
91
+ const HUB_SCOPES = new Set(['fleet', 'local']);
92
+ /** The canonical socket path (POSIX) / pipe-name key (Windows). */
93
+ export function feedHubSocketPath() {
94
+ return path.join(getHelpersDir(), 'feed', SOCKET_NAME);
95
+ }
96
+ /** The address a server listens on and a client connects to. */
97
+ export function feedHubEndpoint(socketPath = feedHubSocketPath()) {
98
+ return ipcEndpoint(socketPath);
99
+ }
100
+ /**
101
+ * One reader's ordered outbound queue.
102
+ *
103
+ * Every line to one socket goes through this — snapshot, live events, and the
104
+ * error envelope that precedes a refusal — so nothing can land inside another
105
+ * line. Lines are written in {@link HUB_WRITE_CHUNK_BYTES} chunks and the pump
106
+ * waits for `'drain'` after every write the socket did not accept, so the socket
107
+ * never holds more than one chunk past its high-water mark. The frame's own
108
+ * Buffer stays allocated until its last chunk is written (`subarray` is a view),
109
+ * so `pendingBytes` is a gauge of UNFLUSHED bytes, not of retained heap. Two
110
+ * bounds drop the reader: a chunk left unaccepted for `drainStallMs`, and live
111
+ * bytes queued past `backlogBytes` for longer than `backlogGraceMs`. The bound
112
+ * on what a reader can make the daemon hold is therefore time- and
113
+ * rate-dependent — the budget, plus the stream's ingress during the grace,
114
+ * plus the snapshot and the frame in flight — not a hard allocation cap.
115
+ */
116
+ class ReaderWriter {
117
+ socket;
118
+ limits;
119
+ drop;
120
+ /** Lines not yet handed to the socket; `live` marks the ones the budget counts. */
121
+ queue = [];
122
+ /** Bytes queued since the snapshot finished enqueuing; the budgeted part. */
123
+ liveBytes = 0;
124
+ live = false;
125
+ running = false;
126
+ closeAfterFlush = false;
127
+ /** Armed while the live queue is over budget; fires the drop if it still is. */
128
+ overBudget = null;
129
+ /** Bytes of the line being pumped that have not yet been handed to the socket. */
130
+ activeRemaining = 0;
131
+ constructor(socket, limits, drop) {
132
+ this.socket = socket;
133
+ this.limits = limits;
134
+ this.drop = drop;
135
+ }
136
+ /**
137
+ * Unflushed bytes for this reader: queued lines, the unwritten rest of the
138
+ * line being pumped, and the socket's own buffer. A gauge of what is still
139
+ * owed to the socket, not of retained heap. A 5 MiB frame to a paused reader
140
+ * leaves the queue on its first chunk, so a queue-only gauge read 0 while
141
+ * almost all of it was still unflushed.
142
+ */
143
+ get pendingBytes() {
144
+ return this.queue.reduce((sum, line) => sum + line.bytes.length, 0) + this.activeRemaining + this.socket.writableLength;
145
+ }
146
+ /** Everything enqueued from now on is a live event and counts against the budget. */
147
+ startLive() { this.live = true; }
148
+ write(line) {
149
+ if (this.socket.destroyed)
150
+ return;
151
+ const bytes = Buffer.from(`${line}\n`, 'utf-8');
152
+ this.queue.push({ bytes, live: this.live });
153
+ if (this.live) {
154
+ this.liveBytes += bytes.length;
155
+ this.judgeBacklog();
156
+ }
157
+ void this.pump();
158
+ }
159
+ /** FIN once everything queued has been handed to the socket. */
160
+ end() {
161
+ this.closeAfterFlush = true;
162
+ void this.pump();
163
+ }
164
+ /** Release the timers; the socket is gone. */
165
+ dispose() {
166
+ if (this.overBudget) {
167
+ clearTimeout(this.overBudget);
168
+ this.overBudget = null;
169
+ }
170
+ this.queue.length = 0;
171
+ this.liveBytes = 0;
172
+ this.activeRemaining = 0;
173
+ }
174
+ judgeBacklog() {
175
+ if (this.liveBytes <= this.limits.backlogBytes) {
176
+ if (this.overBudget) {
177
+ clearTimeout(this.overBudget);
178
+ this.overBudget = null;
179
+ }
180
+ return;
181
+ }
182
+ if (this.overBudget)
183
+ return;
184
+ this.overBudget = setTimeout(() => {
185
+ this.overBudget = null;
186
+ if (this.liveBytes > this.limits.backlogBytes) {
187
+ this.drop('backlog', `feed reader dropped: ${this.liveBytes} live bytes queued exceeded the ${this.limits.backlogBytes}-byte budget for ${this.limits.backlogGraceMs}ms`);
188
+ }
189
+ }, this.limits.backlogGraceMs);
190
+ this.overBudget.unref();
191
+ }
192
+ async pump() {
193
+ if (this.running)
194
+ return;
195
+ this.running = true;
196
+ try {
197
+ while (this.queue.length > 0 && !this.socket.destroyed) {
198
+ const { bytes, live } = this.queue.shift();
199
+ if (live) {
200
+ this.liveBytes -= bytes.length;
201
+ this.judgeBacklog();
202
+ }
203
+ for (let offset = 0; offset < bytes.length && !this.socket.destroyed; offset += this.limits.chunkBytes) {
204
+ const end = Math.min(offset + this.limits.chunkBytes, bytes.length);
205
+ this.activeRemaining = bytes.length - end;
206
+ const accepted = this.socket.write(bytes.subarray(offset, end));
207
+ if (!accepted && !await this.drained())
208
+ return;
209
+ }
210
+ this.activeRemaining = 0;
211
+ }
212
+ if (this.closeAfterFlush && !this.socket.destroyed)
213
+ this.socket.end();
214
+ }
215
+ finally {
216
+ this.running = false;
217
+ }
218
+ }
219
+ /** Resolve true on `'drain'`; false when the socket closed or the stall deadline passed. */
220
+ drained() {
221
+ return new Promise((resolve) => {
222
+ const settle = (ok) => {
223
+ clearTimeout(stall);
224
+ this.socket.off('drain', onDrain);
225
+ this.socket.off('close', onClose);
226
+ resolve(ok);
227
+ };
228
+ const onDrain = () => settle(true);
229
+ const onClose = () => settle(false);
230
+ const stall = setTimeout(() => {
231
+ this.drop('stall', `feed reader dropped: no drain within ${this.limits.drainStallMs}ms with ${this.socket.writableLength} bytes unaccepted`);
232
+ settle(false);
233
+ }, this.limits.drainStallMs);
234
+ stall.unref();
235
+ this.socket.once('drain', onDrain);
236
+ this.socket.once('close', onClose);
237
+ });
238
+ }
239
+ }
240
+ /**
241
+ * Serve one {@link FeedHub} to other processes. Each accepted connection is one
242
+ * subscriber; closing it detaches, and the last detach stops the fan-out.
243
+ */
244
+ export class FeedHubServer {
245
+ hub;
246
+ socketPathOverride;
247
+ localHub;
248
+ server = null;
249
+ detachers = new Map();
250
+ /** Which collector each reader is attached to, so a failure reaches only its own. */
251
+ attachedTo = new Map();
252
+ writers = new Map();
253
+ limits;
254
+ /** Readers dropped for queueing live bytes past the budget. Observability. */
255
+ droppedForBacklog = 0;
256
+ /** Readers dropped for not draining a chunk within the stall deadline. Observability. */
257
+ droppedForStall = 0;
258
+ /** Readers refused for a missing, invalid, or late scope line. Observability. */
259
+ rejectedHandshakes = 0;
260
+ /**
261
+ * @param hub the FLEET collector (every reachable peer plus this box).
262
+ * @param localHub the LOCAL-only collector, served to a reader that asks for
263
+ * `scope: 'local'`. Optional: a server without one answers
264
+ * every reader from the fleet hub, which is what the fleet
265
+ * stream already contained.
266
+ */
267
+ constructor(hub, socketPathOverride, localHub, limits = {}) {
268
+ this.hub = hub;
269
+ this.socketPathOverride = socketPathOverride;
270
+ this.localHub = localHub;
271
+ this.limits = {
272
+ backlogBytes: limits.backlogBytes ?? HUB_CLIENT_BACKLOG_LIMIT,
273
+ backlogGraceMs: limits.backlogGraceMs ?? HUB_BACKLOG_GRACE_MS,
274
+ drainStallMs: limits.drainStallMs ?? HUB_DRAIN_STALL_MS,
275
+ chunkBytes: limits.chunkBytes ?? HUB_WRITE_CHUNK_BYTES,
276
+ };
277
+ // A collector that cannot start is reported to every reader attached to it
278
+ // and the connection is ended, so a consumer sees a failure instead of an
279
+ // indefinitely silent stream it cannot distinguish from an idle fleet.
280
+ for (const collector of [hub, localHub]) {
281
+ if (collector)
282
+ collector.onFailure = (error) => this.failReaders(collector, error);
283
+ }
284
+ }
285
+ /** Report a collector failure to its readers and end those connections. */
286
+ failReaders(collector, error) {
287
+ for (const [socket, attached] of this.attachedTo) {
288
+ if (attached !== collector || socket.destroyed)
289
+ continue;
290
+ const writer = this.writers.get(socket);
291
+ writer.write(JSON.stringify({ v: 1, type: 'error', scope: '', error: error.message }));
292
+ writer.end();
293
+ }
294
+ }
295
+ /** Subscribers currently connected. Observability + tests. */
296
+ get clientCount() { return this.detachers.size; }
297
+ /** Bytes queued for every reader and not yet handed to a socket. Observability + tests. */
298
+ get pendingBytes() {
299
+ let total = 0;
300
+ for (const writer of this.writers.values())
301
+ total += writer.pendingBytes;
302
+ return total;
303
+ }
304
+ async start() {
305
+ const socketPath = this.socketPathOverride ?? feedHubSocketPath();
306
+ const endpoint = this.socketPathOverride ?? feedHubEndpoint();
307
+ const dir = path.dirname(socketPath);
308
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
309
+ if (!IS_WINDOWS) {
310
+ fs.chmodSync(dir, 0o700);
311
+ // A crashed daemon leaves the socket file behind; it accepts nothing, so
312
+ // removing it is the only way to bind. Named pipes vanish with their owner.
313
+ try {
314
+ fs.unlinkSync(socketPath);
315
+ }
316
+ catch { /* nothing stale to remove */ }
317
+ }
318
+ this.server = net.createServer((socket) => {
319
+ // The scope line is REQUIRED and must arrive within the grace window.
320
+ const grace = setTimeout(() => reject(`no scope line within ${HUB_HANDSHAKE_GRACE_MS}ms`), HUB_HANDSHAKE_GRACE_MS);
321
+ grace.unref();
322
+ const end = () => {
323
+ clearTimeout(grace);
324
+ this.detachers.get(socket)?.();
325
+ this.detachers.delete(socket);
326
+ this.attachedTo.delete(socket);
327
+ this.writers.get(socket)?.dispose();
328
+ this.writers.delete(socket);
329
+ };
330
+ socket.on('close', end);
331
+ // A reader that dies mid-write surfaces as an error, not a close, and
332
+ // leaving it subscribed would hold every peer connection open forever.
333
+ socket.on('error', end);
334
+ const writer = new ReaderWriter(socket, this.limits, (reason, message) => {
335
+ if (socket.destroyed)
336
+ return;
337
+ if (reason === 'backlog')
338
+ this.droppedForBacklog += 1;
339
+ else
340
+ this.droppedForStall += 1;
341
+ // `destroy` rather than `end`: a reader this far behind is not going
342
+ // to drain a graceful FIN either, and the point is to release the
343
+ // queued bytes now. 'close' fires and detaches it.
344
+ socket.destroy(new Error(message));
345
+ });
346
+ this.writers.set(socket, writer);
347
+ /** Refuse this reader, telling it why rather than hanging up silently. */
348
+ const reject = (reason) => {
349
+ clearTimeout(grace);
350
+ if (socket.destroyed)
351
+ return;
352
+ this.rejectedHandshakes += 1;
353
+ writer.write(JSON.stringify({ v: 1, type: 'error', scope: '', error: `feed handshake rejected: ${reason}` }));
354
+ writer.end();
355
+ };
356
+ const attach = (hub) => {
357
+ if (socket.destroyed || this.detachers.has(socket))
358
+ return;
359
+ // `subscribe` emits the catch-up snapshot synchronously before it
360
+ // returns, so every line enqueued until then is snapshot and the first
361
+ // live event can only ever queue behind it.
362
+ const detach = hub.subscribe((event) => writer.write(JSON.stringify(event)));
363
+ writer.startLive();
364
+ this.detachers.set(socket, detach);
365
+ this.attachedTo.set(socket, hub);
366
+ // A collector that ALREADY failed must not leave this reader waiting for
367
+ // a stream that is never coming.
368
+ if (hub.lastFailure)
369
+ this.failReaders(hub, hub.lastFailure);
370
+ };
371
+ let handshake = '';
372
+ socket.on('data', (chunk) => {
373
+ if (socket.destroyed)
374
+ return;
375
+ // A line arriving AFTER this reader is attached is a protocol error: the
376
+ // scope is settled and a second one cannot retroactively change it.
377
+ if (this.detachers.has(socket)) {
378
+ reject('scope sent after the stream was already open');
379
+ return;
380
+ }
381
+ handshake += chunk.toString('utf-8');
382
+ const newline = handshake.indexOf('\n');
383
+ if (newline < 0) {
384
+ if (handshake.length > HUB_HANDSHAKE_MAX_BYTES)
385
+ reject('scope line exceeded the handshake budget');
386
+ return;
387
+ }
388
+ clearTimeout(grace);
389
+ let scope;
390
+ try {
391
+ scope = JSON.parse(handshake.slice(0, newline)).scope;
392
+ }
393
+ catch {
394
+ reject('scope line is not valid JSON');
395
+ return;
396
+ }
397
+ if (typeof scope !== 'string' || !HUB_SCOPES.has(scope)) {
398
+ reject(`unknown scope ${JSON.stringify(scope)}; expected "fleet" or "local"`);
399
+ return;
400
+ }
401
+ if (scope === 'local' && !this.localHub) {
402
+ // Serving the fleet collector instead would start peer connections a
403
+ // local-only reader never asked for.
404
+ reject('this server has no local collector');
405
+ return;
406
+ }
407
+ attach(scope === 'local' ? this.localHub : this.hub);
408
+ });
409
+ });
410
+ await new Promise((resolve, reject) => {
411
+ const listener = this.server;
412
+ listener.once('error', reject);
413
+ if (IS_WINDOWS) {
414
+ listener.listen(endpoint, () => resolve());
415
+ return;
416
+ }
417
+ // Restored on EVERY exit path. A listen error (the path is taken, the dir
418
+ // vanished) used to leave the process umask at 0o077 for good, so every
419
+ // later file this process created — a cache write, a journal — silently
420
+ // became owner-only. `once` guards the double-restore when both the
421
+ // success and error paths fire.
422
+ const previousUmask = process.umask(0o077);
423
+ let restored = false;
424
+ const restoreUmask = () => { if (!restored) {
425
+ restored = true;
426
+ process.umask(previousUmask);
427
+ } };
428
+ listener.once('error', restoreUmask);
429
+ listener.listen(socketPath, () => {
430
+ try {
431
+ fs.chmodSync(socketPath, 0o600);
432
+ resolve();
433
+ }
434
+ catch (error) {
435
+ reject(error);
436
+ }
437
+ finally {
438
+ restoreUmask();
439
+ }
440
+ });
441
+ });
442
+ }
443
+ async stop() {
444
+ for (const detach of this.detachers.values())
445
+ detach();
446
+ // Every connection, including one still in its handshake: `server.close`
447
+ // waits for open sockets, and a reader that never sent its scope line
448
+ // would otherwise hold the daemon's shutdown for the whole grace window.
449
+ for (const [socket, writer] of this.writers) {
450
+ writer.dispose();
451
+ socket.destroy();
452
+ }
453
+ this.detachers.clear();
454
+ this.attachedTo.clear();
455
+ this.writers.clear();
456
+ const server = this.server;
457
+ this.server = null;
458
+ if (server)
459
+ await new Promise((resolve) => server.close(() => resolve()));
460
+ await this.hub.close();
461
+ await this.localHub?.close();
462
+ }
463
+ }
464
+ /**
465
+ * Wait until the hub accepts a connection, or the deadline passes.
466
+ *
467
+ * `ensureDaemonStarted()` returns as soon as the daemon PROCESS is spawned, which
468
+ * is well before that process has loaded its services and bound this socket.
469
+ * Retrying immediately therefore raced the bind and failed on a daemon that was
470
+ * about to be perfectly healthy — reported to the operator as "the shared feed
471
+ * stream is unavailable". Resolves true once a connect succeeds.
472
+ */
473
+ export async function waitForHub(endpoint = feedHubEndpoint(), deadlineMs = 10_000, intervalMs = 100) {
474
+ const deadline = Date.now() + deadlineMs;
475
+ for (;;) {
476
+ const reachable = await new Promise((resolve) => {
477
+ const probe = net.createConnection(endpoint);
478
+ const settle = (ok) => { probe.destroy(); resolve(ok); };
479
+ probe.once('connect', () => settle(true));
480
+ probe.once('error', () => settle(false));
481
+ });
482
+ if (reachable)
483
+ return true;
484
+ if (Date.now() >= deadline)
485
+ return false;
486
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
487
+ }
488
+ }
489
+ /**
490
+ * Read the shared stream from the hub until `signal` aborts.
491
+ *
492
+ * Rejects when the hub is not reachable. That is deliberate: a client that
493
+ * quietly ran its own `watchFleetFeed` instead would restore the per-caller
494
+ * ssh fan-out, so the caller starts the daemon and retries rather than
495
+ * degrading into the thing this replaced.
496
+ *
497
+ * Also rejects on any close the caller did not ask for. The stream has no end
498
+ * of its own — the hub serves it until the reader leaves — so a FIN that
499
+ * arrives before `signal` aborts is the hub refusing, failing, or dropping this
500
+ * reader, and a FIN inside a line is a frame the hub never finished. Resolving
501
+ * there let `agents feed watch --json` exit 0 after 8 KiB of a 5 MB reset,
502
+ * which is indistinguishable from an empty fleet.
503
+ */
504
+ export function streamFeedFromHub(options) {
505
+ return new Promise((resolve, reject) => {
506
+ const socket = net.createConnection(options.endpoint ?? feedHubEndpoint());
507
+ let aborted = false;
508
+ let failure;
509
+ const fail = (error) => { failure ??= error; socket.destroy(); };
510
+ // Registered before anything else touches the socket: a connect failure on
511
+ // a missing socket path is emitted as an 'error' with no listener attached
512
+ // yet, which node raises as an uncaught exception rather than rejecting.
513
+ socket.on('error', (error) => { failure ??= error; });
514
+ const stop = () => { aborted = true; socket.destroy(); };
515
+ options.signal.addEventListener('abort', stop, { once: true });
516
+ if (options.signal.aborted)
517
+ stop();
518
+ // Pieces of the line in progress; only the newest chunk is searched for a
519
+ // newline, so an 8 MB reset arriving in 64 KiB chunks is not rescanned from
520
+ // its start on every chunk.
521
+ const partial = [];
522
+ let partialBytes = 0;
523
+ socket.setEncoding('utf-8');
524
+ socket.on('data', (chunk) => {
525
+ let rest = chunk;
526
+ for (;;) {
527
+ const newline = rest.indexOf('\n');
528
+ if (newline < 0) {
529
+ partial.push(rest);
530
+ partialBytes += rest.length;
531
+ return;
532
+ }
533
+ partial.push(rest.slice(0, newline));
534
+ const line = partial.join('');
535
+ partial.length = 0;
536
+ partialBytes = 0;
537
+ rest = rest.slice(newline + 1);
538
+ if (!line)
539
+ continue;
540
+ let event;
541
+ try {
542
+ event = JSON.parse(line);
543
+ }
544
+ catch {
545
+ fail(new Error(`feed hub sent a line that is not JSON (${line.length} chars)`));
546
+ return;
547
+ }
548
+ if (typeof event !== 'object' || event === null) {
549
+ fail(new Error(`feed hub sent a line that is not an envelope: ${line.slice(0, 80)}`));
550
+ return;
551
+ }
552
+ // Protocol only: an unversioned line is not a feed envelope.
553
+ if (event.v !== 1)
554
+ continue;
555
+ // A consumer that throws ends the read as a failure of THIS promise; left
556
+ // to escape the socket's 'data' handler it is an uncaught exception.
557
+ try {
558
+ options.emit(event);
559
+ }
560
+ catch (error) {
561
+ fail(error instanceof Error ? error : new Error(String(error)));
562
+ return;
563
+ }
564
+ }
565
+ });
566
+ socket.on('connect', () => {
567
+ socket.write(`${JSON.stringify({ v: 1, scope: options.scope ?? 'fleet' })}\n`);
568
+ });
569
+ socket.on('close', () => {
570
+ options.signal.removeEventListener('abort', stop);
571
+ if (aborted) {
572
+ resolve();
573
+ return;
574
+ }
575
+ if (failure) {
576
+ reject(failure);
577
+ return;
578
+ }
579
+ if (partialBytes > 0) {
580
+ reject(new Error(`feed hub closed mid-frame: ${partialBytes} chars of an unterminated line`));
581
+ return;
582
+ }
583
+ reject(new Error('feed hub closed the stream'));
584
+ });
585
+ });
586
+ }
@@ -0,0 +1,95 @@
1
+ import { FeedWatchState, type FeedWatchEnvelope } from './envelope.js';
2
+ /** Activity events replayed to a late subscriber. The lane is a rolling view,
3
+ * so a bounded tail is the honest amount of history to hand over. */
4
+ export declare const HUB_ACTIVITY_REPLAY = 50;
5
+ /**
6
+ * The per-scope row state the hub has observed. This is a projection of the
7
+ * events already delivered, never an independent gather: nothing here reads a
8
+ * file, runs a command, or dials a peer.
9
+ */
10
+ export declare class FeedHubState {
11
+ private readonly scopes;
12
+ private readonly activity;
13
+ private scope;
14
+ apply(event: FeedWatchEnvelope): void;
15
+ /** The envelopes that bring a fresh subscriber to the current state. */
16
+ snapshot(state: FeedWatchState): FeedWatchEnvelope[];
17
+ /** Scopes the hub has seen. Observability + tests. */
18
+ get scopeNames(): string[];
19
+ }
20
+ /** The collector a hub owns: it runs until the signal aborts, emitting envelopes. */
21
+ export type HubFanOut = (options: {
22
+ signal: AbortSignal;
23
+ emit: (event: FeedWatchEnvelope) => void;
24
+ reconnectMs?: number;
25
+ }) => Promise<void>;
26
+ /** Told to every attached reader when the shared fan-out cannot start. */
27
+ export type HubFailureListener = (error: Error) => void;
28
+ interface FeedHubOptions {
29
+ /**
30
+ * The collector to own — the fleet ssh fan-out, or the local watcher. Required
31
+ * rather than defaulted so this module depends on neither, which is what keeps
32
+ * `watch.ts` free to depend on THIS module for its shared local collector.
33
+ */
34
+ watch: HubFanOut;
35
+ /** Forwarded to the fan-out. */
36
+ reconnectMs?: number;
37
+ }
38
+ /**
39
+ * The shared collector. Construct one per process; call {@link subscribe} per
40
+ * reader.
41
+ */
42
+ export declare class FeedHub {
43
+ private readonly options;
44
+ private readonly subscribers;
45
+ private readonly held;
46
+ private controller;
47
+ private running;
48
+ /**
49
+ * Bumped on every start/stop. A fan-out's completion handler only clears state
50
+ * when its own generation is still current, so a run winding down cannot clear
51
+ * a newer one's controller.
52
+ */
53
+ private generation;
54
+ /** The most recent fan-out failure, if the current generation hit one. */
55
+ lastFailure: Error | null;
56
+ /**
57
+ * Called when the fan-out rejects. Without this the failure lived only in a
58
+ * dropped promise, so readers sat attached to a collector that had already died
59
+ * and saw an idle stream instead of an error. Settable so the socket server can
60
+ * attach after construction.
61
+ */
62
+ onFailure: HubFailureListener | null;
63
+ private readonly watch;
64
+ constructor(options: FeedHubOptions);
65
+ /** Readers currently attached. The fan-out runs iff this is > 0. */
66
+ get readerCount(): number;
67
+ /** Is the single shared fan-out running right now? */
68
+ get active(): boolean;
69
+ /** The held per-scope state, for observability and tests. */
70
+ get state(): FeedHubState;
71
+ /**
72
+ * Attach a reader. It is immediately served a snapshot of the held state, then
73
+ * every later event. Returns the detach function; the fan-out stops when the
74
+ * last reader detaches.
75
+ */
76
+ subscribe(emit: (event: FeedWatchEnvelope) => void): () => void;
77
+ /** Stop the fan-out and detach every reader. */
78
+ close(): Promise<void>;
79
+ /** Await the in-flight fan-out's teardown. Tests assert no overlap with it. */
80
+ settled(): Promise<void>;
81
+ /**
82
+ * Start the single fan-out, waiting for any previous one to finish first.
83
+ *
84
+ * The wait is the whole point. `stop()` aborts and returns immediately, but the
85
+ * fan-out it aborted is still tearing down ssh children. A reader that detaches
86
+ * and immediately reattaches — a VS Code window reloading, a menu-bar popover
87
+ * closing and reopening — therefore used to start a SECOND fan-out alongside
88
+ * the dying one: two ssh children per peer, two collectors, for as long as the
89
+ * overlap lasted. Serializing on the previous run makes that impossible.
90
+ */
91
+ private start;
92
+ private stop;
93
+ private broadcast;
94
+ }
95
+ export {};