switchroom 0.18.26 → 0.18.27

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 (32) 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 +494 -36
  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 +305 -41
  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/send-gate.test.ts +138 -0
  18. package/telegram-plugin/send-gate.ts +104 -1
  19. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  20. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  22. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  23. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  24. package/telegram-plugin/tests/model-command.test.ts +112 -0
  25. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  26. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  27. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  28. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  29. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  30. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  31. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  32. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -113,6 +113,64 @@ export function decideSupersede(
113
113
  return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
114
114
  }
115
115
 
116
+ /**
117
+ * How the gateway should CORRECT a flushed message once its canonical `reply`
118
+ * lands. Historically the supersede always did `deleteMessage(A)` + fresh send
119
+ * of the canonical reply B — visible to the user as a delete+replace flicker
120
+ * plus a second device ping. When the reply fits a single message and matches
121
+ * the flushed message's type (both plain text, the normal case), we can instead
122
+ * edit message A in place with B's content: no delete, no flicker, no re-ping.
123
+ *
124
+ * - `edit-in-place` → the gateway feeds `editMessageId` into the existing
125
+ * single-message edit path (the `previewMessageId` edit lane in
126
+ * `sendReplyChunks`), which renders the canonical reply with the SAME rich
127
+ * path a fresh reply uses and, on any edit 400 (message too old / can't be
128
+ * edited / not found), falls back to delete+resend — so correctness never
129
+ * regresses. `deleteMessageIds` is empty (message A becomes message B).
130
+ * - `delete-resend` → the legacy behaviour: delete every flushed message id,
131
+ * then send the canonical reply fresh. Chosen whenever edit-in-place isn't
132
+ * safe (multi-part reply, >1 flushed message, a file/voice-only reply, or a
133
+ * draft-stream preview already owns the single-message edit lane).
134
+ */
135
+ export type SupersedeCorrection =
136
+ | { mode: 'edit-in-place'; editMessageId: number; deleteMessageIds: number[] }
137
+ | { mode: 'delete-resend'; deleteMessageIds: number[] }
138
+
139
+ /**
140
+ * Decide how to correct a superseded flush. Pure so the gateway runs the exact
141
+ * branch the regression tests exercise. Edit-in-place is chosen IFF the flush
142
+ * posted exactly ONE message AND the canonical reply is a single plain-text
143
+ * message with no competing edit target:
144
+ * - `flushMessageIds.length === 1` — a multi-message flush has no single
145
+ * edit target; delete all and resend.
146
+ * - `chunkCount === 1` — a multi-part reply can't collapse into one edit.
147
+ * - `!hasFiles` — a file/album reply is not a plain-text edit.
148
+ * - `!suppressText` — a voice-only reply sends no text body to edit into.
149
+ * - `!hasOpenPreview` — a live draft-stream preview already owns the
150
+ * single-message edit lane; deleting the flush and letting the preview edit
151
+ * keeps exactly one message.
152
+ * `literalText` (`format:'text'`) stays eligible — it is still a text message,
153
+ * and the edit lane renders literal vs rich identically to a fresh send.
154
+ */
155
+ export function decideSupersedeCorrection(input: {
156
+ flushMessageIds: number[]
157
+ chunkCount: number
158
+ hasFiles: boolean
159
+ suppressText: boolean
160
+ hasOpenPreview: boolean
161
+ }): SupersedeCorrection {
162
+ const eligible =
163
+ input.flushMessageIds.length === 1 &&
164
+ input.chunkCount === 1 &&
165
+ !input.hasFiles &&
166
+ !input.suppressText &&
167
+ !input.hasOpenPreview
168
+ if (eligible) {
169
+ return { mode: 'edit-in-place', editMessageId: input.flushMessageIds[0]!, deleteMessageIds: [] }
170
+ }
171
+ return { mode: 'delete-resend', deleteMessageIds: [...input.flushMessageIds] }
172
+ }
173
+
116
174
  /** Sentinel key for records whose flush carried no turnId nonce. */
117
175
  const NULL_TURN_KEY = '<<null-turn>>'
118
176
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * derive-turn-id.ts — the stable per-turn identity, extracted from the gateway
3
+ * monolith so BOTH the enqueue seam and out-of-gateway tests use the SAME
4
+ * function (no mirror-drift between production and the round-trip test).
5
+ *
6
+ * Component 3 — derive the stable per-turn identity from the chat, thread, and
7
+ * originating message id. Stamped into the inbound meta at build time
8
+ * (`origin_turn_id`) AND reconstructed at enqueue time from the same three
9
+ * values, so the id stamped on the message the model reads matches the id on
10
+ * the turn the gateway started for it. Using the message id (not the
11
+ * not-yet-known startedAt) is what lets the two sites agree. Returns null when
12
+ * there is no message id (synthetic / cron turns with no originating inbound —
13
+ * they never need origin routing, the live turn IS the origin).
14
+ *
15
+ * NOTE (#3268): a subagent-handback IS a synthetic inbound, but it MUST still
16
+ * derive a stable non-null id so the dead-air pre-turn card can be adopted by
17
+ * identity at enqueue. The handback inbound builder therefore rounds-trips its
18
+ * fabricated `ts` through `meta.message_id` (mirroring resume-inbound-builder),
19
+ * so `ev.messageId` is populated at enqueue and this function yields the SAME
20
+ * `chatKey#ts` the pre-turn seam recorded at release.
21
+ */
22
+
23
+ import { chatKey } from './chat-key.js'
24
+
25
+ export function deriveTurnId(
26
+ chatId: string,
27
+ threadId: number | null | undefined,
28
+ messageId: string | number | null | undefined,
29
+ ): string | null {
30
+ if (messageId == null || messageId === '' || String(messageId) === '0') return null
31
+ return `${chatKey(chatId, threadId ?? null)}#${messageId}`
32
+ }
@@ -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,
@@ -802,7 +808,10 @@ import {
802
808
  removeTurnActiveMarker,
803
809
  sweepStaleTurnActiveMarker,
804
810
  readTurnActiveMarkerAgeMs,
811
+ effectiveTurnAgeMs,
805
812
  TURN_ACTIVE_MARKER_FILE,
813
+ TURN_ACTIVE_HARD_TTL_MS,
814
+ TURN_ACTIVE_IDLE_SWEEP_MS,
806
815
  } from './turn-active-marker.js'
807
816
  import {
808
817
  VERSION,
@@ -2859,14 +2868,91 @@ function turnInFlightForGate(): boolean {
2859
2868
  // handlers and do not sit behind this gate — and the block is surfaced
2860
2869
  // off-Telegram in blocked-approvals/<agent>.json.
2861
2870
  const hasPendingApproval = pendingPermissions.size > 0
2862
- if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0 || hasPendingApproval
2871
+ // The shared inbound gate stays STRICT: unconditionally busy whenever an
2872
+ // approval card is outstanding (#2841 — a new inbound in that window would
2873
+ // displace the approval context and orphan the pending MCP call). Only the
2874
+ // machine-in-turn leg is factored out (`turnInFlightMachineOnly`); the
2875
+ // `|| hasPendingApproval` composition here is what preserves the contract.
2876
+ return turnInFlightMachineOnly() || hasPendingApproval
2877
+ }
2878
+
2879
+ /**
2880
+ * The machine-in-turn portion of the gate WITHOUT the `pendingPermissions`
2881
+ * hold — "claude is actively producing a turn right now", ignoring an
2882
+ * outstanding approval card. `turnInFlightForGate()` = this OR a pending
2883
+ * approval, so the shared inbound-hold contract (#2841) is unchanged.
2884
+ *
2885
+ * Factored out so the `/model` & `/effort` busy decision can read THIS
2886
+ * machine-only leg (plus a TTL-bounded approval check in
2887
+ * `resolveModelEffortBusy`) — a wedged / undeliverable approval that "never
2888
+ * expires by design" (#3084) must not hold their switch queued forever on an
2889
+ * idle session (#3262), even though it correctly holds the general inbound gate.
2890
+ */
2891
+ function turnInFlightMachineOnly(): boolean {
2892
+ if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
2863
2893
  // Machine is authoritative. Run the log-only drift canary (#2794): the
2864
2894
  // imperative `claudeBusyKeys` shadow is still live in parallel, so a
2865
2895
  // dangerous over-hold divergence (machine holds the gate while the
2866
2896
  // imperative view is idle) is surfaced without changing behaviour. The
2867
2897
  // benign orphan-dangle direction — the wedge the machine self-heals — is
2868
2898
  // NOT flagged. `probeGateParity` returns the machine value unchanged.
2869
- return probeGateParity(isMachineInTurn(), claudeBusyKeys.size) || hasPendingApproval
2899
+ return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
2900
+ }
2901
+
2902
+ /**
2903
+ * Age (ms) of the live turn for the phantom-turn cross-check (#3262): the
2904
+ * turn-active liveness marker's mtime age (touched on every foreground
2905
+ * tool_use AND on sub-agent JSONL growth, so a genuinely long turn keeps it
2906
+ * small), falling back to `now - turn.startedAt` when the marker is absent
2907
+ * (e.g. already swept). Null when no turn atom is set. A large age on a
2908
+ * non-null atom is the dangling-atom signal.
2909
+ */
2910
+ function liveTurnAgeMs(now: number): number | null {
2911
+ if (currentTurn === null) return null
2912
+ return effectiveTurnAgeMs(readTurnActiveMarkerAgeMs(STATE_DIR, now), currentTurn.startedAt, now)
2913
+ }
2914
+
2915
+ /** Age (ms) of the OLDEST outstanding pending approval, or null when none. */
2916
+ function oldestPendingApprovalAgeMs(now: number): number | null {
2917
+ let oldest: number | null = null
2918
+ for (const p of pendingPermissions.values()) {
2919
+ const age = now - p.startedAt
2920
+ if (oldest === null || age > oldest) oldest = age
2921
+ }
2922
+ return oldest
2923
+ }
2924
+
2925
+ /**
2926
+ * Resolve the apply-vs-queue busy signals for a `/model` or `/effort` command,
2927
+ * sweeping a stale (dangling) turn atom and discounting a wedged (undeliverable)
2928
+ * approval hold — both bounded by the SAME `TURN_ACTIVE_HARD_TTL_MS` ceiling the
2929
+ * marker sweep uses (#3262). A genuinely in-flight turn (fresh marker) and a
2930
+ * recent pending approval still read busy, so the #3017/#3039 apply-or-queue
2931
+ * contract and #3177/#3180 are preserved. Clears the dangling atom as a side
2932
+ * effect so the phantom can't re-block the next command.
2933
+ */
2934
+ function resolveModelEffortBusy(now = Date.now()): { currentTurnActive: boolean; turnInFlight: boolean } {
2935
+ const turnAgeMs = liveTurnAgeMs(now)
2936
+ const resolved = resolveStaleAwareBusy({
2937
+ currentTurnActive: currentTurn !== null,
2938
+ turnAgeMs,
2939
+ machineInTurn: turnInFlightMachineOnly(),
2940
+ oldestPendingApprovalAgeMs: oldestPendingApprovalAgeMs(now),
2941
+ hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
2942
+ })
2943
+ if (resolved.clearStaleTurn && currentTurn !== null) {
2944
+ const ageSec = Math.round((turnAgeMs ?? 0) / 1000)
2945
+ process.stderr.write(
2946
+ `telegram gateway: [phantomturn] cleared stale currentTurn atom age=${ageSec}s ` +
2947
+ `ttl=${Math.round(TURN_ACTIVE_HARD_TTL_MS / 1000)}s for /model|/effort busy-check agent=${getMyAgentName()}\n`,
2948
+ )
2949
+ // Single-tenant / sequential-CLI invariant: the singleton `currentTurn` IS
2950
+ // the only live turn, and a stale atom is a ghost (its `turn_end` never
2951
+ // fired) — clearing every entry is equivalent to clearing it, matching the
2952
+ // bridge-died "every entry is a ghost" semantics.
2953
+ clearAllCurrentTurns()
2954
+ }
2955
+ return { currentTurnActive: resolved.currentTurnActive, turnInFlight: resolved.turnInFlight }
2870
2956
  }
2871
2957
 
2872
2958
  /**
@@ -3674,25 +3760,10 @@ function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTur
3674
3760
  if (turn == null || turn.sessionChatId !== chatId) return null
3675
3761
  return turn
3676
3762
  }
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
- }
3763
+ // Component 3 — the stable per-turn identity. Extracted to `derive-turn-id.ts`
3764
+ // (#3268) so the enqueue seam and the handback round-trip test share ONE
3765
+ // function. Re-exported into scope under the original name so every existing
3766
+ // callsite is unchanged.
3696
3767
 
3697
3768
  /**
3698
3769
  * Component 3 — resolve the turn that OWNS a reply by its `origin_turn_id`
@@ -7767,8 +7838,8 @@ const pendingStateReaper = setInterval(() => {
7767
7838
  try {
7768
7839
  sweepStaleTurnActiveMarker(STATE_DIR, {
7769
7840
  turnInFlight: currentTurn?.registryKey != null,
7770
- idleSweepMs: 60_000,
7771
- hardTtlMs: 10 * 60_000,
7841
+ idleSweepMs: TURN_ACTIVE_IDLE_SWEEP_MS,
7842
+ hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
7772
7843
  now,
7773
7844
  onRemove: ({ ageMs, reason, payload }) => {
7774
7845
  const agent = getMyAgentName()
@@ -8883,11 +8954,20 @@ async function runMidSessionCardReaper(): Promise<void> {
8883
8954
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
8884
8955
  path: ACTIVITY_CARD_STORE_PATH,
8885
8956
  fs: activityCardStoreFs,
8957
+ // A synthetic pre-turn record's key is never a live topic key, so it is
8958
+ // correctly seen as ownerless here (the seam's own age-based self-reap is
8959
+ // the fast path; this is the crash / long-lived backstop).
8886
8960
  isLive: (record) => topicKeys.has(record.turnKey),
8887
8961
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
8888
8962
  now,
8889
- finalizeCard: (record) =>
8890
- robustApiCall(
8963
+ finalizeCard: (record) => {
8964
+ // Reap hook (design lever 4): a never-adopted pre-turn card being
8965
+ // finalized here must also stop its forever-running typing loop and
8966
+ // drop the seam's in-memory entry.
8967
+ if (handbackPreturnSignal.isPreTurnRecord(record.turnKey)) {
8968
+ handbackPreturnSignal.handleReaped(record.turnKey)
8969
+ }
8970
+ return robustApiCall(
8891
8971
  () =>
8892
8972
  lockedBot.api.editMessageText(
8893
8973
  record.chatId,
@@ -8900,7 +8980,8 @@ async function runMidSessionCardReaper(): Promise<void> {
8900
8980
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
8901
8981
  verb: 'activity-card.mid-session-reap-finalize',
8902
8982
  },
8903
- ),
8983
+ )
8984
+ },
8904
8985
  // #3001: route through reconcileStatusPin when the pin is a live
8905
8986
  // in-memory claim, so the claim AND the durable status-pins.json row
8906
8987
  // clear together (the raw-API unpin left both behind — the boot sweep
@@ -10002,6 +10083,15 @@ _deliveryMachineTick.unref?.()
10002
10083
  // synthetic-source/empty, which never produce an `enqueue` and would otherwise
10003
10084
  // re-deliver forever.
10004
10085
  function trackRedeliveredInbound(merged: InboundMessage): void {
10086
+ // Dead-air pre-turn signal: this is the shared CONFIRMED-release chokepoint
10087
+ // (buffer drain, idle-drain tick, bridge re-register). A released
10088
+ // subagent-handback gets a `typing…` + pre-turn card the imminent turn
10089
+ // adopts; a no-op for every non-handback inbound (guarded inside the seam).
10090
+ // Emitted BEFORE the delivery-confirm early-return so it fires regardless of
10091
+ // that feature flag. Design lever 1: emit at release, do NOT gate on
10092
+ // turn-in-flight (the common case is a worker finishing while the parent is
10093
+ // mid-turn on another topic).
10094
+ if (HANDBACK_PRETURN_ENABLED) handbackPreturnSignal.noteHandbackRelease(merged)
10005
10095
  if (!DELIVERY_CONFIRM_ENABLED) return
10006
10096
  // The boot-resume synthetic ('resume_interrupted') is the ONE synthetic we DO
10007
10097
  // enrol: a restart can drop it into a not-ready session exactly like a user
@@ -13292,6 +13382,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13292
13382
  // exact-text `outboundDedup` above structurally cannot catch. A reply for a
13293
13383
  // DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
13294
13384
  // so a fresh turn's answer is never clobbered.
13385
+ //
13386
+ // Reply-flicker fix: rather than delete the flushed message(s) HERE (which
13387
+ // makes the user see delete+replace when the canonical reply sends fresh
13388
+ // below), we DEFER the correction to the send site. Once chunk count / files /
13389
+ // preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
13390
+ // single flushed message into the canonical reply — no flicker, no re-ping)
13391
+ // when the reply fits one plain-text message, and falls back to the legacy
13392
+ // delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
13393
+ let supersedeFlushIds: number[] = []
13295
13394
  {
13296
13395
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
13297
13396
  // 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
@@ -13319,12 +13418,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13319
13418
  `telegram gateway: reply: superseding flushed turn message(s) ` +
13320
13419
  `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
13321
13420
  )
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
- }
13421
+ // Deferred: the correction (edit-in-place vs delete+resend) is decided at
13422
+ // the send site once chunk count / files / preview are known.
13423
+ supersedeFlushIds = decision.deleteMessageIds
13424
+ // Set the answer-delivered latch NOW, at record consumption — BEFORE the
13425
+ // arg-validation throws between here and the correction site (file
13426
+ // too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
13427
+ // late reply that supersedes a flush AND carries an oversized file /
13428
+ // invalid keyboard would throw before the correction runs (message A
13429
+ // neither deleted nor edited), the model would retry `reply`, and — the
13430
+ // supersede record already consumed by `take()` above — the retry would
13431
+ // fall into the else/no-record branch below with no latch set, so
13432
+ // suppression wouldn't fire and a fresh B would ship alongside the stale
13433
+ // narration A (both visible). Latching here mirrors the else-branch's own
13434
+ // `answerDelivered = true` and closes that resurrection window: the retry
13435
+ // resolves the same ended owner turn, sees the latch, and is suppressed —
13436
+ // exactly one message ever ships. The latch is idempotent and the normal
13437
+ // (no-throw) path is unaffected: the correction below still ships B once.
13438
+ if (ownerTurn != null) ownerTurn.answerDelivered = true
13328
13439
  } else {
13329
13440
  // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
13330
13441
  // Supersede found no record. Either there was no flush (normal reply), or
@@ -13858,6 +13969,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13858
13969
  }
13859
13970
  }
13860
13971
 
13972
+ // Reply-flicker fix — apply the deferred flushed-turn correction now that
13973
+ // chunk count / files / preview are all resolved. `edit-in-place` reuses the
13974
+ // single-message edit lane below (`previewMessageId`): it edits the flushed
13975
+ // message A into the canonical reply B with the SAME rich rendering a fresh
13976
+ // reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
13977
+ // 400 (message too old / uneditable / gone) — so exactly one message with the
13978
+ // canonical content always survives. We also forgo the quote (an edit can't
13979
+ // carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
13980
+ // guard just below does NOT delete our edit target. `delete-resend` keeps the
13981
+ // legacy behaviour (delete the flushed message(s), then send fresh below).
13982
+ if (supersedeFlushIds.length > 0) {
13983
+ const correction = decideSupersedeCorrection({
13984
+ flushMessageIds: supersedeFlushIds,
13985
+ chunkCount: chunks.length,
13986
+ hasFiles: files.length > 0,
13987
+ suppressText,
13988
+ hasOpenPreview: previewMessageId != null,
13989
+ })
13990
+ if (correction.mode === 'edit-in-place') {
13991
+ previewMessageId = correction.editMessageId
13992
+ reply_to = undefined
13993
+ process.stderr.write(
13994
+ `telegram gateway: reply: superseding flushed message via edit-in-place ` +
13995
+ `chatId=${chat_id} id=${correction.editMessageId}\n`,
13996
+ )
13997
+ } else {
13998
+ for (const id of correction.deleteMessageIds) {
13999
+ await swallowingApiCall(
14000
+ () => lockedBot.api.deleteMessage(chat_id, id),
14001
+ { chat_id, verb: 'reply.supersedeFlushed' },
14002
+ )
14003
+ }
14004
+ }
14005
+ }
14006
+
13861
14007
  if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
13862
14008
  await deleteStalePreview(previewMessageId)
13863
14009
  previewMessageId = null
@@ -13874,7 +14020,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
13874
14020
  let silentAnchorEditDone = false
13875
14021
  {
13876
14022
  const turn = currentTurn
13877
- if (turn != null && chunks.length === 1) {
14023
+ // Skip the silent-anchor merge when a flushed-turn supersede is active: an
14024
+ // edit-in-place correction has re-pointed `previewMessageId` at the flushed
14025
+ // message (the chunk loop below edits it), and merging into a prior silent
14026
+ // anchor instead would orphan the flushed message A (leaving BOTH A and the
14027
+ // anchor visible — the exact duplicate this supersede exists to prevent).
14028
+ if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
13878
14029
  const decision = decideSilentReplyAnchor({
13879
14030
  effectivelySilent: disableNotification,
13880
14031
  anchorMessageId: turn.silentAnchorMessageId,
@@ -16996,6 +17147,90 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
16996
17147
  })
16997
17148
  }
16998
17149
 
17150
+ // ─── Sub-agent handback dead-air pre-turn signal ─────────────────────────────
17151
+ // Closes the gap between a background sub-agent's handback being RELEASED for
17152
+ // delivery (the buffer drain) and the parent turn that consumes it minting +
17153
+ // rendering its first tool/narration. On release we emit a `typing…` loop + a
17154
+ // "reading the worker's results…" card; the imminent turn ADOPTS that exact
17155
+ // card (by inbound identity) so it's one continuous card lifecycle finalized by
17156
+ // the turn's normal `clearActivitySummary`, never a second card. See
17157
+ // handback-preturn-signal.ts for the design + the red-team holes each lever
17158
+ // closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
17159
+ const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
17160
+ const HANDBACK_PRETURN_HTML = '🤝 Reading the worker’s results…'
17161
+ const HANDBACK_PRETURN_ORPHAN_HTML =
17162
+ '🤝 A background worker finished, but the handback never started — it may need a nudge.'
17163
+
17164
+ async function openHandbackPreTurnCard(
17165
+ chatId: string,
17166
+ threadId: number | null,
17167
+ ): Promise<number | null> {
17168
+ if (STATIC) return null
17169
+ try {
17170
+ const sent = await robustApiCall(
17171
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
17172
+ () =>
17173
+ bot.api.sendRichMessage(chatId, richMessage(HANDBACK_PRETURN_HTML), {
17174
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
17175
+ disable_notification: true,
17176
+ }),
17177
+ {
17178
+ chat_id: chatId,
17179
+ ...(threadId != null ? { threadId } : {}),
17180
+ verb: 'handback-preturn.send',
17181
+ },
17182
+ )
17183
+ return sent?.message_id ?? null
17184
+ } catch (err) {
17185
+ process.stderr.write(
17186
+ `telegram gateway: handback pre-turn card send failed: ${(err as Error).message}\n`,
17187
+ )
17188
+ return null
17189
+ }
17190
+ }
17191
+
17192
+ function finalizeHandbackPreTurnCard(record: PreTurnCardRecord): Promise<void> {
17193
+ return robustApiCall(
17194
+ () =>
17195
+ bot.api.editMessageText(
17196
+ record.chatId,
17197
+ record.activityMessageId,
17198
+ richMessage(HANDBACK_PRETURN_ORPHAN_HTML),
17199
+ {},
17200
+ ),
17201
+ {
17202
+ chat_id: record.chatId,
17203
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
17204
+ verb: 'handback-preturn.orphan-finalize',
17205
+ },
17206
+ )
17207
+ .then(() => undefined)
17208
+ .catch(() => undefined)
17209
+ }
17210
+
17211
+ const handbackPreturnSignal = createHandbackPreturnSignal({
17212
+ chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
17213
+ deriveTurnId: (chatId, threadId, messageId) => deriveTurnId(chatId, threadId, messageId),
17214
+ startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
17215
+ stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
17216
+ openCard: openHandbackPreTurnCard,
17217
+ finalizeCard: finalizeHandbackPreTurnCard,
17218
+ writeCardRecord: (record) => {
17219
+ if (!activityCardPersistEnabled) return
17220
+ writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
17221
+ },
17222
+ clearCardRecord: (turnKey, activityMessageId) => {
17223
+ if (!activityCardPersistEnabled) return
17224
+ clearActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, turnKey, activityMessageId)
17225
+ },
17226
+ // Lever 5: never paint a pre-turn card beneath a turn that already delivered
17227
+ // its answer / ended by the time the debounce fires.
17228
+ isTurnSettled: (key) => {
17229
+ const live = currentTurnMap.get(key)
17230
+ return live != null && (live.finalAnswerDelivered || live.endedAt != null)
17231
+ },
17232
+ })
17233
+
16999
17234
  /**
17000
17235
  * #2849 hindsight Phase 4 — sparse chat-legible memory.
17001
17236
  *
@@ -17349,6 +17584,27 @@ function handleSessionEvent(ev: SessionEvent): void {
17349
17584
  // Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
17350
17585
  // effects close over the turn). Born with this turn, torn down at turn end.
17351
17586
  next.narrativeGate = makeNarrativeGate(next)
17587
+ // Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
17588
+ // If a subagent-handback pre-turn signal was emitted for THIS exact turn
17589
+ // (matched on `turnId`, not the bare topic key, so a racing user inbound
17590
+ // can't mis-adopt), consume it. A card-bearing adoption seeds
17591
+ // `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
17592
+ // EDITS the existing card instead of opening a second one, and so the
17593
+ // turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
17594
+ // The handback turn also gets the turn-long typing loop it never had —
17595
+ // whether or not a card was painted (the debounce may not have fired) —
17596
+ // stopped by the canonical turn-end (`purgeReactionTracking →
17597
+ // stopTurnTypingLoop`).
17598
+ if (HANDBACK_PRETURN_ENABLED) {
17599
+ const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
17600
+ if (handbackAdoption != null) {
17601
+ if (handbackAdoption.activityMessageId != null) {
17602
+ next.activityMessageId = handbackAdoption.activityMessageId
17603
+ next.activityEverOpened = true
17604
+ }
17605
+ startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
17606
+ }
17607
+ }
17352
17608
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
17353
17609
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
17354
17610
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -23515,8 +23771,13 @@ bot.command('model', async ctx => {
23515
23771
  // command leaves a greppable log line + a history row before any branch. This
23516
23772
  // is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
23517
23773
  // 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()
23774
+ // the authoritative delivery-machine/approval gate) — but a STALE atom or a
23775
+ // wedged approval older than the hard TTL is discounted as idle (#3262), so a
23776
+ // phantom "active turn" on an idle session no longer blocks the switch. Resolve
23777
+ // once (it may clear a dangling atom) and reuse for both the receipt log and
23778
+ // the routing disposition.
23779
+ const modelBusy = resolveModelEffortBusy()
23780
+ const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
23520
23781
  process.stderr.write(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
23521
23782
  if (HISTORY_ENABLED && ctx.message?.message_id != null) {
23522
23783
  try {
@@ -23538,8 +23799,8 @@ bot.command('model', async ctx => {
23538
23799
  // session busy by EITHER measure ack+queues instead of silently injecting
23539
23800
  // into a busy pane. Every branch below produces a visible action.
23540
23801
  const disposition = planModelCommand(parsed, {
23541
- currentTurnActive: currentTurn !== null,
23542
- turnInFlight: turnInFlightForGate(),
23802
+ currentTurnActive: modelBusy.currentTurnActive,
23803
+ turnInFlight: modelBusy.turnInFlight,
23543
23804
  menuEnabled: process.env.SWITCHROOM_MODEL_MENU !== '0',
23544
23805
  })
23545
23806
  if (disposition.kind === 'menu') {
@@ -23650,9 +23911,12 @@ bot.command('effort', async ctx => {
23650
23911
  }
23651
23912
  // Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
23652
23913
  // /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) {
23914
+ // applied") before this gate existed. Use the SAME stale-aware busy resolver
23915
+ // as /model (#3262) so a dangling turn atom older than the hard TTL is
23916
+ // discounted as idle and the switch applies instead of queuing forever on an
23917
+ // idle session.
23918
+ const effortBusy = resolveModelEffortBusy()
23919
+ if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
23656
23920
  const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
23657
23921
  const chatId = String(ctx.chat!.id)
23658
23922
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)