switchroom 0.19.1 → 0.19.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 (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -188,6 +188,293 @@ export function classifyModelSwitchConfirmation(input: {
188
188
  return { kind: 'default', launched: revertedTo }
189
189
  }
190
190
 
191
+ /**
192
+ * Diagnostic stderr lines for a /model apply-boot rehydration.
193
+ * Pure strings so gateway.ts stays thin (line-ratchet #2996).
194
+ */
195
+ export function formatModelRelaunchDiagLog(input: {
196
+ agent: string
197
+ launched: string
198
+ configured: string
199
+ confirmation: ModelSwitchConfirmation | null
200
+ isApplyBoot: boolean
201
+ }): string {
202
+ const { agent, launched, configured, confirmation, isApplyBoot } = input
203
+ const L = launched || '(none)'
204
+ if (confirmation == null) {
205
+ return (
206
+ 'telegram gateway: gw /model relaunch applied agent=' +
207
+ agent +
208
+ ' launched=' +
209
+ L +
210
+ ' configured=' +
211
+ configured +
212
+ ' override=' +
213
+ (isApplyBoot ? 'set' : 'cleared') +
214
+ '\n'
215
+ )
216
+ }
217
+ if (confirmation.kind === 'not-applied') {
218
+ return (
219
+ 'telegram gateway: gw /model relaunch NOT-APPLIED agent=' +
220
+ agent +
221
+ ' target=' +
222
+ confirmation.target +
223
+ ' launched=' +
224
+ L +
225
+ ' configured=' +
226
+ configured +
227
+ ' revertedTo=' +
228
+ confirmation.revertedTo +
229
+ '\n'
230
+ )
231
+ }
232
+ if (confirmation.kind === 'applied') {
233
+ return (
234
+ 'telegram gateway: gw /model relaunch applied agent=' +
235
+ agent +
236
+ ' launched=' +
237
+ L +
238
+ ' configured=' +
239
+ configured +
240
+ ' override=set outcome=applied\n'
241
+ )
242
+ }
243
+ return (
244
+ 'telegram gateway: gw /model relaunch applied agent=' +
245
+ agent +
246
+ ' launched=' +
247
+ L +
248
+ ' configured=' +
249
+ configured +
250
+ ' override=cleared outcome=default\n'
251
+ )
252
+ }
253
+
254
+ /** Telegram body for the single switch-confirmation card (F1/N4). */
255
+ export function formatModelSwitchConfirmationBody(
256
+ confirmation: ModelSwitchConfirmation,
257
+ ): string {
258
+ if (confirmation.kind === 'applied') {
259
+ return (
260
+ '✅ Now running `' +
261
+ confirmation.launched +
262
+ '` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.'
263
+ )
264
+ }
265
+ if (confirmation.kind === 'not-applied') {
266
+ return (
267
+ "⚠️ Your switch to `" +
268
+ confirmation.target +
269
+ "` didn't apply — the agent reverted to `" +
270
+ confirmation.revertedTo +
271
+ "` (the apply-boot didn't complete). Re-issue `/model " +
272
+ confirmation.target +
273
+ "` to try again."
274
+ )
275
+ }
276
+ return (
277
+ '✅ Now running `' +
278
+ confirmation.launched +
279
+ '` (the configured default) — fresh session; memory and the handoff briefing carry the context.'
280
+ )
281
+ }
282
+
283
+ export function formatModelRelaunchSuppressNotAppliedLog(input: {
284
+ agent: string
285
+ target: string
286
+ }): string {
287
+ return (
288
+ 'telegram gateway: gw /model relaunch NOT-APPLIED — suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=' +
289
+ input.agent +
290
+ ' target=' +
291
+ input.target +
292
+ '\n'
293
+ )
294
+ }
295
+
296
+ /**
297
+ * The single boot-time notification decision for a classified /model switch
298
+ * (#3427 item 2): either the ONE confirmation card (green applied/default or
299
+ * the ⚠️ not-applied warn), or — LOW-2 dedup — a suppress log when start.sh
300
+ * wrote a TAILORED `.session-model-alert` for this boot that already explains
301
+ * why the switch didn't apply (the alert relay is the more specific message,
302
+ * so the generic not-applied card would double-warn). Pure so the decision is
303
+ * unit-testable end-to-end (classify → notice) without booting the gateway;
304
+ * gateway.ts only sends `card` bodies / writes `suppress` logs.
305
+ */
306
+ export type ModelSwitchBootNotice =
307
+ | { kind: 'card'; body: string }
308
+ | { kind: 'suppress'; log: string }
309
+
310
+ export function resolveModelSwitchBootNotice(input: {
311
+ agent: string
312
+ confirmation: ModelSwitchConfirmation
313
+ hasSessionModelAlert: boolean
314
+ }): ModelSwitchBootNotice {
315
+ const { agent, confirmation, hasSessionModelAlert } = input
316
+ if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
317
+ return {
318
+ kind: 'suppress',
319
+ log: formatModelRelaunchSuppressNotAppliedLog({ agent, target: confirmation.target }),
320
+ }
321
+ }
322
+ return { kind: 'card', body: formatModelSwitchConfirmationBody(confirmation) }
323
+ }
324
+
325
+ /**
326
+ * Requested-vs-served divergence gate (#3427 item 4). `--fallback-model` masks
327
+ * a shape-valid but UNKNOWN requested Claude id: claude silently serves the
328
+ * fallback while `.active-session-model` (and the boot confirmation) carry the
329
+ * requested token, until the first assistant transcript line reclaims /status.
330
+ * That window used to self-heal SILENTLY — the operator was never told their
331
+ * requested id was bogus. This comparator lets the session-model source flag
332
+ * the divergence deterministically at the FIRST possible post-launch signal
333
+ * (the first assistant line's `message.model`).
334
+ *
335
+ * Deliberately conservative — return true ("matches") whenever the pair is not
336
+ * DETERMINISTICALLY comparable, so a false accusation is impossible:
337
+ * - served non-`claude-*` ids (sr-* / LiteLLM-mapped names) are skipped: the
338
+ * proxy may echo an alias, a mapped id, or the raw route name;
339
+ * - a requested alias (opus/sonnet/haiku/fable) family-matches its resolved
340
+ * full id (`sonnet` ≡ `claude-sonnet-5-…`) via modelFamilyToken;
341
+ * - a requested full `claude-*` id must be served exactly, or as a
342
+ * date-stamped descendant (`claude-sonnet-5` ≡ `claude-sonnet-5-20260203`);
343
+ * - anything else (sr-* requests, legacy friendly labels like "Opus 4.8")
344
+ * is not comparable → true.
345
+ */
346
+ export function servedModelMatchesRequested(requested: string, served: string): boolean {
347
+ const req = requested.trim().toLowerCase()
348
+ const srv = served.trim().toLowerCase()
349
+ if (!srv.startsWith('claude-')) return true
350
+ if ((MODEL_ALIASES as readonly string[]).includes(req)) {
351
+ if (req === 'default') return true
352
+ if (modelFamilyToken(srv) === req) return true
353
+ // L3 (#3437 review): legacy id shapes put the family AFTER the version
354
+ // (`claude-3-opus-20240229` → first segment "3", not "opus"). Accept any
355
+ // dash-segment equal to the alias — widens toward "match" only, so it can
356
+ // suppress a real accusation in weird shapes but never create a false one.
357
+ return srv.slice('claude-'.length).split('-').includes(req)
358
+ }
359
+ if (req.startsWith('claude-')) {
360
+ return srv === req || srv.startsWith(req + '-')
361
+ }
362
+ return true
363
+ }
364
+
365
+ /**
366
+ * Greppable stderr line for a requested-vs-served divergence (#3427 item 4).
367
+ * Names BOTH candidate causes (M2): `--fallback-model` substitutes for an
368
+ * invalid/unknown id AND for a transiently-unavailable one — the signal alone
369
+ * cannot distinguish them, so the log must not assert "invalid".
370
+ */
371
+ export function formatServedModelDivergenceLog(input: {
372
+ agent: string
373
+ requested: string
374
+ served: string
375
+ }): string {
376
+ return (
377
+ 'telegram gateway: gw /model served-model DIVERGENCE agent=' +
378
+ input.agent +
379
+ ' requested=' +
380
+ input.requested +
381
+ ' served=' +
382
+ input.served +
383
+ ' (--fallback-model substituted: requested id invalid/unknown OR model transiently unavailable)\n'
384
+ )
385
+ }
386
+
387
+ /** Operator card for a requested-vs-served divergence (#3427 item 4, M2-softened). */
388
+ export function formatServedModelDivergenceCard(input: {
389
+ requested: string
390
+ served: string
391
+ }): string {
392
+ return (
393
+ '⚠️ The first reply was served by `' +
394
+ input.served +
395
+ '`, not the requested `' +
396
+ input.requested +
397
+ '` — claude substituted the fallback model. Either the requested id is invalid/unknown, or the model was temporarily unavailable for that call (a transient substitution self-corrects on later replies). If it persists, re-issue `/model <valid id>` or `/model default`. `/status` always shows the model actually serving calls.'
398
+ )
399
+ }
400
+
401
+ /** The boot marker chat that initiated a /model switch (thread-aware). */
402
+ export interface ModelBootCardTarget {
403
+ chatId: string
404
+ threadId: number | null
405
+ }
406
+
407
+ /**
408
+ * Injected side-effect surface for the boot-time /model cards (#3427): the
409
+ * served-model divergence warn and the switch confirmation share ONE raw
410
+ * Markdown send closure plus a stderr log sink. Lives here (not inline in
411
+ * gateway.ts's boot IIFE) so gateway.ts does not inflate (#2996 ratchet).
412
+ */
413
+ export interface ModelBootCardDeps {
414
+ agent: string
415
+ /** null → no initiating chat known; cards are skipped, logs still write. */
416
+ chat: ModelBootCardTarget | null
417
+ log: (line: string) => void
418
+ sendCard: (
419
+ chatId: string,
420
+ body: string,
421
+ opts: { parse_mode: 'Markdown'; message_thread_id?: number },
422
+ ) => Promise<unknown>
423
+ }
424
+
425
+ /** One-shot fire-and-forget card to the marker chat; send failures log, never throw. */
426
+ function sendModelBootCard(deps: ModelBootCardDeps, chat: ModelBootCardTarget, body: string, failLabel: string): void {
427
+ void deps
428
+ .sendCard(chat.chatId, body, {
429
+ parse_mode: 'Markdown',
430
+ ...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
431
+ })
432
+ .catch((err: unknown) =>
433
+ deps.log(`telegram gateway: ${failLabel} send failed: ${(err as Error)?.message ?? String(err)}\n`),
434
+ )
435
+ }
436
+
437
+ /**
438
+ * Build the divergence-tripwire handler gateway.ts registers at the boot
439
+ * rehydration site (#3427 item 4): the first LIVE assistant line serving a
440
+ * different model (invalid id OR transient unavailability — --fallback-model
441
+ * substituted) logs + warns the operator. The override is KEPT (M2): freshness
442
+ * rules already make /status show the served model, and a transient
443
+ * substitution self-corrects without destroying the switch record.
444
+ */
445
+ export function buildServedModelDivergenceHandler(
446
+ deps: ModelBootCardDeps,
447
+ ): (d: { requested: string; served: string }) => void {
448
+ return (d) => {
449
+ deps.log(formatServedModelDivergenceLog({ agent: deps.agent, requested: d.requested, served: d.served }))
450
+ if (deps.chat == null) return
451
+ sendModelBootCard(deps, deps.chat, formatServedModelDivergenceCard(d), 'served-model divergence')
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Deliver the boot-time model-switch confirmation (#3427 item 2): resolve the
457
+ * card-vs-suppress decision (pure — resolveModelSwitchBootNotice) and either
458
+ * log the suppress line or send the card. No-op when no initiating chat is
459
+ * known — matching the pre-extraction inline gateway.ts behavior (the
460
+ * suppress log only ever wrote when a marker chat existed).
461
+ */
462
+ export function deliverModelSwitchBootNotice(
463
+ deps: ModelBootCardDeps & { confirmation: ModelSwitchConfirmation; hasSessionModelAlert: boolean },
464
+ ): void {
465
+ if (deps.chat == null) return
466
+ const notice = resolveModelSwitchBootNotice({
467
+ agent: deps.agent,
468
+ confirmation: deps.confirmation,
469
+ hasSessionModelAlert: deps.hasSessionModelAlert,
470
+ })
471
+ if (notice.kind === 'suppress') {
472
+ deps.log(notice.log)
473
+ return
474
+ }
475
+ sendModelBootCard(deps, deps.chat, notice.body, 'model-switch confirmation')
476
+ }
477
+
191
478
  export type ParsedModelCommand =
192
479
  | { kind: 'show' }
193
480
  | { kind: 'set'; model: string }
@@ -528,6 +815,23 @@ function relaunchErrorReply(
528
815
  return { text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true }
529
816
  }
530
817
 
818
+ /**
819
+ * Fail-fast caveat (#3427 item 4) for a free-text full `claude-*` id: the
820
+ * gateway cannot pre-validate an arbitrary id against the API (Claude-native
821
+ * constraint — no raw API probes), so if the id is bogus `--fallback-model`
822
+ * silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
823
+ * point at the first-reply tripwire that will catch it. Aliases and sr-* ids
824
+ * are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
825
+ * so only typed `claude-*` ids carry the caveat. Exported for tests.
826
+ */
827
+ export function unvalidatedIdCaveat(
828
+ deps: Pick<ModelCommandDeps, 'escapeHtml'>,
829
+ model: string,
830
+ ): string | null {
831
+ if (!model.trim().toLowerCase().startsWith('claude-')) return null
832
+ return `_\`${deps.escapeHtml(model)}\` can't be validated before launch — if it isn't a real Claude model id, claude will silently serve the configured fallback model instead. I check the first reply and will warn if that happens._`
833
+ }
834
+
531
835
  /** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
532
836
  async function scheduleRelaunchReply(
533
837
  deps: ModelCommandDeps,
@@ -539,7 +843,11 @@ async function scheduleRelaunchReply(
539
843
  } catch (err) {
540
844
  return relaunchErrorReply(deps, model, err)
541
845
  }
542
- return { text: [switchingLine(deps, model), PERSIST_NOTE].join('\n'), html: true }
846
+ const caveat = unvalidatedIdCaveat(deps, model)
847
+ return {
848
+ text: [switchingLine(deps, model), ...(caveat ? [caveat] : []), PERSIST_NOTE].join('\n'),
849
+ html: true,
850
+ }
543
851
  }
544
852
 
545
853
  /** Schedule the `/model default` clear + revert relaunch, returning its ack. */
@@ -389,7 +389,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
389
389
  // mid-turn has something to finalize on next boot instead of
390
390
  // leaving this card frozen forever. Fire-and-forget/best-effort:
391
391
  // a failed persist degrades to the pre-fix (in-memory-only)
392
- // behaviour, never blocks the card opening.
392
+ // behaviour, never blocks the card opening (writeActivityCardRecord
393
+ // → persistActivityCards swallows write errors — it never throws).
394
+ //
395
+ // F6 (persist-intent-first ordering): this write is the FIRST action
396
+ // taken after the send resolves and BEFORE the status-pin reconcile
397
+ // below — no `await` sits between the send and this persist, so the
398
+ // crash window in which a sent card has no durable record (and is
399
+ // thus unreapable by the boot reaper) is the minimum achievable. A
400
+ // true pre-send provisional record is impossible: the reaper keys
401
+ // its finalizing edit on `activityMessageId`, which only exists once
402
+ // sendRichMessage returns. Keep this persist synchronous and ahead
403
+ // of the pin; do not move it after an await.
393
404
  if (activityCardPersistEnabled) {
394
405
  writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
395
406
  turnKey: statusKey(chat, thread),
@@ -397,15 +408,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
397
408
  threadId: thread ?? null,
398
409
  activityMessageId: sent.message_id,
399
410
  startedAt: turn.startedAt,
400
- // Mirror the ACTUAL pin decision, not an unconditional `true`:
401
- // the OPEN below silently-pins the fresh card only when
402
- // `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
403
- // when it's off, and can also fail on missing supergroup
404
- // rights). Persisting `pinned: true` regardless would make the
405
- // boot reaper attempt an unpin on a card that was never pinned.
406
- // The reaper's unpin is defense-in-depth anyway
411
+ // F5 (persist-intent honesty): mirror the ACTUAL pin decision,
412
+ // not an unconditional `true`. The OPEN below silently-pins the
413
+ // fresh card only when `PIN_STATUS_WHILE_WORKING` is on
414
+ // (`reconcileStatusPin` no-ops when it's off, and can also fail
415
+ // on missing supergroup rights). Persisting `pinned: true`
416
+ // regardless would make the boot reaper attempt an unpin on a
417
+ // card that was never pinned. The persist is intentionally
418
+ // written BEFORE the fire-and-forget pin resolves (intent, not
419
+ // outcome); the reaper's unpin is idempotent defense-in-depth
407
420
  // (`statusPinBootCleanup` owns the primary unpin), so tracking
408
- // the flag honestly is what matters here.
421
+ // the DECISION honestly pinned iff we will actually attempt a
422
+ // pin — is what matters here, not the async pin's result.
409
423
  pinned: PIN_STATUS_WHILE_WORKING,
410
424
  })
411
425
  }
@@ -53,7 +53,11 @@ import { resolveChatIdFallback } from './chat-id-fallback.js'
53
53
  import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
54
54
  import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
55
55
  import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
56
- import { decideSupersedeCorrection, type FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
56
+ import {
57
+ decideSupersedeCorrection,
58
+ flushedAnswerMatchesReply,
59
+ type FlushedTurnSupersedeRegistry,
60
+ } from '../flushed-turn-supersede.js'
57
61
  import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
58
62
  import { deriveTelegraphTitle } from '../telegraph.js'
59
63
  import {
@@ -872,10 +876,16 @@ export async function sendReply(
872
876
  // resolvers agree and the late-reply supersede fires by identity.
873
877
  const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
874
878
  const resolvedTurnId = ownerTurn?.turnId ?? null
879
+ // #3429 — pass the (normalized) reply text so the registry can apply the
880
+ // new-content gate: identity match + TTL alone also fits an async handback
881
+ // that merely resolved this flush-delivered ENDED turn as its owner via
882
+ // the latest-ended tier. Editing the flushed message in place with that
883
+ // handback's text does not re-notify client-side (Telegram edits never
884
+ // push) — the observed silent non-surfacing of msgs 10482/10486.
875
885
  const decision = flushedTurnSupersede.take(
876
886
  chat_id,
877
887
  replyThreadId,
878
- { liveTurnId: resolvedTurnId, now: Date.now() },
888
+ { liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
879
889
  )
880
890
  if (decision.supersede) {
881
891
  process.stderr.write(
@@ -899,20 +909,31 @@ export async function sendReply(
899
909
  // resolves the same ended owner turn, sees the latch, and is suppressed —
900
910
  // exactly one message ever ships. The latch is idempotent and the normal
901
911
  // (no-throw) path is unaffected: the correction below still ships B once.
902
- if (ownerTurn != null) ownerTurn.answerDelivered = true
912
+ // Tagged 'flush' (#3426): a flush record existed for this turn (take()
913
+ // just consumed it), so the flushed message A is what the suppression
914
+ // protects against duplicating. #3429: stash the record's flushed text
915
+ // alongside, so the retry's latch check can discriminate by content —
916
+ // the retry of THIS superseding reply matches and stays suppressed, while
917
+ // a later genuinely-new handback does not and delivers.
918
+ if (ownerTurn != null) {
919
+ ownerTurn.answerDelivered = 'flush'
920
+ if (decision.recordText != null) ownerTurn.flushedAnswerText = decision.recordText
921
+ }
903
922
  } else {
904
923
  // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
905
924
  // Supersede found no record. Either there was no flush (normal reply), or
906
925
  // the flush FIRED but has not yet recorded its message ids (the residual
907
926
  // pre-record race Part 1's supersede cannot reach). The flush sets
908
- // `answerDelivered = true` synchronously at fire time (before its async
909
- // send AND before `record`), and it persists on the ended turn — so when
910
- // this LATE, substantive reply resolves its owner turn and sees the latch
911
- // already set, the flush's message A is already on its way out and this
912
- // reply would ship a duplicate. Suppress it. Scoped to the substantive
913
- // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
914
- // interim sub-floor ack, a chunked multi-part answer, or a legitimate
915
- // second in-turn substantive reply (live `currentTurn`) is never
927
+ // `answerDelivered = 'flush'` synchronously at fire time (before its
928
+ // async send AND before `record`), and it persists on the ended turn — so
929
+ // when this LATE, substantive reply resolves its owner turn and sees the
930
+ // FLUSH-armed latch, the flush's message A is already on its way out and
931
+ // this reply would ship a duplicate. Suppress it. Scoped to the
932
+ // substantive ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor, the late-reply case,
933
+ // AND the 'flush' latch source (#3426) so an interim sub-floor ack, a
934
+ // chunked multi-part answer, a legitimate second in-turn substantive
935
+ // reply (live `currentTurn`), or an async sub-agent handback landing
936
+ // after a reply-delivered turn ended (latch = 'reply') is never
916
937
  // suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
917
938
  // the `reply` path (no `done`); pass the model's original notification
918
939
  // intent to mirror the #2533 decoupling call shape.
@@ -920,12 +941,33 @@ export async function sendReply(
920
941
  text: rawText,
921
942
  disableNotification: args.disable_notification === true,
922
943
  })
944
+ // #3429 — content evidence for the latch. `'new-content'` is the
945
+ // registry's POSITIVE determination that this reply differs from the
946
+ // flushed answer (record present, identity matched, text did not);
947
+ // otherwise compare against the owner turn's stashed `flushedAnswerText`
948
+ // (covers the post-fire pre-record race window, where no record exists
949
+ // yet but the fire site already stamped what it is delivering). Null —
950
+ // no flushed text to compare — keeps the conservative pre-#3429
951
+ // flush-armed suppression.
952
+ const replyMatchesFlushedAnswer: boolean | null =
953
+ decision.reason === 'new-content'
954
+ ? false
955
+ : ownerTurn?.flushedAnswerText != null
956
+ ? flushedAnswerMatchesReply(ownerTurn.flushedAnswerText, text)
957
+ : null
923
958
  const suppressByLatch = decideAnswerLatchSuppression({
924
959
  superseded: false,
925
960
  replySubstantive,
926
961
  isLateReply: turn == null,
927
962
  ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
963
+ replyMatchesFlushedAnswer,
928
964
  })
965
+ if (decision.reason === 'new-content') {
966
+ process.stderr.write(
967
+ `telegram gateway: reply: flush supersede declined — new content (#3429) ` +
968
+ `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh\n`,
969
+ )
970
+ }
929
971
  if (suppressByLatch) {
930
972
  process.stderr.write(
931
973
  `telegram gateway: reply: suppressed by answer-delivered latch ` +
@@ -934,11 +976,22 @@ export async function sendReply(
934
976
  )
935
977
  return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
936
978
  }
937
- // A substantive answer is going out via this reply — set the latch on its
938
- // owner turn so a later bridge-replayed / reworded duplicate of the same
939
- // answer is caught by the branch above.
979
+ // A substantive answer is going out via this reply — record it on the
980
+ // owner turn, tagged 'reply' (#3426). The 'reply' tag does NOT trip the
981
+ // late-reply suppression above: a later reply attributed to this turn
982
+ // after it ends (the async sub-agent handback pattern — dispatch, interim
983
+ // ack, turn_end, handback with no live gateway turn) is genuinely new
984
+ // content and must deliver. Byte-identical replays of THIS answer are
985
+ // deduped by the content-keyed #546 cache at the top of this function.
986
+ // Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
987
+ // while the latest-ended owner tier's 60 s is anchored at `endedAt` —
988
+ // later by the reply→turn_end gap. A byte-identical replay landing >60 s
989
+ // after record but ≤60 s after endedAt is evicted from dedup yet still
990
+ // resolves this ended turn, so it now DELIVERS as a duplicate message.
991
+ // Conscious trade: a rare duplicate beats the silent handback drop the
992
+ // boolean latch caused (#3426).
940
993
  if (replySubstantive && ownerTurn != null) {
941
- ownerTurn.answerDelivered = true
994
+ ownerTurn.answerDelivered = 'reply'
942
995
  }
943
996
  }
944
997
  }
@@ -20,6 +20,28 @@
20
20
  * whichever was observed last. A fresh assistant line always reclaims the
21
21
  * transcript as the source; a confirmed switch always beats an older
22
22
  * transcript line. Pinned by tests/session-model-source.test.ts.
23
+ *
24
+ * Divergence tripwire (#3427 item 4): `--fallback-model` masks an invalid
25
+ * requested model id — claude silently serves the fallback while the override
26
+ * carries the requested token. The FIRST live transcript observation of the
27
+ * post-relaunch session is therefore the earliest deterministic verification
28
+ * point: when the injected comparator says the served id does NOT satisfy the
29
+ * requested token, the registered divergence handler fires (once per armed
30
+ * override) so the gateway can log + warn instead of self-healing silently.
31
+ *
32
+ * Two false-positive guards (#3437 review H1/H2 — "a false accusation must be
33
+ * impossible" is the contract, enforced HERE, not by caller discipline):
34
+ * - H1: verification arms ONLY on an explicit `setOverride(model,
35
+ * { verify: true })` — the boot-rehydration site, where the override IS
36
+ * the launched token of the session now serving. A command-time
37
+ * `setOverride(model)` (the pre-restart status-honesty record in
38
+ * scheduleModelRelaunch) must NOT arm: an assistant line landing in the
39
+ * pre-restart window is served by the OLD model and would false-mismatch
40
+ * the NEW requested token.
41
+ * - H2: observations flagged `replayed: true` (the session-tail's
42
+ * first-attach replay of a prior session's in-flight turn — OLD-model
43
+ * lines delivered AFTER boot) neither consume nor fire verification;
44
+ * the tripwire waits for the first LIVE observation.
23
45
  */
24
46
 
25
47
  export interface SessionModelResolution {
@@ -30,14 +52,45 @@ export interface SessionModelResolution {
30
52
  source: 'transcript' | 'override'
31
53
  }
32
54
 
55
+ /** The first live post-override assistant line served a different model (#3427). */
56
+ export interface SessionModelDivergence {
57
+ /** The override token the operator requested (`/model <token>`). */
58
+ requested: string
59
+ /** The transcript's `message.model` — the model actually serving calls. */
60
+ served: string
61
+ }
62
+
63
+ export interface SessionModelSourceOptions {
64
+ /**
65
+ * Comparator for the divergence tripwire: does `served` (a resolved
66
+ * transcript id) satisfy `requested` (the override token)? Must be
67
+ * CONSERVATIVE — return true when the pair is not deterministically
68
+ * comparable (see servedModelMatchesRequested in model-command.ts).
69
+ * Absent → the tripwire never fires (verification is skipped).
70
+ */
71
+ servedMatchesRequested?: (requested: string, served: string) => boolean
72
+ }
73
+
33
74
  export interface SessionModelSource {
34
- /** Record a transcript observation (an assistant line's `message.model`,
35
- * already sentinel-filtered by the session-tail projection). */
36
- noteTranscriptModel(model: string): void
37
- /** Record an override set (a positively-confirmed /model switch), or clear
38
- * it with null. Setting stamps a fresh sequence, so the override wins over
39
- * every EARLIER transcript observation until a new assistant line lands. */
40
- setOverride(model: string | null): void
75
+ /**
76
+ * Record a transcript observation (an assistant line's `message.model`,
77
+ * already sentinel-filtered by the session-tail projection). Pass
78
+ * `replayed: true` for lines delivered by the session-tail's first-attach
79
+ * replay (a PRIOR session's in-flight turn): they still update /status
80
+ * freshness exactly as before, but are excluded from divergence
81
+ * verification (H2 they carry the pre-relaunch model).
82
+ */
83
+ noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void
84
+ /**
85
+ * Record an override set (a positively-confirmed /model switch), or clear
86
+ * it with null. Setting stamps a fresh sequence, so the override wins over
87
+ * every EARLIER transcript observation until a new assistant line lands.
88
+ * `verify: true` additionally ARMS divergence verification for this
89
+ * override — pass it ONLY when the override is the launched token of the
90
+ * session currently serving (the boot-rehydration site). Default: not
91
+ * armed (H1 — command-time/rollback sets must never arm).
92
+ */
93
+ setOverride(model: string | null, opts?: { verify?: boolean }): void
41
94
  /** Current override value (the #2982 in-memory record), independent of
42
95
  * freshness — for callers that need the override itself (e.g. the model
43
96
  * menu's "session" marker), not the /status resolution. */
@@ -45,18 +98,42 @@ export interface SessionModelSource {
45
98
  /** The freshest observation across both sources, or null when neither has
46
99
  * reported yet. */
47
100
  resolve(): SessionModelResolution | null
101
+ /** Register the handler fired when the first LIVE transcript observation
102
+ * after an ARMED override fails the comparator (#3427 item 4). At most
103
+ * once per armed override; null unregisters. Replaces any prior handler. */
104
+ setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void
48
105
  }
49
106
 
50
- export function createSessionModelSource(): SessionModelSource {
107
+ export function createSessionModelSource(
108
+ options: SessionModelSourceOptions = {},
109
+ ): SessionModelSource {
51
110
  let seq = 0
52
111
  let transcript: { model: string; seq: number } | null = null
53
112
  let override: { model: string; seq: number } | null = null
113
+ // True while an ARMED ({ verify: true }) non-null override awaits its first
114
+ // LIVE transcript observation. Consumed (set false) on that observation
115
+ // whether or not it diverges, so the handler fires at most once per armed
116
+ // override. Replayed observations neither consume nor fire (H2).
117
+ let overrideUnverified = false
118
+ let onDivergence: ((d: SessionModelDivergence) => void) | null = null
54
119
  return {
55
- noteTranscriptModel(model: string): void {
120
+ noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void {
56
121
  transcript = { model, seq: ++seq }
122
+ if (opts?.replayed === true) return // H2: pre-relaunch line — no verification
123
+ if (override != null && overrideUnverified) {
124
+ overrideUnverified = false
125
+ const matches = options.servedMatchesRequested
126
+ if (matches != null && !matches(override.model, model)) {
127
+ onDivergence?.({ requested: override.model, served: model })
128
+ }
129
+ }
57
130
  },
58
- setOverride(model: string | null): void {
131
+ setOverride(model: string | null, opts?: { verify?: boolean }): void {
59
132
  override = model == null ? null : { model, seq: ++seq }
133
+ // H1: only an explicit verify-arm (the boot-rehydration site) starts
134
+ // verification; a plain set (command-time record, rollback restore)
135
+ // clears any pending arm — its token is NOT what is serving right now.
136
+ overrideUnverified = model != null && opts?.verify === true
60
137
  },
61
138
  getOverride(): string | null {
62
139
  return override?.model ?? null
@@ -69,5 +146,8 @@ export function createSessionModelSource(): SessionModelSource {
69
146
  }
70
147
  return { model: transcript.model, source: 'transcript' }
71
148
  },
149
+ setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void {
150
+ onDivergence = handler
151
+ },
72
152
  }
73
153
  }