switchroom 0.17.10 → 0.18.3

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 (61) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
116
116
  if (input.turnInFlight) return 'buffer-until-idle'
117
117
  return 'deliver'
118
118
  }
119
+
120
+ /**
121
+ * #2917 — atomic check-and-reserve for per-chat outbound FIFO.
122
+ *
123
+ * `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
124
+ * `handleInbound` path that decision and the busy-mark that records "a turn is
125
+ * now in flight for this chat" are separated by an `await` (attachment
126
+ * download, composer-clear). Two same-chat inbounds can therefore each read
127
+ * "idle" during the other's async lead-in and both deliver — the replies then
128
+ * come back reordered. This helper couples the decision with a `reserve` flag:
129
+ * a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
130
+ * any await) so the next same-chat inbound sees it and buffers behind it.
131
+ *
132
+ * `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
133
+ * inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
134
+ * (reserving would wedge the running turn's key). A buffered decision never
135
+ * reserves.
136
+ */
137
+ export function reserveInboundDelivery(
138
+ input: InboundDeliveryGateInput,
139
+ ): { decision: InboundDeliveryDecision; reserve: boolean } {
140
+ const decision = decideInboundDelivery(input)
141
+ const reserve =
142
+ decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
143
+ return { decision, reserve }
144
+ }
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
52
52
  * Shape gate for the model argument. This string is typed literally
53
53
  * into the agent's tmux pane, so the gate is strict by construction:
54
54
  * one token, alphanumeric start, then alphanumerics plus the chars
55
- * that appear in real model ids (`.` `_` `-` and the `[1m]`-style
56
- * variant brackets). No whitespace means no second token can ride
55
+ * that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
56
+ * variant brackets). `/` is allowed for OpenRouter-style
57
+ * `sr-vendor/model` ids; it is not a shell metachar inside the
58
+ * double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
59
+ * break the launch. No whitespace means no second token can ride
57
60
  * along; no control characters means no newline/Enter smuggling.
58
61
  */
59
- const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/
62
+ const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
60
63
 
61
64
  export function isValidModelArg(arg: string): boolean {
62
65
  return MODEL_ARG_RE.test(arg)
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
133
136
  * mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
134
137
  */
135
138
  scheduleRestart: (reason: string) => Promise<void>
139
+ /**
140
+ * Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
141
+ * model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
142
+ * inject can't set them. Instead the gateway writes the chosen token to the
143
+ * `.session-model-override` carrier file and gracefully restarts the agent;
144
+ * the next boot launches `claude --model <token>` directly (LiteLLM routes
145
+ * it, no picker validation). Session-only: reverts to the configured default
146
+ * on the following restart. Wired to the same restart dispatch as
147
+ * `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
148
+ * (already alias-expanded); `reason` is stamped as the restart reason.
149
+ */
150
+ scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
136
151
  }
137
152
 
138
153
  export interface ModelCommandReply {
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
152
167
  '\`/model\` — show the configured model',
153
168
  `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
154
169
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
170
+ '_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
155
171
  PERSIST_NOTE,
156
172
  )
157
173
  return { text: lines.join('\n'), html: true }
@@ -214,6 +230,30 @@ export async function handleModelCommand(
214
230
  }
215
231
  }
216
232
 
233
+ // Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
234
+ // native `/model` picker rejects the unknown `sr-*` id ("Model not found").
235
+ // Carry the token across a graceful restart and relaunch `claude --model
236
+ // sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
237
+ // default on the next restart. The sr-* → Claude direction is handled above.
238
+ if (isSrModel(model)) {
239
+ try {
240
+ await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
241
+ } catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err)
243
+ return {
244
+ text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
245
+ html: true,
246
+ }
247
+ }
248
+ return {
249
+ text: [
250
+ `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
251
+ '_Session-only — reverts to the configured default on the next restart._',
252
+ ].join('\n'),
253
+ html: true,
254
+ }
255
+ }
256
+
217
257
  const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
218
258
  let result: InjectResult
219
259
  try {
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
358
398
  * Friendly display names for sr-* synthetic model names. An sr-* model in
359
399
  * LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
360
400
  * so the Anthropic OAuth credential is NEVER forwarded — safe to route to
361
- * OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
362
- * injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
401
+ * OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
402
+ * and switch confirmations); the raw `sr-*` id is what gets injected into the
403
+ * agent's session. This table is a SUPERSET of the menu-reachable set
404
+ * (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
405
+ * the full `/model sr-<name>` (manual passthrough), so those still get a
406
+ * friendly name without appearing as a keyboard button.
407
+ * See reference/rfcs/litellm-max-subscription-invariants.md § I6.
363
408
  */
364
409
  export const SR_MODEL_LABELS: Record<string, string> = {
365
410
  'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
366
411
  'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
367
412
  'sr-deepseek-r1': 'DeepSeek R1',
368
413
  'sr-deepseek-v3': 'DeepSeek V3',
369
- 'sr-glm-5': 'GLM-5',
414
+ // sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
415
+ 'sr-glm-5': 'GLM-5.2',
370
416
  'sr-codex-5.5': 'Codex 5.5',
417
+ // OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
418
+ 'sr-gpt-oss-20b': 'GPT-OSS 20B',
419
+ 'sr-gpt-oss-120b': 'GPT-OSS 120B',
420
+ 'sr-gpt-5.5': 'GPT-5.5',
421
+ 'sr-gpt-5-codex': 'GPT-5 Codex',
422
+ 'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
423
+ 'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
424
+ 'sr-minimax-m3': 'MiniMax M3',
425
+ 'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
371
426
  }
372
427
 
373
428
  /**
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
448
503
  * requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
449
504
  * process. switchroom never sets that env on the gateway, so in production
450
505
  * discoverSrModels() always returns [] and the external group was silently
451
- * empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
452
- * config actually exposes, so seeding from them makes the group reliable
453
- * without the missing env — while still merging any live results on hosts
454
- * that do configure discovery.
506
+ * empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
507
+ * seeding from them makes the group reliable without the missing env — while
508
+ * still merging any live results on hosts that do configure discovery.
509
+ *
510
+ * Deliberately a SUBSET: the litellm config exposes more sr-* models than this
511
+ * (the full OpenRouter catalogue). Those extras are display-labelled in
512
+ * SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
513
+ * passthrough — the set path is a shape gate, no whitelist), but they are kept
514
+ * OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
455
515
  *
456
516
  * Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
457
517
  * gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
@@ -33,8 +33,14 @@
33
33
  "@secretlint/types": "^12.2.0",
34
34
  "@xterm/headless": "^6.0.0",
35
35
  "grammy": "^1.44",
36
+ "mdast-util-from-markdown": "^2.0.2",
37
+ "mdast-util-gfm": "^3.0.0",
38
+ "micromark-extension-gfm": "^3.0.0",
36
39
  "posthog-node": "^5.29.2"
37
40
  },
41
+ "devDependencies": {
42
+ "@types/mdast": "^4.0.4"
43
+ },
38
44
  "engines": {
39
45
  "node": ">=20.11.0"
40
46
  },
@@ -97,9 +97,10 @@ export function emptyAccountState(): QuotaWatchAccountState {
97
97
  * SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP "0" disables the broker claim
98
98
  * (every agent sends, pre-incident
99
99
  * behaviour)
100
- * SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL "1" restores sending from
101
- * cached data when the pre-send
102
- * validation probe fails
100
+ *
101
+ * When the pre-send validation probe fails, the alert is unconditionally
102
+ * suppressed (a quota notification must never carry numbers we could not
103
+ * verify live); the transition re-evaluates on the next poll tick.
103
104
  */
104
105
  export interface QuotaWatchTuning {
105
106
  /** Cached snapshots older than this are treated as unknown (no opinion). 0 = off. */
@@ -108,8 +109,6 @@ export interface QuotaWatchTuning {
108
109
  lateRecoveryMs: number;
109
110
  /** Route sends through the broker's claim-notification dedup. */
110
111
  fleetDedup: boolean;
111
- /** Legacy: send from cached data when the validation probe fails. */
112
- sendOnProbeFail: boolean;
113
112
  }
114
113
 
115
114
  export const DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60_000;
@@ -133,7 +132,6 @@ export function resolveQuotaWatchTuning(
133
132
  maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
134
133
  lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
135
134
  fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
136
- sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1",
137
135
  };
138
136
  }
139
137
 
@@ -21,6 +21,7 @@ import {
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
23
  getTurnByKey,
24
+ reapStaleOpenTurns,
24
25
  } from './turns-schema.js'
25
26
 
26
27
  // ---------------------------------------------------------------------------
@@ -157,3 +158,99 @@ describe('getTurnByKey', () => {
157
158
  db.close()
158
159
  })
159
160
  })
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // reapStaleOpenTurns — mid-session periodic orphan sweep (#2918)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('reapStaleOpenTurns (#2918 mid-session sweep)', () => {
167
+ // Helper: force a row's started_at into the past so the TTL gate is met.
168
+ function ageRow(db: ReturnType<typeof openTurnsDbInMemory>, turnKey: string, startedAt: number): void {
169
+ db.prepare('UPDATE turns SET started_at = ? WHERE turn_key = ?').run(startedAt, turnKey)
170
+ }
171
+
172
+ it('stamps an ownerless open row aged past the TTL as restart', () => {
173
+ const db = openTurnsDbInMemory()
174
+ const now = 1_000_000_000_000
175
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
176
+ ageRow(db, 'dm:dead', now - 30 * 60_000) // 30 min old
177
+ const res = reapStaleOpenTurns(db, {
178
+ activeTurnKeys: new Set<string>(), // process gone → no live owner
179
+ ttlMs: 15 * 60_000,
180
+ now,
181
+ })
182
+ expect(res.reaped).toBe(1)
183
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
184
+ const turn = getTurnByKey(db, 'dm:dead')
185
+ expect(turn?.ended_at).toBe(now)
186
+ expect(turn?.ended_via).toBe('restart')
187
+ db.close()
188
+ })
189
+
190
+ it('NEVER touches a healthy in-flight turn (turn_key in activeTurnKeys)', () => {
191
+ const db = openTurnsDbInMemory()
192
+ const now = 1_000_000_000_000
193
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
194
+ // Even though it is aged well past the TTL, a live owner protects it.
195
+ ageRow(db, 'dm:live', now - 6 * 60 * 60_000) // 6h "long-running" turn
196
+ const res = reapStaleOpenTurns(db, {
197
+ activeTurnKeys: new Set(['dm:live']),
198
+ ttlMs: 15 * 60_000,
199
+ now,
200
+ })
201
+ expect(res.reaped).toBe(0)
202
+ const turn = getTurnByKey(db, 'dm:live')
203
+ expect(turn?.ended_at).toBeNull()
204
+ expect(turn?.ended_via).toBeNull()
205
+ db.close()
206
+ })
207
+
208
+ it('does not reap an ownerless row younger than the TTL (race guard)', () => {
209
+ const db = openTurnsDbInMemory()
210
+ const now = 1_000_000_000_000
211
+ recordTurnStart(db, { turnKey: 'dm:fresh', chatId: '333' })
212
+ ageRow(db, 'dm:fresh', now - 60_000) // 1 min old, ownerless
213
+ const res = reapStaleOpenTurns(db, {
214
+ activeTurnKeys: new Set<string>(),
215
+ ttlMs: 15 * 60_000,
216
+ now,
217
+ })
218
+ expect(res.reaped).toBe(0)
219
+ expect(getTurnByKey(db, 'dm:fresh')?.ended_at).toBeNull()
220
+ db.close()
221
+ })
222
+
223
+ it('reaps the dead orphan while sparing a concurrently-live turn', () => {
224
+ const db = openTurnsDbInMemory()
225
+ const now = 1_000_000_000_000
226
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
227
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
228
+ ageRow(db, 'dm:dead', now - 30 * 60_000)
229
+ ageRow(db, 'dm:live', now - 30 * 60_000)
230
+ const res = reapStaleOpenTurns(db, {
231
+ activeTurnKeys: new Set(['dm:live']),
232
+ ttlMs: 15 * 60_000,
233
+ now,
234
+ })
235
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
236
+ expect(getTurnByKey(db, 'dm:dead')?.ended_via).toBe('restart')
237
+ expect(getTurnByKey(db, 'dm:live')?.ended_at).toBeNull()
238
+ db.close()
239
+ })
240
+
241
+ it('leaves already-ended rows alone (idempotent)', () => {
242
+ const db = openTurnsDbInMemory()
243
+ const now = 1_000_000_000_000
244
+ recordTurnStart(db, { turnKey: 'dm:done', chatId: '444' })
245
+ ageRow(db, 'dm:done', now - 30 * 60_000)
246
+ recordTurnEnd(db, { turnKey: 'dm:done', endedVia: 'stop' })
247
+ const res = reapStaleOpenTurns(db, {
248
+ activeTurnKeys: new Set<string>(),
249
+ ttlMs: 15 * 60_000,
250
+ now,
251
+ })
252
+ expect(res.reaped).toBe(0)
253
+ expect(getTurnByKey(db, 'dm:done')?.ended_via).toBe('stop')
254
+ db.close()
255
+ })
256
+ })
@@ -497,6 +497,84 @@ export function markOrphanedWithTimeoutClassification(
497
497
  return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
498
498
  }
499
499
 
500
+ export interface ReapStaleOpenTurnsOpts {
501
+ /**
502
+ * The set of turn_keys that belong to a turn still LIVE in this process's
503
+ * memory (the gateway's `currentTurnMap` registry keys plus the singleton
504
+ * `currentTurn` mirror). A row whose turn_key is in this set is NEVER
505
+ * reaped — it is a genuinely in-flight turn whose spinner must keep
506
+ * spinning, however long it runs. This is the load-bearing liveness
507
+ * predicate: age alone must never reap; only an open row with NO live
508
+ * owner qualifies.
509
+ */
510
+ activeTurnKeys: ReadonlySet<string>
511
+ /**
512
+ * Minimum age (ms, measured from `started_at`) before an ownerless open row
513
+ * is swept. A secondary guard against races — a turn that has JUST started
514
+ * but not yet populated the live set (or a row recorded microseconds ago) is
515
+ * protected until it ages past this. Liveness (activeTurnKeys) does the real
516
+ * work; the TTL only closes the "recorded-but-not-yet-tracked" window.
517
+ */
518
+ ttlMs: number
519
+ /** Injectable clock for tests. */
520
+ now?: number
521
+ }
522
+
523
+ export interface ReapStaleOpenTurnsResult {
524
+ /** Rows stamped `ended_via='restart'` by this sweep. */
525
+ reaped: number
526
+ /** The turn_keys that were stamped, for logging / card finalization. */
527
+ reapedTurnKeys: string[]
528
+ }
529
+
530
+ /**
531
+ * Mid-session periodic reaper (#2918). The boot-time
532
+ * `markOrphanedWithTimeoutClassification` only runs once, right after
533
+ * `openTurnsDb` — so a turn whose owning process dies MID-session (the SDK
534
+ * subprocess is SIGKILLed / OOMs / crashes) without a clean `recordTurnEnd`
535
+ * leaves its row `ended_at IS NULL`, and its activity card keeps spinning
536
+ * until the NEXT gateway boot (often many hours later). This sweep runs on a
537
+ * periodic timer inside the live gateway and stamps those ownerless open rows
538
+ * `ended_via='restart'` (the same clean-interrupt classification the boot
539
+ * reaper uses for a non-hung orphan) so the stale card can be finalized
540
+ * without waiting for a restart.
541
+ *
542
+ * CORRECTNESS: only rows that are BOTH (a) not owned by any live turn
543
+ * (`turn_key ∉ activeTurnKeys`) AND (b) older than `ttlMs` are swept. A
544
+ * healthy in-flight turn — however long it runs — is always in
545
+ * `activeTurnKeys` and is never touched. Never invents a new state; reuses
546
+ * `'restart'` so the existing resume/report policy applies unchanged.
547
+ */
548
+ export function reapStaleOpenTurns(
549
+ db: SqliteDatabase,
550
+ opts: ReapStaleOpenTurnsOpts,
551
+ ): ReapStaleOpenTurnsResult {
552
+ const now = opts.now ?? Date.now()
553
+ const cutoff = now - opts.ttlMs
554
+ // Candidate = open row aged past the TTL. Liveness is filtered in JS against
555
+ // the injected active set (avoids brittle SQL IN-list binding).
556
+ const candidates = db.prepare(`
557
+ SELECT turn_key FROM turns
558
+ WHERE ended_at IS NULL AND started_at <= ?
559
+ `).all(cutoff) as { turn_key: string }[]
560
+
561
+ const stamp = db.prepare(`
562
+ UPDATE turns
563
+ SET ended_at = ?,
564
+ ended_via = 'restart',
565
+ updated_at = ?
566
+ WHERE turn_key = ? AND ended_at IS NULL
567
+ `)
568
+
569
+ const reapedTurnKeys: string[] = []
570
+ for (const { turn_key } of candidates) {
571
+ if (opts.activeTurnKeys.has(turn_key)) continue // live — never reap
572
+ const r = stamp.run(now, now, turn_key) as { changes: number }
573
+ if (r.changes > 0) reapedTurnKeys.push(turn_key)
574
+ }
575
+ return { reaped: reapedTurnKeys.length, reapedTurnKeys }
576
+ }
577
+
500
578
  /**
501
579
  * Return the most recent N turns for `chatId` (any state — running or ended),
502
580
  * ordered by started_at DESC. Used by the idle-footer renderer to decide
@@ -0,0 +1,209 @@
1
+ // Typed intermediate representation (IR) for the Telegram HTML render engine.
2
+ //
3
+ // This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
4
+ // mdast tree into this shape; a later increment's renderer walks it and emits
5
+ // Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
6
+ // is no renderer yet.
7
+ //
8
+ // Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
9
+ // from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
10
+ // code-unit indices into the original markdown string, so
11
+ // `source.slice(node.start, node.end)` round-trips to the node's source text.
12
+ //
13
+ // Telegram HTML tag mapping (for the next increment — NOT implemented here):
14
+ //
15
+ // Inline
16
+ // plain -> (raw text, HTML-escaped)
17
+ // bold -> <b>…</b> (markdown `**…**`)
18
+ // italic -> <i>…</i> (markdown `*…*`)
19
+ // underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
20
+ // strike -> <s>…</s> (markdown `~~…~~`)
21
+ // spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
22
+ // highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
23
+ // code -> <code>…</code>
24
+ // link -> <a href="…">…</a>
25
+ //
26
+ // Block
27
+ // paragraph -> children joined; blocks separated by "\n\n"
28
+ // heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
29
+ // blockquote -> <blockquote>…</blockquote>
30
+ // (expandable === true -> <blockquote expandable>)
31
+ // code-block -> <pre><code class="language-…">…</code></pre>
32
+ // list -> rendered line-per-item with "•"/"1." bullets
33
+ // (Telegram HTML has no <ul>/<ol>)
34
+ // thematic-break -> a horizontal-rule text line (e.g. "───")
35
+ // table -> monospaced <pre> table (Telegram HTML has no <table>)
36
+
37
+ export interface Pos {
38
+ /** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
39
+ start: number;
40
+ /** UTF-16 code-unit offset just past the node's last char (mdast position.end.offset). */
41
+ end: number;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Inline nodes
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface PlainNode extends Pos {
49
+ type: "plain";
50
+ text: string;
51
+ }
52
+
53
+ export interface BoldNode extends Pos {
54
+ type: "bold";
55
+ children: Inline[];
56
+ }
57
+
58
+ export interface ItalicNode extends Pos {
59
+ type: "italic";
60
+ children: Inline[];
61
+ }
62
+
63
+ /** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
64
+ * double-underscore run is UNDERLINE — distinct from `**…**` bold, even though
65
+ * GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
66
+ * disambiguates the two by looking at the run's source delimiter. */
67
+ export interface UnderlineNode extends Pos {
68
+ type: "underline";
69
+ children: Inline[];
70
+ }
71
+
72
+ export interface StrikeNode extends Pos {
73
+ type: "strike";
74
+ children: Inline[];
75
+ }
76
+
77
+ /** Telegram spoiler (<tg-spoiler>…</tg-spoiler>), markdown `||…||`. GFM has no
78
+ * spoiler syntax, so `parse.ts` recognises the `||…||` delimiter in a
79
+ * post-parse pass over `plain` text. */
80
+ export interface SpoilerNode extends Pos {
81
+ type: "spoiler";
82
+ children: Inline[];
83
+ }
84
+
85
+ /** Telegram highlight / marked text (<mark>…</mark>), markdown `==…==` (Bot API
86
+ * 10.1). Like spoiler, recognised by `parse.ts` in a post-parse pass over
87
+ * `plain` text (GFM has no highlight syntax). */
88
+ export interface HighlightNode extends Pos {
89
+ type: "highlight";
90
+ children: Inline[];
91
+ }
92
+
93
+ export interface CodeNode extends Pos {
94
+ type: "code";
95
+ text: string;
96
+ }
97
+
98
+ export interface LinkNode extends Pos {
99
+ type: "link";
100
+ href: string;
101
+ children: Inline[];
102
+ }
103
+
104
+ export type Inline =
105
+ | PlainNode
106
+ | BoldNode
107
+ | ItalicNode
108
+ | UnderlineNode
109
+ | StrikeNode
110
+ | SpoilerNode
111
+ | HighlightNode
112
+ | CodeNode
113
+ | LinkNode;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Block nodes
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export interface ParagraphNode extends Pos {
120
+ type: "paragraph";
121
+ children: Inline[];
122
+ }
123
+
124
+ export interface HeadingNode extends Pos {
125
+ type: "heading";
126
+ /** 1..6 */
127
+ level: number;
128
+ children: Inline[];
129
+ }
130
+
131
+ export interface BlockquoteNode extends Pos {
132
+ type: "blockquote";
133
+ children: Block[];
134
+ /** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
135
+ expandable: boolean;
136
+ }
137
+
138
+ export interface CodeBlockNode extends Pos {
139
+ type: "code-block";
140
+ text: string;
141
+ language: string | null;
142
+ }
143
+
144
+ export interface ListNode extends Pos {
145
+ type: "list";
146
+ ordered: boolean;
147
+ // NOTE: the spec names this ordinal `start`, but every node already carries
148
+ // `start`/`end` UTF-16 offsets (load-bearing for round-trip slicing). To
149
+ // avoid the collision the ordered-list ordinal is `startNumber` here; its
150
+ // semantics match the spec's `list.start` exactly (mdast `list.start`).
151
+ /** First number of an ordered list (mdast `start`); null for unordered. */
152
+ startNumber: number | null;
153
+ /** Loose vs tight (mdast `list.spread`). A LOOSE list separates its items
154
+ * with a blank line in the source; a TIGHT list keeps them on adjacent
155
+ * lines. The renderer preserves this: loose lists join items with a blank
156
+ * line, tight lists (including nested sub-lists) stay on single newlines so
157
+ * no spurious blank line is injected between a tight item and its sub-list. */
158
+ spread: boolean;
159
+ items: ListItem[];
160
+ }
161
+
162
+ export interface ThematicBreakNode extends Pos {
163
+ type: "thematic-break";
164
+ }
165
+
166
+ export interface TableNode extends Pos {
167
+ type: "table";
168
+ header: TableRow;
169
+ rows: TableRow[];
170
+ /** Per-column alignment, parallel to the cells. */
171
+ align: ("left" | "center" | "right" | null)[];
172
+ }
173
+
174
+ export type Block =
175
+ | ParagraphNode
176
+ | HeadingNode
177
+ | BlockquoteNode
178
+ | CodeBlockNode
179
+ | ListNode
180
+ | ThematicBreakNode
181
+ | TableNode;
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Composite / container shapes
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface ListItem extends Pos {
188
+ children: Block[];
189
+ /** GFM task-list state: true (checked), false (unchecked), null (not a task item). */
190
+ checked: boolean | null;
191
+ /** Loose vs tight at the ITEM level (mdast `listItem.spread`): whether this
192
+ * item's own block children are separated by a blank line in the source.
193
+ * A tight item (spread=false) — e.g. a paragraph followed by a nested
194
+ * sub-list — keeps its children on single newlines, so no blank line is
195
+ * injected before the sub-list. */
196
+ spread: boolean;
197
+ }
198
+
199
+ export interface TableRow extends Pos {
200
+ cells: TableCell[];
201
+ }
202
+
203
+ export interface TableCell extends Pos {
204
+ children: Inline[];
205
+ }
206
+
207
+ export interface Document {
208
+ blocks: Block[];
209
+ }