switchroom 0.19.2 → 0.19.4

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 +109 -7
  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
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26663
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26664
26664
 
26665
26665
  // src/build-info.ts
26666
- var VERSION = "0.19.2";
26666
+ var VERSION = "0.19.4";
26667
26667
 
26668
26668
  // src/cli/resolve-version.ts
26669
26669
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.2",
4
+ "version": "0.19.4",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -70,6 +70,14 @@ Don't retain:
70
70
 
71
71
  The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each retain only processes a small recent window (~3 turns) — so storage stays prompt and cheap and survives restarts without re-sending the whole transcript, and you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
72
72
 
73
+ ### When to synthesize — concrete triggers
74
+
75
+ Auto-recall and auto-retain keep the bank fed, but they never *synthesize* — that's on you, and it only happens if you act on these triggers. Each is paired with a deterministic backstop so nothing depends on you remembering:
76
+
77
+ - **Reflect instead of hand-assembling.** When a question needs an answer *across many past memories* — "what did we decide about X", "summarize where Y stands", "have I seen this before" — call `mcp__hindsight__reflect` rather than firing several `recall`s and stitching the fragments yourself. Rule of thumb: if you're about to make 2+ manual `recall` calls to reconstruct one answer, that's a `reflect`. (Backstop: auto-recall already injects the top hits every turn — reflect is the escalation when those aren't enough, not a replacement.)
78
+ - **Propose a model when you keep re-deriving.** When you notice you've rebuilt the *same standing answer* two or three sessions running — the recurring state of your specialty a user keeps asking around — stop re-deriving and propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill to survey the bank and propose the earned few). One organic proposal beats ten reflexive recalls. Don't propose for a one-off fact — that's a `retain`; don't propose identity/"who is the user" — profile banks own that.
79
+ - **Merge or retire directives when they pile up.** Directives are capped at `MAX_DIRECTIVES=15` active per bank — past that, the lowest-priority ones are silently truncated from the `<active_directives>` recall block and never reach you. When directives start overlapping or reading stale, run the `mental-model-curator` skill's directive merge/retire pass (it proposes; deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12 and FAILs at >15 active directives, so the pile-up surfaces on the issues card even if you don't notice it in-session.)
80
+
73
81
  ## Sub-Agent Delegation
74
82
 
75
83
  The main session is for conversation. Execution belongs in sub-agents. Before making tool calls, classify the request:
@@ -9,7 +9,11 @@ description: >
9
9
  "review my memory bank", "what standing models should I have", "bootstrap
10
10
  mental models", "suggest mental models from my bank", "audit my mental models",
11
11
  "what recurring questions do I keep re-deriving", and typos like "propose
12
- knowlege models". Do NOT use for storing a single fact/preference/decision
12
+ knowlege models". ALSO owns the directive merge/retire pass triggers like
13
+ "audit my directives", "merge my directives", "retire stale directives", "my
14
+ directives are overlapping", "too many active directives", "clean up my
15
+ guardrails", and when switchroom doctor FLAGs a bank over the directive WARN/FAIL
16
+ threshold. Do NOT use for storing a single fact/preference/decision —
13
17
  that is retain, not a mental model. Do NOT use for identity or "who is the
14
18
  user" — that lives in profile banks, so never propose an identity model here
15
19
  (the operator's review of the card, not any code guard, is the only gate — so
@@ -18,7 +22,7 @@ description: >
18
22
  whose shape you know, PROPOSE it through the approve/deny card — never call
19
23
  create_mental_model directly (it is not pre-approved; the propose card is the
20
24
  only sanctioned write path, Fix 1.2 / #2903).
21
- allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose
25
+ allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__list_directives mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose
22
26
  ---
23
27
 
24
28
  # mental-model-curator — Propose standing knowledge models from your own bank
@@ -54,6 +58,9 @@ models. Propose the few that are earned; let the operator ratify.
54
58
  (name + source_query + one-line reason) and STOP. Do NOT call
55
59
  `mental_model_propose` in this mode. This is the safe way to run against any
56
60
  bank.
61
+ - **Directive merge/retire pass:** a separate job — see "Directive merge/retire
62
+ pass" below. It is always proposal-only (text out; the operator taps to enact),
63
+ and runs independently of the mental-model modes above.
57
64
 
58
65
  ## Workflow
59
66
 
@@ -145,6 +152,65 @@ empty topic at 3am is useless and noisy. If invoked from a scheduled sweep,
145
152
  the next interactive turn instead of firing cards unattended. Treat live-turn,
146
153
  operator-present proposing as the only supported path for now.
147
154
 
155
+ ## Directive merge/retire pass
156
+
157
+ A second, independent job of this skill: keep the bank's **active directives**
158
+ lean. Directives are hard rules applied on every `reflect` — but the bank caps
159
+ active directives at **`MAX_DIRECTIVES=15`**. Past the cap, the lowest-priority
160
+ directives are **silently truncated** from the `<active_directives>` recall block
161
+ and never reach the agent — so an overloaded, overlapping, or stale directive set
162
+ doesn't just add noise, it silently drops your real guardrails. Fleet doctor
163
+ WARNs at >12 active and FAILs at >15 (workstream C2), and its fix text points
164
+ here — this pass is the durable path it names.
165
+
166
+ **You PROPOSE; you never delete.** `delete_directive` is deliberately NOT
167
+ pre-approved (#2911) — it falls through to a per-call operator approval card, and
168
+ that human tap is the only gate that retires a standing guardrail. So this pass
169
+ outputs a plan; it does not enact retirements itself.
170
+
171
+ ### When to run it
172
+
173
+ - The operator asks (see the directive triggers in the description), OR
174
+ - switchroom doctor flags a bank at/over the directive WARN/FAIL threshold, OR
175
+ - you're already curating the bank and notice the directive set is bloated.
176
+
177
+ ### Workflow
178
+
179
+ 1. **List the active set.** `mcp__hindsight__list_directives` (active only). If
180
+ the count is comfortably under the WARN threshold (≤12) AND nothing reads
181
+ stale/overlapping, STOP and report "directive set is healthy (N active) —
182
+ nothing to merge or retire." Don't manufacture churn.
183
+ 2. **Cluster for overlap.** Group directives that encode substantially the same
184
+ rule (even reworded) or that are strict specializations of a broader one. Each
185
+ cluster of 2+ is a **merge candidate**: one crisp consolidated directive that
186
+ subsumes the group, with the retirements it replaces named.
187
+ 3. **Flag the stale.** A directive is a **retire candidate** when it's obsoleted
188
+ by a newer rule, references a decision/tool/project that no longer exists, or
189
+ contradicts a directive you're keeping. Contradictions are the priority — a
190
+ live contradictory pair makes `reflect` nondeterministic.
191
+ 4. **Present the plan as text — do NOT enact.** Output, ranked by how much it
192
+ reduces the active count and risk:
193
+ - **Merges:** the proposed consolidated directive text + the exact directives
194
+ (by name/id) it would retire.
195
+ - **Retirements:** each stale directive (name/id) + one-line reason.
196
+ - The resulting active count vs `MAX_DIRECTIVES`, so the operator sees whether
197
+ the plan clears the cap.
198
+ Then STOP. The operator decides. Enacting a merge is a follow-up: the
199
+ consolidated `create_directive` is autonomous, but **each** retirement fires
200
+ its own `delete_directive` approval card — that per-card human tap is the
201
+ deterministic backstop, not something this skill routes around.
202
+
203
+ ### Directive-pass anti-patterns
204
+
205
+ - ❌ Calling `delete_directive` from this skill — it proposes; the operator's tap
206
+ on the per-call card is the only sanctioned retire path.
207
+ - ❌ Merging directives that only look similar — a merge that drops a real
208
+ distinction silently weakens a guardrail. When unsure, propose retire-nothing.
209
+ - ❌ Manufacturing merges on a healthy small set just to "tidy" — run the pass
210
+ when it's earned (bloat, overlap, staleness, or a doctor flag), not reflexively.
211
+ - ❌ Leaving a live contradictory pair in place — surface it as the top retire
212
+ candidate; it's what makes `reflect` nondeterministic.
213
+
148
214
  ## Anti-patterns
149
215
 
150
216
  - ❌ Proposing against an empty or thin bank — synthesizes to noise.
@@ -26,8 +26,24 @@ import { escapeMarkdown, codeSpanSafe } from './card-format.js';
26
26
 
27
27
  // ── shared types ─────────────────────────────────────────────────────
28
28
 
29
- /** Tri-state health verdict per account, derived from live quota. */
30
- export type AccountHealth = 'healthy' | 'throttling' | 'blocked' | 'unknown';
29
+ /**
30
+ * Health verdict per account. The first three are derived from live quota;
31
+ * `retired` and `org-blocked` are config/entitlement classifications that
32
+ * OUTRANK quota (an out-of-service account has no meaningful "health"):
33
+ * - `org-blocked` — Anthropic disabled the account at the org level
34
+ * (entitlement_blocked); renders "DISABLED (org)". Populated by PR2.
35
+ * - `retired` — the config no longer routes to the account (`in_service`
36
+ * false: removed from fallback_order + every agent/consumer pin). It must
37
+ * never read as available/healthy, is sorted LAST, and is excluded from
38
+ * the switch keyboard + the recommendation.
39
+ */
40
+ export type AccountHealth =
41
+ | 'healthy'
42
+ | 'throttling'
43
+ | 'blocked'
44
+ | 'unknown'
45
+ | 'retired'
46
+ | 'org-blocked';
31
47
 
32
48
  /**
33
49
  * Combined per-account view used by every formatter in this module.
@@ -51,6 +67,19 @@ export interface AccountSnapshot {
51
67
  * stale data as current; undefined for live-probe snapshots (fresh
52
68
  * by construction). */
53
69
  capturedAtMs?: number;
70
+ /**
71
+ * Fleet in-service classification (broker `in_service`). TRUE when the config
72
+ * still routes to this account; `false` → RETIRED. Absent → treated as
73
+ * in-service (only an explicit `false` retires, so a pre-field broker never
74
+ * falsely retires the fleet). Drives the `retired` health verdict.
75
+ */
76
+ inService?: boolean;
77
+ /**
78
+ * Org / entitlement-level block (broker `entitlement_blocked`, populated by
79
+ * PR2). TRUE → `org-blocked` health verdict ("DISABLED (org)"), which OUTRANKS
80
+ * retirement. Absent / false on a pre-PR2 broker.
81
+ */
82
+ entitlementBlocked?: boolean;
54
83
  }
55
84
 
56
85
  // ── health classification ────────────────────────────────────────────
@@ -98,6 +127,18 @@ const OVERAGE_EXHAUSTED_REASONS = new Set<string>(['out_of_credits']);
98
127
  * Failover safety against a real 429 is preserved via the mark-exhausted path.
99
128
  */
100
129
  export function classifyHealth(snap: AccountSnapshot, now: Date = new Date()): AccountHealth {
130
+ // Precedence (config/entitlement outranks quota): active > DISABLED (org) >
131
+ // RETIRED > quota-derived health. The active account always keeps its
132
+ // quota-derived verdict (it is by definition in-service and never org-blocked
133
+ // in the view). For every non-active account, an org-level entitlement block
134
+ // wins over retirement, and either wins over the quota windows — an
135
+ // out-of-service account has no meaningful quota "health". Only an explicit
136
+ // `inService === false` retires (absent → in-service), so a pre-field broker
137
+ // never falsely retires the fleet.
138
+ if (!snap.isActive) {
139
+ if (snap.entitlementBlocked === true) return 'org-blocked';
140
+ if (snap.inService === false) return 'retired';
141
+ }
101
142
  if (!snap.quota) return 'unknown';
102
143
  const q = snap.quota;
103
144
  // #2494 Bug C — a thin/headerless probe (no real utilization signal on
@@ -308,6 +349,8 @@ const HEALTH_EMOJI: Record<AccountHealth, string> = {
308
349
  throttling: '🟡',
309
350
  blocked: '🔴',
310
351
  unknown: '⚪',
352
+ 'org-blocked': '⛔',
353
+ retired: '⚫',
311
354
  };
312
355
 
313
356
  /**
@@ -332,6 +375,19 @@ function renderAccountRow(
332
375
  const marker = snap.isActive ? '● ' : '';
333
376
  const label = displayLabel(snap.label, opts);
334
377
 
378
+ // Out-of-service accounts (org-disabled / retired) outrank quota: their live
379
+ // windows are irrelevant and misleading (a retired account at 0%/0% reads as
380
+ // "available"), so render a single status note instead of a quota bar.
381
+ const health = classifyHealth(snap, now);
382
+ if (health === 'org-blocked' || health === 'retired') {
383
+ const note =
384
+ health === 'org-blocked'
385
+ ? 'DISABLED (org) — no fleet routing'
386
+ : 'retired — removed from fleet rotation';
387
+ lines.push(`${marker}\`${codeSpanSafe(label)}\` _${note}_`);
388
+ return lines;
389
+ }
390
+
335
391
  if (!snap.quota) {
336
392
  lines.push(
337
393
  `${marker}\`${codeSpanSafe(label)}\` _quota probe failed_`,
@@ -360,7 +416,6 @@ function renderAccountRow(
360
416
  `${marker}\`${codeSpanSafe(label)}\` ${fiveStr} / ${sevenStr}`,
361
417
  );
362
418
 
363
- const health = classifyHealth(snap, now);
364
419
  if (health === 'blocked') {
365
420
  // quota-exhausted (recoverable): surface only the recovery countdown — the
366
421
  // binding window's reset is the only thing that matters until then.
@@ -432,10 +487,14 @@ function formatAgeStamp(atMs: number, now: Date = new Date()): string {
432
487
  * blocked → throttling → unknown → healthy.
433
488
  */
434
489
  const TABLE_HEALTH_RANK: Record<AccountHealth, number> = {
435
- blocked: 0,
436
- throttling: 1,
437
- unknown: 2,
438
- healthy: 3,
490
+ // org-disabled is a hard, actionable problem → first. RETIRED is out of
491
+ // service (informational, not actionable) → LAST, after healthy.
492
+ 'org-blocked': 0,
493
+ blocked: 1,
494
+ throttling: 2,
495
+ unknown: 3,
496
+ healthy: 4,
497
+ retired: 5,
439
498
  };
440
499
 
441
500
  /** Escape a cell value for a GFM table cell: pipes break the column grid, and
@@ -565,7 +624,15 @@ export function recommendation(
565
624
  const active = snapshots.find((s) => s.isActive);
566
625
  if (!active) return 'No active account set.';
567
626
  const activeHealth = classifyHealth(active, now);
568
- const others = snapshots.filter((s) => !s.isActive);
627
+ // EXCLUDE out-of-service accounts (retired / org-disabled) from the
628
+ // recommendation entirely: a retired account is not a switch target and must
629
+ // not inflate an "all accounts blocked" verdict. Only in-service accounts are
630
+ // candidate alternatives or count toward the fleet-blocked summary.
631
+ const inServiceFleet = snapshots.filter((s) => {
632
+ const h = classifyHealth(s, now);
633
+ return h !== 'retired' && h !== 'org-blocked';
634
+ });
635
+ const others = inServiceFleet.filter((s) => !s.isActive);
569
636
  const healthyAlt = others.find((s) => classifyHealth(s, now) === 'healthy');
570
637
  // Demo mode masks the email labels that appear in the recommendation
571
638
  // sentence, in lockstep with the per-account rows above.
@@ -590,7 +657,8 @@ export function recommendation(
590
657
  // #2494 Bug B — no healthy alternative. Do NOT collapse to "All accounts
591
658
  // blocked": that's only honest when EVERY account is truly walled with no
592
659
  // usable or imminently-refilling slot. Distinguish the buckets first.
593
- return summarizeNoHealthyAlt(snapshots, now, demo);
660
+ // Only in-service accounts count — a retired account is not "blocked".
661
+ return summarizeNoHealthyAlt(inServiceFleet, now, demo);
594
662
  }
595
663
 
596
664
  // unknown
@@ -770,7 +838,15 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
770
838
  // Blocked-first ordering mirrors renderAuthSnapshotFormat2 — the user
771
839
  // scans the walled accounts (and their recovery times) at the top, with
772
840
  // the active account floating first within its group.
773
- const healthOrder: AccountHealth[] = ['blocked', 'throttling', 'healthy', 'unknown'];
841
+ const healthOrder: AccountHealth[] = [
842
+ 'org-blocked',
843
+ 'blocked',
844
+ 'throttling',
845
+ 'healthy',
846
+ 'unknown',
847
+ // RETIRED last — out of service, informational only.
848
+ 'retired',
849
+ ];
774
850
  const rank = (s: AccountSnapshot): number => healthOrder.indexOf(classifyHealth(s, now));
775
851
  const ordered = [...fleet].sort(
776
852
  (a, b) => rank(a) - rank(b) || Number(b.isActive) - Number(a.isActive),
@@ -937,7 +1013,18 @@ export function buildSnapshotKeyboard(
937
1013
  const switchTargets = snapshots
938
1014
  .filter((s) => !s.isActive)
939
1015
  .sort((a, b) => switchPriority(a, now) - switchPriority(b, now))
940
- .filter((s) => classifyHealth(s, now) !== 'blocked' && classifyHealth(s, now) !== 'unknown')
1016
+ .filter((s) => {
1017
+ const h = classifyHealth(s, now);
1018
+ // Never offer a switch into a target we can't (or shouldn't) serve on:
1019
+ // blocked / unknown (existing), and now RETIRED / org-disabled — an
1020
+ // out-of-service account is not a valid fleet target.
1021
+ return (
1022
+ h !== 'blocked' &&
1023
+ h !== 'unknown' &&
1024
+ h !== 'retired' &&
1025
+ h !== 'org-blocked'
1026
+ );
1027
+ })
941
1028
  .slice(0, max);
942
1029
 
943
1030
  for (const t of switchTargets) {
@@ -964,7 +1051,8 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
964
1051
  if (h === 'healthy') return 0;
965
1052
  if (h === 'throttling') return 1;
966
1053
  if (h === 'unknown') return 2;
967
- return 3; // blocked
1054
+ if (h === 'blocked') return 3;
1055
+ return 4; // retired / org-blocked — out of service, always last (and filtered)
968
1056
  }
969
1057
 
970
1058
  // ── shared HTML escape ───────────────────────────────────────────────
@@ -1073,6 +1161,8 @@ export function buildSnapshotsFromState(
1073
1161
  quota: q && q.ok ? q.data : null,
1074
1162
  quotaError: q && !q.ok ? q.reason : undefined,
1075
1163
  expiresAtMs: acc.expiresAt,
1164
+ inService: acc.in_service,
1165
+ entitlementBlocked: acc.entitlement_blocked,
1076
1166
  });
1077
1167
  }
1078
1168
  return out;
@@ -1128,6 +1218,8 @@ export function buildSnapshotsFromCachedState(
1128
1218
  // stale data (the 2026-06-09 incident: a recovery latched days
1129
1219
  // earlier only surfaced — and notified — at the next fleet bounce).
1130
1220
  capturedAtMs: lq?.capturedAt,
1221
+ inService: acc.in_service,
1222
+ entitlementBlocked: acc.entitlement_blocked,
1131
1223
  };
1132
1224
  });
1133
1225
  }
@@ -23711,6 +23711,7 @@ function startSessionTail(config2) {
23711
23711
  let stopped = false;
23712
23712
  let pendingPartial = "";
23713
23713
  const fileCursors = new Map;
23714
+ const replayUntilByFile = new Map;
23714
23715
  function readNew() {
23715
23716
  if (stopped || !currentFile)
23716
23717
  return;
@@ -23719,11 +23720,15 @@ function startSessionTail(config2) {
23719
23720
  if (stat.size < cursor) {
23720
23721
  cursor = 0;
23721
23722
  pendingPartial = "";
23722
- if (currentFile != null)
23723
+ if (currentFile != null) {
23723
23724
  fileCursors.delete(currentFile);
23725
+ replayUntilByFile.delete(currentFile);
23726
+ }
23724
23727
  }
23725
23728
  if (stat.size === cursor)
23726
23729
  return;
23730
+ const chunkStart = cursor;
23731
+ const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0);
23727
23732
  const buf = Buffer.alloc(stat.size - cursor);
23728
23733
  const fd = openSync(currentFile, "r");
23729
23734
  try {
@@ -23743,7 +23748,7 @@ function startSessionTail(config2) {
23743
23748
  const sid = sessionIdForFile(currentFile);
23744
23749
  for (const ev of events) {
23745
23750
  try {
23746
- onEvent(decorate(ev, sid));
23751
+ onEvent(decorate(isReplayChunk && ev.kind === "model" ? { ...ev, replayed: true } : ev, sid));
23747
23752
  } catch (err) {
23748
23753
  log?.(`session-tail: onEvent threw: ${err.message}`);
23749
23754
  }
@@ -23796,6 +23801,7 @@ function startSessionTail(config2) {
23796
23801
  const size = statSync3(file).size;
23797
23802
  cursor = computeFirstAttachCursor(file, size);
23798
23803
  if (cursor < size) {
23804
+ replayUntilByFile.set(file, size);
23799
23805
  log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
23800
23806
  } else {
23801
23807
  log?.(`session-tail: attached to ${file} (cursor=${cursor})`);