switchroom 0.20.22 → 0.21.1

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.
@@ -60,7 +60,7 @@
60
60
 
61
61
  import { writeFileSync, unlinkSync } from 'node:fs'
62
62
  import { join } from 'node:path'
63
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
63
+ import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
64
64
  import {
65
65
  preserveUnreadableStoreFile,
66
66
  quarantineCorruptStoreFile,
@@ -182,7 +182,7 @@ export function computeBackoffMs(attempts: number, retryAfterMs?: number): numbe
182
182
  */
183
183
  export const atomicWriteSeam = ((path, data, opts) => {
184
184
  const mode = typeof opts === 'object' && opts !== null && typeof opts.mode === 'number' ? opts.mode : 0o600
185
- atomicWriteFileSync(path as string, data as string, mode)
185
+ atomicWriteStateFileSync(path as string, data as string, mode)
186
186
  }) as typeof writeFileSync
187
187
 
188
188
  export function createAlwaysAllowPersistQueue(
@@ -68,7 +68,6 @@ import { randomUUID } from 'node:crypto'
68
68
  import {
69
69
  closeSync,
70
70
  fsyncSync,
71
- mkdirSync,
72
71
  openSync,
73
72
  readFileSync,
74
73
  renameSync,
@@ -76,6 +75,7 @@ import {
76
75
  writeFileSync,
77
76
  } from 'node:fs'
78
77
  import { join } from 'node:path'
78
+ import { adoptStateOwnershipFd, mkdirStateSync } from '../../src/util/state-owner.js'
79
79
 
80
80
  /** Filename under `TELEGRAM_STATE_DIR`. */
81
81
  export const BOOT_BEACON_FILE = 'gateway-beacon.json'
@@ -297,12 +297,19 @@ export function writeBootBeaconFile(stateDir: string, beacon: BootBeacon): boole
297
297
  // gateway creates it 0o700 at boot. If it were ever missing here, a
298
298
  // default-mode recreate by this 5s tick would silently loosen the
299
299
  // permissions on that whole directory.
300
- mkdirSync(stateDir, { recursive: true, mode: 0o700 })
300
+ mkdirStateSync(stateDir, { recursive: true, mode: 0o700 })
301
301
  fd = openSync(tmp, 'w', 0o600)
302
302
  // writeFileSync (not writeSync) on the fd: it loops internally, so a short
303
303
  // write can't leave a truncated beacon that we then fsync and rename into
304
304
  // place as if it were whole.
305
305
  writeFileSync(fd, serializeBootBeacon(beacon))
306
+ // Ownership on the TEMPFILE FD, before the rename: a root-running gateway
307
+ // re-creates this file every 5s, so it is the loudest source of root-owned
308
+ // state in an agent-owned dir. Doing it on the fd (never a path) means no
309
+ // symlink can be raced in, and doing it before the rename means the file
310
+ // is never visible at its final name under the wrong owner. No-op off the
311
+ // root path.
312
+ adoptStateOwnershipFd(fd, stateDir)
306
313
  fsyncSync(fd)
307
314
  closeSync(fd)
308
315
  fd = undefined
@@ -21,8 +21,9 @@
21
21
  * conservatively BLOCKS (re-prompt), which is the safe direction.
22
22
  */
23
23
 
24
- import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
24
+ import { utimesSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
+ import { mkdirStateSync, writeStateFileSync } from '../../src/util/state-owner.js'
26
27
 
27
28
  /** Filename under `TELEGRAM_STATE_DIR`. MUST stay in sync with
28
29
  * `GATEWAY_HEARTBEAT_FILE` in `hooks/silent-end-scan.mjs`. */
@@ -47,8 +48,8 @@ export function touchGatewayHeartbeat(stateDir: string): void {
47
48
  } catch {
48
49
  // File doesn't exist yet (or unstattable) — create it.
49
50
  try {
50
- mkdirSync(stateDir, { recursive: true })
51
- writeFileSync(path, `${Date.now()}\n`, { mode: 0o600 })
51
+ mkdirStateSync(stateDir, { recursive: true })
52
+ writeStateFileSync(path, `${Date.now()}\n`, { mode: 0o600 })
52
53
  } catch {
53
54
  // Best-effort — a heartbeat write failure makes the hook BLOCK
54
55
  // (re-prompt), which is the safe direction.
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
303
303
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
304
304
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
305
305
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
306
+ import { installSentTextCapture } from '../shared/sent-text-capture.js'
306
307
  import {
307
308
  floodStatePath,
308
309
  floodWindowsPath,
@@ -395,7 +396,9 @@ import {
395
396
  pruneMessagesOlderThanDays,
396
397
  hasOutboundDeliveredSince,
397
398
  hasOutboundWithText,
399
+ recordSystemOutbound, updateSystemOutboundText,
398
400
  } from '../history.js'
401
+ import { makeSystemMessageObserver } from './system-message-observer.js'
399
402
  import {
400
403
  runRegistryReaper,
401
404
  resolveRetentionDays as resolveRegistryRetentionDays,
@@ -658,7 +661,8 @@ import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './st
658
661
  import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
659
662
  import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
660
663
  import { reseedSweepLedger } from './stale-pin-sweep-store.js'
661
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
664
+ // Ownership choke point for every state-dir write; zero-syscall no-op off the root path. Rationale: src/util/state-owner.ts.
665
+ import { appendStateFileSync, atomicWriteStateFileSync, mkdirStateSync, reconcileStateDirOwnershipLogged, writeStateFileSync } from '../../src/util/state-owner.js'
662
666
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
663
667
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
664
668
 
@@ -2148,6 +2152,8 @@ function runHistoryReaperNow(reason: 'boot' | 'periodic'): void {
2148
2152
  process.stderr.write(`telegram gateway: history-reaper (${reason}) failed: ${(err as Error).message}\n`)
2149
2153
  }
2150
2154
  }
2155
+ // State-dir ownership backfill (never throws; rides this EXISTING boot/6h tick). See src/util/state-owner.ts.
2156
+ reconcileStateDirOwnershipLogged(STATE_DIR, reason, { prefix: 'telegram gateway' })
2151
2157
  }
2152
2158
  // Run once at boot to catch up long-stopped agents. (#2996 P0c: gated.)
2153
2159
  if (isGatewayMain) runHistoryReaperNow('boot')
@@ -2668,7 +2674,7 @@ function noteAgentOutputAt(key: string, ts: number): void {
2668
2674
  const OBLIGATION_STORE_PATH = join(STATE_DIR, 'obligations.json')
2669
2675
  const obligationStoreFs = {
2670
2676
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
2671
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
2677
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
2672
2678
  renameSync: (a: string, b: string) => renameSync(a, b),
2673
2679
  existsSync: (p: string) => existsSync(p),
2674
2680
  fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
@@ -5595,10 +5601,28 @@ const rawRobustApiCall = createRetryApiCall({
5595
5601
  floodWaitRemainingMs: probeFloodWaitRemainingMs,
5596
5602
  })
5597
5603
 
5604
+ // #4571 — card/system-surface history lane. Every card the gateway posts
5605
+ // (activity card, status pin, approval/boot/issues/worker-feed cards, progress
5606
+ // lines, notices) goes through `robustApiCall`, so observing its RESOLVED
5607
+ // result is the one chokepoint that makes every posted message id resolvable
5608
+ // when the operator quote-replies to it. Never throws; see
5609
+ // system-message-observer.ts for the send-vs-edit and throttling contract.
5610
+ // Gated on the SAME condition as `initHistory` above — a non-main gateway
5611
+ // process never opens the DB, so an observer there would be pure noise.
5612
+ // The empty-body alarm (#4576) is the observer's own default — see
5613
+ // `defaultEmptyCardTextWarning` in system-message-observer.ts.
5614
+ const observeSentMessage = isGatewayMain && HISTORY_ENABLED
5615
+ ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
5616
+ : undefined
5617
+
5598
5618
  const robustApiCall = <T>(
5599
5619
  fn: () => Promise<T>,
5600
5620
  opts?: Parameters<typeof rawRobustApiCall<T>>[1],
5601
- ): Promise<T> => sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5621
+ ): Promise<T> => {
5622
+ const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5623
+ if (observeSentMessage == null) return p
5624
+ return p.then((res) => { observeSentMessage(res, opts); return res })
5625
+ }
5602
5626
 
5603
5627
  // Fire-and-forget wrapper for outbound surfaces that previously had
5604
5628
  // `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
@@ -8384,7 +8408,7 @@ const statusPinRightsCache = new PinRightsCache()
8384
8408
  const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
8385
8409
  const statusPinStoreFs = {
8386
8410
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8387
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8411
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8388
8412
  renameSync: (a: string, b: string) => renameSync(a, b),
8389
8413
  existsSync: (p: string) => existsSync(p),
8390
8414
  }
@@ -8400,7 +8424,7 @@ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
8400
8424
  const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
8401
8425
  const activityCardStoreFs: ActivityCardStoreFsSeam = {
8402
8426
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8403
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8427
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8404
8428
  renameSync: (a: string, b: string) => renameSync(a, b),
8405
8429
  existsSync: (p: string) => existsSync(p),
8406
8430
  }
@@ -8419,7 +8443,7 @@ const activityCardPersistEnabled = !STATIC
8419
8443
  const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
8420
8444
  const queuedCardStoreFs: QueuedCardStoreFsSeam = {
8421
8445
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8422
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8446
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8423
8447
  renameSync: (a: string, b: string) => renameSync(a, b),
8424
8448
  existsSync: (p: string) => existsSync(p),
8425
8449
  }
@@ -9054,7 +9078,7 @@ let stalePinSweepEligible = false
9054
9078
  const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
9055
9079
  const sweepStoreFs = {
9056
9080
  readFileSync: (p: string) => readFileSync(p, 'utf-8'),
9057
- writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
9081
+ writeFileSync: (p: string, data: string) => atomicWriteStateFileSync(p, data, 0o600),
9058
9082
  existsSync: (p: string) => existsSync(p),
9059
9083
  }
9060
9084
  const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
@@ -9152,7 +9176,7 @@ let workerActivityFeed: ReturnType<typeof createWorkerActivityFeed> | null = nul
9152
9176
  // ─── IPC server ───────────────────────────────────────────────────────────
9153
9177
  const SOCKET_PATH = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(STATE_DIR, 'gateway.sock')
9154
9178
  // Ensure the directory for the socket exists (#2996 P0c: gated — disk write).
9155
- if (isGatewayMain) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
9179
+ if (isGatewayMain) mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
9156
9180
 
9157
9181
  // PID file + session marker. See pid-file.ts and session-marker.ts for
9158
9182
  // the 2026-04-22 incident that motivates these. The PID file lets the
@@ -9739,9 +9763,9 @@ if (isGatewayMain) inboundSpool = STATIC
9739
9763
  : createInboundSpool({
9740
9764
  path: join(STATE_DIR, 'inbound-spool.jsonl'),
9741
9765
  fs: {
9742
- appendFileSync: (p, d) => appendFileSync(p, d),
9766
+ appendFileSync: (p, d) => appendStateFileSync(p, d),
9743
9767
  readFileSync: (p) => readFileSync(p, 'utf8'),
9744
- writeFileSync: (p, d) => writeFileSync(p, d),
9768
+ writeFileSync: (p, d) => writeStateFileSync(p, d),
9745
9769
  renameSync: (a, b) => renameSync(a, b),
9746
9770
  existsSync: (p) => existsSync(p),
9747
9771
  statSizeSync: (p) => statSync(p).size,
@@ -12848,7 +12872,7 @@ async function publishToTelegraph(
12848
12872
  }
12849
12873
  account = created.value
12850
12874
  try {
12851
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
12875
+ mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
12852
12876
  writeFileSync(accountPath, JSON.stringify(account, null, 2), { mode: 0o600 })
12853
12877
  } catch (err) {
12854
12878
  process.stderr.write(`telegram gateway: telegraph cache write failed: ${(err as Error).message}\n`)
@@ -15048,13 +15072,18 @@ export async function handleInbound(
15048
15072
  replyToText,
15049
15073
  replyToTextEscaped,
15050
15074
  replyToRole,
15075
+ replyToKind,
15051
15076
  } = resolveReplyToFromBuffer({
15052
15077
  replyToMessageId,
15053
15078
  replyToText: replyForwardCtx.replyToText,
15054
15079
  replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
15055
15080
  historyEnabled: HISTORY_ENABLED,
15056
15081
  replyToTextMax: REPLY_TO_TEXT_MAX,
15057
- lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId),
15082
+ // includeSystem (#4571): a quote-reply to the live activity card / a status
15083
+ // pin / an approval card must resolve. Scoped to THIS lookup — the
15084
+ // reaction-trigger's bot-authored predicate keeps the default (cards
15085
+ // invisible), so its behaviour is unchanged.
15086
+ lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId, { includeSystem: true }),
15058
15087
  })
15059
15088
 
15060
15089
  if (HISTORY_ENABLED) {
@@ -15132,6 +15161,7 @@ export async function handleInbound(
15132
15161
  replyToMessageId,
15133
15162
  replyToTextEscaped,
15134
15163
  replyToRole,
15164
+ replyToKind,
15135
15165
  forwardOriginMeta,
15136
15166
  topicFramingEnabled: TOPIC_FRAMING_ENABLED,
15137
15167
  personDirectory: PERSON_DIRECTORY,
@@ -16061,7 +16091,7 @@ function spawnSwitchroomDetached(
16061
16091
  const logPath = join(STATE_DIR, 'detached-spawn.log')
16062
16092
  let outFd: number | null = null
16063
16093
  try {
16064
- mkdirSync(STATE_DIR, { recursive: true })
16094
+ mkdirStateSync(STATE_DIR, { recursive: true })
16065
16095
  outFd = openSync(logPath, 'a')
16066
16096
  writeFileSync(logPath, `\n[${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(' ')}\n`, { flag: 'a' })
16067
16097
  } catch {}
@@ -17577,9 +17607,9 @@ function buildFolderPickerDeps(): FolderPickerHandlerDeps {
17577
17607
  // existing on-disk lockouts age out via DEFAULT_FALLBACK_COOLDOWN_MS.
17578
17608
  const lockoutOps: LockoutPersistOps = {
17579
17609
  readFileSync: (p, enc) => readFileSync(p, enc),
17580
- writeFileSync: (p, data, opts) => writeFileSync(p, data, opts),
17610
+ writeFileSync: (p, data, opts) => writeStateFileSync(p, data, opts),
17581
17611
  existsSync: (p) => existsSync(p),
17582
- mkdirSync: (p, opts) => mkdirSync(p, opts),
17612
+ mkdirSync: (p, opts) => mkdirStateSync(p, opts),
17583
17613
  joinPath: (...parts) => join(...parts),
17584
17614
  }
17585
17615
 
@@ -22850,6 +22880,11 @@ async function initGatewayBot(): Promise<void> {
22850
22880
 
22851
22881
  bot = new Bot(TOKEN)
22852
22882
  installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
22883
+ // #4576 follow-up: FALLBACK card body. The observer takes the stored body off
22884
+ // the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
22885
+ // on the resolved Message for the shapes a response can't supply. After the fmt
22886
+ // guard so it composes OUTSIDE it. See sent-text-capture.ts.
22887
+ installSentTextCapture(bot)
22853
22888
  // #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
22854
22889
  // outbound call can bypass (grammY has no route to the network that skips the
22855
22890
  // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
@@ -208,8 +208,14 @@ export interface ReplyToBufferFallbackParams {
208
208
  /** REPLY_TO_TEXT_MAX. */
209
209
  replyToTextMax: number
210
210
  /** `lookupMessageRoleAndText` bound to the chat, or any equivalent. May
211
- * throw (e.g. requireDb when history disabled mid-run) — this is caught. */
212
- lookup: (messageId: number) => { role: 'user' | 'assistant'; text: string } | null
211
+ * throw (e.g. requireDb when history disabled mid-run) — this is caught.
212
+ *
213
+ * Bind it with `includeSystem: true` (#4571) so a reply that points at a
214
+ * CARD — the activity card, a status pin, an approval card — resolves
215
+ * instead of returning null. `kind` carries the card family. */
216
+ lookup: (
217
+ messageId: number,
218
+ ) => { role: 'user' | 'assistant' | 'system'; text: string; kind?: string | null } | null
213
219
  }
214
220
 
215
221
  /**
@@ -232,15 +238,22 @@ export interface ReplyToBufferFallbackParams {
232
238
  export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
233
239
  replyToText: string | undefined
234
240
  replyToTextEscaped: string | undefined
235
- replyToRole: 'user' | 'assistant' | undefined
241
+ replyToRole: 'user' | 'assistant' | 'system' | undefined
242
+ /** Card family (`activity-summary`, `approval-card`, …) when the antecedent
243
+ * is a system-lane row. Undefined otherwise. #4571. */
244
+ replyToKind: string | undefined
236
245
  } {
237
246
  let replyToText = p.replyToText
238
247
  let replyToTextEscaped = p.replyToTextEscaped
239
- let replyToRole: 'user' | 'assistant' | undefined
248
+ let replyToRole: 'user' | 'assistant' | 'system' | undefined
249
+ let replyToKind: string | undefined
240
250
  const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
241
251
  if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
242
252
  try {
243
253
  const recovered = p.lookup(p.replyToMessageId)
254
+ if (recovered != null && recovered.role === 'system' && recovered.kind) {
255
+ replyToKind = recovered.kind
256
+ }
244
257
  if (recovered && recovered.text.length > 0) {
245
258
  replyToText =
246
259
  recovered.text.length > p.replyToTextMax
@@ -258,7 +271,7 @@ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
258
271
  // silently to the id-only inputs (current behavior).
259
272
  }
260
273
  }
261
- return { replyToText, replyToTextEscaped, replyToRole }
274
+ return { replyToText, replyToTextEscaped, replyToRole, replyToKind }
262
275
  }
263
276
 
264
277
  /** Inputs for {@link buildInboundEnvelope} — every field is an at-call
@@ -288,7 +301,11 @@ export interface EnvelopeBuildParams {
288
301
  * message, 'user' = a person's), when known — recovered from the history
289
302
  * buffer by handleInbound's reply-to fallback. Undefined when the role
290
303
  * can't be determined (e.g. text came live off the update, not the DB). */
291
- replyToRole: 'user' | 'assistant' | undefined
304
+ replyToRole: 'user' | 'assistant' | 'system' | undefined
305
+ /** #4571 — when the antecedent is a CARD the gateway posted (activity card,
306
+ * status pin, approval card…), the card family. Emitted as `reply_to_kind`
307
+ * alongside `reply_to_role="system"`. */
308
+ replyToKind?: string | undefined
292
309
  forwardOriginMeta: Record<string, string>
293
310
  /** TOPIC_FRAMING_ENABLED — fixed constant. */
294
311
  topicFramingEnabled: boolean
@@ -390,6 +407,14 @@ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
390
407
  // guessing the antecedent. Free: the history-buffer lookup that recovers
391
408
  // reply_to_text already returns the role. Emitted only when known.
392
409
  ...(p.replyToRole != null ? { reply_to_role: p.replyToRole } : {}),
410
+ // #4571 — the antecedent is one of the bot's own CARDS, not a reply.
411
+ // `reply_to_role="system"` + `reply_to_kind="activity-summary"` tells the
412
+ // agent the operator tapped the live working card (the most recent thing
413
+ // on their screen for most of a turn) rather than an answer, so it can
414
+ // read `reply_to_text` as the card body instead of reporting amnesia.
415
+ ...(p.replyToKind != null && p.replyToKind.length > 0
416
+ ? { reply_to_kind: p.replyToKind }
417
+ : {}),
393
418
  // Forwarded-message origin (server-stamped, attrs-only — see above).
394
419
  // forwarded_from / forwarded_from_type / forwarded_from_id /
395
420
  // forwarded_date, plus numbered _2.. siblings for a multi-origin
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { unlinkSync } from 'node:fs'
33
33
  import { join } from 'node:path'
34
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
34
+ import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
35
35
  import {
36
36
  preserveUnreadableStoreFile,
37
37
  quarantineCorruptStoreFile,
@@ -149,7 +149,7 @@ export function createMissedApprovalsStore(
149
149
  unreadable = false
150
150
  }
151
151
  // tmp + fsync + rename — never truncate the destination in place.
152
- atomicWriteFileSync(filePath, JSON.stringify(f), 0o600)
152
+ atomicWriteStateFileSync(filePath, JSON.stringify(f), 0o600)
153
153
  } catch (err) {
154
154
  log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
155
155
  }
@@ -44,7 +44,7 @@
44
44
 
45
45
  import { unlinkSync } from 'node:fs'
46
46
  import { join } from 'node:path'
47
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
47
+ import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
48
48
  import {
49
49
  preserveUnreadableStoreFile,
50
50
  quarantineCorruptStoreFile,
@@ -162,7 +162,7 @@ export function createPendingCardStore(
162
162
  // tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
163
163
  // existing file can't keep laxer perms, and a crash mid-write leaves
164
164
  // the previous good file untouched).
165
- atomicWriteFileSync(filePath, JSON.stringify(entries), 0o600)
165
+ atomicWriteStateFileSync(filePath, JSON.stringify(entries), 0o600)
166
166
  } catch (err) {
167
167
  log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
168
168
  }
@@ -39,7 +39,7 @@ import { readFileSync, mkdirSync } from 'node:fs'
39
39
  import { homedir } from 'node:os'
40
40
  import { join } from 'node:path'
41
41
 
42
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
42
+ import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
43
43
 
44
44
  /** One half-open privacy interval. `end: null` = still open ("private now"). */
45
45
  export interface PrivacyInterval {
@@ -130,7 +130,7 @@ function writePrivacyState(state: PrivacyState, stateDir: string): void {
130
130
  /* dir may already exist / be unwritable — the write below reports */
131
131
  }
132
132
  try {
133
- atomicWriteFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
133
+ atomicWriteStateFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
134
134
  } catch (err) {
135
135
  process.stderr.write(
136
136
  `telegram gateway: privacy-state write failed: ${err instanceof Error ? err.message : String(err)}\n`,
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { join } from 'node:path'
31
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
31
+ import { atomicWriteStateFileSync } from '../../src/util/state-owner.js'
32
32
  import {
33
33
  preserveUnreadableStoreFile,
34
34
  quarantineCorruptStoreFile,
@@ -105,7 +105,7 @@ export function createScopedGrantStore(
105
105
  }
106
106
  // tmp + fsync + rename — a crash mid-persist leaves the previous
107
107
  // grant set intact rather than a torn file that reads as "no grants".
108
- atomicWriteFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
108
+ atomicWriteStateFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
109
109
  } catch (err) {
110
110
  log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
111
111
  }