switchroom 0.18.26 → 0.18.28

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 (35) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +571 -43
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +358 -53
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  18. package/telegram-plugin/send-gate.test.ts +138 -0
  19. package/telegram-plugin/send-gate.ts +104 -1
  20. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  21. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  22. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  23. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  24. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  25. package/telegram-plugin/tests/model-command.test.ts +112 -0
  26. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  27. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  28. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  29. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  30. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  31. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  32. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  33. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  34. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  35. package/telegram-plugin/worker-activity-feed.ts +169 -6
@@ -88,7 +88,7 @@ import {
88
88
  type TelegraphAccount,
89
89
  } from '../telegraph.js'
90
90
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
91
- import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
91
+ import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS, decideSupersedeCorrection } from '../flushed-turn-supersede.js'
92
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
93
93
  import {
94
94
  splitCoalescedAttachments,
@@ -198,6 +198,11 @@ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrati
198
198
  import { toolLabel } from '../tool-labels.js'
199
199
  import { createTypingWrapper } from '../typing-wrap.js'
200
200
  import { createTurnTypingLoop } from './turn-typing-loop.js'
201
+ import {
202
+ createHandbackPreturnSignal,
203
+ type PreTurnCardRecord,
204
+ } from './handback-preturn-signal.js'
205
+ import { deriveTurnId } from './derive-turn-id.js'
201
206
  import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
202
207
  import { type DraftStreamHandle } from '../draft-stream.js'
203
208
  import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
@@ -463,6 +468,7 @@ import { handleInjectCommand, type InjectDeps } from './inject-handler.js'
463
468
  import {
464
469
  parseModelCommand,
465
470
  planModelCommand,
471
+ resolveStaleAwareBusy,
466
472
  modelCommandReceiptLine,
467
473
  handleModelCommand,
468
474
  buildModelMenu,
@@ -598,6 +604,7 @@ import {
598
604
  } from './queued-card-store.js'
599
605
  import {
600
606
  decideWorkerPinReaps,
607
+ storeOnlyWorkerPinCandidates,
601
608
  WORKER_PIN_TTL_MS_DEFAULT,
602
609
  } from './worker-pin-reaper.js'
603
610
  import { driveEscalation } from './escalation-drive.js'
@@ -802,7 +809,10 @@ import {
802
809
  removeTurnActiveMarker,
803
810
  sweepStaleTurnActiveMarker,
804
811
  readTurnActiveMarkerAgeMs,
812
+ effectiveTurnAgeMs,
805
813
  TURN_ACTIVE_MARKER_FILE,
814
+ TURN_ACTIVE_HARD_TTL_MS,
815
+ TURN_ACTIVE_IDLE_SWEEP_MS,
806
816
  } from './turn-active-marker.js'
807
817
  import {
808
818
  VERSION,
@@ -2859,14 +2869,91 @@ function turnInFlightForGate(): boolean {
2859
2869
  // handlers and do not sit behind this gate — and the block is surfaced
2860
2870
  // off-Telegram in blocked-approvals/<agent>.json.
2861
2871
  const hasPendingApproval = pendingPermissions.size > 0
2862
- if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0 || hasPendingApproval
2872
+ // The shared inbound gate stays STRICT: unconditionally busy whenever an
2873
+ // approval card is outstanding (#2841 — a new inbound in that window would
2874
+ // displace the approval context and orphan the pending MCP call). Only the
2875
+ // machine-in-turn leg is factored out (`turnInFlightMachineOnly`); the
2876
+ // `|| hasPendingApproval` composition here is what preserves the contract.
2877
+ return turnInFlightMachineOnly() || hasPendingApproval
2878
+ }
2879
+
2880
+ /**
2881
+ * The machine-in-turn portion of the gate WITHOUT the `pendingPermissions`
2882
+ * hold — "claude is actively producing a turn right now", ignoring an
2883
+ * outstanding approval card. `turnInFlightForGate()` = this OR a pending
2884
+ * approval, so the shared inbound-hold contract (#2841) is unchanged.
2885
+ *
2886
+ * Factored out so the `/model` & `/effort` busy decision can read THIS
2887
+ * machine-only leg (plus a TTL-bounded approval check in
2888
+ * `resolveModelEffortBusy`) — a wedged / undeliverable approval that "never
2889
+ * expires by design" (#3084) must not hold their switch queued forever on an
2890
+ * idle session (#3262), even though it correctly holds the general inbound gate.
2891
+ */
2892
+ function turnInFlightMachineOnly(): boolean {
2893
+ if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
2863
2894
  // Machine is authoritative. Run the log-only drift canary (#2794): the
2864
2895
  // imperative `claudeBusyKeys` shadow is still live in parallel, so a
2865
2896
  // dangerous over-hold divergence (machine holds the gate while the
2866
2897
  // imperative view is idle) is surfaced without changing behaviour. The
2867
2898
  // benign orphan-dangle direction — the wedge the machine self-heals — is
2868
2899
  // NOT flagged. `probeGateParity` returns the machine value unchanged.
2869
- return probeGateParity(isMachineInTurn(), claudeBusyKeys.size) || hasPendingApproval
2900
+ return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
2901
+ }
2902
+
2903
+ /**
2904
+ * Age (ms) of the live turn for the phantom-turn cross-check (#3262): the
2905
+ * turn-active liveness marker's mtime age (touched on every foreground
2906
+ * tool_use AND on sub-agent JSONL growth, so a genuinely long turn keeps it
2907
+ * small), falling back to `now - turn.startedAt` when the marker is absent
2908
+ * (e.g. already swept). Null when no turn atom is set. A large age on a
2909
+ * non-null atom is the dangling-atom signal.
2910
+ */
2911
+ function liveTurnAgeMs(now: number): number | null {
2912
+ if (currentTurn === null) return null
2913
+ return effectiveTurnAgeMs(readTurnActiveMarkerAgeMs(STATE_DIR, now), currentTurn.startedAt, now)
2914
+ }
2915
+
2916
+ /** Age (ms) of the OLDEST outstanding pending approval, or null when none. */
2917
+ function oldestPendingApprovalAgeMs(now: number): number | null {
2918
+ let oldest: number | null = null
2919
+ for (const p of pendingPermissions.values()) {
2920
+ const age = now - p.startedAt
2921
+ if (oldest === null || age > oldest) oldest = age
2922
+ }
2923
+ return oldest
2924
+ }
2925
+
2926
+ /**
2927
+ * Resolve the apply-vs-queue busy signals for a `/model` or `/effort` command,
2928
+ * sweeping a stale (dangling) turn atom and discounting a wedged (undeliverable)
2929
+ * approval hold — both bounded by the SAME `TURN_ACTIVE_HARD_TTL_MS` ceiling the
2930
+ * marker sweep uses (#3262). A genuinely in-flight turn (fresh marker) and a
2931
+ * recent pending approval still read busy, so the #3017/#3039 apply-or-queue
2932
+ * contract and #3177/#3180 are preserved. Clears the dangling atom as a side
2933
+ * effect so the phantom can't re-block the next command.
2934
+ */
2935
+ function resolveModelEffortBusy(now = Date.now()): { currentTurnActive: boolean; turnInFlight: boolean } {
2936
+ const turnAgeMs = liveTurnAgeMs(now)
2937
+ const resolved = resolveStaleAwareBusy({
2938
+ currentTurnActive: currentTurn !== null,
2939
+ turnAgeMs,
2940
+ machineInTurn: turnInFlightMachineOnly(),
2941
+ oldestPendingApprovalAgeMs: oldestPendingApprovalAgeMs(now),
2942
+ hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
2943
+ })
2944
+ if (resolved.clearStaleTurn && currentTurn !== null) {
2945
+ const ageSec = Math.round((turnAgeMs ?? 0) / 1000)
2946
+ process.stderr.write(
2947
+ `telegram gateway: [phantomturn] cleared stale currentTurn atom age=${ageSec}s ` +
2948
+ `ttl=${Math.round(TURN_ACTIVE_HARD_TTL_MS / 1000)}s for /model|/effort busy-check agent=${getMyAgentName()}\n`,
2949
+ )
2950
+ // Single-tenant / sequential-CLI invariant: the singleton `currentTurn` IS
2951
+ // the only live turn, and a stale atom is a ghost (its `turn_end` never
2952
+ // fired) — clearing every entry is equivalent to clearing it, matching the
2953
+ // bridge-died "every entry is a ghost" semantics.
2954
+ clearAllCurrentTurns()
2955
+ }
2956
+ return { currentTurnActive: resolved.currentTurnActive, turnInFlight: resolved.turnInFlight }
2870
2957
  }
2871
2958
 
2872
2959
  /**
@@ -3674,25 +3761,10 @@ function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTur
3674
3761
  if (turn == null || turn.sessionChatId !== chatId) return null
3675
3762
  return turn
3676
3763
  }
3677
- /**
3678
- * Component 3 derive the stable per-turn identity from the chat, thread,
3679
- * and originating message id. Stamped into the inbound meta at build time
3680
- * (`origin_turn_id`) AND reconstructed at enqueue time from the same three
3681
- * values, so the id stamped on the message the model reads matches the id
3682
- * on the turn the gateway started for it. Using the message id (not the
3683
- * not-yet-known startedAt) is what lets the two sites agree. Returns null
3684
- * when there is no message id (synthetic / cron / handback turns have no
3685
- * originating inbound — they never need origin routing, the live turn IS
3686
- * the origin).
3687
- */
3688
- function deriveTurnId(
3689
- chatId: string,
3690
- threadId: number | null | undefined,
3691
- messageId: string | number | null | undefined,
3692
- ): string | null {
3693
- if (messageId == null || messageId === '' || String(messageId) === '0') return null
3694
- return `${chatKey(chatId, threadId ?? null)}#${messageId}`
3695
- }
3764
+ // Component 3 — the stable per-turn identity. Extracted to `derive-turn-id.ts`
3765
+ // (#3268) so the enqueue seam and the handback round-trip test share ONE
3766
+ // function. Re-exported into scope under the original name so every existing
3767
+ // callsite is unchanged.
3696
3768
 
3697
3769
  /**
3698
3770
  * Component 3 — resolve the turn that OWNS a reply by its `origin_turn_id`
@@ -7767,8 +7839,8 @@ const pendingStateReaper = setInterval(() => {
7767
7839
  try {
7768
7840
  sweepStaleTurnActiveMarker(STATE_DIR, {
7769
7841
  turnInFlight: currentTurn?.registryKey != null,
7770
- idleSweepMs: 60_000,
7771
- hardTtlMs: 10 * 60_000,
7842
+ idleSweepMs: TURN_ACTIVE_IDLE_SWEEP_MS,
7843
+ hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
7772
7844
  now,
7773
7845
  onRemove: ({ ageMs, reason, payload }) => {
7774
7846
  const agent = getMyAgentName()
@@ -8883,11 +8955,20 @@ async function runMidSessionCardReaper(): Promise<void> {
8883
8955
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
8884
8956
  path: ACTIVITY_CARD_STORE_PATH,
8885
8957
  fs: activityCardStoreFs,
8958
+ // A synthetic pre-turn record's key is never a live topic key, so it is
8959
+ // correctly seen as ownerless here (the seam's own age-based self-reap is
8960
+ // the fast path; this is the crash / long-lived backstop).
8886
8961
  isLive: (record) => topicKeys.has(record.turnKey),
8887
8962
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
8888
8963
  now,
8889
- finalizeCard: (record) =>
8890
- robustApiCall(
8964
+ finalizeCard: (record) => {
8965
+ // Reap hook (design lever 4): a never-adopted pre-turn card being
8966
+ // finalized here must also stop its forever-running typing loop and
8967
+ // drop the seam's in-memory entry.
8968
+ if (handbackPreturnSignal.isPreTurnRecord(record.turnKey)) {
8969
+ handbackPreturnSignal.handleReaped(record.turnKey)
8970
+ }
8971
+ return robustApiCall(
8891
8972
  () =>
8892
8973
  lockedBot.api.editMessageText(
8893
8974
  record.chatId,
@@ -8900,7 +8981,8 @@ async function runMidSessionCardReaper(): Promise<void> {
8900
8981
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
8901
8982
  verb: 'activity-card.mid-session-reap-finalize',
8902
8983
  },
8903
- ),
8984
+ )
8985
+ },
8904
8986
  // #3001: route through reconcileStatusPin when the pin is a live
8905
8987
  // in-memory claim, so the claim AND the durable status-pins.json row
8906
8988
  // clear together (the raw-API unpin left both behind — the boot sweep
@@ -8942,16 +9024,32 @@ async function runMidSessionCardReaper(): Promise<void> {
8942
9024
  // the durable store row clear together.
8943
9025
  if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
8944
9026
  try {
8945
- const candidates = [...statusPinState.keys()]
8946
- .filter((k) => k.startsWith('wk:'))
8947
- .map((k) => ({
8948
- pinKey: k,
8949
- chatId: statusPinChatIds.get(k) ?? '',
8950
- // A missing timestamp (should not happen — set on every claim)
8951
- // degrades to "claimed just now": terminality can still reap it,
8952
- // the TTL gate never can. Conservative, never a spurious unpin.
8953
- pinnedAt: statusPinPinnedAt.get(k) ?? now,
8954
- }))
9027
+ const inMemoryKeys = new Set(
9028
+ [...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
9029
+ )
9030
+ const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
9031
+ pinKey: k,
9032
+ chatId: statusPinChatIds.get(k) ?? '',
9033
+ // A missing timestamp (should not happen set on every claim)
9034
+ // degrades to "claimed just now": terminality can still reap it,
9035
+ // the TTL gate never can. Conservative, never a spurious unpin.
9036
+ pinnedAt: statusPinPinnedAt.get(k) ?? now,
9037
+ }))
9038
+ // #3001 durable group net: fold in `wk:` rows that live in the DURABLE
9039
+ // store but have NO in-memory claim — the divergence window where the
9040
+ // claim was lost but the Telegram pin + store row survive. These would
9041
+ // otherwise linger until the next boot cleanup; the reconciling sweep
9042
+ // recovers them mid-session too, group-safely (per-message unpin of a
9043
+ // bot-tracked row, NEVER an unpin-all, NEVER an untracked/human pin).
9044
+ const storeOnlyCandidates = statusPinPersistEnabled
9045
+ ? storeOnlyWorkerPinCandidates({
9046
+ rows: loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
9047
+ inMemoryPinKeys: inMemoryKeys,
9048
+ now,
9049
+ })
9050
+ : []
9051
+ const storeOnlyKeys = new Set(storeOnlyCandidates.map((c) => c.pinKey))
9052
+ const candidates = [...inMemoryCandidates, ...storeOnlyCandidates]
8955
9053
  const reaps = decideWorkerPinReaps({
8956
9054
  pins: candidates,
8957
9055
  statusOf: (agentId) => {
@@ -8983,11 +9081,35 @@ async function runMidSessionCardReaper(): Promise<void> {
8983
9081
  now,
8984
9082
  })
8985
9083
  for (const reap of reaps) {
9084
+ const storeOnly = storeOnlyKeys.has(reap.pinKey)
8986
9085
  process.stderr.write(
8987
9086
  `telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
8988
- `(chat=${reap.chatId} reason=${reap.reason})\n`,
9087
+ `(chat=${reap.chatId} reason=${reap.reason}` +
9088
+ `${storeOnly ? ' source=store-orphan' : ''})\n`,
8989
9089
  )
8990
- await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
9090
+ if (storeOnly && reap.messageId != null) {
9091
+ // No in-memory claim to reconcile: unpin the exact tracked message
9092
+ // (per-message — group-safe) and drop the store row directly. Both
9093
+ // are best-effort/idempotent; a failed unpin still drops the row so
9094
+ // the next boot's cleanup is the final backstop.
9095
+ try {
9096
+ await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
9097
+ } catch (err) {
9098
+ process.stderr.write(
9099
+ `telegram gateway: worker-pin reaper store-orphan unpin failed ` +
9100
+ `(${reap.pinKey} chat=${reap.chatId} msg=${reap.messageId}): ` +
9101
+ `${(err as Error).message}\n`,
9102
+ )
9103
+ }
9104
+ await mutateStatusPinRow(
9105
+ STATUS_PIN_STORE_PATH,
9106
+ statusPinStoreFs,
9107
+ reap.pinKey,
9108
+ null,
9109
+ )
9110
+ } else {
9111
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
9112
+ }
8991
9113
  }
8992
9114
  } catch (err) {
8993
9115
  process.stderr.write(
@@ -10002,6 +10124,15 @@ _deliveryMachineTick.unref?.()
10002
10124
  // synthetic-source/empty, which never produce an `enqueue` and would otherwise
10003
10125
  // re-deliver forever.
10004
10126
  function trackRedeliveredInbound(merged: InboundMessage): void {
10127
+ // Dead-air pre-turn signal: this is the shared CONFIRMED-release chokepoint
10128
+ // (buffer drain, idle-drain tick, bridge re-register). A released
10129
+ // subagent-handback gets a `typing…` + pre-turn card the imminent turn
10130
+ // adopts; a no-op for every non-handback inbound (guarded inside the seam).
10131
+ // Emitted BEFORE the delivery-confirm early-return so it fires regardless of
10132
+ // that feature flag. Design lever 1: emit at release, do NOT gate on
10133
+ // turn-in-flight (the common case is a worker finishing while the parent is
10134
+ // mid-turn on another topic).
10135
+ if (HANDBACK_PRETURN_ENABLED) handbackPreturnSignal.noteHandbackRelease(merged)
10005
10136
  if (!DELIVERY_CONFIRM_ENABLED) return
10006
10137
  // The boot-resume synthetic ('resume_interrupted') is the ONE synthetic we DO
10007
10138
  // enrol: a restart can drop it into a not-ready session exactly like a user
@@ -13292,6 +13423,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13292
13423
  // exact-text `outboundDedup` above structurally cannot catch. A reply for a
13293
13424
  // DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
13294
13425
  // so a fresh turn's answer is never clobbered.
13426
+ //
13427
+ // Reply-flicker fix: rather than delete the flushed message(s) HERE (which
13428
+ // makes the user see delete+replace when the canonical reply sends fresh
13429
+ // below), we DEFER the correction to the send site. Once chunk count / files /
13430
+ // preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
13431
+ // single flushed message into the canonical reply — no flicker, no re-ping)
13432
+ // when the reply fits one plain-text message, and falls back to the legacy
13433
+ // delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
13434
+ let supersedeFlushIds: number[] = []
13295
13435
  {
13296
13436
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
13297
13437
  // 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
@@ -13319,12 +13459,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13319
13459
  `telegram gateway: reply: superseding flushed turn message(s) ` +
13320
13460
  `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
13321
13461
  )
13322
- for (const id of decision.deleteMessageIds) {
13323
- await swallowingApiCall(
13324
- () => lockedBot.api.deleteMessage(chat_id, id),
13325
- { chat_id, verb: 'reply.supersedeFlushed' },
13326
- )
13327
- }
13462
+ // Deferred: the correction (edit-in-place vs delete+resend) is decided at
13463
+ // the send site once chunk count / files / preview are known.
13464
+ supersedeFlushIds = decision.deleteMessageIds
13465
+ // Set the answer-delivered latch NOW, at record consumption — BEFORE the
13466
+ // arg-validation throws between here and the correction site (file
13467
+ // too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
13468
+ // late reply that supersedes a flush AND carries an oversized file /
13469
+ // invalid keyboard would throw before the correction runs (message A
13470
+ // neither deleted nor edited), the model would retry `reply`, and — the
13471
+ // supersede record already consumed by `take()` above — the retry would
13472
+ // fall into the else/no-record branch below with no latch set, so
13473
+ // suppression wouldn't fire and a fresh B would ship alongside the stale
13474
+ // narration A (both visible). Latching here mirrors the else-branch's own
13475
+ // `answerDelivered = true` and closes that resurrection window: the retry
13476
+ // resolves the same ended owner turn, sees the latch, and is suppressed —
13477
+ // exactly one message ever ships. The latch is idempotent and the normal
13478
+ // (no-throw) path is unaffected: the correction below still ships B once.
13479
+ if (ownerTurn != null) ownerTurn.answerDelivered = true
13328
13480
  } else {
13329
13481
  // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
13330
13482
  // Supersede found no record. Either there was no flush (normal reply), or
@@ -13858,6 +14010,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13858
14010
  }
13859
14011
  }
13860
14012
 
14013
+ // Reply-flicker fix — apply the deferred flushed-turn correction now that
14014
+ // chunk count / files / preview are all resolved. `edit-in-place` reuses the
14015
+ // single-message edit lane below (`previewMessageId`): it edits the flushed
14016
+ // message A into the canonical reply B with the SAME rich rendering a fresh
14017
+ // reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
14018
+ // 400 (message too old / uneditable / gone) — so exactly one message with the
14019
+ // canonical content always survives. We also forgo the quote (an edit can't
14020
+ // carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
14021
+ // guard just below does NOT delete our edit target. `delete-resend` keeps the
14022
+ // legacy behaviour (delete the flushed message(s), then send fresh below).
14023
+ if (supersedeFlushIds.length > 0) {
14024
+ const correction = decideSupersedeCorrection({
14025
+ flushMessageIds: supersedeFlushIds,
14026
+ chunkCount: chunks.length,
14027
+ hasFiles: files.length > 0,
14028
+ suppressText,
14029
+ hasOpenPreview: previewMessageId != null,
14030
+ })
14031
+ if (correction.mode === 'edit-in-place') {
14032
+ previewMessageId = correction.editMessageId
14033
+ reply_to = undefined
14034
+ process.stderr.write(
14035
+ `telegram gateway: reply: superseding flushed message via edit-in-place ` +
14036
+ `chatId=${chat_id} id=${correction.editMessageId}\n`,
14037
+ )
14038
+ } else {
14039
+ for (const id of correction.deleteMessageIds) {
14040
+ await swallowingApiCall(
14041
+ () => lockedBot.api.deleteMessage(chat_id, id),
14042
+ { chat_id, verb: 'reply.supersedeFlushed' },
14043
+ )
14044
+ }
14045
+ }
14046
+ }
14047
+
13861
14048
  if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
13862
14049
  await deleteStalePreview(previewMessageId)
13863
14050
  previewMessageId = null
@@ -13874,7 +14061,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13874
14061
  let silentAnchorEditDone = false
13875
14062
  {
13876
14063
  const turn = currentTurn
13877
- if (turn != null && chunks.length === 1) {
14064
+ // Skip the silent-anchor merge when a flushed-turn supersede is active: an
14065
+ // edit-in-place correction has re-pointed `previewMessageId` at the flushed
14066
+ // message (the chunk loop below edits it), and merging into a prior silent
14067
+ // anchor instead would orphan the flushed message A (leaving BOTH A and the
14068
+ // anchor visible — the exact duplicate this supersede exists to prevent).
14069
+ if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
13878
14070
  const decision = decideSilentReplyAnchor({
13879
14071
  effectivelySilent: disableNotification,
13880
14072
  anchorMessageId: turn.silentAnchorMessageId,
@@ -16996,6 +17188,90 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
16996
17188
  })
16997
17189
  }
16998
17190
 
17191
+ // ─── Sub-agent handback dead-air pre-turn signal ─────────────────────────────
17192
+ // Closes the gap between a background sub-agent's handback being RELEASED for
17193
+ // delivery (the buffer drain) and the parent turn that consumes it minting +
17194
+ // rendering its first tool/narration. On release we emit a `typing…` loop + a
17195
+ // "reading the worker's results…" card; the imminent turn ADOPTS that exact
17196
+ // card (by inbound identity) so it's one continuous card lifecycle finalized by
17197
+ // the turn's normal `clearActivitySummary`, never a second card. See
17198
+ // handback-preturn-signal.ts for the design + the red-team holes each lever
17199
+ // closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
17200
+ const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
17201
+ const HANDBACK_PRETURN_HTML = '🤝 Reading the worker’s results…'
17202
+ const HANDBACK_PRETURN_ORPHAN_HTML =
17203
+ '🤝 A background worker finished, but the handback never started — it may need a nudge.'
17204
+
17205
+ async function openHandbackPreTurnCard(
17206
+ chatId: string,
17207
+ threadId: number | null,
17208
+ ): Promise<number | null> {
17209
+ if (STATIC) return null
17210
+ try {
17211
+ const sent = await robustApiCall(
17212
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
17213
+ () =>
17214
+ bot.api.sendRichMessage(chatId, richMessage(HANDBACK_PRETURN_HTML), {
17215
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
17216
+ disable_notification: true,
17217
+ }),
17218
+ {
17219
+ chat_id: chatId,
17220
+ ...(threadId != null ? { threadId } : {}),
17221
+ verb: 'handback-preturn.send',
17222
+ },
17223
+ )
17224
+ return sent?.message_id ?? null
17225
+ } catch (err) {
17226
+ process.stderr.write(
17227
+ `telegram gateway: handback pre-turn card send failed: ${(err as Error).message}\n`,
17228
+ )
17229
+ return null
17230
+ }
17231
+ }
17232
+
17233
+ function finalizeHandbackPreTurnCard(record: PreTurnCardRecord): Promise<void> {
17234
+ return robustApiCall(
17235
+ () =>
17236
+ bot.api.editMessageText(
17237
+ record.chatId,
17238
+ record.activityMessageId,
17239
+ richMessage(HANDBACK_PRETURN_ORPHAN_HTML),
17240
+ {},
17241
+ ),
17242
+ {
17243
+ chat_id: record.chatId,
17244
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
17245
+ verb: 'handback-preturn.orphan-finalize',
17246
+ },
17247
+ )
17248
+ .then(() => undefined)
17249
+ .catch(() => undefined)
17250
+ }
17251
+
17252
+ const handbackPreturnSignal = createHandbackPreturnSignal({
17253
+ chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
17254
+ deriveTurnId: (chatId, threadId, messageId) => deriveTurnId(chatId, threadId, messageId),
17255
+ startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
17256
+ stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
17257
+ openCard: openHandbackPreTurnCard,
17258
+ finalizeCard: finalizeHandbackPreTurnCard,
17259
+ writeCardRecord: (record) => {
17260
+ if (!activityCardPersistEnabled) return
17261
+ writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
17262
+ },
17263
+ clearCardRecord: (turnKey, activityMessageId) => {
17264
+ if (!activityCardPersistEnabled) return
17265
+ clearActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, turnKey, activityMessageId)
17266
+ },
17267
+ // Lever 5: never paint a pre-turn card beneath a turn that already delivered
17268
+ // its answer / ended by the time the debounce fires.
17269
+ isTurnSettled: (key) => {
17270
+ const live = currentTurnMap.get(key)
17271
+ return live != null && (live.finalAnswerDelivered || live.endedAt != null)
17272
+ },
17273
+ })
17274
+
16999
17275
  /**
17000
17276
  * #2849 hindsight Phase 4 — sparse chat-legible memory.
17001
17277
  *
@@ -17349,6 +17625,27 @@ function handleSessionEvent(ev: SessionEvent): void {
17349
17625
  // Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
17350
17626
  // effects close over the turn). Born with this turn, torn down at turn end.
17351
17627
  next.narrativeGate = makeNarrativeGate(next)
17628
+ // Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
17629
+ // If a subagent-handback pre-turn signal was emitted for THIS exact turn
17630
+ // (matched on `turnId`, not the bare topic key, so a racing user inbound
17631
+ // can't mis-adopt), consume it. A card-bearing adoption seeds
17632
+ // `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
17633
+ // EDITS the existing card instead of opening a second one, and so the
17634
+ // turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
17635
+ // The handback turn also gets the turn-long typing loop it never had —
17636
+ // whether or not a card was painted (the debounce may not have fired) —
17637
+ // stopped by the canonical turn-end (`purgeReactionTracking →
17638
+ // stopTurnTypingLoop`).
17639
+ if (HANDBACK_PRETURN_ENABLED) {
17640
+ const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
17641
+ if (handbackAdoption != null) {
17642
+ if (handbackAdoption.activityMessageId != null) {
17643
+ next.activityMessageId = handbackAdoption.activityMessageId
17644
+ next.activityEverOpened = true
17645
+ }
17646
+ startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
17647
+ }
17648
+ }
17352
17649
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
17353
17650
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
17354
17651
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -23515,8 +23812,13 @@ bot.command('model', async ctx => {
23515
23812
  // command leaves a greppable log line + a history row before any branch. This
23516
23813
  // is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
23517
23814
  // ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
23518
- // the authoritative delivery-machine/approval gate).
23519
- const busyNow = currentTurn !== null || turnInFlightForGate()
23815
+ // the authoritative delivery-machine/approval gate) — but a STALE atom or a
23816
+ // wedged approval older than the hard TTL is discounted as idle (#3262), so a
23817
+ // phantom "active turn" on an idle session no longer blocks the switch. Resolve
23818
+ // once (it may clear a dangling atom) and reuse for both the receipt log and
23819
+ // the routing disposition.
23820
+ const modelBusy = resolveModelEffortBusy()
23821
+ const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
23520
23822
  process.stderr.write(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
23521
23823
  if (HISTORY_ENABLED && ctx.message?.message_id != null) {
23522
23824
  try {
@@ -23538,8 +23840,8 @@ bot.command('model', async ctx => {
23538
23840
  // session busy by EITHER measure ack+queues instead of silently injecting
23539
23841
  // into a busy pane. Every branch below produces a visible action.
23540
23842
  const disposition = planModelCommand(parsed, {
23541
- currentTurnActive: currentTurn !== null,
23542
- turnInFlight: turnInFlightForGate(),
23843
+ currentTurnActive: modelBusy.currentTurnActive,
23844
+ turnInFlight: modelBusy.turnInFlight,
23543
23845
  menuEnabled: process.env.SWITCHROOM_MODEL_MENU !== '0',
23544
23846
  })
23545
23847
  if (disposition.kind === 'menu') {
@@ -23650,9 +23952,12 @@ bot.command('effort', async ctx => {
23650
23952
  }
23651
23953
  // Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
23652
23954
  // /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
23653
- // applied") before this gate existed. `currentTurn !== null` is the same
23654
- // busy signal the model path reads via deps.isBusy().
23655
- if ((parsed.kind === 'set' || parsed.kind === 'default') && currentTurn !== null) {
23955
+ // applied") before this gate existed. Use the SAME stale-aware busy resolver
23956
+ // as /model (#3262) so a dangling turn atom older than the hard TTL is
23957
+ // discounted as idle and the switch applies instead of queuing forever on an
23958
+ // idle session.
23959
+ const effortBusy = resolveModelEffortBusy()
23960
+ if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
23656
23961
  const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
23657
23962
  const chatId = String(ctx.chat!.id)
23658
23963
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)