switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/model` and `/effort` slash commands extracted verbatim from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 bot.command drain).
|
|
4
|
+
*
|
|
5
|
+
* Pure-logic helpers (parse/plan/menu/handle from model-command.ts,
|
|
6
|
+
* effort-command.ts, pending-session-command.ts) are imported directly;
|
|
7
|
+
* every gateway-local surface (busy resolver, deps factories, reply/queue
|
|
8
|
+
* plumbing, history recording) is injected via `deps` (repo factory-deps
|
|
9
|
+
* precedent, same shape as registerOpsInfoCommands). `bot` is injected so
|
|
10
|
+
* grammy registration order is preserved at the original call site.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Bot, Context, InlineKeyboard } from 'grammy'
|
|
14
|
+
import { richMessage } from '../rich-send.js'
|
|
15
|
+
import { hardenCardBreaks } from '../format.js'
|
|
16
|
+
import { escapeHtmlForTg } from '../shared/bot-runtime.js'
|
|
17
|
+
import {
|
|
18
|
+
parseModelCommand,
|
|
19
|
+
planModelCommand,
|
|
20
|
+
buildModelMenu,
|
|
21
|
+
handleModelCommand,
|
|
22
|
+
modelCommandReceiptLine,
|
|
23
|
+
type ModelMenuDeps,
|
|
24
|
+
type ModelCommandDeps,
|
|
25
|
+
type ModelMenuReply,
|
|
26
|
+
type ParsedModelCommand,
|
|
27
|
+
} from './model-command.js'
|
|
28
|
+
import {
|
|
29
|
+
parseEffortCommand,
|
|
30
|
+
handleEffortCommand,
|
|
31
|
+
buildEffortMenu,
|
|
32
|
+
type EffortCommandDeps,
|
|
33
|
+
type EffortMenuReply,
|
|
34
|
+
} from './effort-command.js'
|
|
35
|
+
import {
|
|
36
|
+
ackText as pendingCmdAckText,
|
|
37
|
+
type PendingSessionCommand,
|
|
38
|
+
} from './pending-session-command.js'
|
|
39
|
+
|
|
40
|
+
export interface ModelEffortCommandDeps {
|
|
41
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
42
|
+
getMyAgentName: () => string
|
|
43
|
+
resolveThreadId: (chat_id: string, explicit?: string | number | null) => number | undefined
|
|
44
|
+
/** Stale-aware busy resolver (#3262) shared by /model and /effort. */
|
|
45
|
+
resolveModelEffortBusy: () => { currentTurnActive: boolean; turnInFlight: boolean }
|
|
46
|
+
historyEnabled: boolean
|
|
47
|
+
recordInbound: (args: {
|
|
48
|
+
chat_id: string
|
|
49
|
+
thread_id: number | null
|
|
50
|
+
message_id: number
|
|
51
|
+
user: string | null
|
|
52
|
+
user_id: string | null
|
|
53
|
+
ts: number
|
|
54
|
+
text: string
|
|
55
|
+
}) => void
|
|
56
|
+
buildModelDeps: (restartCtx?: { chatId: string; threadId: number | undefined }) => ModelMenuDeps & ModelCommandDeps
|
|
57
|
+
buildEffortDeps: () => EffortCommandDeps
|
|
58
|
+
modelMenuReplyMarkup: (reply: ModelMenuReply) => InlineKeyboard | undefined
|
|
59
|
+
effortMenuReplyMarkup: (reply: EffortMenuReply) => InlineKeyboard | undefined
|
|
60
|
+
enqueueSessionCommand: (cmd: PendingSessionCommand) => void
|
|
61
|
+
switchroomReply: (
|
|
62
|
+
ctx: Context,
|
|
63
|
+
text: string,
|
|
64
|
+
options?: { html?: boolean; reply_markup?: InlineKeyboard },
|
|
65
|
+
) => Promise<void>
|
|
66
|
+
modelMenuEnabled: () => boolean
|
|
67
|
+
/** stderr log sink. */
|
|
68
|
+
log: (line: string) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerModelEffortCommands(bot: Bot, deps: ModelEffortCommandDeps): void {
|
|
72
|
+
const {
|
|
73
|
+
isAuthorizedSender,
|
|
74
|
+
getMyAgentName,
|
|
75
|
+
resolveThreadId,
|
|
76
|
+
resolveModelEffortBusy,
|
|
77
|
+
historyEnabled,
|
|
78
|
+
recordInbound,
|
|
79
|
+
buildModelDeps,
|
|
80
|
+
buildEffortDeps,
|
|
81
|
+
modelMenuReplyMarkup,
|
|
82
|
+
effortMenuReplyMarkup,
|
|
83
|
+
enqueueSessionCommand,
|
|
84
|
+
switchroomReply,
|
|
85
|
+
modelMenuEnabled,
|
|
86
|
+
log,
|
|
87
|
+
} = deps
|
|
88
|
+
|
|
89
|
+
bot.command('model', async ctx => {
|
|
90
|
+
if (!isAuthorizedSender(ctx)) return
|
|
91
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
92
|
+
const parsed: ParsedModelCommand = parseModelCommand(text) ?? { kind: 'show' as const }
|
|
93
|
+
const chatId = String(ctx.chat!.id)
|
|
94
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
95
|
+
// #3177 — durable receipt FIRST. A typed /model must NEVER be invisible: even
|
|
96
|
+
// if every downstream reply is shed/dropped, or the session is in a
|
|
97
|
+
// phantom-idle window (turn atom cleared while claude is still busy), the
|
|
98
|
+
// command leaves a greppable log line + a history row before any branch. This
|
|
99
|
+
// is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
|
|
100
|
+
// ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
|
|
101
|
+
// the authoritative delivery-machine/approval gate) — but a STALE atom or a
|
|
102
|
+
// wedged approval older than the hard TTL is discounted as idle (#3262), so a
|
|
103
|
+
// phantom "active turn" on an idle session no longer blocks the switch. Resolve
|
|
104
|
+
// once (it may clear a dangling atom) and reuse for both the receipt log and
|
|
105
|
+
// the routing disposition.
|
|
106
|
+
const modelBusy = resolveModelEffortBusy()
|
|
107
|
+
const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
|
|
108
|
+
log(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
|
|
109
|
+
if (historyEnabled && ctx.message?.message_id != null) {
|
|
110
|
+
try {
|
|
111
|
+
recordInbound({
|
|
112
|
+
chat_id: chatId,
|
|
113
|
+
thread_id: threadId ?? null,
|
|
114
|
+
message_id: ctx.message.message_id,
|
|
115
|
+
user: ctx.from?.username ?? (ctx.from?.id != null ? String(ctx.from.id) : null),
|
|
116
|
+
user_id: ctx.from?.id != null ? String(ctx.from.id) : null,
|
|
117
|
+
ts: ctx.message.date ?? Math.floor(Date.now() / 1000),
|
|
118
|
+
text,
|
|
119
|
+
})
|
|
120
|
+
} catch (err) {
|
|
121
|
+
log(`telegram gateway: /model recordInbound failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const cmdDeps = buildModelDeps({ chatId, threadId })
|
|
125
|
+
// Route on a pure disposition (#3177) that folds BOTH busy signals so a
|
|
126
|
+
// session busy by EITHER measure ack+queues instead of silently injecting
|
|
127
|
+
// into a busy pane. Every branch below produces a visible action.
|
|
128
|
+
const disposition = planModelCommand(parsed, {
|
|
129
|
+
currentTurnActive: modelBusy.currentTurnActive,
|
|
130
|
+
turnInFlight: modelBusy.turnInFlight,
|
|
131
|
+
menuEnabled: modelMenuEnabled(),
|
|
132
|
+
})
|
|
133
|
+
if (disposition.kind === 'menu') {
|
|
134
|
+
const menu = await buildModelMenu(cmdDeps)
|
|
135
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Mid-turn (by either busy signal): instead of dead-ending ("Try again in a
|
|
139
|
+
// moment") or silently injecting into a busy pane, ACK + QUEUE + apply-on-idle
|
|
140
|
+
// + confirm (#3017/#3177). The typed set path either injects into claude's
|
|
141
|
+
// input box or triggers a carrier restart — both unsafe while busy.
|
|
142
|
+
if (disposition.kind === 'queue') {
|
|
143
|
+
const target = disposition.target
|
|
144
|
+
const sent = await ctx.replyWithRichMessage(
|
|
145
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
|
|
146
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
147
|
+
)
|
|
148
|
+
enqueueSessionCommand({
|
|
149
|
+
kind: 'model',
|
|
150
|
+
origin: 'typed',
|
|
151
|
+
arg: target,
|
|
152
|
+
targetLabel: target,
|
|
153
|
+
chatId,
|
|
154
|
+
threadId,
|
|
155
|
+
ackChatId: chatId,
|
|
156
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
157
|
+
requestedAt: Date.now(),
|
|
158
|
+
})
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// Rev 5: the handler relaunches through the carrier and owns every side effect
|
|
162
|
+
// (override write, carrier, premium-recovery clear). There is no post-hoc
|
|
163
|
+
// recording — /status is reconciled at boot from `.active-session-model`, so
|
|
164
|
+
// an unapplied switch can never be optimistically recorded here.
|
|
165
|
+
const reply = await handleModelCommand(parsed, cmdDeps)
|
|
166
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
167
|
+
})
|
|
168
|
+
bot.command('effort', async ctx => {
|
|
169
|
+
if (!isAuthorizedSender(ctx)) return
|
|
170
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
171
|
+
const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
|
|
172
|
+
const cmdDeps = buildEffortDeps()
|
|
173
|
+
if (parsed.kind === 'show') {
|
|
174
|
+
const menu = buildEffortMenu(cmdDeps)
|
|
175
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
|
|
179
|
+
// /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
|
|
180
|
+
// applied") before this gate existed. Use the SAME stale-aware busy resolver
|
|
181
|
+
// as /model (#3262) so a dangling turn atom older than the hard TTL is
|
|
182
|
+
// discounted as idle and the switch applies instead of queuing forever on an
|
|
183
|
+
// idle session.
|
|
184
|
+
const effortBusy = resolveModelEffortBusy()
|
|
185
|
+
if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
|
|
186
|
+
const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
|
|
187
|
+
const chatId = String(ctx.chat!.id)
|
|
188
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
189
|
+
const sent = await ctx.replyWithRichMessage(
|
|
190
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('effort', requestedLevel, escapeHtmlForTg))),
|
|
191
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
192
|
+
)
|
|
193
|
+
enqueueSessionCommand({
|
|
194
|
+
kind: 'effort',
|
|
195
|
+
origin: 'typed',
|
|
196
|
+
arg: requestedLevel,
|
|
197
|
+
targetLabel: requestedLevel,
|
|
198
|
+
chatId,
|
|
199
|
+
threadId,
|
|
200
|
+
ackChatId: chatId,
|
|
201
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
202
|
+
requestedAt: Date.now(),
|
|
203
|
+
})
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const reply = await handleEffortCommand(parsed, cmdDeps)
|
|
207
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
208
|
+
})
|
|
209
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic info slash commands extracted verbatim from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 bot.command drain): `/start`, `/help`, `/status`,
|
|
4
|
+
* `/agents`.
|
|
5
|
+
*
|
|
6
|
+
* Pure read-only info surfaces (welcome/help text, pairing status, agent
|
|
7
|
+
* list) with no send-path or turn-lifecycle coupling. Every gateway-local
|
|
8
|
+
* helper they close over is passed in via `deps` (repo factory-deps
|
|
9
|
+
* precedent, same shape as registerOpsInfoCommands) so the module never
|
|
10
|
+
* back-references the gateway singleton. `bot` is injected so grammy
|
|
11
|
+
* registration order is preserved at the original call site.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Bot, Context } from 'grammy'
|
|
15
|
+
import { richMessage } from '../rich-send.js'
|
|
16
|
+
import {
|
|
17
|
+
startText as buildStartText,
|
|
18
|
+
helpText as buildHelpText,
|
|
19
|
+
statusPairedText as buildStatusPairedText,
|
|
20
|
+
statusPendingText as buildStatusPendingText,
|
|
21
|
+
statusUnpairedText as buildStatusUnpairedText,
|
|
22
|
+
type AgentMetadata,
|
|
23
|
+
} from '../welcome-text.js'
|
|
24
|
+
import { escapeHtmlForTg } from '../shared/bot-runtime.js'
|
|
25
|
+
import type { Access } from './gateway.js'
|
|
26
|
+
|
|
27
|
+
export interface StartInfoCommandDeps {
|
|
28
|
+
/** #894 backport gate: silent drop on disabled / non-allowlisted senders. */
|
|
29
|
+
dmCommandGate: (ctx: Context) => { access: Access; senderId: string } | null
|
|
30
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
31
|
+
getMyAgentName: () => string
|
|
32
|
+
getCommandArgs: (ctx: Context) => string
|
|
33
|
+
hasDemoFlag: (args: string) => boolean
|
|
34
|
+
buildAgentMetadata: (agentName: string) => Promise<AgentMetadata>
|
|
35
|
+
runSwitchroomCommandFormatted: (
|
|
36
|
+
ctx: Context,
|
|
37
|
+
args: string[],
|
|
38
|
+
label: string,
|
|
39
|
+
formatter: () => string | null,
|
|
40
|
+
) => Promise<void>
|
|
41
|
+
switchroomExecJson: <T = unknown>(args: string[]) => T | null
|
|
42
|
+
statusIcon: (status: string) => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerStartInfoCommands(bot: Bot, deps: StartInfoCommandDeps): void {
|
|
46
|
+
const {
|
|
47
|
+
dmCommandGate,
|
|
48
|
+
isAuthorizedSender,
|
|
49
|
+
getMyAgentName,
|
|
50
|
+
getCommandArgs,
|
|
51
|
+
hasDemoFlag,
|
|
52
|
+
buildAgentMetadata,
|
|
53
|
+
runSwitchroomCommandFormatted,
|
|
54
|
+
switchroomExecJson,
|
|
55
|
+
statusIcon,
|
|
56
|
+
} = deps
|
|
57
|
+
|
|
58
|
+
bot.command('start', async ctx => {
|
|
59
|
+
// dmCommandGate (#894 backport): silent drop on disabled or
|
|
60
|
+
// non-allowlisted senders so the bot doesn't leak its existence.
|
|
61
|
+
const gated = dmCommandGate(ctx)
|
|
62
|
+
if (!gated) return
|
|
63
|
+
const disabled = gated.access.dmPolicy === 'disabled'
|
|
64
|
+
await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
|
|
65
|
+
})
|
|
66
|
+
bot.command('help', async ctx => {
|
|
67
|
+
if (!dmCommandGate(ctx)) return
|
|
68
|
+
await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
|
|
69
|
+
})
|
|
70
|
+
bot.command('status', async ctx => {
|
|
71
|
+
// dmCommandGate (#894 backport) drops disabled / non-allowlisted in
|
|
72
|
+
// allowlist mode. Pairing-mode users still fall through to either
|
|
73
|
+
// the pending-code branch or the unpaired branch.
|
|
74
|
+
const gated = dmCommandGate(ctx)
|
|
75
|
+
if (!gated) return
|
|
76
|
+
const { access, senderId } = gated
|
|
77
|
+
const from = ctx.from!
|
|
78
|
+
if (access.allowFrom.includes(senderId)) {
|
|
79
|
+
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
80
|
+
const userTag = from.username ? `@${from.username}` : senderId
|
|
81
|
+
const meta = await buildAgentMetadata(getMyAgentName())
|
|
82
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
for (const [code, p] of Object.entries(access.pending)) {
|
|
86
|
+
if (p.senderId === senderId) {
|
|
87
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await ctx.reply(buildStatusUnpairedText())
|
|
92
|
+
})
|
|
93
|
+
bot.command('agents', async ctx => {
|
|
94
|
+
if (!isAuthorizedSender(ctx)) return
|
|
95
|
+
await runSwitchroomCommandFormatted(ctx, ['agent', 'list'], 'agent list', () => {
|
|
96
|
+
type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
|
|
97
|
+
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
98
|
+
if (!data) return null
|
|
99
|
+
if (data.agents.length === 0) return '_No agents defined_'
|
|
100
|
+
const lines = ['**Agents**']
|
|
101
|
+
for (const a of data.agents) {
|
|
102
|
+
lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`)
|
|
103
|
+
lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
|
|
104
|
+
}
|
|
105
|
+
return lines.join('\n')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -226,6 +226,38 @@ export interface PendingVaultRequestSave {
|
|
|
226
226
|
restoredWithoutValue?: boolean
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* #2045 `request_secret` — agent asks the operator to PROVIDE a secret it does
|
|
231
|
+
* NOT have. No `value` is staged (unlike PendingVaultRequestSave): the value
|
|
232
|
+
* arrives via secure capture (the operator's next message after they tap
|
|
233
|
+
* [Provide securely]). Only request metadata lives here — nothing sensitive.
|
|
234
|
+
*/
|
|
235
|
+
export interface PendingSecretRequest {
|
|
236
|
+
agent: string
|
|
237
|
+
chat_id: string
|
|
238
|
+
key: string
|
|
239
|
+
reason?: string
|
|
240
|
+
staged_at: number
|
|
241
|
+
card_message_id?: number
|
|
242
|
+
/** Supergroup forum topic the agent was working in — carried into the
|
|
243
|
+
* provide/decline/fail outcome inbounds so the resumed reply lands back
|
|
244
|
+
* in that topic, not General. */
|
|
245
|
+
threadId?: number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The armed capture for a `request_secret` card: after [Provide securely] is
|
|
250
|
+
* tapped, the operator's NEXT message in `chat_id` is the value for `key`.
|
|
251
|
+
* Transient post-tap window (never persisted).
|
|
252
|
+
*/
|
|
253
|
+
export interface ArmedSecretCapture {
|
|
254
|
+
key: string
|
|
255
|
+
agent: string
|
|
256
|
+
stageId: string
|
|
257
|
+
armed_at: number
|
|
258
|
+
threadId?: number
|
|
259
|
+
}
|
|
260
|
+
|
|
229
261
|
/**
|
|
230
262
|
* Issue #1012 — agent-initiated vault ACL request. The agent calls
|
|
231
263
|
* `vault_request_access` when it hits VAULT-BROKER-DENIED (or
|
|
@@ -367,6 +399,10 @@ export interface CallbackQueryHandlersDeps {
|
|
|
367
399
|
// Pending-state stores (consolidated surfaces from #3008/#3011).
|
|
368
400
|
pendingVaultRequestAccesses: SweepableCardStore<PendingVaultRequestAccess>
|
|
369
401
|
pendingVaultRequestSaves: SweepableCardStore<PendingVaultRequestSave>
|
|
402
|
+
/** Agent-initiated `request_secret` cards awaiting an operator tap (`vsp:`). */
|
|
403
|
+
pendingSecretRequests: SweepableCardStore<PendingSecretRequest>
|
|
404
|
+
/** Post-[Provide securely] armed captures, keyed by chat_id. */
|
|
405
|
+
armedSecretCaptures: SweepableStore<ArmedSecretCapture>
|
|
370
406
|
pendingMentalModelProposes: SweepableCardStore<PendingMentalModelPropose>
|
|
371
407
|
pendingCardStore: { remove(stageId: string): void }
|
|
372
408
|
pendingMentalModelCorrelations: SweepableStore<{
|
|
@@ -431,6 +467,8 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
|
|
|
431
467
|
swallowingApiCall,
|
|
432
468
|
pendingVaultRequestAccesses,
|
|
433
469
|
pendingVaultRequestSaves,
|
|
470
|
+
pendingSecretRequests,
|
|
471
|
+
armedSecretCaptures,
|
|
434
472
|
pendingMentalModelProposes,
|
|
435
473
|
pendingCardStore,
|
|
436
474
|
pendingMentalModelCorrelations,
|
|
@@ -1676,6 +1714,91 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
1676
1714
|
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
1677
1715
|
}
|
|
1678
1716
|
|
|
1717
|
+
/**
|
|
1718
|
+
* `vsp:` callbacks — agent-requested-secret card (#2045).
|
|
1719
|
+
* vsp:provide:<stageId> — arm capture: operator's next message is the value
|
|
1720
|
+
* vsp:decline:<stageId> — drop the request; tell the agent it was declined
|
|
1721
|
+
*
|
|
1722
|
+
* Extracted verbatim from gateway.ts (#2996 P5) — behavior-preserving. The
|
|
1723
|
+
* `pendingSecretRequests` / `armedSecretCaptures` stores are the SAME singleton
|
|
1724
|
+
* instances gateway.ts constructs (injected via deps, never re-`new`ed here).
|
|
1725
|
+
*/
|
|
1726
|
+
async function handleSecretRequestCallback(ctx: Context, data: string): Promise<void> {
|
|
1727
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1728
|
+
const access = loadAccess()
|
|
1729
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1730
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1731
|
+
return
|
|
1732
|
+
}
|
|
1733
|
+
const parts = data.split(':')
|
|
1734
|
+
const action = parts[1]
|
|
1735
|
+
const stageId = parts[2] ?? ''
|
|
1736
|
+
const pending = pendingSecretRequests.get(stageId)
|
|
1737
|
+
if (!pending) {
|
|
1738
|
+
await ctx.answerCallbackQuery({ text: 'This request expired.' }).catch(() => {})
|
|
1739
|
+
return
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (action === 'provide') {
|
|
1743
|
+
armedSecretCaptures.set(pending.chat_id, {
|
|
1744
|
+
key: pending.key,
|
|
1745
|
+
agent: pending.agent,
|
|
1746
|
+
stageId,
|
|
1747
|
+
armed_at: Date.now(),
|
|
1748
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1749
|
+
})
|
|
1750
|
+
await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
|
|
1751
|
+
if (pending.card_message_id != null) {
|
|
1752
|
+
await ctx.api
|
|
1753
|
+
.editMessageText(
|
|
1754
|
+
pending.chat_id,
|
|
1755
|
+
pending.card_message_id,
|
|
1756
|
+
richMessage(`🔐 Send the value for \`${pending.key}\` as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`),
|
|
1757
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1758
|
+
)
|
|
1759
|
+
.catch(() => {})
|
|
1760
|
+
}
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (action === 'decline') {
|
|
1765
|
+
pendingSecretRequests.delete(stageId)
|
|
1766
|
+
pendingCardStore.remove(stageId)
|
|
1767
|
+
armedSecretCaptures.delete(pending.chat_id)
|
|
1768
|
+
await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
|
|
1769
|
+
if (pending.card_message_id != null) {
|
|
1770
|
+
await ctx.api
|
|
1771
|
+
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(`🚫 Declined — \`${pending.key}\` not provided.`), {
|
|
1772
|
+
reply_markup: { inline_keyboard: [] },
|
|
1773
|
+
})
|
|
1774
|
+
.catch(() => {})
|
|
1775
|
+
}
|
|
1776
|
+
// Tell the agent so it stops waiting.
|
|
1777
|
+
const ts = Date.now()
|
|
1778
|
+
const synthetic: InboundMessage = {
|
|
1779
|
+
type: 'inbound',
|
|
1780
|
+
chatId: pending.chat_id,
|
|
1781
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1782
|
+
messageId: ts,
|
|
1783
|
+
user: 'vault-broker',
|
|
1784
|
+
userId: 0,
|
|
1785
|
+
ts,
|
|
1786
|
+
text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
|
|
1787
|
+
meta: {
|
|
1788
|
+
source: 'secret_declined',
|
|
1789
|
+
agent: pending.agent,
|
|
1790
|
+
...(pending.threadId != null ? { message_thread_id: String(pending.threadId) } : {}),
|
|
1791
|
+
key: pending.key,
|
|
1792
|
+
stage_id: stageId,
|
|
1793
|
+
},
|
|
1794
|
+
}
|
|
1795
|
+
deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
|
|
1796
|
+
return
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
await ctx.answerCallbackQuery().catch(() => {})
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1679
1802
|
async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
|
|
1680
1803
|
const senderId = String(ctx.from?.id ?? '')
|
|
1681
1804
|
const access = loadAccess()
|
|
@@ -2734,6 +2857,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
2734
2857
|
handleMentalModelProposeCallback,
|
|
2735
2858
|
handleVaultRequestAccessCallback,
|
|
2736
2859
|
handleVaultRequestSaveCallback,
|
|
2860
|
+
handleSecretRequestCallback,
|
|
2737
2861
|
handleVaultDeferCallback,
|
|
2738
2862
|
parseGrantDuration,
|
|
2739
2863
|
formatGrantExpiry,
|