switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -0,0 +1,302 @@
1
+ /**
2
+ * status-pin-store.ts — durable snapshot for the status-pin claim set.
3
+ *
4
+ * Why this exists: the gateway tracks the messages it has SILENTLY pinned
5
+ * (`channels.telegram.pin_status_while_working`) in an in-memory Map
6
+ * (`statusPinState` keyed by pinKey → PinState, plus a companion chatId map).
7
+ * A gateway/container restart empties that Map. If a session pins the per-turn
8
+ * status / `🛠 Worker` message and then crashes BEFORE the unpin reconcile runs,
9
+ * the message stays pinned in Telegram but the next boot has no record of it —
10
+ * so it never unpins the orphan, and the service-message-deletion handler can't
11
+ * recognise the orphan's pin as "ours" either. Result: a stale status pin from
12
+ * a dead session lingers.
13
+ *
14
+ * This makes cleanup self-contained across restart: every pin claim persists
15
+ * here; on boot the gateway loads the persisted set and unpins each entry
16
+ * (a status pin from a PRIOR session is stale by definition — the turn it
17
+ * represented is over or crashed), then clears the store. It does NOT re-adopt
18
+ * or re-pin — it only cleans up.
19
+ *
20
+ * Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
21
+ * claim set is tiny and bounded (one entry per in-flight pinned key, normally
22
+ * 0–2). Rewriting the whole set on each change is trivially cheap and needs no
23
+ * compaction. Crash-safety is write-tmp + atomic rename: a crash leaves EITHER
24
+ * the prior complete snapshot OR the new one, never a torn file. PURE w.r.t.
25
+ * the injected fs seam ⇒ unit-testable.
26
+ */
27
+
28
+ export interface StatusPinStoreFsSeam {
29
+ readFileSync: (path: string) => string
30
+ writeFileSync: (path: string, data: string) => void
31
+ /** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
32
+ * the snapshot. */
33
+ renameSync: (from: string, to: string) => void
34
+ existsSync: (path: string) => boolean
35
+ }
36
+
37
+ /** One persisted pin claim: the pinKey, the chat it lives in, and the pinned
38
+ * message id (so boot cleanup can unpin exactly that message).
39
+ *
40
+ * Concurrency note: there is NO turn/worker-id dimension on the ownership guard
41
+ * because the single-gateway startup mutex (acquireStartupLock) guarantees
42
+ * exactly one live gateway per agent owns this shared file at a time — so a
43
+ * pinKey (`fg:`/`wk:`) is unambiguous within the one owning process, and boot
44
+ * cleanup only ever runs after winning that mutex.
45
+ *
46
+ * `pending` marks a record written BEFORE the pin API call landed (persist-
47
+ * intent-first). A crash in the window between the pin API call and the
48
+ * confirming rewrite leaves a pending record on disk; next-boot cleanup unpins
49
+ * pending records too, closing the persist-after-pin leak. Absent/false means
50
+ * the pin was confirmed applied. Optional so a v1 snapshot (no field) still
51
+ * loads fail-open as a confirmed pin. */
52
+ export interface PersistedStatusPin {
53
+ pinKey: string
54
+ chatId: string
55
+ messageId: number
56
+ /** True while the pin API call is in-flight / unconfirmed (see above). */
57
+ pending?: boolean
58
+ }
59
+
60
+ /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
61
+ * pin (pending undefined). v2 adds the optional `pending` flag. Both load
62
+ * fail-open — an unknown/newer version yields []. */
63
+ interface SnapshotEnvelope {
64
+ v: 1 | 2
65
+ pins: PersistedStatusPin[]
66
+ }
67
+
68
+ function isPinRow(x: unknown): x is PersistedStatusPin {
69
+ if (x == null || typeof x !== 'object') return false
70
+ const o = x as Record<string, unknown>
71
+ return (
72
+ typeof o.pinKey === 'string' &&
73
+ o.pinKey.length > 0 &&
74
+ typeof o.chatId === 'string' &&
75
+ o.chatId.length > 0 &&
76
+ typeof o.messageId === 'number' &&
77
+ (o.pending === undefined || typeof o.pending === 'boolean')
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Load the persisted pin set. Returns [] on a missing, unreadable, or malformed
83
+ * file (fail-open to empty: a corrupt snapshot must never crash boot — worst
84
+ * case an orphaned pin isn't cleaned up this boot, strictly no worse than the
85
+ * pre-persistence behaviour).
86
+ */
87
+ export function loadStatusPins(
88
+ path: string,
89
+ fs: StatusPinStoreFsSeam,
90
+ ): PersistedStatusPin[] {
91
+ if (!fs.existsSync(path)) return []
92
+ let raw = ''
93
+ try {
94
+ raw = fs.readFileSync(path)
95
+ } catch {
96
+ return []
97
+ }
98
+ let parsed: unknown
99
+ try {
100
+ parsed = JSON.parse(raw)
101
+ } catch {
102
+ return []
103
+ }
104
+ if (parsed == null || typeof parsed !== 'object') return []
105
+ const env = parsed as Record<string, unknown>
106
+ if ((env.v !== 1 && env.v !== 2) || !Array.isArray(env.pins)) return []
107
+ return env.pins.filter(isPinRow)
108
+ }
109
+
110
+ /**
111
+ * Persist the pin set atomically (write sibling tmp → rename over the real
112
+ * path). Best-effort relative to fs availability: a write failure is logged but
113
+ * never thrown — a failing store degrades to in-memory-only (the pre-
114
+ * persistence behaviour), it must not break live pinning.
115
+ */
116
+ export function persistStatusPins(
117
+ path: string,
118
+ fs: StatusPinStoreFsSeam,
119
+ snapshot: readonly PersistedStatusPin[],
120
+ log: (line: string) => void = (l) => process.stderr.write(l),
121
+ ): void {
122
+ const env: SnapshotEnvelope = { v: 2, pins: [...snapshot] }
123
+ const tmp = path + '.tmp'
124
+ try {
125
+ fs.writeFileSync(tmp, JSON.stringify(env))
126
+ fs.renameSync(tmp, path)
127
+ } catch (err) {
128
+ log(
129
+ `status-pin-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
130
+ `durability degraded to in-memory\n`,
131
+ )
132
+ }
133
+ }
134
+
135
+ /**
136
+ * A single tracked live pin claim, as the gateway holds it in memory
137
+ * (statusPinState keyed by pinKey → PinState.messageId, plus the companion
138
+ * statusPinChatIds pinKey → chatId map). Flattened here so the ownership guard
139
+ * is a pure, unit-testable function decoupled from the gateway's Maps.
140
+ */
141
+ export interface TrackedStatusPin {
142
+ chatId: string
143
+ messageId: number
144
+ }
145
+
146
+ /**
147
+ * CHAT-SCOPED ownership guard for a `pinned_message` service update.
148
+ *
149
+ * Telegram message_ids are per-chat small integers, and the gateway tracks
150
+ * many simultaneous pins across different chats/topics. Matching on messageId
151
+ * ALONE is a real bug: a pin update in chat B whose pinned id happens to equal
152
+ * a status-pin id tracked in chat A would pass, and the gateway would delete
153
+ * chat B's service message — including an operator's MANUAL pin notice. So the
154
+ * match REQUIRES both the messageId AND that the tracked entry lives in the
155
+ * SAME chat as the incoming update.
156
+ */
157
+ export function pinnedMessageIsOurs(
158
+ tracked: Iterable<TrackedStatusPin>,
159
+ chatId: string,
160
+ pinnedMessageId: number,
161
+ ): boolean {
162
+ for (const t of tracked) {
163
+ if (t.messageId === pinnedMessageId && t.chatId === chatId) return true
164
+ }
165
+ return false
166
+ }
167
+
168
+ /**
169
+ * Boot-time orphan cleanup, extracted as a pure routine over injected seams so
170
+ * the ordering + best-effort contract is unit-testable against the REAL code
171
+ * (the gateway's thin wrapper just binds the live fs / unpin api / logger).
172
+ *
173
+ * Any pin persisted by a PRIOR session is stale by definition — its turn ended
174
+ * or the session crashed before its unpin reconcile ran. This includes records
175
+ * left `pending` (the persist-intent-first write from `reconcileAndPersist-
176
+ * StatusPin`): a crash between the pin API call and its confirming rewrite
177
+ * leaves a pending record whose pin MAY have landed in Telegram, so we must
178
+ * treat it exactly like a confirmed one and unpin it. We therefore best-effort
179
+ * unpin EVERY persisted entry (confirmed and pending alike — a failure is
180
+ * non-fatal) and then EMPTY the store regardless, so a permanently-
181
+ * undeliverable unpin can't re-run on every boot. We do NOT re-adopt or re-pin.
182
+ * Returns the counts for logging/testing.
183
+ *
184
+ * CRITICAL: the caller MUST only invoke this AFTER winning the startup mutex.
185
+ * The store is a shared per-agent file; on a double-boot a losing gateway
186
+ * running this would unpin the still-alive holder's legitimate pins.
187
+ */
188
+ export async function runStatusPinBootCleanup(args: {
189
+ path: string
190
+ fs: StatusPinStoreFsSeam
191
+ unpin: (chatId: string, messageId: number) => Promise<unknown>
192
+ log?: (line: string) => void
193
+ }): Promise<{ cleared: number; total: number }> {
194
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
195
+ const persisted = loadStatusPins(args.path, args.fs)
196
+ if (persisted.length === 0) return { cleared: 0, total: 0 }
197
+ let cleared = 0
198
+ for (const pin of persisted) {
199
+ try {
200
+ await args.unpin(pin.chatId, pin.messageId)
201
+ cleared++
202
+ } catch (err) {
203
+ log(
204
+ `status-pin-store: boot cleanup unpin failed ` +
205
+ `(chat=${pin.chatId} msg=${pin.messageId}): ${(err as Error).message}\n`,
206
+ )
207
+ }
208
+ }
209
+ // Empty the store regardless — these claims belong to a dead session; leaving
210
+ // them would re-attempt the same (already-tried) unpins on every future boot.
211
+ persistStatusPins(args.path, args.fs, [], log)
212
+ return { cleared, total: persisted.length }
213
+ }
214
+
215
+ /**
216
+ * The pin action the gateway wants to take for one key, distilled from
217
+ * `decidePinAction`. `pin` carries the target message id; `clear` covers both
218
+ * unpin and no-longer-pinned. The gateway's reconcile computes this and hands
219
+ * it here so the persist-BEFORE-pin ordering lives in one testable place.
220
+ */
221
+ export type StatusPinPersistOp =
222
+ | { kind: 'pin'; messageId: number }
223
+ | { kind: 'clear' }
224
+
225
+ /**
226
+ * Persist-ordering wrapper closing the persist-AFTER-pin leak window.
227
+ *
228
+ * THE BUG this fixes: previously the gateway pinned the Telegram message FIRST,
229
+ * then snapshotted the claim set to disk. A SIGKILL landing between the pin API
230
+ * call succeeding and the snapshot rename left a pin live in Telegram with NO
231
+ * on-disk record — so next-boot cleanup couldn't see it and the pin lingered
232
+ * forever.
233
+ *
234
+ * THE FIX: for a `pin`, write a `pending` record to disk BEFORE issuing the pin
235
+ * API call, then rewrite it as confirmed (pending cleared) once the pin lands
236
+ * — or drop it if the pin failed. Now a crash anywhere in the window leaves a
237
+ * pending record on disk, and boot cleanup unpins pending records too (see
238
+ * runStatusPinBootCleanup), so the orphan is recovered next boot.
239
+ *
240
+ * `snapshotOthers` returns the OTHER live claims (every key except `pinKey`) so
241
+ * the whole set is rewritten atomically each step — mirrors how the gateway
242
+ * snapshots its Map. `applyPin` performs the real Telegram pin/unpin (the
243
+ * gateway binds `reconcilePin`) and returns the next in-memory state so the
244
+ * caller can update its Map. Never throws — persistence is best-effort/fail-open
245
+ * and pin errors are already swallowed by `applyPin` (reconcilePin).
246
+ */
247
+ export async function reconcileAndPersistStatusPin(args: {
248
+ path: string
249
+ fs: StatusPinStoreFsSeam
250
+ pinKey: string
251
+ chatId: string
252
+ op: StatusPinPersistOp
253
+ /** Live claims for every OTHER key (rewritten alongside on each step). */
254
+ snapshotOthers: () => PersistedStatusPin[]
255
+ /** Execute the real pin/unpin; returns the confirmed message id (pin) or
256
+ * null (cleared). Must never throw — API errors are swallowed inside. */
257
+ applyPin: () => Promise<{ messageId: number } | null>
258
+ log?: (line: string) => void
259
+ }): Promise<{ messageId: number } | null> {
260
+ const { path, fs, pinKey, chatId, op } = args
261
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
262
+
263
+ if (op.kind === 'pin') {
264
+ // Persist INTENT first, marked pending — BEFORE the pin API call. If we
265
+ // crash after the pin lands but before the confirm rewrite, this pending
266
+ // record is what boot cleanup uses to unpin the orphan.
267
+ persistStatusPins(
268
+ path,
269
+ fs,
270
+ [
271
+ ...args.snapshotOthers(),
272
+ { pinKey, chatId, messageId: op.messageId, pending: true },
273
+ ],
274
+ log,
275
+ )
276
+ const next = await args.applyPin()
277
+ if (next == null) {
278
+ // Pin failed (claim NOT taken by reconcilePin). Clear the pending record
279
+ // so we don't leave a phantom claim for a pin that never landed.
280
+ persistStatusPins(path, fs, args.snapshotOthers(), log)
281
+ return null
282
+ }
283
+ // Pin confirmed — rewrite the record without the pending flag.
284
+ persistStatusPins(
285
+ path,
286
+ fs,
287
+ [
288
+ ...args.snapshotOthers(),
289
+ { pinKey, chatId, messageId: next.messageId },
290
+ ],
291
+ log,
292
+ )
293
+ return next
294
+ }
295
+
296
+ // clear: unpin (best-effort) THEN drop the record. Ordering is safe here —
297
+ // if we crash after the unpin but before the rewrite, the stale record just
298
+ // gets unpinned again next boot (idempotent), never a lingering pin.
299
+ const next = await args.applyPin()
300
+ persistStatusPins(path, fs, args.snapshotOthers(), log)
301
+ return next
302
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Minimal size-cap rotation for the turn-record JSONL (`turns.jsonl`).
3
+ *
4
+ * emitTurnRecord appends one line per turn forever with no rotation, so the
5
+ * file grows unbounded on a long-lived agent. This keeps at most one rotated
6
+ * generation: when the live file exceeds the cap, it is renamed to
7
+ * `<path>.1` (overwriting any prior generation) and a fresh live file starts.
8
+ * Bounded disk: ≤ 2× the cap.
9
+ *
10
+ * Pure of the append itself — `maybeRotate` only decides + performs the rename.
11
+ * Best-effort: the fs hooks are injected so it's unit-testable and so a rotate
12
+ * failure can be swallowed by the caller (never break turn teardown).
13
+ */
14
+ export const TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
15
+
16
+ export interface RotateFs {
17
+ statSize: (path: string) => number | undefined // undefined ⇒ file absent
18
+ rename: (from: string, to: string) => void
19
+ }
20
+
21
+ /**
22
+ * Rotate `path` → `path.1` if it is at/over `maxBytes`. Returns true if a
23
+ * rotation happened. Absent file (statSize undefined) ⇒ no rotation.
24
+ */
25
+ export function maybeRotate(path: string, fs: RotateFs, maxBytes = TURNS_JSONL_MAX_BYTES): boolean {
26
+ const size = fs.statSize(path)
27
+ if (size == null || size < maxBytes) return false
28
+ fs.rename(path, `${path}.1`)
29
+ return true
30
+ }
@@ -115,7 +115,18 @@ export function classifyRejection(
115
115
  // same family so a single bad chat id can't restart-loop the
116
116
  // process. The visible boot-probe-failed log is the primary
117
117
  // diagnostic; this is the can't-restart-loop guarantee.
118
- desc.includes('chat not found')
118
+ desc.includes('chat not found') ||
119
+ // Pin-rights failure in a supergroup where the bot is not an admin (or
120
+ // lacks the "pin messages" right). Telegram returns this as a 400, not a
121
+ // 403: "not enough rights to manage pinned messages in the chat". The
122
+ // auto status-pin driver is best-effort and cosmetic — a bot that can't
123
+ // pin should skip the pin, never crash-loop the gateway. This was the
124
+ // exact 400 that took marko's gateway down (2026-07-01): the fire-and-
125
+ // forget `void reconcileStatusPin(...)` leaked the rejection and this
126
+ // handler shut the process down. reconcileStatusPin now absorbs its own
127
+ // errors (primary fix); this entry is defense-in-depth so ANY leaked
128
+ // pin-rights 400 from any path is log-only, not fatal.
129
+ desc.includes('not enough rights')
119
130
  ) {
120
131
  return 'log_only'
121
132
  }
@@ -132,6 +132,41 @@ export function buildVaultGrantDeniedInbound(opts: {
132
132
  }
133
133
  }
134
134
 
135
+ /**
136
+ * Build the raw GFM markdown for the "grant approved" confirmation the
137
+ * gateway edits the approval card into after the operator taps Approve.
138
+ *
139
+ * This is ONE markdown string, wrapped ONCE in `richMessage(...)` at the
140
+ * callsite. The bug this guards (the whole reason this helper exists):
141
+ * `richMessage(text)` returns an OBJECT `{ markdown }`, so concatenating a
142
+ * bare string in front of it — `"prefix " + richMessage("suffix")` —
143
+ * coerces the object via `toString()` and renders a literal
144
+ * `[object Object]` in the card. Keep the full message inside this builder
145
+ * and pass the whole thing to a single `richMessage()` call.
146
+ *
147
+ * @param agentEscaped Agent name already run through `escapeHtmlForTg`.
148
+ * @param scope 'read' | 'write'.
149
+ * @param key Vault key (rendered inline-code).
150
+ * @param days Grant TTL in whole days.
151
+ * @param grantId Broker-returned grant id.
152
+ * @param footer Optional trailing footer (e.g. the telegram-id
153
+ * auth-mode note). Empty string when absent.
154
+ */
155
+ export function buildVaultGrantApprovedCardText(opts: {
156
+ agentEscaped: string
157
+ scope: 'read' | 'write'
158
+ key: string
159
+ days: number
160
+ grantId: string
161
+ footer?: string
162
+ }): string {
163
+ return (
164
+ `✅ Granted **${opts.agentEscaped}** ${opts.scope} access to ` +
165
+ `\`${opts.key}\` for ${opts.days}d. ` +
166
+ `(grant \`${opts.grantId}\`)` + (opts.footer ?? '')
167
+ )
168
+ }
169
+
135
170
  /** Subset of PendingVaultRequestSave the save-outcome builders need.
136
171
  * The `vault_request_save` flow has no scope/ttl (it stores a value,
137
172
  * it doesn't mint a scoped grant). */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure renderer for the agent-initiated vault-access approval card (#1012).
3
+ *
4
+ * Extracted from gateway.ts so it can be unit-tested without dragging in the
5
+ * whole gateway module. The card is sent via the rich-message path (GFM
6
+ * markdown), so:
7
+ *
8
+ * - Non-code-span interpolations (`**agent**`, `_why_`) are markdown-escaped
9
+ * via `escapeHtmlForTg` so a stray `*`/`_`/`[` in the agent name or reason
10
+ * can't break the surrounding emphasis.
11
+ * - Code-span interpolations (`key`, `scope`, `duration`) are LITERAL inside
12
+ * the span, so they must NOT be markdown-escaped (that would leak visible
13
+ * backslashes, e.g. `openai/OPENAI\_API\_KEY`). The only char that can
14
+ * prematurely close a code span is a backtick, so `codeSpanSafe` defuses
15
+ * just that one (mirrors approval-card.ts's scope span, #2669). Without
16
+ * this, a key whose value closes the span early lets the following
17
+ * `_why_` / `_footer_` markdown pair against stray delimiters and the card
18
+ * renders on top of itself.
19
+ */
20
+
21
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
22
+ import { codeSpanSafe } from './approval-card.js'
23
+
24
+ /** Minimal shape the card needs — a subset of PendingVaultRequestAccess. */
25
+ export interface VaultRequestAccessCardInput {
26
+ agent: string
27
+ key: string
28
+ scope: string
29
+ reason?: string
30
+ ttl_seconds: number
31
+ }
32
+
33
+ export function renderVaultRequestAccessCard(
34
+ req: VaultRequestAccessCardInput,
35
+ ): string {
36
+ const lines: string[] = []
37
+ const scopeLabel = req.scope === 'write' ? 'write' : 'read'
38
+ const days = Math.round(req.ttl_seconds / 86400)
39
+ const durationLabel =
40
+ days >= 1 ? `${days}d` : `${Math.round(req.ttl_seconds / 3600)}h`
41
+ lines.push(`🔐 **${escapeHtmlForTg(req.agent)}** wants vault access`)
42
+ // Code-span content is literal — defuse only the backtick, never
43
+ // markdown-escape (see file header).
44
+ lines.push(`key: \`${codeSpanSafe(req.key)}\``)
45
+ lines.push(
46
+ `scope: \`${codeSpanSafe(scopeLabel)}\` · duration: \`${codeSpanSafe(durationLabel)}\``,
47
+ )
48
+ // #1790 — always render the why-line, even when the agent omitted `reason`.
49
+ // Rendering "not provided" makes a missing rationale visibly an agent-side
50
+ // failure rather than a card-template choice.
51
+ if (req.reason && req.reason.length > 0) {
52
+ lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
53
+ } else {
54
+ lines.push(`why: _not provided_`)
55
+ }
56
+ lines.push('')
57
+ lines.push(
58
+ `_Tap Approve to mint a scoped grant token (same flow as \`switchroom vault grant\`). Tap Deny to refuse — the agent will receive a denial result._`,
59
+ )
60
+ return lines.join('\n')
61
+ }
@@ -25,7 +25,7 @@
25
25
  * chat exists in the DB.
26
26
  */
27
27
 
28
- import { chmodSync, mkdirSync } from 'fs'
28
+ import { chmodSync, existsSync, mkdirSync } from 'fs'
29
29
  import { join } from 'path'
30
30
  import { redact } from './secret-detect/redact.js'
31
31
 
@@ -121,6 +121,7 @@ const DEFAULT_LIMIT = 10
121
121
  const MAX_LIMIT = 50
122
122
 
123
123
  let db: SqliteDatabase | null = null
124
+ let dbPath: string | null = null
124
125
 
125
126
  /**
126
127
  * Open (or create) the history DB and run migrations + retention sweep.
@@ -133,6 +134,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
133
134
  const Database = loadDatabaseClass()
134
135
  mkdirSync(stateDir, { recursive: true, mode: 0o700 })
135
136
  const path = join(stateDir, 'history.db')
137
+ dbPath = path
136
138
  db = new Database(path, { create: true })
137
139
  // WAL is friendlier for concurrent reads while a long transaction writes,
138
140
  // and survives crashes more cleanly than rollback journal.
@@ -171,12 +173,13 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
171
173
  }
172
174
  }
173
175
 
174
- // Lock the file to owner-only. Same pattern the plugin uses for .env at
175
- // server.ts:52. No-op on Windows (would need ACLs).
176
- try {
177
- chmodSync(path, 0o600)
178
- } catch {
179
- /* ignore — chmod not supported, e.g. some FUSE mounts */
176
+ // Readable by owner and others so the web dashboard (different uid than the
177
+ // agent) can stream replies back to Hermes Desktop. The WAL sidecar files
178
+ // (-shm/-wal) are also chmod'd so SQLite readonly opens succeed for uid=1000.
179
+ // 0644 is safe: the per-agent state dir is already operator-accessible.
180
+ for (const suffix of ['', '-shm', '-wal']) {
181
+ const f = path + suffix
182
+ if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
180
183
  }
181
184
 
182
185
  if (retentionDays > 0) {
@@ -213,6 +216,13 @@ export function checkpointWal(): boolean {
213
216
  if (db == null) return false
214
217
  try {
215
218
  db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').run()
219
+ // Re-apply permissions after WAL truncation (SQLite may recreate -wal/-shm)
220
+ if (dbPath) {
221
+ for (const suffix of ['-shm', '-wal']) {
222
+ const f = dbPath + suffix
223
+ if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
224
+ }
225
+ }
216
226
  return true
217
227
  } catch {
218
228
  return false
@@ -58,8 +58,8 @@ export function formatIdleFooter(rows: ReadonlyArray<TurnRow>, now: number): str
58
58
  const latest = rows.reduce((best, row) => (row.startedAt > best.startedAt ? row : best));
59
59
 
60
60
  if (latest.endedAt == null) {
61
- return `⚙️ working since ${formatAgo(latest.startedAt, now)}`;
61
+ return `⚙️ working since **${formatAgo(latest.startedAt, now)}**`;
62
62
  }
63
63
 
64
- return `🟢 idle · last reply ${formatAgo(latest.endedAt, now)}`;
64
+ return `🟢 idle · last reply **${formatAgo(latest.endedAt, now)}**`;
65
65
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, writeFileSync } from "node:fs";
20
- import { escapeMarkdown } from "./card-format.js";
20
+ import { escapeMarkdown, stackCardLines } from "./card-format.js";
21
21
  import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
22
22
 
23
23
  export interface BotApiForIssuesCard {
@@ -99,26 +99,31 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
99
99
  const overflow = sorted.length - visible.length;
100
100
 
101
101
  const now = opts.now ?? Date.now();
102
- const rows = visible.map((e) => {
102
+ // Build a flat list of SINGLE lines (a row may contribute a head line plus a
103
+ // remediation line) so stackCardLines can promote every inter-line break to a
104
+ // GFM hard break — otherwise the rows collapse onto one visual line in the
105
+ // rich-message renderer (the same bug stackCardLines fixes for status cards).
106
+ const rowLines: string[] = [];
107
+ for (const e of visible) {
103
108
  const emoji = SEVERITY_EMOJI[e.severity];
104
109
  const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
105
110
  const ago = relTime(now - e.last_seen);
106
- const head = `${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`;
111
+ rowLines.push(`${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`);
107
112
  // Render the `detail` line below the summary when present and short
108
113
  // enough to be a remediation hint (not a multi-line stderr tail).
109
114
  // Convention from the cron prompt template: agents put "Fix: <cmd>"
110
115
  // or "→ <cmd>" in detail. Long stderr details are omitted from the
111
116
  // card to keep the layout tight; users can run /issues to see them.
112
117
  const remediation = formatRemediation(e.detail);
113
- return remediation == null ? head : `${head}\n → _${escapeMarkdown(remediation)}_`;
114
- });
118
+ if (remediation != null) rowLines.push(` → _${escapeMarkdown(remediation)}_`);
119
+ }
115
120
 
116
- const lines = [header, "", ...rows];
121
+ const lines = [header, "", ...rowLines];
117
122
  if (overflow > 0) {
118
123
  lines.push("");
119
124
  lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
120
125
  }
121
- return lines.join("\n");
126
+ return stackCardLines(lines);
122
127
  }
123
128
 
124
129
  /**
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { formatResetRelative } from './quota-check.js'
30
+ import { escapeMarkdown } from './card-format.js'
30
31
 
31
32
  // ─── Public types ────────────────────────────────────────────────────────────
32
33
 
@@ -366,10 +367,10 @@ export function formatModelUnavailableCard(
366
367
  opts: FormatCardOptions = {},
367
368
  ): string {
368
369
  const now = opts.now ?? new Date()
369
- const slotPart = opts.slot ? ` (slot **${escHtml(opts.slot)}**)` : ''
370
+ const slotPart = opts.slot ? ` (slot **${escapeMarkdown(opts.slot)}**)` : ''
370
371
  const reason = formatReason(detection, now)
371
372
  const lines = [
372
- `⚠️ **Model unavailable** on agent **${escHtml(agent)}**${slotPart}`,
373
+ `⚠️ **Model unavailable** on agent **${escapeMarkdown(agent)}**${slotPart}`,
373
374
  `Reason: ${reason}`,
374
375
  '',
375
376
  ]
@@ -469,7 +470,3 @@ export function resolveModelUnavailableFromOperatorEvent(
469
470
  }
470
471
 
471
472
  // ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
472
-
473
- function escHtml(text: string): string {
474
- return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
475
- }
@@ -12,6 +12,8 @@
12
12
  * including the quota-exhausted strings migrated from auto-fallback.ts.
13
13
  */
14
14
 
15
+ import { escapeMarkdown } from './format.js'
16
+
15
17
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
16
18
 
17
19
  export type OperatorEventKind =
@@ -210,8 +212,8 @@ export interface RenderResult {
210
212
  * `:` or other delimiter characters.
211
213
  */
212
214
  export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
213
- const agent = escHtml(ev.agent)
214
- const detail = escHtml(ev.detail)
215
+ const agent = escapeMarkdown(ev.agent)
216
+ const detail = escapeMarkdown(ev.detail)
215
217
 
216
218
  switch (ev.kind) {
217
219
  case 'credentials-expired':
@@ -412,7 +414,3 @@ export function resetAllCooldowns(): void {
412
414
  }
413
415
 
414
416
  // ─── Markdown escape (#2669) ──────────────────────────────────────────────────
415
-
416
- function escHtml(text: string): string {
417
- return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
418
- }
@@ -253,10 +253,10 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
253
253
  lines.push("**Claude plan quota**");
254
254
  lines.push("");
255
255
  lines.push(
256
- `**5h window** ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
256
+ `**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
257
257
  );
258
258
  lines.push(
259
- `**7d window** ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
259
+ `**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
260
260
  );
261
261
  if (q.representativeClaim) {
262
262
  lines.push("");