switchroom 0.19.43 → 0.19.45

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 (29) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +254 -119
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +3995 -3196
  5. package/dist/host-control/main.js +270 -130
  6. package/dist/vault/approvals/kernel-server.js +201 -66
  7. package/dist/vault/broker/server.js +263 -128
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -4
  10. package/telegram-plugin/dist/gateway/gateway.js +945 -638
  11. package/telegram-plugin/flushed-turn-supersede.ts +190 -16
  12. package/telegram-plugin/gateway/cron-session.ts +31 -0
  13. package/telegram-plugin/gateway/gateway.ts +66 -65
  14. package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
  15. package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
  16. package/telegram-plugin/gateway/stream-render.ts +91 -1
  17. package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
  18. package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
  19. package/telegram-plugin/gateway/turn-end.ts +9 -1
  20. package/telegram-plugin/reply-owner-resolve.ts +102 -26
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
  22. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
  24. package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
  25. package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
  26. package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
  27. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  29. package/vendor/hindsight-memory/settings.json +1 -1
@@ -75,9 +75,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
75
75
  import {
76
76
  decideSupersedeCorrection,
77
77
  flushedAnswerMatchesReply,
78
- DEFAULT_SUPERSEDE_TTL_MS,
79
78
  type FlushedTurnSupersedeRegistry,
80
79
  } from '../flushed-turn-supersede.js'
80
+ import { HANDBACK_RECENCY_WINDOW_MS } from './subagent-handback-marker.js'
81
+ import type { SubagentReplyAuthorityView } from './subagent-reply-authority.js'
81
82
  import {
82
83
  decideAnswerLatchSuppression,
83
84
  decideContentGateBypass,
@@ -762,6 +763,16 @@ export interface SendReplyRequest {
762
763
  * Late writes (finalAnswerDelivered) attribute to THIS turn even if the
763
764
  * module-scope turn rolls over mid-call. */
764
765
  turn: CurrentTurn | null
766
+ /** #4172 — true when the calling IPC client is NOT the main agent bridge
767
+ * (`replyCallerIsForeignSession`, cron-session.ts): a Tier-1 cheap-cron
768
+ * session or an unregistered client. A foreign-session reply can never be
769
+ * the main session's own late answer, so the flushed-turn
770
+ * supersede/bypass/latch block is skipped wholesale — it always sends
771
+ * FRESH (never edits/deletes a flushed answer, never gets latch-
772
+ * suppressed). Omitted/false ⇒ main-bridge caller (the gateway's
773
+ * `executeReply` always passes the computed value; the default only
774
+ * affects test harnesses). */
775
+ callerIsForeignSession?: boolean
765
776
  }
766
777
 
767
778
  /** Gateway dependencies for {@link sendReply}. Function members use method
@@ -834,6 +845,12 @@ export interface SendReplyGatewayDeps {
834
845
  resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
835
846
  getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
836
847
  getLastSubagentHandbackAt(chatId: string): number | null
848
+ /** #4176 — gateway-observed sub-agent liveness (`subagentReplyAuthority`,
849
+ * subagent-reply-authority.ts). A background `Task` sub-agent replies over
850
+ * THIS bridge, so neither the #4172 caller-identity gate nor the handback
851
+ * marker can see it; while one is live the content-gate bypass is refused so
852
+ * a sub-agent's reply can never edit over a flushed answer. */
853
+ subagentReplyAuthority: SubagentReplyAuthorityView
837
854
  recordOutbound(rec: {
838
855
  chat_id: string
839
856
  thread_id: number | null
@@ -898,7 +915,7 @@ export async function sendReply(
898
915
  statusKey, streamKey,
899
916
  resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
900
917
  resolveAnswerThreadWithLog, resolveThreadId,
901
- getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
918
+ getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
902
919
  emissionAuthorityFor, clearActivitySummary,
903
920
  startTypingLoop, stopTypingLoop, logOutbound,
904
921
  closeObligationOnSubstantiveReply, finalizeStatusReaction,
@@ -1025,7 +1042,20 @@ export async function sendReply(
1025
1042
  // when the reply fits one plain-text message, and falls back to the legacy
1026
1043
  // delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
1027
1044
  let supersedeFlushIds: number[] = []
1028
- {
1045
+ // #4172 — caller-identity gate, BEFORE any owner attribution. A reply from a
1046
+ // foreign session (a Tier-1 cheap-cron bridge, or an unregistered client)
1047
+ // can never be the main session's own late/reworded answer, so it gets NO
1048
+ // supersede authority (it must never edit/delete a flushed answer — the
1049
+ // measured #4172 double loss), NO content-gate bypass, and NO exposure to
1050
+ // the answer-delivered latch (which could otherwise silently swallow a cron
1051
+ // digest landing in the flush's pre-record race window). It simply sends
1052
+ // fresh below, leaving any flush record intact for the genuine own-replay.
1053
+ if (req.callerIsForeignSession === true) {
1054
+ process.stderr.write(
1055
+ `telegram gateway: reply: foreign-session caller — supersede/latch skipped (#4172) ` +
1056
+ `chatId=${chat_id}\n`,
1057
+ )
1058
+ } else {
1029
1059
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
1030
1060
  // 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
1031
1061
  // belongs to by IDENTITY, via the SAME full chain the thread-router uses
@@ -1107,11 +1137,16 @@ export async function sendReply(
1107
1137
  // correction only ever touches ITS OWN topic's record.
1108
1138
  const handbackAt = getLastSubagentHandbackAt(chat_id)
1109
1139
  const now = Date.now()
1140
+ // #4174 — the handback gate-hold keeps its OWN ~60 s bound
1141
+ // (`HANDBACK_RECENCY_WINDOW_MS`), deliberately NOT the supersede window's:
1142
+ // this read is CHAT-WIDE, so a longer bound would hold the content gate —
1143
+ // and re-open the reworded-own-answer visible-dup class — for every topic
1144
+ // in a delegation-heavy chat, near-continuously. Rationale on the constant.
1110
1145
  const handbackCouldOwnReply =
1111
1146
  handbackAt != null &&
1112
1147
  ownerEndedAt != null &&
1113
1148
  handbackAt > ownerEndedAt &&
1114
- now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
1149
+ now - handbackAt <= HANDBACK_RECENCY_WINDOW_MS
1115
1150
  // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) + the 2026-07-27
1116
1151
  // corroboration widening — decided by the pure `decideContentGateBypass` so
1117
1152
  // the gateway runs the exact code the unit tests exercise. The rule, in
@@ -1135,11 +1170,27 @@ export async function sendReply(
1135
1170
  // its OWN turn no longer loses the collapse it would have got
1136
1171
  // by omitting the echo entirely (the observed 2026-07-27
1137
1172
  // `via=origin` duplicate).
1173
+ //
1174
+ // #4176 — the SECOND ambiguity, which the marker structurally cannot see. A
1175
+ // background `Task` sub-agent (the `researcher`/`reviewer` types hold the
1176
+ // full tool set) calls `reply` over THIS bridge, mid-run: the #4172
1177
+ // caller-identity gate sees the MAIN agent identity, and a direct tool call
1178
+ // never traverses `pendingInboundBuffer.push`, so no handback marker is
1179
+ // stamped either. Under the #4173 OPEN window that reply resolves the
1180
+ // flush-ended turn at ANY age, so an unguarded bypass supersedes REGARDLESS
1181
+ // of content and silently edits the sub-agent's message over the user's
1182
+ // flushed answer (the review MAJOR on #4167; pre-existing on main at the
1183
+ // 60 s TTL, extended by #4173 to the OPEN cap). Gateway-observed sub-agent
1184
+ // liveness is the deterministic discriminator: while one is live the content
1185
+ // gate is KEPT, so a reply that IS the flushed answer still collapses and a
1186
+ // sub-agent's foreign content ships FRESH (visible, notifying).
1187
+ const subagentCouldOwnReply = subagentReplyAuthority.subagentCouldOwnReply()
1138
1188
  const replyIsOwnAnswer = decideContentGateBypass({
1139
1189
  tier: ownerTier,
1140
1190
  resolvedTurnId,
1141
1191
  candidates: ownerCandidates,
1142
1192
  handbackCouldOwnReply,
1193
+ subagentCouldOwnReply,
1143
1194
  })
1144
1195
  const decision = flushedTurnSupersede.take(
1145
1196
  chat_id,
@@ -1229,7 +1280,7 @@ export async function sendReply(
1229
1280
  // 2026-07-27 duplicate looked like a pure text-matcher failure and
1230
1281
  // took a log-archive dig to attribute to the tier restriction.
1231
1282
  `tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` +
1232
- `handbackInWindow=${handbackCouldOwnReply}; sending fresh\n`,
1283
+ `handbackInWindow=${handbackCouldOwnReply} subagentLive=${subagentCouldOwnReply}; sending fresh\n`,
1233
1284
  )
1234
1285
  }
1235
1286
  if (suppressByLatch) {
@@ -1248,9 +1299,10 @@ export async function sendReply(
1248
1299
  // content and must deliver. Byte-identical replays of THIS answer are
1249
1300
  // deduped by the content-keyed #546 cache at the top of this function.
1250
1301
  // Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
1251
- // while the latest-ended owner tier's 60 s is anchored at `endedAt` —
1252
- // later by the reply→turn_end gap. A byte-identical replay landing >60 s
1253
- // after record but ≤60 s after endedAt is evicted from dedup yet still
1302
+ // while the latest-ended owner tier's bound is the turn-completion
1303
+ // window (#4173), anchored at the turn's OBSERVED real end later by
1304
+ // the reply→turn_end gap. A byte-identical replay landing >60 s after
1305
+ // record but still inside that window is evicted from dedup yet still
1254
1306
  // resolves this ended turn, so it now DELIVERS as a duplicate message.
1255
1307
  // Conscious trade: a rare duplicate beats the silent handback drop the
1256
1308
  // boolean latch caused (#3426).
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Reply owner-turn resolution WIRING (#2996 anti-inflation extraction; #4173).
3
+ *
4
+ * The pure precedence/acceptance rules live in `../reply-owner-resolve.ts`;
5
+ * gateway.ts holds the stateful lookups (recent-turn registry, quoted-message
6
+ * reverse index). This module is the seam between them: it composes the four
7
+ * lookups into the `ReplyOwnerCandidates` set — INCLUDING the turn-completion
8
+ * window bounds (#4173) — exactly once, so the supersede path, the content-gate
9
+ * bypass, and the tier resolution can never disagree on the candidate set.
10
+ *
11
+ * 2026-07 double-reply-on-DM fix (Part 1): this composition uses the SAME full
12
+ * chain the thread-router uses, so the supersede resolver can never again
13
+ * diverge from routing (the exact bug: supersede omitted the quoted-message and
14
+ * latest-ended recoveries, so a DM late reply — no live turn, no
15
+ * `origin_turn_id` — resolved to a null owner and its flush message was never
16
+ * superseded → duplicate).
17
+ *
18
+ * Precedence (first non-null wins), delegated to the pure
19
+ * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
20
+ * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
21
+ * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
22
+ * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
23
+ * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
24
+ * Returns the turn atom for the winning id (so callers can read its
25
+ * `answerDelivered` latch), or null when every lookup missed.
26
+ */
27
+
28
+ import {
29
+ resolveReplyOwnerTier,
30
+ resolveReplyOwnerTurnId,
31
+ type ReplyOwnerCandidates,
32
+ type ReplyOwnerTier,
33
+ } from '../reply-owner-resolve.js'
34
+ import {
35
+ SUPERSEDE_OPEN_WINDOW_CAP_MS,
36
+ SUPERSEDE_COMPLETED_GRACE_MS,
37
+ } from '../flushed-turn-supersede.js'
38
+
39
+ /**
40
+ * #4173/#4175 — the two turn-completion-window bounds, env-overridable as the
41
+ * operator kill-switch for the window mechanism (clamp the open cap / grace to
42
+ * taste; e.g. `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS=60000` restores something
43
+ * close to the pre-#4166 60 s behaviour). Resolved HERE once — the pure
44
+ * modules stay env-free — and consumed by BOTH window surfaces (the gateway's
45
+ * `FlushedTurnSupersedeRegistry` ctor and the latest-ended owner-tier
46
+ * candidates below) so they can never disagree.
47
+ */
48
+ export const SUPERSEDE_OPEN_CAP_MS = (() => {
49
+ const raw = Number(process.env.SWITCHROOM_SUPERSEDE_OPEN_CAP_MS)
50
+ return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_OPEN_WINDOW_CAP_MS
51
+ })()
52
+ export const SUPERSEDE_GRACE_MS = (() => {
53
+ const raw = Number(process.env.SWITCHROOM_SUPERSEDE_GRACE_MS)
54
+ return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_COMPLETED_GRACE_MS
55
+ })()
56
+
57
+ /** The minimal turn-atom shape this wiring reads. Structural on purpose so the
58
+ * module never imports gateway.ts (no cycle); gateway's `CurrentTurn`
59
+ * satisfies it. */
60
+ export interface OwnerTurnLike {
61
+ turnId: string
62
+ endedAt: number | null
63
+ realEndObservedAt: number | null
64
+ }
65
+
66
+ /**
67
+ * Compose the owner-resolution result from the gateway's four lookups.
68
+ * Generic over the concrete turn atom type so gateway.ts gets its own
69
+ * `CurrentTurn` back without a cast.
70
+ */
71
+ export function resolveReplyOwnerTurnWith<T extends OwnerTurnLike>(
72
+ lookups: {
73
+ findTurnByOriginId(originTurnId: string | null | undefined): T | null
74
+ findTurnByQuotedMessageId(chatId: string, replyTo: unknown): T | null
75
+ findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): T | null
76
+ now(): number
77
+ },
78
+ liveTurn: T | null,
79
+ chatId: string,
80
+ args: Record<string, unknown>,
81
+ ): { turn: T | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
82
+ const origin = lookups.findTurnByOriginId(args.origin_turn_id as string | undefined)
83
+ const quoted = lookups.findTurnByQuotedMessageId(chatId, args.reply_to)
84
+ const latestEnded = lookups.findLatestTurnForChat(chatId, { endedOnly: true })
85
+ const byId = new Map<string, T>()
86
+ // Populate lowest-precedence first so a higher tier's turn wins the id slot
87
+ // when two lookups resolve the same turn (they carry the same turnId anyway).
88
+ for (const t of [latestEnded, quoted, origin, liveTurn]) {
89
+ if (t != null) byId.set(t.turnId, t)
90
+ }
91
+ // F2 — bound the DESTRUCTIVE latest-ended tier to the turn-completion window
92
+ // (#4173) so a stale latest-ended turn can't inherit deletion authority over
93
+ // a newer turn's flush record. #3725: the lookup above is `endedOnly`, so
94
+ // `endedAt` is non-null here and the age is ALWAYS a real number — a
95
+ // not-yet-ended turn is no longer a candidate at all, and an explicit null
96
+ // age fails CLOSED downstream (as does an omitted one, since #4175).
97
+ const latestEndedAgeMs =
98
+ latestEnded?.endedAt != null ? lookups.now() - latestEnded.endedAt : null
99
+ // #4173 — the completion signal: null while the flush-ended turn's REAL
100
+ // turn_end has not been observed (window OPEN — acceptance falls to the
101
+ // crash-backstop cap in `latestEndedTtlMs`); ms-since-real-end once observed
102
+ // (window COMPLETED — only the replay grace remains). For a normally-ended
103
+ // turn `realEndObservedAt === endedAt`, so the tier keeps its tight bound.
104
+ const latestEndedRealEndAgeMs =
105
+ latestEnded == null
106
+ ? null
107
+ : latestEnded.realEndObservedAt != null
108
+ ? lookups.now() - latestEnded.realEndObservedAt
109
+ : null
110
+ const candidates: ReplyOwnerCandidates = {
111
+ liveTurnId: liveTurn?.turnId ?? null,
112
+ originTurnId: origin?.turnId ?? null,
113
+ quotedTurnId: quoted?.turnId ?? null,
114
+ latestEndedTurnId: latestEnded?.turnId ?? null,
115
+ latestEndedAgeMs,
116
+ latestEndedTtlMs: SUPERSEDE_OPEN_CAP_MS,
117
+ latestEndedRealEndAgeMs,
118
+ latestEndedCompletedGraceMs: SUPERSEDE_GRACE_MS,
119
+ }
120
+ // #3429 — the winning tier AND the candidate set it came from travel with the
121
+ // turn. Tier alone no longer decides the content-gate bypass: the
122
+ // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
123
+ // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
124
+ // derive from these SAME candidates, so they can never disagree.
125
+ const tier = resolveReplyOwnerTier(candidates)
126
+ const winnerId = resolveReplyOwnerTurnId(candidates)
127
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
128
+ }
@@ -73,6 +73,8 @@ import { logStreamingEvent } from '../streaming-metrics.js'
73
73
  import { appendActivityLabel } from '../tool-activity-summary.js'
74
74
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
75
75
  import { decideTurnFlush } from '../turn-flush-safety.js'
76
+ import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
77
+ import { subagentReplyAuthority } from './subagent-reply-authority.js'
76
78
  import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
77
79
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
78
80
  import { deriveTurnId } from './derive-turn-id.js'
@@ -222,6 +224,38 @@ function discardParkedTurnStart(rawContent: string): ParkedTurnStart | null {
222
224
  return null
223
225
  }
224
226
 
227
+ /**
228
+ * #4173 — the ONE turn-completion tracker for flush-ended turns (module-scope
229
+ * like `parkedTurnStarts`: one CLI session per gateway process). The
230
+ * quiescence flush ends a turn SYNTHETICALLY while the claude session is still
231
+ * composing; this holds that turn's atom pending until the session's REAL
232
+ * turn_end (or a proxy: a new turn minting, the bridge dying) is observed,
233
+ * then stamps `realEndObservedAt` — the signal that flips the supersede
234
+ * machinery from the unbounded OPEN phase to the ~60 s replay grace. See the
235
+ * `flushed-turn-supersede.ts` module header for the full three-phase model.
236
+ */
237
+ export const flushCompletionTracker = new FlushCompletionTracker()
238
+
239
+ /**
240
+ * #4173 — a turn-completion signal was observed: close every open flush
241
+ * window. Stamps the pending atoms (`flushCompletionTracker`) AND starts the
242
+ * replay grace on the registry's records (`completeAll`) in one call, so the
243
+ * two surfaces (latest-ended owner tier / supersede record) can never observe
244
+ * different phases. Called from the real-turn_end path, the new-turn mint, and
245
+ * the gateway's bridge-death sweep. Returns how many atom windows closed.
246
+ */
247
+ export function closeFlushCompletionWindows(
248
+ // Optional-shaped on purpose: several test harnesses drive `beginTurn` /
249
+ // `handleSessionEvent` with a partial deps object that carries no supersede
250
+ // registry. The production deps builder always injects the real one.
251
+ registry: { completeAll?: (now: number) => void } | null | undefined,
252
+ now: number,
253
+ ): number {
254
+ const closed = flushCompletionTracker.closeAll(now)
255
+ registry?.completeAll?.(now)
256
+ return closed
257
+ }
258
+
225
259
  /** Test seam: the parked store is module-scope (one CLI session, one queue), so
226
260
  * suites that drive `handleSessionEvent` need a deterministic reset. */
227
261
  export function __resetParkedTurnStartsForTest(): void {
@@ -558,6 +592,9 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
558
592
  flushedAnswerText: null,
559
593
  // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
560
594
  endedAt: null,
595
+ // #4173 — the turn-completion signal, stamped at turn end (or later, for
596
+ // a flush-ended turn, when the real turn_end is observed).
597
+ realEndObservedAt: null,
561
598
  firstPingAt: null,
562
599
  // Notification ownership (R8 / PR-2): no slot claimed yet, so the
563
600
  // "claimer was substantive" flag starts false. Set atomically with
@@ -682,6 +719,12 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
682
719
  // already the single stop-owner for ALL turns, and a start is
683
720
  // self-healing anyway, so this cannot leak an interval.
684
721
  startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
722
+ // #4173 — a new turn minting on the serial claude session is a completion
723
+ // PROXY for any still-open flush window: the session has moved on, so a
724
+ // prior flushed turn's own late reply can now only arrive as a bounded
725
+ // replay (the grace). Closing here is the SAFE direction — a window closed
726
+ // too early costs at most a visible duplicate, never an edit-over.
727
+ closeFlushCompletionWindows(deps.flushedTurnSupersede, Date.now())
685
728
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
686
729
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
687
730
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -916,6 +959,16 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
916
959
  const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
917
960
  idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
918
961
  }
962
+ // #4176 — sub-agent liveness, fed from the SAME whole-stream position as the
963
+ // idle clock above and for the same reason: `sub_agent_*` events arrive with
964
+ // or without a live gateway turn, and the reply send path must know whether a
965
+ // background sub-agent could be the author of a decoupled reply (a sub-agent
966
+ // calls `reply` over the parent's OWN bridge, so neither the caller-identity
967
+ // gate nor the handback marker can see it). Deliberately BEFORE the switch:
968
+ // the `sub_agent_tool_use` case below early-returns when `getCurrentTurn()` is
969
+ // null, which is exactly the post-flush state this gate exists for. See
970
+ // gateway/subagent-reply-authority.ts.
971
+ subagentReplyAuthority.noteSessionEvent(ev as { kind: string; agentId?: string })
919
972
  switch (ev.kind) {
920
973
  case 'enqueue': {
921
974
  // #3927 FIX A — an `enqueue` is a QUEUE event, not a turn START event
@@ -1724,6 +1777,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1724
1777
  return
1725
1778
  }
1726
1779
  }
1780
+ // #4173 — every turn_end that PROCEEDS (a real turn_end always; a
1781
+ // synthetic one only past the suppression guard above) is a completion
1782
+ // signal for any flush window still open from an EARLIER turn: the
1783
+ // serial session has reached a stop, so that turn's own late reply can
1784
+ // now only arrive as a bounded replay. Close them BEFORE this event's
1785
+ // own processing — the quiescence-flush branch below may OPEN a new
1786
+ // window for THIS turn right after. The common case this serves: the
1787
+ // flush's synthetic turn_end tore the atom down, so when the REAL
1788
+ // turn_end for that same turn lands here it finds no live atom and
1789
+ // otherwise no-ops — this close is how that real turn_end is observed.
1790
+ closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
1727
1791
  // #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
1728
1792
  // BEFORE the body runs (the body re-reads currentTurn as `turn`, then
1729
1793
  // nulls it via endCurrentTurnAtomic on every clean branch). The guarded
@@ -2244,6 +2308,22 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2244
2308
  // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
2245
2309
  // atom was already torn down elsewhere (no record to emit).
2246
2310
  const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
2311
+ // #4173 — the ANSWER-READY QUIESCENCE variant of this branch ends the
2312
+ // turn SYNTHETICALLY (durationMs === -1): the claude session is still
2313
+ // composing and its REAL turn_end arrives later. Re-open the atom's
2314
+ // completion window (endCurrentTurnAtomic just stamped
2315
+ // `realEndObservedAt` with the default "ended = really ended" rule) and
2316
+ // hold it pending, so the supersede window for this flush stays
2317
+ // claimable until the real turn_end — or a proxy — is observed. This is
2318
+ // what makes a same-session reply landing after a 20-minute /compact
2319
+ // still collapse onto the flushed message (#4166), without holding the
2320
+ // window open for even a second once the session actually stops.
2321
+ // The TURN-END BACKSTOP variant (durationMs >= 0) fires ON the real
2322
+ // turn_end: the session already stopped, so its window is born
2323
+ // completed (grace-bounded) — it must NOT be re-opened.
2324
+ if (ev.durationMs === -1) {
2325
+ flushCompletionTracker.open(turn)
2326
+ }
2247
2327
  // #549 fix — turn-flush takes ownership of the captured-text
2248
2328
  // backup; reset the preamble buffer (its content is already in
2249
2329
  // the captured `capturedText`, which turn-flush is about to send).
@@ -2446,7 +2526,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2446
2526
  flushedTurnSupersede.record(
2447
2527
  backstopChatId,
2448
2528
  backstopThreadId,
2449
- { turnId: turn.turnId, messageIds: sentIds, text: capturedText },
2529
+ // #4173 inherit the atom's completion state: the async send
2530
+ // above can outlast the real turn_end (a close that ran while
2531
+ // we awaited stamped `realEndObservedAt`), and a window closed
2532
+ // before its record existed must be born completed
2533
+ // (grace-bounded), never resurrected as open.
2534
+ {
2535
+ turnId: turn.turnId,
2536
+ messageIds: sentIds,
2537
+ text: capturedText,
2538
+ completedAt: turn.realEndObservedAt,
2539
+ },
2450
2540
  Date.now(),
2451
2541
  )
2452
2542
  }
@@ -18,9 +18,9 @@
18
18
  * #1177/#1182/#1201 double-sent).
19
19
  * - CASE B — a background handback attributed to that ended turn. A
20
20
  * `subagent_handback` WAS enqueued after the turn ended and within the
21
- * supersede TTL, so the late reply might BE it → keep the #3429 content gate
22
- * and send fresh (two messages), never silently edit/delete the flushed
23
- * answer.
21
+ * handback recency window (`HANDBACK_RECENCY_WINDOW_MS` below, #4174), so
22
+ * the late reply might BE it keep the #3429 content gate and send fresh
23
+ * (two messages), never silently edit/delete the flushed answer.
24
24
  *
25
25
  * ## Why thread-keyed (F2, dup-audit 2026-07-21)
26
26
  *
@@ -65,12 +65,30 @@
65
65
  * reply with NO live gateway turn of its own (a background worker completion),
66
66
  * so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
67
67
  * tier. It is the F1 vector.
68
- * - Every OTHER synthesized source (cron, resume_*, reaction, vault_*, …) lands
69
- * as its OWN live inbound turn, so its reply resolves the `live` tier for its
70
- * own turnId and structurally cannot supersede a different ended turn's flush
71
- * record (`decideSupersede` requires `record.turnId === liveTurnId`). Those
72
- * must NOT stamp — stamping them would needlessly hold the content gate open
73
- * for an unrelated own-reply in the window (a safe but avoidable visible dup).
68
+ * - Every OTHER synthesized source that traverses THIS chokepoint (resume_*,
69
+ * reaction, vault_*, Tier-2 cron, …) lands as its OWN live inbound turn, so
70
+ * its reply resolves the `live` tier for its own turnId and structurally
71
+ * cannot supersede a different ended turn's flush record (`decideSupersede`
72
+ * requires `record.turnId === liveTurnId`). Those must NOT stamp — stamping
73
+ * them would needlessly hold the content gate open for an unrelated
74
+ * own-reply in the window (a safe but avoidable visible dup).
75
+ *
76
+ * ## What this chokepoint CANNOT see (#4172 — the caller-identity gate)
77
+ *
78
+ * The invariant above only covers sources delivered through
79
+ * `pendingInboundBuffer.push`. A Tier-1 CHEAP-CRON fire is delivered via
80
+ * `sendToAgent` to the derived `<agent>-cron` bridge and never traverses the
81
+ * buffer — AND its session events are dropped for the cron identity in
82
+ * `onSessionEvent`, so it never mints a live gateway turn either. Its `reply`
83
+ * therefore arrives decoupled (turn == null) with foreign content and NO
84
+ * marker — exactly the shape marker-absence was claimed to disprove. The same
85
+ * holds for any reply arriving on a connection that is not the main agent
86
+ * bridge. That class is closed at a DIFFERENT chokepoint: the reply send path
87
+ * (`outbound-send-path.ts`) refuses supersede/bypass/latch authority to any
88
+ * caller that is not the main agent bridge (`replyCallerIsForeignSession`,
89
+ * cron-session.ts) — identity, not marker stamping, is the deterministic
90
+ * signal there. Setting `cron: { decoupledCompletion: true }` below would NOT
91
+ * work: a Tier-1 fire never reaches this registry's write site.
74
92
  *
75
93
  * This predicate is therefore the SINGLE point of extension: any future feature
76
94
  * that synthesizes an inbound which can land as a DECOUPLED late reply (no live
@@ -114,6 +132,12 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
114
132
  // Everything below lands as its OWN live inbound turn (live tier), so its reply
115
133
  // resolves the live tier for its own turnId and structurally cannot supersede a
116
134
  // different ended turn's record → not a decoupled-completion vector, must not stamp.
135
+ //
136
+ // cron: `false` covers the TIER-2 (main-bridge, `context:'agent'`) fire, which
137
+ // does land as its own live turn through this chokepoint. The TIER-1 cheap-cron
138
+ // fire never traverses this chokepoint AT ALL (see "What this chokepoint
139
+ // CANNOT see" above) — it is closed by the caller-identity gate in the reply
140
+ // send path (#4172), and flipping this to `true` would not reach it.
117
141
  cron: { decoupledCompletion: false },
118
142
  reaction: { decoupledCompletion: false },
119
143
  subagent_progress: { decoupledCompletion: false },
@@ -168,6 +192,22 @@ export function stampsHandbackMarker(source: string | null | undefined): boolean
168
192
  return known.decoupledCompletion
169
193
  }
170
194
 
195
+ /**
196
+ * #4174 — how long after a `subagent_handback` enqueue the content gate stays
197
+ * held for late replies attributed to an ended turn (`handbackCouldOwnReply`,
198
+ * outbound-send-path.ts). This deliberately does NOT follow the supersede
199
+ * window's bounds (#4173): the gate-hold is CHAT-WIDE (the latest-ended owner
200
+ * tier is chat-wide, so the read must be too — MUST-FIX 2 above), which means
201
+ * every in-window handback re-opens the reworded-own-answer visible-duplicate
202
+ * class for EVERY topic in the chat for the window's length. 60 s — the
203
+ * originally shipped bound — covers the enqueue→decoupled-reply gap the marker
204
+ * exists for, while keeping the chat-wide dup exposure per handback small. In
205
+ * a delegation-heavy agent, tying this to a minutes-long supersede bound would
206
+ * hold the gate almost continuously — re-opening the exact duplicate class the
207
+ * supersede machinery closes.
208
+ */
209
+ export const HANDBACK_RECENCY_WINDOW_MS = 60_000
210
+
171
211
  /** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
172
212
  const MAIN_THREAD_KEY = '<main>'
173
213
 
@@ -0,0 +1,147 @@
1
+ /**
2
+ * #4176 — sub-agent reply authority: the discriminator the "serial session"
3
+ * premise was missing.
4
+ *
5
+ * ## The premise that was false
6
+ *
7
+ * The turn-completion window (#4173, `flushed-turn-supersede.ts`) rests on this
8
+ * claim: a `reply` landing on the MAIN agent bridge between a flush's SYNTHETIC
9
+ * turn_end and the session's REAL turn_end is, by construction, that turn's own
10
+ * answer still being composed. The stated justification was that no other work
11
+ * can run on the serial claude session in that span.
12
+ *
13
+ * That is true of the session's OWN loop and false of its sub-agents. A
14
+ * background `Task` sub-agent runs CONCURRENTLY with the parent loop, and it
15
+ * calls MCP tools through the SAME plugin process and therefore the SAME IPC
16
+ * bridge — so `client.agentName` is the main agent and the #4172 caller-identity
17
+ * gate (`replyCallerIsForeignSession`) does not see it. In this fleet the
18
+ * `researcher` and `reviewer` sub-agent types hold the full tool set, `reply`
19
+ * included (`worker` holds only `progress_update`).
20
+ *
21
+ * The concrete loss that made this a MAJOR (review of #4167): the quiescence
22
+ * flush delivers the answer as message A and the window is OPEN; a background
23
+ * sub-agent calls `reply` into the same chat; owner resolution lands on the
24
+ * flush-ended turn (OPEN is accepted at ANY age up to the crash cap); no
25
+ * `subagent_handback` marker exists because a DIRECT tool call never traverses
26
+ * `pendingInboundBuffer.push`; `decideContentGateBypass` therefore grants
27
+ * `positiveAttribution`, and `decideSupersede` supersedes REGARDLESS of content
28
+ * — the sub-agent's message silently EDITS OVER the user's flushed answer
29
+ * (Telegram edits do not re-notify: both messages are lost client-side). The
30
+ * class pre-exists on `main` bounded by the old 60 s TTL; #4173 extends the tail
31
+ * to the OPEN-phase cap, which is why it is closed here rather than narrowed.
32
+ *
33
+ * ## The discriminator: gateway-observed sub-agent LIVENESS
34
+ *
35
+ * The gateway already receives the whole session-event stream, including every
36
+ * `sub_agent_*` kind, at `onSessionEvent` → `handleSessionEvent`. Those events
37
+ * are derived by the framework from the sidechain transcript
38
+ * (`projectSubagentLine`, session-tail.ts) — never from model discipline, never
39
+ * from anything a reply can steer.
40
+ *
41
+ * A sub-agent is tracked LIVE from any `sub_agent_*` event carrying its
42
+ * `agentId` until its `sub_agent_turn_end`. While ANY sub-agent is live, a
43
+ * decoupled reply on the main bridge MIGHT be that sub-agent's, so it is denied
44
+ * the #3429 content-gate bypass (`decideContentGateBypass`) — the reply must
45
+ * then BE the flushed answer to supersede it. A sub-agent's own (foreign)
46
+ * content therefore decides `'new-content'` and ships as a fresh, notifying
47
+ * message, with the flush record left intact for the turn's genuine replay.
48
+ *
49
+ * ### Why liveness and not per-reply correlation
50
+ *
51
+ * Correlating the landing MCP call with the `sub_agent_tool_use` event for that
52
+ * same `reply` would be exact, but the two arrive on independent transports (an
53
+ * MCP stdio round-trip vs. a JSONL tail), so the event can land AFTER the reply
54
+ * it describes — a race whose losing side is the silent edit-over. Liveness has
55
+ * the ordering the correlation lacks: a sub-agent cannot call a tool before it
56
+ * exists, and `sub_agent_started` is projected from its FIRST transcript line,
57
+ * which precedes any tool call it makes by a full model round-trip. The gate is
58
+ * therefore armed by the time the reply can exist — a structural ordering
59
+ * argument, not a tuned delay.
60
+ *
61
+ * ### Failure directions (all fail SAFE)
62
+ *
63
+ * - A sub-agent whose `sub_agent_turn_end` is never observed (capped, killed,
64
+ * crashed tail) stays live: the content gate holds, so the worst case is a
65
+ * REWORDED own-answer shipping as a visible duplicate. Never an edit-over.
66
+ * `reset()` on bridge death bounds it — the sub-agents die with the session.
67
+ * - A sub-agent steered after its `turn_end` (switchroom's `SendMessage`
68
+ * pattern) re-arms on its next `sub_agent_*` event, which again precedes any
69
+ * reply it makes by a model round-trip.
70
+ * - No sub-agent live ⇒ the premise HOLDS and the #4166 collapse is untouched;
71
+ * this gate costs nothing in the non-delegating case.
72
+ *
73
+ * ### Residual, stated honestly
74
+ *
75
+ * Denying the BYPASS (rather than supersede authority wholesale) is deliberate:
76
+ * with the gate on, a reply still supersedes when it IS the flushed answer
77
+ * (`flushedAnswerMatchesReply` — equality, or containment above
78
+ * `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS`). Superseding on equality is
79
+ * content-preserving by definition. The residual is the containment arm: a
80
+ * sub-agent reply that is a ≥32-char verbatim substring of the flushed answer
81
+ * would still collapse into it. Denying supersede outright instead would, in a
82
+ * delegation-heavy agent where a background worker is nearly always live,
83
+ * re-open #4166 for every own-answer replay — a far larger, constant cost for a
84
+ * class that requires a sub-agent to emit a verbatim substring of the parent's
85
+ * answer.
86
+ *
87
+ * Pure: a set of ids, no clock reads, no I/O. The gateway wires the feed
88
+ * (`onSessionEvent`) and the read (the `sendReply` deps).
89
+ */
90
+
91
+ /** The read surface the reply send path consumes (injected, so the golden
92
+ * harness drives it without touching the singleton). */
93
+ export interface SubagentReplyAuthorityView {
94
+ /** True when at least one sub-agent is live on this session and could
95
+ * therefore be the author of a decoupled reply landing right now. */
96
+ subagentCouldOwnReply(): boolean
97
+ }
98
+
99
+ /** The minimal session-event shape this tracker reads. Structural so the module
100
+ * never imports the session-tail union (no cycle, trivially fake-able). */
101
+ export interface SubagentLifecycleEvent {
102
+ kind: string
103
+ agentId?: string
104
+ }
105
+
106
+ const SUB_AGENT_KIND_PREFIX = 'sub_agent_'
107
+
108
+ export class SubagentReplyAuthority implements SubagentReplyAuthorityView {
109
+ private readonly live = new Set<string>()
110
+
111
+ /** Feed EVERY session event here. Non-`sub_agent_*` kinds are ignored, so the
112
+ * call site needs no filtering (and cannot drift from this one). */
113
+ noteSessionEvent(ev: SubagentLifecycleEvent | null | undefined): void {
114
+ if (ev == null) return
115
+ const kind = ev.kind
116
+ if (typeof kind !== 'string' || !kind.startsWith(SUB_AGENT_KIND_PREFIX)) return
117
+ const agentId = ev.agentId
118
+ // An id-less sub-agent event cannot be attributed to a lifecycle; ignoring
119
+ // it is the safe direction for `turn_end` (liveness persists → gate holds)
120
+ // and merely a missed arm otherwise — every other kind for a real sub-agent
121
+ // carries the id.
122
+ if (typeof agentId !== 'string' || agentId === '') return
123
+ if (kind === `${SUB_AGENT_KIND_PREFIX}turn_end`) this.live.delete(agentId)
124
+ else this.live.add(agentId)
125
+ }
126
+
127
+ subagentCouldOwnReply(): boolean {
128
+ return this.live.size > 0
129
+ }
130
+
131
+ /** The bridge died: the claude session and every sub-agent under it are gone.
132
+ * Bounds a leaked liveness entry (see "Failure directions"). */
133
+ reset(): void {
134
+ this.live.clear()
135
+ }
136
+
137
+ /** Test/diagnostics: how many sub-agents are currently tracked live. */
138
+ liveCount(): number {
139
+ return this.live.size
140
+ }
141
+ }
142
+
143
+ /** The ONE live instance (one CLI session per gateway process — the same
144
+ * module-singleton shape as `flushCompletionTracker` in stream-render.ts).
145
+ * Re-`new`ing this anywhere else splits the signal and silently re-opens the
146
+ * edit-over hole. */
147
+ export const subagentReplyAuthority = new SubagentReplyAuthority()