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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +109 -7
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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.
|
|
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.
|
|
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".
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[] = [
|
|
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) =>
|
|
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;
|
|
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})`);
|