switchroom 0.20.21 → 0.21.0

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 (36) hide show
  1. package/dist/auth-broker/index.js +1 -1
  2. package/dist/cli/switchroom.js +1953 -1539
  3. package/dist/host-control/main.js +286 -122
  4. package/dist/vault/approvals/kernel-server.js +1 -1
  5. package/dist/vault/broker/server.js +1 -1
  6. package/package.json +1 -1
  7. package/skills/switchroom-release/SKILL.md +12 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +1405 -851
  9. package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
  10. package/telegram-plugin/gateway/boot-beacon.ts +9 -2
  11. package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
  12. package/telegram-plugin/gateway/gateway.ts +42 -15
  13. package/telegram-plugin/gateway/inbound-router.ts +31 -6
  14. package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
  15. package/telegram-plugin/gateway/pending-card-store.ts +2 -2
  16. package/telegram-plugin/gateway/privacy-state.ts +2 -2
  17. package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
  18. package/telegram-plugin/gateway/system-message-observer.ts +242 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
  20. package/telegram-plugin/history.ts +178 -11
  21. package/telegram-plugin/registry/turns-schema.ts +8 -2
  22. package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
  23. package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
  24. package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
  25. package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
  26. package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
  27. package/vendor/hindsight-memory/scripts/recall.py +11 -2
  28. package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
  29. package/vendor/hindsight-memory/scripts/retain.py +6 -1
  30. package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
  33. package/vendor/hindsight-memory/settings.json +2 -2
  34. package/vendor/hindsight-memory/tests/test_config.py +8 -3
  35. package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
  36. package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
@@ -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.
@@ -395,7 +395,9 @@ import {
395
395
  pruneMessagesOlderThanDays,
396
396
  hasOutboundDeliveredSince,
397
397
  hasOutboundWithText,
398
+ recordSystemOutbound, updateSystemOutboundText,
398
399
  } from '../history.js'
400
+ import { makeSystemMessageObserver } from './system-message-observer.js'
399
401
  import {
400
402
  runRegistryReaper,
401
403
  resolveRetentionDays as resolveRegistryRetentionDays,
@@ -658,7 +660,8 @@ import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './st
658
660
  import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
659
661
  import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
660
662
  import { reseedSweepLedger } from './stale-pin-sweep-store.js'
661
- import { atomicWriteFileSync } from '../../src/util/atomic.js'
663
+ // Ownership choke point for every state-dir write; zero-syscall no-op off the root path. Rationale: src/util/state-owner.ts.
664
+ import { appendStateFileSync, atomicWriteStateFileSync, mkdirStateSync, reconcileStateDirOwnershipLogged, writeStateFileSync } from '../../src/util/state-owner.js'
662
665
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
663
666
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
664
667
 
@@ -2148,6 +2151,8 @@ function runHistoryReaperNow(reason: 'boot' | 'periodic'): void {
2148
2151
  process.stderr.write(`telegram gateway: history-reaper (${reason}) failed: ${(err as Error).message}\n`)
2149
2152
  }
2150
2153
  }
2154
+ // State-dir ownership backfill (never throws; rides this EXISTING boot/6h tick). See src/util/state-owner.ts.
2155
+ reconcileStateDirOwnershipLogged(STATE_DIR, reason, { prefix: 'telegram gateway' })
2151
2156
  }
2152
2157
  // Run once at boot to catch up long-stopped agents. (#2996 P0c: gated.)
2153
2158
  if (isGatewayMain) runHistoryReaperNow('boot')
@@ -2668,7 +2673,7 @@ function noteAgentOutputAt(key: string, ts: number): void {
2668
2673
  const OBLIGATION_STORE_PATH = join(STATE_DIR, 'obligations.json')
2669
2674
  const obligationStoreFs = {
2670
2675
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
2671
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
2676
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
2672
2677
  renameSync: (a: string, b: string) => renameSync(a, b),
2673
2678
  existsSync: (p: string) => existsSync(p),
2674
2679
  fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
@@ -5595,10 +5600,26 @@ const rawRobustApiCall = createRetryApiCall({
5595
5600
  floodWaitRemainingMs: probeFloodWaitRemainingMs,
5596
5601
  })
5597
5602
 
5603
+ // #4571 — card/system-surface history lane. Every card the gateway posts
5604
+ // (activity card, status pin, approval/boot/issues/worker-feed cards, progress
5605
+ // lines, notices) goes through `robustApiCall`, so observing its RESOLVED
5606
+ // result is the one chokepoint that makes every posted message id resolvable
5607
+ // when the operator quote-replies to it. Never throws; see
5608
+ // system-message-observer.ts for the send-vs-edit and throttling contract.
5609
+ // Gated on the SAME condition as `initHistory` above — a non-main gateway
5610
+ // process never opens the DB, so an observer there would be pure noise.
5611
+ const observeSentMessage = isGatewayMain && HISTORY_ENABLED
5612
+ ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
5613
+ : undefined
5614
+
5598
5615
  const robustApiCall = <T>(
5599
5616
  fn: () => Promise<T>,
5600
5617
  opts?: Parameters<typeof rawRobustApiCall<T>>[1],
5601
- ): Promise<T> => sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5618
+ ): Promise<T> => {
5619
+ const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5620
+ if (observeSentMessage == null) return p
5621
+ return p.then((res) => { observeSentMessage(res, opts); return res })
5622
+ }
5602
5623
 
5603
5624
  // Fire-and-forget wrapper for outbound surfaces that previously had
5604
5625
  // `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
@@ -8384,7 +8405,7 @@ const statusPinRightsCache = new PinRightsCache()
8384
8405
  const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
8385
8406
  const statusPinStoreFs = {
8386
8407
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8387
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8408
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8388
8409
  renameSync: (a: string, b: string) => renameSync(a, b),
8389
8410
  existsSync: (p: string) => existsSync(p),
8390
8411
  }
@@ -8400,7 +8421,7 @@ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
8400
8421
  const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
8401
8422
  const activityCardStoreFs: ActivityCardStoreFsSeam = {
8402
8423
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8403
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8424
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8404
8425
  renameSync: (a: string, b: string) => renameSync(a, b),
8405
8426
  existsSync: (p: string) => existsSync(p),
8406
8427
  }
@@ -8419,7 +8440,7 @@ const activityCardPersistEnabled = !STATIC
8419
8440
  const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
8420
8441
  const queuedCardStoreFs: QueuedCardStoreFsSeam = {
8421
8442
  readFileSync: (p: string) => readFileSync(p, 'utf8'),
8422
- writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8443
+ writeFileSync: (p: string, d: string) => writeStateFileSync(p, d),
8423
8444
  renameSync: (a: string, b: string) => renameSync(a, b),
8424
8445
  existsSync: (p: string) => existsSync(p),
8425
8446
  }
@@ -9054,7 +9075,7 @@ let stalePinSweepEligible = false
9054
9075
  const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
9055
9076
  const sweepStoreFs = {
9056
9077
  readFileSync: (p: string) => readFileSync(p, 'utf-8'),
9057
- writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
9078
+ writeFileSync: (p: string, data: string) => atomicWriteStateFileSync(p, data, 0o600),
9058
9079
  existsSync: (p: string) => existsSync(p),
9059
9080
  }
9060
9081
  const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
@@ -9152,7 +9173,7 @@ let workerActivityFeed: ReturnType<typeof createWorkerActivityFeed> | null = nul
9152
9173
  // ─── IPC server ───────────────────────────────────────────────────────────
9153
9174
  const SOCKET_PATH = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(STATE_DIR, 'gateway.sock')
9154
9175
  // Ensure the directory for the socket exists (#2996 P0c: gated — disk write).
9155
- if (isGatewayMain) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
9176
+ if (isGatewayMain) mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
9156
9177
 
9157
9178
  // PID file + session marker. See pid-file.ts and session-marker.ts for
9158
9179
  // the 2026-04-22 incident that motivates these. The PID file lets the
@@ -9739,9 +9760,9 @@ if (isGatewayMain) inboundSpool = STATIC
9739
9760
  : createInboundSpool({
9740
9761
  path: join(STATE_DIR, 'inbound-spool.jsonl'),
9741
9762
  fs: {
9742
- appendFileSync: (p, d) => appendFileSync(p, d),
9763
+ appendFileSync: (p, d) => appendStateFileSync(p, d),
9743
9764
  readFileSync: (p) => readFileSync(p, 'utf8'),
9744
- writeFileSync: (p, d) => writeFileSync(p, d),
9765
+ writeFileSync: (p, d) => writeStateFileSync(p, d),
9745
9766
  renameSync: (a, b) => renameSync(a, b),
9746
9767
  existsSync: (p) => existsSync(p),
9747
9768
  statSizeSync: (p) => statSync(p).size,
@@ -12848,7 +12869,7 @@ async function publishToTelegraph(
12848
12869
  }
12849
12870
  account = created.value
12850
12871
  try {
12851
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
12872
+ mkdirStateSync(STATE_DIR, { recursive: true, mode: 0o700 })
12852
12873
  writeFileSync(accountPath, JSON.stringify(account, null, 2), { mode: 0o600 })
12853
12874
  } catch (err) {
12854
12875
  process.stderr.write(`telegram gateway: telegraph cache write failed: ${(err as Error).message}\n`)
@@ -15048,13 +15069,18 @@ export async function handleInbound(
15048
15069
  replyToText,
15049
15070
  replyToTextEscaped,
15050
15071
  replyToRole,
15072
+ replyToKind,
15051
15073
  } = resolveReplyToFromBuffer({
15052
15074
  replyToMessageId,
15053
15075
  replyToText: replyForwardCtx.replyToText,
15054
15076
  replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
15055
15077
  historyEnabled: HISTORY_ENABLED,
15056
15078
  replyToTextMax: REPLY_TO_TEXT_MAX,
15057
- lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId),
15079
+ // includeSystem (#4571): a quote-reply to the live activity card / a status
15080
+ // pin / an approval card must resolve. Scoped to THIS lookup — the
15081
+ // reaction-trigger's bot-authored predicate keeps the default (cards
15082
+ // invisible), so its behaviour is unchanged.
15083
+ lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId, { includeSystem: true }),
15058
15084
  })
15059
15085
 
15060
15086
  if (HISTORY_ENABLED) {
@@ -15132,6 +15158,7 @@ export async function handleInbound(
15132
15158
  replyToMessageId,
15133
15159
  replyToTextEscaped,
15134
15160
  replyToRole,
15161
+ replyToKind,
15135
15162
  forwardOriginMeta,
15136
15163
  topicFramingEnabled: TOPIC_FRAMING_ENABLED,
15137
15164
  personDirectory: PERSON_DIRECTORY,
@@ -16061,7 +16088,7 @@ function spawnSwitchroomDetached(
16061
16088
  const logPath = join(STATE_DIR, 'detached-spawn.log')
16062
16089
  let outFd: number | null = null
16063
16090
  try {
16064
- mkdirSync(STATE_DIR, { recursive: true })
16091
+ mkdirStateSync(STATE_DIR, { recursive: true })
16065
16092
  outFd = openSync(logPath, 'a')
16066
16093
  writeFileSync(logPath, `\n[${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(' ')}\n`, { flag: 'a' })
16067
16094
  } catch {}
@@ -17577,9 +17604,9 @@ function buildFolderPickerDeps(): FolderPickerHandlerDeps {
17577
17604
  // existing on-disk lockouts age out via DEFAULT_FALLBACK_COOLDOWN_MS.
17578
17605
  const lockoutOps: LockoutPersistOps = {
17579
17606
  readFileSync: (p, enc) => readFileSync(p, enc),
17580
- writeFileSync: (p, data, opts) => writeFileSync(p, data, opts),
17607
+ writeFileSync: (p, data, opts) => writeStateFileSync(p, data, opts),
17581
17608
  existsSync: (p) => existsSync(p),
17582
- mkdirSync: (p, opts) => mkdirSync(p, opts),
17609
+ mkdirSync: (p, opts) => mkdirStateSync(p, opts),
17583
17610
  joinPath: (...parts) => join(...parts),
17584
17611
  }
17585
17612
 
@@ -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
  }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * system-message-observer.ts — make CARD message ids resolvable (#4571).
3
+ *
4
+ * The problem
5
+ * -----------
6
+ * Only two things ever reached `history.db`: an inbound message, and an
7
+ * outbound that flowed through the `reply` / `stream_reply` family (which call
8
+ * `recordOutbound` explicitly). Everything else the gateway posts — the
9
+ * mid-turn activity card, the pinned status message, approval / boot / issues
10
+ * / worker-feed cards, `progress_update` lines, restart notices — consumed a
11
+ * real Telegram message id and left NO row behind. Measured on a live agent's
12
+ * buffer: 116 rows across a 266-id span above id 20000, i.e. ~56% of the ids
13
+ * in that chat were absent, clustered exactly where the cards were.
14
+ *
15
+ * That is user-visible, not cosmetic. The activity card is the most recent
16
+ * message on the operator's screen for most of a turn, so quote-replying to it
17
+ * is the natural gesture. Telegram then delivers `reply_to_message_id` pointing
18
+ * at a message the agent has no record of, the reply-antecedent resolver
19
+ * (`resolveReplyToFromBuffer`) gets `null` back, and the agent has to say "I
20
+ * can't see the message you replied to".
21
+ *
22
+ * The mechanism
23
+ * -------------
24
+ * Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
25
+ * send sites (which is exactly the kind of per-call-site discipline that
26
+ * decays — the `reply` path was the only site anyone remembered), this hooks
27
+ * the ONE chokepoint every gateway outbound already goes through:
28
+ * `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
29
+ * card send in the gateway is routed through it, enforced by the
30
+ * `check-bot-api-wrapping` lint guard.
31
+ *
32
+ * The observer reads the Telegram RESPONSE, not the request, which buys three
33
+ * things for free:
34
+ * - the real `message_id` (the only thing a reply can point at),
35
+ * - the chat and forum-topic the message actually landed in,
36
+ * - the RENDERED text, which for a card is otherwise unrecoverable (it is
37
+ * composed from live tool activity and never persisted anywhere).
38
+ *
39
+ * Send vs edit is NOT guessed from the verb (verb tagging is not uniform
40
+ * across call sites and would rot). It falls out of the data: an edit returns
41
+ * the same `message_id` it edited, so the conditional insert no-ops and the
42
+ * call falls through to an in-place text refresh. One row per card, forever,
43
+ * regardless of how many times it is edited.
44
+ *
45
+ * Cost control. The activity card is the highest-volume repeated
46
+ * `editMessageText` in the gateway (it climbs every few seconds for the whole
47
+ * turn). Refreshing its stored text on every edit would be a SQLite write per
48
+ * edit. So a per-message throttle (`editRefreshMs`, default 20s) keeps the hot
49
+ * path entirely in memory: a card edit inside the window costs one Map lookup
50
+ * and no DB work at all. The stored text is therefore a recent snapshot, not
51
+ * a byte-exact mirror of the live card — which is the right trade for a
52
+ * quote-reply antecedent.
53
+ *
54
+ * Nothing here throws. A failure to record a card must never break the send it
55
+ * is observing.
56
+ */
57
+
58
+ /** The subset of a Telegram `Message` response this observer reads. */
59
+ export interface SentMessageLike {
60
+ message_id?: unknown
61
+ chat?: { id?: unknown } | null
62
+ message_thread_id?: unknown
63
+ text?: unknown
64
+ caption?: unknown
65
+ }
66
+
67
+ /** The subset of `robustApiCall`'s opts the observer reads. */
68
+ export interface ObservedCallOpts {
69
+ chat_id?: string
70
+ threadId?: number
71
+ verb?: string
72
+ }
73
+
74
+ export interface SystemMessageObserverDeps {
75
+ /**
76
+ * `history.recordSystemOutbound`. Must return true iff it inserted a NEW
77
+ * row, and false if any row already existed for (chat_id, message_id).
78
+ */
79
+ insert: (args: {
80
+ chat_id: string
81
+ thread_id: number | null
82
+ message_id: number
83
+ kind: string | null
84
+ text: string
85
+ }) => boolean
86
+ /**
87
+ * `history.updateSystemOutboundText`. Must return true iff it updated a row,
88
+ * and false when the target row is absent or is NOT a system row (i.e. the
89
+ * id belongs to a real reply or an inbound).
90
+ */
91
+ updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
92
+ /** Injectable clock for the edit throttle. Defaults to `Date.now`. */
93
+ now?: () => number
94
+ }
95
+
96
+ export interface SystemMessageObserverOptions {
97
+ /**
98
+ * Minimum gap between two stored-text refreshes of the SAME message. Edits
99
+ * inside the window are dropped without touching SQLite.
100
+ */
101
+ editRefreshMs?: number
102
+ /** Cap on tracked message ids before the oldest half is evicted. */
103
+ maxTracked?: number
104
+ }
105
+
106
+ export const DEFAULT_EDIT_REFRESH_MS = 20_000
107
+ export const DEFAULT_MAX_TRACKED = 512
108
+
109
+ /**
110
+ * Normalise a `robustApiCall` verb into the stored `kind` discriminator.
111
+ *
112
+ * The verb is the honest, already-present label for what a send IS
113
+ * (`activity-summary.send`, `boot-card`, `worker-feed`, `approval-card`), so
114
+ * the kind is derived rather than invented. The trailing transport suffix is
115
+ * stripped so a card's OPEN and its EDITs classify identically.
116
+ *
117
+ * Pure. Returns null for an absent / blank verb.
118
+ */
119
+ export function normalizeSendVerb(verb: string | undefined | null): string | null {
120
+ if (typeof verb !== 'string') return null
121
+ const trimmed = verb.trim()
122
+ if (trimmed.length === 0) return null
123
+ const base = trimmed.replace(/\.(send|edit|create|post|update)$/i, '')
124
+ const cleaned = (base.length > 0 ? base : trimmed).slice(0, 64)
125
+ return cleaned.length > 0 ? cleaned : null
126
+ }
127
+
128
+ /**
129
+ * Extract the (chat_id, message_id, thread, text) tuple from a Telegram API
130
+ * result, or null when the result is not a sent/edited Message (the retry
131
+ * wrapper also returns `true` for pins / deletes / reactions / callback
132
+ * answers, and `undefined` for a swallowed benign 400).
133
+ *
134
+ * The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
135
+ * Telegram actually delivered to, and several call sites pass no `chat_id` at
136
+ * all. Pure.
137
+ */
138
+ export function extractSentMessage(
139
+ result: unknown,
140
+ opts?: ObservedCallOpts,
141
+ ): { chatId: string; messageId: number; threadId: number | null; text: string } | null {
142
+ if (result == null || typeof result !== 'object') return null
143
+ const msg = result as SentMessageLike
144
+ const id = msg.message_id
145
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null
146
+ const rawChat = msg.chat?.id
147
+ const chatId =
148
+ rawChat != null && (typeof rawChat === 'number' || typeof rawChat === 'string')
149
+ ? String(rawChat)
150
+ : opts?.chat_id
151
+ if (chatId == null || chatId.length === 0) return null
152
+ const rawThread = msg.message_thread_id
153
+ const threadId =
154
+ typeof rawThread === 'number' && Number.isInteger(rawThread)
155
+ ? rawThread
156
+ : typeof opts?.threadId === 'number'
157
+ ? opts.threadId
158
+ : null
159
+ const text =
160
+ typeof msg.text === 'string' ? msg.text : typeof msg.caption === 'string' ? msg.caption : ''
161
+ return { chatId, messageId: id, threadId, text }
162
+ }
163
+
164
+ /** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
165
+ * reply or an inbound); never write to it again. */
166
+ type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number }
167
+
168
+ /**
169
+ * Build the observer. The returned function is called with the RESOLVED result
170
+ * of every `robustApiCall` and never throws.
171
+ */
172
+ export function makeSystemMessageObserver(
173
+ deps: SystemMessageObserverDeps,
174
+ options?: SystemMessageObserverOptions,
175
+ ): (result: unknown, opts?: ObservedCallOpts) => void {
176
+ const now = deps.now ?? Date.now
177
+ const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
178
+ const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
179
+ const tracked = new Map<string, TrackedState>()
180
+
181
+ function remember(key: string, state: TrackedState): void {
182
+ tracked.set(key, state)
183
+ if (tracked.size > maxTracked) {
184
+ // Map iterates in insertion order — drop the oldest half in one pass so
185
+ // eviction is amortised O(1) rather than per-insert.
186
+ const drop = Math.ceil(tracked.size / 2)
187
+ let i = 0
188
+ for (const k of tracked.keys()) {
189
+ if (i++ >= drop) break
190
+ tracked.delete(k)
191
+ }
192
+ }
193
+ }
194
+
195
+ return function observeSentMessage(result: unknown, opts?: ObservedCallOpts): void {
196
+ try {
197
+ const sent = extractSentMessage(result, opts)
198
+ if (sent == null) return
199
+ const key = `${sent.chatId}:${sent.messageId}`
200
+ const t = now()
201
+ const seen = tracked.get(key)
202
+
203
+ if (seen != null) {
204
+ if (seen.lane === 'foreign') return
205
+ if (t - seen.lastStoredMs < editRefreshMs) return
206
+ if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
207
+ seen.lastStoredMs = t
208
+ } else {
209
+ // The row is gone (retention prune / delete) or was promoted to a
210
+ // real `assistant` reply by recordOutbound. Either way this id is no
211
+ // longer ours to write.
212
+ seen.lane = 'foreign'
213
+ }
214
+ return
215
+ }
216
+
217
+ const inserted = deps.insert({
218
+ chat_id: sent.chatId,
219
+ thread_id: sent.threadId,
220
+ message_id: sent.messageId,
221
+ kind: normalizeSendVerb(opts?.verb),
222
+ text: sent.text,
223
+ })
224
+ if (inserted) {
225
+ remember(key, { lane: 'system', lastStoredMs: t })
226
+ return
227
+ }
228
+ // A row already exists for this id and we did not create it in this
229
+ // process: either a real reply / inbound (leave it alone), or a card this
230
+ // gateway posted before a restart. One probing update disambiguates —
231
+ // `updateText` only ever matches a `system` row.
232
+ const refreshed = deps.updateText({
233
+ chat_id: sent.chatId,
234
+ message_id: sent.messageId,
235
+ text: sent.text,
236
+ })
237
+ remember(key, { lane: refreshed ? 'system' : 'foreign', lastStoredMs: t })
238
+ } catch {
239
+ /* observing a send must never break the send */
240
+ }
241
+ }
242
+ }