switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,237 @@
1
+ /**
2
+ * provider-credit.ts — the ONE registry of "this paid third-party provider is
3
+ * out of credit / its billing is blocked" signals, plus the pure classifier
4
+ * both surfaces consult.
5
+ *
6
+ * WHY THIS EXISTS (the leak it closes)
7
+ * ------------------------------------
8
+ * Every quota/credit wording switchroom recognised was an ANTHROPIC wording
9
+ * (`model-unavailable.ts`'s `quotaSignals`, `operator-events.ts`'s
10
+ * `credit_balance_too_low` branch). OpenRouter — which serves the whole
11
+ * `gpt-oss-20b` group and every `openrouter/*` model in
12
+ * `docker/litellm-proxy/litellm-config.yaml` — answers an exhausted balance
13
+ * with HTTP 402 `payment_required` / "insufficient credits", which matched
14
+ * NONE of them. It fell through `classifyClaudeError` to `unknown-4xx`, and
15
+ * `unknown-4xx` is NOT in `OPERATOR_ACTIONABLE_KINDS`, so the card (with the
16
+ * raw provider error in a code span, and a "🔐 Reauth" button nobody can act
17
+ * on) was BROADCAST to every allowlist chat — an end user included.
18
+ *
19
+ * Ken's standing product rule: a raw API/auth error that is OPERATOR-actionable
20
+ * rather than USER-actionable must never reach an end user. So a provider
21
+ * credit wall must classify to an operator-only kind.
22
+ *
23
+ * WHAT THIS MODULE IS AND IS NOT
24
+ * ------------------------------
25
+ * It is DATA (`PROVIDER_CREDIT_REGISTRY` + `CREDIT_EXHAUSTION_SIGNALS`) plus
26
+ * two pure predicates over that data. It is deliberately NOT wired to the
27
+ * Anthropic quota path: `detectModelUnavailable`'s `quota_exhausted` kind is
28
+ * what fires `fireFleetAutoFallback` (swap the Anthropic ACCOUNT SLOT). An
29
+ * OpenRouter balance of $0 says nothing about any Anthropic account, and
30
+ * rotating Anthropic slots would not buy a single OpenRouter token — so
31
+ * teaching the Anthropic detector these wordings would fire a bogus fleet
32
+ * failover on every OpenRouter 402. See the explicit early-return for
33
+ * `provider-credit-exhausted` in `resolveModelUnavailableFromOperatorEvent`.
34
+ *
35
+ * PROVENANCE of each wording (verified against live vendor docs 2026-07-28):
36
+ * - OpenRouter — https://openrouter.ai/docs/api-reference/errors:
37
+ * "402: Your account or API key has insufficient credits. Add more credits
38
+ * and retry the request." Typed error code `payment_required`. The limits
39
+ * page (https://openrouter.ai/docs/api-reference/limits) documents the same
40
+ * 402 for both an exhausted ACCOUNT balance and an exhausted PER-KEY
41
+ * `limit_remaining`.
42
+ * - OpenAI — https://platform.openai.com/docs/guides/error-codes: 429 with
43
+ * `code: "insufficient_quota"`, message "You exceeded your current quota,
44
+ * please check your plan and billing details"; hard billing stop surfaces as
45
+ * `billing_hard_limit_reached`. NOTE the status is 429, NOT 402 — which is
46
+ * exactly why status alone is not a sufficient signal and the wording list
47
+ * carries its own weight.
48
+ * - Perplexity — https://docs.perplexity.ai/guides/usage-tiers: an exhausted
49
+ * balance answers 401/403 with "insufficient credits"/"credit balance"; the
50
+ * account is otherwise valid. Perplexity reaches switchroom through the MCP
51
+ * tool surface, not through LiteLLM, so this registry is consumed there too
52
+ * (`mcp-credential-failure.ts`) — one registry, both paths.
53
+ *
54
+ * Pure module: no IPC, no bot, no FS, no network. Trivially unit-testable.
55
+ */
56
+
57
+ // ─── Types ───────────────────────────────────────────────────────────────────
58
+
59
+ export interface ProviderCreditEntry {
60
+ /** Stable id — also the `mcp_servers.<name>` key where one exists. */
61
+ id: string
62
+ /** Human display name for the operator card. */
63
+ label: string
64
+ /**
65
+ * The VAULT KEY NAME the operator must top up / re-issue. A NAME only —
66
+ * this module never sees, reads, or renders a secret VALUE.
67
+ */
68
+ vaultKey: string
69
+ /** Where the operator actually fixes it. */
70
+ consoleUrl: string
71
+ /** The one-line remedy sentence rendered on the operator card. */
72
+ action: string
73
+ /**
74
+ * Lowercase substrings that ATTRIBUTE an error to this provider. Matched
75
+ * against the raw error text. Deliberately specific (hostnames, route
76
+ * prefixes, SDK exception names) so a passing mention can't misattribute.
77
+ */
78
+ markers: string[]
79
+ }
80
+
81
+ /**
82
+ * The providers whose credit exhaustion switchroom can recognise. Adding a
83
+ * provider is a DATA edit here — never a new conditional at a call site.
84
+ */
85
+ export const PROVIDER_CREDIT_REGISTRY: readonly ProviderCreditEntry[] = [
86
+ {
87
+ id: 'openrouter',
88
+ label: 'OpenRouter',
89
+ vaultKey: 'openrouter/api-key',
90
+ consoleUrl: 'https://openrouter.ai/credits',
91
+ action: 'Top up the OpenRouter balance (or raise the key’s credit limit) in the OpenRouter console.',
92
+ markers: ['openrouter', 'openrouter.ai'],
93
+ },
94
+ {
95
+ id: 'openai',
96
+ label: 'OpenAI',
97
+ vaultKey: 'openai/api-key',
98
+ consoleUrl: 'https://platform.openai.com/settings/organization/billing',
99
+ action: 'Add credit / raise the billing limit in the OpenAI console.',
100
+ markers: ['api.openai.com', 'openai.', 'openaiexception', 'openai_api'],
101
+ },
102
+ {
103
+ id: 'perplexity',
104
+ label: 'Perplexity',
105
+ vaultKey: 'perplexity/api-key',
106
+ consoleUrl: 'https://www.perplexity.ai/settings/api',
107
+ action: 'Top up the Perplexity API balance (or re-issue the key) in the Perplexity console.',
108
+ markers: ['perplexity', 'api.perplexity.ai', 'pplx'],
109
+ },
110
+ ]
111
+
112
+ // ─── Credit-exhaustion wording ───────────────────────────────────────────────
113
+
114
+ /**
115
+ * Provider-agnostic markers of a CREDIT/BILLING wall — "there is no money
116
+ * left", as distinct from "you are going too fast" (rate limit) or "your key
117
+ * is wrong" (auth).
118
+ *
119
+ * Every entry is a multi-word phrase or an explicit vendor error CODE. A bare
120
+ * `'402'` is deliberately ABSENT: a three-digit substring appears in model ids,
121
+ * token counts, request ids and timestamps, and matching it would misclassify
122
+ * unrelated errors as a billing wall. HTTP 402 is honoured only as a STRUCTURED
123
+ * status field — see {@link isProviderCreditStatus}.
124
+ */
125
+ export const CREDIT_EXHAUSTION_SIGNALS: readonly string[] = [
126
+ // OpenRouter (402)
127
+ 'payment_required',
128
+ 'payment required',
129
+ 'insufficient credits',
130
+ 'insufficient_credits',
131
+ 'more credits are required',
132
+ 'requires more credits',
133
+ 'add more credits',
134
+ 'out of credits',
135
+ // OpenAI (429 insufficient_quota / hard billing stop)
136
+ 'insufficient_quota',
137
+ 'exceeded your current quota',
138
+ 'billing_hard_limit_reached',
139
+ 'billing hard limit',
140
+ // Cross-vendor balance wordings (Perplexity, Brevo, and the generic shape)
141
+ 'insufficient balance',
142
+ 'insufficient_balance',
143
+ 'credit balance is too low',
144
+ 'credit balance too low',
145
+ 'credit_balance_too_low',
146
+ 'no credits remaining',
147
+ 'quota exceeded for credits',
148
+ ]
149
+
150
+ /** HTTP statuses that, on their own, mean "billing". Only 402 qualifies. */
151
+ export const CREDIT_EXHAUSTION_STATUSES: readonly number[] = [402]
152
+
153
+ const MAX_SCAN_CHARS = 16_384
154
+
155
+ function sample(text: unknown): string {
156
+ if (typeof text !== 'string' || text.length === 0) return ''
157
+ return (text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text).toLowerCase()
158
+ }
159
+
160
+ /**
161
+ * True when `status` is a structured HTTP status that means "out of credit".
162
+ * Structured only — never inferred from a substring (see the note on
163
+ * {@link CREDIT_EXHAUSTION_SIGNALS}).
164
+ */
165
+ export function isProviderCreditStatus(status: unknown): boolean {
166
+ return typeof status === 'number' && CREDIT_EXHAUSTION_STATUSES.includes(status)
167
+ }
168
+
169
+ /** True when `text` carries an explicit credit/billing-exhaustion wording. */
170
+ export function hasCreditExhaustionWording(text: unknown): boolean {
171
+ const lower = sample(text)
172
+ if (lower.length === 0) return false
173
+ return CREDIT_EXHAUSTION_SIGNALS.some(s => lower.includes(s))
174
+ }
175
+
176
+ /**
177
+ * Attribute an error string to a registered provider, or `null` when nothing
178
+ * in the text names one. `null` is a legitimate, honest outcome: the card then
179
+ * says "an upstream model provider" rather than guessing a vendor (and a wrong
180
+ * vendor name would send the operator to the wrong console).
181
+ */
182
+ export function attributeProvider(text: unknown): ProviderCreditEntry | null {
183
+ const lower = sample(text)
184
+ if (lower.length === 0) return null
185
+ for (const entry of PROVIDER_CREDIT_REGISTRY) {
186
+ if (entry.markers.some(m => lower.includes(m))) return entry
187
+ }
188
+ return null
189
+ }
190
+
191
+ export interface ProviderCreditDetection {
192
+ /** The registry entry, when the text named a known provider. */
193
+ provider: ProviderCreditEntry | null
194
+ /** Which evidence fired — useful in logs and in the test assertions. */
195
+ via: 'status' | 'wording'
196
+ }
197
+
198
+ /**
199
+ * The single decision: is this error a THIRD-PARTY PROVIDER credit/billing
200
+ * wall? Returns `null` when it is not — callers then fall through to their
201
+ * existing classification unchanged.
202
+ *
203
+ * `status` is the structured HTTP status when the caller has one (an SDK throw,
204
+ * a LiteLLM error body with `"status": 402`). Omit it and the decision rests on
205
+ * wording alone.
206
+ *
207
+ * ANTHROPIC IS DELIBERATELY EXCLUDED. `credit_balance_too_low` is in the
208
+ * wording list because it is a genuine cross-vendor balance phrase, but an
209
+ * Anthropic-attributed credit wall already has its own `credit-exhausted` kind
210
+ * with slot-switch advice (`/auth use`). Callers keep that branch FIRST; this
211
+ * one only catches what that branch does not. See `classifyClaudeError`.
212
+ */
213
+ export function detectProviderCreditExhaustion(
214
+ text: unknown,
215
+ status?: unknown,
216
+ ): ProviderCreditDetection | null {
217
+ if (isProviderCreditStatus(status)) {
218
+ return { provider: attributeProvider(text), via: 'status' }
219
+ }
220
+ if (hasCreditExhaustionWording(text)) {
221
+ return { provider: attributeProvider(text), via: 'wording' }
222
+ }
223
+ return null
224
+ }
225
+
226
+ /**
227
+ * The operator-facing remedy line for a detection. Names the provider, the
228
+ * VAULT KEY NAME (never a value) and the console to act in. Used by both the
229
+ * operator-event card and the MCP credential alert so the two speak with one
230
+ * voice.
231
+ */
232
+ export function describeProviderCreditRemedy(provider: ProviderCreditEntry | null): string {
233
+ if (provider == null) {
234
+ return 'An upstream model provider reports no credit remaining. Check the provider console for the key the LiteLLM proxy uses.'
235
+ }
236
+ return `${provider.action} Vault key: \`${provider.vaultKey}\` → ${provider.consoleUrl}`
237
+ }
@@ -40,18 +40,48 @@ ATTEMPT_TIMEOUT="${BUN_TEST_ATTEMPT_TIMEOUT:-180}"
40
40
  KILL_AFTER="${BUN_TEST_KILL_AFTER:-30}"
41
41
  MAX_ATTEMPTS="${BUN_TEST_MAX_ATTEMPTS:-3}"
42
42
 
43
- # Same explicit dir list as the Buildkite/CI invocationrunning `bun
44
- # test` with no args recurses into telegram-plugin/uat/scenarios/ which
45
- # need live Telegram creds. Keep this in sync with ci-tests-plugin.yml if
46
- # the surface changes. BUN_TEST_TARGETS (space-separated) overrides the
47
- # list used only to exercise the watchdog against a narrow subset.
43
+ # THE single source of truth for what the bun CI job runs `bun test` with
44
+ # no args recurses into telegram-plugin/uat/scenarios/, which needs live
45
+ # Telegram creds. Both ci-tests-plugin.yml and ci-full.yml invoke this script
46
+ # rather than re-typing the list (they used to keep hand-copied duplicates,
47
+ # which is how a test file ends up in no runner at all — see
48
+ # scripts/check-test-runner-coverage.mjs). BUN_TEST_TARGETS (space-separated)
49
+ # overrides the list — used only to exercise the watchdog against a subset.
48
50
  if [ -n "${BUN_TEST_TARGETS:-}" ]; then
49
51
  # shellcheck disable=SC2206
50
52
  BUN_TEST_ARGS=(${BUN_TEST_TARGETS})
51
53
  else
52
54
  BUN_TEST_ARGS=(
53
- admin-commands gateway registry secret-detect tests
55
+ # TRAILING SLASHES ARE LOAD-BEARING. A `bun test` positional is a plain
56
+ # SUBSTRING match on the file path, not a directory selector: the bare
57
+ # `gateway` this list used to carry also matched
58
+ # `uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts`, quietly
59
+ # pulling a live-Telegram scenario into the ordinary bun-test job (it only
60
+ # stayed harmless because that scenario self-skips without creds). `gateway/`
61
+ # matches the directory and nothing else.
62
+ admin-commands/ gateway/ registry/ secret-detect/ tests/
54
63
  channel-envelope-safety.test.ts
64
+ # Hosted UAT unit tests: no creds, no live Telegram, no driver session.
65
+ # Named as specific files / the runners dir — never a bare `uat` — so the
66
+ # filter can NEVER widen into uat/scenarios/, which does hit real Telegram
67
+ # and must stay on the gated uat-host runner (ci-uat.yml) only.
68
+ #
69
+ # These ran in NEITHER CI runner before this change: vitest.config.ts
70
+ # excludes `**/telegram-plugin/uat/**` wholesale (right for scenarios/,
71
+ # collateral damage for these) and this list never named them. So
72
+ # feed-matcher.test.ts — whose own docblock calls it "the CI-verifiable
73
+ # floor" for the worker-feed matcher, and which #3821 extended with
74
+ # `stripCardNesting` assertions — was executed by nothing.
75
+ #
76
+ # They go on the BUN side rather than being un-excluded from vitest because
77
+ # three of them import `bun:test`, and uat/runners/skill-coverage.test.ts
78
+ # transitively imports uat/driver.ts → `@mtcute/node`, which vite's resolver
79
+ # cannot load out of the bun workspace layout (bun's resolver has no
80
+ # trouble). Fenced by scripts/check-test-runner-coverage.mjs.
81
+ uat/feed-matcher.test.ts
82
+ uat/load-env.test.ts
83
+ uat/uat-driver.test.ts
84
+ uat/runners/
55
85
  )
56
86
  fi
57
87
 
@@ -73,6 +73,7 @@ import {
73
73
  makeFloodWaitActiveError,
74
74
  isFloodWaitActiveError,
75
75
  } from './retry-api-call.js'
76
+ import { withOutboundClass, type OutboundClass } from './outbound-class.js'
76
77
 
77
78
  /** Injectable time source. Default binds to real wall clock + setTimeout. */
78
79
  export interface Clock {
@@ -112,6 +113,20 @@ export type ChatType = 'private' | 'group' | 'supergroup' | 'channel'
112
113
  */
113
114
  export type PriorityClass = 'critical' | 'useful' | 'cosmetic'
114
115
 
116
+ /**
117
+ * Compile-time lock: `PriorityClass` and `outbound-class.ts`'s `OutboundClass`
118
+ * must stay identical, because the gate publishes the former into the latter's
119
+ * AsyncLocalStorage so the edit-flood fuse (a grammY transformer, which sees
120
+ * only `(method, payload)`) can tell a repaint from a reply. Adding a class to
121
+ * one and not the other fails `tsc`, not review.
122
+ */
123
+ type _ClassesMatch =
124
+ OutboundClass extends PriorityClass
125
+ ? PriorityClass extends OutboundClass ? true : never
126
+ : never
127
+ const _classesMatch: _ClassesMatch = true
128
+ void _classesMatch
129
+
115
130
  /**
116
131
  * Priority class an UNTAGGED non-edit send is admitted as. `critical` =
117
132
  * non-droppable (L2). See the `PriorityClass` doc above.
@@ -1130,7 +1145,13 @@ export function createSendGate(config: SendGateConfig): SendGate {
1130
1145
  // the fact that the only production caller is `robustApiCall`, whose
1131
1146
  // own request/retry timeouts bound every send — so `p.fn()` is
1132
1147
  // guaranteed to settle. No separate watchdog is needed here.
1133
- const res = await p.fn()
1148
+ // Publish the class for the edit-flood fuse (a grammY transformer
1149
+ // downstream of here, which otherwise cannot tell a cosmetic repaint
1150
+ // from an approval-card edit). `p.priorityClass` is the COALESCED
1151
+ // class — a cosmetic edit that a `useful` one superseded is sent as
1152
+ // `useful`, which is exactly what should happen: the frame going out
1153
+ // carries the higher-priority payload.
1154
+ const res = await withOutboundClass(p.priorityClass, () => p.fn())
1134
1155
  // M1: only record the payload as on-screen AFTER a successful send,
1135
1156
  // so a FAILED edit can be retried with the same payload (not dropped
1136
1157
  // as a phantom no-op).
@@ -1363,7 +1384,8 @@ export function createSendGate(config: SendGateConfig): SendGate {
1363
1384
 
1364
1385
  try {
1365
1386
  // N4: count `sent` only AFTER a successful send, mirroring the edit path.
1366
- const res = await fn()
1387
+ // Class published for the fuse — see the edit path's call above.
1388
+ const res = await withOutboundClass(priority, () => fn())
1367
1389
  counters.sent++
1368
1390
  return res
1369
1391
  } catch (err) {
@@ -83,6 +83,16 @@ export const NESTED_PREFIX = ' ↳ '
83
83
  * (2+ worker) card — three U+2800 BRAILLE PATTERN BLANK, written as escapes so
84
84
  * the bytes are visible in source.
85
85
  *
86
+ * This is now the ONLY indent any card carries (#3842). #3820/#3821 also
87
+ * indented the whole worker card by one level and prefixed line 1 with `└─ `,
88
+ * which cost a level of horizontal space on a phone and asserted a
89
+ * parent/child relationship that is not always true (the worker card does not
90
+ * always sit below the agent card). That whole-card subordination is gone:
91
+ * every card is flush at the left margin, and this indent survives for the one
92
+ * job that genuinely needs it — telling one worker's step lines apart from the
93
+ * next worker's on the combined card, where the numbered row headers stay
94
+ * flush and their steps sit one level in.
95
+ *
86
96
  * ── Why not ASCII, and why not U+00A0 ────────────────────────────────
87
97
  * Card bodies reach Telegram as raw GFM markdown (`richMessage` →
88
98
  * `sendRichMessage` / `editMessageText({ markdown })`, #2669) and are parsed
@@ -125,51 +135,3 @@ export const NESTED_PREFIX = ' ↳ '
125
135
  */
126
136
  export const WORKER_STEP_INDENT = '\u2800\u2800\u2800'
127
137
 
128
- /**
129
- * Line-1 prefix that marks a card as structurally SUBORDINATE to the \ud83e\udd16 agent
130
- * card (#3820). Worker cards carry it; the agent card never does.
131
- *
132
- * `\u2514\u2500` (U+2514 BOX DRAWINGS LIGHT UP AND RIGHT + U+2500 BOX DRAWINGS LIGHT
133
- * HORIZONTAL) is ordinary ink to Telegram's server-side GFM parser: it is not
134
- * a line-start block trigger (`#`, `>`, `-`/`+`/`*`, `N.` \u2014 see
135
- * render/line-start-guard.ts for the full trigger set), so it can neither be
136
- * promoted to a list/quote/heading nor left-trimmed the way a leading
137
- * whitespace run is (the #3662 failure documented on WORKER_STEP_INDENT).
138
- *
139
- * LENGTH INVARIANT: exactly the same length as `SUBORDINATE_LINE_INDENT`, so
140
- * the char-budget arithmetic in `fitCardToBudget` charges one flat per-line
141
- * cost instead of special-casing line 1. `status-accent.test.ts` asserts it.
142
- */
143
- export const SUBORDINATE_HEADER_PREFIX = '\u2514\u2500 '
144
-
145
- /**
146
- * Left indent applied to EVERY line of a subordinate (worker) card after
147
- * line 1 \u2014 the whole card block sits one level in from the agent card's left
148
- * margin, so "parent vs child" is readable from the block's SHAPE at a glance
149
- * on a phone, not from reading its label (#3820).
150
- *
151
- * Same U+2800 run as `WORKER_STEP_INDENT` and for the same live-verified
152
- * reason (category So, not Zs \u2192 survives Telegram's inline left-trim; ASCII
153
- * spaces and U+00A0 both render flat). Aliased rather than re-declared so the
154
- * two indents can never drift to different glyphs.
155
- *
156
- * On the combined (2+ worker) card this composes with `WORKER_STEP_INDENT`:
157
- * chrome / row headers land at one level, their steps at two.
158
- */
159
- export const SUBORDINATE_LINE_INDENT = WORKER_STEP_INDENT
160
-
161
- /**
162
- * Apply subordinate-card nesting to a card's pre-rendered lines: line 1 gets
163
- * `SUBORDINATE_HEADER_PREFIX`, every later line gets `SUBORDINATE_LINE_INDENT`.
164
- *
165
- * Prefixes go OUTSIDE the markdown spans (lines arrive already wrapped in
166
- * `**` / `_` / `~~`), exactly like `renderStepFeed`'s `indent` parameter, so a
167
- * prefix can never land inside an emphasis run and break it.
168
- *
169
- * Pure; returns a new array. Empty in \u2192 empty out.
170
- */
171
- export function nestSubordinateCardLines(lines: string[]): string[] {
172
- return lines.map((line, i) =>
173
- i === 0 ? `${SUBORDINATE_HEADER_PREFIX}${line}` : `${SUBORDINATE_LINE_INDENT}${line}`,
174
- )
175
- }
@@ -1,11 +1,23 @@
1
1
  /**
2
- * Status-pin driver — executes a PinAction against a Telegram Bot API.
2
+ * Status-pin driver — executes ONE already-decided `PinLegAction` against a
3
+ * Telegram Bot API.
3
4
  *
4
- * The pure decision lives in `status-pin.ts` (decidePinAction). This
5
- * module is the side-effecting half: takes the previously-claimed state
6
- * and the desired state, computes ONE action, executes it, and returns
7
- * the next state. The state itself stays in the caller (the gateway holds
8
- * a `Map<pinKey, PinState>` and re-passes the entry on every call).
5
+ * The pure decision lives in `status-pin.ts` (`decidePinAction`) and is taken
6
+ * exactly ONCE per leg, by the orchestrator (`gateway/status-pin-retarget.ts`).
7
+ * This module is the side-effecting half only: it is HANDED a leg action,
8
+ * executes it, and returns the next claim. The state itself stays in the caller
9
+ * (the gateway holds a `Map<pinKey, StatusPinClaim>` and re-passes the entry on
10
+ * every call).
11
+ *
12
+ * #3831 — why this takes an ACTION and not a `desired` state. It used to take
13
+ * `(prevState, desired)` and re-run `decidePinAction` itself, which meant one
14
+ * reconcile decided the same transition up to THREE times (orchestrator leg
15
+ * split → persist-op mapping → driver) and, worse, gave the driver a `repin`
16
+ * branch to implement. That branch expanded the retarget a SECOND time, with no
17
+ * per-leg persistence, and was unreachable from the gateway — a dead fork that
18
+ * a future caller wiring `reconcilePin` directly would have silently inherited.
19
+ * The parameter type is now `PinLegAction`, which cannot express `repin`, so
20
+ * the expansion exists in one place and cannot re-fork.
9
21
  *
10
22
  * Copied from `slot-banner-driver.ts`. The load-bearing contract (see
11
23
  * slot-banner-driver.ts:100-110):
@@ -36,8 +48,8 @@
36
48
  * job spec.
37
49
  */
38
50
 
39
- import type { PinState, DesiredPin, PinRightsCache } from './status-pin.js'
40
- import { decidePinAction, isPinRightsError, isUnpinTerminalError } from './status-pin.js'
51
+ import type { PinState, PinLegAction, PinRightsCache } from './status-pin.js'
52
+ import { isPinRightsError, isUnpinTerminalError } from './status-pin.js'
41
53
 
42
54
  /** Minimal subset of grammy's `bot.api` the pin driver depends on.
43
55
  * Lets tests swap in a fake without dragging in the full Bot type. */
@@ -53,13 +65,13 @@ export interface PinBotApi {
53
65
  ): Promise<unknown>
54
66
  }
55
67
 
56
- export interface ReconcilePinArgs {
68
+ export interface ExecutePinLegArgs {
57
69
  api: PinBotApi
58
70
  chatId: string
59
71
  /** State the caller is holding from the last reconcile. `null` on first. */
60
72
  prevState: PinState | null
61
- /** What the caller wants pinned for this key right now. */
62
- desired: DesiredPin
73
+ /** The single transition to execute, decided by the orchestrator. */
74
+ action: PinLegAction
63
75
  /** Optional API-failure observer. Default: silent. */
64
76
  onError?: (phase: 'pin' | 'unpin', err: unknown) => void
65
77
  /** Optional per-process rights-aware negative cache (issue #3024). When a
@@ -75,9 +87,9 @@ export interface ReconcilePinArgs {
75
87
  }
76
88
 
77
89
  /**
78
- * Execute the next pin-state transition for one key. Returns the new
79
- * `PinState` (or `null` when unpinned / nothing pinned). Always resolves;
80
- * never throws — API errors route through `onError`.
90
+ * Execute ONE decided pin transition for a key. Returns the new `PinState`
91
+ * (or `null` when unpinned / nothing pinned). Always resolves; never throws —
92
+ * API errors route through `onError`.
81
93
  *
82
94
  * - `pin` : pins the existing message SILENTLY; on failure the claim is
83
95
  * NOT taken (returns prevState) so the next reconcile retries
@@ -86,43 +98,19 @@ export interface ReconcilePinArgs {
86
98
  * success and on a TERMINAL failure (never leave state stuck
87
99
  * pinned). A never-confirmed failure returns prevState so the
88
100
  * still-pinned message keeps a record to retry from (#3664).
89
- * - `repin`: the wanted message CHANGED — unpins the stale claim then pins
90
- * the new message, both in this one call, returning the new
91
- * claim. A never-confirmed unpin aborts the pin leg and retains
92
- * the old claim (the old message is still up and must keep a
93
- * record). Single-shot callers depend on this: they never
94
- * reconcile the key a second time.
95
101
  * - `noop` : returns prevState unchanged.
102
+ *
103
+ * A RETARGET is NOT executable here by construction — `PinLegAction` has no
104
+ * `repin` member. `runStatusPinReconcile` splits it into an unpin leg and a pin
105
+ * leg, each persisted, and owns the abort rule (#3831).
96
106
  */
97
- export async function reconcilePin(
98
- args: ReconcilePinArgs,
107
+ export async function executePinLeg(
108
+ args: ExecutePinLegArgs,
99
109
  ): Promise<PinState | null> {
100
- const action = decidePinAction(args.prevState, args.desired)
110
+ const { action } = args
101
111
 
102
112
  if (action.kind === 'noop') return args.prevState
103
113
 
104
- if (action.kind === 'repin') {
105
- // RETARGET: unpin the stale claim, then pin the new message — BOTH legs in
106
- // this one call. Callers that reconcile a key exactly once (the foreground
107
- // activity card) have no "next reconcile" to finish the job, so splitting
108
- // it left nothing pinned. See the `repin` docblock in status-pin.ts.
109
- const afterUnpin = await reconcilePin({
110
- ...args,
111
- desired: { pinned: false },
112
- })
113
- // A non-null result here means the unpin was NEVER CONFIRMED (#3664
114
- // Defect B): the old message is provably still pinned and the claim was
115
- // deliberately retained. Pinning the new one now would leave two pins with
116
- // a record of only one. Keep the retained claim and let the next reconcile
117
- // / the mid-session reaper / the boot sweep retry.
118
- if (afterUnpin != null) return afterUnpin
119
- return reconcilePin({
120
- ...args,
121
- prevState: null,
122
- desired: { pinned: true, messageId: action.pinMessageId },
123
- })
124
- }
125
-
126
114
  if (action.kind === 'unpin') {
127
115
  // Skip the unpin API call in a chat the bot can't manage pins in — the
128
116
  // call would fail with the same rights 400 and spam the log. The claim is
@@ -38,7 +38,21 @@ export type DesiredPin =
38
38
  /** Work is done (or never opened a message); nothing should be pinned. */
39
39
  | { pinned: false }
40
40
 
41
- export type PinAction =
41
+ /**
42
+ * A SINGLE, directly-executable pin transition — the only shape the driver
43
+ * (`status-pin-driver.ts`) can execute.
44
+ *
45
+ * The type is deliberately narrower than `PinAction`: it structurally EXCLUDES
46
+ * `repin`. That exclusion is the mechanism (#3831) that keeps the retarget
47
+ * expansion — "a repin is an unpin leg then a pin leg, and a never-confirmed
48
+ * unpin aborts the pin leg" — defined in exactly ONE place
49
+ * (`gateway/status-pin-retarget.ts`). It used to be written twice, once in the
50
+ * driver with no per-leg persistence and once in the orchestrator with it; the
51
+ * driver copy was unreachable in production and silently carried the weaker
52
+ * semantics. A driver that cannot NAME a repin cannot re-grow a second copy of
53
+ * it — the compiler enforces what a comment could not.
54
+ */
55
+ export type PinLegAction =
42
56
  | { kind: 'noop'; reason: string }
43
57
  /** Pin an EXISTING message. Caller pins, then records the message_id
44
58
  * back into PinState. No new message is sent — this pins a message the
@@ -48,6 +62,9 @@ export type PinAction =
48
62
  * or a TERMINAL failure; a never-confirmed failure keeps the claim so the
49
63
  * still-pinned message can be retried (`isUnpinTerminalError`, #3664). */
50
64
  | { kind: 'unpin'; messageId: number }
65
+
66
+ export type PinAction =
67
+ | PinLegAction
51
68
  /**
52
69
  * RETARGET — the caller still wants something pinned for this key, but a
53
70
  * DIFFERENT message than the one we claim (the surface was re-posted: an
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GOLDEN SUITE — every status/progress card variant, pinned byte-for-byte.
3
+ *
4
+ * Why this exists: card rendering now flows through ONE layout core
5
+ * (`card-layout.ts`). That is the point — but it also means a change made for
6
+ * one card silently reshapes the other twenty. This suite is the alarm: it
7
+ * renders all variants in `card-variants.ts` through the real renderers and
8
+ * compares the exact wire text against a checked-in golden file. Any diff, in
9
+ * any card, fails.
10
+ *
11
+ * Regenerate deliberately (and read the diff in the PR):
12
+ * UPDATE_CARD_GOLDEN=1 npx vitest run telegram-plugin/tests/card-golden.test.ts
13
+ */
14
+ import { readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { describe, expect, it } from 'vitest'
18
+
19
+ import { CARD_VARIANTS, GOLDEN_DELIMITER, renderGoldenDocument } from './card-variants.js'
20
+
21
+ const GOLDEN_PATH = join(dirname(fileURLToPath(import.meta.url)), 'card-variants.golden.txt')
22
+
23
+ describe('card golden suite', () => {
24
+ it('every card variant renders exactly its pinned golden text', () => {
25
+ const actual = renderGoldenDocument()
26
+ if (process.env.UPDATE_CARD_GOLDEN === '1') {
27
+ writeFileSync(GOLDEN_PATH, actual, 'utf8')
28
+ }
29
+ const expected = readFileSync(GOLDEN_PATH, 'utf8')
30
+ // Compare per-variant first: a one-card regression should name that card,
31
+ // not dump a 21-card diff.
32
+ const split = (doc: string): Map<string, string> => {
33
+ const out = new Map<string, string>()
34
+ for (const chunk of doc.split(GOLDEN_DELIMITER).slice(1)) {
35
+ const nl = chunk.indexOf('\n')
36
+ out.set(chunk.slice(0, nl).trim(), chunk.slice(nl + 1))
37
+ }
38
+ return out
39
+ }
40
+ const exp = split(expected)
41
+ const act = split(actual)
42
+ for (const [name, body] of act) {
43
+ expect(exp.has(name), `golden has no entry for "${name}" — regenerate it`).toBe(true)
44
+ expect(body, `card "${name}" no longer renders its golden text`).toBe(exp.get(name))
45
+ }
46
+ // And the whole document, so a REMOVED or reordered variant also fails.
47
+ expect(actual).toBe(expected)
48
+ })
49
+
50
+ it('the golden covers every variant in the catalogue and nothing else', () => {
51
+ const golden = readFileSync(GOLDEN_PATH, 'utf8')
52
+ const namesInGolden = golden
53
+ .split(GOLDEN_DELIMITER)
54
+ .slice(1)
55
+ .map((c) => c.slice(0, c.indexOf('\n')).trim())
56
+ expect(namesInGolden).toEqual(CARD_VARIANTS.map((v) => v.name))
57
+ // Guards against a fixture that silently stops rendering (e.g. a renderer
58
+ // starting to return null) and thus stops asserting anything real.
59
+ expect(namesInGolden.length).toBeGreaterThanOrEqual(21)
60
+ })
61
+
62
+ it('no variant renders empty or null', () => {
63
+ for (const v of CARD_VARIANTS) {
64
+ const text = v.render()
65
+ expect(text, `variant "${v.name}" rendered null`).not.toBeNull()
66
+ expect((text ?? '').trim().length, `variant "${v.name}" rendered empty`).toBeGreaterThan(0)
67
+ }
68
+ })
69
+ })