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.
- package/dist/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- 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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
61
|
+
return `⚙️ working since **${formatAgo(latest.startedAt, now)}**`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
return `🟢 idle · last reply
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
}
|
|
118
|
+
if (remediation != null) rowLines.push(` → _${escapeMarkdown(remediation)}_`);
|
|
119
|
+
}
|
|
115
120
|
|
|
116
|
-
const lines = [header, "", ...
|
|
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
|
|
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 **${
|
|
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 **${
|
|
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 =
|
|
214
|
-
const 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**
|
|
256
|
+
`**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
|
|
257
257
|
);
|
|
258
258
|
lines.push(
|
|
259
|
-
`**7d window**
|
|
259
|
+
`**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
|
|
260
260
|
);
|
|
261
261
|
if (q.representativeClaim) {
|
|
262
262
|
lines.push("");
|