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.
Files changed (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. 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: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
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 — same turn identity, but genuinely different content: an async
199
- // handback attributed to the flush-delivered ended turn, not the turn's own
200
- // answer landing late. Never edit/delete the flushed message for it.
201
- if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
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: { liveTurnId: string | null; replyText?: string | null; now: number },
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: { liveTurnId: string | null; replyText?: string | null; now: number },
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
+ }