@phnx-labs/agents-cli 1.22.115 → 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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -23,7 +23,38 @@ export interface MessageReceipt {
23
23
  at: string;
24
24
  /** Optional sender label for the message. */
25
25
  from?: string;
26
+ /**
27
+ * The ask this receipt is ABOUT — the block generation live when the answer
28
+ * was sent. A block id is per SESSION, so it is reused by every generation of
29
+ * that session's questions; without this a late acknowledgement for question N
30
+ * is indistinguishable from one for question N+1 and would resolve the wrong
31
+ * ask (PHNX-3999). Carried durably on the queued message so it survives the
32
+ * process that sent it.
33
+ */
34
+ generation?: string;
35
+ /** The claim (attempt) this receipt is about — `AnswerRecord.answeredAt`. */
36
+ attempt?: string;
37
+ }
38
+ /** The ask a receipt or queued message belongs to, plus the attempt that sent it. */
39
+ export interface ReceiptOrigin {
40
+ generation: string;
41
+ attempt: string;
26
42
  }
43
+ /**
44
+ * Whether a receipt describes THIS ask.
45
+ *
46
+ * Identity is the GENERATION alone, never the attempt. The question is "does
47
+ * this receipt answer this ask?", and a second attempt on the same ask carries
48
+ * the same answer -- so a stranded claim that is adopted (which necessarily
49
+ * mints a new attempt) must still recognise the message its predecessor queued,
50
+ * or it enqueues a duplicate. `attempt` rides along as provenance for the
51
+ * delivery check, not as part of identity.
52
+ *
53
+ * An UNBOUND receipt (written before these fields existed) matches NOTHING: it
54
+ * cannot name an ask, so attributing it to one would let a message queued for an
55
+ * earlier question resolve whichever question is current (PHNX-3999).
56
+ */
57
+ export declare function receiptMatchesOrigin(receipt: MessageReceipt, origin: ReceiptOrigin): boolean;
27
58
  export interface AnswerRecord {
28
59
  /** ISO-8601 timestamp of when the answer was recorded. */
29
60
  answeredAt: string;
@@ -267,7 +298,22 @@ export declare function recordAnswer(blockId: string, answer: {
267
298
  answeredFrom: string;
268
299
  operatorId?: string;
269
300
  verified?: boolean;
270
- }, root?: string): RecordAnswerResult;
301
+ }, root?: string, options?: {
302
+ pending?: boolean;
303
+ }): RecordAnswerResult;
304
+ /**
305
+ * Promote a pending claim to a resolved answer — the second half of the
306
+ * two-phase answer protocol (see `recordAnswer`'s `pending` option).
307
+ *
308
+ * Called only once a rail has reported a real {@link MessageReceipt}: that is
309
+ * the point the item stops needing a human, so that is the point the tombstone
310
+ * is written and the card may leave the feed. An unconfirmed delivery never
311
+ * reaches here, so its card stays up.
312
+ */
313
+ export declare function confirmAnswerResolution(blockId: string, root?: string, expected?: {
314
+ generation: string;
315
+ answeredAt: string;
316
+ }): boolean;
271
317
  /** Read the answer record for a block, if one exists. */
272
318
  export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
273
319
  /**
@@ -277,6 +323,12 @@ export declare function getAnswerRecord(blockId: string, root?: string): AnswerR
277
323
  * restoring the attention lifecycle to the state another surface observed.
278
324
  */
279
325
  export declare function rollbackAnswerClaim(blockId: string, answeredAt: string, previousBlock: OpenBlock, previousResolution: AttentionResolution | undefined, root?: string): boolean;
326
+ /**
327
+ * How long a release token may sit before it is treated as abandoned. A release
328
+ * is a handful of synchronous file operations, so anything this old belongs to a
329
+ * process that died holding it.
330
+ */
331
+ export declare const RELEASE_TOKEN_STALE_MS = 60000;
280
332
  /** True when the block has already been answered. */
281
333
  export declare function isBlockAnswered(blockId: string, root?: string): boolean;
282
334
  /**
@@ -288,6 +340,14 @@ export declare function isBlockAnswered(blockId: string, root?: string): boolean
288
340
  export declare function recordMessageReceipt(blockId: string, receipt: MessageReceipt, root?: string): void;
289
341
  /** Read the receipt list for a block. */
290
342
  export declare function getBlockReceipts(blockId: string, root?: string): MessageReceipt[];
343
+ /**
344
+ * The furthest-along receipt recorded for a block, or undefined when no rail
345
+ * ever reported one. This is the ONLY truthful evidence that an answer reached
346
+ * a delivery rail: an answer marker alone proves a claim was taken, not that
347
+ * anything was delivered, so a caller reporting on a block it did not deliver
348
+ * must read this rather than synthesize a receipt (PHNX-3999).
349
+ */
350
+ export declare function latestMessageReceipt(blockId: string, root?: string, origin?: ReceiptOrigin): MessageReceipt | undefined;
291
351
  /** Mark a block as "continued" -- the agent consumed the answer and moved on. */
292
352
  export declare function recordContinued(blockId: string, root?: string): void;
293
353
  /** Mark a decision-class block as hard-parked (no safe default existed). */
@@ -23,12 +23,32 @@
23
23
  */
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
+ import * as os from 'os';
26
27
  import * as yaml from 'yaml';
27
28
  import { stringifyDoc } from '../yaml-io.js';
28
29
  import { getFeedDir, getUserAgentsDir } from '../state.js';
29
30
  import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
30
31
  import { projectKeyFromCwd } from '../project-key.js';
31
32
  import { atomicWriteJsonSync } from '../fs-atomic.js';
33
+ /**
34
+ * Whether a receipt describes THIS ask.
35
+ *
36
+ * Identity is the GENERATION alone, never the attempt. The question is "does
37
+ * this receipt answer this ask?", and a second attempt on the same ask carries
38
+ * the same answer -- so a stranded claim that is adopted (which necessarily
39
+ * mints a new attempt) must still recognise the message its predecessor queued,
40
+ * or it enqueues a duplicate. `attempt` rides along as provenance for the
41
+ * delivery check, not as part of identity.
42
+ *
43
+ * An UNBOUND receipt (written before these fields existed) matches NOTHING: it
44
+ * cannot name an ask, so attributing it to one would let a message queued for an
45
+ * earlier question resolve whichever question is current (PHNX-3999).
46
+ */
47
+ export function receiptMatchesOrigin(receipt, origin) {
48
+ if (receipt.generation === undefined)
49
+ return false;
50
+ return receipt.generation === origin.generation;
51
+ }
32
52
  function resolutionDir(root) { return path.join(root, 'resolutions'); }
33
53
  /**
34
54
  * Canonical generation for a block. A writer that stamped `generation` wins;
@@ -129,7 +149,7 @@ export function readBlock(blockId, root) {
129
149
  * High-consequence blocks require a verified operator identity. Unverified
130
150
  * answers (no operatorId or not in the registry/allowed list) are refused.
131
151
  */
132
- export function recordAnswer(blockId, answer, root) {
152
+ export function recordAnswer(blockId, answer, root, options = {}) {
133
153
  const dir = root ?? getFeedDir();
134
154
  const block = readBlock(blockId, dir);
135
155
  const operatorId = answer.operatorId;
@@ -187,24 +207,74 @@ export function recordAnswer(blockId, answer, root) {
187
207
  }
188
208
  throw err;
189
209
  }
190
- // Marker created successfully -- mirror the answer into the block file and
191
- // advance the lifecycle to `answered`. The resolution tombstone is written
192
- // first, so if a stale lifecycle re-read races the block-file update the
193
- // reconciler already refuses to resurrect this generation.
210
+ // Marker created successfully -- mirror the answer into the block file.
211
+ //
212
+ // A `pending` claim stops there: the claim is recorded so no second surface
213
+ // can take it, but the generation is NOT resolved and the lifecycle stays
214
+ // `open`, so the card remains in the operator's feed until a rail reports a
215
+ // real receipt (`confirmAnswerResolution`). A claim is not a delivery, and a
216
+ // claim whose delivery is never confirmed must not silently remove the item
217
+ // (PHNX-3999). `state` wins over `answer` in `deriveBlockState`, so the
218
+ // explicit `open` is what keeps the claimed block visible.
219
+ //
220
+ // The default one-phase path advances straight to `answered` for surfaces
221
+ // that resolve atomically (a policy default, a synchronous enqueue). The
222
+ // resolution tombstone is written first, so if a stale lifecycle re-read races
223
+ // the block-file update the reconciler already refuses to resurrect this
224
+ // generation.
194
225
  if (block) {
195
- recordResolution({
196
- blockId,
197
- generation: blockGeneration(block),
198
- resolvedAt: record.answeredAt,
199
- sourceCursor: block.sourceCursor,
200
- reason: 'answered',
201
- }, dir);
226
+ if (!options.pending) {
227
+ recordResolution({
228
+ blockId,
229
+ generation: blockGeneration(block),
230
+ resolvedAt: record.answeredAt,
231
+ sourceCursor: block.sourceCursor,
232
+ reason: 'answered',
233
+ }, dir);
234
+ }
202
235
  block.answer = record;
203
- block.state = 'answered';
236
+ block.state = options.pending ? 'open' : 'answered';
204
237
  publishBlock(block, dir);
205
238
  }
206
239
  return { ok: true };
207
240
  }
241
+ /**
242
+ * Promote a pending claim to a resolved answer — the second half of the
243
+ * two-phase answer protocol (see `recordAnswer`'s `pending` option).
244
+ *
245
+ * Called only once a rail has reported a real {@link MessageReceipt}: that is
246
+ * the point the item stops needing a human, so that is the point the tombstone
247
+ * is written and the card may leave the feed. An unconfirmed delivery never
248
+ * reaches here, so its card stays up.
249
+ */
250
+ export function confirmAnswerResolution(blockId, root, expected) {
251
+ const dir = root ?? getFeedDir();
252
+ const block = readBlock(blockId, dir);
253
+ const record = getAnswerRecord(blockId, dir);
254
+ if (!block || !record)
255
+ return false;
256
+ // One block id serves every generation of a session's asks, so a slow
257
+ // delivery for the PREVIOUS question must not resolve the one the agent has
258
+ // moved on to. A caller that knows which ask and which attempt it is
259
+ // confirming says so, and a mismatch is a no-op rather than a wrong tombstone.
260
+ // Bound to the ASK. The attempt is deliberately NOT compared: adopting a
261
+ // stranded claim mints a new attempt for the same question, and that adoption
262
+ // must still be able to resolve the ask it completed. The generation is what
263
+ // distinguishes one question from the next, which is the actual hazard.
264
+ if (expected && blockGeneration(block) !== expected.generation)
265
+ return false;
266
+ recordResolution({
267
+ blockId,
268
+ generation: blockGeneration(block),
269
+ resolvedAt: record.answeredAt,
270
+ sourceCursor: block.sourceCursor,
271
+ reason: 'answered',
272
+ }, dir);
273
+ block.answer = record;
274
+ block.state = 'answered';
275
+ publishBlock(block, dir);
276
+ return true;
277
+ }
208
278
  /** Read the answer record for a block, if one exists. */
209
279
  export function getAnswerRecord(blockId, root) {
210
280
  return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
@@ -221,6 +291,31 @@ export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previous
221
291
  const current = safeReadJson(marker);
222
292
  if (!current || current.answeredAt !== answeredAt)
223
293
  return false;
294
+ // Read-compare-then-unlink is NOT atomic, and two callers releasing the SAME
295
+ // claim is a real interleaving: both pass the compare, the first unlinks and
296
+ // re-claims, then the second unlinks the FIRST'S fresh marker and both end up
297
+ // holding a claim. The release is therefore gated on an O_EXCL token keyed by
298
+ // the exact claim being released -- the same primitive `recordAnswer` uses, so
299
+ // exactly one caller can ever release a given `answeredAt` (PHNX-3999).
300
+ const release = path.join(answeredDir(dir), `${blockId}.${answeredAt.replace(/[^0-9A-Za-z]/g, '')}.release`);
301
+ if (!acquireReleaseToken(release))
302
+ return false;
303
+ const dropToken = () => {
304
+ try {
305
+ fs.unlinkSync(release);
306
+ }
307
+ catch (error) {
308
+ if (error.code !== 'ENOENT')
309
+ throw error;
310
+ }
311
+ };
312
+ // Re-read INSIDE the token: a racer that released-and-re-claimed between our
313
+ // first read and the token acquisition would otherwise be clobbered.
314
+ const held = safeReadJson(marker);
315
+ if (!held || held.answeredAt !== answeredAt) {
316
+ dropToken();
317
+ return false;
318
+ }
224
319
  // Restore while the O_EXCL marker still excludes every other claimant. The
225
320
  // marker is removed LAST; once another writer can win recordAnswer, this
226
321
  // rollback has no state left to overwrite.
@@ -237,9 +332,79 @@ export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previous
237
332
  throw error;
238
333
  }
239
334
  }
240
- fs.unlinkSync(marker);
335
+ try {
336
+ fs.unlinkSync(marker);
337
+ }
338
+ catch (error) {
339
+ if (error.code !== 'ENOENT')
340
+ throw error;
341
+ }
342
+ // The token has done its job: this exact claim can never be released again,
343
+ // because the claim it names no longer exists. Leaving it would accumulate one
344
+ // dead file per released claim forever.
345
+ dropToken();
241
346
  return true;
242
347
  }
348
+ /**
349
+ * How long a release token may sit before it is treated as abandoned. A release
350
+ * is a handful of synchronous file operations, so anything this old belongs to a
351
+ * process that died holding it.
352
+ */
353
+ export const RELEASE_TOKEN_STALE_MS = 60_000;
354
+ /**
355
+ * Take the O_EXCL token that serialises releasing one specific claim.
356
+ *
357
+ * The token MUST be recoverable: a process killed between creating it and
358
+ * finishing would otherwise wedge that claim forever, and "the answer can never
359
+ * be released again" is a worse failure than the race the token prevents. So the
360
+ * token records its owner and its age, and a token whose owner is provably gone
361
+ * (same host, no such pid) or which is simply stale is reclaimed once.
362
+ */
363
+ function acquireReleaseToken(release) {
364
+ const mine = { pid: process.pid, host: os.hostname(), at: Date.now() };
365
+ const create = () => {
366
+ try {
367
+ const fd = fs.openSync(release, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
368
+ try {
369
+ fs.writeSync(fd, Buffer.from(JSON.stringify(mine), 'utf-8'));
370
+ }
371
+ finally {
372
+ fs.closeSync(fd);
373
+ }
374
+ return true;
375
+ }
376
+ catch (error) {
377
+ if (error.code === 'EEXIST')
378
+ return false;
379
+ throw error;
380
+ }
381
+ };
382
+ if (create())
383
+ return true;
384
+ const held = safeReadJson(release);
385
+ const ageMs = held?.at ? Date.now() - held.at : Number.POSITIVE_INFINITY;
386
+ let ownerGone = false;
387
+ if (held?.host === mine.host && typeof held.pid === 'number') {
388
+ // Signal 0 probes liveness without delivering anything.
389
+ try {
390
+ process.kill(held.pid, 0);
391
+ }
392
+ catch {
393
+ ownerGone = true;
394
+ }
395
+ }
396
+ if (!ownerGone && ageMs < RELEASE_TOKEN_STALE_MS)
397
+ return false; // a live peer owns it
398
+ try {
399
+ fs.unlinkSync(release);
400
+ }
401
+ catch (error) {
402
+ if (error.code !== 'ENOENT')
403
+ throw error;
404
+ }
405
+ // Exactly one reclaimer wins the re-create; the rest see EEXIST and back off.
406
+ return create();
407
+ }
243
408
  /** True when the block has already been answered. */
244
409
  export function isBlockAnswered(blockId, root) {
245
410
  return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
@@ -277,11 +442,58 @@ export function recordMessageReceipt(blockId, receipt, root) {
277
442
  }
278
443
  block.receipts = receipts;
279
444
  publishBlock(block, dir);
445
+ // The AGENT's own acknowledgement is what resolves a pending claim: `queued`
446
+ // only says a rail took the answer, so it must never remove the card
447
+ // (PHNX-3999).
448
+ //
449
+ // The promotion is bound to the RECEIPT's own origin, not to whatever the
450
+ // block happens to hold now. Checking `block.answer` alone was not enough: if
451
+ // the agent moved to question N+1 AND that ask was itself claimed, a late
452
+ // acknowledgement for question N found a live claim and resolved the wrong
453
+ // question. `confirmAnswerResolution`'s own compare is what rejects it.
454
+ //
455
+ // An UNBOUND receipt never resolves. It cannot say which ask it acknowledges,
456
+ // so promoting it against whatever claim happens to be live would resolve the
457
+ // wrong question -- the exact failure this binding exists to prevent. Its
458
+ // delivery evidence is still recorded above; the card then clears through the
459
+ // ordinary session-advance path instead.
460
+ if ((receipt.status === 'consumed' || receipt.status === 'continued')
461
+ && receipt.generation !== undefined && block.answer) {
462
+ confirmAnswerResolution(blockId, dir, {
463
+ generation: receipt.generation, answeredAt: block.answer.answeredAt,
464
+ });
465
+ }
280
466
  }
281
467
  /** Read the receipt list for a block. */
282
468
  export function getBlockReceipts(blockId, root) {
283
469
  return readBlock(blockId, root)?.receipts ?? [];
284
470
  }
471
+ /**
472
+ * The furthest-along receipt recorded for a block, or undefined when no rail
473
+ * ever reported one. This is the ONLY truthful evidence that an answer reached
474
+ * a delivery rail: an answer marker alone proves a claim was taken, not that
475
+ * anything was delivered, so a caller reporting on a block it did not deliver
476
+ * must read this rather than synthesize a receipt (PHNX-3999).
477
+ */
478
+ export function latestMessageReceipt(blockId, root, origin) {
479
+ const all = getBlockReceipts(blockId, root);
480
+ // A block id is per SESSION, so its receipt list accumulates across every
481
+ // generation of that session's asks. Reading it unfiltered lets question N's
482
+ // receipt answer for question N+1 -- so a caller that knows which ask it is
483
+ // asking about passes the origin and sees only that ask's evidence.
484
+ const receipts = origin ? all.filter((receipt) => receiptMatchesOrigin(receipt, origin)) : all;
485
+ let best;
486
+ for (const receipt of receipts) {
487
+ if (!best) {
488
+ best = receipt;
489
+ continue;
490
+ }
491
+ const rank = RECEIPT_STATUS_RANK[receipt.status] - RECEIPT_STATUS_RANK[best.status];
492
+ if (rank > 0 || (rank === 0 && receipt.at >= best.at))
493
+ best = receipt;
494
+ }
495
+ return best;
496
+ }
285
497
  /** Mark a block as "continued" -- the agent consumed the answer and moved on. */
286
498
  export function recordContinued(blockId, root) {
287
499
  const dir = root ?? getFeedDir();
@@ -1,7 +1,8 @@
1
1
  import { FeedHub } from './hub.js';
2
2
  import type { FeedWatchEnvelope } from './envelope.js';
3
3
  /**
4
- * Outbound bytes a single reader may leave unflushed before it is dropped.
4
+ * Live bytes a reader may leave queued, sustained past {@link HUB_BACKLOG_GRACE_MS},
5
+ * before it is dropped.
5
6
  *
6
7
  * `socket.write()` never blocks: when a reader stops draining — a stopped
7
8
  * process, a suspended laptop, a debugger paused on a breakpoint — node buffers
@@ -10,8 +11,49 @@ import type { FeedWatchEnvelope } from './envelope.js';
10
11
  * can never reclaim, and the daemon is the process every other surface depends
11
12
  * on. A reader that cannot keep up is dropped loudly instead: it can reconnect
12
13
  * and be caught up from held state, which is cheaper than the backlog.
14
+ *
15
+ * The budget is a SUSTAINED condition on queued live events, never a verdict on
16
+ * one envelope or one burst. A cold collector delivers every peer's reset as a
17
+ * live event, thirteen of them inside one tick, and a healthy reader drains
18
+ * that in milliseconds; judging the budget the instant an envelope was written
19
+ * is how a 5 MB fleet reset was cut off after 8 KiB and delivered as one
20
+ * unterminated line (the Menu activation failure this module's writer fixes).
21
+ * The catch-up snapshot is never counted: it is bounded by the held state and is
22
+ * exactly what a fresh reader is waiting for.
13
23
  */
14
24
  export declare const HUB_CLIENT_BACKLOG_LIMIT: number;
25
+ /**
26
+ * How long a reader may stay past {@link HUB_CLIENT_BACKLOG_LIMIT} before it is
27
+ * dropped. A healthy reader on a unix socket clears the whole budget in well
28
+ * under this; one still over it after this long is not keeping up. The live
29
+ * bytes queued for a reader are therefore bounded by the budget plus what the
30
+ * stream produces in this window; the snapshot and the frame in flight sit
31
+ * outside that figure.
32
+ */
33
+ export declare const HUB_BACKLOG_GRACE_MS = 2000;
34
+ /**
35
+ * How long a reader may leave one chunk unaccepted before it is dropped.
36
+ *
37
+ * A write that returned `false` is a kernel buffer full of bytes the reader has
38
+ * not read. A healthy reader — even one on a busy laptop — clears it in
39
+ * milliseconds; one that has not in this long is not reading at all, and the
40
+ * live-bytes budget alone would let a paused reader hold a large snapshot's
41
+ * remainder in the daemon's heap forever.
42
+ */
43
+ export declare const HUB_DRAIN_STALL_MS = 30000;
44
+ /**
45
+ * Bytes handed to the socket per write. Small enough that a reader's own
46
+ * backpressure (`write()` returning `false`, then `'drain'`) paces a multi-MB
47
+ * snapshot instead of dumping it into the daemon's heap in one copy.
48
+ */
49
+ export declare const HUB_WRITE_CHUNK_BYTES: number;
50
+ /** Optional overrides for the transport bounds. Tests exercise both bounds fast. */
51
+ export type FeedHubLimits = {
52
+ backlogBytes?: number;
53
+ backlogGraceMs?: number;
54
+ drainStallMs?: number;
55
+ chunkBytes?: number;
56
+ };
15
57
  /**
16
58
  * How long a reader gets to send its scope line before it is REJECTED.
17
59
  *
@@ -40,8 +82,12 @@ export declare class FeedHubServer {
40
82
  private readonly detachers;
41
83
  /** Which collector each reader is attached to, so a failure reaches only its own. */
42
84
  private readonly attachedTo;
43
- /** Readers dropped for exceeding {@link HUB_CLIENT_BACKLOG_LIMIT}. Observability. */
85
+ private readonly writers;
86
+ private readonly limits;
87
+ /** Readers dropped for queueing live bytes past the budget. Observability. */
44
88
  droppedForBacklog: number;
89
+ /** Readers dropped for not draining a chunk within the stall deadline. Observability. */
90
+ droppedForStall: number;
45
91
  /** Readers refused for a missing, invalid, or late scope line. Observability. */
46
92
  rejectedHandshakes: number;
47
93
  /**
@@ -51,11 +97,13 @@ export declare class FeedHubServer {
51
97
  * every reader from the fleet hub, which is what the fleet
52
98
  * stream already contained.
53
99
  */
54
- constructor(hub: FeedHub, socketPathOverride?: string | undefined, localHub?: FeedHub | undefined);
100
+ constructor(hub: FeedHub, socketPathOverride?: string | undefined, localHub?: FeedHub | undefined, limits?: FeedHubLimits);
55
101
  /** Report a collector failure to its readers and end those connections. */
56
102
  private failReaders;
57
103
  /** Subscribers currently connected. Observability + tests. */
58
104
  get clientCount(): number;
105
+ /** Bytes queued for every reader and not yet handed to a socket. Observability + tests. */
106
+ get pendingBytes(): number;
59
107
  start(): Promise<void>;
60
108
  stop(): Promise<void>;
61
109
  }
@@ -76,6 +124,13 @@ export declare function waitForHub(endpoint?: string, deadlineMs?: number, inter
76
124
  * quietly ran its own `watchFleetFeed` instead would restore the per-caller
77
125
  * ssh fan-out, so the caller starts the daemon and retries rather than
78
126
  * degrading into the thing this replaced.
127
+ *
128
+ * Also rejects on any close the caller did not ask for. The stream has no end
129
+ * of its own — the hub serves it until the reader leaves — so a FIN that
130
+ * arrives before `signal` aborts is the hub refusing, failing, or dropping this
131
+ * reader, and a FIN inside a line is a frame the hub never finished. Resolving
132
+ * there let `agents feed watch --json` exit 0 after 8 KiB of a 5 MB reset,
133
+ * which is indistinguishable from an empty fleet.
79
134
  */
80
135
  export declare function streamFeedFromHub(options: {
81
136
  signal: AbortSignal;