switchroom 0.19.4 → 0.19.5
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 +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- 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/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -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/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/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/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -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
|
+
}
|