borgmcp 1.0.6 → 1.0.7

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 (157) hide show
  1. package/dist/assimilate-cmd.js +39 -511
  2. package/dist/assimilate-deps.js +3 -177
  3. package/dist/assimilate-welcome.js +2 -24
  4. package/dist/auth-env.js +1 -107
  5. package/dist/auth.js +23 -612
  6. package/dist/claude.js +11 -281
  7. package/dist/cli-help.js +29 -50
  8. package/dist/cli-platform.js +4 -94
  9. package/dist/codex-app-server.js +4 -228
  10. package/dist/codex-app-wake.js +2 -122
  11. package/dist/codex-launch.js +1 -81
  12. package/dist/codex-remote.js +1 -250
  13. package/dist/config-utils.js +3 -385
  14. package/dist/config.js +1 -190
  15. package/dist/console-prefix.js +1 -86
  16. package/dist/cube-name.js +1 -65
  17. package/dist/cubes.js +4 -269
  18. package/dist/debug.js +1 -71
  19. package/dist/device-auth.js +1 -167
  20. package/dist/direct-log.js +1 -11
  21. package/dist/health-beat.js +1 -168
  22. package/dist/inbox-monitor.js +1 -129
  23. package/dist/index.js +26 -1378
  24. package/dist/lifecycle-log-guard.js +2 -93
  25. package/dist/list-roles-render.js +6 -39
  26. package/dist/log-audit.js +3 -186
  27. package/dist/log-stream.js +9 -848
  28. package/dist/name-validator.js +1 -22
  29. package/dist/parse-assimilate-args.js +1 -82
  30. package/dist/postinstall.js +8 -22
  31. package/dist/regen-format.js +11 -337
  32. package/dist/regen.js +5 -83
  33. package/dist/remote-client.js +1 -695
  34. package/dist/role-resolver.js +1 -36
  35. package/dist/role-section.js +8 -208
  36. package/dist/roster-render.js +3 -96
  37. package/dist/setup.js +36 -251
  38. package/dist/shell-escape.js +1 -22
  39. package/dist/spawn.js +10 -29
  40. package/dist/stale-version-check.js +1 -102
  41. package/dist/stream-owner.js +2 -202
  42. package/dist/stream-status.js +3 -211
  43. package/dist/subscription-retry.js +1 -23
  44. package/dist/sync-roles-render.js +3 -118
  45. package/dist/sync.js +22 -286
  46. package/dist/templates.js +120 -626
  47. package/dist/terminal-title.js +1 -68
  48. package/dist/token-crypto.js +1 -91
  49. package/dist/token-store.js +1 -222
  50. package/dist/types.js +0 -5
  51. package/dist/version.js +2 -78
  52. package/dist/worktree-lifecycle.js +2 -173
  53. package/package.json +11 -2
  54. package/dist/assimilate-cmd.d.ts.map +0 -1
  55. package/dist/assimilate-cmd.js.map +0 -1
  56. package/dist/assimilate-deps.d.ts.map +0 -1
  57. package/dist/assimilate-deps.js.map +0 -1
  58. package/dist/assimilate-welcome.d.ts.map +0 -1
  59. package/dist/assimilate-welcome.js.map +0 -1
  60. package/dist/auth-env.d.ts.map +0 -1
  61. package/dist/auth-env.js.map +0 -1
  62. package/dist/auth.d.ts.map +0 -1
  63. package/dist/auth.js.map +0 -1
  64. package/dist/claude.d.ts.map +0 -1
  65. package/dist/claude.js.map +0 -1
  66. package/dist/cli-help.d.ts.map +0 -1
  67. package/dist/cli-help.js.map +0 -1
  68. package/dist/cli-platform.d.ts.map +0 -1
  69. package/dist/cli-platform.js.map +0 -1
  70. package/dist/codex-app-server.d.ts.map +0 -1
  71. package/dist/codex-app-server.js.map +0 -1
  72. package/dist/codex-app-wake.d.ts.map +0 -1
  73. package/dist/codex-app-wake.js.map +0 -1
  74. package/dist/codex-launch.d.ts.map +0 -1
  75. package/dist/codex-launch.js.map +0 -1
  76. package/dist/codex-remote.d.ts.map +0 -1
  77. package/dist/codex-remote.js.map +0 -1
  78. package/dist/config-utils.d.ts.map +0 -1
  79. package/dist/config-utils.js.map +0 -1
  80. package/dist/config.d.ts.map +0 -1
  81. package/dist/config.js.map +0 -1
  82. package/dist/console-prefix.d.ts.map +0 -1
  83. package/dist/console-prefix.js.map +0 -1
  84. package/dist/cube-name.d.ts.map +0 -1
  85. package/dist/cube-name.js.map +0 -1
  86. package/dist/cubes.d.ts.map +0 -1
  87. package/dist/cubes.js.map +0 -1
  88. package/dist/debug.d.ts.map +0 -1
  89. package/dist/debug.js.map +0 -1
  90. package/dist/device-auth.d.ts.map +0 -1
  91. package/dist/device-auth.js.map +0 -1
  92. package/dist/direct-log.d.ts.map +0 -1
  93. package/dist/direct-log.js.map +0 -1
  94. package/dist/health-beat.d.ts.map +0 -1
  95. package/dist/health-beat.js.map +0 -1
  96. package/dist/inbox-monitor.d.ts.map +0 -1
  97. package/dist/inbox-monitor.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/lifecycle-log-guard.d.ts.map +0 -1
  101. package/dist/lifecycle-log-guard.js.map +0 -1
  102. package/dist/list-roles-render.d.ts.map +0 -1
  103. package/dist/list-roles-render.js.map +0 -1
  104. package/dist/log-audit.d.ts.map +0 -1
  105. package/dist/log-audit.js.map +0 -1
  106. package/dist/log-stream.d.ts.map +0 -1
  107. package/dist/log-stream.js.map +0 -1
  108. package/dist/name-validator.d.ts.map +0 -1
  109. package/dist/name-validator.js.map +0 -1
  110. package/dist/parse-assimilate-args.d.ts.map +0 -1
  111. package/dist/parse-assimilate-args.js.map +0 -1
  112. package/dist/postinstall.d.ts.map +0 -1
  113. package/dist/postinstall.js.map +0 -1
  114. package/dist/regen-format.d.ts.map +0 -1
  115. package/dist/regen-format.js.map +0 -1
  116. package/dist/regen.d.ts.map +0 -1
  117. package/dist/regen.js.map +0 -1
  118. package/dist/remote-client.d.ts.map +0 -1
  119. package/dist/remote-client.js.map +0 -1
  120. package/dist/role-resolver.d.ts.map +0 -1
  121. package/dist/role-resolver.js.map +0 -1
  122. package/dist/role-section.d.ts.map +0 -1
  123. package/dist/role-section.js.map +0 -1
  124. package/dist/roster-render.d.ts.map +0 -1
  125. package/dist/roster-render.js.map +0 -1
  126. package/dist/setup.d.ts.map +0 -1
  127. package/dist/setup.js.map +0 -1
  128. package/dist/shell-escape.d.ts.map +0 -1
  129. package/dist/shell-escape.js.map +0 -1
  130. package/dist/spawn.d.ts.map +0 -1
  131. package/dist/spawn.js.map +0 -1
  132. package/dist/stale-version-check.d.ts.map +0 -1
  133. package/dist/stale-version-check.js.map +0 -1
  134. package/dist/stream-owner.d.ts.map +0 -1
  135. package/dist/stream-owner.js.map +0 -1
  136. package/dist/stream-status.d.ts.map +0 -1
  137. package/dist/stream-status.js.map +0 -1
  138. package/dist/subscription-retry.d.ts.map +0 -1
  139. package/dist/subscription-retry.js.map +0 -1
  140. package/dist/sync-roles-render.d.ts.map +0 -1
  141. package/dist/sync-roles-render.js.map +0 -1
  142. package/dist/sync.d.ts.map +0 -1
  143. package/dist/sync.js.map +0 -1
  144. package/dist/templates.d.ts.map +0 -1
  145. package/dist/templates.js.map +0 -1
  146. package/dist/terminal-title.d.ts.map +0 -1
  147. package/dist/terminal-title.js.map +0 -1
  148. package/dist/token-crypto.d.ts.map +0 -1
  149. package/dist/token-crypto.js.map +0 -1
  150. package/dist/token-store.d.ts.map +0 -1
  151. package/dist/token-store.js.map +0 -1
  152. package/dist/types.d.ts.map +0 -1
  153. package/dist/types.js.map +0 -1
  154. package/dist/version.d.ts.map +0 -1
  155. package/dist/version.js.map +0 -1
  156. package/dist/worktree-lifecycle.d.ts.map +0 -1
  157. package/dist/worktree-lifecycle.js.map +0 -1
@@ -1,848 +1,9 @@
1
- /**
2
- * Real-time drone wakeup via Server-Sent Events.
3
- *
4
- * Replaces the long-poll + inbox-file-shim path in `client/src/inbox.ts`
5
- * for the wire layer; the local inbox file is preserved as the
6
- * Claude-side wake primitive (Monitor on tail -F) because Claude Code
7
- * does not currently wake idle agent loops on MCP-protocol
8
- * notifications. See spec:
9
- * docs/superpowers/specs/2026-05-11-server-push-log-subscription.md
10
- *
11
- * Lifetimes:
12
- * - One persistent fetch-streaming connection to /api/drone/stream
13
- * per active cube.
14
- * - On every received `event: log`, a single line is appended to the
15
- * per-drone inbox file (same format the old poller wrote).
16
- * - On every `event: heartbeat`, any carried hwm is compared against
17
- * `lastPersistedEventId`; divergence starts a short grace timer
18
- * before reconnect so an in-flight live broadcast can arrive first.
19
- * - Outer `runLoop` reconnects with exponential backoff on any
20
- * stream-level error, including heartbeat watchdog firing.
21
- *
22
- * State exposed via `getStreamStatus()` so the `borg:stream-status`
23
- * MCP tool can probe without perturbing the stream (no second
24
- * connection, no second auth — just an in-process state snapshot).
25
- */
26
- import { promises as fs } from 'node:fs';
27
- import os from 'node:os';
28
- import path from 'node:path';
29
- import { getActiveCube, inboxPathForDrone } from './cubes.js';
30
- import { formatCodexWakePrompt, resolveSessionAgentKind, wakeCodexViaAppServer, } from './codex-app-wake.js';
31
- import { getValidToken } from './remote-client.js';
32
- import { recordEventReceipt, emitHealthBeat, getCachedMonitorHealthy, getCachedWakeArmed, } from './health-beat.js';
33
- import { getPackageVersion } from './version.js';
34
- import { acquireStreamLease, readOwnershipSnapshot, } from './stream-owner.js';
35
- // ------------------------------------------------------------------
36
- // Tuning constants
37
- // ------------------------------------------------------------------
38
- /** Server emits heartbeats every 20s; allow several misses before reconnecting. */
39
- const HEARTBEAT_TIMEOUT_MS = 90_000;
40
- /** Grace window for a heartbeat HWM that is ahead of the local cursor. */
41
- const HWM_DIVERGENCE_GRACE_MS = 2_000;
42
- const RECONNECT_MIN_MS = 500;
43
- const RECONNECT_MAX_MS = 30_000;
44
- /**
45
- * Bounded recent-id set sized for the SSE replay window per spec §(3).
46
- * The catchup query returns up to 200 entries on reconnect; 50 is
47
- * comfortable headroom for the typical reorder/dedup case (1–3
48
- * entries) without the memory cost of the old long-poll 500-entry
49
- * ring buffer.
50
- */
51
- const RECENT_IDS_CAP = 50;
52
- function resolveRuntimeHostname() {
53
- try {
54
- const h = os.hostname();
55
- return h && h.trim() ? h.trim().slice(0, 255) : null;
56
- }
57
- catch {
58
- return null;
59
- }
60
- }
61
- const processStartMs = Date.now();
62
- const streamState = {
63
- connected: false,
64
- lastWireActivityAt: null,
65
- lastContentEventAt: null,
66
- lastHeartbeatAt: null,
67
- lastPersistedEventId: null,
68
- reconnectAttempts: 0,
69
- runLoopRestartCount: 0,
70
- ownership: { state: 'unowned' },
71
- };
72
- /**
73
- * Snapshot of the current stream status. Safe to call at any time
74
- * (returns a copy, does not interact with the running connection).
75
- */
76
- export function classifyRunLoopHealth(state, uptimeMs = Date.now() - processStartMs) {
77
- if (state.connected && state.lastWireActivityAt)
78
- return 'connected';
79
- if (!state.connected && state.reconnectAttempts > 0)
80
- return 'reconnecting';
81
- if (!state.connected && !state.lastWireActivityAt && state.reconnectAttempts === 0 && uptimeMs > 10_000)
82
- return 'silent-inert';
83
- return 'never-started';
84
- }
85
- export function getStreamStatus() {
86
- return { ...streamState, runLoopHealth: classifyRunLoopHealth(streamState) };
87
- }
88
- /**
89
- * Reset the module-level stream state. EXPORTED FOR TESTS ONLY — the
90
- * stream state is a singleton that accumulates across the process
91
- * lifetime in normal operation. Tests asserting on the new
92
- * content-vs-wire field semantics need a clean slate between
93
- * scenarios; nothing in production code should call this.
94
- *
95
- * @internal — test-only surface; not part of the public client API. The
96
- * `__`-prefix + `ForTest` suffix mark it as such at every call site.
97
- */
98
- export function __resetStreamStateForTest() {
99
- streamState.connected = false;
100
- streamState.lastWireActivityAt = null;
101
- streamState.lastContentEventAt = null;
102
- streamState.lastHeartbeatAt = null;
103
- streamState.lastPersistedEventId = null;
104
- streamState.reconnectAttempts = 0;
105
- streamState.runLoopRestartCount = 0;
106
- streamState.ownership = { state: 'unowned' };
107
- }
108
- // ------------------------------------------------------------------
109
- // Entry point
110
- // ------------------------------------------------------------------
111
- /**
112
- * Spawn the background SSE consumer loop. Fire-and-forget; the loop
113
- * runs until process exit. Errors are written to stderr (so they don't
114
- * pollute the MCP stdio channel) and the loop continues.
115
- *
116
- * Idempotent in the sense that calling twice would create two parallel
117
- * loops; the caller (`index.ts`) wires this once at startup.
118
- */
119
- export function startLogStream() {
120
- void (async () => {
121
- while (true) {
122
- try {
123
- await runLoop();
124
- process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
125
- }
126
- catch (err) {
127
- process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
128
- }
129
- streamState.runLoopRestartCount += 1;
130
- await sleep(5000);
131
- }
132
- })();
133
- }
134
- const defaultDeps = {
135
- fetchImpl: globalThis.fetch.bind(globalThis),
136
- appendLine: defaultAppendLine,
137
- hasInboxEntryId: defaultHasInboxEntryId,
138
- getToken: getValidToken,
139
- wakeCodex: wakeCodexViaAppServer,
140
- heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
141
- hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
142
- abortSignal: new AbortController().signal,
143
- ownerDeps: {},
144
- ownerStaleMs: 70_000,
145
- onInboxReceipt: defaultOnInboxReceipt,
146
- };
147
- /**
148
- * gh#541 WU-2 default receipt handler: record the wake-path receipt watermark
149
- * and fire a best-effort health beat below the agent classifier. Reuses the
150
- * SSE session's already-fetched token (no extra keychain read) and the cached
151
- * monitor-health from the periodic tick (no pgrep per inbound entry).
152
- */
153
- function defaultOnInboxReceipt(active, token) {
154
- recordEventReceipt();
155
- void emitHealthBeat(active, {
156
- sseConnected: true,
157
- inboxMonitorHealthy: getCachedMonitorHealthy(),
158
- // gh#633: reuse the cached transport-agnostic wake-armed from the periodic
159
- // tick (no bridge/Monitor re-probe per inbound entry).
160
- wakeArmed: getCachedWakeArmed(),
161
- // gh#634: live runtime agent_kind (cheap env read, constant per session).
162
- agentKind: resolveSessionAgentKind(),
163
- hostname: resolveRuntimeHostname(),
164
- version: getPackageVersion(),
165
- getToken: async () => token,
166
- // The beat rides the real global fetch — its OWN child-process HTTP wire,
167
- // independent of the SSE stream's (possibly test-injected) fetchImpl.
168
- fetchImpl: globalThis.fetch.bind(globalThis),
169
- });
170
- }
171
- // ------------------------------------------------------------------
172
- // Outer reconnect loop
173
- // ------------------------------------------------------------------
174
- async function runLoop() {
175
- let attempt = 0;
176
- let lastEventId = null;
177
- let currentCubeId = null;
178
- let lease = null;
179
- let leaseKey = null;
180
- while (true) {
181
- const active = await getActiveCube();
182
- if (!active) {
183
- if (lease) {
184
- await lease.release();
185
- lease = null;
186
- leaseKey = null;
187
- }
188
- streamState.connected = false;
189
- streamState.ownership = { state: 'unowned' };
190
- await sleep(5000);
191
- continue;
192
- }
193
- // Reset resume cursor on cube switch — entries from a prior cube
194
- // mean nothing for the new cube's stream.
195
- if (active.cubeId !== currentCubeId) {
196
- currentCubeId = active.cubeId;
197
- lastEventId = null;
198
- }
199
- const nextLeaseKey = `${active.cubeId}:${active.droneId}`;
200
- if (lease && leaseKey !== nextLeaseKey) {
201
- await lease.release();
202
- lease = null;
203
- leaseKey = null;
204
- }
205
- if (!lease) {
206
- lease = await acquireStreamLease(active.cubeId, active.droneId);
207
- leaseKey = lease ? nextLeaseKey : null;
208
- }
209
- if (!lease) {
210
- streamState.connected = false;
211
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
212
- await sleep(5000);
213
- continue;
214
- }
215
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
216
- let ownerLost = false;
217
- try {
218
- const ownerAbort = new AbortController();
219
- const refresh = async () => {
220
- try {
221
- if (!(await lease.refresh())) {
222
- ownerLost = true;
223
- ownerAbort.abort(new Error('stream ownership lost'));
224
- }
225
- }
226
- catch (err) {
227
- ownerLost = true;
228
- ownerAbort.abort(err instanceof Error ? err : new Error(String(err)));
229
- }
230
- };
231
- const refreshTimer = setInterval(() => {
232
- void refresh();
233
- }, Math.max(1000, Math.floor(HEARTBEAT_TIMEOUT_MS / 2)));
234
- try {
235
- await streamOnce(active, lastEventId, (id) => {
236
- lastEventId = id;
237
- }, { abortSignal: ownerAbort.signal });
238
- }
239
- finally {
240
- clearInterval(refreshTimer);
241
- }
242
- if (ownerLost) {
243
- lease = null;
244
- leaseKey = null;
245
- streamState.connected = false;
246
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
247
- await sleep(5000);
248
- continue;
249
- }
250
- // Clean disconnect (e.g. server-side rollout). Reset backoff.
251
- attempt = 0;
252
- streamState.reconnectAttempts = 0;
253
- }
254
- catch (err) {
255
- if (ownerLost) {
256
- lease = null;
257
- leaseKey = null;
258
- streamState.connected = false;
259
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
260
- await sleep(5000);
261
- continue;
262
- }
263
- streamState.connected = false;
264
- const delay = Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
265
- Math.random() * 500;
266
- process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(delay)}ms: ${err?.message ?? err}\n`);
267
- attempt += 1;
268
- streamState.reconnectAttempts = attempt;
269
- await sleep(delay);
270
- }
271
- }
272
- }
273
- export async function streamOnce(active, lastEventId, onEventId, deps = {}) {
274
- const { fetchImpl, appendLine, hasInboxEntryId, getToken, wakeCodex, heartbeatTimeoutMs, hwmDivergenceGraceMs, abortSignal, onInboxReceipt, } = { ...defaultDeps, ...deps };
275
- const token = await getToken();
276
- const headers = {
277
- Authorization: `Bearer ${token}`,
278
- 'X-Drone-Session': active.sessionToken,
279
- Accept: 'text/event-stream',
280
- };
281
- if (lastEventId)
282
- headers['Last-Event-ID'] = lastEventId;
283
- const ac = new AbortController();
284
- const abortFromExternal = () => {
285
- try {
286
- ac.abort(abortSignal.reason ?? new Error('external abort'));
287
- }
288
- catch {
289
- // ignore
290
- }
291
- };
292
- if (abortSignal.aborted)
293
- abortFromExternal();
294
- abortSignal.addEventListener('abort', abortFromExternal, { once: true });
295
- // Heartbeat watchdog: if no event of any type arrives within
296
- // heartbeatTimeoutMs, abort the request so the outer loop reconnects.
297
- let watchdog = null;
298
- const bumpWatchdog = () => {
299
- if (watchdog)
300
- clearTimeout(watchdog);
301
- watchdog = setTimeout(() => {
302
- try {
303
- ac.abort(new Error('heartbeat watchdog timeout'));
304
- }
305
- catch {
306
- // ignore
307
- }
308
- }, heartbeatTimeoutMs);
309
- };
310
- bumpWatchdog();
311
- // Local mirror of the resume cursor, updated AFTER each successful
312
- // disk write (or dedup-recognized replay). Heartbeat-hwm comparison
313
- // reads this value.
314
- let lastPersistedEventId = lastEventId;
315
- let lastBroadcastHwm = null;
316
- let pendingHwmDivergence = null;
317
- const clearPendingHwmDivergence = () => {
318
- if (!pendingHwmDivergence)
319
- return;
320
- clearTimeout(pendingHwmDivergence.timer);
321
- pendingHwmDivergence = null;
322
- };
323
- // gh#402 replay-storm amplifier fix (c80b1aaa #1): advance the resume cursor
324
- // MONOTONICALLY by (created_at, id). An out-of-order older broadcast (or a
325
- // catchup replay of an older entry) must NOT regress lastPersistedEventId —
326
- // a regressed resume cursor widens the next reconnect's catchup window and
327
- // re-replays entries (the tail -F storm). Reuses compareBroadcastHwm
328
- // ((created_at,id) tiebreak), the SAME key the server orders broadcasts by.
329
- //
330
- // FAIL-OPEN on a missing created_at: every real SSE payload carries a
331
- // non-empty created_at (EnrichedEntry / ack / heartbeat-hwm all guarantee
332
- // it server-side), so the guard engages for every production event. But an
333
- // absent/empty created_at is NOT ordinally comparable (UUID ids aren't
334
- // either — see the dedup branch), so we only BLOCK a regression we can
335
- // PROVE: both the incoming entry and the current cursor carry a real
336
- // created_at AND the incoming is older-or-equal. Otherwise ADVANCE —
337
- // freezing the resume cursor on a fresh forward entry that happens to lack
338
- // a created_at would itself widen the next reconnect's window (the very
339
- // storm this fixes). Proven older-or-equal events are still recorded in
340
- // recentIds for dedup but do not move the cursor or re-fire onEventId.
341
- let lastPersistedHwm = null;
342
- const markEventPersisted = (id, createdAt) => {
343
- const next = { id, created_at: createdAt };
344
- if (lastPersistedHwm &&
345
- createdAt &&
346
- lastPersistedHwm.created_at &&
347
- compareBroadcastHwm(next, lastPersistedHwm) <= 0) {
348
- return;
349
- }
350
- lastPersistedHwm = next;
351
- lastPersistedEventId = id;
352
- streamState.lastPersistedEventId = id;
353
- onEventId(id);
354
- };
355
- const markBroadcastPersisted = (hwm) => {
356
- if (!hwm)
357
- return;
358
- lastBroadcastHwm =
359
- !lastBroadcastHwm || compareBroadcastHwm(hwm, lastBroadcastHwm) > 0
360
- ? hwm
361
- : lastBroadcastHwm;
362
- if (pendingHwmDivergence &&
363
- compareBroadcastHwm(lastBroadcastHwm, pendingHwmDivergence.hwm) >= 0) {
364
- clearPendingHwmDivergence();
365
- }
366
- };
367
- const scheduleHwmDivergenceReconnect = (hwm) => {
368
- if (pendingHwmDivergence?.hwm.id === hwm.id)
369
- return;
370
- clearPendingHwmDivergence();
371
- const timer = setTimeout(() => {
372
- if (lastBroadcastHwm && compareBroadcastHwm(lastBroadcastHwm, hwm) >= 0) {
373
- clearPendingHwmDivergence();
374
- return;
375
- }
376
- try {
377
- ac.abort(new Error('hwm divergence — reconnect for catchup'));
378
- }
379
- catch {
380
- // ignore
381
- }
382
- }, hwmDivergenceGraceMs);
383
- pendingHwmDivergence = { hwm, timer };
384
- };
385
- // Bounded recent-id set for replay-on-reconnect dedup per spec §(3).
386
- // Set + FIFO array for O(1) membership + bounded memory.
387
- const recentIds = new Set();
388
- const recentIdsOrder = [];
389
- let isCatchingUp = lastEventId !== null;
390
- // gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
391
- // extracted from the previously-duplicated ack / regular-log branches in the
392
- // event loop below. Behavior-preserving — the per-branch comments document
393
- // the load-bearing semantics each path relies on.
394
- // Format + (catchup-dedup-aware) append the entry's inbox line.
395
- // LOAD-BEARING ORDER: the disk write must complete before the cursor
396
- // advances (recordSeen), so an append failure (disk full, EACCES, path race
397
- // during cube switch) replays the entry on reconnect rather than being
398
- // skipped past by an already-advanced Last-Event-ID — the §(3) durability
399
- // contract. Returns 'persisted-skip' when the entry is already on disk from
400
- // an earlier catchup receive (caller advances the cursor via markEventPersisted
401
- // here, then continues WITHOUT re-recording); 'written' after a fresh append.
402
- const writeInboxLine = async (ev) => {
403
- const line = formatInboxLine(withSseEventId(ev.data, ev.id));
404
- if (isCatchingUp &&
405
- // gh#441: pass the rendered line so the dedup can also recognize LEGACY
406
- // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
407
- (await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line))) {
408
- markEventPersisted(ev.id, ev.data?.created_at ?? '');
409
- return 'persisted-skip';
410
- }
411
- await appendLine(active.cubeId, active.droneId, line);
412
- wakeCodex(formatCodexWakePrompt(line));
413
- // gh#541 WU-2: a fresh inbound entry just hit the inbox (the wake-path
414
- // receipt). Record it + beat below the classifier (best-effort).
415
- onInboxReceipt(active, token);
416
- return 'written';
417
- };
418
- // Record the event in the bounded recent-ids dedup set (FIFO-capped) and
419
- // advance both cursors. The broadcast-HWM cursor advances via
420
- // broadcastHwmFromLogEvent, which returns null for visibility==='direct' and
421
- // kind==='ack' (those do NOT advance the server's DO broadcast HWM either —
422
- // D6), so it advances ONLY for broadcast entries. gh#402 replay-storm fix
423
- // (583aed7e): this runs for OWN-POST broadcasts too — the author's own
424
- // broadcast IS counted by the server HWM, so the client broadcast cursor
425
- // must advance to match; otherwise the next heartbeat reads server-hwm >
426
- // client-cursor and fires a spurious divergence-reconnect (the storm
427
- // trigger). The null-for-direct/ack guard keeps own-direct echoes correct.
428
- const recordSeen = (ev) => {
429
- recentIds.add(ev.id);
430
- recentIdsOrder.push(ev.id);
431
- while (recentIdsOrder.length > RECENT_IDS_CAP) {
432
- const oldId = recentIdsOrder.shift();
433
- if (oldId)
434
- recentIds.delete(oldId);
435
- }
436
- markEventPersisted(ev.id, ev.data?.created_at ?? '');
437
- markBroadcastPersisted(broadcastHwmFromLogEvent(ev));
438
- };
439
- let response;
440
- try {
441
- response = await fetchImpl(`${active.apiUrl}/api/drone/stream`, {
442
- method: 'GET',
443
- headers,
444
- signal: ac.signal,
445
- });
446
- }
447
- catch (err) {
448
- if (watchdog)
449
- clearTimeout(watchdog);
450
- throw err;
451
- }
452
- if (!response.ok || !response.body) {
453
- if (watchdog)
454
- clearTimeout(watchdog);
455
- throw new Error(`stream HTTP ${response.status}`);
456
- }
457
- streamState.connected = true;
458
- try {
459
- for await (const event of parseSSE(response.body)) {
460
- bumpWatchdog();
461
- const nowIso = new Date().toISOString();
462
- streamState.lastWireActivityAt = nowIso;
463
- // Content vs wire split (T1.2): content freshness is what a reader
464
- // skimming the top-line verdict actually cares about. Heartbeats
465
- // bump wire-activity only; log and bookmark events bump both.
466
- if (event.type === 'log' || event.type === 'bookmark') {
467
- streamState.lastContentEventAt = nowIso;
468
- }
469
- if (event.type === 'heartbeat') {
470
- streamState.lastHeartbeatAt = nowIso;
471
- // First/baseline heartbeat absorb: until this session has seen
472
- // a broadcast entry, the server's broadcast HWM is our baseline.
473
- // Direct messages may advance the persistence cursor past this
474
- // value; directional comparison must not treat that mirror case
475
- // as missed-broadcast evidence.
476
- if (event.hwm && lastBroadcastHwm === null) {
477
- markBroadcastPersisted(event.hwm);
478
- if (lastPersistedEventId === null) {
479
- markEventPersisted(event.hwm.id, event.hwm.created_at);
480
- }
481
- continue;
482
- }
483
- if (event.hwm &&
484
- lastBroadcastHwm &&
485
- compareBroadcastHwm(event.hwm, lastBroadcastHwm) <= 0) {
486
- clearPendingHwmDivergence();
487
- continue;
488
- }
489
- // §(5) divergence-detection: reconnect only when the server's
490
- // broadcast HWM is AHEAD of the client's broadcast cursor. A
491
- // recipient persisting a direct entry legitimately advances
492
- // lastPersistedEventId beyond the broadcast HWM; strict
493
- // inequality would reintroduce gh#402 churn for that mirror case.
494
- if (event.hwm &&
495
- lastBroadcastHwm &&
496
- compareBroadcastHwm(event.hwm, lastBroadcastHwm) > 0) {
497
- scheduleHwmDivergenceReconnect(event.hwm);
498
- }
499
- continue;
500
- }
501
- if (event.type === 'bookmark') {
502
- isCatchingUp = false;
503
- continue;
504
- }
505
- if (event.type === 'log') {
506
- // DEDUP per §(3) recent-ids contract: an out-of-order DO
507
- // broadcast followed by reconnect+catchup can replay an entry
508
- // we already persisted. The entry IS on disk from an earlier
509
- // receive in this session — just not from THIS iteration's
510
- // appendLine. So we skip the duplicate write AND advance the
511
- // cursor (so the heartbeat-hwm comparison converges and so
512
- // Last-Event-ID on the next reconnect reflects the highest
513
- // id we've actually got persisted). UUIDs are not ordinally
514
- // comparable with created_at, so set membership is the check.
515
- if (recentIds.has(event.id)) {
516
- markEventPersisted(event.id, event.data?.created_at ?? '');
517
- markBroadcastPersisted(broadcastHwmFromLogEvent(event));
518
- continue;
519
- }
520
- // OWN-DRONE FILTER: restore the silent-self property — parity
521
- // with pre-cutover inbox.ts:87-88. The DO broadcasts every
522
- // entry to every connected drone INCLUDING the originator;
523
- // without this skip, posting a log entry would wake the
524
- // posting drone on its own message (visible via Monitor on
525
- // the inbox file). The entry IS on disk (we wrote it via
526
- // appendLog), so skip the inbox echo but still advance the
527
- // cursor + record in recentIds so heartbeat-hwm comparison
528
- // converges and the next reconnect's Last-Event-ID reflects
529
- // the highest id we've actually got persisted. Structurally
530
- // identical to the dedup branch above — same "skip write,
531
- // advance state" shape, different trigger condition.
532
- //
533
- // HEARTBEAT-PING CARVE-OUT (gh#71): the gh#39 cron watchdog
534
- // authors heartbeat-pings WITH the silent target as drone_id
535
- // so each ping is attributed to the drone it intends to wake.
536
- // Without this carve-out, the own-drone filter would silently
537
- // skip the target's own ping → inbox file never written →
538
- // Monitor never fires → the platform-level wake guarantee is
539
- // broken for the cube-wide-silent class gh#39 was designed to
540
- // prevent. We let heartbeat-pings authored "by" the target
541
- // drone through the disk-write path; the existing rate-limit
542
- // in workers/heartbeat.ts (max 1 ping per drone per ~1h)
543
- // bounds the silent-self property's relaxation.
544
- const isHeartbeatPing = typeof event.data?.message === 'string' &&
545
- event.data.message.startsWith('[HEARTBEAT-PING]');
546
- // Sprint 26 ack-fan-out: ack events have `kind: 'ack'` plus an
547
- // `author_drone_id` field naming the recipient (the author of
548
- // the entry that got acked). Only the author writes the ack
549
- // line to their inbox — all other subscribers drop the event
550
- // but still advance the cursor. The legacy entry-shaped fields
551
- // on the ack payload exist for pre-Sprint-26 clients that
552
- // don't recognize the `kind` discriminator; new clients route
553
- // here BEFORE the legacy own-drone filter so the ack-specific
554
- // semantic takes precedence.
555
- if (event.data?.kind === 'ack') {
556
- if (event.data?.author_drone_id === active.droneId) {
557
- if ((await writeInboxLine(event)) === 'persisted-skip')
558
- continue;
559
- }
560
- recordSeen(event);
561
- continue;
562
- }
563
- if (event.data?.drone_id === active.droneId && !isHeartbeatPing) {
564
- // Own post: silent-self (no inbox echo — already on disk via
565
- // appendLog). recordSeen still advances BOTH cursors, including the
566
- // broadcast cursor for an own broadcast — see recordSeen's gh#402
567
- // (583aed7e) note for why skipping it here would storm.
568
- recordSeen(event);
569
- continue;
570
- }
571
- // Regular inbound entry: write the inbox line (catchup-dedup aware),
572
- // then advance both cursors.
573
- if ((await writeInboxLine(event)) === 'persisted-skip')
574
- continue;
575
- recordSeen(event);
576
- }
577
- }
578
- }
579
- finally {
580
- abortSignal.removeEventListener('abort', abortFromExternal);
581
- if (watchdog)
582
- clearTimeout(watchdog);
583
- clearPendingHwmDivergence();
584
- streamState.connected = false;
585
- }
586
- }
587
- export async function streamOnceIfOwner(active, lastEventId, onEventId, deps = {}) {
588
- const { ownerDeps, ownerStaleMs } = { ...defaultDeps, ...deps };
589
- const lease = await acquireStreamLease(active.cubeId, active.droneId, ownerStaleMs, ownerDeps);
590
- if (!lease) {
591
- streamState.connected = false;
592
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
593
- return 'skipped';
594
- }
595
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
596
- try {
597
- await streamOnce(active, lastEventId, onEventId, deps);
598
- return 'streamed';
599
- }
600
- finally {
601
- await lease.release();
602
- }
603
- }
604
- /**
605
- * Async generator over an SSE response body. Yields one ParsedEvent
606
- * per "event:/data:" block (separated by blank lines per RFC 5234).
607
- *
608
- * Exported so tests can pump a synthetic ReadableStream through it.
609
- */
610
- export async function* parseSSE(body) {
611
- const reader = body.getReader();
612
- const decoder = new TextDecoder();
613
- let buffer = '';
614
- try {
615
- while (true) {
616
- const { value, done } = await reader.read();
617
- if (done) {
618
- if (buffer.trim()) {
619
- const parsed = parseEventBlock(buffer);
620
- if (parsed)
621
- yield parsed;
622
- }
623
- return;
624
- }
625
- buffer += decoder.decode(value, { stream: true });
626
- // Split on double-newline (event terminator). Keep the trailing
627
- // partial block in `buffer` for the next read.
628
- let idx;
629
- while ((idx = buffer.indexOf('\n\n')) !== -1) {
630
- const block = buffer.slice(0, idx);
631
- buffer = buffer.slice(idx + 2);
632
- const parsed = parseEventBlock(block);
633
- if (parsed)
634
- yield parsed;
635
- }
636
- }
637
- }
638
- finally {
639
- try {
640
- reader.releaseLock();
641
- }
642
- catch {
643
- // ignore — stream may already be closed
644
- }
645
- }
646
- }
647
- function parseEventBlock(block) {
648
- let eventName = null;
649
- let id = null;
650
- let dataLines = [];
651
- for (const line of block.split('\n')) {
652
- if (line.startsWith('event:')) {
653
- eventName = line.slice(6).trim();
654
- }
655
- else if (line.startsWith('id:')) {
656
- id = line.slice(3).trim();
657
- }
658
- else if (line.startsWith('data:')) {
659
- dataLines.push(line.slice(5).trim());
660
- }
661
- }
662
- const dataStr = dataLines.join('\n');
663
- if (!eventName)
664
- return null;
665
- if (eventName === 'log') {
666
- if (!id)
667
- return null;
668
- let parsed;
669
- try {
670
- parsed = JSON.parse(dataStr);
671
- }
672
- catch {
673
- return null;
674
- }
675
- return { type: 'log', id, data: parsed };
676
- }
677
- if (eventName === 'heartbeat') {
678
- let ts = null;
679
- let hwm = null;
680
- try {
681
- const parsed = JSON.parse(dataStr);
682
- ts = typeof parsed.ts === 'string' ? parsed.ts : null;
683
- hwm = parseHeartbeatHwm(parsed.hwm);
684
- }
685
- catch {
686
- // fall through with nulls
687
- }
688
- return { type: 'heartbeat', ts, hwm };
689
- }
690
- if (eventName === 'bookmark') {
691
- let as_of = null;
692
- try {
693
- const parsed = JSON.parse(dataStr);
694
- as_of = typeof parsed.as_of === 'string' ? parsed.as_of : null;
695
- }
696
- catch {
697
- // fall through with null
698
- }
699
- return { type: 'bookmark', as_of };
700
- }
701
- return { type: 'unknown', raw: block };
702
- }
703
- function parseHeartbeatHwm(value) {
704
- if (!value || typeof value !== 'object')
705
- return null;
706
- const candidate = value;
707
- return typeof candidate.id === 'string' &&
708
- candidate.id.length > 0 &&
709
- typeof candidate.created_at === 'string' &&
710
- candidate.created_at.length > 0
711
- ? { id: candidate.id, created_at: candidate.created_at }
712
- : null;
713
- }
714
- // Cross-bundle duplicate of workers/log-hwm.ts `compareBroadcastHwm` — the two
715
- // MUST stay in sync on the (created_at, id) tiebreak, else a heartbeat-HWM
716
- // comparison could diverge between client and server and cause spurious or
717
- // missed gh#402 divergence-reconnects. Exported so the parity test
718
- // (workers/__tests__/log-hwm-parity.test.ts) can pin them equal on a shared
719
- // fixture set. See ISSUE-FOUND fb3986b7.
720
- export function compareBroadcastHwm(a, b) {
721
- const aTime = Date.parse(a.created_at);
722
- const bTime = Date.parse(b.created_at);
723
- if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
724
- return aTime - bTime;
725
- }
726
- if (a.created_at !== b.created_at) {
727
- return a.created_at < b.created_at ? -1 : 1;
728
- }
729
- return a.id.localeCompare(b.id);
730
- }
731
- function broadcastHwmFromLogEvent(event) {
732
- if (event.data?.visibility === 'direct' || event.data?.kind === 'ack') {
733
- return null;
734
- }
735
- const createdAt = event.data?.created_at;
736
- return typeof createdAt === 'string' && createdAt.length > 0
737
- ? { id: event.id, created_at: createdAt }
738
- : null;
739
- }
740
- function withSseEventId(entry, eventId) {
741
- if (!entry || typeof entry !== 'object')
742
- return { id: eventId };
743
- if (typeof entry.id === 'string' && entry.id.length > 0)
744
- return entry;
745
- return { ...entry, id: eventId };
746
- }
747
- /**
748
- * First argument that is a non-empty string, else ''. Used to pick the
749
- * inbox entry id from the server `id` field, falling back to the legacy
750
- * `entry_id` field. Flattens what was a nested ternary at the call site.
751
- */
752
- function firstNonEmptyString(...candidates) {
753
- for (const candidate of candidates) {
754
- if (typeof candidate === 'string' && candidate.length > 0)
755
- return candidate;
756
- }
757
- return '';
758
- }
759
- /**
760
- * Format one inbox-file line. Preserves the long-poll inbox.ts prefix
761
- * shape (`<iso-ts> <drone-label> (<role-name>): ...`) so existing
762
- * Monitor tail consumers and audit tooling keep parsing it.
763
- *
764
- * Newlines in the message body are joined to ` ⏎ ` (U+23CE Return Symbol,
765
- * space-padded) so the entire entry is one physical line in the inbox
766
- * file. Rationale: Claude Code's Monitor primitive consumes stdout via
767
- * Node `readline` — every `\n` creates a NEW notification event, so a
768
- * multi-line cube-log entry would fire multiple notifications with only
769
- * the first line being a recognized entry-start (the rest dropped by
770
- * `formatEventLine`'s regex). Joining at write time delivers the full
771
- * entry content in a single notification body. Drones recognize ⏎ as
772
- * "newline was here" — convention noted in the regen-format playbook.
773
- */
774
- export function formatInboxLine(entry) {
775
- const ts = typeof entry.created_at === 'string'
776
- ? new Date(entry.created_at).toISOString()
777
- : new Date().toISOString();
778
- const label = entry.drone_label ?? '?';
779
- const role = entry.role_name ?? '?';
780
- const rawMessage = typeof entry.message === 'string' ? entry.message : '';
781
- // Server `id`, else legacy `entry_id`, else '' (behavior-identical to the
782
- // prior nested ternary).
783
- const entryId = firstNonEmptyString(entry.id, entry.entry_id);
784
- const idPrefix = entryId ? `[entry_id: ${entryId}] ` : '';
785
- // Normalize \r\n, \r, and \n all to ` ⏎ ` so the entry fits on one
786
- // physical line regardless of line-ending convention in the source.
787
- const message = rawMessage.replace(/\r\n|\r|\n/g, ' ⏎ ');
788
- return `${ts} ${label} (${role}): ${idPrefix}${message}`;
789
- }
790
- async function defaultAppendLine(cubeId, droneId, line) {
791
- const p = inboxPathForDrone(cubeId, droneId);
792
- await fs.mkdir(path.dirname(p), { recursive: true });
793
- await fs.appendFile(p, line + '\n', 'utf-8');
794
- }
795
- /**
796
- * @internal Exported for unit tests.
797
- *
798
- * Decide whether `raw` (the full inbox-file contents) already contains the
799
- * entry identified by `entryId` / rendered as `renderedLine`. Robust to BOTH
800
- * on-disk line formats (gh#441 — catchup-replay-flood fix):
801
- * - NEW format (0.9.39+): `…: [entry_id: <id>] <message>` — matched by the
802
- * `[entry_id: <id>]` marker (the #412 dedup, preserved verbatim).
803
- * - LEGACY format (pre-0.9.39 / old ACK lines): `…: <message>` with NO
804
- * entry_id prefix — matched by an EXACT, line-anchored comparison against
805
- * the entry's legacy rendering (`renderedLine` with the id-prefix stripped).
806
- *
807
- * Why this exists: a worker DEPLOY evicts the LogBroadcaster DO → every drone's
808
- * SSE drops → fleet-wide simultaneous reconnect → catchup. The old substring
809
- * check (`raw.includes('[entry_id: <id>]')`) missed legacy lines, so catchup
810
- * re-appended them → tail -F replay flood.
811
- *
812
- * The legacy match is LINE-ANCHORED (exact line equality), NOT a substring
813
- * match, on purpose: a substring match would false-positive-DROP a genuinely
814
- * new entry whose message merely EXTENDS an existing line (e.g. `…: hello` vs
815
- * `…: hello world`). A dropped entry is worse than a re-append — the drone
816
- * misses a wake — so when in doubt this fails toward re-append, never drop.
817
- */
818
- export function inboxRawHasEntry(raw, entryId, renderedLine) {
819
- // New-format: entry_id marker (preserves the #412 dedup).
820
- if (entryId && raw.includes(`[entry_id: ${entryId}]`))
821
- return true;
822
- // Legacy-format: exact, line-anchored match against the id-prefix-stripped
823
- // rendering. `String.prototype.replace` with a string pattern strips only the
824
- // first (sole) prefix occurrence; entryId is treated literally (not regex).
825
- const legacyForm = entryId
826
- ? renderedLine.replace(`[entry_id: ${entryId}] `, '')
827
- : renderedLine;
828
- if (legacyForm && raw.split(/\r?\n/).includes(legacyForm))
829
- return true;
830
- return false;
831
- }
832
- async function defaultHasInboxEntryId(cubeId, droneId, entryId, renderedLine) {
833
- const p = inboxPathForDrone(cubeId, droneId);
834
- let raw;
835
- try {
836
- raw = await fs.readFile(p, 'utf-8');
837
- }
838
- catch (err) {
839
- if (err?.code === 'ENOENT')
840
- return false;
841
- throw err;
842
- }
843
- return inboxRawHasEntry(raw, entryId, renderedLine);
844
- }
845
- function sleep(ms) {
846
- return new Promise((resolve) => setTimeout(resolve, ms));
847
- }
848
- //# sourceMappingURL=log-stream.js.map
1
+ import{promises as D}from"node:fs";import X from"node:os";import q from"node:path";import{getActiveCube as z,inboxPathForDrone as W}from"./cubes.js";import{formatCodexWakePrompt as Q,resolveSessionAgentKind as Y,wakeCodexViaAppServer as Z}from"./codex-app-wake.js";import{getValidToken as ee}from"./remote-client.js";import{recordEventReceipt as te,emitHealthBeat as ne,getCachedMonitorHealthy as re,getCachedWakeArmed as ae}from"./health-beat.js";import{getPackageVersion as oe}from"./version.js";import{acquireStreamLease as F,readOwnershipSnapshot as g}from"./stream-owner.js";const B=9e4,ie=2e3,se=500,ce=3e4,le=50;function de(){try{const e=X.hostname();return e&&e.trim()?e.trim().slice(0,255):null}catch{return null}}const ue=Date.now(),o={connected:!1,lastWireActivityAt:null,lastContentEventAt:null,lastHeartbeatAt:null,lastPersistedEventId:null,reconnectAttempts:0,runLoopRestartCount:0,ownership:{state:"unowned"}};function fe(e,t=Date.now()-ue){return e.connected&&e.lastWireActivityAt?"connected":!e.connected&&e.reconnectAttempts>0?"reconnecting":!e.connected&&!e.lastWireActivityAt&&e.reconnectAttempts===0&&t>1e4?"silent-inert":"never-started"}function De(){return{...o,runLoopHealth:fe(o)}}function Re(){o.connected=!1,o.lastWireActivityAt=null,o.lastContentEventAt=null,o.lastHeartbeatAt=null,o.lastPersistedEventId=null,o.reconnectAttempts=0,o.runLoopRestartCount=0,o.ownership={state:"unowned"}}function Ne(){(async()=>{for(;;){try{await me(),process.stderr.write(`[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s
2
+ `)}catch(e){process.stderr.write(`[borg-mcp log stream] runLoop threw: ${e?.message??e}; restarting in 5s
3
+ `)}o.runLoopRestartCount+=1,await y(5e3)}})()}const G={fetchImpl:globalThis.fetch.bind(globalThis),appendLine:Ie,hasInboxEntryId:Ee,getToken:ee,wakeCodex:Z,heartbeatTimeoutMs:B,hwmDivergenceGraceMs:ie,abortSignal:new AbortController().signal,ownerDeps:{},ownerStaleMs:7e4,onInboxReceipt:pe};function pe(e,t){te(),ne(e,{sseConnected:!0,inboxMonitorHealthy:re(),wakeArmed:ae(),agentKind:Y(),hostname:de(),version:oe(),getToken:async()=>t,fetchImpl:globalThis.fetch.bind(globalThis)})}async function me(){let e=0,t=null,i=null,a=null,s=null;for(;;){const r=await z();if(!r){a&&(await a.release(),a=null,s=null),o.connected=!1,o.ownership={state:"unowned"},await y(5e3);continue}r.cubeId!==i&&(i=r.cubeId,t=null);const c=`${r.cubeId}:${r.droneId}`;if(a&&s!==c&&(await a.release(),a=null,s=null),a||(a=await F(r.cubeId,r.droneId),s=a?c:null),!a){o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}o.ownership=await g(r.cubeId,r.droneId);let l=!1;try{const f=new AbortController,h=async()=>{try{await a.refresh()||(l=!0,f.abort(new Error("stream ownership lost")))}catch(p){l=!0,f.abort(p instanceof Error?p:new Error(String(p)))}},k=setInterval(()=>{h()},Math.max(1e3,Math.floor(B/2)));try{await K(r,t,p=>{t=p},{abortSignal:f.signal})}finally{clearInterval(k)}if(l){a=null,s=null,o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}e=0,o.reconnectAttempts=0}catch(f){if(l){a=null,s=null,o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}o.connected=!1;const h=Math.min(se*2**e,ce)+Math.random()*500;process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(h)}ms: ${f?.message??f}
4
+ `),e+=1,o.reconnectAttempts=e,await y(h)}}}async function K(e,t,i,a={}){const{fetchImpl:s,appendLine:r,hasInboxEntryId:c,getToken:l,wakeCodex:f,heartbeatTimeoutMs:h,hwmDivergenceGraceMs:k,abortSignal:p,onInboxReceipt:J}={...G,...a},R=await l(),N={Authorization:`Bearer ${R}`,"X-Drone-Session":e.sessionToken,Accept:"text/event-stream"};t&&(N["Last-Event-ID"]=t);const E=new AbortController,x=()=>{try{E.abort(p.reason??new Error("external abort"))}catch{}};p.aborted&&x(),p.addEventListener("abort",x,{once:!0});let m=null;const O=()=>{m&&clearTimeout(m),m=setTimeout(()=>{try{E.abort(new Error("heartbeat watchdog timeout"))}catch{}},h)};O();let P=t,u=null,w=null;const I=()=>{w&&(clearTimeout(w.timer),w=null)};let A=null;const S=(n,d)=>{const T={id:n,created_at:d};A&&d&&A.created_at&&b(T,A)<=0||(A=T,P=n,o.lastPersistedEventId=n,i(n))},C=n=>{n&&(u=!u||b(n,u)>0?n:u,w&&b(u,w.hwm)>=0&&I())},U=n=>{if(w?.hwm.id===n.id)return;I();const d=setTimeout(()=>{if(u&&b(u,n)>=0){I();return}try{E.abort(new Error("hwm divergence \u2014 reconnect for catchup"))}catch{}},k);w={hwm:n,timer:d}},H=new Set,M=[];let v=t!==null;const $=async n=>{const d=ye(ge(n.data,n.id));return v&&await c(e.cubeId,e.droneId,n.id,d)?(S(n.id,n.data?.created_at??""),"persisted-skip"):(await r(e.cubeId,e.droneId,d),f(Q(d)),J(e,R),"written")},L=n=>{for(H.add(n.id),M.push(n.id);M.length>le;){const d=M.shift();d&&H.delete(d)}S(n.id,n.data?.created_at??""),C(j(n))};let _;try{_=await s(`${e.apiUrl}/api/drone/stream`,{method:"GET",headers:N,signal:E.signal})}catch(n){throw m&&clearTimeout(m),n}if(!_.ok||!_.body)throw m&&clearTimeout(m),new Error(`stream HTTP ${_.status}`);o.connected=!0;try{for await(const n of we(_.body)){O();const d=new Date().toISOString();if(o.lastWireActivityAt=d,(n.type==="log"||n.type==="bookmark")&&(o.lastContentEventAt=d),n.type==="heartbeat"){if(o.lastHeartbeatAt=d,n.hwm&&u===null){C(n.hwm),P===null&&S(n.hwm.id,n.hwm.created_at);continue}if(n.hwm&&u&&b(n.hwm,u)<=0){I();continue}n.hwm&&u&&b(n.hwm,u)>0&&U(n.hwm);continue}if(n.type==="bookmark"){v=!1;continue}if(n.type==="log"){if(H.has(n.id)){S(n.id,n.data?.created_at??""),C(j(n));continue}const T=typeof n.data?.message=="string"&&n.data.message.startsWith("[HEARTBEAT-PING]");if(n.data?.kind==="ack"){if(n.data?.author_drone_id===e.droneId&&await $(n)==="persisted-skip")continue;L(n);continue}if(n.data?.drone_id===e.droneId&&!T){L(n);continue}if(await $(n)==="persisted-skip")continue;L(n)}}}finally{p.removeEventListener("abort",x),m&&clearTimeout(m),I(),o.connected=!1}}async function Oe(e,t,i,a={}){const{ownerDeps:s,ownerStaleMs:r}={...G,...a},c=await F(e.cubeId,e.droneId,r,s);if(!c)return o.connected=!1,o.ownership=await g(e.cubeId,e.droneId,s),"skipped";o.ownership=await g(e.cubeId,e.droneId,s);try{return await K(e,t,i,a),"streamed"}finally{await c.release()}}async function*we(e){const t=e.getReader(),i=new TextDecoder;let a="";try{for(;;){const{value:s,done:r}=await t.read();if(r){if(a.trim()){const l=V(a);l&&(yield l)}return}a+=i.decode(s,{stream:!0});let c;for(;(c=a.indexOf(`
5
+
6
+ `))!==-1;){const l=a.slice(0,c);a=a.slice(c+2);const f=V(l);f&&(yield f)}}}finally{try{t.releaseLock()}catch{}}}function V(e){let t=null,i=null,a=[];for(const r of e.split(`
7
+ `))r.startsWith("event:")?t=r.slice(6).trim():r.startsWith("id:")?i=r.slice(3).trim():r.startsWith("data:")&&a.push(r.slice(5).trim());const s=a.join(`
8
+ `);if(!t)return null;if(t==="log"){if(!i)return null;let r;try{r=JSON.parse(s)}catch{return null}return{type:"log",id:i,data:r}}if(t==="heartbeat"){let r=null,c=null;try{const l=JSON.parse(s);r=typeof l.ts=="string"?l.ts:null,c=he(l.hwm)}catch{}return{type:"heartbeat",ts:r,hwm:c}}if(t==="bookmark"){let r=null;try{const c=JSON.parse(s);r=typeof c.as_of=="string"?c.as_of:null}catch{}return{type:"bookmark",as_of:r}}return{type:"unknown",raw:e}}function he(e){if(!e||typeof e!="object")return null;const t=e;return typeof t.id=="string"&&t.id.length>0&&typeof t.created_at=="string"&&t.created_at.length>0?{id:t.id,created_at:t.created_at}:null}function b(e,t){const i=Date.parse(e.created_at),a=Date.parse(t.created_at);return Number.isFinite(i)&&Number.isFinite(a)&&i!==a?i-a:e.created_at!==t.created_at?e.created_at<t.created_at?-1:1:e.id.localeCompare(t.id)}function j(e){if(e.data?.visibility==="direct"||e.data?.kind==="ack")return null;const t=e.data?.created_at;return typeof t=="string"&&t.length>0?{id:e.id,created_at:t}:null}function ge(e,t){return!e||typeof e!="object"?{id:t}:typeof e.id=="string"&&e.id.length>0?e:{...e,id:t}}function be(...e){for(const t of e)if(typeof t=="string"&&t.length>0)return t;return""}function ye(e){const t=typeof e.created_at=="string"?new Date(e.created_at).toISOString():new Date().toISOString(),i=e.drone_label??"?",a=e.role_name??"?",s=typeof e.message=="string"?e.message:"",r=be(e.id,e.entry_id),c=r?`[entry_id: ${r}] `:"",l=s.replace(/\r\n|\r|\n/g," \u23CE ");return`${t} ${i} (${a}): ${c}${l}`}async function Ie(e,t,i){const a=W(e,t);await D.mkdir(q.dirname(a),{recursive:!0}),await D.appendFile(a,i+`
9
+ `,"utf-8")}function _e(e,t,i){if(t&&e.includes(`[entry_id: ${t}]`))return!0;const a=t?i.replace(`[entry_id: ${t}] `,""):i;return!!(a&&e.split(/\r?\n/).includes(a))}async function Ee(e,t,i,a){const s=W(e,t);let r;try{r=await D.readFile(s,"utf-8")}catch(c){if(c?.code==="ENOENT")return!1;throw c}return _e(r,i,a)}function y(e){return new Promise(t=>setTimeout(t,e))}export{Re as __resetStreamStateForTest,fe as classifyRunLoopHealth,b as compareBroadcastHwm,ye as formatInboxLine,De as getStreamStatus,_e as inboxRawHasEntry,we as parseSSE,Ne as startLogStream,K as streamOnce,Oe as streamOnceIfOwner};