switchroom 0.19.2 → 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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -53,7 +53,11 @@ import { resolveChatIdFallback } from './chat-id-fallback.js'
53
53
  import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
54
54
  import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
55
55
  import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
56
- import { decideSupersedeCorrection, type FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
56
+ import {
57
+ decideSupersedeCorrection,
58
+ flushedAnswerMatchesReply,
59
+ type FlushedTurnSupersedeRegistry,
60
+ } from '../flushed-turn-supersede.js'
57
61
  import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
58
62
  import { deriveTelegraphTitle } from '../telegraph.js'
59
63
  import {
@@ -872,10 +876,16 @@ export async function sendReply(
872
876
  // resolvers agree and the late-reply supersede fires by identity.
873
877
  const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
874
878
  const resolvedTurnId = ownerTurn?.turnId ?? null
879
+ // #3429 — pass the (normalized) reply text so the registry can apply the
880
+ // new-content gate: identity match + TTL alone also fits an async handback
881
+ // that merely resolved this flush-delivered ENDED turn as its owner via
882
+ // the latest-ended tier. Editing the flushed message in place with that
883
+ // handback's text does not re-notify client-side (Telegram edits never
884
+ // push) — the observed silent non-surfacing of msgs 10482/10486.
875
885
  const decision = flushedTurnSupersede.take(
876
886
  chat_id,
877
887
  replyThreadId,
878
- { liveTurnId: resolvedTurnId, now: Date.now() },
888
+ { liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
879
889
  )
880
890
  if (decision.supersede) {
881
891
  process.stderr.write(
@@ -899,20 +909,31 @@ export async function sendReply(
899
909
  // resolves the same ended owner turn, sees the latch, and is suppressed —
900
910
  // exactly one message ever ships. The latch is idempotent and the normal
901
911
  // (no-throw) path is unaffected: the correction below still ships B once.
902
- if (ownerTurn != null) ownerTurn.answerDelivered = true
912
+ // Tagged 'flush' (#3426): a flush record existed for this turn (take()
913
+ // just consumed it), so the flushed message A is what the suppression
914
+ // protects against duplicating. #3429: stash the record's flushed text
915
+ // alongside, so the retry's latch check can discriminate by content —
916
+ // the retry of THIS superseding reply matches and stays suppressed, while
917
+ // a later genuinely-new handback does not and delivers.
918
+ if (ownerTurn != null) {
919
+ ownerTurn.answerDelivered = 'flush'
920
+ if (decision.recordText != null) ownerTurn.flushedAnswerText = decision.recordText
921
+ }
903
922
  } else {
904
923
  // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
905
924
  // Supersede found no record. Either there was no flush (normal reply), or
906
925
  // the flush FIRED but has not yet recorded its message ids (the residual
907
926
  // pre-record race Part 1's supersede cannot reach). The flush sets
908
- // `answerDelivered = true` synchronously at fire time (before its async
909
- // send AND before `record`), and it persists on the ended turn — so when
910
- // this LATE, substantive reply resolves its owner turn and sees the latch
911
- // already set, the flush's message A is already on its way out and this
912
- // reply would ship a duplicate. Suppress it. Scoped to the substantive
913
- // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
914
- // interim sub-floor ack, a chunked multi-part answer, or a legitimate
915
- // second in-turn substantive reply (live `currentTurn`) is never
927
+ // `answerDelivered = 'flush'` synchronously at fire time (before its
928
+ // async send AND before `record`), and it persists on the ended turn — so
929
+ // when this LATE, substantive reply resolves its owner turn and sees the
930
+ // FLUSH-armed latch, the flush's message A is already on its way out and
931
+ // this reply would ship a duplicate. Suppress it. Scoped to the
932
+ // substantive ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor, the late-reply case,
933
+ // AND the 'flush' latch source (#3426) so an interim sub-floor ack, a
934
+ // chunked multi-part answer, a legitimate second in-turn substantive
935
+ // reply (live `currentTurn`), or an async sub-agent handback landing
936
+ // after a reply-delivered turn ended (latch = 'reply') is never
916
937
  // suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
917
938
  // the `reply` path (no `done`); pass the model's original notification
918
939
  // intent to mirror the #2533 decoupling call shape.
@@ -920,12 +941,33 @@ export async function sendReply(
920
941
  text: rawText,
921
942
  disableNotification: args.disable_notification === true,
922
943
  })
944
+ // #3429 — content evidence for the latch. `'new-content'` is the
945
+ // registry's POSITIVE determination that this reply differs from the
946
+ // flushed answer (record present, identity matched, text did not);
947
+ // otherwise compare against the owner turn's stashed `flushedAnswerText`
948
+ // (covers the post-fire pre-record race window, where no record exists
949
+ // yet but the fire site already stamped what it is delivering). Null —
950
+ // no flushed text to compare — keeps the conservative pre-#3429
951
+ // flush-armed suppression.
952
+ const replyMatchesFlushedAnswer: boolean | null =
953
+ decision.reason === 'new-content'
954
+ ? false
955
+ : ownerTurn?.flushedAnswerText != null
956
+ ? flushedAnswerMatchesReply(ownerTurn.flushedAnswerText, text)
957
+ : null
923
958
  const suppressByLatch = decideAnswerLatchSuppression({
924
959
  superseded: false,
925
960
  replySubstantive,
926
961
  isLateReply: turn == null,
927
962
  ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
963
+ replyMatchesFlushedAnswer,
928
964
  })
965
+ if (decision.reason === 'new-content') {
966
+ process.stderr.write(
967
+ `telegram gateway: reply: flush supersede declined — new content (#3429) ` +
968
+ `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh\n`,
969
+ )
970
+ }
929
971
  if (suppressByLatch) {
930
972
  process.stderr.write(
931
973
  `telegram gateway: reply: suppressed by answer-delivered latch ` +
@@ -934,11 +976,22 @@ export async function sendReply(
934
976
  )
935
977
  return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
936
978
  }
937
- // A substantive answer is going out via this reply — set the latch on its
938
- // owner turn so a later bridge-replayed / reworded duplicate of the same
939
- // answer is caught by the branch above.
979
+ // A substantive answer is going out via this reply — record it on the
980
+ // owner turn, tagged 'reply' (#3426). The 'reply' tag does NOT trip the
981
+ // late-reply suppression above: a later reply attributed to this turn
982
+ // after it ends (the async sub-agent handback pattern — dispatch, interim
983
+ // ack, turn_end, handback with no live gateway turn) is genuinely new
984
+ // content and must deliver. Byte-identical replays of THIS answer are
985
+ // deduped by the content-keyed #546 cache at the top of this function.
986
+ // Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
987
+ // while the latest-ended owner tier's 60 s is anchored at `endedAt` —
988
+ // later by the reply→turn_end gap. A byte-identical replay landing >60 s
989
+ // after record but ≤60 s after endedAt is evicted from dedup yet still
990
+ // resolves this ended turn, so it now DELIVERS as a duplicate message.
991
+ // Conscious trade: a rare duplicate beats the silent handback drop the
992
+ // boolean latch caused (#3426).
940
993
  if (replySubstantive && ownerTurn != null) {
941
- ownerTurn.answerDelivered = true
994
+ ownerTurn.answerDelivered = 'reply'
942
995
  }
943
996
  }
944
997
  }
@@ -20,6 +20,28 @@
20
20
  * whichever was observed last. A fresh assistant line always reclaims the
21
21
  * transcript as the source; a confirmed switch always beats an older
22
22
  * transcript line. Pinned by tests/session-model-source.test.ts.
23
+ *
24
+ * Divergence tripwire (#3427 item 4): `--fallback-model` masks an invalid
25
+ * requested model id — claude silently serves the fallback while the override
26
+ * carries the requested token. The FIRST live transcript observation of the
27
+ * post-relaunch session is therefore the earliest deterministic verification
28
+ * point: when the injected comparator says the served id does NOT satisfy the
29
+ * requested token, the registered divergence handler fires (once per armed
30
+ * override) so the gateway can log + warn instead of self-healing silently.
31
+ *
32
+ * Two false-positive guards (#3437 review H1/H2 — "a false accusation must be
33
+ * impossible" is the contract, enforced HERE, not by caller discipline):
34
+ * - H1: verification arms ONLY on an explicit `setOverride(model,
35
+ * { verify: true })` — the boot-rehydration site, where the override IS
36
+ * the launched token of the session now serving. A command-time
37
+ * `setOverride(model)` (the pre-restart status-honesty record in
38
+ * scheduleModelRelaunch) must NOT arm: an assistant line landing in the
39
+ * pre-restart window is served by the OLD model and would false-mismatch
40
+ * the NEW requested token.
41
+ * - H2: observations flagged `replayed: true` (the session-tail's
42
+ * first-attach replay of a prior session's in-flight turn — OLD-model
43
+ * lines delivered AFTER boot) neither consume nor fire verification;
44
+ * the tripwire waits for the first LIVE observation.
23
45
  */
24
46
 
25
47
  export interface SessionModelResolution {
@@ -30,14 +52,45 @@ export interface SessionModelResolution {
30
52
  source: 'transcript' | 'override'
31
53
  }
32
54
 
55
+ /** The first live post-override assistant line served a different model (#3427). */
56
+ export interface SessionModelDivergence {
57
+ /** The override token the operator requested (`/model <token>`). */
58
+ requested: string
59
+ /** The transcript's `message.model` — the model actually serving calls. */
60
+ served: string
61
+ }
62
+
63
+ export interface SessionModelSourceOptions {
64
+ /**
65
+ * Comparator for the divergence tripwire: does `served` (a resolved
66
+ * transcript id) satisfy `requested` (the override token)? Must be
67
+ * CONSERVATIVE — return true when the pair is not deterministically
68
+ * comparable (see servedModelMatchesRequested in model-command.ts).
69
+ * Absent → the tripwire never fires (verification is skipped).
70
+ */
71
+ servedMatchesRequested?: (requested: string, served: string) => boolean
72
+ }
73
+
33
74
  export interface SessionModelSource {
34
- /** Record a transcript observation (an assistant line's `message.model`,
35
- * already sentinel-filtered by the session-tail projection). */
36
- noteTranscriptModel(model: string): void
37
- /** Record an override set (a positively-confirmed /model switch), or clear
38
- * it with null. Setting stamps a fresh sequence, so the override wins over
39
- * every EARLIER transcript observation until a new assistant line lands. */
40
- setOverride(model: string | null): void
75
+ /**
76
+ * Record a transcript observation (an assistant line's `message.model`,
77
+ * already sentinel-filtered by the session-tail projection). Pass
78
+ * `replayed: true` for lines delivered by the session-tail's first-attach
79
+ * replay (a PRIOR session's in-flight turn): they still update /status
80
+ * freshness exactly as before, but are excluded from divergence
81
+ * verification (H2 they carry the pre-relaunch model).
82
+ */
83
+ noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void
84
+ /**
85
+ * Record an override set (a positively-confirmed /model switch), or clear
86
+ * it with null. Setting stamps a fresh sequence, so the override wins over
87
+ * every EARLIER transcript observation until a new assistant line lands.
88
+ * `verify: true` additionally ARMS divergence verification for this
89
+ * override — pass it ONLY when the override is the launched token of the
90
+ * session currently serving (the boot-rehydration site). Default: not
91
+ * armed (H1 — command-time/rollback sets must never arm).
92
+ */
93
+ setOverride(model: string | null, opts?: { verify?: boolean }): void
41
94
  /** Current override value (the #2982 in-memory record), independent of
42
95
  * freshness — for callers that need the override itself (e.g. the model
43
96
  * menu's "session" marker), not the /status resolution. */
@@ -45,18 +98,42 @@ export interface SessionModelSource {
45
98
  /** The freshest observation across both sources, or null when neither has
46
99
  * reported yet. */
47
100
  resolve(): SessionModelResolution | null
101
+ /** Register the handler fired when the first LIVE transcript observation
102
+ * after an ARMED override fails the comparator (#3427 item 4). At most
103
+ * once per armed override; null unregisters. Replaces any prior handler. */
104
+ setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void
48
105
  }
49
106
 
50
- export function createSessionModelSource(): SessionModelSource {
107
+ export function createSessionModelSource(
108
+ options: SessionModelSourceOptions = {},
109
+ ): SessionModelSource {
51
110
  let seq = 0
52
111
  let transcript: { model: string; seq: number } | null = null
53
112
  let override: { model: string; seq: number } | null = null
113
+ // True while an ARMED ({ verify: true }) non-null override awaits its first
114
+ // LIVE transcript observation. Consumed (set false) on that observation
115
+ // whether or not it diverges, so the handler fires at most once per armed
116
+ // override. Replayed observations neither consume nor fire (H2).
117
+ let overrideUnverified = false
118
+ let onDivergence: ((d: SessionModelDivergence) => void) | null = null
54
119
  return {
55
- noteTranscriptModel(model: string): void {
120
+ noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void {
56
121
  transcript = { model, seq: ++seq }
122
+ if (opts?.replayed === true) return // H2: pre-relaunch line — no verification
123
+ if (override != null && overrideUnverified) {
124
+ overrideUnverified = false
125
+ const matches = options.servedMatchesRequested
126
+ if (matches != null && !matches(override.model, model)) {
127
+ onDivergence?.({ requested: override.model, served: model })
128
+ }
129
+ }
57
130
  },
58
- setOverride(model: string | null): void {
131
+ setOverride(model: string | null, opts?: { verify?: boolean }): void {
59
132
  override = model == null ? null : { model, seq: ++seq }
133
+ // H1: only an explicit verify-arm (the boot-rehydration site) starts
134
+ // verification; a plain set (command-time record, rollback restore)
135
+ // clears any pending arm — its token is NOT what is serving right now.
136
+ overrideUnverified = model != null && opts?.verify === true
60
137
  },
61
138
  getOverride(): string | null {
62
139
  return override?.model ?? null
@@ -69,5 +146,8 @@ export function createSessionModelSource(): SessionModelSource {
69
146
  }
70
147
  return { model: transcript.model, source: 'transcript' }
71
148
  },
149
+ setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void {
150
+ onDivergence = handler
151
+ },
72
152
  }
73
153
  }
@@ -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
  }
@@ -147,18 +147,41 @@ export function buildBar(pct: number, elapsedFrac: number): string {
147
147
 
148
148
  // ── account status (title-line suffix) ───────────────────────────────
149
149
 
150
- 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
+ };
151
165
 
152
166
  /**
153
- * Title-line status word. `active` wins over `exhausted` (the fleet's
154
- * pinned account is reported as active even if the broker also flags it
155
- * exhausted matches the locked example where the operator wants to know
156
- * WHICH account is live first, and its health second, from the two window
157
- * rows underneath). Otherwise: `exhausted` if the broker's own flag says
158
- * 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`.
159
176
  */
160
- 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 {
161
182
  if (isActive) return 'active';
183
+ if (opts.entitlementBlocked === true) return 'org-disabled';
184
+ if (opts.inService === false) return 'retired';
162
185
  if (exhausted) return 'exhausted';
163
186
  return 'idle';
164
187
  }
@@ -188,8 +211,9 @@ export function renderQuotaBarAccount(
188
211
  quota: QuotaUtilization | null,
189
212
  now: Date = new Date(),
190
213
  demo = false,
214
+ service: { inService?: boolean; entitlementBlocked?: boolean } = {},
191
215
  ): string[] {
192
- const status = accountStatus(isActive, exhausted);
216
+ const status = accountStatus(isActive, exhausted, service);
193
217
  // Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
194
218
  // needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
195
219
  // `codeSpanSafe` (which only defuses backticks and is only correct
@@ -197,7 +221,18 @@ export function renderQuotaBarAccount(
197
221
  // was a bug: a label containing e.g. `**` or `[x](url)` would break the
198
222
  // bold run or inject a markdown link into the card.
199
223
  const displayLabel = demo ? maskEmail(label) : label;
200
- 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
+ }
201
236
  if (!quota || isProbeThin(quota)) {
202
237
  // Data-quality gap. A failed / thin probe carries NO real utilization
203
238
  // signal, so it must NOT render as a healthy 🟢 0% bar — that's
@@ -288,10 +323,21 @@ export function renderQuotaBarBlock(
288
323
  const now = opts.now ?? new Date();
289
324
  const demo = opts.demo ?? false;
290
325
  const lines: string[] = [];
291
- 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) {
292
335
  const exhausted = exhaustedByLabel.get(snap.label) ?? false;
293
336
  lines.push(
294
- ...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
+ }),
295
341
  );
296
342
  }
297
343
  return lines.join('\n');
@@ -319,6 +365,8 @@ export function renderQuotaBarBlockFromListState(
319
365
  quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
320
366
  expiresAtMs: acc.expiresAt,
321
367
  capturedAtMs: acc.last_quota?.capturedAt,
368
+ inService: acc.in_service,
369
+ entitlementBlocked: acc.entitlement_blocked,
322
370
  }));
323
371
  return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
324
372
  }
@@ -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})`)