switchroom 0.19.44 → 0.19.46

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.
@@ -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()
@@ -437,10 +437,18 @@ function endCurrentTurnAtomic(
437
437
  const turnEndedAt = Date.now()
438
438
  // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
439
439
  // `latest-ended` supersede tier can be recency-bounded to the
440
- // supersede TTL (a stale latest-ended turn must not inherit deletion
440
+ // turn-completion window (a stale latest-ended turn must not inherit deletion
441
441
  // authority over a newer turn's flush record). Set once; idempotent on the
442
442
  // deferRecord flush path (which calls this synchronously before its send).
443
443
  turn.endedAt = turnEndedAt
444
+ // #4173 — default completion stamp: for every ordinary turn-end path, the
445
+ // turn ending IS the session's real stop, so the completion window is born
446
+ // closed (the latest-ended tier keeps its tight ~60 s replay grace). The ONE
447
+ // path where that is false — the answer-ready quiescence flush, which ends
448
+ // the turn synthetically while the session is still composing — re-opens the
449
+ // window right after this call (`flushCompletionTracker.open`, stream-render
450
+ // turn-flush branch) and holds it until the REAL turn_end is observed.
451
+ turn.realEndObservedAt = turnEndedAt
444
452
  process.stderr.write(
445
453
  `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
446
454
  )
@@ -41,6 +41,11 @@
41
41
  * feeds their resolved turnIds here; the precedence lives in one place.
42
42
  */
43
43
 
44
+ // The one cross-module import this pure module takes — a sibling PURE module's
45
+ // constant, so the two consumers of the turn-completion window (#4173) can
46
+ // never disagree on the default replay grace.
47
+ import { SUPERSEDE_COMPLETED_GRACE_MS } from './flushed-turn-supersede.js'
48
+
44
49
  /**
45
50
  * The four owner-turn candidate ids, in the gateway's resolution precedence.
46
51
  * Each is the `turnId` of the turn a given lookup resolved, or null when that
@@ -63,38 +68,75 @@ export interface ReplyOwnerCandidates {
63
68
  latestEndedTurnId: string | null
64
69
  /** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
65
70
  * tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
66
- * honoured ONLY when the turn ended within `latestEndedTtlMs` (the supersede
67
- * TTL). Without the bound, a late reply belonging to an OLDER turn could
68
- * resolve its owner to a NEWER turn now sitting at the registry tail and
69
- * delete THAT turn's legit answer.
71
+ * honoured ONLY within the turn-completion window (#4173 — see
72
+ * `latestEndedRealEndAgeMs`). Without a bound, a late reply belonging to an
73
+ * OLDER turn could resolve its owner to a NEWER turn now sitting at the
74
+ * registry tail and delete THAT turn's legit answer.
70
75
  *
71
76
  * Two distinct absences (#3725):
72
- * - `undefined` (property omitted) ⇒ unbounded, the pre-F2 back-compat
73
- * escape for callers that don't supply an age at all;
77
+ * - `undefined` (property omitted) ⇒ no age supplied at all — fails
78
+ * CLOSED since #4175 (the pre-F2 "unbounded" escape granted unbounded
79
+ * destructive authority exactly when the wiring was DROPPED, the
80
+ * dangerous direction);
74
81
  * - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
75
- * has no `endedAt` and has NOT ended. That cannot be TTL-bounded, so it
82
+ * has no `endedAt` and has NOT ended. That cannot be bounded, so it
76
83
  * fails CLOSED (not accepted) rather than granting unbounded authority
77
84
  * to a turn that is still running. */
78
85
  latestEndedAgeMs?: number | null
79
- /** The supersede TTL bound applied to `latestEndedAgeMs`. Undefined ⇒
80
- * unbounded. */
86
+ /** Bound applied to `latestEndedAgeMs` while the turn's completion window is
87
+ * OPEN (#4173): for a flush-ended turn whose REAL turn_end has not been
88
+ * observed yet, this is the crash-backstop cap
89
+ * (`SUPERSEDE_OPEN_WINDOW_CAP_MS`). Undefined ⇒ fails closed (#4175). */
81
90
  latestEndedTtlMs?: number
91
+ /** #4173 — the turn-completion signal, ms since the candidate turn's REAL
92
+ * turn_end was observed (`turn.realEndObservedAt`):
93
+ * - `null` ⇒ the real turn_end has NOT been observed (a flush ended this
94
+ * turn synthetically and the session is still composing) — the OPEN
95
+ * phase; acceptance falls to `latestEndedAgeMs <= latestEndedTtlMs`
96
+ * (the crash backstop), so a 20-minute compaction still resolves.
97
+ * - a number ⇒ the session stopped that long ago — the COMPLETED phase;
98
+ * accepted only within `latestEndedCompletedGraceMs` (the bounded
99
+ * tool-call-replay window). For a normally-ended turn the real end IS
100
+ * `endedAt`, so this equals `latestEndedAgeMs` and the tier keeps its
101
+ * original tight ~60 s bound.
102
+ * - `undefined` ⇒ legacy caller shape (no completion signal wired):
103
+ * falls back to the plain `age <= ttl` rule. */
104
+ latestEndedRealEndAgeMs?: number | null
105
+ /** COMPLETED-phase bound for `latestEndedRealEndAgeMs`. Undefined ⇒ the
106
+ * module default (`SUPERSEDE_COMPLETED_GRACE_MS`). */
107
+ latestEndedCompletedGraceMs?: number
82
108
  }
83
109
 
84
110
  /**
85
111
  * Whether the latest-ended candidate is fresh enough to carry supersede
86
- * (deletion) authority. An OMITTED age or TTL means unbounded (the pre-F2
87
- * back-compat escape); an EXPLICIT null age fails closed (#3725 — the caller
88
- * computed no age because its candidate turn has not ended, and an un-ended turn
89
- * must be resolved by the `live` tier, never by this destructive fallback).
112
+ * (deletion) authority — the turn-completion-window rule (#4173, three phases
113
+ * documented on `latestEndedRealEndAgeMs` above).
114
+ *
115
+ * An EXPLICIT null age fails closed (#3725 — the caller computed no age
116
+ * because its candidate turn has not ended, and an un-ended turn must be
117
+ * resolved by the `live` tier, never by this destructive fallback). An OMITTED
118
+ * age or TTL also fails closed (#4175): the old "omitted ⇒ unbounded"
119
+ * back-compat escape meant DROPPING the gateway's bound wiring silently
120
+ * granted unbounded destructive authority — failing toward silent edit-over.
121
+ * Now dropped wiring degrades to "tier never accepted" — a visible duplicate,
122
+ * the safe direction.
90
123
  */
91
124
  function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
92
125
  if (candidates.latestEndedTurnId == null) return false
93
126
  const age = candidates.latestEndedAgeMs
94
127
  const ttl = candidates.latestEndedTtlMs
95
- if (age === null) return false
96
- if (age === undefined || ttl == null) return true
97
- return age <= ttl
128
+ if (age == null || ttl == null) return false
129
+ const realEndAge = candidates.latestEndedRealEndAgeMs
130
+ if (realEndAge === undefined || realEndAge === null) {
131
+ // Legacy caller (no completion signal) or OPEN window (real turn_end not
132
+ // yet observed): the plain age-vs-cap rule. For an open window `ttl` is
133
+ // the generous crash backstop, so a long compaction still resolves.
134
+ return age <= ttl
135
+ }
136
+ // COMPLETED: the session stopped `realEndAge` ago — only the bounded
137
+ // tool-call-replay grace remains.
138
+ const grace = candidates.latestEndedCompletedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
139
+ return realEndAge <= grace
98
140
  }
99
141
 
100
142
  /**
@@ -207,6 +249,21 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
207
249
  * `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
208
250
  * `decideSupersede`'s same-turnId requirement already bars it from reaching a
209
251
  * DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
252
+ *
253
+ * ## The second ambiguity: a live sub-agent (#4176)
254
+ *
255
+ * `handbackCouldOwnReply` covers the sub-agent completion that the gateway
256
+ * SYNTHESIZED as an inbound. It does not cover a background sub-agent calling
257
+ * `reply` DIRECTLY, mid-run: that call rides the same main bridge as the parent
258
+ * loop (so the #4172 caller-identity gate cannot see it) and never traverses
259
+ * `pendingInboundBuffer.push` (so no marker is stamped). Marker-absence is only
260
+ * evidence of "the turn's own answer" if nothing else on the session could have
261
+ * emitted this reply — which is exactly what `subagentCouldOwnReply` reports,
262
+ * from gateway-observed `sub_agent_*` liveness. When it is true the content gate
263
+ * is KEPT on every non-`live` tier: a reply that IS the flushed answer still
264
+ * collapses, a sub-agent's foreign content sends fresh. See
265
+ * `gateway/subagent-reply-authority.ts` for the ordering argument and the
266
+ * failure directions.
210
267
  */
211
268
  export function decideContentGateBypass(input: {
212
269
  /** The winning owner tier (`resolveReplyOwnerTier`). */
@@ -216,18 +273,36 @@ export function decideContentGateBypass(input: {
216
273
  /** The SAME candidate set both of the above were derived from — supplies the
217
274
  * framework-derived `latestEndedTurnId` plus its freshness bound, so the
218
275
  * corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
219
- * (`latestEndedAccepted`) instead of duplicating it: within the TTL, and —
220
- * since #3725 — not a turn that is still running. */
276
+ * (`latestEndedAccepted`) instead of duplicating it: within the
277
+ * turn-completion window (#4173), and — since #3725 — not a turn that is
278
+ * still running. */
221
279
  candidates: ReplyOwnerCandidates
222
280
  /** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
223
- * in this chat AFTER the owner turn ended and within the supersede TTL — the
224
- * ambiguous window where the late reply might BE that handback rather than
225
- * the turn's own answer. Keeps the content gate on every non-`live` tier. */
281
+ * in this chat AFTER the owner turn ended and within the handback recency
282
+ * window (`HANDBACK_RECENCY_WINDOW_MS`, #4174) — the ambiguous window where
283
+ * the late reply might BE that handback rather than the turn's own answer.
284
+ * Keeps the content gate on every non-`live` tier. */
226
285
  handbackCouldOwnReply: boolean
286
+ /** #4176 — true when a sub-agent is LIVE on this session
287
+ * (`subagentReplyAuthority`, gateway/subagent-reply-authority.ts). A
288
+ * background `Task` sub-agent calls `reply` over the SAME main bridge as the
289
+ * parent loop, so the #4172 caller-identity gate cannot see it and — because
290
+ * a DIRECT tool call never traverses `pendingInboundBuffer.push` — it stamps
291
+ * no handback marker either. Marker-absence therefore does NOT prove "the
292
+ * turn's own answer" while a sub-agent is live: the reply might be the
293
+ * sub-agent's, and bypassing the content gate would silently EDIT OVER the
294
+ * flushed answer. Typed REQUIRED and evaluated FAIL-CLOSED (anything but an
295
+ * explicit `false` keeps the gate): `telegram-plugin/` is not covered by
296
+ * `tsc --noEmit`, so the type alone cannot stop a caller from dropping the
297
+ * wiring — and the #4175 precedent is that a dropped bound fails closed,
298
+ * never open. */
299
+ subagentCouldOwnReply: boolean
227
300
  }): boolean {
228
301
  if (input.tier === 'live') return true
229
302
  if (input.tier === 'none') return false
230
303
  if (input.handbackCouldOwnReply) return false
304
+ // Fail-CLOSED on an omitted flag (see the field doc): `!== false`, not truthiness.
305
+ if (input.subagentCouldOwnReply !== false) return false
231
306
  // Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
232
307
  // and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
233
308
  // this cannot fire today — but this module is exported precisely so the
@@ -260,16 +335,17 @@ export function decideContentGateBypass(input: {
260
335
  * handback pattern (#3426): the parent turn's interim ack (a substantive
261
336
  * `reply`) armed the latch, the turn ended, and the sub-agent completion
262
337
  * handback — a genuinely NEW answer arriving with no live gateway turn —
263
- * resolved the ended turn as owner (latest-ended tier, inside the 60 s
264
- * supersede TTL), saw the stale latch, and was silently dropped with a false
338
+ * resolved the ended turn as owner (latest-ended tier, inside the — then 60 s —
339
+ * supersede window), saw the stale latch, and was silently dropped with a false
265
340
  * "deduped" success. Tagging the source lets the suppression fire ONLY for the
266
341
  * flush races it exists for; byte-identical replays of a reply-delivered
267
342
  * answer remain covered by the content-keyed outbound dedup (#546).
268
343
  *
269
344
  * Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
270
- * RECORD time, while the latest-ended owner tier's 60 s is anchored at the
271
- * turn's `endedAt` — later by the reply→turn_end gap. A byte-identical replay
272
- * landing >60 s after record but ≤60 s after endedAt is evicted from dedup yet
345
+ * RECORD time, while the latest-ended owner tier's bound is the turn-completion
346
+ * window (#4173) anchored at the turn's OBSERVED real end — later by the
347
+ * reply→turn_end gap. A byte-identical replay landing >60 s after record but
348
+ * still inside the completion window is evicted from dedup yet
273
349
  * still resolves the ended turn, so it DELIVERS as a duplicate message. That
274
350
  * is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
275
351
  * duplicate" suppression the reply-armed boolean latch used to provide —
@@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'
22
22
  import { EventEmitter } from 'node:events'
23
23
 
24
24
  import {
25
+ buildRelayArgs,
25
26
  parseLoopbackRedirect,
26
27
  extractConsentUrl,
27
28
  parseConsentUrl,
@@ -492,6 +493,7 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
492
493
  email: 'user@example.com',
493
494
  replace: false,
494
495
  write: false,
496
+ calendar: false,
495
497
  orgMode: false,
496
498
  })
497
499
  })
@@ -501,6 +503,26 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
501
503
  expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
502
504
  })
503
505
 
506
+ it('parses google --calendar, and it does not imply --write', () => {
507
+ const p = parseAuthCommand('/auth google add user@example.com --replace --calendar')
508
+ expect(p).toMatchObject({
509
+ kind: 'provider-add',
510
+ replace: true,
511
+ calendar: true,
512
+ write: false,
513
+ })
514
+ })
515
+
516
+ it('parses --write and --calendar together', () => {
517
+ const p = parseAuthCommand('/auth google add user@example.com --write --calendar')
518
+ expect(p).toMatchObject({ kind: 'provider-add', write: true, calendar: true })
519
+ })
520
+
521
+ it('--calendar is google-only — microsoft rejects it as an unknown flag', () => {
522
+ const p = parseAuthCommand('/auth microsoft add user@corp.com --calendar')
523
+ expect(p).toMatchObject({ kind: 'help' })
524
+ })
525
+
504
526
  it('parses /auth microsoft add with --org-mode', () => {
505
527
  const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
506
528
  expect(p).toMatchObject({
@@ -531,3 +553,39 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
531
553
  expect(p).toMatchObject({ kind: 'help' })
532
554
  })
533
555
  })
556
+
557
+
558
+ describe('buildRelayArgs — chat flags actually reach the CLI', () => {
559
+ it('bare google add: no opt-in flags', () => {
560
+ expect(buildRelayArgs('google', 'a@b.com', {})).toEqual([
561
+ 'auth', 'google', 'account', 'add', 'a@b.com',
562
+ ])
563
+ })
564
+
565
+ it('forwards --calendar', () => {
566
+ expect(buildRelayArgs('google', 'a@b.com', { calendar: true })).toEqual([
567
+ 'auth', 'google', 'account', 'add', 'a@b.com', '--calendar',
568
+ ])
569
+ })
570
+
571
+ it('forwards --replace --write --calendar together, in order', () => {
572
+ expect(
573
+ buildRelayArgs('google', 'a@b.com', {
574
+ replace: true,
575
+ write: true,
576
+ calendar: true,
577
+ }),
578
+ ).toEqual([
579
+ 'auth', 'google', 'account', 'add', 'a@b.com',
580
+ '--replace', '--write', '--calendar',
581
+ ])
582
+ })
583
+
584
+ it('--calendar never leaks into the microsoft argv', () => {
585
+ expect(
586
+ buildRelayArgs('microsoft', 'a@corp.com', { calendar: true, orgMode: true }),
587
+ ).toEqual([
588
+ 'auth', 'microsoft', 'account', 'add', 'a@corp.com', '--org-mode',
589
+ ])
590
+ })
591
+ })