switchroom 0.19.1 → 0.19.3

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 (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -313,6 +313,40 @@ export function withStoreLock<T>(
313
313
  return run
314
314
  }
315
315
 
316
+ /**
317
+ * Per-pinKey async serial lock (F2, invisible-worker-cards review).
318
+ *
319
+ * The gateway's status-pin reconcile reads its `prev` claim from an in-memory
320
+ * map at the TOP of the reconcile, then awaits the persist+pin. Two overlapping
321
+ * reconciles for the SAME key could each capture a stale `prev`: a turn-end
322
+ * `{pinned:false}` reading prev=null no-ops and clears the durable row while a
323
+ * flood-delayed open-pin lands right after — a stuck pin with NO on-disk record.
324
+ *
325
+ * Chaining every reconcile for one pinKey through this tail map guarantees the
326
+ * NEXT reconcile reads `prev` only after the prior one for that key has fully
327
+ * settled (its in-memory Maps updated), so the pin decision always sees the true
328
+ * current claim. Different keys never contend. Same non-rejecting-tail contract
329
+ * as `withStoreLock`. Keyed by pinKey, held by the gateway around the WHOLE
330
+ * read-prev → decide → reconcileAndPersistStatusPin → update-Maps sequence.
331
+ */
332
+ const pinReconcileTails = new Map<string, Promise<unknown>>()
333
+
334
+ export function withPinReconcileLock<T>(
335
+ pinKey: string,
336
+ fn: () => Promise<T>,
337
+ ): Promise<T> {
338
+ const prev = pinReconcileTails.get(pinKey) ?? Promise.resolve()
339
+ const run = prev.then(fn, fn)
340
+ pinReconcileTails.set(
341
+ pinKey,
342
+ run.then(
343
+ () => undefined,
344
+ () => undefined,
345
+ ),
346
+ )
347
+ return run
348
+ }
349
+
316
350
  /**
317
351
  * READ-MODIFY-WRITE for exactly ONE pinKey's row, against the authoritative
318
352
  * on-disk file. Loads the current snapshot from disk, drops any row for
@@ -431,11 +465,37 @@ export function reconcileAndPersistStatusPin(args: {
431
465
  return next
432
466
  }
433
467
 
434
- // clear: unpin (best-effort) THEN drop the record. Ordering is safe here —
435
- // if we crash after the unpin but before the rewrite, the stale record just
436
- // gets unpinned again next boot (idempotent), never a lingering pin.
468
+ // clear: unpin (best-effort) THEN reconcile the record with the OUTCOME.
469
+ //
470
+ // F1 (invisible-worker-cards review): a `clear` op covers BOTH a genuine
471
+ // unpin AND a `noop: already pinned` — the pin decision maps every non-`pin`
472
+ // action here (see decidePinAction → the gateway's op mapping). For a real
473
+ // unpin, applyPin drops the claim and returns null → we remove the row. But
474
+ // for a noop-already-pinned, reconcilePin returns the LIVE claim unchanged
475
+ // (non-null) and issues NO Telegram call — the pin is still up. The worker
476
+ // feed calls syncPin on EVERY steady-state edit, so a noop-clear fires
477
+ // constantly; unconditionally deleting the row there erased the durable
478
+ // status-pins.json entry for a still-live pin. A crash after that left a
479
+ // stuck pinned card boot cleanup could never see. So: only drop the row when
480
+ // the claim is actually gone (next == null); when applyPin returns a live
481
+ // claim, PRESERVE the row (rewritten confirmed) so the durable record keeps
482
+ // tracking the pin that is genuinely still up. No extra Telegram API call is
483
+ // added — applyPin already ran; this only changes the disk write's content.
484
+ // Ordering for the real-unpin case is safe: if we crash after the unpin but
485
+ // before the rewrite, the stale record just gets unpinned again next boot
486
+ // (idempotent), never a lingering pin.
437
487
  const next = await args.applyPin()
438
- applyStatusPinRow(path, fs, pinKey, null, log)
488
+ if (next == null) {
489
+ applyStatusPinRow(path, fs, pinKey, null, log)
490
+ } else {
491
+ applyStatusPinRow(
492
+ path,
493
+ fs,
494
+ pinKey,
495
+ { pinKey, chatId, messageId: next.messageId },
496
+ log,
497
+ )
498
+ }
439
499
  return next
440
500
  })
441
501
  }
@@ -307,6 +307,9 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
307
307
  // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
308
308
  // latch, reset at turn start alongside the other answer flags.
309
309
  answerDelivered: false,
310
+ // #3429 — flushed-answer text for the content-vs-flush latch
311
+ // discrimination; stamped at flush arm, reset at turn start.
312
+ flushedAnswerText: null,
310
313
  // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
311
314
  endedAt: null,
312
315
  firstPingAt: null,
@@ -535,7 +538,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
535
538
  if (turn != null) {
536
539
  turn.currentModel = ev.model
537
540
  }
538
- sessionModelSource.noteTranscriptModel(ev.model)
541
+ // `replayed` (#3427 H2): a first-attach replay line carries the
542
+ // PRE-restart session's model — record it for freshness (unchanged
543
+ // behavior) but exclude it from divergence verification.
544
+ sessionModelSource.noteTranscriptModel(ev.model, { replayed: ev.replayed === true })
539
545
  return
540
546
  }
541
547
  case 'usage': {
@@ -1623,16 +1629,25 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1623
1629
  // corrects it in place.
1624
1630
  //
1625
1631
  // TWO distinct arbiters set synchronously here, before any `await`:
1626
- // (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
1627
- // reply path already reads (`decideAnswerLatchSuppression` +
1628
- // `flushedTurnSupersede`), exactly as on `main`.
1632
+ // (a) `turn.answerDelivered = 'flush'` — the backstop-vs-LATE-REPLY
1633
+ // signal the reply path already reads
1634
+ // (`decideAnswerLatchSuppression` + `flushedTurnSupersede`).
1635
+ // Source-tagged 'flush' (#3426): the late-reply suppression is
1636
+ // scoped to flush-armed latches, so a later async handback
1637
+ // attributed to a reply-delivered ended turn is never dropped.
1629
1638
  // (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
1630
1639
  // double-fire latch: `claim` returning false means this turn
1631
1640
  // already fired a backstop (answer-ready quiescence, then the
1632
1641
  // turn-end backstop), so this fire is a no-op. It does NOT
1633
1642
  // arbitrate the late reply (that is (a)); it is redundant-but-
1634
1643
  // cheap with the `currentTurn == null` bail below.
1635
- turn.answerDelivered = true
1644
+ turn.answerDelivered = 'flush'
1645
+ // #3429 — stamp WHAT the flush is delivering alongside the arm, so the
1646
+ // late-reply suppression can discriminate by content: a late reply
1647
+ // carrying this same answer is the flush race (suppress/supersede); a
1648
+ // late reply carrying DIFFERENT content is a genuinely new async
1649
+ // handback attributed to this ended turn and must send fresh.
1650
+ turn.flushedAnswerText = capturedText
1636
1651
  const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
1637
1652
 
1638
1653
  // #654 deterministic double-message fix. Hand off the pinned
@@ -1830,6 +1845,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1830
1845
  if (backstopCtrl) backstopCtrl.finalize('error')
1831
1846
  backstopDeliveryLedger.release(turn.turnId)
1832
1847
  turn.answerDelivered = false
1848
+ turn.flushedAnswerText = null // #3429 — cleared with the latch
1833
1849
  } else if (backstopCtrl) {
1834
1850
  backstopCtrl.finalize('done')
1835
1851
  }
@@ -1854,6 +1870,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1854
1870
  process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
1855
1871
  if (!delivered) {
1856
1872
  turn.answerDelivered = false
1873
+ turn.flushedAnswerText = null // #3429 — cleared with the latch
1857
1874
  backstopDeliveryLedger.release(turn.turnId)
1858
1875
  if (backstopCtrl) backstopCtrl.finalize('error')
1859
1876
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Adversarial-review F4 — account-label masking policy for the `/usage` card.
3
+ *
4
+ * A group configured with an EMPTY `allowFrom` authorizes every member
5
+ * (`isAuthorizedSender` returns true for any sender in that group). That is an
6
+ * intentional "whole-group" access mode, but it means `/usage` would expose
7
+ * per-account email labels + quota headroom to every member of a broadly
8
+ * shared group. This predicate decides whether to mask account labels for the
9
+ * quota-bearing card, reusing the existing demo-mask machinery:
10
+ *
11
+ * - private (operator DM) chat → never mask
12
+ * - group / supergroup with a NON-empty pinned `allowFrom` → trusted,
13
+ * operator-curated member list → don't mask
14
+ * - group / supergroup with an EMPTY `allowFrom` (open membership) → mask
15
+ * - any other chat type reaching a quota render → mask defensively
16
+ *
17
+ * This changes only what `/usage` REVEALS, not who may run it — general
18
+ * command authorization semantics are untouched.
19
+ */
20
+ export function shouldMaskUsageLabels(
21
+ chatType: string | undefined,
22
+ groupAllowFrom: readonly string[] | undefined,
23
+ ): boolean {
24
+ if (chatType === 'private') return false
25
+ if (chatType === 'group' || chatType === 'supergroup') {
26
+ return (groupAllowFrom ?? []).length === 0
27
+ }
28
+ return true
29
+ }
@@ -360,6 +360,22 @@ function main() {
360
360
  }
361
361
 
362
362
  const input = event.tool_input ?? {}
363
+ // F3 (progress-card fork model): a FORK dispatch (`subagent_type === 'fork'`)
364
+ // inherits the PARENT session's model and IGNORES any `tool_input.model`
365
+ // override. Seeding the row's first-paint model from that ignored override
366
+ // makes the worker card show a WRONG model (e.g. "sonnet" while the fork
367
+ // actually runs Opus) until the watcher overwrites it from the fork's own
368
+ // transcript. Suppress the dispatch-time seed for forks — leave model NULL so
369
+ // the card omits the model rather than showing a value the fork won't honor;
370
+ // the transcript-sourced model then paints as soon as the first assistant
371
+ // line lands (transcript wins, exactly as for non-fork workers). Fixing it at
372
+ // the seed source (not downstream in worker-feed-dispatch) keeps the
373
+ // transcript-confirmed model flowing to the terminal card unchanged.
374
+ const isFork = input.subagent_type === 'fork'
375
+ const dispatchModel =
376
+ !isFork && typeof input.model === 'string' && input.model.length > 0
377
+ ? input.model
378
+ : null
363
379
  // Resolve parent_turn_key from the live turn-active marker (the turn whose
364
380
  // tool call is dispatching this sub-agent). Claude Code's PreToolUse payload
365
381
  // carries only its own session id, never the gateway-minted Telegram turn_key
@@ -384,8 +400,9 @@ function main() {
384
400
  // BEFORE the sub-agent writes its first assistant line. Persisted so the
385
401
  // card can render the model from dispatch; the watcher later overwrites it
386
402
  // from the worker's own transcript (transcript wins). Only a non-empty
387
- // string is stored — never guess from config.
388
- model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
403
+ // string is stored — never guess from config. NULL for a fork dispatch,
404
+ // which ignores the model override (see dispatchModel above, F3).
405
+ model: dispatchModel,
389
406
  now: Date.now(),
390
407
  },
391
408
  (err) => {
@@ -42,6 +42,7 @@ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
42
42
  import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
43
43
  import { escapeMarkdown } from './card-format.js';
44
44
  import { maskEmail } from './demo-mask.js';
45
+ import { formatExternalSpendBlock } from './external-spend.js';
45
46
 
46
47
  // ── dot thresholds ───────────────────────────────────────────────────
47
48
 
@@ -146,18 +147,41 @@ export function buildBar(pct: number, elapsedFrac: number): string {
146
147
 
147
148
  // ── account status (title-line suffix) ───────────────────────────────
148
149
 
149
- export type QuotaBarAccountStatus = 'active' | 'exhausted' | 'idle';
150
+ export type QuotaBarAccountStatus =
151
+ | 'active'
152
+ | 'org-disabled'
153
+ | 'retired'
154
+ | 'exhausted'
155
+ | 'idle';
156
+
157
+ /** Title-line display word for each status (some differ from the enum key). */
158
+ const STATUS_LABEL: Record<QuotaBarAccountStatus, string> = {
159
+ active: 'active',
160
+ 'org-disabled': 'DISABLED (org)',
161
+ retired: 'retired',
162
+ exhausted: 'exhausted',
163
+ idle: 'idle',
164
+ };
150
165
 
151
166
  /**
152
- * Title-line status word. `active` wins over `exhausted` (the fleet's
153
- * pinned account is reported as active even if the broker also flags it
154
- * exhausted matches the locked example where the operator wants to know
155
- * WHICH account is live first, and its health second, from the two window
156
- * rows underneath). Otherwise: `exhausted` if the broker's own flag says
157
- * so, else `idle` (present, healthy, just not the current pick).
167
+ * Title-line status word. Precedence: `active` > `DISABLED (org)` > `retired` >
168
+ * `exhausted` > `idle`.
169
+ * - `active` wins over everything (the fleet's pinned account is reported
170
+ * active even if also flagged exhausted the operator wants WHICH account
171
+ * is live first, health second, from the window rows underneath).
172
+ * - `org-disabled` (entitlement block, PR2) and `retired` (`in_service` false:
173
+ * removed from every config list) are OUT OF SERVICE — never "idle", which
174
+ * reads as an available-but-unused account.
175
+ * - else `exhausted` if the broker flags it, else `idle`.
158
176
  */
159
- export function accountStatus(isActive: boolean, exhausted: boolean): QuotaBarAccountStatus {
177
+ export function accountStatus(
178
+ isActive: boolean,
179
+ exhausted: boolean,
180
+ opts: { inService?: boolean; entitlementBlocked?: boolean } = {},
181
+ ): QuotaBarAccountStatus {
160
182
  if (isActive) return 'active';
183
+ if (opts.entitlementBlocked === true) return 'org-disabled';
184
+ if (opts.inService === false) return 'retired';
161
185
  if (exhausted) return 'exhausted';
162
186
  return 'idle';
163
187
  }
@@ -187,8 +211,9 @@ export function renderQuotaBarAccount(
187
211
  quota: QuotaUtilization | null,
188
212
  now: Date = new Date(),
189
213
  demo = false,
214
+ service: { inService?: boolean; entitlementBlocked?: boolean } = {},
190
215
  ): string[] {
191
- const status = accountStatus(isActive, exhausted);
216
+ const status = accountStatus(isActive, exhausted, service);
192
217
  // Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
193
218
  // needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
194
219
  // `codeSpanSafe` (which only defuses backticks and is only correct
@@ -196,7 +221,18 @@ export function renderQuotaBarAccount(
196
221
  // was a bug: a label containing e.g. `**` or `[x](url)` would break the
197
222
  // bold run or inject a markdown link into the card.
198
223
  const displayLabel = demo ? maskEmail(label) : label;
199
- const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
224
+ const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${STATUS_LABEL[status]})`];
225
+ // Out-of-service accounts (retired / org-disabled) have no meaningful live
226
+ // windows — a 0%/0% bar would read as "available", the exact bug this fixes.
227
+ // Replace the two window rows with a single status note (shape stays a list).
228
+ if (status === 'retired' || status === 'org-disabled') {
229
+ const note =
230
+ status === 'org-disabled'
231
+ ? 'disabled by org — no fleet routing'
232
+ : 'retired — removed from fleet rotation';
233
+ lines.push(`- ⚫ \`${note}\``);
234
+ return lines;
235
+ }
200
236
  if (!quota || isProbeThin(quota)) {
201
237
  // Data-quality gap. A failed / thin probe carries NO real utilization
202
238
  // signal, so it must NOT render as a healthy 🟢 0% bar — that's
@@ -257,6 +293,17 @@ export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
257
293
  * because in that case there IS real data, just stale.
258
294
  */
259
295
  probeFailed?: boolean;
296
+ /**
297
+ * Optional External (OpenRouter / non-Claude cash) spend block — layout B
298
+ * (operator-locked 2026-07-19). When null/undefined the block is omitted
299
+ * entirely (no error rows). When present (including $0.00), bullets land
300
+ * after the recommendation and before the freshness footer.
301
+ */
302
+ externalSpend?: {
303
+ day24hUsd: number;
304
+ day7dUsd: number;
305
+ top: Array<{ label: string; usd: number }>;
306
+ } | null;
260
307
  }
261
308
 
262
309
  /**
@@ -276,10 +323,21 @@ export function renderQuotaBarBlock(
276
323
  const now = opts.now ?? new Date();
277
324
  const demo = opts.demo ?? false;
278
325
  const lines: string[] = [];
279
- for (const snap of snapshots) {
326
+ // Out-of-service accounts (retired / org-disabled) sort LAST — stable
327
+ // partition preserves the caller's active-first ordering among the rest.
328
+ const outOfService = (s: AccountSnapshot) =>
329
+ !s.isActive && (s.entitlementBlocked === true || s.inService === false);
330
+ const ordered = [
331
+ ...snapshots.filter((s) => !outOfService(s)),
332
+ ...snapshots.filter(outOfService),
333
+ ];
334
+ for (const snap of ordered) {
280
335
  const exhausted = exhaustedByLabel.get(snap.label) ?? false;
281
336
  lines.push(
282
- ...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo),
337
+ ...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo, {
338
+ inService: snap.inService,
339
+ entitlementBlocked: snap.entitlementBlocked,
340
+ }),
283
341
  );
284
342
  }
285
343
  return lines.join('\n');
@@ -307,6 +365,8 @@ export function renderQuotaBarBlockFromListState(
307
365
  quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
308
366
  expiresAtMs: acc.expiresAt,
309
367
  capturedAtMs: acc.last_quota?.capturedAt,
368
+ inService: acc.in_service,
369
+ entitlementBlocked: acc.entitlement_blocked,
310
370
  }));
311
371
  return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
312
372
  }
@@ -342,6 +402,12 @@ export function renderUsageCard(
342
402
  const lines = [bar];
343
403
  // Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
344
404
  lines.push(`_${recommendation(snapshots, now, demo)}_`);
405
+ // External cash spend (OpenRouter / non-Claude) — layout B. Omitted when
406
+ // the caller could not fetch a summary (null/undefined: no admin key,
407
+ // timeout, error). Present summary including $0.00 still renders.
408
+ if (opts.externalSpend != null) {
409
+ lines.push(...formatExternalSpendBlock(opts.externalSpend));
410
+ }
345
411
  // Freshness signal: stale-cache warning takes precedence over a live stamp,
346
412
  // which takes precedence over an explicit probe-failed marker (no live data
347
413
  // AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
@@ -252,11 +252,26 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
252
252
  const lines: string[] = [];
253
253
  lines.push("**Claude plan quota**");
254
254
  lines.push("");
255
+ // #2494 Bug C / adversarial-review F3 — a window whose utilization header
256
+ // was absent (a thin probe) has a numeric field that coalesced to 0, so a
257
+ // naive `${pct}%` renders a confident `0%` indistinguishable from a genuine
258
+ // fresh-account 0%. The modern bar card (quota-bar-format.ts ~200) already
259
+ // renders these as "no data"; backport that honesty here. The presence
260
+ // markers are optional — `undefined` means a legacy/real probe (render the
261
+ // percent); only an explicit `false` suppresses the number.
262
+ const fiveHour =
263
+ q.fiveHourUtilPresent === false
264
+ ? "no data"
265
+ : `\`${Math.round(q.fiveHourUtilizationPct)}%\``;
266
+ const sevenDay =
267
+ q.sevenDayUtilPresent === false
268
+ ? "no data"
269
+ : `\`${Math.round(q.sevenDayUtilizationPct)}%\``;
255
270
  lines.push(
256
- `**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
271
+ `**5h window** ${fiveHour} · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
257
272
  );
258
273
  lines.push(
259
- `**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
274
+ `**7d window** ${sevenDay} · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
260
275
  );
261
276
  if (q.representativeClaim) {
262
277
  lines.push("");
@@ -107,6 +107,42 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
107
107
  )
108
108
  }
109
109
 
110
+ /**
111
+ * The answer-delivered latch value — SOURCE-TAGGED (#3426).
112
+ *
113
+ * `false` — no answer delivered this turn (latch unarmed).
114
+ * `'flush'` — the TURN-FLUSH backstop delivered (or is mid-delivering) this
115
+ * turn's answer as its own message A. Set synchronously at
116
+ * flush-fire time, and at supersede-record consumption (the
117
+ * resurrection window — a flush record existed for this turn).
118
+ * `'reply'` — a normally-delivered `reply` tool call carried this turn's
119
+ * answer (no flush involved).
120
+ *
121
+ * Why the tag exists: the late-reply suppression below is a race backstop for
122
+ * FLUSH duplicates only. A boolean latch also suppressed the async sub-agent
123
+ * handback pattern (#3426): the parent turn's interim ack (a substantive
124
+ * `reply`) armed the latch, the turn ended, and the sub-agent completion
125
+ * handback — a genuinely NEW answer arriving with no live gateway turn —
126
+ * resolved the ended turn as owner (latest-ended tier, inside the 60 s
127
+ * supersede TTL), saw the stale latch, and was silently dropped with a false
128
+ * "deduped" success. Tagging the source lets the suppression fire ONLY for the
129
+ * flush races it exists for; byte-identical replays of a reply-delivered
130
+ * answer remain covered by the content-keyed outbound dedup (#546).
131
+ *
132
+ * Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
133
+ * RECORD time, while the latest-ended owner tier's 60 s is anchored at the
134
+ * turn's `endedAt` — later by the reply→turn_end gap. A byte-identical replay
135
+ * landing >60 s after record but ≤60 s after endedAt is evicted from dedup yet
136
+ * still resolves the ended turn, so it DELIVERS as a duplicate message. That
137
+ * is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
138
+ * duplicate" suppression the reply-armed boolean latch used to provide —
139
+ * replays of an un-acked tool_call are byte-identical (content dedup's case),
140
+ * and a model-REGENERATED paraphrase is indistinguishable from a genuinely new
141
+ * handback, so delivering it is the correct default. A rare duplicate message
142
+ * beats the silent handback drop.
143
+ */
144
+ export type AnswerDeliveredLatch = false | 'flush' | 'reply'
145
+
110
146
  /**
111
147
  * The answer-delivered latch inputs (Part 2 — the race backstop).
112
148
  *
@@ -118,11 +154,11 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
118
154
  * There `flushed-turn-supersede` finds no record (nothing to delete yet) and the
119
155
  * reply would ship message B as a duplicate of the flush's message A.
120
156
  *
121
- * The latch closes that window: the gateway sets `answerDelivered = true` on the
122
- * turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send and
123
- * before the record — and the flag persists on the ended turn (readable via the
124
- * unified resolver after `currentTurn` is null). A reply landing in the race
125
- * window then sees the latch already set and suppresses itself.
157
+ * The latch closes that window: the gateway sets `answerDelivered = 'flush'` on
158
+ * the turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send
159
+ * and before the record — and the flag persists on the ended turn (readable via
160
+ * the unified resolver after `currentTurn` is null). A reply landing in the race
161
+ * window then sees the flush latch already set and suppresses itself.
126
162
  */
127
163
  export interface AnswerLatchSuppressInput {
128
164
  /** True when Part 1's supersede already fired for THIS reply (message A was
@@ -140,21 +176,50 @@ export interface AnswerLatchSuppressInput {
140
176
  * legitimate second in-turn substantive reply (a genuine multi-message
141
177
  * answer, live currentTurn) untouched. */
142
178
  isLateReply: boolean
143
- /** The resolved owner turn's `answerDelivered` latch. */
144
- ownerAnswerDelivered: boolean
179
+ /** The resolved owner turn's source-tagged `answerDelivered` latch. */
180
+ ownerAnswerDelivered: AnswerDeliveredLatch
181
+ /** #3429 — content evidence: does the landing reply carry the SAME answer
182
+ * the flush delivered (`flushedAnswerMatchesReply` against the supersede
183
+ * record's text or the owner turn's stashed `flushedAnswerText`)?
184
+ * - `false` → POSITIVE evidence of genuinely new content (an async
185
+ * handback attributed to the flush-delivered ended turn).
186
+ * Suppressing it would silently drop the user's answer —
187
+ * never suppress.
188
+ * - `true` → the reply is the flushed answer landing again — suppress
189
+ * (the flush race the latch exists for).
190
+ * - `null` / omitted → no flushed text available to compare (legacy atom,
191
+ * pre-#3429 caller). Conservative: keep the pre-#3429
192
+ * flush-armed suppression. */
193
+ replyMatchesFlushedAnswer?: boolean | null
145
194
  }
146
195
 
147
196
  /**
148
197
  * Decide whether the answer-delivered latch suppresses a landing reply.
149
198
  *
150
199
  * Suppress IFF: Part 1 did NOT already supersede, the reply is a substantive
151
- * final answer, it is a late reply (no live turn), AND the owner turn's latch is
152
- * already set (the flush delivered the same substantive answer as message A in
153
- * the pre-record race window). Otherwise the reply sends.
200
+ * final answer, it is a late reply (no live turn), AND the owner turn's latch
201
+ * was armed by the TURN-FLUSH path (`'flush'` — the flush delivered the same
202
+ * substantive answer as message A in the pre-record race window, or the
203
+ * supersede-consumed resurrection window). Otherwise the reply sends.
204
+ *
205
+ * A `'reply'`-armed latch deliberately does NOT suppress (#3426): the prior
206
+ * answer went out via a normal, completed `reply`, so a later late-landing
207
+ * reply attributed to that ended turn is NOT a flush duplicate — it is
208
+ * (typically) an async sub-agent handback carrying genuinely new content, and
209
+ * suppressing it silently drops the user's answer. Byte-identical replays of
210
+ * the delivered reply are still deduped by the content-keyed #546 cache.
211
+ *
212
+ * #3429 refinement: even a FLUSH-armed latch does not suppress when there is
213
+ * POSITIVE content evidence (`replyMatchesFlushedAnswer === false`) that the
214
+ * landing reply is NOT the flushed answer — an async handback attributed to a
215
+ * flush-delivered ended turn must send fresh, not vanish. Absent evidence
216
+ * (`null`/omitted) the flush-armed suppression holds, preserving the #2996
217
+ * Part 2 race backstop.
154
218
  */
155
219
  export function decideAnswerLatchSuppression(input: AnswerLatchSuppressInput): boolean {
156
220
  if (input.superseded) return false
157
221
  if (!input.replySubstantive) return false
158
222
  if (!input.isLateReply) return false
159
- return input.ownerAnswerDelivered
223
+ if (input.replyMatchesFlushedAnswer === false) return false
224
+ return input.ownerAnswerDelivered === 'flush'
160
225
  }
@@ -100,7 +100,12 @@ export type SessionEvent =
100
100
  // same batch already reflects the current model. Sentinels (`<synthetic>` on
101
101
  // compaction lines, fixture junk) are filtered at projection — see
102
102
  // isModelSentinel — so this only ever carries a real resolved model id.
103
- | { kind: 'model'; model: string }
103
+ // `replayed: true` marks a model observation delivered by the FIRST-ATTACH
104
+ // replay of a prior session's in-flight turn (computeFirstAttachCursor) —
105
+ // it reflects the PRE-restart session's model, not the live one. Consumers
106
+ // that verify the live model (#3427 divergence tripwire, H2) must skip
107
+ // replayed observations; freshness consumers may still record them.
108
+ | { kind: 'model'; model: string; replayed?: boolean }
104
109
  | { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
105
110
  // Real-time tool label from the PreToolUse-hook sidecar — fires when the
106
111
  // hook writes the label (synchronous at tool-call time), independent of
@@ -1217,6 +1222,17 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1217
1222
  // re-attach.
1218
1223
  const fileCursors = new Map<string, { cursor: number; pendingPartial: string }>()
1219
1224
 
1225
+ // First-attach REPLAY window per file (#3427 H2): when attachToFile replays
1226
+ // a prior session's in-flight turn (computeFirstAttachCursor returned an
1227
+ // offset below the size-at-attach), every byte below that size is HISTORY
1228
+ // written by the pre-restart session. Model observations projected from it
1229
+ // must be marked `replayed` so the divergence tripwire ignores them.
1230
+ // Granularity is the read CHUNK (a batch whose read started inside the
1231
+ // window is marked wholesale) — deliberately conservative: over-marking a
1232
+ // boundary batch can only delay verification to the next live line, never
1233
+ // false-accuse.
1234
+ const replayUntilByFile = new Map<string, number>()
1235
+
1220
1236
  function readNew(): void {
1221
1237
  if (stopped || !currentFile) return
1222
1238
  try {
@@ -1226,9 +1242,14 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1226
1242
  // stored per-file state for this path.
1227
1243
  cursor = 0
1228
1244
  pendingPartial = ''
1229
- if (currentFile != null) fileCursors.delete(currentFile)
1245
+ if (currentFile != null) {
1246
+ fileCursors.delete(currentFile)
1247
+ replayUntilByFile.delete(currentFile)
1248
+ }
1230
1249
  }
1231
1250
  if (stat.size === cursor) return
1251
+ const chunkStart = cursor
1252
+ const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0)
1232
1253
  const buf = Buffer.alloc(stat.size - cursor)
1233
1254
  const fd = openSync(currentFile, 'r')
1234
1255
  try {
@@ -1247,7 +1268,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1247
1268
  const sid = sessionIdForFile(currentFile)
1248
1269
  for (const ev of events) {
1249
1270
  try {
1250
- onEvent(decorate(ev, sid))
1271
+ onEvent(decorate(isReplayChunk && ev.kind === 'model' ? { ...ev, replayed: true } : ev, sid))
1251
1272
  } catch (err) {
1252
1273
  log?.(`session-tail: onEvent threw: ${(err as Error).message}`)
1253
1274
  }
@@ -1322,6 +1343,9 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1322
1343
  const size = statSync(file).size
1323
1344
  cursor = computeFirstAttachCursor(file, size)
1324
1345
  if (cursor < size) {
1346
+ // #3427 H2: everything below size-at-attach is pre-restart history;
1347
+ // model events projected from it are marked `replayed` in readNew.
1348
+ replayUntilByFile.set(file, size)
1325
1349
  log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
1326
1350
  } else {
1327
1351
  log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
@@ -58,6 +58,53 @@ describe('activity-card durability wiring', () => {
58
58
  expect(recordCode).not.toMatch(/pinned: true/)
59
59
  })
60
60
 
61
+ // F6 (persist-intent-first ordering): the durable record must be written in
62
+ // the SAME synchronous block as the send, BEFORE the status-pin reconcile and
63
+ // with NO `await` between the send resolving and the persist — otherwise the
64
+ // crash window in which a sent card has no reapable record reopens. Structural
65
+ // lock (the gateway IIFE can't be instantiated in-process); complements the
66
+ // behavioural store/reaper tests in activity-card-store.test.ts.
67
+ it('(F6) persists the record BEFORE the status-pin reconcile, with no await between send and persist', () => {
68
+ const openBranch = between(
69
+ laneSrc,
70
+ 'if (turn.activityMessageId == null) {',
71
+ 'turn.activityLastSentRender = target',
72
+ )
73
+ const persistIdx = openBranch.indexOf('writeActivityCardRecord(')
74
+ const pinIdx = openBranch.indexOf('void reconcileStatusPin(')
75
+ expect(persistIdx).toBeGreaterThanOrEqual(0)
76
+ expect(pinIdx).toBeGreaterThanOrEqual(0)
77
+ // Persist-intent-first: the record write precedes the pin reconcile.
78
+ expect(persistIdx).toBeLessThan(pinIdx)
79
+ // No `await` sits between the send returning and the persist — the whole
80
+ // window from `const sent =` to writeActivityCardRecord( is synchronous.
81
+ const sendToPersist = between(openBranch, 'const sent = await robustApiCall', 'writeActivityCardRecord(')
82
+ const codeOnly = sendToPersist
83
+ .split('\n')
84
+ .filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
85
+ .join('\n')
86
+ expect(codeOnly).not.toMatch(/\bawait\b/)
87
+ })
88
+
89
+ // F5 (persist-intent honesty): the persisted `pinned` mirrors the ACTUAL pin
90
+ // decision (PIN_STATUS_WHILE_WORKING), never a bare `pinned: true`. This is
91
+ // also asserted in the OPEN test above; kept as a named F5 lock so a rename or
92
+ // refactor that reintroduces an unconditional `pinned: true` is caught here too.
93
+ it('(F5) the persisted record mirrors the real pin decision, not an unconditional pinned:true', () => {
94
+ const openBranch = between(
95
+ laneSrc,
96
+ 'if (turn.activityMessageId == null) {',
97
+ 'turn.activityLastSentRender = target',
98
+ )
99
+ const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
100
+ const recordCode = recordBlock
101
+ .split('\n')
102
+ .filter((l) => !l.trim().startsWith('//'))
103
+ .join('\n')
104
+ expect(recordCode).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
105
+ expect(recordCode).not.toMatch(/pinned: true/)
106
+ })
107
+
61
108
  it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
62
109
  const closeBody = between(
63
110
  laneSrc,