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
@@ -26,7 +26,9 @@
26
26
  *
27
27
  * NOT fully deterministic — a residual race remains. The flush→reply direction
28
28
  * this registry covers, plus the controller's fire-time recount for the
29
- * reply→flush direction, close the COMMON ~10 s replay-gap case; but if a reply
29
+ * reply→flush direction, close the COMMON replay-gap case (~10 s for a
30
+ * tool-call replay; unbounded for the Stop-hook-nudged recompose after a
31
+ * proactive /compact — see the turn-completion window below, #4173); but if a reply
30
32
  * and a flush interleave so the reply's `take()` runs BEFORE the flush's
31
33
  * `record()` (a much smaller window), `take` finds no record and the duplicate
32
34
  * can still slip through. We deliberately trade that residual window for the
@@ -46,10 +48,85 @@
46
48
  * `now`. Fully unit-testable; the gateway wires the actual delete/send.
47
49
  */
48
50
 
49
- /** TTL after which a recorded flush is forgotten. 60 s comfortably spans the
50
- * observed ~10 s flush→reply replay gap with margin, and matches the outbound
51
- * dedup window so the two mechanisms age out together. */
52
- export const DEFAULT_SUPERSEDE_TTL_MS = 60_000
51
+ /**
52
+ * ## The supersede window is a TURN-COMPLETION SIGNAL, not a fixed TTL (#4173)
53
+ *
54
+ * The window in which a landing reply may claim a flushed turn used to be a
55
+ * single constant (`DEFAULT_SUPERSEDE_TTL_MS = 60_000`). That constant was
56
+ * sized for the ~10 s tool-replay gap and missed the SLOW variant of the exact
57
+ * class this module exists for (2026-08-01, klanker DM, msgs 25680/25682): the
58
+ * quiescence flush delivered the composed answer, the Stop hook told the model
59
+ * its prose never reached the user, and the model recomposed and called
60
+ * `reply` — but a proactive `/compact` ran in between (occupancy ~393 k), so
61
+ * the canonical reply landed **2 m 24 s** after the flush. Every layer keyed
62
+ * on the 60 s TTL had aged out and the reply shipped as a second bubble
63
+ * (#4166). Widening the constant (the first attempt: 5 min) merely moves the
64
+ * cliff — a longer compaction reproduces the duplicate — while ALSO holding
65
+ * the destructive window open for foreign late repliers (#4172).
66
+ *
67
+ * The framework already holds the right signal. The flush fires a
68
+ * SYNTHETIC turn_end (it nulls the gateway's turn atom so the session can
69
+ * move on), but the claude session's REAL turn_end arrives later, when the
70
+ * model actually stops. A `reply` landing on the MAIN session bridge BETWEEN
71
+ * those two events is that turn's own answer still being composed — PROVIDED
72
+ * nothing else on that bridge can emit a reply in the span. Two things can, and
73
+ * neither is covered by this window; each is closed by its own deterministic
74
+ * gate, and the window is only sound in combination with them:
75
+ *
76
+ * - a FOREIGN session (a Tier-1 cheap-cron bridge, an unregistered client) —
77
+ * closed by caller identity at the send path (`replyCallerIsForeignSession`,
78
+ * gateway/cron-session.ts, #4172);
79
+ * - a background `Task` SUB-AGENT, which runs concurrently with the parent
80
+ * loop and calls `reply` over the SAME main bridge, so identity cannot see
81
+ * it — closed by gateway-observed sub-agent liveness refusing the
82
+ * content-gate bypass (gateway/subagent-reply-authority.ts, #4176). The
83
+ * claude session's own loop is serial; its sub-agents are not, and the
84
+ * earlier revision of this header asserted the stronger, false claim that
85
+ * "no other work can run on the serial session in that span".
86
+ *
87
+ * So the record's lifetime is now three-phase:
88
+ *
89
+ * 1. OPEN (`completedAt == null`) — the flush delivered, the real turn_end
90
+ * has not been observed yet. The turn is still composing (Stop-hook
91
+ * nudge, /compact, recompose all live here). A same-session reply
92
+ * supersedes REGARDLESS of how long this takes — a 20-minute compaction
93
+ * no longer reproduces #4166. Bounded only by the
94
+ * `SUPERSEDE_OPEN_WINDOW_CAP_MS` crash backstop below, and — for a reply
95
+ * the two gates above cannot positively attribute to the session's own
96
+ * loop — by the #3429 content gate, which this phase does NOT relax.
97
+ * 2. COMPLETED (`completedAt` set) — the real turn_end (or a proxy for it:
98
+ * a new turn minting, or the bridge dying) was observed. The turn's own
99
+ * answer either already landed or can only arrive as a bounded REPLAY of
100
+ * an un-acked tool_call (~10 s after a bridge reconnect), so the record
101
+ * stays claimable for `SUPERSEDE_COMPLETED_GRACE_MS` and then expires.
102
+ * 3. EXPIRED — no supersede; a genuinely late duplicate ships as a second
103
+ * visible bubble (safe direction — never an edit-over).
104
+ *
105
+ * In the COMMON case (real turn_end lands seconds after the flush) this is
106
+ * STRICTLY NARROWER than any widened TTL — the destructive window closes ~60 s
107
+ * after the session actually stopped — while being unbounded-correct in the
108
+ * slow case. The `FlushCompletionTracker` below carries the same signal onto
109
+ * the ended TURN ATOM (`realEndObservedAt`) so the latest-ended owner tier
110
+ * (`reply-owner-resolve.ts`) applies the identical three-phase rule.
111
+ */
112
+
113
+ /** Crash backstop for an OPEN record: if the real turn_end signal is LOST
114
+ * (every loss path — bridge death, new-turn mint, gateway restart dropping
115
+ * the in-memory registry — normally closes the window explicitly), an open
116
+ * record is forgotten after this long. Generous by design: it exists only
117
+ * for signal loss, and the failure past it is a visible duplicate, never a
118
+ * silent edit-over. Operator override: `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS`
119
+ * (resolved in gateway.ts — this module stays env-free/pure). */
120
+ export const SUPERSEDE_OPEN_WINDOW_CAP_MS = 30 * 60_000
121
+
122
+ /** How long a COMPLETED record stays claimable after the real turn_end was
123
+ * observed. Sized for the bounded replay class the original 60 s TTL was
124
+ * built for: claude-code re-sending an un-acked `reply` tool_call after a
125
+ * bridge reconnect (~10 s), with margin. The exact-text #546 dedup keeps its
126
+ * own 60 s window (`recent-outbound-dedup.ts`) — byte-identical replays, a
127
+ * different class. Operator override: `SWITCHROOM_SUPERSEDE_GRACE_MS`
128
+ * (resolved in gateway.ts). */
129
+ export const SUPERSEDE_COMPLETED_GRACE_MS = 60_000
53
130
 
54
131
  export interface FlushedTurnRecord {
55
132
  /** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
@@ -68,6 +145,13 @@ export interface FlushedTurnRecord {
68
145
  text: string
69
146
  /** Wall-clock ms when recorded. */
70
147
  ts: number
148
+ /** #4173 — wall-clock ms the flushed turn's REAL turn_end (or a proxy: new
149
+ * turn mint / bridge death) was observed, or null while the session is
150
+ * still composing (the OPEN phase — see the module header). Null records
151
+ * are claimable indefinitely up to the `openCapMs` crash backstop; once
152
+ * set, the record expires `completedGraceMs` later (the bounded
153
+ * tool-call-replay window). */
154
+ completedAt: number | null
71
155
  }
72
156
 
73
157
  /**
@@ -186,12 +270,24 @@ export function decideSupersede(
186
270
  replyText?: string | null
187
271
  positiveAttribution?: boolean
188
272
  now: number
189
- ttlMs?: number
273
+ /** OPEN-phase crash backstop (see module header). */
274
+ openCapMs?: number
275
+ /** COMPLETED-phase replay grace (see module header). */
276
+ completedGraceMs?: number
190
277
  },
191
278
  ): SupersedeDecision {
192
- const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
279
+ const openCapMs = args.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
280
+ const completedGraceMs = args.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
193
281
  if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
194
- if (args.now - record.ts > ttlMs) {
282
+ // #4173 three-phase freshness (module header): an OPEN record (real turn_end
283
+ // not yet observed — the session is still composing this turn's answer) is
284
+ // claimable up to the crash-backstop cap; a COMPLETED record only for the
285
+ // bounded tool-call-replay grace after the observed completion.
286
+ const expired =
287
+ record.completedAt == null
288
+ ? args.now - record.ts > openCapMs
289
+ : args.now - record.completedAt > completedGraceMs
290
+ if (expired) {
195
291
  return { supersede: false, deleteMessageIds: [], reason: 'expired' }
196
292
  }
197
293
  const sameTurn =
@@ -312,20 +408,26 @@ function turnKey(turnId: string | null): string {
312
408
  */
313
409
  export class FlushedTurnSupersedeRegistry {
314
410
  private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
315
- private readonly ttlMs: number
411
+ private readonly openCapMs: number
412
+ private readonly completedGraceMs: number
316
413
 
317
- constructor(opts: { ttlMs?: number } = {}) {
318
- this.ttlMs = opts.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
414
+ constructor(opts: { openCapMs?: number; completedGraceMs?: number } = {}) {
415
+ this.openCapMs = opts.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
416
+ this.completedGraceMs = opts.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
319
417
  }
320
418
 
321
419
  /** Record a flush's posted message(s) so a later same-turn reply supersedes
322
420
  * them, keyed on the flush's `turnId` within the chat/thread lane. No record
323
421
  * is kept when the flush posted zero messages. Sweeps expired records first
324
- * (lightweight active GC — LOW-2) so orphan records never accumulate. */
422
+ * (lightweight active GC — LOW-2) so orphan records never accumulate.
423
+ * `completedAt` (#4173): the flush's async send can outlast the real
424
+ * turn_end, so the record site passes the turn atom's `realEndObservedAt` —
425
+ * a window that was closed BEFORE the record existed is born completed
426
+ * (grace-bounded), never resurrected as open. */
325
427
  record(
326
428
  chatId: string,
327
429
  threadId: number | undefined,
328
- rec: { turnId: string | null; messageIds: number[]; text: string },
430
+ rec: { turnId: string | null; messageIds: number[]; text: string; completedAt?: number | null },
329
431
  now: number,
330
432
  ): void {
331
433
  if (rec.messageIds.length === 0) return
@@ -341,9 +443,23 @@ export class FlushedTurnSupersedeRegistry {
341
443
  messageIds: [...rec.messageIds],
342
444
  text: rec.text,
343
445
  ts: now,
446
+ completedAt: rec.completedAt ?? null,
344
447
  })
345
448
  }
346
449
 
450
+ /** #4173 — the real-turn-end signal (or a proxy: new turn mint / bridge
451
+ * death) was observed. Close every OPEN record's window: from `now` each
452
+ * has `completedGraceMs` (the bounded replay window) left to be claimed by
453
+ * its own late replay, then expires. Records already completed keep their
454
+ * original (earlier) completion — the signal only ever narrows. */
455
+ completeAll(now: number): void {
456
+ for (const laneMap of this.entries.values()) {
457
+ for (const rec of laneMap.values()) {
458
+ if (rec.completedAt == null) rec.completedAt = now
459
+ }
460
+ }
461
+ }
462
+
347
463
  /** Decide supersede for a landing reply WITHOUT consuming the record. Selects
348
464
  * the record whose turnId matches the reply's resolved `liveTurnId` (or the
349
465
  * null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
@@ -364,7 +480,8 @@ export class FlushedTurnSupersedeRegistry {
364
480
  replyText: args.replyText,
365
481
  positiveAttribution: args.positiveAttribution,
366
482
  now: args.now,
367
- ttlMs: this.ttlMs,
483
+ openCapMs: this.openCapMs,
484
+ completedGraceMs: this.completedGraceMs,
368
485
  })
369
486
  }
370
487
 
@@ -404,11 +521,16 @@ export class FlushedTurnSupersedeRegistry {
404
521
  this.entries.clear()
405
522
  }
406
523
 
407
- /** Evict every record past its TTL and prune emptied lanes. */
524
+ /** Evict every record past its window (three-phase rule, module header) and
525
+ * prune emptied lanes. */
408
526
  private sweep(now: number): void {
409
527
  for (const [lane, laneMap] of this.entries) {
410
528
  for (const [tk, rec] of laneMap) {
411
- if (now - rec.ts > this.ttlMs) laneMap.delete(tk)
529
+ const expired =
530
+ rec.completedAt == null
531
+ ? now - rec.ts > this.openCapMs
532
+ : now - rec.completedAt > this.completedGraceMs
533
+ if (expired) laneMap.delete(tk)
412
534
  }
413
535
  if (laneMap.size === 0) this.entries.delete(lane)
414
536
  }
@@ -426,3 +548,55 @@ export class FlushedTurnSupersedeRegistry {
426
548
  function makeKey(chatId: string, threadId: number | undefined): string {
427
549
  return threadId == null ? chatId : `${chatId}|${threadId}`
428
550
  }
551
+
552
+ /**
553
+ * #4173 — carries the turn-completion signal onto the flushed TURN ATOMS, the
554
+ * registry's sibling surface. The registry above bounds the RECORD (message
555
+ * ids); the latest-ended owner tier (`reply-owner-resolve.ts`) is bounded by
556
+ * the TURN's `realEndObservedAt`, and this tracker is what stamps it:
557
+ *
558
+ * - `open(turn)` at flush-fire time (stream-render's turn-flush branch, the
559
+ * one path that ends a turn SYNTHETICALLY before the session stopped):
560
+ * nulls `realEndObservedAt` and holds the atom pending.
561
+ * - `closeAll(now)` when the real turn_end signal (or a proxy: a new turn
562
+ * minting on the session, the bridge dying) is observed: stamps every
563
+ * pending atom's `realEndObservedAt = now` and releases it. Every other
564
+ * turn-end path stamps `realEndObservedAt` directly in
565
+ * `endCurrentTurnAtomic` (turn-end.ts) — for a normally-ended turn the
566
+ * real end IS its end.
567
+ *
568
+ * Serial-session note: claude processes one turn at a time, so pending atoms
569
+ * are naturally few (normally exactly one); `closeAll` closing every pending
570
+ * window on any completion signal is deliberate — closing early is the SAFE
571
+ * direction (a too-short window costs a visible duplicate, never an
572
+ * edit-over). Pure: no clock reads beyond the caller-supplied `now`, no I/O.
573
+ */
574
+ export class FlushCompletionTracker {
575
+ private pending: Array<{ realEndObservedAt: number | null }> = []
576
+
577
+ /** Hold `turn` pending until the real-turn-end signal; marks its window
578
+ * open (`realEndObservedAt = null`). */
579
+ open(turn: { realEndObservedAt: number | null }): void {
580
+ turn.realEndObservedAt = null
581
+ if (!this.pending.includes(turn)) this.pending.push(turn)
582
+ }
583
+
584
+ /** The real-turn-end signal (or a proxy) was observed: stamp every pending
585
+ * atom and release it. Returns how many windows were closed. */
586
+ closeAll(now: number): number {
587
+ let closed = 0
588
+ for (const turn of this.pending) {
589
+ if (turn.realEndObservedAt == null) {
590
+ turn.realEndObservedAt = now
591
+ closed++
592
+ }
593
+ }
594
+ this.pending = []
595
+ return closed
596
+ }
597
+
598
+ /** Test/diagnostics: number of atoms still awaiting their real turn_end. */
599
+ pendingCount(): number {
600
+ return this.pending.length
601
+ }
602
+ }
@@ -35,6 +35,37 @@ export function baseAgent(name: string): string {
35
35
  return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
36
36
  }
37
37
 
38
+ /**
39
+ * #4172 — is a `reply` tool call arriving on this IPC client identity a
40
+ * FOREIGN-SESSION reply, i.e. one that can NEVER be the main claude session's
41
+ * own (possibly late/reworded) answer to a turn the gateway attributed?
42
+ *
43
+ * The whole flushed-turn supersede/bypass/latch machinery
44
+ * (`outbound-send-path.ts`) rests on the premise that a decoupled late reply
45
+ * resolving an ended turn is that turn's own answer unless a handback marker
46
+ * says otherwise. That premise only holds for replies from the MAIN agent
47
+ * bridge — the one whose session the turns belong to. A Tier-1 cheap-cron
48
+ * fire's reply arrives on the derived `<agent>-cron` bridge: it never mints a
49
+ * gateway turn (its session events are dropped in `onSessionEvent`), never
50
+ * traverses `pendingInboundBuffer.push` (so it cannot stamp the handback
51
+ * marker), and lands decoupled with foreign content — the measured #4172
52
+ * failure was such a reply EDITING OVER a flushed answer (double loss: the
53
+ * flushed answer destroyed, the cron digest delivered as a silent edit).
54
+ * Identity is the one deterministic signal that separates it.
55
+ *
56
+ * Returns true (foreign — refuse supersede/bypass/latch authority, always
57
+ * send fresh) for:
58
+ * - a cron-session identity (`<agent>-cron`);
59
+ * - a null/absent identity (an unregistered client — it owns no session,
60
+ * so fail SAFE: a visible fresh message, never destructive authority).
61
+ * Returns false only for a registered non-cron agent bridge — the main
62
+ * session.
63
+ */
64
+ export function replyCallerIsForeignSession(name: string | null | undefined): boolean {
65
+ if (name == null || name === "") return true;
66
+ return isCronIdentity(name);
67
+ }
68
+
38
69
  /**
39
70
  * True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
40
71
  * (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
@@ -85,7 +85,9 @@ import {
85
85
  type TelegraphAccount,
86
86
  } from '../telegraph.js'
87
87
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
88
- import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
88
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
89
+ import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
90
+ import { subagentReplyAuthority } from './subagent-reply-authority.js'
89
91
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
90
92
  import {
91
93
  splitCoalescedAttachments,
@@ -455,7 +457,7 @@ import {
455
457
  type SendReplyGatewayDeps,
456
458
  type DeliverCapturedProseDeps,
457
459
  } from './outbound-send-path.js'
458
- import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
460
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
459
461
  import { createNarrativeLane } from './narrative-lane.js'
460
462
  import {
461
463
  parseAgentCallback,
@@ -488,8 +490,6 @@ import {
488
490
  FLUSH_SUBSTANTIVE_MIN_CHARS,
489
491
  } from '../turn-flush-safety.js'
490
492
  import {
491
- resolveReplyOwnerTurnId,
492
- resolveReplyOwnerTier,
493
493
  type ReplyOwnerTier, type ReplyOwnerCandidates,
494
494
  type AnswerDeliveredLatch,
495
495
  } from '../reply-owner-resolve.js'
@@ -657,7 +657,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
657
657
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
658
658
  import { buildDiffPreviewCard } from './diff-preview-card.js'
659
659
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
660
- import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
660
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
661
661
  import {
662
662
  ObligationLedger,
663
663
  obligationEscalationText,
@@ -2377,7 +2377,12 @@ const outboundDedup = new OutboundDedupCache()
2377
2377
  // a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
2378
2378
  // catches the containment case the exact-text `outboundDedup` misses (a
2379
2379
  // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2380
- const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2380
+ // #4173/#4175 window bounds (env kill-switch + rationale) live in
2381
+ // reply-owner-wiring.ts so this ctor and the owner-tier candidates share them.
2382
+ const flushedTurnSupersede = new FlushedTurnSupersedeRegistry({
2383
+ openCapMs: SUPERSEDE_OPEN_CAP_MS,
2384
+ completedGraceMs: SUPERSEDE_GRACE_MS,
2385
+ })
2381
2386
  // #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
2382
2387
  // idempotency ledger. The latch (keyed on `turnId`) is the deterministic
2383
2388
  // arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
@@ -3457,12 +3462,18 @@ export type CurrentTurn = {
3457
3462
  // The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
3458
3463
  // message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
3459
3464
  // turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
3460
- // START, so the tail entry may still be RUNNING) AND within the supersede TTL —
3461
- // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3462
- // a NEWER turn at the registry tail and delete that turn's legit answer. The
3463
- // unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
3464
- // topic to deliver into and deletes nothing.
3465
+ // START, so the tail entry may still be RUNNING) AND within the
3466
+ // turn-completion window (#4173) — otherwise a late reply belonging to an
3467
+ // OLDER turn could resolve its owner to a NEWER turn at the registry tail and
3468
+ // delete that turn's legit answer. The unbounded ROUTING use
3469
+ // (`endedOnly: false`) is unaffected: it only picks a topic to deliver into
3470
+ // and deletes nothing.
3465
3471
  endedAt: number | null
3472
+ // #4173 — the turn-completion signal: ms the SESSION's real stop was
3473
+ // observed, or null while the window is OPEN (a flush-ended turn still
3474
+ // composing). Stamped in turn-end.ts / closed by stream-render's hooks;
3475
+ // full three-phase model in flushed-turn-supersede.ts.
3476
+ realEndObservedAt: number | null
3466
3477
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3467
3478
  // this turn that landed with `disable_notification: false` (a real
3468
3479
  // device ping). The conversational-pacing contract
@@ -3985,60 +3996,21 @@ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): Cu
3985
3996
  return latestTurnForChat(recentTurnsById.values(), chatId, opts)
3986
3997
  }
3987
3998
 
3988
- /**
3989
- * 2026-07 double-reply-on-DM fix (Part 1). Resolve the turn that OWNS a landing
3990
- * reply using the SAME full chain the thread-router uses, so the supersede
3991
- * resolver can never again diverge from routing (the exact bug: supersede
3992
- * omitted the quoted-message and latest-ended recoveries, so a DM late reply —
3993
- * no live turn, no `origin_turn_id` — resolved to a null owner and its flush
3994
- * message was never superseded → duplicate).
3995
- *
3996
- * Precedence (first non-null wins), delegated to the pure
3997
- * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
3998
- * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
3999
- * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
4000
- * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
4001
- * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
4002
- * Returns the CurrentTurn for the winning id (so callers can read its
4003
- * `answerDelivered` latch), or null when every lookup missed.
4004
- */
3999
+ /** 2026-07 double-reply-on-DM fix (Part 1) — thin wrapper over the extracted
4000
+ * composition (`reply-owner-wiring.ts`, which also carries the #4173
4001
+ * turn-completion-window bounds). Kept here only to bind the gateway's
4002
+ * stateful lookups. */
4005
4003
  function resolveReplyOwnerTurn(
4006
4004
  liveTurn: CurrentTurn | null,
4007
4005
  chatId: string,
4008
4006
  args: Record<string, unknown>,
4009
4007
  ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
4010
- const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4011
- const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4012
- const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
4013
- const byId = new Map<string, CurrentTurn>()
4014
- // Populate lowest-precedence first so a higher tier's turn wins the id slot
4015
- // when two lookups resolve the same turn (they carry the same turnId anyway).
4016
- for (const t of [latestEnded, quoted, origin, liveTurn]) {
4017
- if (t != null) byId.set(t.turnId, t)
4018
- }
4019
- // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
4020
- // latest-ended turn can't inherit deletion authority over a newer turn's flush
4021
- // record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
4022
- // and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
4023
- // candidate at all, and an explicit null age now fails CLOSED downstream.
4024
- const latestEndedAgeMs =
4025
- latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4026
- const candidates: ReplyOwnerCandidates = {
4027
- liveTurnId: liveTurn?.turnId ?? null,
4028
- originTurnId: origin?.turnId ?? null,
4029
- quotedTurnId: quoted?.turnId ?? null,
4030
- latestEndedTurnId: latestEnded?.turnId ?? null,
4031
- latestEndedAgeMs,
4032
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4033
- }
4034
- // #3429 — the winning tier AND the candidate set it came from travel with the
4035
- // turn. Tier alone no longer decides the content-gate bypass: the
4036
- // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
4037
- // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
4038
- // derive from these SAME candidates, so they can never disagree.
4039
- const tier = resolveReplyOwnerTier(candidates)
4040
- const winnerId = resolveReplyOwnerTurnId(candidates)
4041
- return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
4008
+ return resolveReplyOwnerTurnWith(
4009
+ { findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat, now: Date.now },
4010
+ liveTurn,
4011
+ chatId,
4012
+ args,
4013
+ )
4042
4014
  }
4043
4015
 
4044
4016
  /**
@@ -10759,12 +10731,27 @@ if (isGatewayMain) ipcServer = createIpcServer({
10759
10731
  },
10760
10732
  log: (msg) => process.stderr.write(`${msg}\n`),
10761
10733
  })
10734
+ // #4173 — a MAIN-bridge death is a turn-completion proxy: the dead session
10735
+ // can never deliver a flush-ended turn's real turn_end, so close any open
10736
+ // flush windows now (the replay grace still covers a reconnect replay).
10737
+ // Scoped to a registered non-cron agent; rationale in stream-render.ts.
10738
+ if (client.agentName != null && !isCronIdentity(client.agentName)) {
10739
+ subagentReplyAuthority.reset() // #4176 — the sub-agents died with the session
10740
+ const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
10741
+ if (closedWindows > 0) {
10742
+ process.stderr.write(
10743
+ `telegram gateway: bridge disconnect closed ${closedWindows} open flush-completion window(s) (#4173)\n`,
10744
+ )
10745
+ }
10746
+ }
10762
10747
  },
10763
10748
 
10764
10749
  async onToolCall(client: IpcClient, msg: ToolCallMessage): Promise<ToolCallResult> {
10765
10750
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
10766
10751
  try {
10767
- const result = await executeToolCall(msg.tool, msg.args)
10752
+ // #4172 the calling client's identity gates reply supersede authority
10753
+ // (see replyCallerIsForeignSession, cron-session.ts).
10754
+ const result = await executeToolCall(msg.tool, msg.args, client.agentName)
10768
10755
  return { type: 'tool_call_result', id: msg.id, success: true, result }
10769
10756
  } catch (err) {
10770
10757
  return {
@@ -11972,13 +11959,18 @@ const ALLOWED_TOOLS = new Set([
11972
11959
  'linear_agent_setup',
11973
11960
  ])
11974
11961
 
11975
- async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
11962
+ async function executeToolCall(
11963
+ tool: string,
11964
+ args: Record<string, unknown>,
11965
+ // #4172 — calling client identity; only the reply path consumes it today.
11966
+ callerAgentName: string | null = null,
11967
+ ): Promise<unknown> {
11976
11968
  if (!ALLOWED_TOOLS.has(tool)) {
11977
11969
  throw new Error(`tool not allowed: ${tool}`)
11978
11970
  }
11979
11971
  switch (tool) {
11980
11972
  case 'reply':
11981
- return executeReply(args)
11973
+ return executeReply(args, callerAgentName)
11982
11974
  case 'progress_update':
11983
11975
  return executeProgressUpdate(args)
11984
11976
  case 'react':
@@ -12490,14 +12482,22 @@ async function synthesizeVoiceOut(plan: {
12490
12482
  }
12491
12483
  }
12492
12484
 
12493
- async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
12485
+ async function executeReply(
12486
+ args: Record<string, unknown>,
12487
+ callerAgentName: string | null = null,
12488
+ ): Promise<{ content: Array<{ type: string; text: string }> }> {
12494
12489
  // #2996 P2: the ~1,400-line orchestration body moved VERBATIM to
12495
12490
  // outbound-send-path.ts `sendReply()` — the single tested send primitive.
12496
12491
  // #1664 — the turn is pinned HERE at entry (same read, same position as the
12497
12492
  // old inline `const turn = currentTurn`) and passed in `req`; the façade
12498
12493
  // also gets a live `getCurrentTurn()` accessor for the deliberate live
12499
12494
  // re-reads (Amendment 9: each call site keeps its pin-vs-live choice).
12500
- return sendReply(gatewaySendReplyDeps(), { args, turn: currentTurn })
12495
+ // #4172 a foreign-session caller gets no supersede/bypass/latch authority.
12496
+ return sendReply(gatewaySendReplyDeps(), {
12497
+ args,
12498
+ turn: currentTurn,
12499
+ callerIsForeignSession: replyCallerIsForeignSession(callerAgentName),
12500
+ })
12501
12501
  }
12502
12502
 
12503
12503
  /** Live gateway deps for the extracted send façade (#2996 P2). Built fresh
@@ -12540,6 +12540,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12540
12540
  resolveThreadId,
12541
12541
  getLatestInboundMessageId,
12542
12542
  getLastSubagentHandbackAt,
12543
+ subagentReplyAuthority,
12543
12544
  recordOutbound,
12544
12545
  emissionAuthorityFor,
12545
12546
  clearActivitySummary,