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.
Files changed (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. 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,