switchroom 0.19.1 โ†’ 0.19.2

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 (34) hide show
  1. package/dist/agent-scheduler/index.js +29 -1
  2. package/dist/auth-broker/index.js +552 -48
  3. package/dist/cli/autoaccept-poll.js +29 -1
  4. package/dist/cli/drive-write-pretool.mjs +30 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +30 -2
  6. package/dist/cli/switchroom.js +751 -36
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/skills/switchroom-cli/SKILL.md +25 -0
  13. package/telegram-plugin/auth-snapshot-format.ts +39 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +363 -25
  15. package/telegram-plugin/external-spend.ts +135 -0
  16. package/telegram-plugin/gateway/gateway.ts +83 -67
  17. package/telegram-plugin/gateway/model-command.ts +106 -0
  18. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  19. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  20. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  21. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  22. package/telegram-plugin/quota-bar-format.ts +18 -0
  23. package/telegram-plugin/quota-check.ts +17 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  25. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  26. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
  27. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  28. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  29. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  30. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  31. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  32. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  33. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  34. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * External (non-Claude / OpenRouter cash) spend for the `/usage` card.
3
+ *
4
+ * Layout B (operator-locked 2026-07-19):
5
+ * ```
6
+ * - ๐Ÿ’ธ External
7
+ * - 24h `$X.XX` ยท 7d `$Y.YY`
8
+ * - top `model $a` ยท `model $b` ยท `model $c`
9
+ * ```
10
+ *
11
+ * **Durable path:** the auth-broker holds the LiteLLM master key and
12
+ * serves a sanitized summary via `get-external-spend`. Agents never
13
+ * receive the master key. Soft-fail โ†’ omit the External block.
14
+ *
15
+ * Pure helpers (filter/format) live in `src/litellm/external-spend.ts`
16
+ * and are re-exported here for card rendering + tests.
17
+ */
18
+
19
+ import {
20
+ formatUsd,
21
+ type ExternalSpendSummary,
22
+ type ExternalSpendTopModel,
23
+ } from '../src/litellm/external-spend.js';
24
+
25
+ export type { ExternalSpendSummary, ExternalSpendTopModel };
26
+ export {
27
+ isExternalModel,
28
+ shortModelLabel,
29
+ formatUsd,
30
+ summarizeExternalSpend,
31
+ normalizeSpendLogRows,
32
+ utcDateString,
33
+ addUtcDays,
34
+ EXTERNAL_SPEND_CACHE_TTL_MS,
35
+ type LiteLLMDaySpendRow,
36
+ } from '../src/litellm/external-spend.js';
37
+
38
+ /** Soft client-side cache so strip double-taps of /usage within a process. */
39
+ const CLIENT_CACHE_TTL_MS = 30_000;
40
+ let clientCache: { atMs: number; summary: ExternalSpendSummary } | null = null;
41
+
42
+ /** Test seam. */
43
+ export function clearExternalSpendCache(): void {
44
+ clientCache = null;
45
+ }
46
+
47
+ /**
48
+ * Render layout-B bullet lines (no trailing freshness).
49
+ * Returns `[]` when summary is null/undefined so callers can omit.
50
+ * Zero totals still render โ€” honest "no external spend".
51
+ */
52
+ export function formatExternalSpendBlock(
53
+ summary: ExternalSpendSummary | null | undefined,
54
+ ): string[] {
55
+ if (summary == null) return [];
56
+ const lines = [
57
+ '- ๐Ÿ’ธ External',
58
+ `- 24h \`${formatUsd(summary.day24hUsd)}\` ยท 7d \`${formatUsd(summary.day7dUsd)}\``,
59
+ ];
60
+ if (summary.top.length > 0) {
61
+ const topParts = summary.top.map((t) => `\`${t.label} ${formatUsd(t.usd)}\``);
62
+ lines.push(`- top ${topParts.join(' ยท ')}`);
63
+ }
64
+ return lines;
65
+ }
66
+
67
+ export interface FetchExternalSpendDeps {
68
+ now?: Date;
69
+ /** Prefer injected client for tests; defaults to env agent socket. */
70
+ getExternalSpend?: (forceLive?: boolean) => Promise<{
71
+ available: boolean;
72
+ day24hUsd?: number;
73
+ day7dUsd?: number;
74
+ top?: Array<{ label: string; usd: number }>;
75
+ capturedAtMs?: number;
76
+ served?: 'live' | 'cache';
77
+ reason?: string;
78
+ }>;
79
+ forceLive?: boolean;
80
+ bypassCache?: boolean;
81
+ cacheTtlMs?: number;
82
+ }
83
+
84
+ /**
85
+ * Best-effort fleet External spend for `/usage`.
86
+ * Primary: auth-broker `get-external-spend` (master key stays in broker).
87
+ * Returns null when unavailable โ€” caller omits the block.
88
+ */
89
+ export async function fetchExternalSpendSummary(
90
+ nowOrDeps: Date | FetchExternalSpendDeps = {},
91
+ ): Promise<ExternalSpendSummary | null> {
92
+ const deps: FetchExternalSpendDeps =
93
+ nowOrDeps instanceof Date ? { now: nowOrDeps } : nowOrDeps;
94
+ const ttl = deps.cacheTtlMs ?? CLIENT_CACHE_TTL_MS;
95
+ if (!deps.bypassCache && clientCache && Date.now() - clientCache.atMs < ttl) {
96
+ return clientCache.summary;
97
+ }
98
+
99
+ try {
100
+ let data: Awaited<ReturnType<NonNullable<FetchExternalSpendDeps['getExternalSpend']>>>;
101
+ if (deps.getExternalSpend) {
102
+ data = await deps.getExternalSpend(deps.forceLive);
103
+ } else {
104
+ // Lazy import avoids pulling the full client graph into pure test paths.
105
+ const { AuthBrokerClient } = await import('../src/auth/broker/client.js');
106
+ const client = new AuthBrokerClient();
107
+ try {
108
+ data = await client.getExternalSpend(deps.forceLive);
109
+ } finally {
110
+ try {
111
+ await client.close();
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ }
116
+ }
117
+ if (!data?.available) return null;
118
+ if (
119
+ typeof data.day24hUsd !== 'number' ||
120
+ typeof data.day7dUsd !== 'number' ||
121
+ !Array.isArray(data.top)
122
+ ) {
123
+ return null;
124
+ }
125
+ const summary: ExternalSpendSummary = {
126
+ day24hUsd: data.day24hUsd,
127
+ day7dUsd: data.day7dUsd,
128
+ top: data.top.map((t) => ({ label: String(t.label), usd: Number(t.usd) })),
129
+ };
130
+ clientCache = { atMs: Date.now(), summary };
131
+ return summary;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
@@ -42,6 +42,7 @@ import {
42
42
  resolveSafeBoundaryEnabled,
43
43
  } from './interrupt-defer.js'
44
44
  import { parseStopKeyword, buildStopReply } from './stop-command.js'
45
+ import { shouldMaskUsageLabels } from './usage-mask.js'
45
46
  import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
46
47
  import {
47
48
  resolveStickerSendArgs,
@@ -537,6 +538,9 @@ import {
537
538
  modelCommandReceiptLine,
538
539
  handleModelCommand,
539
540
  classifyModelSwitchConfirmation,
541
+ formatModelRelaunchDiagLog,
542
+ formatModelSwitchConfirmationBody,
543
+ formatModelRelaunchSuppressNotAppliedLog,
540
544
  buildModelMenu,
541
545
  handleModelMenuCallback,
542
546
  isValidModelArg,
@@ -651,6 +655,7 @@ import {
651
655
  mutateStatusPinRow,
652
656
  reconcileAndPersistStatusPin,
653
657
  runStatusPinBootCleanup,
658
+ withPinReconcileLock,
654
659
  type PersistedStatusPin,
655
660
  type StatusPinPersistOp,
656
661
  } from './status-pin-store.js'
@@ -877,7 +882,7 @@ import {
877
882
  decideAnnouncementDelivery,
878
883
  foldAnnouncementIntoCard,
879
884
  } from '../fallback-card-collapse.js'
880
- import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
885
+ import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults, deriveUsageFooterFreshness } from '../auth-snapshot-format.js'
881
886
  import { maskUsername } from '../demo-mask.js'
882
887
  import {
883
888
  writeTurnActiveMarker,
@@ -8440,6 +8445,10 @@ const PIN_STATUS_WHILE_WORKING = (() => {
8440
8445
  // handlers unconditionally unpinning on every send) and runs NO polling
8441
8446
  // watchdog / getChat().pinned_message reconciler.
8442
8447
  const statusPinState = new Map<string, PinState>()
8448
+ // F2 serialization: same-pinKey reconciles run one-at-a-time via
8449
+ // `withPinReconcileLock` (status-pin-store.ts โ€” kept there so it's
8450
+ // unit-testable) so each reads a fresh `prev`; see its doc for the stale-`prev`
8451
+ // race it closes. Adds zero Telegram API calls (a serialized noop still no-ops).
8443
8452
  // Companion registry: pinKey โ†’ chatId, so the pre-restart sweep can unpin
8444
8453
  // owned pins without threading the chat id through every call site. Written on
8445
8454
  // every desired-pinned reconcile, cleared alongside the state on unpin.
@@ -9023,7 +9032,10 @@ async function reconcileStatusPin(
9023
9032
  // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
9024
9033
  // as a normal tool-error โ€” that path is `executePinMessage`, not this one.)
9025
9034
  try {
9026
- await reconcileStatusPinInner(pinKey, chatId, desired)
9035
+ // Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
9036
+ // in-memory claim map at its top, so overlapping same-key reconciles must
9037
+ // run one-at-a-time or a stale `prev` clears the disk row under a live pin.
9038
+ await withPinReconcileLock(pinKey, () => reconcileStatusPinInner(pinKey, chatId, desired))
9027
9039
  } catch (err) {
9028
9040
  const msg = err instanceof Error ? err.message : String(err)
9029
9041
  process.stderr.write(
@@ -9040,14 +9052,11 @@ async function reconcileStatusPinInner(
9040
9052
  ): Promise<void> {
9041
9053
  if (!PIN_STATUS_WHILE_WORKING) return
9042
9054
  if (chatId.length === 0) return
9043
- // NOTE (invisible-worker-cards review, intentionally left): this reconcile is
9044
- // NOT serialized per pinKey โ€” it snapshots `prev` then awaits. Two edits that
9045
- // fire `syncPin` in the same microtask window after a dropped claim can both
9046
- // read `prev=null` and both issue a `pinChatMessage` for the SAME id. That is
9047
- // benign and self-healing: re-pinning an already-pinned id is idempotent on
9048
- // Telegram, and the first reconcile to set the claim makes every subsequent
9049
- // edit a no-op โ€” it converges in one round, never a storm. A per-key mutex
9050
- // would remove the duplicate pin but adds lock complexity for zero UX gain.
9055
+ // Serialized per pinKey by `withPinReconcileLock` at the caller (F2), so this
9056
+ // `prev` snapshot is taken only after any prior same-key reconcile fully
9057
+ // settled โ€” always the true current claim. Closes the stale-`prev` race (a
9058
+ // turn-end clear dropping the disk row under a flood-delayed open-pin) and the
9059
+ // older duplicate-pin concern (two edits both reading prev=null).
9051
9060
  const prev = statusPinState.get(pinKey) ?? null
9052
9061
 
9053
9062
  const runReconcile = () =>
@@ -14333,6 +14342,26 @@ function isAuthorizedSender(ctx: Context): boolean {
14333
14342
  return false
14334
14343
  }
14335
14344
 
14345
+ // Adversarial-review F4 โ€” a group configured with an EMPTY `allowFrom`
14346
+ // authorizes every member (isAuthorizedSender returns true for any sender in
14347
+ // that group). That's an intentional "whole-group" access mode, but it means
14348
+ // `/usage` would expose per-account email labels + quota headroom to every
14349
+ // member of a broadly-shared group. Harden minimally: for the quota-bearing
14350
+ // card in a non-private chat, mask account labels (reusing the demo-mask
14351
+ // machinery) UNLESS the group pinned a non-empty `allowFrom` โ€” i.e. an
14352
+ // explicit operator-curated member list is treated as trusted enough to see
14353
+ // the real labels. Private (operator DM) chats are never masked. This changes
14354
+ // only what /usage REVEALS, not who may run it. The pure decision lives in
14355
+ // ./usage-mask.ts (shouldMaskUsageLabels) so it is unit-testable without
14356
+ // importing the whole gateway module.
14357
+ function shouldMaskAccountLabels(ctx: Context): boolean {
14358
+ const groupAllowFrom =
14359
+ ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
14360
+ ? loadAccess().groups[String(ctx.chat.id)]?.allowFrom
14361
+ : undefined
14362
+ return shouldMaskUsageLabels(ctx.chat?.type, groupAllowFrom)
14363
+ }
14364
+
14336
14365
  // safeName moved to ./media-message-handlers.ts (switchroom#2996 P6 cluster A);
14337
14366
  // imported above and shared with the attachment handlers still inline here.
14338
14367
 
@@ -21444,7 +21473,12 @@ bot.command('issues', async ctx => {
21444
21473
  })
21445
21474
  bot.command('usage', async ctx => {
21446
21475
  if (!isAuthorizedSender(ctx)) return
21447
- const demo = hasDemoFlag(getCommandArgs(ctx))
21476
+ // `demo` is the explicit `/usage demo` opt-in mask. F4 additionally masks
21477
+ // account labels for a non-private chat whose group has no pinned
21478
+ // `allowFrom` (open-membership group) โ€” see shouldMaskAccountLabels. The
21479
+ // effective mask feeds the label-rendering paths (renderUsageCard,
21480
+ // buildSnapshotKeyboard) exactly as `demo` did.
21481
+ const demo = hasDemoFlag(getCommandArgs(ctx)) || shouldMaskAccountLabels(ctx)
21448
21482
  // Format 2 path: enumerate every account in the broker's known set,
21449
21483
  // probe live quota in parallel, render the health-grouped snapshot.
21450
21484
  // Falls back to the legacy single-agent shape when the broker is
@@ -21484,26 +21518,34 @@ bot.command('usage', async ctx => {
21484
21518
  // segment, just relative instead of absolute โ€” no info lost; the
21485
21519
  // recommendation + cached/live footer the table used to carry are
21486
21520
  // preserved by renderUsageCard.
21521
+ // External OpenRouter/$ block (layout B) โ€” best-effort; omitted when
21522
+ // LiteLLM admin key is unavailable or the spend endpoint fails.
21523
+ const { fetchExternalSpendSummary } = await import('../external-spend.js')
21524
+ const externalSpend = await fetchExternalSpendSummary(renderNow).catch(() => null)
21487
21525
  const exhaustedByLabel = new Map<string, boolean>(
21488
21526
  state.accounts.map((a) => [a.label, a.exhausted]),
21489
21527
  )
21490
21528
  const text = renderUsageCard(snapshots, exhaustedByLabel, {
21491
21529
  now: renderNow,
21492
21530
  demo,
21531
+ externalSpend,
21493
21532
  // #2495 Change 2 โ€” a TTL-hit / failed-probe fallback is tagged
21494
21533
  // served:"cache"; surface it as `โš  cached Nm ago` instead of a
21495
21534
  // false live stamp. Otherwise stamp the live refresh time.
21496
21535
  // Honesty backstop: a TOTAL probe failure (the .catch above
21497
21536
  // returned `{results: []}` and nothing was served from cache)
21498
21537
  // must render an explicit "probe failed" marker, NOT a false
21499
- // "Live" stamp next to "โš ๏ธ no data" rows. Without this the
21500
- // footer claimed "Live ยท refreshed 0s ago" while every account
21501
- // row said "no data โ€” probe failed" (#2959 review finding).
21502
- ...(staleCachedAtMs != null
21503
- ? { staleCachedAtMs }
21504
- : probeResp.results.length > 0
21505
- ? { liveProbedAtMs: renderNow.getTime() }
21506
- : { probeFailed: true }),
21538
+ // "Live" stamp next to "โš ๏ธ no data" rows (#2959 review finding).
21539
+ // Honesty invariant (adversarial-review F1): the live stamp is
21540
+ // derived from whether ANY row carries usable data, NOT from the
21541
+ // array length โ€” a failed live probe against an empty cache returns
21542
+ // a non-empty results array of all-`ok:false` rows. Full rationale
21543
+ // in deriveUsageFooterFreshness (auth-snapshot-format.ts).
21544
+ ...deriveUsageFooterFreshness(
21545
+ probeResp.results,
21546
+ staleCachedAtMs,
21547
+ renderNow.getTime(),
21548
+ ),
21507
21549
  })
21508
21550
  // Preserve the Switch/Refresh/usage/Add inline keyboard on the
21509
21551
  // rich-message render โ€” the table card carries the same actions the
@@ -23868,62 +23910,36 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23868
23910
  // scraped pane or an optimistic record.
23869
23911
  const isApplyBoot = launched.length > 0 && launched !== configured
23870
23912
  sessionModelSource.setOverride(isApplyBoot ? launched : null)
23871
- // Diagnosability (rev 5): the applied model is now always
23872
- // greppable โ€” `grep 'gw /model relaunch applied'`. F4 note:
23873
- // `launched` is the REQUESTED token start.sh wrote before `exec
23874
- // claude` (it is NOT a post-launch confirmation). If a shape-valid
23875
- // but unknown Claude id was requested, `--fallback-model` may mask
23876
- // it: claude serves a fallback while this records the requested
23877
- // token. That divergence is NOT a persistent lie โ€” the transcript's
23878
- // `message.model` (noteTranscriptModel) reclaims the source from
23879
- // this override on the first assistant line, correcting /status to
23880
- // the model actually serving calls. The pre-first-assistant window
23881
- // is the only optimistic window (G2), and it is bounded and
23882
- // self-healing; it is documented, not silently asserted as success.
23913
+ // F1/N4: classify + log + one confirmation card. Formatters live
23914
+ // in model-command.ts so this file does not inflate (#2996 ratchet).
23915
+ const confirmation = modelSwitchReason != null
23916
+ ? classifyModelSwitchConfirmation({
23917
+ reason: modelSwitchReason,
23918
+ launched,
23919
+ configured,
23920
+ })
23921
+ : null
23883
23922
  process.stderr.write(
23884
- `telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || '(none)'} configured=${configured} override=${isApplyBoot ? 'set' : 'cleared'}\n`,
23885
- )
23886
- // Switch-confirmation (F1 / PLAN ยง4 step 2): on a /model apply-boot
23887
- // with a known initiating chat, send ONE confirmation built from the
23888
- // ACTUAL launched model โ€” never optimistic. Keyed on the DETERMINISTIC
23889
- // /model switch reason (from the clean-shutdown marker), not on
23890
- // `launched !== configured`, so it ALSO fires when a switch landed on
23891
- // the configured default (`/model default`, or `/model <configured>`)
23892
- // โ€” N4. The generic boot card is suppressed for this boot (N3), so
23893
- // this is the single card the operator sees for the switch.
23894
- if (modelSwitchReason != null && modelSwitchMarkerChat) {
23895
- const chat = modelSwitchMarkerChat
23896
- // Derive the confirmation from the DETERMINISTIC post-boot
23897
- // signals. A non-default switch that reverted to the configured
23898
- // default (a wedged/consumed apply-boot โ€” the silent-revert bug)
23899
- // must WARN, not print a misleading green "โœ… Now running
23900
- // <default>" card. `applied` / `default` keep the honest green
23901
- // card (N4: the default/revert case still confirms).
23902
- const confirmation = classifyModelSwitchConfirmation({
23903
- reason: modelSwitchReason,
23923
+ formatModelRelaunchDiagLog({
23924
+ agent: getMyAgentName(),
23904
23925
  launched,
23905
23926
  configured,
23906
- })
23907
- // LOW-2 dedup: the config-default-changed / proxy-down revert
23908
- // paths in start.sh write a TAILORED `.session-model-alert`
23909
- // (relayed to operators below) that already explains why the
23910
- // switch didn't apply and how to re-issue it. Suppress the
23911
- // generic not-applied card when such an alert is present for
23912
- // this boot so the operator isn't double-warned โ€” the alert is
23913
- // the more specific message. The not-applied card still fires
23914
- // for the plain wedge/revert case (no alert on disk).
23927
+ confirmation,
23928
+ isApplyBoot,
23929
+ }),
23930
+ )
23931
+ if (confirmation != null && modelSwitchMarkerChat) {
23932
+ const chat = modelSwitchMarkerChat
23915
23933
  const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
23916
23934
  if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
23917
23935
  process.stderr.write(
23918
- `telegram gateway: gw /model relaunch applied โ€” suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=${getMyAgentName()} target=${confirmation.target}\n`,
23936
+ formatModelRelaunchSuppressNotAppliedLog({
23937
+ agent: getMyAgentName(),
23938
+ target: confirmation.target,
23939
+ }),
23919
23940
  )
23920
23941
  } else {
23921
- const body =
23922
- confirmation.kind === 'applied'
23923
- ? `โœ… Now running \`${confirmation.launched}\` โ€” session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
23924
- : confirmation.kind === 'not-applied'
23925
- ? `โš ๏ธ Your switch to \`${confirmation.target}\` didn't apply โ€” the agent reverted to \`${confirmation.revertedTo}\` (the apply-boot didn't complete). Re-issue \`/model ${confirmation.target}\` to try again.`
23926
- : `โœ… Now running \`${confirmation.launched}\` (the configured default) โ€” fresh session; memory and the handoff briefing carry the context.`
23942
+ const body = formatModelSwitchConfirmationBody(confirmation)
23927
23943
  // allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
23928
23944
  void lockedBot.api
23929
23945
  .sendMessage(chat.chatId, body, {
@@ -188,6 +188,112 @@ export function classifyModelSwitchConfirmation(input: {
188
188
  return { kind: 'default', launched: revertedTo }
189
189
  }
190
190
 
191
+ /**
192
+ * Diagnostic stderr lines for a /model apply-boot rehydration.
193
+ * Pure strings so gateway.ts stays thin (line-ratchet #2996).
194
+ */
195
+ export function formatModelRelaunchDiagLog(input: {
196
+ agent: string
197
+ launched: string
198
+ configured: string
199
+ confirmation: ModelSwitchConfirmation | null
200
+ isApplyBoot: boolean
201
+ }): string {
202
+ const { agent, launched, configured, confirmation, isApplyBoot } = input
203
+ const L = launched || '(none)'
204
+ if (confirmation == null) {
205
+ return (
206
+ 'telegram gateway: gw /model relaunch applied agent=' +
207
+ agent +
208
+ ' launched=' +
209
+ L +
210
+ ' configured=' +
211
+ configured +
212
+ ' override=' +
213
+ (isApplyBoot ? 'set' : 'cleared') +
214
+ '\n'
215
+ )
216
+ }
217
+ if (confirmation.kind === 'not-applied') {
218
+ return (
219
+ 'telegram gateway: gw /model relaunch NOT-APPLIED agent=' +
220
+ agent +
221
+ ' target=' +
222
+ confirmation.target +
223
+ ' launched=' +
224
+ L +
225
+ ' configured=' +
226
+ configured +
227
+ ' revertedTo=' +
228
+ confirmation.revertedTo +
229
+ '\n'
230
+ )
231
+ }
232
+ if (confirmation.kind === 'applied') {
233
+ return (
234
+ 'telegram gateway: gw /model relaunch applied agent=' +
235
+ agent +
236
+ ' launched=' +
237
+ L +
238
+ ' configured=' +
239
+ configured +
240
+ ' override=set outcome=applied\n'
241
+ )
242
+ }
243
+ return (
244
+ 'telegram gateway: gw /model relaunch applied agent=' +
245
+ agent +
246
+ ' launched=' +
247
+ L +
248
+ ' configured=' +
249
+ configured +
250
+ ' override=cleared outcome=default\n'
251
+ )
252
+ }
253
+
254
+ /** Telegram body for the single switch-confirmation card (F1/N4). */
255
+ export function formatModelSwitchConfirmationBody(
256
+ confirmation: ModelSwitchConfirmation,
257
+ ): string {
258
+ if (confirmation.kind === 'applied') {
259
+ return (
260
+ 'โœ… Now running `' +
261
+ confirmation.launched +
262
+ '` โ€” session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.'
263
+ )
264
+ }
265
+ if (confirmation.kind === 'not-applied') {
266
+ return (
267
+ "โš ๏ธ Your switch to `" +
268
+ confirmation.target +
269
+ "` didn't apply โ€” the agent reverted to `" +
270
+ confirmation.revertedTo +
271
+ "` (the apply-boot didn't complete). Re-issue `/model " +
272
+ confirmation.target +
273
+ "` to try again."
274
+ )
275
+ }
276
+ return (
277
+ 'โœ… Now running `' +
278
+ confirmation.launched +
279
+ '` (the configured default) โ€” fresh session; memory and the handoff briefing carry the context.'
280
+ )
281
+ }
282
+
283
+ export function formatModelRelaunchSuppressNotAppliedLog(input: {
284
+ agent: string
285
+ target: string
286
+ }): string {
287
+ return (
288
+ 'telegram gateway: gw /model relaunch NOT-APPLIED โ€” suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=' +
289
+ input.agent +
290
+ ' target=' +
291
+ input.target +
292
+ '\n'
293
+ )
294
+ }
295
+
296
+
191
297
  export type ParsedModelCommand =
192
298
  | { kind: 'show' }
193
299
  | { kind: 'set'; model: string }
@@ -389,7 +389,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
389
389
  // mid-turn has something to finalize on next boot instead of
390
390
  // leaving this card frozen forever. Fire-and-forget/best-effort:
391
391
  // a failed persist degrades to the pre-fix (in-memory-only)
392
- // behaviour, never blocks the card opening.
392
+ // behaviour, never blocks the card opening (writeActivityCardRecord
393
+ // โ†’ persistActivityCards swallows write errors โ€” it never throws).
394
+ //
395
+ // F6 (persist-intent-first ordering): this write is the FIRST action
396
+ // taken after the send resolves and BEFORE the status-pin reconcile
397
+ // below โ€” no `await` sits between the send and this persist, so the
398
+ // crash window in which a sent card has no durable record (and is
399
+ // thus unreapable by the boot reaper) is the minimum achievable. A
400
+ // true pre-send provisional record is impossible: the reaper keys
401
+ // its finalizing edit on `activityMessageId`, which only exists once
402
+ // sendRichMessage returns. Keep this persist synchronous and ahead
403
+ // of the pin; do not move it after an await.
393
404
  if (activityCardPersistEnabled) {
394
405
  writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
395
406
  turnKey: statusKey(chat, thread),
@@ -397,15 +408,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
397
408
  threadId: thread ?? null,
398
409
  activityMessageId: sent.message_id,
399
410
  startedAt: turn.startedAt,
400
- // Mirror the ACTUAL pin decision, not an unconditional `true`:
401
- // the OPEN below silently-pins the fresh card only when
402
- // `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
403
- // when it's off, and can also fail on missing supergroup
404
- // rights). Persisting `pinned: true` regardless would make the
405
- // boot reaper attempt an unpin on a card that was never pinned.
406
- // The reaper's unpin is defense-in-depth anyway
411
+ // F5 (persist-intent honesty): mirror the ACTUAL pin decision,
412
+ // not an unconditional `true`. The OPEN below silently-pins the
413
+ // fresh card only when `PIN_STATUS_WHILE_WORKING` is on
414
+ // (`reconcileStatusPin` no-ops when it's off, and can also fail
415
+ // on missing supergroup rights). Persisting `pinned: true`
416
+ // regardless would make the boot reaper attempt an unpin on a
417
+ // card that was never pinned. The persist is intentionally
418
+ // written BEFORE the fire-and-forget pin resolves (intent, not
419
+ // outcome); the reaper's unpin is idempotent defense-in-depth
407
420
  // (`statusPinBootCleanup` owns the primary unpin), so tracking
408
- // the flag honestly is what matters here.
421
+ // the DECISION honestly โ€” pinned iff we will actually attempt a
422
+ // pin โ€” is what matters here, not the async pin's result.
409
423
  pinned: PIN_STATUS_WHILE_WORKING,
410
424
  })
411
425
  }
@@ -313,6 +313,40 @@ export function withStoreLock<T>(
313
313
  return run
314
314
  }
315
315
 
316
+ /**
317
+ * Per-pinKey async serial lock (F2, invisible-worker-cards review).
318
+ *
319
+ * The gateway's status-pin reconcile reads its `prev` claim from an in-memory
320
+ * map at the TOP of the reconcile, then awaits the persist+pin. Two overlapping
321
+ * reconciles for the SAME key could each capture a stale `prev`: a turn-end
322
+ * `{pinned:false}` reading prev=null no-ops and clears the durable row while a
323
+ * flood-delayed open-pin lands right after โ€” a stuck pin with NO on-disk record.
324
+ *
325
+ * Chaining every reconcile for one pinKey through this tail map guarantees the
326
+ * NEXT reconcile reads `prev` only after the prior one for that key has fully
327
+ * settled (its in-memory Maps updated), so the pin decision always sees the true
328
+ * current claim. Different keys never contend. Same non-rejecting-tail contract
329
+ * as `withStoreLock`. Keyed by pinKey, held by the gateway around the WHOLE
330
+ * read-prev โ†’ decide โ†’ reconcileAndPersistStatusPin โ†’ update-Maps sequence.
331
+ */
332
+ const pinReconcileTails = new Map<string, Promise<unknown>>()
333
+
334
+ export function withPinReconcileLock<T>(
335
+ pinKey: string,
336
+ fn: () => Promise<T>,
337
+ ): Promise<T> {
338
+ const prev = pinReconcileTails.get(pinKey) ?? Promise.resolve()
339
+ const run = prev.then(fn, fn)
340
+ pinReconcileTails.set(
341
+ pinKey,
342
+ run.then(
343
+ () => undefined,
344
+ () => undefined,
345
+ ),
346
+ )
347
+ return run
348
+ }
349
+
316
350
  /**
317
351
  * READ-MODIFY-WRITE for exactly ONE pinKey's row, against the authoritative
318
352
  * on-disk file. Loads the current snapshot from disk, drops any row for
@@ -431,11 +465,37 @@ export function reconcileAndPersistStatusPin(args: {
431
465
  return next
432
466
  }
433
467
 
434
- // clear: unpin (best-effort) THEN drop the record. Ordering is safe here โ€”
435
- // if we crash after the unpin but before the rewrite, the stale record just
436
- // gets unpinned again next boot (idempotent), never a lingering pin.
468
+ // clear: unpin (best-effort) THEN reconcile the record with the OUTCOME.
469
+ //
470
+ // F1 (invisible-worker-cards review): a `clear` op covers BOTH a genuine
471
+ // unpin AND a `noop: already pinned` โ€” the pin decision maps every non-`pin`
472
+ // action here (see decidePinAction โ†’ the gateway's op mapping). For a real
473
+ // unpin, applyPin drops the claim and returns null โ†’ we remove the row. But
474
+ // for a noop-already-pinned, reconcilePin returns the LIVE claim unchanged
475
+ // (non-null) and issues NO Telegram call โ€” the pin is still up. The worker
476
+ // feed calls syncPin on EVERY steady-state edit, so a noop-clear fires
477
+ // constantly; unconditionally deleting the row there erased the durable
478
+ // status-pins.json entry for a still-live pin. A crash after that left a
479
+ // stuck pinned card boot cleanup could never see. So: only drop the row when
480
+ // the claim is actually gone (next == null); when applyPin returns a live
481
+ // claim, PRESERVE the row (rewritten confirmed) so the durable record keeps
482
+ // tracking the pin that is genuinely still up. No extra Telegram API call is
483
+ // added โ€” applyPin already ran; this only changes the disk write's content.
484
+ // Ordering for the real-unpin case is safe: if we crash after the unpin but
485
+ // before the rewrite, the stale record just gets unpinned again next boot
486
+ // (idempotent), never a lingering pin.
437
487
  const next = await args.applyPin()
438
- applyStatusPinRow(path, fs, pinKey, null, log)
488
+ if (next == null) {
489
+ applyStatusPinRow(path, fs, pinKey, null, log)
490
+ } else {
491
+ applyStatusPinRow(
492
+ path,
493
+ fs,
494
+ pinKey,
495
+ { pinKey, chatId, messageId: next.messageId },
496
+ log,
497
+ )
498
+ }
439
499
  return next
440
500
  })
441
501
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Adversarial-review F4 โ€” account-label masking policy for the `/usage` card.
3
+ *
4
+ * A group configured with an EMPTY `allowFrom` authorizes every member
5
+ * (`isAuthorizedSender` returns true for any sender in that group). That is an
6
+ * intentional "whole-group" access mode, but it means `/usage` would expose
7
+ * per-account email labels + quota headroom to every member of a broadly
8
+ * shared group. This predicate decides whether to mask account labels for the
9
+ * quota-bearing card, reusing the existing demo-mask machinery:
10
+ *
11
+ * - private (operator DM) chat โ†’ never mask
12
+ * - group / supergroup with a NON-empty pinned `allowFrom` โ†’ trusted,
13
+ * operator-curated member list โ†’ don't mask
14
+ * - group / supergroup with an EMPTY `allowFrom` (open membership) โ†’ mask
15
+ * - any other chat type reaching a quota render โ†’ mask defensively
16
+ *
17
+ * This changes only what `/usage` REVEALS, not who may run it โ€” general
18
+ * command authorization semantics are untouched.
19
+ */
20
+ export function shouldMaskUsageLabels(
21
+ chatType: string | undefined,
22
+ groupAllowFrom: readonly string[] | undefined,
23
+ ): boolean {
24
+ if (chatType === 'private') return false
25
+ if (chatType === 'group' || chatType === 'supergroup') {
26
+ return (groupAllowFrom ?? []).length === 0
27
+ }
28
+ return true
29
+ }