switchroom 0.19.4 → 0.19.6
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/auth-broker/index.js +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -181,7 +181,13 @@ export function flushedAnswerMatchesReply(flushedText: string, replyText: string
|
|
|
181
181
|
*/
|
|
182
182
|
export function decideSupersede(
|
|
183
183
|
record: FlushedTurnRecord | undefined,
|
|
184
|
-
args: {
|
|
184
|
+
args: {
|
|
185
|
+
liveTurnId: string | null
|
|
186
|
+
replyText?: string | null
|
|
187
|
+
positiveAttribution?: boolean
|
|
188
|
+
now: number
|
|
189
|
+
ttlMs?: number
|
|
190
|
+
},
|
|
185
191
|
): SupersedeDecision {
|
|
186
192
|
const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
|
|
187
193
|
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
@@ -195,10 +201,29 @@ export function decideSupersede(
|
|
|
195
201
|
if (!sameTurn) {
|
|
196
202
|
return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
|
|
197
203
|
}
|
|
198
|
-
// #3429 —
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
204
|
+
// #3429 content gate — but ONLY when the caller is UNSURE the reply is this
|
|
205
|
+
// turn's own answer. `positiveAttribution === true` means the gateway
|
|
206
|
+
// established the reply IS this turn's OWN late answer — either by a positive
|
|
207
|
+
// owner-resolution tier (live atom / `origin_turn_id` echo / quoted message
|
|
208
|
+
// id) OR because no background `subagent_handback` could own it (none enqueued
|
|
209
|
+
// for the chat after the flushed turn ended within the supersede TTL; see
|
|
210
|
+
// outbound-send-path). Then supersede the flushed provisional draft REGARDLESS
|
|
211
|
+
// of text, so a model that RE-WORDED the answer between the narration that
|
|
212
|
+
// flushed and the `reply` tool call still collapses to ONE message (the
|
|
213
|
+
// duplicate-reply regression #3429 introduced on the reworded same-turn path).
|
|
214
|
+
//
|
|
215
|
+
// The content gate remains when `positiveAttribution` is falsey — the
|
|
216
|
+
// ambiguous residual (latest-ended tier AND a handback in flight) where
|
|
217
|
+
// identity alone cannot tell the turn's own late reply from a background
|
|
218
|
+
// sub-agent handback that resolved this flush-delivered ended turn as its
|
|
219
|
+
// owner. There, genuinely different content is a handback and must send FRESH
|
|
220
|
+
// (both messages surface), never edit/delete the flushed answer. Legacy
|
|
221
|
+
// identity-only callers (no `replyText`) keep the pre-#3429 behaviour.
|
|
222
|
+
if (
|
|
223
|
+
!args.positiveAttribution &&
|
|
224
|
+
args.replyText != null &&
|
|
225
|
+
!flushedAnswerMatchesReply(record.text, args.replyText)
|
|
226
|
+
) {
|
|
202
227
|
return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
|
|
203
228
|
}
|
|
204
229
|
return {
|
|
@@ -326,12 +351,18 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
326
351
|
peek(
|
|
327
352
|
chatId: string,
|
|
328
353
|
threadId: number | undefined,
|
|
329
|
-
args: {
|
|
354
|
+
args: {
|
|
355
|
+
liveTurnId: string | null
|
|
356
|
+
replyText?: string | null
|
|
357
|
+
positiveAttribution?: boolean
|
|
358
|
+
now: number
|
|
359
|
+
},
|
|
330
360
|
): SupersedeDecision {
|
|
331
361
|
const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
|
|
332
362
|
return decideSupersede(rec, {
|
|
333
363
|
liveTurnId: args.liveTurnId,
|
|
334
364
|
replyText: args.replyText,
|
|
365
|
+
positiveAttribution: args.positiveAttribution,
|
|
335
366
|
now: args.now,
|
|
336
367
|
ttlMs: this.ttlMs,
|
|
337
368
|
})
|
|
@@ -345,7 +376,12 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
345
376
|
take(
|
|
346
377
|
chatId: string,
|
|
347
378
|
threadId: number | undefined,
|
|
348
|
-
args: {
|
|
379
|
+
args: {
|
|
380
|
+
liveTurnId: string | null
|
|
381
|
+
replyText?: string | null
|
|
382
|
+
positiveAttribution?: boolean
|
|
383
|
+
now: number
|
|
384
|
+
},
|
|
349
385
|
): SupersedeDecision {
|
|
350
386
|
const lane = makeKey(chatId, threadId)
|
|
351
387
|
const decision = this.peek(chatId, threadId, args)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure slash-command output formatting & keyboard helpers extracted from
|
|
3
|
+
* `gateway.ts` (switchroom#3461, chips at #3460).
|
|
4
|
+
*
|
|
5
|
+
* Every function here is a straight MOVE from gateway.ts — bodies are
|
|
6
|
+
* byte-identical, only `export` was added. None of them closed over any
|
|
7
|
+
* IIFE-local mutable state in gateway.ts: their inputs are explicit
|
|
8
|
+
* parameters plus imported constants/types (RICH_MESSAGE_MAX_CHARS,
|
|
9
|
+
* grammy's InlineKeyboard/Context, AuthCodeOutcome, and the vault error
|
|
10
|
+
* parser/renderer). That is what makes the extraction behavior-neutral
|
|
11
|
+
* and the helpers independently unit-testable
|
|
12
|
+
* (`tests/command-format.test.ts`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { InlineKeyboard, type Context } from 'grammy'
|
|
16
|
+
import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
17
|
+
import type { AuthCodeOutcome } from '../../src/auth/manager.js'
|
|
18
|
+
import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
|
|
19
|
+
|
|
20
|
+
// Default truncation budget for CLI output bound for Telegram. The rich-message
|
|
21
|
+
// wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
|
|
22
|
+
// plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
|
|
23
|
+
export function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
|
|
24
|
+
const trimmed = output.trim()
|
|
25
|
+
if (trimmed.length <= maxLen) return trimmed
|
|
26
|
+
return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stripAnsi(text: string): string {
|
|
30
|
+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// #2669: escape GFM-markdown specials in dynamic values interpolated into
|
|
34
|
+
// rich-message bodies (kept under the legacy name to avoid churn).
|
|
35
|
+
export function escapeHtmlForTg(text: string): string {
|
|
36
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Wrap CLI/command output in a fenced code block (content is literal there).
|
|
40
|
+
export function preBlock(text: string): string {
|
|
41
|
+
return '```\n' + text.replace(/```/g, '```') + '\n```'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getCommandArgs(ctx: Context): string {
|
|
45
|
+
const fromMatch = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
|
46
|
+
if (fromMatch) return fromMatch
|
|
47
|
+
const text = (ctx.msg as { text?: string } | undefined)?.text ?? (ctx.message as { text?: string } | undefined)?.text ?? ''
|
|
48
|
+
const m = text.match(/^\/\S+\s+([\s\S]*)$/)
|
|
49
|
+
return m ? m[1].trim() : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when a slash command's argument string carries a trailing `demo`
|
|
54
|
+
* token — the per-command PII-mask modifier for screen recordings
|
|
55
|
+
* (`/usage demo`, `/auth demo`, `/status demo`, `/whoami demo`). Matches
|
|
56
|
+
* `demo` as the last whitespace-delimited token, case-insensitively, so
|
|
57
|
+
* `/auth show demo` and `/usage demo` both flip the flag while a label
|
|
58
|
+
* literally named `demo-foo` does not.
|
|
59
|
+
*/
|
|
60
|
+
export function hasDemoFlag(args: string): boolean {
|
|
61
|
+
return /(?:^|\s)demo$/i.test(args.trim())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validate that a string looks like a safe agent/resource name.
|
|
65
|
+
* Agent names should be alphanumeric with hyphens/underscores only.
|
|
66
|
+
* This prevents shell metacharacter injection even though both exec
|
|
67
|
+
* functions already handle quoting. Defense in depth. */
|
|
68
|
+
export function assertSafeAgentName(name: string): void {
|
|
69
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name) && name !== 'all') {
|
|
70
|
+
throw new Error(`invalid agent name: ${name}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatAuthOutputForTelegram(output: string): { text: string; url: string | null } {
|
|
75
|
+
const trimmed = stripAnsi(output).trim()
|
|
76
|
+
const url = trimmed.match(/https:\/\/\S+/)?.[0] ?? null
|
|
77
|
+
const lines = trimmed.split(/\n+/).map(l => l.trim()).filter(Boolean)
|
|
78
|
+
if (!url) return { text: preBlock(formatSwitchroomOutput(trimmed)), url: null }
|
|
79
|
+
// Drop the `switchroom auth code ...` and `switchroom auth cancel ...`
|
|
80
|
+
// CLI hints. In Telegram the user never types those — they just reply
|
|
81
|
+
// with the code (intercepted by the pendingReauthFlows flow above) or
|
|
82
|
+
// tap the inline button. Surfacing shell syntax is confusing noise on
|
|
83
|
+
// a phone.
|
|
84
|
+
const body = lines.filter(line => {
|
|
85
|
+
if (line === url) return false
|
|
86
|
+
if (line.startsWith('switchroom auth code')) return false
|
|
87
|
+
if (line.startsWith('switchroom auth cancel')) return false
|
|
88
|
+
if (line.startsWith("Use 'tmux attach")) return false
|
|
89
|
+
if (line.startsWith('After Claude shows you a browser code')) return false
|
|
90
|
+
if (line.startsWith('Then finish with:')) return false
|
|
91
|
+
if (line.startsWith('Cancel with:')) return false
|
|
92
|
+
return true
|
|
93
|
+
})
|
|
94
|
+
const rendered = body.map(line => {
|
|
95
|
+
if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
|
|
96
|
+
if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
|
|
97
|
+
return escapeHtmlForTg(line)
|
|
98
|
+
})
|
|
99
|
+
// Mobile-native post-script. Two paths depending on which Anthropic
|
|
100
|
+
// account the user wants to authorize:
|
|
101
|
+
//
|
|
102
|
+
// (a) Button: 🔐 Open Claude auth — opens in Telegram's in-app
|
|
103
|
+
// browser (WebView) on most mobile clients. WebView has its
|
|
104
|
+
// own cookie jar, separate from the user's main browser. Fine
|
|
105
|
+
// when the WebView is already signed into the intended Claude
|
|
106
|
+
// account; wrong when it's signed into a different one.
|
|
107
|
+
//
|
|
108
|
+
// (b) Long-press the URL text at the bottom of this message — every
|
|
109
|
+
// mobile Telegram client exposes "Copy Link" / "Open in
|
|
110
|
+
// Browser" / "Open in Chrome" on long-press. That's the
|
|
111
|
+
// escape hatch when you need to land in your main browser
|
|
112
|
+
// where you control which account is signed in.
|
|
113
|
+
//
|
|
114
|
+
// Why not a copy_text button? We tried. Telegram's CopyTextButton.text
|
|
115
|
+
// field caps at 256 chars and OAuth URLs run ~320–340 chars. Result
|
|
116
|
+
// was BUTTON_COPY_TEXT_INVALID. The long-press-the-URL path achieves
|
|
117
|
+
// the same outcome with no API constraint. See PR #30.
|
|
118
|
+
rendered.push(
|
|
119
|
+
'',
|
|
120
|
+
'👇 Tap **🔐 Open Claude auth** below, then **reply with the browser code**.',
|
|
121
|
+
'',
|
|
122
|
+
'_Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies._',
|
|
123
|
+
'',
|
|
124
|
+
url,
|
|
125
|
+
)
|
|
126
|
+
return { text: rendered.join('\n'), url }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the inline keyboard shown under an auth-flow response that has
|
|
131
|
+
* an OAuth URL. Single button:
|
|
132
|
+
*
|
|
133
|
+
* [🔐 Open Claude auth] — `url` button. On mobile Telegram clients
|
|
134
|
+
* this typically opens in the app's in-app
|
|
135
|
+
* browser (WebView).
|
|
136
|
+
*
|
|
137
|
+
* We previously tried adding a `[📋 Copy URL]` button using Telegram's
|
|
138
|
+
* Bot API 7.7 `copy_text` type but it capped at 256 chars for the
|
|
139
|
+
* copyable text. OAuth URLs (~320–340 chars) exceed that and produce
|
|
140
|
+
* `BUTTON_COPY_TEXT_INVALID`. Instead, the message body renders the
|
|
141
|
+
* URL as a tappable link; users long-press the URL text to get native
|
|
142
|
+
* "Copy Link" / "Open in Browser" actions, bypassing the WebView.
|
|
143
|
+
*
|
|
144
|
+
* Defense in depth: this function's output is validated against
|
|
145
|
+
* Telegram's real field-length constraints in
|
|
146
|
+
* `telegram-plugin/tests/auth-url-keyboard-constraints.test.ts` so
|
|
147
|
+
* future changes that breach a limit fail loudly at CI time rather
|
|
148
|
+
* than silently in production.
|
|
149
|
+
*/
|
|
150
|
+
export function buildAuthUrlKeyboard(authorizeUrl: string): InlineKeyboard {
|
|
151
|
+
return new InlineKeyboard().url('🔐 Open Claude auth', authorizeUrl)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Issue #44: inline keyboard offering a one-tap unlock-and-save flow for
|
|
156
|
+
* a deferred secret. The two buttons fire `vd:` callback_data which the
|
|
157
|
+
* dispatcher in `bot.on('callback_query:data')` routes to
|
|
158
|
+
* `handleVaultDeferCallback`.
|
|
159
|
+
*
|
|
160
|
+
* `vd:unlock:<deferKey>` → prompt for passphrase, then auto-write the
|
|
161
|
+
* held secret. Replaces the legacy six-step
|
|
162
|
+
* "/vault list → re-paste" flow.
|
|
163
|
+
* `vd:cancel:<deferKey>` → discard the deferred secret without saving.
|
|
164
|
+
*
|
|
165
|
+
* `deferKey` is `<chat_id>:<message_id>` (the same key as
|
|
166
|
+
* `deferredSecrets.set()`). Telegram limits callback_data to 64 bytes;
|
|
167
|
+
* the prefix + key fits well within that on any realistic chat id.
|
|
168
|
+
*/
|
|
169
|
+
export function buildDeferredSecretKeyboard(deferKey: string): InlineKeyboard {
|
|
170
|
+
const unlockData = `vd:unlock:${deferKey}`
|
|
171
|
+
const cancelData = `vd:cancel:${deferKey}`
|
|
172
|
+
if (unlockData.length > 64 || cancelData.length > 64) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
`telegram gateway: callback_data overflow — deferKey=${deferKey} unlockLen=${unlockData.length} cancelLen=${cancelData.length}\n`,
|
|
175
|
+
)
|
|
176
|
+
throw new Error(`callback_data overflow: deferKey too long (${deferKey.length} chars)`)
|
|
177
|
+
}
|
|
178
|
+
return new InlineKeyboard()
|
|
179
|
+
.text('🔓 Unlock vault & save', unlockData)
|
|
180
|
+
.text('🗑 Discard', cancelData)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a vault-CLI failure as Telegram HTML. Routes recognised P0a
|
|
185
|
+
* stderr markers (VAULT-SANDBOX-CONTEXT / VAULT-NEEDS-APPROVAL /
|
|
186
|
+
* VAULT-BROKER-UNREACHABLE / VAULT-BROKER-DENIED) through the structured
|
|
187
|
+
* renderer; falls back to a raw pre-block for anything else.
|
|
188
|
+
*/
|
|
189
|
+
export function renderVaultOpFailure(
|
|
190
|
+
verbLabel: 'list' | 'get' | 'set' | 'delete',
|
|
191
|
+
cliOutput: string,
|
|
192
|
+
key: string | undefined,
|
|
193
|
+
): string {
|
|
194
|
+
const parsed = parseVaultCliError(cliOutput)
|
|
195
|
+
// Map the gateway-internal op label onto the renderer's verb. 'delete'
|
|
196
|
+
// surfaces in the host hint as `switchroom vault remove <key>` (the
|
|
197
|
+
// canonical CLI name); 'list' has no key.
|
|
198
|
+
const verb = verbLabel === 'delete' ? 'remove' : verbLabel
|
|
199
|
+
const rendered = renderVaultCliError(parsed, { verb, key })
|
|
200
|
+
if (rendered.suppressRaw) return rendered.html
|
|
201
|
+
return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function statusIcon(status: string): string {
|
|
205
|
+
if (status === 'active' || status === 'running') return '🟢'
|
|
206
|
+
if (status === 'inactive' || status === 'stopped' || status === 'dead') return '🔴'
|
|
207
|
+
if (status === 'failed') return '⚠️'
|
|
208
|
+
return '⚪'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Render an `AuthCodeOutcome` as a user-facing Telegram HTML string.
|
|
213
|
+
* Returns null when the outcome is not present or is `success` (caller
|
|
214
|
+
* can handle success via the existing text path).
|
|
215
|
+
*/
|
|
216
|
+
export function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
|
|
217
|
+
if (!outcome || outcome.kind === 'success') return null
|
|
218
|
+
const tail = outcome.paneTailText
|
|
219
|
+
? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
|
|
220
|
+
: ''
|
|
221
|
+
switch (outcome.kind) {
|
|
222
|
+
case 'invalid-code':
|
|
223
|
+
case 'expired-code':
|
|
224
|
+
return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
|
|
225
|
+
case 'pane-not-ready':
|
|
226
|
+
return `Auth pane not ready — tap **Retry**.`
|
|
227
|
+
case 'timeout':
|
|
228
|
+
return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Two-button scope picker shown to admin agents (when hostd is
|
|
233
|
+
// reachable) so the operator can run doctor for the WHOLE FLEET
|
|
234
|
+
// (host-side via hostd — has the docker socket) or just THIS agent
|
|
235
|
+
// (in-container, degraded). callback_data is tiny (`dr:fleet` /
|
|
236
|
+
// `dr:self`) — well within Telegram's 64-byte limit.
|
|
237
|
+
export function buildDoctorScopeKeyboard(): InlineKeyboard {
|
|
238
|
+
return new InlineKeyboard()
|
|
239
|
+
.text('🩺 Whole fleet', 'dr:fleet')
|
|
240
|
+
.text('🩺 This agent', 'dr:self')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Shared report prettifier: ANSI-strip + status-glyph swap + pre block.
|
|
244
|
+
// Identical rendering for the in-container and the hostd fleet report.
|
|
245
|
+
export function formatDoctorReport(raw: string): string {
|
|
246
|
+
const trimmed = stripAnsi(raw).trim()
|
|
247
|
+
if (!trimmed) return 'doctor: no output'
|
|
248
|
+
const pretty = trimmed
|
|
249
|
+
.replace(/^( *)✓ /gm, '$1🟢 ')
|
|
250
|
+
.replace(/^( *)✗ /gm, '$1🔴 ')
|
|
251
|
+
.replace(/^( *)! /gm, '$1🟡 ')
|
|
252
|
+
return preBlock(formatSwitchroomOutput(pretty))
|
|
253
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway liveness heartbeat (duplicate-message fix, single-writer election).
|
|
3
|
+
*
|
|
4
|
+
* The silent-end Stop hook's single-writer election (see
|
|
5
|
+
* `hooks/silent-end-scan.mjs` `decideStopHookDisposition`) may ALLOW a turn to
|
|
6
|
+
* end without re-prompting — handing delivery of the model's trailing prose to
|
|
7
|
+
* the gateway's turn-end flush / captured-prose bridge. That hand-off is only
|
|
8
|
+
* safe when the gateway is actually alive and WILL run its `turn_end` handler.
|
|
9
|
+
* Allowing into a DEAD gateway would drop the answer entirely — the one outcome
|
|
10
|
+
* worse than a duplicate.
|
|
11
|
+
*
|
|
12
|
+
* Existing markers under `TELEGRAM_STATE_DIR` (`turn-active.json`) only exist
|
|
13
|
+
* DURING a turn and are removed at turn_complete, so they can't prove
|
|
14
|
+
* "gateway alive and ready to process the NEXT turn_end". This adds a minimal,
|
|
15
|
+
* always-on gateway heartbeat: the gateway process touches
|
|
16
|
+
* `<STATE_DIR>/gateway-heartbeat` on a fixed interval while it lives. The Stop
|
|
17
|
+
* hook stats its mtime and requires it fresh before electing an allow.
|
|
18
|
+
*
|
|
19
|
+
* Pure file I/O, best-effort — the heartbeat is a safety gate, never a
|
|
20
|
+
* correctness dependency of the turn path. A write failure just means the hook
|
|
21
|
+
* conservatively BLOCKS (re-prompt), which is the safe direction.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** Filename under `TELEGRAM_STATE_DIR`. MUST stay in sync with
|
|
28
|
+
* `GATEWAY_HEARTBEAT_FILE` in `hooks/silent-end-scan.mjs`. */
|
|
29
|
+
export const GATEWAY_HEARTBEAT_FILE = 'gateway-heartbeat'
|
|
30
|
+
|
|
31
|
+
/** How often the gateway refreshes the heartbeat while alive. The hook's
|
|
32
|
+
* freshness bound (`GATEWAY_HEARTBEAT_FRESH_MS`, 60s) is 4× this — tolerant of
|
|
33
|
+
* event-loop / scheduler jitter, tight enough that a crashed gateway is
|
|
34
|
+
* detected within one turn's election window. */
|
|
35
|
+
export const GATEWAY_HEARTBEAT_INTERVAL_MS = 15_000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write / refresh the heartbeat file's mtime. Creates the file (and the state
|
|
39
|
+
* dir) on first call, then bumps mtime via `utimesSync` on subsequent calls.
|
|
40
|
+
* Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function touchGatewayHeartbeat(stateDir: string): void {
|
|
43
|
+
const path = join(stateDir, GATEWAY_HEARTBEAT_FILE)
|
|
44
|
+
const now = new Date()
|
|
45
|
+
try {
|
|
46
|
+
utimesSync(path, now, now)
|
|
47
|
+
} catch {
|
|
48
|
+
// File doesn't exist yet (or unstattable) — create it.
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(stateDir, { recursive: true })
|
|
51
|
+
writeFileSync(path, `${Date.now()}\n`, { mode: 0o600 })
|
|
52
|
+
} catch {
|
|
53
|
+
// Best-effort — a heartbeat write failure makes the hook BLOCK
|
|
54
|
+
// (re-prompt), which is the safe direction.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the periodic heartbeat. Returns the interval handle (unref'd so it
|
|
61
|
+
* never keeps the process alive on its own). Touches once immediately so the
|
|
62
|
+
* file exists before the first turn can end.
|
|
63
|
+
*/
|
|
64
|
+
export function startGatewayHeartbeat(
|
|
65
|
+
stateDir: string,
|
|
66
|
+
intervalMs: number = GATEWAY_HEARTBEAT_INTERVAL_MS,
|
|
67
|
+
): ReturnType<typeof setInterval> {
|
|
68
|
+
touchGatewayHeartbeat(stateDir)
|
|
69
|
+
const timer = setInterval(() => touchGatewayHeartbeat(stateDir), intervalMs)
|
|
70
|
+
timer.unref?.()
|
|
71
|
+
return timer
|
|
72
|
+
}
|