switchroom 0.19.0 โ 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.
- package/dist/agent-scheduler/index.js +29 -1
- package/dist/auth-broker/index.js +552 -48
- package/dist/cli/autoaccept-poll.js +29 -1
- package/dist/cli/drive-write-pretool.mjs +30 -2
- package/dist/cli/ms-365-write-pretool.mjs +30 -2
- package/dist/cli/switchroom.js +751 -36
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +39 -0
- package/telegram-plugin/dist/gateway/gateway.js +363 -25
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/gateway/gateway.ts +83 -67
- package/telegram-plugin/gateway/model-command.ts +106 -0
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +18 -0
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- 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
|
-
|
|
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
|
-
//
|
|
9044
|
-
//
|
|
9045
|
-
//
|
|
9046
|
-
//
|
|
9047
|
-
//
|
|
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
|
-
|
|
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
|
|
21500
|
-
//
|
|
21501
|
-
//
|
|
21502
|
-
|
|
21503
|
-
|
|
21504
|
-
|
|
21505
|
-
|
|
21506
|
-
|
|
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
|
-
//
|
|
23872
|
-
//
|
|
23873
|
-
|
|
23874
|
-
|
|
23875
|
-
|
|
23876
|
-
|
|
23877
|
-
|
|
23878
|
-
|
|
23879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23908
|
-
|
|
23909
|
-
|
|
23910
|
-
|
|
23911
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// `PIN_STATUS_WHILE_WORKING` is on
|
|
403
|
-
// when it's off, and can also fail
|
|
404
|
-
// rights). Persisting `pinned: true`
|
|
405
|
-
// boot reaper attempt an unpin on a
|
|
406
|
-
// The
|
|
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
|
|
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
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|